Skip to content

Commit 27818f6

Browse files
committed
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>
1 parent 7ccea72 commit 27818f6

File tree

4 files changed

+137
-44
lines changed

4 files changed

+137
-44
lines changed

apps/mobile/src/modules/entry-list/EntryListContentGrid.tsx

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import { useTypeScriptHappyCallback } from "@follow/hooks"
22
import type { MasonryFlashListProps } from "@shopify/flash-list"
3+
import { useCallback } from "react"
34
import { ActivityIndicator, View } from "react-native"
45

56
import { useFetchEntriesControls } from "@/src/modules/screen/atoms"
7+
import { useEntryStore } from "@/src/store/entry/store"
68

79
import { TimelineSelectorMasonryList } from "../screen/TimelineSelectorList"
810
import { useOnViewableItemsChanged } from "./hooks"
11+
import type { MasonryItem } from "./templates/EntryGridItem"
912
import { EntryGridItem } from "./templates/EntryGridItem"
1013

1114
export function EntryListContentGrid({
@@ -15,16 +18,61 @@ export function EntryListContentGrid({
1518
entryIds: string[]
1619
} & Omit<MasonryFlashListProps<string>, "data" | "renderItem">) {
1720
const { fetchNextPage, refetch, isRefetching, hasNextPage } = useFetchEntriesControls()
18-
const onViewableItemsChanged = useOnViewableItemsChanged()
21+
const onViewableItemsChanged = useOnViewableItemsChanged(
22+
(item) => (item.key as any).split("-")[0],
23+
)
24+
25+
const data = useEntryStore(
26+
useCallback(
27+
(state) => {
28+
const data: (MasonryItem & { index: number })[] = []
29+
30+
let index = 0
31+
for (const id of entryIds) {
32+
const entry = state.data[id]
33+
if (!entry) {
34+
continue
35+
}
36+
if (!entry.media) {
37+
continue
38+
}
39+
40+
for (const media of entry.media) {
41+
if (media.type === "photo") {
42+
data.push({
43+
id,
44+
index: index++,
45+
type: "image",
46+
imageUrl: media.url,
47+
blurhash: media.blurhash,
48+
width: media.width,
49+
height: media.height,
50+
})
51+
} else if (media.type === "video") {
52+
data.push({
53+
id,
54+
index: index++,
55+
type: "video",
56+
videoUrl: media.url,
57+
videoPreviewImageUrl: media.preview_image_url,
58+
})
59+
}
60+
}
61+
}
62+
return data
63+
},
64+
[entryIds],
65+
),
66+
)
1967

2068
return (
2169
<TimelineSelectorMasonryList
2270
isRefetching={isRefetching}
23-
data={entryIds}
24-
renderItem={useTypeScriptHappyCallback(({ item }) => {
25-
return <EntryGridItem id={item} />
71+
data={data}
72+
renderItem={useTypeScriptHappyCallback(({ item }: { item: MasonryItem }) => {
73+
return <EntryGridItem {...item} />
2674
}, [])}
27-
keyExtractor={(id) => id}
75+
keyExtractor={defaultKeyExtractor}
2876
onViewableItemsChanged={onViewableItemsChanged}
2977
onEndReached={() => {
3078
fetchNextPage()
@@ -43,3 +91,8 @@ export function EntryListContentGrid({
4391
/>
4492
)
4593
}
94+
95+
const defaultKeyExtractor = (item: MasonryItem & { index: number }) => {
96+
const key = `${item.id}-${item.index}`
97+
return key
98+
}
Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,29 @@
11
import type ViewToken from "@shopify/flash-list/dist/viewability/ViewToken"
2-
import { useCallback } from "react"
2+
import { useCallback, useState } from "react"
33

44
import { useGeneralSettingKey } from "@/src/atoms/settings/general"
55
import { debouncedFetchEntryContentByStream } from "@/src/store/entry/store"
66
import { unreadSyncService } from "@/src/store/unread/store"
77

8-
export function useOnViewableItemsChanged(): (info: {
9-
viewableItems: ViewToken[]
10-
changed: ViewToken[]
11-
}) => void {
8+
const defaultIdExtractor = (item: ViewToken) => item.key
9+
export function useOnViewableItemsChanged(
10+
idExtractor: (item: ViewToken) => string = defaultIdExtractor,
11+
): (info: { viewableItems: ViewToken[]; changed: ViewToken[] }) => void {
1212
const markAsReadWhenScrolling = useGeneralSettingKey("scrollMarkUnread")
1313

14+
const [stableIdExtractor] = useState(() => idExtractor)
15+
1416
return useCallback(
1517
({ viewableItems, changed }) => {
16-
debouncedFetchEntryContentByStream(viewableItems.map((item) => item.key))
18+
debouncedFetchEntryContentByStream(viewableItems.map((item) => stableIdExtractor(item)))
1719
if (markAsReadWhenScrolling) {
1820
changed
1921
.filter((item) => !item.isViewable)
2022
.forEach((item) => {
21-
unreadSyncService.markEntryAsRead(item.key)
23+
unreadSyncService.markEntryAsRead(stableIdExtractor(item))
2224
})
2325
}
2426
},
25-
[markAsReadWhenScrolling],
27+
[markAsReadWhenScrolling, stableIdExtractor],
2628
)
2729
}
Lines changed: 65 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { FeedViewType } from "@follow/constants"
2+
import { useMemo } from "react"
23
import { Text, View } from "react-native"
34

45
import { useUISettingKey } from "@/src/atoms/settings/ui"
@@ -9,44 +10,78 @@ import { useEntry } from "@/src/store/entry/hooks"
910

1011
import { useSelectedView } from "../../screen/atoms"
1112

12-
export function EntryGridItem({ id }: { id: string }) {
13+
export type MasonryItem = {
14+
id: string
15+
} & (
16+
| {
17+
type: "image"
18+
imageUrl: string
19+
blurhash?: string
20+
height?: number
21+
width?: number
22+
}
23+
| {
24+
type: "video"
25+
videoUrl: string
26+
videoPreviewImageUrl?: string
27+
}
28+
)
29+
export function EntryGridItem(props: MasonryItem) {
30+
const { type, id } = props
1331
const view = useSelectedView()
1432
const item = useEntry(id)
1533

1634
const pictureViewFilterNoImage = useUISettingKey("pictureViewFilterNoImage")
35+
36+
const Content = useMemo(() => {
37+
switch (type) {
38+
case "image": {
39+
const { imageUrl, blurhash, height, width } = props
40+
const aspectRatio = height && width ? width / height : 16 / 9
41+
42+
return imageUrl ? (
43+
<ImageContextMenu imageUrl={imageUrl}>
44+
<PreviewImage imageUrl={imageUrl} blurhash={blurhash} aspectRatio={aspectRatio} />
45+
</ImageContextMenu>
46+
) : (
47+
<View className="aspect-video w-full items-center justify-center">
48+
<Text className="text-label text-center">No media available</Text>
49+
</View>
50+
)
51+
}
52+
case "video": {
53+
const { videoPreviewImageUrl } = props
54+
return (
55+
<>
56+
{videoPreviewImageUrl ? (
57+
<ImageContextMenu imageUrl={videoPreviewImageUrl}>
58+
<PreviewImage imageUrl={videoPreviewImageUrl} aspectRatio={16 / 9} />
59+
</ImageContextMenu>
60+
) : (
61+
<View className="aspect-video w-full items-center justify-center">
62+
<Text className="text-label text-center">No media available</Text>
63+
</View>
64+
)}
65+
<Text className="text-label p-2 font-medium" numberOfLines={2}>
66+
{item?.title}
67+
</Text>
68+
</>
69+
)
70+
}
71+
}
72+
}, [type, JSON.stringify(props), item?.title])
1773
if (!item) {
1874
return null
1975
}
20-
const photo = item.media?.find((media) => media.type === "photo")
21-
const video = item.media?.find((media) => media.type === "video")
22-
const imageUrl = photo?.url || video?.preview_image_url
23-
if (pictureViewFilterNoImage && !imageUrl && view === FeedViewType.Pictures) {
76+
77+
if (
78+
pictureViewFilterNoImage &&
79+
type === "image" &&
80+
!props.imageUrl &&
81+
view === FeedViewType.Pictures
82+
) {
2483
return null
2584
}
2685

27-
const blurhash = photo?.blurhash || video?.blurhash
28-
const aspectRatio =
29-
view === FeedViewType.Pictures && photo?.height && photo.width
30-
? photo.width / photo.height
31-
: 16 / 9
32-
33-
return (
34-
<ItemPressable className="m-1 overflow-hidden rounded-md">
35-
{imageUrl ? (
36-
<ImageContextMenu imageUrl={imageUrl}>
37-
<PreviewImage imageUrl={imageUrl} blurhash={blurhash} aspectRatio={aspectRatio} />
38-
</ImageContextMenu>
39-
) : (
40-
<View className="aspect-video w-full items-center justify-center">
41-
<Text className="text-label text-center">No media available</Text>
42-
</View>
43-
)}
44-
45-
{view === FeedViewType.Videos && (
46-
<Text className="text-label p-2 font-medium" numberOfLines={2}>
47-
{item.title}
48-
</Text>
49-
)}
50-
</ItemPressable>
51-
)
86+
return <ItemPressable className="m-1 overflow-hidden rounded-md">{Content}</ItemPressable>
5287
}

apps/mobile/src/screens/(stack)/feeds/[feedId]/index.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"
66

77
import { EntryListSelector } from "@/src/modules/entry-list/EntryListSelector"
88
import { EntryListContext, useSelectedView } from "@/src/modules/screen/atoms"
9+
import { TimelineSelectorHeader } from "@/src/modules/screen/TimelineSelectorHeader"
910
import { useCollectionEntryList } from "@/src/store/collection/hooks"
1011
import { useEntryIdsByCategory, useEntryIdsByFeedId } from "@/src/store/entry/hooks"
1112
import { FEED_COLLECTION_LIST } from "@/src/store/entry/utils"
@@ -28,7 +29,9 @@ export default function Feed() {
2829
return (
2930
<EntryListContext.Provider value={useMemo(() => ({ type: "feed" }), [])}>
3031
<BottomTabBarHeightContext.Provider value={insets.bottom}>
31-
<EntryListSelector entryIds={entryIds} viewId={view} />
32+
<TimelineSelectorHeader>
33+
<EntryListSelector entryIds={entryIds} viewId={view} />
34+
</TimelineSelectorHeader>
3235
</BottomTabBarHeightContext.Provider>
3336
</EntryListContext.Provider>
3437
)

0 commit comments

Comments
 (0)