From 903b98953aa74fe472bf3793c6a4b8edbfe519bf Mon Sep 17 00:00:00 2001 From: Whitewater Date: Fri, 14 Feb 2025 00:02:14 +0800 Subject: [PATCH] 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> --- .../mobile/src/modules/context-menu/entry.tsx | 4 +- .../src/modules/entry-list/ItemSeparator.tsx | 9 + .../modules/entry-list/entry-list-article.tsx | 135 ++++++++++++++ .../modules/entry-list/entry-list-social.tsx | 128 +++++++++++++ .../src/modules/entry-list/entry-list.tsx | 172 +++--------------- 5 files changed, 297 insertions(+), 151 deletions(-) create mode 100644 apps/mobile/src/modules/entry-list/ItemSeparator.tsx create mode 100644 apps/mobile/src/modules/entry-list/entry-list-article.tsx create mode 100644 apps/mobile/src/modules/entry-list/entry-list-social.tsx diff --git a/apps/mobile/src/modules/context-menu/entry.tsx b/apps/mobile/src/modules/context-menu/entry.tsx index 28a95bcef..9b21ea915 100644 --- a/apps/mobile/src/modules/context-menu/entry.tsx +++ b/apps/mobile/src/modules/context-menu/entry.tsx @@ -54,7 +54,7 @@ export const EntryItemContextMenu = ({ id, children }: PropsWithChildren<{ id: s unreadSyncService.markEntryAsRead(id) }} > - Mark As Read + Mark as Read - Open link + Open Link )} diff --git a/apps/mobile/src/modules/entry-list/ItemSeparator.tsx b/apps/mobile/src/modules/entry-list/ItemSeparator.tsx new file mode 100644 index 000000000..eef10a98a --- /dev/null +++ b/apps/mobile/src/modules/entry-list/ItemSeparator.tsx @@ -0,0 +1,9 @@ +import { StyleSheet, View } from "react-native" + +const el = ( + +) + +export const ItemSeparator = () => { + return el +} diff --git a/apps/mobile/src/modules/entry-list/entry-list-article.tsx b/apps/mobile/src/modules/entry-list/entry-list-article.tsx new file mode 100644 index 000000000..c8cb0d5a3 --- /dev/null +++ b/apps/mobile/src/modules/entry-list/entry-list-article.tsx @@ -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) => , + [], + ) + + const ListFooterComponent = useMemo( + () => + isFetching ? : screenType === "feed" ? : null, + [isFetching, screenType], + ) + + return ( + { + 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 + const { title, description, publishedAt, media } = entry + const image = media?.[0]?.url + const blurhash = media?.[0]?.blurhash + + return ( + + + + + + + {title} + + {description} + {publishedAt.toLocaleString()} + + {image && ( + + )} + + + ) +} + +function EntryItemSkeleton() { + return ( + + + {/* Title skeleton */} + + {/* Description skeleton */} + + + + {/* Image skeleton */} + + + ) +} diff --git a/apps/mobile/src/modules/entry-list/entry-list-social.tsx b/apps/mobile/src/modules/entry-list/entry-list-social.tsx new file mode 100644 index 000000000..1389a7299 --- /dev/null +++ b/apps/mobile/src/modules/entry-list/entry-list-social.tsx @@ -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) => , + [], + ) + + const ListFooterComponent = useMemo( + () => + isFetching ? : screenType === "feed" ? : null, + [isFetching, screenType], + ) + + return ( + { + 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 + const { description, publishedAt, media } = entry + + return ( + + + + + {entry.author} + {/* TODO relative time */} + {publishedAt.toLocaleString()} + + + + {description} + + + {media && media.length > 0 && ( + + {media.map((image) => { + return ( + + ) + })} + + )} + + + ) +} + +function EntryItemSkeleton() { + return ( + + {/* Header row with avatar, author, and date */} + + + + + + + {/* Description area */} + + + + + + {/* Media preview area */} + + + + + + ) +} diff --git a/apps/mobile/src/modules/entry-list/entry-list.tsx b/apps/mobile/src/modules/entry-list/entry-list.tsx index 039525857..a1e132925 100644 --- a/apps/mobile/src/modules/entry-list/entry-list.tsx +++ b/apps/mobile/src/modules/entry-list/entry-list.tsx @@ -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 ( - {view === FeedViewType.Pictures || view === FeedViewType.Videos ? ( - - ) : ( - - )} + ) } - -function EntryListContent({ entryIds }: { entryIds: string[] }) { - const screenType = useEntryListContext().type - - const { fetchNextPage, isFetching, refetch, isRefetching } = useFetchEntriesControls() - - const renderItem = useCallback( - ({ item: id }: ListRenderItemInfo) => , - [], - ) - - const ListFooterComponent = useMemo( - () => - isFetching ? : screenType === "feed" ? : null, - [isFetching, screenType], - ) - - return ( - { - refetch() - }} - isRefetching={isRefetching} - data={entryIds} - renderItem={renderItem} - onEndReached={() => { - fetchNextPage() - }} - onViewableItemsChanged={({ viewableItems }) => { - debouncedFetchEntryContentByStream(viewableItems.map((item) => item.key)) - }} - ItemSeparatorComponent={ItemSeparator} - ListFooterComponent={ListFooterComponent} - /> - ) -} - -const ItemSeparator = () => { - return ( - - ) -} -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 - const { title, description, publishedAt, media } = entry - const image = media?.[0]?.url - const blurhash = media?.[0]?.blurhash - - return ( - - - - - - - {title} - - {description} - {publishedAt.toLocaleString()} - - {image && ( - - )} - - - ) -} - -function EntryItemSkeleton() { - return ( - - - {/* Title skeleton */} - - {/* Description skeleton */} - - - - {/* Image skeleton */} - - - ) -}