feat(mobile): add context menu for video (#2876)

* chore: clean code

* feat(mobile): add star fn in the image context menu

* feat(mobile): add video context menu with star, open, copy, and share options

* feat(mobile): integrate video context menu and enhance media item rendering

* refactor: strict types

* chore: clean code

* fix: types
This commit is contained in:
Whitewater 2025-02-25 13:27:04 +08:00 committed by GitHub
parent 8d76c35d69
commit 6cbdf4599c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 218 additions and 73 deletions

View File

@ -14,14 +14,16 @@ import type { PreviewImageProps } from "../image/PreviewImage"
import { PreviewImage } from "../image/PreviewImage"
export const MediaCarousel = ({
entryId,
media,
onPreview,
aspectRatio,
Accessory,
AccessoryProps,
}: {
entryId: string
media: MediaModel[]
onPreview: () => void
onPreview?: () => void
aspectRatio: number
} & Pick<PreviewImageProps, "Accessory" | "AccessoryProps">) => {
const [containerWidth, setContainerWidth] = useState(0)
@ -53,7 +55,7 @@ export const MediaCarousel = ({
if (m.type === "photo") {
return (
<View key={index} className="relative" style={{ width: containerWidth }}>
<ImageContextMenu imageUrl={m.url}>
<ImageContextMenu entryId={entryId} imageUrl={m.url}>
<PreviewImage
onPreview={onPreview}
imageUrl={m.url}

View File

@ -3,19 +3,29 @@ import { setImageAsync } from "expo-clipboard"
import * as FileSystem from "expo-file-system"
import { saveToLibraryAsync } from "expo-media-library"
import { shareAsync } from "expo-sharing"
import type { FC, PropsWithChildren } from "react"
import type { PropsWithChildren } from "react"
import { Image } from "react-native"
import { toast } from "@/src/lib/toast"
import { useSelectedView } from "@/src/modules/screen/atoms"
import { useIsEntryStarred } from "@/src/store/collection/hooks"
import { collectionSyncService } from "@/src/store/collection/store"
import { useEntry } from "@/src/store/entry/hooks"
import { ContextMenu } from "../context-menu"
export const ImageContextMenu: FC<
{
imageUrl?: string
} & PropsWithChildren
> = ({ imageUrl, children }) => {
if (!imageUrl) {
type ImageContextMenuProps = PropsWithChildren<{
imageUrl?: string
entryId?: string
}>
export const ImageContextMenu = ({ imageUrl, entryId, children }: ImageContextMenuProps) => {
const entry = useEntry(entryId!)
const feedId = entry?.feedId
const view = useSelectedView()
const isEntryStarred = useIsEntryStarred(entryId!)
if (!imageUrl || !entry) {
return children
}
@ -51,6 +61,32 @@ export const ImageContextMenu: FC<
<ContextMenu.Root>
<ContextMenu.Trigger>{children}</ContextMenu.Trigger>
<ContextMenu.Content>
{entryId && feedId && view !== undefined && (
<ContextMenu.Item
key="Star"
onSelect={() => {
if (isEntryStarred) {
collectionSyncService.unstarEntry(entryId)
toast.info("Unstarred")
} else {
collectionSyncService.starEntry({
feedId,
entryId,
view,
})
toast.info("Starred")
}
}}
>
<ContextMenu.ItemIcon
ios={{
name: isEntryStarred ? "star.slash" : "star",
}}
/>
<ContextMenu.ItemTitle>{isEntryStarred ? "Unstar" : "Star"}</ContextMenu.ItemTitle>
</ContextMenu.Item>
)}
<ContextMenu.Item
key="Copy"
onSelect={async () => {

View File

@ -3,12 +3,12 @@ import { router } from "expo-router"
import type { PropsWithChildren } from "react"
import { useCallback } from "react"
import { Share, Text, View } from "react-native"
import * as ContextMenu from "zeego/context-menu"
import {
EntryContentWebView,
setWebViewEntry,
} from "@/src/components/native/webview/EntryContentWebView"
import { ContextMenu } from "@/src/components/ui/context-menu"
import { openLink } from "@/src/lib/native"
import { toast } from "@/src/lib/toast"
import { useSelectedView } from "@/src/modules/screen/atoms"
@ -19,6 +19,7 @@ import { unreadSyncService } from "@/src/store/unread/store"
export const EntryItemContextMenu = ({ id, children }: PropsWithChildren<{ id: string }>) => {
const entry = useEntry(id)
const feedId = entry?.feedId
const view = useSelectedView()
const isEntryStarred = useIsEntryStarred(id)
@ -62,37 +63,31 @@ export const EntryItemContextMenu = ({ id, children }: PropsWithChildren<{ id: s
/>
</ContextMenu.Item>
<ContextMenu.Item
key="Star"
onSelect={() => {
if (isEntryStarred) {
collectionSyncService.unstarEntry(id)
toast.info("Unstarred")
} else {
if (!entry.feedId) {
toast.error("Feed not found")
return
{feedId && view !== undefined && (
<ContextMenu.Item
key="Star"
onSelect={() => {
if (isEntryStarred) {
collectionSyncService.unstarEntry(id)
toast.info("Unstarred")
} else {
collectionSyncService.starEntry({
feedId,
entryId: id,
view,
})
toast.info("Starred")
}
if (!view) {
toast.error("View not found")
return
}
collectionSyncService.starEntry({
feedId: entry.feedId,
entryId: id,
view,
})
toast.info("Starred")
}
}}
>
<ContextMenu.ItemIcon
ios={{
name: isEntryStarred ? "star.slash" : "star",
}}
/>
<ContextMenu.ItemTitle>{isEntryStarred ? "Unstar" : "Star"}</ContextMenu.ItemTitle>
</ContextMenu.Item>
>
<ContextMenu.ItemIcon
ios={{
name: isEntryStarred ? "star.slash" : "star",
}}
/>
<ContextMenu.ItemTitle>{isEntryStarred ? "Unstar" : "Star"}</ContextMenu.ItemTitle>
</ContextMenu.Item>
)}
{entry.url && (
<ContextMenu.Item

View File

@ -2,8 +2,8 @@ import type { FeedViewType } from "@follow/constants"
import type { FC, PropsWithChildren } from "react"
import { useCallback } from "react"
import { Alert, Clipboard } from "react-native"
import * as ContextMenu from "zeego/context-menu"
import { ContextMenu } from "@/src/components/ui/context-menu"
import { views } from "@/src/constants/views"
import { toast } from "@/src/lib/toast"
import { getFeed } from "@/src/store/feed/getter"

View File

@ -1,8 +1,8 @@
import type { FC, PropsWithChildren } from "react"
import { useMemo } from "react"
import { Alert, Clipboard } from "react-native"
import * as ContextMenu from "zeego/context-menu"
import { ContextMenu } from "@/src/components/ui/context-menu"
import { getWebUrl } from "@/src/lib/env"
import { toast } from "@/src/lib/toast"
import { getList } from "@/src/store/list/getters"

View File

@ -0,0 +1,109 @@
import type { PropsWithChildren } from "react"
import { Clipboard, Share } from "react-native"
import { ContextMenu } from "@/src/components/ui/context-menu"
import { openLink } from "@/src/lib/native"
import { toast } from "@/src/lib/toast"
import { useSelectedView } from "@/src/modules/screen/atoms"
import { useIsEntryStarred } from "@/src/store/collection/hooks"
import { collectionSyncService } from "@/src/store/collection/store"
import { useEntry } from "@/src/store/entry/hooks"
type VideoContextMenuProps = PropsWithChildren<{
entryId: string
}>
export const VideoContextMenu = ({ entryId, children }: VideoContextMenuProps) => {
const entry = useEntry(entryId)
const feedId = entry?.feedId
const view = useSelectedView()
const isEntryStarred = useIsEntryStarred(entryId)
if (!entry) {
return children
}
return (
<ContextMenu.Root>
<ContextMenu.Trigger>{children}</ContextMenu.Trigger>
<ContextMenu.Content>
{feedId && view !== undefined && (
<ContextMenu.Item
key="Star"
onSelect={() => {
if (isEntryStarred) {
collectionSyncService.unstarEntry(entryId)
toast.info("Unstarred")
} else {
collectionSyncService.starEntry({
feedId,
entryId,
view,
})
toast.info("Starred")
}
}}
>
<ContextMenu.ItemIcon
ios={{
name: isEntryStarred ? "star.slash" : "star",
}}
/>
<ContextMenu.ItemTitle>{isEntryStarred ? "Unstar" : "Star"}</ContextMenu.ItemTitle>
</ContextMenu.Item>
)}
<ContextMenu.Item
key="Open Link"
onSelect={() => {
if (!entry.url) return
openLink(entry.url)
}}
>
<ContextMenu.ItemIcon
ios={{
name: "link",
}}
/>
<ContextMenu.ItemTitle>Open Link</ContextMenu.ItemTitle>
</ContextMenu.Item>
<ContextMenu.Item
key="Copy Link"
onSelect={async () => {
if (!entry.url) return
Clipboard.setString(entry.url)
toast.success("Link copied to clipboard")
}}
>
<ContextMenu.ItemIcon
ios={{
name: "document.on.document",
}}
/>
<ContextMenu.ItemTitle>Copy Link</ContextMenu.ItemTitle>
</ContextMenu.Item>
<ContextMenu.Item
key="Share"
onSelect={async () => {
if (!entry.url) return
await Share.share({
message: [entry.title, entry.url].filter(Boolean).join("\n"),
url: entry.url,
title: entry.title || "Shared Video",
})
return
}}
>
<ContextMenu.ItemIcon
ios={{
name: "square.and.arrow.up",
}}
/>
<ContextMenu.ItemTitle>Share</ContextMenu.ItemTitle>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Root>
)
}

View File

@ -1,5 +1,7 @@
import { FeedViewType } from "@follow/constants"
import { transformVideoUrl } from "@follow/utils"
import { uniqBy } from "es-toolkit/compat"
import { Image } from "expo-image"
import { LinearGradient } from "expo-linear-gradient"
import { useEffect, useMemo } from "react"
import { ScrollView, Text, View } from "react-native"
@ -7,20 +9,17 @@ import Animated, { useSharedValue, withTiming } from "react-native-reanimated"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { useUISettingKey } from "@/src/atoms/settings/ui"
import {
EntryContentWebView,
setWebViewEntry,
} from "@/src/components/native/webview/EntryContentWebView"
import { EntryContentWebView } from "@/src/components/native/webview/EntryContentWebView"
import { MediaCarousel } from "@/src/components/ui/carousel/MediaCarousel"
import { RelativeDateTime } from "@/src/components/ui/datetime/RelativeDateTime"
import { FeedIcon } from "@/src/components/ui/icon/feed-icon"
import { PreviewImage } from "@/src/components/ui/image/PreviewImage"
import { ItemPressable } from "@/src/components/ui/pressable/ItemPressable"
import type { MediaModel } from "@/src/database/schemas/types"
import { openLink } from "@/src/lib/native"
import { useEntry } from "@/src/store/entry/hooks"
import { useFeed } from "@/src/store/feed/hooks"
import { VideoContextMenu } from "../../context-menu/video"
import { useEntryListContextView } from "../EntryListContext"
export function EntryGridItem({ id }: { id: string }) {
@ -45,32 +44,27 @@ export function EntryGridItem({ id }: { id: string }) {
)
}
const WrapperComponent = view === FeedViewType.Videos ? ItemPressable : View
if (view === FeedViewType.Pictures) {
return (
<View className="m-1 overflow-hidden rounded-md">
<MediaItems media={item.media} title={item.title || ""} view={view} entryId={id} />
</View>
)
}
return (
<WrapperComponent
className="m-1 overflow-hidden rounded-md"
onPress={() => {
if (!item.url) {
return
}
if (view === FeedViewType.Videos) {
openLink(item.url)
}
}}
>
<MediaItems
media={item.media}
title={item.title || ""}
view={view!}
entryId={id}
onPreview={() => {
if (item) {
setWebViewEntry(item)
}
<VideoContextMenu entryId={id}>
<ItemPressable
className="m-1 overflow-hidden rounded-md"
onPress={() => {
if (!item.url) return
const formattedUrl = transformVideoUrl({ url: item.url }) || item.url
openLink(formattedUrl)
}}
/>
</WrapperComponent>
>
<MediaItems media={item.media} title={item.title || ""} view={view} entryId={id} />
</ItemPressable>
</VideoContextMenu>
)
}
@ -84,7 +78,7 @@ const MediaItems = ({
media: MediaModel[]
view: FeedViewType
entryId: string
onPreview: () => void
onPreview?: () => void
title: string
}) => {
const firstMedia = media[0]
@ -108,7 +102,15 @@ const MediaItems = ({
<View>
<View className="flex-1" style={{ aspectRatio }}>
{mediaUrl && (
<PreviewImage imageUrl={mediaUrl} aspectRatio={aspectRatio} onPreview={onPreview} />
<Image
transition={500}
source={{ uri: mediaUrl }}
placeholder={{ blurhas: firstMedia.blurhash }}
className="w-full"
style={{ aspectRatio }}
placeholderContentFit="cover"
recyclingKey={mediaUrl}
/>
)}
</View>
<Text className="text-label p-2 font-medium" numberOfLines={2}>
@ -120,6 +122,7 @@ const MediaItems = ({
return (
<MediaCarousel
entryId={entryId}
media={uniqMedia}
onPreview={onPreview}
aspectRatio={aspectRatio}

View File

@ -13,7 +13,7 @@ export const transformVideoUrl = ({
mime_type?: string
}[]
| null
}) => {
}): string | null => {
if (url?.match(/\/\/www.bilibili.com\/video\/BV\w+/)) {
const player = isIframe
? "https://player.bilibili.com/player.html"
@ -39,7 +39,7 @@ export const transformVideoUrl = ({
}
if (attachments) {
return attachments.find((attachment) => attachment.mime_type === "text/html")?.url
return attachments.find((attachment) => attachment.mime_type === "text/html")?.url ?? null
}
return null
}