From 27818f6559bfb5b95bf529e38814eb6b83ad0d29 Mon Sep 17 00:00:00 2001 From: Innei Date: Wed, 19 Feb 2025 19:25:42 +0800 Subject: [PATCH] feat(mobile): Enhance grid view with dynamic media rendering and improved item handling - Refactor EntryListContentGrid to dynamically render media items from entry store - Update EntryGridItem to support image and video media types - Modify viewable items hook to support custom ID extraction - Add support for more flexible grid item rendering with type-specific content Signed-off-by: Innei --- .../entry-list/EntryListContentGrid.tsx | 63 +++++++++++- apps/mobile/src/modules/entry-list/hooks.ts | 18 ++-- .../entry-list/templates/EntryGridItem.tsx | 95 +++++++++++++------ .../screens/(stack)/feeds/[feedId]/index.tsx | 5 +- 4 files changed, 137 insertions(+), 44 deletions(-) diff --git a/apps/mobile/src/modules/entry-list/EntryListContentGrid.tsx b/apps/mobile/src/modules/entry-list/EntryListContentGrid.tsx index 6e1c52e63..900a9eac0 100644 --- a/apps/mobile/src/modules/entry-list/EntryListContentGrid.tsx +++ b/apps/mobile/src/modules/entry-list/EntryListContentGrid.tsx @@ -1,11 +1,14 @@ import { useTypeScriptHappyCallback } from "@follow/hooks" import type { MasonryFlashListProps } from "@shopify/flash-list" +import { useCallback } from "react" import { ActivityIndicator, View } from "react-native" import { useFetchEntriesControls } from "@/src/modules/screen/atoms" +import { useEntryStore } from "@/src/store/entry/store" import { TimelineSelectorMasonryList } from "../screen/TimelineSelectorList" import { useOnViewableItemsChanged } from "./hooks" +import type { MasonryItem } from "./templates/EntryGridItem" import { EntryGridItem } from "./templates/EntryGridItem" export function EntryListContentGrid({ @@ -15,16 +18,61 @@ export function EntryListContentGrid({ entryIds: string[] } & Omit, "data" | "renderItem">) { const { fetchNextPage, refetch, isRefetching, hasNextPage } = useFetchEntriesControls() - const onViewableItemsChanged = useOnViewableItemsChanged() + const onViewableItemsChanged = useOnViewableItemsChanged( + (item) => (item.key as any).split("-")[0], + ) + + const data = useEntryStore( + useCallback( + (state) => { + const data: (MasonryItem & { index: number })[] = [] + + let index = 0 + for (const id of entryIds) { + const entry = state.data[id] + if (!entry) { + continue + } + if (!entry.media) { + continue + } + + for (const media of entry.media) { + if (media.type === "photo") { + data.push({ + id, + index: index++, + type: "image", + imageUrl: media.url, + blurhash: media.blurhash, + width: media.width, + height: media.height, + }) + } else if (media.type === "video") { + data.push({ + id, + index: index++, + type: "video", + videoUrl: media.url, + videoPreviewImageUrl: media.preview_image_url, + }) + } + } + } + return data + }, + [entryIds], + ), + ) return ( { - return + data={data} + renderItem={useTypeScriptHappyCallback(({ item }: { item: MasonryItem }) => { + return }, [])} - keyExtractor={(id) => id} + keyExtractor={defaultKeyExtractor} onViewableItemsChanged={onViewableItemsChanged} onEndReached={() => { fetchNextPage() @@ -43,3 +91,8 @@ export function EntryListContentGrid({ /> ) } + +const defaultKeyExtractor = (item: MasonryItem & { index: number }) => { + const key = `${item.id}-${item.index}` + return key +} diff --git a/apps/mobile/src/modules/entry-list/hooks.ts b/apps/mobile/src/modules/entry-list/hooks.ts index f213ba589..9804cedf3 100644 --- a/apps/mobile/src/modules/entry-list/hooks.ts +++ b/apps/mobile/src/modules/entry-list/hooks.ts @@ -1,27 +1,29 @@ import type ViewToken from "@shopify/flash-list/dist/viewability/ViewToken" -import { useCallback } from "react" +import { useCallback, useState } from "react" import { useGeneralSettingKey } from "@/src/atoms/settings/general" import { debouncedFetchEntryContentByStream } from "@/src/store/entry/store" import { unreadSyncService } from "@/src/store/unread/store" -export function useOnViewableItemsChanged(): (info: { - viewableItems: ViewToken[] - changed: ViewToken[] -}) => void { +const defaultIdExtractor = (item: ViewToken) => item.key +export function useOnViewableItemsChanged( + idExtractor: (item: ViewToken) => string = defaultIdExtractor, +): (info: { viewableItems: ViewToken[]; changed: ViewToken[] }) => void { const markAsReadWhenScrolling = useGeneralSettingKey("scrollMarkUnread") + const [stableIdExtractor] = useState(() => idExtractor) + return useCallback( ({ viewableItems, changed }) => { - debouncedFetchEntryContentByStream(viewableItems.map((item) => item.key)) + debouncedFetchEntryContentByStream(viewableItems.map((item) => stableIdExtractor(item))) if (markAsReadWhenScrolling) { changed .filter((item) => !item.isViewable) .forEach((item) => { - unreadSyncService.markEntryAsRead(item.key) + unreadSyncService.markEntryAsRead(stableIdExtractor(item)) }) } }, - [markAsReadWhenScrolling], + [markAsReadWhenScrolling, stableIdExtractor], ) } diff --git a/apps/mobile/src/modules/entry-list/templates/EntryGridItem.tsx b/apps/mobile/src/modules/entry-list/templates/EntryGridItem.tsx index 62f98bfea..563ad08d3 100644 --- a/apps/mobile/src/modules/entry-list/templates/EntryGridItem.tsx +++ b/apps/mobile/src/modules/entry-list/templates/EntryGridItem.tsx @@ -1,4 +1,5 @@ import { FeedViewType } from "@follow/constants" +import { useMemo } from "react" import { Text, View } from "react-native" import { useUISettingKey } from "@/src/atoms/settings/ui" @@ -9,44 +10,78 @@ import { useEntry } from "@/src/store/entry/hooks" import { useSelectedView } from "../../screen/atoms" -export function EntryGridItem({ id }: { id: string }) { +export type MasonryItem = { + id: string +} & ( + | { + type: "image" + imageUrl: string + blurhash?: string + height?: number + width?: number + } + | { + type: "video" + videoUrl: string + videoPreviewImageUrl?: string + } +) +export function EntryGridItem(props: MasonryItem) { + const { type, id } = props const view = useSelectedView() const item = useEntry(id) const pictureViewFilterNoImage = useUISettingKey("pictureViewFilterNoImage") + + const Content = useMemo(() => { + switch (type) { + case "image": { + const { imageUrl, blurhash, height, width } = props + const aspectRatio = height && width ? width / height : 16 / 9 + + return imageUrl ? ( + + + + ) : ( + + No media available + + ) + } + case "video": { + const { videoPreviewImageUrl } = props + return ( + <> + {videoPreviewImageUrl ? ( + + + + ) : ( + + No media available + + )} + + {item?.title} + + + ) + } + } + }, [type, JSON.stringify(props), item?.title]) if (!item) { return null } - const photo = item.media?.find((media) => media.type === "photo") - const video = item.media?.find((media) => media.type === "video") - const imageUrl = photo?.url || video?.preview_image_url - if (pictureViewFilterNoImage && !imageUrl && view === FeedViewType.Pictures) { + + if ( + pictureViewFilterNoImage && + type === "image" && + !props.imageUrl && + view === FeedViewType.Pictures + ) { return null } - const blurhash = photo?.blurhash || video?.blurhash - const aspectRatio = - view === FeedViewType.Pictures && photo?.height && photo.width - ? photo.width / photo.height - : 16 / 9 - - return ( - - {imageUrl ? ( - - - - ) : ( - - No media available - - )} - - {view === FeedViewType.Videos && ( - - {item.title} - - )} - - ) + return {Content} } diff --git a/apps/mobile/src/screens/(stack)/feeds/[feedId]/index.tsx b/apps/mobile/src/screens/(stack)/feeds/[feedId]/index.tsx index e4cd35c88..36ee35b32 100644 --- a/apps/mobile/src/screens/(stack)/feeds/[feedId]/index.tsx +++ b/apps/mobile/src/screens/(stack)/feeds/[feedId]/index.tsx @@ -6,6 +6,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context" import { EntryListSelector } from "@/src/modules/entry-list/EntryListSelector" import { EntryListContext, useSelectedView } from "@/src/modules/screen/atoms" +import { TimelineSelectorHeader } from "@/src/modules/screen/TimelineSelectorHeader" import { useCollectionEntryList } from "@/src/store/collection/hooks" import { useEntryIdsByCategory, useEntryIdsByFeedId } from "@/src/store/entry/hooks" import { FEED_COLLECTION_LIST } from "@/src/store/entry/utils" @@ -28,7 +29,9 @@ export default function Feed() { return ( ({ type: "feed" }), [])}> - + + + )