Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: basic masonry entry list and entry content view #2493

Merged
merged 19 commits into from
Jan 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading