From e569681198cabbfeb4c8133e421533c37b1fd96d Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Wed, 22 Jan 2025 22:23:56 +0800 Subject: [PATCH] feat: basic masonry entry list and entry content view (#2493) --- apps/mobile/app.config.ts | 1 + apps/mobile/package.json | 1 + .../components/common/AnimatedComponents.tsx | 2 + .../src/components/ui/typography/HtmlWeb.tsx | 38 ++++- .../modules/entry-list/entry-list-gird.tsx | 98 ++++++++++++ .../src/modules/entry-list/entry-list.tsx | 82 +++++----- apps/mobile/src/morph/hono.ts | 33 +++- apps/mobile/src/morph/types.ts | 3 +- .../mobile/src/screens/(headless)/_layout.tsx | 1 + .../(stack)/entries/[entryId]/index.tsx | 150 ++++++++++++++++++ apps/mobile/src/services/index.ts | 2 + apps/mobile/src/store/entry/hooks.ts | 6 + apps/mobile/src/store/entry/store.ts | 27 +++- packages/components/src/ui/markdown/html.tsx | 2 +- pnpm-lock.yaml | 22 +++ 15 files changed, 424 insertions(+), 44 deletions(-) create mode 100644 apps/mobile/src/modules/entry-list/entry-list-gird.tsx create mode 100644 apps/mobile/src/screens/(stack)/entries/[entryId]/index.tsx diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 84a79cd935..d4b1050701 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -96,6 +96,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ }, ], "expo-apple-authentication", + "expo-av", [require("./scripts/with-follow-assets.js")], [require("./scripts/with-follow-app-delegate.js")], "expo-secure-store", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 5cad3bb8ed..97a45337b8 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -39,6 +39,7 @@ "es-toolkit": "1.29.0", "expo": "52.0.18", "expo-apple-authentication": "~7.1.2", + "expo-av": "~15.0.1", "expo-blur": "~14.0.1", "expo-build-properties": "^0.13.1", "expo-clipboard": "~7.0.0", diff --git a/apps/mobile/src/components/common/AnimatedComponents.tsx b/apps/mobile/src/components/common/AnimatedComponents.tsx index 602341839d..6da5b5c479 100644 --- a/apps/mobile/src/components/common/AnimatedComponents.tsx +++ b/apps/mobile/src/components/common/AnimatedComponents.tsx @@ -1,3 +1,4 @@ +import { Image as ExpoImage } from "expo-image" import { Animated, FlatList, Pressable, ScrollView, TouchableOpacity } from "react-native" import Reanimated from "react-native-reanimated" @@ -5,6 +6,7 @@ export const AnimatedScrollView = Animated.createAnimatedComponent(ScrollView) export const AnimatedFlatList = Animated.createAnimatedComponent(FlatList) export const AnimatedTouchableOpacity = Animated.createAnimatedComponent(TouchableOpacity) +export const ReAnimatedExpoImage = Reanimated.createAnimatedComponent(ExpoImage) export const ReAnimatedPressable = Reanimated.createAnimatedComponent(Pressable) export const ReAnimatedScrollView = Reanimated.createAnimatedComponent(ScrollView) export const ReAnimatedTouchableOpacity = Reanimated.createAnimatedComponent(TouchableOpacity) diff --git a/apps/mobile/src/components/ui/typography/HtmlWeb.tsx b/apps/mobile/src/components/ui/typography/HtmlWeb.tsx index d152110cb1..be5c0bb14a 100644 --- a/apps/mobile/src/components/ui/typography/HtmlWeb.tsx +++ b/apps/mobile/src/components/ui/typography/HtmlWeb.tsx @@ -4,11 +4,47 @@ import "@follow/components/assets/tailwind.css" import type { HtmlProps } from "@follow/components" import { Html } from "@follow/components" +import { useEffect } from "react" + +function useSize(callback: (size: [number, number]) => void) { + useEffect(() => { + const lastSize = [document.body.clientWidth, document.body.clientHeight] as [number, number] + + // Observe window size changes + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width, height } = entry.contentRect + + if ( + width.toFixed(0) !== lastSize[0].toFixed(0) || + height.toFixed(0) !== lastSize[1].toFixed(0) + ) { + lastSize[0] = width + lastSize[1] = height + callback([width, height]) + } + } + }) + + observer.observe(document.body) + + callback([document.body.clientWidth, document.body.clientHeight]) + + return () => { + observer.disconnect() + } + }, [callback]) +} export default function HtmlWeb({ content, dom, + onLayout, ...options -}: { dom?: import("expo/dom").DOMProps } & HtmlProps) { +}: { + dom?: import("expo/dom").DOMProps + onLayout: (size: [number, number]) => void +} & HtmlProps) { + useSize(onLayout) return } diff --git a/apps/mobile/src/modules/entry-list/entry-list-gird.tsx b/apps/mobile/src/modules/entry-list/entry-list-gird.tsx new file mode 100644 index 0000000000..bbe48c7f41 --- /dev/null +++ b/apps/mobile/src/modules/entry-list/entry-list-gird.tsx @@ -0,0 +1,98 @@ +import { FeedViewType } from "@follow/constants" +import { useTypeScriptHappyCallback } from "@follow/hooks" +import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs" +import { useHeaderHeight } from "@react-navigation/elements" +import type { MasonryFlashListProps } from "@shopify/flash-list" +import { MasonryFlashList } from "@shopify/flash-list" +import { Link } from "expo-router" +import { useContext } from "react" +import { Pressable, View } from "react-native" +import { useSafeAreaInsets } from "react-native-safe-area-context" + +import { ReAnimatedExpoImage } from "@/src/components/common/AnimatedComponents" +import { NavigationContext } from "@/src/components/common/SafeNavigationScrollView" +import { ThemedText } from "@/src/components/common/ThemedText" +import { ItemPressable } from "@/src/components/ui/pressable/item-pressable" +import { useEntry } from "@/src/store/entry/hooks" + +import { useSelectedFeed } from "../feed-drawer/atoms" + +export function EntryListContentGrid({ + entryIds, + ...rest +}: { + entryIds: string[] +} & Omit, "data" | "renderItem">) { + const insets = useSafeAreaInsets() + const tabBarHeight = useBottomTabBarHeight() + const headerHeight = useHeaderHeight() + const { scrollY } = useContext(NavigationContext)! + return ( + { + return + }, [])} + numColumns={2} + onScroll={useTypeScriptHappyCallback( + (e) => { + scrollY.setValue(e.nativeEvent.contentOffset.y) + }, + [scrollY], + )} + scrollIndicatorInsets={{ + top: headerHeight - insets.top, + bottom: tabBarHeight - insets.bottom, + }} + estimatedItemSize={100} + contentContainerStyle={{ + paddingTop: headerHeight, + paddingBottom: tabBarHeight, + }} + {...rest} + /> + ) +} + +function RenderEntryItem({ id }: { id: string }) { + const selectedFeed = useSelectedFeed() + const view = selectedFeed.type === "view" ? selectedFeed.viewId : null + const item = useEntry(id) + 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 + const aspectRatio = + view === FeedViewType.Pictures && photo?.height && photo.width + ? photo.width / photo.height + : 16 / 9 + + return ( + + + + {imageUrl ? ( + + ) : ( + + No media available + + )} + + + + {item.title} + + ) +} diff --git a/apps/mobile/src/modules/entry-list/entry-list.tsx b/apps/mobile/src/modules/entry-list/entry-list.tsx index b3f479240a..aa4c8f684b 100644 --- a/apps/mobile/src/modules/entry-list/entry-list.tsx +++ b/apps/mobile/src/modules/entry-list/entry-list.tsx @@ -1,11 +1,11 @@ -import type { FeedViewType } from "@follow/constants" +import { FeedViewType } from "@follow/constants" import { useTypeScriptHappyCallback } from "@follow/hooks" import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs" import { useHeaderHeight } from "@react-navigation/elements" import { useIsFocused } from "@react-navigation/native" import { FlashList } from "@shopify/flash-list" import { router } from "expo-router" -import { useCallback, useEffect, useMemo } from "react" +import { useCallback, useContext, useEffect, useMemo } from "react" import { Image, StyleSheet, Text, useAnimatedValue, View } from "react-native" import { useSafeAreaInsets } from "react-native-safe-area-context" @@ -32,6 +32,7 @@ import { useInbox } from "@/src/store/inbox/hooks" import { useList } from "@/src/store/list/hooks" import { LeftAction, RightAction } from "./action" +import { EntryListContentGrid } from "./entry-list-gird" export function EntryList() { const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() @@ -100,9 +101,9 @@ function InboxEntryList({ inboxId }: { inboxId: string }) { function EntryListScreen({ title, entryIds }: { title: string; entryIds: string[] }) { const scrollY = useAnimatedValue(0) - const insets = useSafeAreaInsets() - const tabBarHeight = useBottomTabBarHeight() - const headerHeight = useHeaderHeight() + const selectedFeed = useSelectedFeed() + const view = selectedFeed.type === "view" ? selectedFeed.viewId : null + return ( ({ scrollY }), [scrollY])}> - { - scrollY.setValue(e.nativeEvent.contentOffset.y) - }, - [scrollY], - )} - data={entryIds} - renderItem={useTypeScriptHappyCallback( - ({ item: id }) => ( - - ), - [], - )} - scrollIndicatorInsets={{ - top: headerHeight - insets.top, - bottom: tabBarHeight - insets.bottom, - }} - estimatedItemSize={100} - contentContainerStyle={{ - paddingTop: headerHeight, - paddingBottom: tabBarHeight, - }} - ItemSeparatorComponent={ItemSeparator} - /> + {view === FeedViewType.Pictures || view === FeedViewType.Videos ? ( + + ) : ( + + )} ) } +function EntryListContent({ entryIds }: { entryIds: string[] }) { + const insets = useSafeAreaInsets() + const tabBarHeight = useBottomTabBarHeight() + const headerHeight = useHeaderHeight() + const { scrollY } = useContext(NavigationContext)! + return ( + { + scrollY.setValue(e.nativeEvent.contentOffset.y) + }, + [scrollY], + )} + data={entryIds} + renderItem={useTypeScriptHappyCallback( + ({ item: id }) => ( + + ), + [], + )} + scrollIndicatorInsets={{ + top: headerHeight - insets.top, + bottom: tabBarHeight - insets.bottom, + }} + estimatedItemSize={100} + contentContainerStyle={{ + paddingTop: headerHeight, + paddingBottom: tabBarHeight, + }} + ItemSeparatorComponent={ItemSeparator} + /> + ) +} + const ItemSeparator = () => { return ( { - router.push({ - pathname: `/feeds/[feedId]`, - params: { - feedId: entryId, - }, - }) + router.push(`/entries/${entryId}`) }, [entryId]) if (!entry) return diff --git a/apps/mobile/src/morph/hono.ts b/apps/mobile/src/morph/hono.ts index cb7d6f89cc..7c3a183f69 100644 --- a/apps/mobile/src/morph/hono.ts +++ b/apps/mobile/src/morph/hono.ts @@ -97,7 +97,7 @@ class Morph { } } - toEntry(data?: HonoApiClient.Entry_Get): EntryModel[] { + toEntryList(data?: HonoApiClient.Entry_Post): EntryModel[] { const entries: EntryModel[] = [] for (const item of data ?? []) { entries.push({ @@ -129,6 +129,37 @@ class Morph { } return entries } + + toEntry(data?: HonoApiClient.Entry_Get): EntryModel | null { + if (!data) return null + + return { + id: data.entries.id, + title: data.entries.title, + url: data.entries.url, + content: data.entries.content, + description: data.entries.description, + guid: data.entries.guid, + author: data.entries.author, + authorUrl: data.entries.authorUrl, + authorAvatar: data.entries.authorAvatar, + insertedAt: new Date(data.entries.insertedAt), + publishedAt: new Date(data.entries.publishedAt), + media: data.entries.media ?? null, + categories: data.entries.categories ?? null, + attachments: data.entries.attachments ?? null, + extra: data.entries.extra + ? { + links: data.entries.extra.links ?? undefined, + } + : null, + language: data.entries.language, + feedId: data.feeds.id, + // TODO: handle inboxHandle + inboxHandle: "", + read: false, + } + } } export const honoMorph = new Morph() diff --git a/apps/mobile/src/morph/types.ts b/apps/mobile/src/morph/types.ts index 375f38d5ae..1112477cc3 100644 --- a/apps/mobile/src/morph/types.ts +++ b/apps/mobile/src/morph/types.ts @@ -8,5 +8,6 @@ type ExtractData any> = export namespace HonoApiClient { export type Subscription_Get = ExtractData export type List_Get = ExtractData - export type Entry_Get = ExtractData + export type Entry_Post = ExtractData + export type Entry_Get = ExtractData } diff --git a/apps/mobile/src/screens/(headless)/_layout.tsx b/apps/mobile/src/screens/(headless)/_layout.tsx index 66955b9add..ed7a8a6b74 100644 --- a/apps/mobile/src/screens/(headless)/_layout.tsx +++ b/apps/mobile/src/screens/(headless)/_layout.tsx @@ -6,6 +6,7 @@ import { getSystemBackgroundColor } from "@/src/theme/utils" export default function HeadlessLayout() { useColorScheme() const systemBackgroundColor = getSystemBackgroundColor() + return ( (null) + if (!imageUrl && !videoUrl) { + return null + } + + return ( + <> + {isVideo && ( +