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:
parent
7ccea72707
commit
27818f6559
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue