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:
parent
8d76c35d69
commit
6cbdf4599c
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue