feat(mobile): add social entry list component (#2733)

* refactor(mobile): extract entry list article component

* feat(mobile): add social entry list component

* fix: lint

* chore: typo

* refactor(mobile): replace FlashList with TimelineSelectorList

* chore: auto-fix linting and formatting issues

* refactor(mobile): extract ItemSeparator component

* chore: add diff

* fix: merge

---------

Co-authored-by: Stephen Zhou <38493346+hyoban@users.noreply.github.com>
This commit is contained in:
Whitewater 2025-02-14 00:02:14 +08:00 committed by GitHub
parent c475ecd400
commit 903b98953a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 297 additions and 151 deletions

View File

@ -54,7 +54,7 @@ export const EntryItemContextMenu = ({ id, children }: PropsWithChildren<{ id: s
unreadSyncService.markEntryAsRead(id)
}}
>
<ContextMenu.ItemTitle>Mark As Read</ContextMenu.ItemTitle>
<ContextMenu.ItemTitle>Mark as Read</ContextMenu.ItemTitle>
</ContextMenu.Item>
<ContextMenu.Item
@ -92,7 +92,7 @@ export const EntryItemContextMenu = ({ id, children }: PropsWithChildren<{ id: s
openLink(entry.url)
}}
>
<ContextMenu.ItemTitle>Open link</ContextMenu.ItemTitle>
<ContextMenu.ItemTitle>Open Link</ContextMenu.ItemTitle>
</ContextMenu.Item>
)}

View File

@ -0,0 +1,9 @@
import { StyleSheet, View } from "react-native"
const el = (
<View className="bg-opaque-separator mx-4" style={{ height: StyleSheet.hairlineWidth }} />
)
export const ItemSeparator = () => {
return el
}

View File

@ -0,0 +1,135 @@
import type { ListRenderItemInfo } from "@shopify/flash-list"
import { Image } from "expo-image"
import { router } from "expo-router"
import { useCallback, useEffect, useMemo } from "react"
import { Animated, Text, View } from "react-native"
import { useAnimatedStyle, useSharedValue, withSpring } from "react-native-reanimated"
import { setWebViewEntry } from "@/src/components/native/webview/EntryContentWebView"
import { ItemPressable } from "@/src/components/ui/pressable/item-pressable"
import { gentleSpringPreset } from "@/src/constants/spring"
import { useEntryListContext, useFetchEntriesControls } from "@/src/modules/feed-drawer/atoms"
import { useEntry } from "@/src/store/entry/hooks"
import { debouncedFetchEntryContentByStream } from "@/src/store/entry/store"
import { EntryItemContextMenu } from "../context-menu/entry"
import { TimelineSelectorList } from "../screen/TimelineSelectorList"
import { LoadArchiveButton } from "./action"
import { ItemSeparator } from "./ItemSeparator"
export function EntryListContentArticle({ entryIds }: { entryIds: string[] }) {
const screenType = useEntryListContext().type
const { fetchNextPage, isFetching, refetch, isRefetching } = useFetchEntriesControls()
const renderItem = useCallback(
({ item: id }: ListRenderItemInfo<string>) => <EntryItem key={id} entryId={id} />,
[],
)
const ListFooterComponent = useMemo(
() =>
isFetching ? <EntryItemSkeleton /> : screenType === "feed" ? <LoadArchiveButton /> : null,
[isFetching, screenType],
)
return (
<TimelineSelectorList
onRefresh={() => {
refetch()
}}
isRefetching={isRefetching}
data={entryIds}
renderItem={renderItem}
onEndReached={() => {
fetchNextPage()
}}
onViewableItemsChanged={({ viewableItems }) => {
debouncedFetchEntryContentByStream(viewableItems.map((item) => item.key))
}}
estimatedItemSize={100}
ItemSeparatorComponent={ItemSeparator}
ListFooterComponent={ListFooterComponent}
/>
)
}
function EntryItem({ entryId }: { entryId: string }) {
const entry = useEntry(entryId)
const handlePress = useCallback(() => {
if (!entry) return
setWebViewEntry(entry)
router.push(`/entries/${entryId}`)
}, [entryId, entry])
const unreadZoomSharedValue = useSharedValue(entry?.read ? 0 : 1)
const unreadIndicatorStyle = useAnimatedStyle(() => {
return {
transform: [
{
scale: unreadZoomSharedValue.value,
},
],
}
})
useEffect(() => {
if (!entry) return
if (entry.read) {
unreadZoomSharedValue.value = withSpring(0, gentleSpringPreset)
} else {
unreadZoomSharedValue.value = withSpring(1, gentleSpringPreset)
}
}, [entry, entry?.read, unreadZoomSharedValue])
if (!entry) return <EntryItemSkeleton />
const { title, description, publishedAt, media } = entry
const image = media?.[0]?.url
const blurhash = media?.[0]?.blurhash
return (
<EntryItemContextMenu id={entryId}>
<ItemPressable className="flex flex-row items-center p-4 pl-6" onPress={handlePress}>
<Animated.View
className="bg-red absolute left-2 top-[22] size-2.5 rounded-full"
style={unreadIndicatorStyle}
/>
<View className="flex-1 space-y-2">
<Text numberOfLines={2} className="text-label text-lg font-semibold">
{title}
</Text>
<Text className="text-secondary-label line-clamp-2 text-sm">{description}</Text>
<Text className="text-tertiary-label text-xs">{publishedAt.toLocaleString()}</Text>
</View>
{image && (
<Image
source={{ uri: image }}
placeholder={{ blurhash }}
className="bg-system-fill ml-2 size-20 rounded-md"
contentFit="cover"
/>
)}
</ItemPressable>
</EntryItemContextMenu>
)
}
function EntryItemSkeleton() {
return (
<View className="bg-secondary-system-grouped-background flex flex-row items-center p-4">
<View className="flex flex-1 flex-col justify-between">
{/* Title skeleton */}
<View className="bg-system-fill h-6 w-3/4 animate-pulse rounded-md" />
{/* Description skeleton */}
<View className="bg-system-fill mt-2 w-full flex-1 animate-pulse rounded-md" />
</View>
{/* Image skeleton */}
<View className="bg-system-fill ml-2 size-20 animate-pulse rounded-md" />
</View>
)
}

View File

@ -0,0 +1,128 @@
import type { ListRenderItemInfo } from "@shopify/flash-list"
import { Image } from "expo-image"
import { router } from "expo-router"
import { useCallback, useMemo } from "react"
import { Text, View } from "react-native"
import { setWebViewEntry } from "@/src/components/native/webview/EntryContentWebView"
import { ItemPressable } from "@/src/components/ui/pressable/item-pressable"
import { useEntryListContext, useFetchEntriesControls } from "@/src/modules/feed-drawer/atoms"
import { useEntry } from "@/src/store/entry/hooks"
import { debouncedFetchEntryContentByStream } from "@/src/store/entry/store"
import { EntryItemContextMenu } from "../context-menu/entry"
import { TimelineSelectorList } from "../screen/TimelineSelectorList"
import { LoadArchiveButton } from "./action"
import { ItemSeparator } from "./ItemSeparator"
export function EntryListContentSocial({ entryIds }: { entryIds: string[] }) {
const screenType = useEntryListContext().type
const { fetchNextPage, isFetching, refetch, isRefetching } = useFetchEntriesControls()
const renderItem = useCallback(
({ item: id }: ListRenderItemInfo<string>) => <EntryItem key={id} entryId={id} />,
[],
)
const ListFooterComponent = useMemo(
() =>
isFetching ? <EntryItemSkeleton /> : screenType === "feed" ? <LoadArchiveButton /> : null,
[isFetching, screenType],
)
return (
<TimelineSelectorList
onRefresh={() => {
refetch()
}}
isRefetching={isRefetching}
data={entryIds}
renderItem={renderItem}
onEndReached={() => {
fetchNextPage()
}}
onViewableItemsChanged={({ viewableItems }) => {
debouncedFetchEntryContentByStream(viewableItems.map((item) => item.key))
}}
estimatedItemSize={100}
ItemSeparatorComponent={ItemSeparator}
ListFooterComponent={ListFooterComponent}
/>
)
}
function EntryItem({ entryId }: { entryId: string }) {
const entry = useEntry(entryId)
const handlePress = useCallback(() => {
if (!entry) return
setWebViewEntry(entry)
router.push(`/entries/${entryId}`)
}, [entryId, entry])
if (!entry) return <EntryItemSkeleton />
const { description, publishedAt, media } = entry
return (
<EntryItemContextMenu id={entryId}>
<ItemPressable className="flex flex-col gap-2 p-4" onPress={handlePress}>
<View className="flex flex-1 flex-row items-center gap-2">
<Image
source={{ uri: entry.authorAvatar }}
className="bg-system-fill size-8 rounded-full"
contentFit="cover"
/>
<Text className="text-label">{entry.author}</Text>
{/* TODO relative time */}
<Text className="text-tertiary-label text-xs">{publishedAt.toLocaleString()}</Text>
</View>
<Text numberOfLines={4} className="text-label ml-10 text-sm">
{description}
</Text>
{media && media.length > 0 && (
<View className="ml-10 flex flex-row flex-wrap gap-2">
{media.map((image) => {
return (
<Image
key={image.url}
source={{ uri: image.url }}
placeholder={{ blurhash: image.blurhash }}
className="bg-system-fill ml-2 size-20 rounded-md"
contentFit="cover"
/>
)
})}
</View>
)}
</ItemPressable>
</EntryItemContextMenu>
)
}
function EntryItemSkeleton() {
return (
<View className="flex flex-col gap-2 p-4">
{/* Header row with avatar, author, and date */}
<View className="flex flex-1 flex-row items-center gap-2">
<View className="bg-system-fill size-8 animate-pulse rounded-full" />
<View className="bg-system-fill h-4 w-24 animate-pulse rounded-md" />
<View className="bg-system-fill h-3 w-20 animate-pulse rounded-md" />
</View>
{/* Description area */}
<View className="ml-10 space-y-2">
<View className="bg-system-fill h-4 w-full animate-pulse rounded-md rounded-bl-none" />
<View className="bg-system-fill h-4 w-3/4 animate-pulse rounded-md rounded-tl-none" />
</View>
{/* Media preview area */}
<View className="ml-10 flex flex-row gap-2">
<View className="bg-system-fill size-20 animate-pulse rounded-md" />
<View className="bg-system-fill size-20 animate-pulse rounded-md" />
</View>
</View>
)
}

