refactor(mobile): enhance video entry handling in mobile app (#2903)
* fix(mobile): improve description height for video entries in EntryGridFooter * fix(mobile): set fixed height for MediaCarousel to prevent resizing issues * feat(mobile): add EntryListContentVideo and EntryVideoItem components for video entries * refactor(mobile): simplify EntryGridItem component * feat(mobile): integrate EntryListContentVideo for video feed view * refactor(mobile): rename grid to picture for entry list selector * feat(mobile): add skeleton loading component for video entries
This commit is contained in:
parent
63c304c02e
commit
97dda3ed7d
|
|
@ -30,18 +30,21 @@ export const MediaCarousel = ({
|
|||
noPreview?: boolean
|
||||
} & Pick<PreviewImageProps, "Accessory" | "AccessoryProps">) => {
|
||||
const [containerWidth, setContainerWidth] = useState(0)
|
||||
const containerHeight = Math.floor(containerWidth / aspectRatio)
|
||||
const hasMany = media.length > 1
|
||||
|
||||
// const activeIndex = useSharedValue(0)
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
|
||||
const Wrapper = noPreview ? View : ImageContextMenu
|
||||
|
||||
return (
|
||||
<View
|
||||
onLayout={(e) => {
|
||||
setContainerWidth(e.nativeEvent.layout.width)
|
||||
}}
|
||||
>
|
||||
<View className="relative">
|
||||
<View className="relative overflow-hidden rounded-md">
|
||||
<ScrollView
|
||||
onScroll={(e) => {
|
||||
setActiveIndex(Math.round(e.nativeEvent.contentOffset.x / containerWidth))
|
||||
|
|
@ -52,26 +55,27 @@ export const MediaCarousel = ({
|
|||
showsHorizontalScrollIndicator={false}
|
||||
pagingEnabled
|
||||
className="flex-1"
|
||||
contentContainerClassName="flex-row overflow-hidden rounded-md"
|
||||
style={{ aspectRatio }}
|
||||
// We need to fixed the height of the container to prevent the carousel from resizing
|
||||
// See https://github.com/Shopify/flash-list/issues/797
|
||||
style={{ height: containerHeight }}
|
||||
>
|
||||
{media.map((m, index) => {
|
||||
if (m.type === "photo") {
|
||||
return (
|
||||
<View key={index} className="relative" style={{ width: containerWidth }}>
|
||||
<ImageContextMenu entryId={entryId} imageUrl={m.url}>
|
||||
<Wrapper entryId={entryId} imageUrl={m.url}>
|
||||
<PreviewImage
|
||||
noPreview={noPreview}
|
||||
onPreview={onPreview}
|
||||
imageUrl={m.url}
|
||||
aspectRatio={m.width && m.height ? m.width / m.height : 1}
|
||||
aspectRatio={aspectRatio}
|
||||
Accessory={Accessory}
|
||||
AccessoryProps={AccessoryProps}
|
||||
proxy={{
|
||||
width: 200,
|
||||
width: containerWidth,
|
||||
}}
|
||||
/>
|
||||
</ImageContextMenu>
|
||||
</Wrapper>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
|
@ -84,7 +88,7 @@ export const MediaCarousel = ({
|
|||
// open player
|
||||
}}
|
||||
imageUrl={m.url}
|
||||
aspectRatio={m.width && m.height ? m.width / m.height : 1}
|
||||
aspectRatio={aspectRatio}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,11 @@ export const EntryGridFooter = ({
|
|||
{entry.description && (
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
className={cn("text-label text-base font-medium", descriptionClassName)}
|
||||
className={cn(
|
||||
"text-label shrink text-base font-medium",
|
||||
view === FeedViewType.Videos && "min-h-12",
|
||||
descriptionClassName,
|
||||
)}
|
||||
>
|
||||
{entry.description}
|
||||
</Text>
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@ import { useFetchEntriesControls } from "@/src/modules/screen/atoms"
|
|||
import { TimelineSelectorMasonryList } from "../screen/TimelineSelectorList"
|
||||
import { useOnViewableItemsChanged } from "./hooks"
|
||||
// import type { MasonryItem } from "./templates/EntryGridItem"
|
||||
import { EntryGridItem } from "./templates/EntryGridItem"
|
||||
import { EntryPictureItem } from "./templates/EntryPictureItem"
|
||||
|
||||
export const EntryListContentGrid = forwardRef<
|
||||
export const EntryListContentPicture = forwardRef<
|
||||
ElementRef<typeof TimelineSelectorMasonryList>,
|
||||
{
|
||||
entryIds: string[]
|
||||
|
|
@ -28,7 +28,7 @@ export const EntryListContentGrid = forwardRef<
|
|||
isRefetching={isRefetching}
|
||||
data={entryIds}
|
||||
renderItem={useTypeScriptHappyCallback(({ item }: { item: string }) => {
|
||||
return <EntryGridItem id={item} />
|
||||
return <EntryPictureItem id={item} />
|
||||
}, [])}
|
||||
keyExtractor={defaultKeyExtractor}
|
||||
onViewableItemsChanged={onViewableItemsChanged}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import { useTypeScriptHappyCallback } from "@follow/hooks"
|
||||
import type { MasonryFlashListProps } from "@shopify/flash-list"
|
||||
import type { ElementRef } from "react"
|
||||
import { forwardRef, useMemo } from "react"
|
||||
import { View } from "react-native"
|
||||
|
||||
import { useFetchEntriesControls } from "@/src/modules/screen/atoms"
|
||||
|
||||
import { TimelineSelectorMasonryList } from "../screen/TimelineSelectorList"
|
||||
import { useOnViewableItemsChanged } from "./hooks"
|
||||
import { EntryVideoItem } from "./templates/EntryVideoItem"
|
||||
|
||||
export const EntryListContentVideo = forwardRef<
|
||||
ElementRef<typeof TimelineSelectorMasonryList>,
|
||||
{
|
||||
entryIds: string[]
|
||||
} & Omit<MasonryFlashListProps<string>, "data" | "renderItem">
|
||||
>(({ entryIds, ...rest }, ref) => {
|
||||
const { fetchNextPage, refetch, isRefetching, isFetching } = useFetchEntriesControls()
|
||||
const { onViewableItemsChanged, onScroll } = useOnViewableItemsChanged({
|
||||
disabled: isRefetching,
|
||||
})
|
||||
|
||||
const ListFooterComponent = useMemo(
|
||||
() =>
|
||||
isFetching ? (
|
||||
<View className="flex flex-row justify-between">
|
||||
<EntryItemSkeleton />
|
||||
<EntryItemSkeleton />
|
||||
</View>
|
||||
) : null,
|
||||
[isFetching],
|
||||
)
|
||||
|
||||
return (
|
||||
<TimelineSelectorMasonryList
|
||||
ref={ref}
|
||||
isRefetching={isRefetching}
|
||||
data={entryIds}
|
||||
renderItem={useTypeScriptHappyCallback(({ item }: { item: string }) => {
|
||||
return <EntryVideoItem id={item} />
|
||||
}, [])}
|
||||
keyExtractor={defaultKeyExtractor}
|
||||
onViewableItemsChanged={onViewableItemsChanged}
|
||||
onScroll={onScroll}
|
||||
onEndReached={fetchNextPage}
|
||||
numColumns={2}
|
||||
estimatedItemSize={100}
|
||||
ListFooterComponent={ListFooterComponent}
|
||||
{...rest}
|
||||
onRefresh={refetch}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
const defaultKeyExtractor = (item: string) => {
|
||||
return item
|
||||
}
|
||||
|
||||
export function EntryItemSkeleton() {
|
||||
return (
|
||||
<View className="m-1 overflow-hidden rounded-md">
|
||||
{/* Video thumbnail */}
|
||||
<View className="bg-system-fill aspect-video h-32 w-full animate-pulse rounded-md" />
|
||||
|
||||
{/* Description and footer */}
|
||||
<View className="my-2 px-2">
|
||||
{/* Description */}
|
||||
<View className="bg-system-fill mb-1 h-4 w-full animate-pulse rounded-md" />
|
||||
<View className="bg-system-fill mb-3 h-4 w-3/4 animate-pulse rounded-md" />
|
||||
|
||||
{/* Footer with feed icon and metadata */}
|
||||
<View className="flex-row items-center gap-1">
|
||||
<View className="bg-system-fill size-4 animate-pulse rounded-full" />
|
||||
<View className="bg-system-fill h-3 w-24 animate-pulse rounded-md" />
|
||||
<View className="bg-system-fill h-3 w-20 animate-pulse rounded-md" />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
|
@ -5,10 +5,11 @@ import { useContext, useEffect, useRef } from "react"
|
|||
import type { ScrollView } from "react-native"
|
||||
|
||||
import { SetAttachNavigationScrollViewContext } from "@/src/components/layouts/tabbar/contexts/AttachNavigationScrollViewContext"
|
||||
import { EntryListContentGrid } from "@/src/modules/entry-list/EntryListContentGrid"
|
||||
import { EntryListContentPicture } from "@/src/modules/entry-list/EntryListContentPicture"
|
||||
|
||||
import { EntryListContentArticle } from "./EntryListContentArticle"
|
||||
import { EntryListContentSocial } from "./EntryListContentSocial"
|
||||
import { EntryListContentVideo } from "./EntryListContentVideo"
|
||||
import { EntryListContextViewContext } from "./EntryListContext"
|
||||
|
||||
export function EntryListSelector({
|
||||
|
|
@ -30,16 +31,19 @@ export function EntryListSelector({
|
|||
}
|
||||
}, [setAttachNavigationScrollViewRef, ref, active])
|
||||
|
||||
let ContentComponent: typeof EntryListContentSocial | typeof EntryListContentGrid =
|
||||
let ContentComponent: typeof EntryListContentSocial | typeof EntryListContentPicture =
|
||||
EntryListContentArticle
|
||||
switch (viewId) {
|
||||
case FeedViewType.SocialMedia: {
|
||||
ContentComponent = EntryListContentSocial
|
||||
break
|
||||
}
|
||||
case FeedViewType.Pictures:
|
||||
case FeedViewType.Pictures: {
|
||||
ContentComponent = EntryListContentPicture
|
||||
break
|
||||
}
|
||||
case FeedViewType.Videos: {
|
||||
ContentComponent = EntryListContentGrid
|
||||
ContentComponent = EntryListContentVideo
|
||||
break
|
||||
}
|
||||
case FeedViewType.Articles: {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import { FeedViewType } from "@follow/constants"
|
||||
import { transformVideoUrl } from "@follow/utils"
|
||||
import { uniqBy } from "es-toolkit/compat"
|
||||
import { LinearGradient } from "expo-linear-gradient"
|
||||
import { useEffect, useMemo } from "react"
|
||||
|
|
@ -11,19 +9,13 @@ import { EntryContentWebView } from "@/src/components/native/webview/EntryConten
|
|||
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 { 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 { unreadSyncService } from "@/src/store/unread/store"
|
||||
|
||||
import { VideoContextMenu } from "../../context-menu/video"
|
||||
import { useEntryListContextView } from "../EntryListContext"
|
||||
|
||||
export function EntryGridItem({ id }: { id: string }) {
|
||||
export function EntryPictureItem({ id }: { id: string }) {
|
||||
const item = useEntry(id)
|
||||
const view = useEntryListContextView()
|
||||
|
||||
if (!item || !item.media) {
|
||||
return null
|
||||
|
|
@ -39,32 +31,14 @@ export function EntryGridItem({ id }: { id: string }) {
|
|||
)
|
||||
}
|
||||
|
||||
if (view === FeedViewType.Pictures) {
|
||||
return (
|
||||
<View className="m-1">
|
||||
<MediaItems
|
||||
media={item.media}
|
||||
entryId={id}
|
||||
onPreview={() => unreadSyncService.markEntryAsRead(id)}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<VideoContextMenu entryId={id}>
|
||||
<ItemPressable
|
||||
className="m-1 overflow-hidden rounded-md"
|
||||
onPress={() => {
|
||||
unreadSyncService.markEntryAsRead(id)
|
||||
if (!item.url) return
|
||||
const formattedUrl = transformVideoUrl({ url: item.url }) || item.url
|
||||
openLink(formattedUrl)
|
||||
}}
|
||||
>
|
||||
<MediaItems media={item.media} entryId={id} noPreview aspectRatio={16 / 9} />
|
||||
</ItemPressable>
|
||||
</VideoContextMenu>
|
||||
<View className="m-1">
|
||||
<MediaItems
|
||||
media={item.media}
|
||||
entryId={id}
|
||||
onPreview={() => unreadSyncService.markEntryAsRead(id)}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import { transformVideoUrl } from "@follow/utils"
|
||||
|
||||
import { MediaCarousel } from "@/src/components/ui/carousel/MediaCarousel"
|
||||
import { ItemPressable } from "@/src/components/ui/pressable/ItemPressable"
|
||||
import { openLink } from "@/src/lib/native"
|
||||
import { useEntry } from "@/src/store/entry/hooks"
|
||||
import { unreadSyncService } from "@/src/store/unread/store"
|
||||
|
||||
import { VideoContextMenu } from "../../context-menu/video"
|
||||
|
||||
export function EntryVideoItem({ id }: { id: string }) {
|
||||
const item = useEntry(id)
|
||||
|
||||
if (!item || !item.media) {
|
||||
return null
|
||||
}
|
||||
|
||||
const firstMedia = item.media[0]!
|
||||
|
||||
return (
|
||||
<VideoContextMenu entryId={id}>
|
||||
<ItemPressable
|
||||
className="m-1 overflow-hidden rounded-md"
|
||||
onPress={() => {
|
||||
unreadSyncService.markEntryAsRead(id)
|
||||
if (!item.url) return
|
||||
const formattedUrl = transformVideoUrl({ url: item.url }) || item.url
|
||||
openLink(formattedUrl)
|
||||
}}
|
||||
>
|
||||
<MediaCarousel
|
||||
entryId={id}
|
||||
media={[firstMedia]}
|
||||
aspectRatio={16 / 9}
|
||||
AccessoryProps={{ id }}
|
||||
noPreview={true}
|
||||
/>
|
||||
</ItemPressable>
|
||||
</VideoContextMenu>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue