diff --git a/apps/mobile/src/modules/context-menu/entry.tsx b/apps/mobile/src/modules/context-menu/entry.tsx index bdea0258cc..3bd809729f 100644 --- a/apps/mobile/src/modules/context-menu/entry.tsx +++ b/apps/mobile/src/modules/context-menu/entry.tsx @@ -59,24 +59,19 @@ export const EntryItemContextMenu = ({ id, children }: PropsWithChildren<{ id: s key="Star" onSelect={() => { if (isEntryStarred) { - collectionSyncService.unstarEntry({ - createdAt: new Date().toISOString(), + collectionSyncService.unstarEntry(id) + toast.info("Unstarred") + } else { + if (!entry.feedId) { + toast.error("Feed not found") + return + } + collectionSyncService.starEntry({ feedId: entry.feedId, entryId: id, + // TODO update view view: 0, }) - toast.info("Unstarred") - } else { - collectionSyncService.starEntry( - { - createdAt: new Date().toISOString(), - feedId: entry.feedId, - entryId: id, - // TODO update view - view: 0, - }, - 0, - ) toast.info("Starred") } }} diff --git a/apps/mobile/src/modules/entry-list/action.tsx b/apps/mobile/src/modules/entry-list/action.tsx index 24636f6a32..8ccb0f2573 100644 --- a/apps/mobile/src/modules/entry-list/action.tsx +++ b/apps/mobile/src/modules/entry-list/action.tsx @@ -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() { ) } + +export function LoadArchiveButton() { + const isLoadingArchivedEntries = useIsLoadingArchivedEntries() + if (isLoadingArchivedEntries) return null + return ( + + { + setIsLoadingArchivedEntries(true) + }} + > + Load archived entries + + + ) +} diff --git a/apps/mobile/src/modules/entry-list/entry-list.tsx b/apps/mobile/src/modules/entry-list/entry-list.tsx index 958da08df5..fb073388a2 100644 --- a/apps/mobile/src/modules/entry-list/entry-list.tsx +++ b/apps/mobile/src/modules/entry-list/entry-list.tsx @@ -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) => { @@ -95,6 +95,13 @@ function EntryListContent({ entryIds }: { entryIds: string[] }) { ) const tabBarHeight = useBottomTabBarHeight() + + const ListFooterComponent = useMemo( + () => + isFetching ? : screenType === "feed" ? : null, + [isFetching, screenType], + ) + return ( : null} + ListFooterComponent={ListFooterComponent} /> ) } diff --git a/apps/mobile/src/modules/feed-drawer/atoms.ts b/apps/mobile/src/modules/feed-drawer/atoms.ts index e7a1466dd0..c4b5af482c 100644 --- a/apps/mobile/src/modules/feed-drawer/atoms.ts +++ b/apps/mobile/src/modules/feed-drawer/atoms.ts @@ -103,6 +103,15 @@ const selectedTimelineAtom = atom({ const selectedFeedAtom = atom(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) => { diff --git a/apps/mobile/src/services/entry.ts b/apps/mobile/src/services/entry.ts index af7c5affdf..854f7515aa 100644 --- a/apps/mobile/src/services/entry.ts +++ b/apps/mobile/src/services/entry.ts @@ -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", + ) } } diff --git a/apps/mobile/src/store/entry/store.ts b/apps/mobile/src/store/entry/store.ts index 2f4bd957f1..fde5d36bda 100644 --- a/apps/mobile/src/store/entry/store.ts +++ b/apps/mobile/src/store/entry/store.ts @@ -48,8 +48,10 @@ export const useEntryStore = createZustandStore("entry")(() => defau const immerSet = createImmerSetter(useEntryStore) +type UpsertPosition = "all" | "view" | "category" | "feed" | "inbox" | "list" + class EntryActions { - private addEntryIdToFeed({ + private addEntryIdToView({ draft, feedId, entryId, @@ -59,13 +61,6 @@ class EntryActions { entryId: EntryId }) { if (!feedId) return - const entryIdSetByFeed = draft.entryIdByFeed[feedId] - if (!entryIdSetByFeed) { - draft.entryIdByFeed[feedId] = new Set([entryId]) - } else { - entryIdSetByFeed.add(entryId) - } - const subscription = getSubscription(feedId) if (typeof subscription?.view === "number") { draft.entryIdByView[subscription.view].add(entryId) @@ -80,6 +75,45 @@ class EntryActions { } } + 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, + entryId, + }: { + draft: EntryState + feedId?: FeedId | null + entryId: EntryId + }) { + if (!feedId) return + const entryIdSetByFeed = draft.entryIdByFeed[feedId] + if (!entryIdSetByFeed) { + draft.entryIdByFeed[feedId] = new Set([entryId]) + } else { + entryIdSetByFeed.add(entryId) + } + } + private addEntryIdToInbox({ draft, inboxHandle, @@ -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, - }) - - this.addEntryIdToInbox({ - draft, - inboxHandle, - entryId: entry.id, - }) + if (position === "all" || position === "feed") { + this.addEntryIdToFeed({ + draft, + feedId, + 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) { diff --git a/apps/mobile/src/store/subscription/getter.ts b/apps/mobile/src/store/subscription/getter.ts index bad22e63c3..6dd0bf2716 100644 --- a/apps/mobile/src/store/subscription/getter.ts +++ b/apps/mobile/src/store/subscription/getter.ts @@ -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[] => {