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:
parent
c475ecd400
commit
903b98953a
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue