Skip to content

Commit

Permalink
feat: basic masonry entry list and entry content view (#2493)
Browse files Browse the repository at this point in the history
  • Loading branch information
hyoban authored Jan 22, 2025
1 parent 041318a commit e569681
Show file tree
Hide file tree
Showing 15 changed files with 424 additions and 44 deletions.
1 change: 1 addition & 0 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/components/common/AnimatedComponents.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { Image as ExpoImage } from "expo-image"
import { Animated, FlatList, Pressable, ScrollView, TouchableOpacity } from "react-native"
import Reanimated from "react-native-reanimated"

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)
38 changes: 37 additions & 1 deletion apps/mobile/src/components/ui/typography/HtmlWeb.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Html content={content} {...options} />
}
98 changes: 98 additions & 0 deletions apps/mobile/src/modules/entry-list/entry-list-gird.tsx
Original file line number Diff line number Diff line change
@@ -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<MasonryFlashListProps<string>, "data" | "renderItem">) {
const insets = useSafeAreaInsets()
const tabBarHeight = useBottomTabBarHeight()
const headerHeight = useHeaderHeight()
const { scrollY } = useContext(NavigationContext)!
return (
<MasonryFlashList
data={entryIds}
renderItem={useTypeScriptHappyCallback(({ item }) => {
return <RenderEntryItem id={item} />
}, [])}
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 (
<ItemPressable className="m-1 overflow-hidden rounded-md">
<Link href={`/entries/${item.id}`} asChild>
<Pressable>
{imageUrl ? (
<ReAnimatedExpoImage
source={{ uri: imageUrl }}
style={{
width: "100%",
aspectRatio,
}}
sharedTransitionTag={`entry-image-${imageUrl}`}
allowDownscaling={false}
recyclingKey={imageUrl}
/>
) : (
<View className="aspect-video w-full items-center justify-center">
<ThemedText className="text-center">No media available</ThemedText>
</View>
)}
</Pressable>
</Link>

<ThemedText className="p-2">{item.title}</ThemedText>
</ItemPressable>
)
}
82 changes: 46 additions & 36 deletions apps/mobile/src/modules/entry-list/entry-list.tsx
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -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()
Expand Down Expand Up @@ -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 (
<NavigationContext.Provider value={useMemo(() => ({ scrollY }), [scrollY])}>
<NavigationBlurEffectHeader
Expand All @@ -121,35 +122,49 @@ function EntryListScreen({ title, entryIds }: { title: string; entryIds: string[
[],
)}
/>
<FlashList
onScroll={useTypeScriptHappyCallback(
(e) => {
scrollY.setValue(e.nativeEvent.contentOffset.y)
},
[scrollY],
)}
data={entryIds}
renderItem={useTypeScriptHappyCallback(
({ item: id }) => (
<EntryItem key={id} entryId={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 ? (
<EntryListContentGrid entryIds={entryIds} />
) : (
<EntryListContent entryIds={entryIds} />
)}
</NavigationContext.Provider>
)
}

function EntryListContent({ entryIds }: { entryIds: string[] }) {
const insets = useSafeAreaInsets()
const tabBarHeight = useBottomTabBarHeight()
const headerHeight = useHeaderHeight()
const { scrollY } = useContext(NavigationContext)!
return (
<FlashList
onScroll={useTypeScriptHappyCallback(
(e) => {
scrollY.setValue(e.nativeEvent.contentOffset.y)
},
[scrollY],
)}
data={entryIds}
renderItem={useTypeScriptHappyCallback(
({ item: id }) => (
<EntryItem key={id} entryId={id} />
),
[],
)}
scrollIndicatorInsets={{
top: headerHeight - insets.top,
bottom: tabBarHeight - insets.bottom,
}}
estimatedItemSize={100}
contentContainerStyle={{
paddingTop: headerHeight,
paddingBottom: tabBarHeight,
}}
ItemSeparatorComponent={ItemSeparator}
/>
)
}

const ItemSeparator = () => {
return (
<View
Expand All @@ -165,12 +180,7 @@ function EntryItem({ entryId }: { entryId: string }) {
const entry = useEntry(entryId)

const handlePress = useCallback(() => {
router.push({
pathname: `/feeds/[feedId]`,
params: {
feedId: entryId,
},
})
router.push(`/entries/${entryId}`)
}, [entryId])

if (!entry) return <EntryItemSkeleton />
Expand Down
33 changes: 32 additions & 1 deletion apps/mobile/src/morph/hono.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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()
3 changes: 2 additions & 1 deletion apps/mobile/src/morph/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ type ExtractData<T extends (...args: any) => any> =
export namespace HonoApiClient {
export type Subscription_Get = ExtractData<typeof apiClient.subscriptions.$get>
export type List_Get = ExtractData<typeof apiClient.lists.$get>
export type Entry_Get = ExtractData<typeof apiClient.entries.$post>
export type Entry_Post = ExtractData<typeof apiClient.entries.$post>
export type Entry_Get = ExtractData<typeof apiClient.entries.$get>
}
1 change: 1 addition & 0 deletions apps/mobile/src/screens/(headless)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { getSystemBackgroundColor } from "@/src/theme/utils"
export default function HeadlessLayout() {
useColorScheme()
const systemBackgroundColor = getSystemBackgroundColor()

return (
<Stack
screenOptions={{
Expand Down
Loading

0 comments on commit e569681

Please sign in to comment.