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 <tukon479@gmail.com>
This commit is contained in:
Innei 2025-02-19 19:25:42 +08:00
parent 7ccea72707
commit 27818f6559
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
4 changed files with 137 additions and 44 deletions

View File

@ -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<MasonryFlashListProps<string>, "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 (
<TimelineSelectorMasonryList
isRefetching={isRefetching}
data={entryIds}
renderItem={useTypeScriptHappyCallback(({ item }) => {
return <EntryGridItem id={item} />
data={data}
renderItem={useTypeScriptHappyCallback(({ item }: { item: MasonryItem }) => {
return <EntryGridItem {...item} />
}, [])}
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
}

View File

@ -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],
)
}

View File

@ -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 ? (
<ImageContextMenu imageUrl={imageUrl}>
<PreviewImage imageUrl={imageUrl} blurhash={blurhash} aspectRatio={aspectRatio} />
</ImageContextMenu>
) : (
<View className="aspect-video w-full items-center justify-center">
<Text className="text-label text-center">No media available</Text>
</View>
)
}
case "video": {
const { videoPreviewImageUrl } = props
return (
<>
{videoPreviewImageUrl ? (
<ImageContextMenu imageUrl={videoPreviewImageUrl}>
<PreviewImage imageUrl={videoPreviewImageUrl} aspectRatio={16 / 9} />
</ImageContextMenu>
) : (
<View className="aspect-video w-full items-center justify-center">
<Text className="text-label text-center">No media available</Text>
</View>
)}
<Text className="text-label p-2 font-medium" numberOfLines={2}>
{item?.title}
</Text>
</>
)
}
}
}, [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 (
<ItemPressable className="m-1 overflow-hidden rounded-md">
{imageUrl ? (
<ImageContextMenu imageUrl={imageUrl}>
<PreviewImage imageUrl={imageUrl} blurhash={blurhash} aspectRatio={aspectRatio} />
</ImageContextMenu>
) : (
<View className="aspect-video w-full items-center justify-center">
<Text className="text-label text-center">No media available</Text>
</View>
)}
{view === FeedViewType.Videos && (
<Text className="text-label p-2 font-medium" numberOfLines={2}>
{item.title}
</Text>
)}
</ItemPressable>
)
return <ItemPressable className="m-1 overflow-hidden rounded-md">{Content}</ItemPressable>
}

View File

@ -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 (
<EntryListContext.Provider value={useMemo(() => ({ type: "feed" }), [])}>
<BottomTabBarHeightContext.Provider value={insets.bottom}>
<EntryListSelector entryIds={entryIds} viewId={view} />
<TimelineSelectorHeader>
<EntryListSelector entryIds={entryIds} viewId={view} />
</TimelineSelectorHeader>
</BottomTabBarHeightContext.Provider>
</EntryListContext.Provider>
)