Skip to content

Commit

Permalink
feat(mobile): prefetch entry content
Browse files Browse the repository at this point in the history
  • Loading branch information
hyoban committed Feb 5, 2025
1 parent 34b15c6 commit 03a1438
Show file tree
Hide file tree
Showing 5 changed files with 145 additions and 9 deletions.
11 changes: 9 additions & 2 deletions apps/mobile/src/modules/entry-list/entry-list-gird.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@ import type { MasonryFlashListProps } from "@shopify/flash-list"
import { MasonryFlashList } from "@shopify/flash-list"
import { Image } from "expo-image"
import { Link } from "expo-router"
import { useContext } from "react"
import { useContext, useState } from "react"
import { Pressable, View } from "react-native"
import { useSafeAreaInsets } from "react-native-safe-area-context"

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 { useEntry, useFetchEntryContentByStream } from "@/src/store/entry/hooks"

import { useSelectedFeed } from "../feed-drawer/atoms"

Expand All @@ -23,6 +23,9 @@ export function EntryListContentGrid({
}: {
entryIds: string[]
} & Omit<MasonryFlashListProps<string>, "data" | "renderItem">) {
const [viewableEntryIds, setViewableEntryIds] = useState<string[]>([])
useFetchEntryContentByStream(viewableEntryIds)

const insets = useSafeAreaInsets()
const tabBarHeight = useBottomTabBarHeight()
const headerHeight = useHeaderHeight()
Expand All @@ -33,6 +36,10 @@ export function EntryListContentGrid({
renderItem={useTypeScriptHappyCallback(({ item }) => {
return <RenderEntryItem id={item} />
}, [])}
keyExtractor={(id) => id}
onViewableItemsChanged={({ viewableItems }) => {
setViewableEntryIds(viewableItems.map((item) => item.key))
}}
numColumns={2}
onScroll={useTypeScriptHappyCallback(
(e) => {
Expand Down
11 changes: 9 additions & 2 deletions apps/mobile/src/modules/entry-list/entry-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs"
import { FlashList } from "@shopify/flash-list"
import { Image } from "expo-image"
import { router } from "expo-router"
import { useCallback, useContext, useMemo } from "react"
import { useCallback, useContext, useMemo, useState } from "react"
import { StyleSheet, Text, useAnimatedValue, View } from "react-native"
import { useSafeAreaInsets } from "react-native-safe-area-context"

Expand All @@ -15,7 +15,7 @@ import {
import { ItemPressable } from "@/src/components/ui/pressable/item-pressable"
import { useDefaultHeaderHeight } from "@/src/hooks/useDefaultHeaderHeight"
import { useSelectedFeed, useSelectedFeedTitle } from "@/src/modules/feed-drawer/atoms"
import { useEntry } from "@/src/store/entry/hooks"
import { useEntry, useFetchEntryContentByStream } from "@/src/store/entry/hooks"

import { ViewSelector } from "../feed-drawer/view-selector"
import { LeftAction, RightAction } from "./action"
Expand Down Expand Up @@ -59,6 +59,9 @@ export function EntryListScreen({ entryIds }: { entryIds: string[] }) {
}

function EntryListContent({ entryIds }: { entryIds: string[] }) {
const [viewableEntryIds, setViewableEntryIds] = useState<string[]>([])
useFetchEntryContentByStream(viewableEntryIds)

const insets = useSafeAreaInsets()
const tabBarHeight = useBottomTabBarHeight()
const originalDefaultHeaderHeight = useDefaultHeaderHeight()
Expand All @@ -79,6 +82,10 @@ function EntryListContent({ entryIds }: { entryIds: string[] }) {
),
[],
)}
keyExtractor={(id) => id}
onViewableItemsChanged={({ viewableItems }) => {
setViewableEntryIds(viewableItems.map((item) => item.key))
}}
scrollIndicatorInsets={{
top: headerHeight - insets.top,
bottom: tabBarHeight - insets.bottom,
Expand Down
6 changes: 5 additions & 1 deletion apps/mobile/src/services/entry.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { eq } from "drizzle-orm"
import { eq, inArray } from "drizzle-orm"

import { db } from "../database"
import { entriesTable } from "../database/schemas"
Expand Down Expand Up @@ -28,6 +28,10 @@ class EntryServiceStatic implements Hydratable, Resetable {
await db.update(entriesTable).set(entry).where(eq(entriesTable.id, entry.id))
}

getEntryMany(entryId: string[]) {
return db.query.entriesTable.findMany({ where: inArray(entriesTable.id, entryId) })
}

async hydrate() {
const entries = await db.query.entriesTable.findMany()
entryActions.upsertManyInSession(entries.map((e) => dbStoreMorph.toEntryModel(e)))
Expand Down
93 changes: 90 additions & 3 deletions apps/mobile/src/store/entry/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import type { FeedViewType } from "@follow/constants"
import { useQuery } from "@tanstack/react-query"
import { useCallback } from "react"
import { useMutation, useQuery } from "@tanstack/react-query"
import { fetch } from "expo/fetch"
import { useCallback, useEffect } from "react"

import { apiClient } from "@/src/lib/api-fetch"
import { getCookie } from "@/src/lib/auth"

import { getEntry } from "./getter"
import { entrySyncServices, useEntryStore } from "./store"
import { entryActions, entrySyncServices, useEntryStore } from "./store"
import type { EntryModel, FetchEntriesProps } from "./types"

export const usePrefetchEntries = (props: FetchEntriesProps) => {
Expand Down Expand Up @@ -82,3 +86,86 @@ export const useEntryIdsByCategory = (category: string) => {
),
)
}

export const useFetchEntryContentByStream = (remoteEntryIds?: string[]) => {
const { mutate: updateEntryContent } = useMutation({
mutationKey: ["stream-entry-content", remoteEntryIds],
mutationFn: async (remoteEntryIds: string[]) => {
const onlyNoStored = true

const nextIds = [] as string[]
if (onlyNoStored) {
for (const id of remoteEntryIds) {
const entry = getEntry(id)!
if (entry.content) {
continue
}

nextIds.push(id)
}
}

if (nextIds.length === 0) return

const readStream = async () => {
// https://github.com/facebook/react-native/issues/37505
// TODO: And it seems we can not just use fetch from expo for ofetch, need further investigation
const response = await fetch(apiClient.entries.stream.$url().toString(), {
method: "post",
headers: {
cookie: getCookie(),
},
body: JSON.stringify({
ids: nextIds,
}),
})

const reader = response.body?.getReader()
if (!reader) return

const decoder = new TextDecoder()
let buffer = ""

try {
while (true) {
const { done, value } = await reader.read()
if (done) break

buffer += decoder.decode(value, { stream: true })
const lines = buffer.split("\n")

// Process all complete lines
for (let i = 0; i < lines.length - 1; i++) {
if (lines[i]!.trim()) {
const json = JSON.parse(lines[i]!)
// Handle each JSON line here
entryActions.updateEntryContent(json.id, json.content)
}
}

// Keep the last incomplete line in the buffer
buffer = lines.at(-1) || ""
}

// Process any remaining data
if (buffer.trim()) {
const json = JSON.parse(buffer)

entryActions.updateEntryContent(json.id, json.content)
}
} catch (error) {
console.error("Error reading stream:", error)
} finally {
reader.releaseLock()
}
}

readStream()
},
})

useEffect(() => {
if (!remoteEntryIds) return
updateEntryContent(remoteEntryIds)
}, [remoteEntryIds, updateEntryContent])
}
33 changes: 32 additions & 1 deletion apps/mobile/src/store/entry/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { EntryService } from "@/src/services/entry"
import { createImmerSetter, createTransaction, createZustandStore } from "../internal/helper"
import { listActions } from "../list/store"
import { getSubscription } from "../subscription/getter"
import { getEntry } from "./getter"
import type { EntryModel, FetchEntriesProps } from "./types"
import { getEntriesParams } from "./utils"

Expand Down Expand Up @@ -130,6 +131,27 @@ class EntryActions {
await tx.run()
}

updateEntryContentInSession(entryId: EntryId, content: string) {
immerSet((draft) => {
const entry = draft.data[entryId]
if (!entry) return
entry.content = content
})
}

async updateEntryContent(entryId: EntryId, content: string) {
const tx = createTransaction()
tx.store(() => {
this.updateEntryContentInSession(entryId, content)
})

tx.persist(() => {
return EntryService.patch({ id: entryId, content })
})

await tx.run()
}

reset(entries: EntryModel[] = []) {
if (entries.length > 0) {
immerSet((draft) => {
Expand Down Expand Up @@ -166,6 +188,13 @@ class EntrySyncServices {
})

const entries = honoMorph.toEntryList(res.data)
const entriesInDB = await EntryService.getEntryMany(entries.map((e) => e.id))
for (const entry of entries) {
const entryContent = entriesInDB.find((e) => e.id === entry.id)
if (entryContent) {
entry.content = entryContent.content
}
}

await entryActions.upsertMany(entries)
if (params.listId) {
Expand All @@ -181,7 +210,9 @@ class EntrySyncServices {
const res = await apiClient.entries.$get({ query: { id: entryId } })
const entry = honoMorph.toEntry(res.data)
if (!entry) return null
await entryActions.upsertMany([entry])
if (entry.content && getEntry(entryId)?.content !== entry.content) {
await entryActions.updateEntryContent(entryId, entry.content)
}
return entry
}
}
Expand Down

0 comments on commit 03a1438

Please sign in to comment.