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 && (
+