feat(mobile): load archive entries (#2725)

* feat(mobile): load archive entries

* only hydrate for view

* leave note

* fix type
This commit is contained in:
Stephen Zhou 2025-02-12 15:27:03 +08:00 committed by GitHub
parent 3b1db30456
commit d8a796d3ab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 187 additions and 58 deletions

View File

@ -59,24 +59,19 @@ export const EntryItemContextMenu = ({ id, children }: PropsWithChildren<{ id: s
key="Star"
onSelect={() => {
if (isEntryStarred) {
collectionSyncService.unstarEntry({
createdAt: new Date().toISOString(),
feedId: entry.feedId,
entryId: id,
view: 0,
})
collectionSyncService.unstarEntry(id)
toast.info("Unstarred")
} else {
collectionSyncService.starEntry(
{
createdAt: new Date().toISOString(),
feedId: entry.feedId,
entryId: id,
// TODO update view
view: 0,
},
0,
)
if (!entry.feedId) {
toast.error("Feed not found")
return
}
collectionSyncService.starEntry({
feedId: entry.feedId,
entryId: id,
// TODO update view
view: 0,
})
toast.info("Starred")
}
}}

View File

@ -1,12 +1,16 @@
import { Link } from "expo-router"
import { TouchableOpacity, View } from "react-native"
import { Text, TouchableOpacity, View } from "react-native"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { AddCuteReIcon } from "@/src/icons/add_cute_re"
import { LayoutLeftbarOpenCuteReIcon } from "@/src/icons/layout_leftbar_open_cute_re"
import { accentColor } from "@/src/theme/colors"
import { useFeedDrawer } from "../feed-drawer/atoms"
import {
setIsLoadingArchivedEntries,
useFeedDrawer,
useIsLoadingArchivedEntries,
} from "../feed-drawer/atoms"
const useActionPadding = () => {
const insets = useSafeAreaInsets()
@ -42,3 +46,19 @@ export function HomeRightAction() {
</View>
)
}
export function LoadArchiveButton() {
const isLoadingArchivedEntries = useIsLoadingArchivedEntries()
if (isLoadingArchivedEntries) return null
return (
<View className="items-center pt-2">
<TouchableOpacity
onPress={() => {
setIsLoadingArchivedEntries(true)
}}
>
<Text className="text-label">Load archived entries</Text>
</TouchableOpacity>
</View>
)
}

View File

@ -28,7 +28,7 @@ import { debouncedFetchEntryContentByStream } from "@/src/store/entry/store"
import { EntryItemContextMenu } from "../context-menu/entry"
import { ViewSelector } from "../feed-drawer/view-selector"
import { HomeLeftAction, HomeRightAction } from "./action"
import { HomeLeftAction, HomeRightAction, LoadArchiveButton } from "./action"
import { EntryListContentGrid } from "./entry-list-gird"
const headerHideableBottomHeight = 58
@ -80,7 +80,7 @@ function EntryListContent({ entryIds }: { entryIds: string[] }) {
const scrollY = useContext(NavigationContext)?.scrollY
const { colorScheme } = useColorScheme()
const { fetchNextPage, isFetchingNextPage, refetch, isRefetching } = useFetchEntriesControls()
const { fetchNextPage, isFetching, refetch, isRefetching } = useFetchEntriesControls()
const onScroll = useCallback(
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
@ -95,6 +95,13 @@ function EntryListContent({ entryIds }: { entryIds: string[] }) {
)
const tabBarHeight = useBottomTabBarHeight()
const ListFooterComponent = useMemo(
() =>
isFetching ? <EntryItemSkeleton /> : screenType === "feed" ? <LoadArchiveButton /> : null,
[isFetching, screenType],
)
return (
<FlashList
refreshControl={
@ -128,7 +135,7 @@ function EntryListContent({ entryIds }: { entryIds: string[] }) {
paddingBottom: tabBarHeight,
}}
ItemSeparatorComponent={ItemSeparator}
ListFooterComponent={isFetchingNextPage ? <EntryItemSkeleton /> : null}
ListFooterComponent={ListFooterComponent}
/>
)
}

View File

@ -103,6 +103,15 @@ const selectedTimelineAtom = atom<SelectedTimeline>({
const selectedFeedAtom = atom<SelectedFeed>(null)
const isLoadingArchivedEntriesAtom = atom(false)
export const useIsLoadingArchivedEntries = () => {
return useAtomValue(isLoadingArchivedEntriesAtom)
}
export const setIsLoadingArchivedEntries = (isLoading: boolean) => {
jotaiStore.set(isLoadingArchivedEntriesAtom, isLoading)
}
export const EntryListContext = createContext<{ type: "timeline" | "feed" }>({ type: "timeline" })
export const useEntryListContext = () => {
return useContext(EntryListContext)
@ -129,6 +138,7 @@ export function useSelectedView() {
function getFetchEntryPayload(
selectedFeed: SelectedTimeline | SelectedFeed,
isArchived = false,
): FetchEntriesProps | null {
if (!selectedFeed) {
return null
@ -158,6 +168,8 @@ function getFetchEntryPayload(
}
// No default
}
payload.isArchived = isArchived
return payload
}
@ -167,8 +179,10 @@ export function useSelectedFeed() {
const selectedTimeline = useAtomValue(selectedTimelineAtom)
const selectedFeed = useAtomValue(selectedFeedAtom)
const isArchived = useIsLoadingArchivedEntries()
const payload = getFetchEntryPayload(
entryListContext.type === "feed" ? selectedFeed : selectedTimeline,
entryListContext.type === "feed" ? isArchived : false,
)
usePrefetchEntries(payload)
@ -176,8 +190,15 @@ export function useSelectedFeed() {
}
export function useFetchEntriesControls() {
const entryListContext = useEntryListContext()
const selectedFeed = useSelectedFeed()
const payload = getFetchEntryPayload(selectedFeed)
const isArchived = useIsLoadingArchivedEntries()
const payload = getFetchEntryPayload(
selectedFeed,
entryListContext.type === "feed" ? isArchived : false,
)
return usePrefetchEntries(payload)
}
@ -220,6 +241,7 @@ export const selectTimeline = (state: SelectedTimeline) => {
export const selectFeed = (state: SelectedFeed) => {
jotaiStore.set(selectedFeedAtom, state)
jotaiStore.set(isLoadingArchivedEntriesAtom, false)
}
export const useViewDefinition = (view?: FeedViewType) => {

View File

@ -34,7 +34,11 @@ class EntryServiceStatic implements Hydratable, Resetable {
async hydrate() {
const entries = await db.query.entriesTable.findMany()
entryActions.upsertManyInSession(entries.map((e) => dbStoreMorph.toEntryModel(e)))
// TODO: Find a way to determine whether entry is archived, and then only hydrate unarchived entries
entryActions.upsertManyInSession(
entries.map((e) => dbStoreMorph.toEntryModel(e)),
"view",
)
}
}

View File

@ -48,7 +48,54 @@ export const useEntryStore = createZustandStore<EntryState>("entry")(() => defau
const immerSet = createImmerSetter(useEntryStore)
type UpsertPosition = "all" | "view" | "category" | "feed" | "inbox" | "list"
class EntryActions {
private addEntryIdToView({
draft,
feedId,
entryId,
}: {
draft: EntryState
feedId?: FeedId | null
entryId: EntryId
}) {
if (!feedId) return
const subscription = getSubscription(feedId)
if (typeof subscription?.view === "number") {
draft.entryIdByView[subscription.view].add(entryId)
}
if (subscription?.category) {
const entryIdSetByCategory = draft.entryIdByCategory[subscription.category]
if (!entryIdSetByCategory) {
draft.entryIdByCategory[subscription.category] = new Set([entryId])
} else {
entryIdSetByCategory.add(entryId)
}
}
}
private addEntryIdToCategory({
draft,
feedId,
entryId,
}: {
draft: EntryState
feedId?: FeedId | null
entryId: EntryId
}) {
if (!feedId) return
const subscription = getSubscription(feedId)
if (subscription?.category) {
const entryIdSetByCategory = draft.entryIdByCategory[subscription.category]
if (!entryIdSetByCategory) {
draft.entryIdByCategory[subscription.category] = new Set([entryId])
} else {
entryIdSetByCategory.add(entryId)
}
}
}
private addEntryIdToFeed({
draft,
feedId,
@ -65,19 +112,6 @@ class EntryActions {
} else {
entryIdSetByFeed.add(entryId)
}
const subscription = getSubscription(feedId)
if (typeof subscription?.view === "number") {
draft.entryIdByView[subscription.view].add(entryId)
}
if (subscription?.category) {
const entryIdSetByCategory = draft.entryIdByCategory[subscription.category]
if (!entryIdSetByCategory) {
draft.entryIdByCategory[subscription.category] = new Set([entryId])
} else {
entryIdSetByCategory.add(entryId)
}
}
}
private addEntryIdToInbox({
@ -98,7 +132,7 @@ class EntryActions {
}
}
upsertManyInSession(entries: EntryModel[]) {
upsertManyInSession(entries: EntryModel[], position: UpsertPosition) {
if (entries.length === 0) return
immerSet((draft) => {
@ -106,25 +140,45 @@ class EntryActions {
draft.data[entry.id] = entry
const { feedId, inboxHandle } = entry
this.addEntryIdToFeed({
draft,
feedId,
entryId: entry.id,
})
if (position === "all" || position === "feed") {
this.addEntryIdToFeed({
draft,
feedId,
entryId: entry.id,
})
}
this.addEntryIdToInbox({
draft,
inboxHandle,
entryId: entry.id,
})
if (position === "all" || position === "view") {
this.addEntryIdToView({
draft,
feedId,
entryId: entry.id,
})
}
if (position === "all" || position === "inbox") {
this.addEntryIdToInbox({
draft,
inboxHandle,
entryId: entry.id,
})
}
if (position === "all" || position === "category") {
this.addEntryIdToCategory({
draft,
feedId,
entryId: entry.id,
})
}
}
})
}
async upsertMany(entries: EntryModel[]) {
async upsertMany(entries: EntryModel[], position: UpsertPosition) {
const tx = createTransaction()
tx.store(() => {
this.upsertManyInSession(entries)
this.upsertManyInSession(entries, position)
})
tx.persist(() => {
@ -155,17 +209,26 @@ class EntryActions {
await tx.run()
}
resetByView({ view, entries }: { view: FeedViewType; entries: EntryModel[] }) {
resetByView({ view, entries }: { view?: FeedViewType; entries: EntryModel[] }) {
if (view === undefined) return
immerSet((draft) => {
draft.entryIdByView[view] = new Set(entries.map((e) => e.id))
})
}
resetByCategory({ category, entries }: { category: Category; entries: EntryModel[] }) {
resetByCategory({ category, entries }: { category?: Category; entries: EntryModel[] }) {
if (!category) return
immerSet((draft) => {
draft.entryIdByCategory[category] = new Set(entries.map((e) => e.id))
})
}
resetByFeed({ feedId, entries }: { feedId?: FeedId; entries: EntryModel[] }) {
if (!feedId) return
immerSet((draft) => {
draft.entryIdByFeed[feedId] = new Set(entries.map((e) => e.id))
})
}
}
class EntrySyncServices {
@ -198,7 +261,20 @@ class EntrySyncServices {
}
}
await entryActions.upsertMany(entries)
const position =
params.view !== undefined
? "view"
: params.feedId
? "feed"
: params.feedIdList
? "category"
: params.inboxId
? "inbox"
: params.listId
? "list"
: "all"
await entryActions.upsertMany(entries, position)
if (params.listId) {
await listActions.addEntryIds({
listId: params.listId,
@ -208,16 +284,20 @@ class EntrySyncServices {
// After initial fetch, we can reset the state to prefer the entries data from the server
if (!pageParam) {
if (view !== undefined) {
entryActions.resetByView({ view, entries })
if (position === "view") {
entryActions.resetByView({ view: params.view, entries })
}
if (params.feedIdList && params.feedIdList.length > 0) {
const category = getSubscription(params.feedIdList[0]!)?.category
if (position === "category") {
const category = getSubscription(params.feedIdList?.[0])?.category
if (category) {
entryActions.resetByCategory({ category, entries })
}
}
if (position === "feed") {
entryActions.resetByFeed({ feedId: params.feedId, entries })
}
}
if (isCollection && res.data) {

View File

@ -4,8 +4,9 @@ import type { SubscriptionModel } from "./store"
import { useSubscriptionStore } from "./store"
const get = useSubscriptionStore.getState
export const getSubscription = (id: string): SubscriptionModel | null => {
return get().data[id] || null
export const getSubscription = (id?: string): SubscriptionModel | undefined => {
if (!id) return
return get().data[id]
}
export const getSubscriptionByView = (view: FeedViewType): string[] => {