View File

@ -1,161 +1,35 @@
import { FeedViewType } from "@follow/constants"
import type { ListRenderItemInfo } from "@shopify/flash-list"
import { Image } from "expo-image"
import { router } from "expo-router"
import { useCallback, useEffect, useMemo } from "react"
import { StyleSheet, Text, View } from "react-native"
import Animated, { useAnimatedStyle, useSharedValue, withSpring } from "react-native-reanimated"
import { setWebViewEntry } from "@/src/components/native/webview/EntryContentWebView"
import { ItemPressable } from "@/src/components/ui/pressable/item-pressable"
import { gentleSpringPreset } from "@/src/constants/spring"
import { EntryItemContextMenu } from "@/src/modules/context-menu/entry"
import { LoadArchiveButton } from "@/src/modules/entry-list/action"
import { EntryListContentGrid } from "@/src/modules/entry-list/entry-list-gird"
import {
useEntryListContext,
useFetchEntriesControls,
useSelectedView,
} from "@/src/modules/screen/atoms"
import { TimelineSelectorHeader } from "@/src/modules/screen/TimelineSelectorHeader"
import { TimelineSelectorList } from "@/src/modules/screen/TimelineSelectorList"
import { useEntry } from "@/src/store/entry/hooks"
import { debouncedFetchEntryContentByStream } from "@/src/store/entry/store"
import { useSelectedView } from "@/src/modules/screen/atoms"
import { TimelineSelectorHeader } from "../screen/TimelineSelectorHeader"
import { EntryListContentArticle } from "./entry-list-article"
import { EntryListContentSocial } from "./entry-list-social"
export function EntryListScreen({ entryIds }: { entryIds: string[] }) {
const view = useSelectedView()
let ContentComponent = EntryListContentArticle
switch (view) {
case FeedViewType.SocialMedia: {
ContentComponent = EntryListContentSocial
break
}
case FeedViewType.Pictures:
case FeedViewType.Videos: {
ContentComponent = EntryListContentGrid
break
}
case FeedViewType.Articles: {
ContentComponent = EntryListContentArticle
break
}
}
return (
<TimelineSelectorHeader>
{view === FeedViewType.Pictures || view === FeedViewType.Videos ? (
<EntryListContentGrid entryIds={entryIds} />
) : (
<EntryListContent entryIds={entryIds} />
)}
<ContentComponent entryIds={entryIds} />
</TimelineSelectorHeader>
)
}
function EntryListContent({ entryIds }: { entryIds: string[] }) {
const screenType = useEntryListContext().type
const { fetchNextPage, isFetching, refetch, isRefetching } = useFetchEntriesControls()
const renderItem = useCallback(
({ item: id }: ListRenderItemInfo<string>) => <EntryItem key={id} entryId={id} />,
[],
)
const ListFooterComponent = useMemo(
() =>
isFetching ? <EntryItemSkeleton /> : screenType === "feed" ? <LoadArchiveButton /> : null,
[isFetching, screenType],
)
return (
<TimelineSelectorList
onRefresh={() => {
refetch()
}}
isRefetching={isRefetching}
data={entryIds}
renderItem={renderItem}
onEndReached={() => {
fetchNextPage()
}}
onViewableItemsChanged={({ viewableItems }) => {
debouncedFetchEntryContentByStream(viewableItems.map((item) => item.key))
}}
ItemSeparatorComponent={ItemSeparator}
ListFooterComponent={ListFooterComponent}
/>
)
}
const ItemSeparator = () => {
return (
<View
className="bg-opaque-separator mx-4"
style={{
height: StyleSheet.hairlineWidth,
}}
/>
)
}
function EntryItem({ entryId }: { entryId: string }) {
const entry = useEntry(entryId)
const handlePress = useCallback(() => {
if (!entry) return
setWebViewEntry(entry)
router.push(`/entries/${entryId}`)
}, [entryId, entry])
const unreadZoomSharedValue = useSharedValue(entry?.read ? 0 : 1)
const unreadIndicatorStyle = useAnimatedStyle(() => {
return {
transform: [
{
scale: unreadZoomSharedValue.value,
},
],
}
})
useEffect(() => {
if (!entry) return
if (entry.read) {
unreadZoomSharedValue.value = withSpring(0, gentleSpringPreset)
} else {
unreadZoomSharedValue.value = withSpring(1, gentleSpringPreset)
}
}, [entry?.read, unreadZoomSharedValue])
if (!entry) return <EntryItemSkeleton />
const { title, description, publishedAt, media } = entry
const image = media?.[0]?.url
const blurhash = media?.[0]?.blurhash
return (
<EntryItemContextMenu id={entryId}>
<ItemPressable className="flex flex-row items-center p-4 pl-6" onPress={handlePress}>
<Animated.View
className="bg-red absolute left-2 top-[22] size-2.5 rounded-full"
style={unreadIndicatorStyle}
/>
<View className="flex-1 space-y-2">
<Text numberOfLines={2} className="text-label text-lg font-semibold">
{title}
</Text>
<Text className="text-secondary-label line-clamp-2 text-sm">{description}</Text>
<Text className="text-tertiary-label text-xs">{publishedAt.toLocaleString()}</Text>
</View>
{image && (
<Image
source={{ uri: image }}
placeholder={{ blurhash }}
className="bg-system-fill ml-2 size-20 rounded-md"
contentFit="cover"
/>
)}
</ItemPressable>
</EntryItemContextMenu>
)
}
function EntryItemSkeleton() {
return (
<View className="bg-secondary-system-grouped-background flex flex-row items-center p-4">
<View className="flex flex-1 flex-col justify-between">
{/* Title skeleton */}
<View className="bg-system-fill h-6 w-3/4 animate-pulse rounded-md" />
{/* Description skeleton */}
<View className="bg-system-fill mt-2 w-full flex-1 animate-pulse rounded-md" />
</View>
{/* Image skeleton */}
<View className="bg-system-fill ml-2 size-20 animate-pulse rounded-md" />
</View>
)
}