From 7180e00d197720abfcd568740dc29a7700fb8eee Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Wed, 5 Mar 2025 21:36:26 +0800 Subject: [PATCH] feat(mobile): list and inbox (#2969) * feat(mobile): bring list and inbox back * navigate to list * inbox support * fix: query entries * use proxy image * fix list and inbox state * estimatedItemSize * update * update * update * fix list unread count * unread count component * update * fix unread count by view --- apps/mobile/drizzle/0014_chemical_shocker.sql | 1 + apps/mobile/drizzle/meta/0014_snapshot.json | 615 ++++++++++++++++++ apps/mobile/drizzle/meta/_journal.json | 7 + apps/mobile/drizzle/migrations.js | 2 + apps/mobile/src/database/schemas/index.ts | 1 - .../entry-content/EntryReadHistory.tsx | 4 + apps/mobile/src/modules/entry-list/index.tsx | 4 +- .../entry-list/templates/EntryNormalItem.tsx | 4 +- apps/mobile/src/modules/screen/atoms.ts | 40 +- .../subscription/SubscriptionLists.tsx | 124 +++- .../modules/subscription/items/InboxItem.tsx | 13 +- .../items/ListSubscriptionItem.tsx | 28 +- .../subscription/items/SubscriptionItem.tsx | 5 +- .../subscription/items/UnreadCount.tsx | 15 + apps/mobile/src/morph/hono.ts | 18 +- apps/mobile/src/morph/types.ts | 2 + .../screens/(stack)/feeds/[feedId]/index.tsx | 33 +- apps/mobile/src/services/list.ts | 14 - apps/mobile/src/store/entry/hooks.ts | 18 +- apps/mobile/src/store/entry/store.ts | 125 +++- apps/mobile/src/store/entry/utils.ts | 12 + apps/mobile/src/store/list/getters.ts | 7 +- apps/mobile/src/store/list/hooks.ts | 8 +- apps/mobile/src/store/list/store.ts | 20 - apps/mobile/src/store/subscription/getter.ts | 3 +- apps/mobile/src/store/subscription/store.ts | 1 - apps/mobile/src/store/unread/hooks.ts | 23 +- 27 files changed, 976 insertions(+), 171 deletions(-) create mode 100644 apps/mobile/drizzle/0014_chemical_shocker.sql create mode 100644 apps/mobile/drizzle/meta/0014_snapshot.json create mode 100644 apps/mobile/src/modules/subscription/items/UnreadCount.tsx diff --git a/apps/mobile/drizzle/0014_chemical_shocker.sql b/apps/mobile/drizzle/0014_chemical_shocker.sql new file mode 100644 index 000000000..243537573 --- /dev/null +++ b/apps/mobile/drizzle/0014_chemical_shocker.sql @@ -0,0 +1 @@ +ALTER TABLE `lists` DROP COLUMN `entry_ids`; \ No newline at end of file diff --git a/apps/mobile/drizzle/meta/0014_snapshot.json b/apps/mobile/drizzle/meta/0014_snapshot.json new file mode 100644 index 000000000..38bb3ced7 --- /dev/null +++ b/apps/mobile/drizzle/meta/0014_snapshot.json @@ -0,0 +1,615 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "95c0e828-629d-42da-8708-93156bde1199", + "prevId": "9859ecf0-4bcb-4d5a-90f0-66ceb1c133a5", + "tables": { + "collections": { + "name": "collections", + "columns": { + "feed_id": { + "name": "feed_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "entry_id": { + "name": "entry_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "view": { + "name": "view", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "entries": { + "name": "entries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guid": { + "name": "guid", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author_url": { + "name": "author_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author_avatar": { + "name": "author_avatar", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "media": { + "name": "media", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "categories": { + "name": "categories", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attachments": { + "name": "attachments", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "extra": { + "name": "extra", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "feed_id": { + "name": "feed_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inbox_handle": { + "name": "inbox_handle", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "read": { + "name": "read", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sources": { + "name": "sources", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feeds": { + "name": "feeds", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_at": { + "name": "error_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "site_url": { + "name": "site_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "images": { + "name": "images", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "colors": { + "name": "colors", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inboxes": { + "name": "inboxes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "lists": { + "name": "lists", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "feed_ids": { + "name": "feed_ids", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "view": { + "name": "view", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fee": { + "name": "fee", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "subscriptions": { + "name": "subscriptions", + "columns": { + "feed_id": { + "name": "feed_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "list_id": { + "name": "list_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inbox_id": { + "name": "inbox_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "view": { + "name": "view", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_private": { + "name": "is_private", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "summaries": { + "name": "summaries", + "columns": { + "entry_id": { + "name": "entry_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "unq": { + "name": "unq", + "columns": ["entry_id", "language"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "unread": { + "name": "unread", + "columns": { + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_me": { + "name": "is_me", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/mobile/drizzle/meta/_journal.json b/apps/mobile/drizzle/meta/_journal.json index 3bf66fa99..a1b431853 100644 --- a/apps/mobile/drizzle/meta/_journal.json +++ b/apps/mobile/drizzle/meta/_journal.json @@ -99,6 +99,13 @@ "when": 1741017801271, "tag": "0013_chunky_stephen_strange", "breakpoints": true + }, + { + "idx": 14, + "version": "6", + "when": 1741168250902, + "tag": "0014_chemical_shocker", + "breakpoints": true } ] } diff --git a/apps/mobile/drizzle/migrations.js b/apps/mobile/drizzle/migrations.js index 4e4947173..8c93e0b1b 100644 --- a/apps/mobile/drizzle/migrations.js +++ b/apps/mobile/drizzle/migrations.js @@ -14,6 +14,7 @@ import m0010 from "./0010_legal_ben_grimm.sql" import m0011 from "./0011_mysterious_stark_industries.sql" import m0012 from "./0012_magenta_thing.sql" import m0013 from "./0013_chunky_stephen_strange.sql" +import m0014 from "./0014_chemical_shocker.sql" import journal from "./meta/_journal.json" export default { @@ -33,5 +34,6 @@ export default { m0011, m0012, m0013, + m0014, }, } diff --git a/apps/mobile/src/database/schemas/index.ts b/apps/mobile/src/database/schemas/index.ts index a2e3af3e0..8dc8f8eec 100644 --- a/apps/mobile/src/database/schemas/index.ts +++ b/apps/mobile/src/database/schemas/index.ts @@ -45,7 +45,6 @@ export const listsTable = sqliteTable("lists", { image: text("image"), fee: integer("fee"), ownerUserId: text("owner_user_id"), - entryIds: text("entry_ids", { mode: "json" }).$type(), }) export const unreadTable = sqliteTable("unread", { diff --git a/apps/mobile/src/modules/entry-content/EntryReadHistory.tsx b/apps/mobile/src/modules/entry-content/EntryReadHistory.tsx index d9045b8fe..2944478d7 100644 --- a/apps/mobile/src/modules/entry-content/EntryReadHistory.tsx +++ b/apps/mobile/src/modules/entry-content/EntryReadHistory.tsx @@ -4,9 +4,12 @@ import { Pressable, View } from "react-native" import { UserAvatar } from "@/src/components/ui/avatar/UserAvatar" import { apiClient } from "@/src/lib/api-fetch" +import { useEntry } from "@/src/store/entry/hooks" +import { isInboxEntry } from "@/src/store/entry/utils" import { userActions } from "@/src/store/user/store" export const EntryReadHistory = ({ entryId }: { entryId: string }) => { + const entry = useEntry(entryId) const { data } = useQuery({ queryKey: ["entry-read-history", entryId], queryFn: () => { @@ -20,6 +23,7 @@ export const EntryReadHistory = ({ entryId }: { entryId: string }) => { }) }, staleTime: 1000 * 60 * 5, + enabled: !isInboxEntry(entry), }) if (!data?.data.entryReadHistories) return null return ( diff --git a/apps/mobile/src/modules/entry-list/index.tsx b/apps/mobile/src/modules/entry-list/index.tsx index b140dcf97..bad0f6870 100644 --- a/apps/mobile/src/modules/entry-list/index.tsx +++ b/apps/mobile/src/modules/entry-list/index.tsx @@ -8,9 +8,9 @@ import { useEntryIdsByCategory, useEntryIdsByFeedId, useEntryIdsByInboxId, + useEntryIdsByListId, useEntryIdsByView, } from "@/src/store/entry/hooks" -import { useListEntryIds } from "@/src/store/list/hooks" import { useViewWithSubscription } from "@/src/store/subscription/hooks" import { TimelineSelectorProvider } from "../screen/TimelineSelectorProvider" @@ -106,7 +106,7 @@ function CategoryEntryList({ categoryName }: { categoryName: string }) { function ListEntryList({ listId }: { listId: string }) { const view = useSelectedView() ?? FeedViewType.Articles - const entryIds = useListEntryIds(listId) + const entryIds = useEntryIdsByListId(listId) if (!entryIds) return null return } diff --git a/apps/mobile/src/modules/entry-list/templates/EntryNormalItem.tsx b/apps/mobile/src/modules/entry-list/templates/EntryNormalItem.tsx index cd1696ba0..880ba8834 100644 --- a/apps/mobile/src/modules/entry-list/templates/EntryNormalItem.tsx +++ b/apps/mobile/src/modules/entry-list/templates/EntryNormalItem.tsx @@ -16,6 +16,7 @@ import { PauseCuteFiIcon } from "@/src/icons/pause_cute_fi" import { PlayCuteFiIcon } from "@/src/icons/play_cute_fi" import { getAttachmentState, player } from "@/src/lib/player" import { useEntry } from "@/src/store/entry/hooks" +import { getInboxFrom } from "@/src/store/entry/utils" import { useFeed } from "@/src/store/feed/hooks" import { EntryItemContextMenu } from "../../context-menu/entry" @@ -24,6 +25,7 @@ import { useEntryListContextView } from "../EntryListContext" export function EntryNormalItem({ entryId, extraData }: { entryId: string; extraData: string }) { const entry = useEntry(entryId) + const from = getInboxFrom(entry) const feed = useFeed(entry?.feedId as string) const view = useEntryListContextView() @@ -84,7 +86,7 @@ export function EntryNormalItem({ entryId, extraData }: { entryId: string; extra - {feed?.title ?? "Unknown feed"} + {feed?.title || from || "Unknown feed"} ยท {estimatedMins ? ( diff --git a/apps/mobile/src/modules/screen/atoms.ts b/apps/mobile/src/modules/screen/atoms.ts index ad86d34ec..28bc23bb5 100644 --- a/apps/mobile/src/modules/screen/atoms.ts +++ b/apps/mobile/src/modules/screen/atoms.ts @@ -44,15 +44,10 @@ export function useSetDrawerSwipeDisabled() { // collection panel selected state -type CollectionPanelState = - | { - type: "view" - viewId: FeedViewType - } - | { - type: "list" - listId: string - } +type CollectionPanelState = { + type: "view" + viewId: FeedViewType +} const collectionPanelStateAtom = atom({ type: "view", @@ -71,19 +66,10 @@ export const selectCollection = (state: CollectionPanelState) => { // feed panel selected state -export type SelectedTimeline = - | { - type: "view" - viewId: FeedViewType - } - | { - type: "list" - listId: string - } - | { - type: "inbox" - inboxId: string - } +export type SelectedTimeline = { + type: "view" + viewId: FeedViewType +} export type SelectedFeed = | { @@ -94,6 +80,14 @@ export type SelectedFeed = type: "category" categoryName: string } + | { + type: "list" + listId: string + } + | { + type: "inbox" + inboxId: string + } | null const selectedTimelineAtom = atom({ @@ -114,7 +108,7 @@ export function useSelectedView() { const selectedTimeLine = useAtomValue(selectedTimelineAtom) const selectedFeed = useAtomValue(selectedFeedAtom) - const list = useList(selectedTimeLine.type === "list" ? selectedTimeLine.listId : "") + const list = useList(selectedFeed?.type === "list" ? selectedFeed.listId : "") const subscription = useSubscription( selectedFeed && selectedFeed.type === "feed" ? selectedFeed.feedId : "", ) diff --git a/apps/mobile/src/modules/subscription/SubscriptionLists.tsx b/apps/mobile/src/modules/subscription/SubscriptionLists.tsx index af6ac8cb7..28e78b03d 100644 --- a/apps/mobile/src/modules/subscription/SubscriptionLists.tsx +++ b/apps/mobile/src/modules/subscription/SubscriptionLists.tsx @@ -13,13 +13,18 @@ import { TimelineSelectorList } from "@/src/modules/screen/TimelineSelectorList" import { FEED_COLLECTION_LIST } from "@/src/store/entry/utils" import { useGroupedSubscription, + useInboxSubscription, + useListSubscription, useSortedGroupedSubscription, + useSortedListSubscription, useSortedUngroupedSubscription, } from "@/src/store/subscription/hooks" import { subscriptionSyncService } from "@/src/store/subscription/store" import { useFeedListSortMethod, useFeedListSortOrder } from "./atoms" import { CategoryGrouped } from "./CategoryGrouped" +import { InboxItem } from "./items/InboxItem" +import { ListSubscriptionItem } from "./items/ListSubscriptionItem" import { SubscriptionItem } from "./items/SubscriptionItem" import { ItemSeparator } from "./ItemSeparator" @@ -31,17 +36,46 @@ const keyExtractor = (item: string | { category: string; subscriptionIds: string } export const SubscriptionList = ({ view }: { view: FeedViewType }) => { + const listIds = useListSubscription(view) + const sortedListIds = useSortedListSubscription(listIds, "alphabet") + + const inboxes = useInboxSubscription(view) + const { grouped, unGrouped } = useGroupedSubscription(view) const sortBy = useFeedListSortMethod() const sortOrder = useFeedListSortOrder() const sortedGrouped = useSortedGroupedSubscription(grouped, sortBy, sortOrder) const sortedUnGrouped = useSortedUngroupedSubscription(unGrouped, sortBy, sortOrder) + const data = useMemo( - () => [...sortedGrouped, ...sortedUnGrouped], - [sortedGrouped, sortedUnGrouped], + () => [ + "Starred", + "Lists", + ...sortedListIds, + "Inbox", + ...inboxes, + "Feeds", + ...sortedGrouped, + ...sortedUnGrouped, + ], + [inboxes, sortedListIds, sortedGrouped, sortedUnGrouped], ) + const extraData = useMemo(() => { + const listsIndexStart = 2 + const listsIndexEnd = listsIndexStart + sortedListIds.length - 1 + const inboxIndexStart = listsIndexEnd + 2 + const inboxIndexEnd = inboxIndexStart + inboxes.length - 1 + const feedsIndexStart = inboxIndexEnd + 2 + const feedsIndexEnd = feedsIndexStart + sortedGrouped.length + sortedUnGrouped.length - 1 + return { + inboxIndexRange: [inboxIndexStart, inboxIndexEnd], + feedsIndexRange: [feedsIndexStart, feedsIndexEnd], + listsIndexRange: [listsIndexStart, listsIndexEnd], + } + }, [inboxes.length, sortedGrouped.length, sortedListIds.length, sortedUnGrouped.length]) + const [refreshing, setRefreshing] = useState(false) const onRefresh = useEventCallback(() => { return subscriptionSyncService.fetch(view) @@ -60,15 +94,12 @@ export const SubscriptionList = ({ view }: { view: FeedViewType }) => { }} className="bg-system-grouped-background" isRefetching={refreshing} - ItemSeparatorComponent={ItemSeparator} data={data} - ListHeaderComponent={ListHeaderComponent} + estimatedItemSize={50} renderItem={ItemRender} keyExtractor={keyExtractor} // itemLayoutAnimation={LinearTransition} - extraData={{ - total: data.length, - }} + extraData={extraData} /> ) } @@ -81,31 +112,84 @@ const ItemRender = ({ item: string | { category: string; subscriptionIds: string[] } index: number extraData?: { - total: number + inboxIndexRange: [number, number] + feedsIndexRange: [number, number] + listsIndexRange: [number, number] } }) => { if (typeof item === "string") { - return ( - - ) + switch (item) { + case "Starred": { + return + } + case "Inbox": { + if (!extraData) return null + const { inboxIndexRange } = extraData + if (inboxIndexRange[0] > inboxIndexRange[1]) return null + return + } + case "Lists": { + if (!extraData) return null + const { listsIndexRange } = extraData + if (listsIndexRange[0] > listsIndexRange[1]) return null + return + } + case "Feeds": { + if (!extraData) return null + const { feedsIndexRange } = extraData + if (feedsIndexRange[0] > feedsIndexRange[1]) return null + return + } + default: { + if (!extraData) return null + const { inboxIndexRange, feedsIndexRange, listsIndexRange } = extraData + + if (listsIndexRange[0] <= index && index <= listsIndexRange[1]) { + return ( + <> + + {index !== listsIndexRange[1] ? : ""} + + ) + } + + if (inboxIndexRange[0] <= index && index <= inboxIndexRange[1]) { + return ( + <> + + {index !== inboxIndexRange[1] ? : ""} + + ) + } + + if (feedsIndexRange[0] <= index && index <= feedsIndexRange[1]) { + return ( + <> + + {index !== feedsIndexRange[1] ? : ""} + + ) + } + + return null + } + } } + const { category, subscriptionIds } = item - return -} - -const ListHeaderComponent = () => { return ( <> - - Feeds + + {extraData && index !== extraData.feedsIndexRange[1] ? : ""} ) } +const SectionTitle = ({ title }: { title: string }) => { + return {title} +} + const StarItem = () => { return ( { const subscription = useSubscription(getInboxStoreId(id)) const unreadCount = useUnreadCount(id) @@ -20,8 +23,8 @@ export const InboxItem = memo(({ id }: { id: string }) => { { - selectTimeline({ type: "inbox", inboxId: id }) - closeDrawer() + selectFeed({ type: "inbox", inboxId: id }) + router.push(`/feeds/${id}`) }} > @@ -33,9 +36,7 @@ export const InboxItem = memo(({ id }: { id: string }) => { {subscription.title} - {!!unreadCount && ( - {unreadCount} - )} + ) diff --git a/apps/mobile/src/modules/subscription/items/ListSubscriptionItem.tsx b/apps/mobile/src/modules/subscription/items/ListSubscriptionItem.tsx index 33ac23607..815925475 100644 --- a/apps/mobile/src/modules/subscription/items/ListSubscriptionItem.tsx +++ b/apps/mobile/src/modules/subscription/items/ListSubscriptionItem.tsx @@ -1,18 +1,21 @@ +import { router } from "expo-router" import { memo } from "react" -import { Image, Text, View } from "react-native" +import { Text, View } from "react-native" import Animated, { FadeOutUp } from "react-native-reanimated" import { FallbackIcon } from "@/src/components/ui/icon/fallback-icon" +import { ProxiedImage } from "@/src/components/ui/image/ProxiedImage" import { ItemPressable } from "@/src/components/ui/pressable/ItemPressable" -import { closeDrawer, selectTimeline } from "@/src/modules/screen/atoms" import { useList } from "@/src/store/list/hooks" -import { useUnreadCount } from "@/src/store/unread/hooks" +import { useListUnreadCount } from "@/src/store/unread/hooks" import { SubscriptionListItemContextMenu } from "../../context-menu/lists" +import { selectFeed } from "../../screen/atoms" +import { UnreadCount } from "./UnreadCount" export const ListSubscriptionItem = memo(({ id }: { id: string; className?: string }) => { const list = useList(id) - const unreadCount = useUnreadCount(id) + const unreadCount = useListUnreadCount(id) if (!list) return null return ( @@ -20,22 +23,29 @@ export const ListSubscriptionItem = memo(({ id }: { id: string; className?: stri { - selectTimeline({ + selectFeed({ type: "list", listId: id, }) - closeDrawer() + router.push(`/feeds/${id}`) }} > {!!list.image && ( - + )} - {!list.image && } + {!list.image && } {list.title} - {!!unreadCount && } + diff --git a/apps/mobile/src/modules/subscription/items/SubscriptionItem.tsx b/apps/mobile/src/modules/subscription/items/SubscriptionItem.tsx index eff4f701b..85c8b2579 100644 --- a/apps/mobile/src/modules/subscription/items/SubscriptionItem.tsx +++ b/apps/mobile/src/modules/subscription/items/SubscriptionItem.tsx @@ -13,6 +13,7 @@ import { useUnreadCount } from "@/src/store/unread/hooks" import { SubscriptionFeedItemContextMenu } from "../../context-menu/feeds" import { GroupedContext } from "../ctx" +import { UnreadCount } from "./UnreadCount" // const renderRightActions = () => { // return ( @@ -104,9 +105,7 @@ export const SubscriptionItem = memo(({ id, className }: { id: string; className {subscription?.title || feed.title} - {!!unreadCount && ( - {unreadCount} - )} + diff --git a/apps/mobile/src/modules/subscription/items/UnreadCount.tsx b/apps/mobile/src/modules/subscription/items/UnreadCount.tsx new file mode 100644 index 000000000..c557bd7b7 --- /dev/null +++ b/apps/mobile/src/modules/subscription/items/UnreadCount.tsx @@ -0,0 +1,15 @@ +import { cn } from "@follow/utils" +import { Text, View } from "react-native" + +import { useUISettingKey } from "@/src/atoms/settings/ui" + +export function UnreadCount({ unread, className }: { unread?: number; className?: string }) { + const showUnreadCount = useUISettingKey("subscriptionShowUnreadCount") + + if (!unread) return null + return showUnreadCount ? ( + {unread} + ) : ( + + ) +} diff --git a/apps/mobile/src/morph/hono.ts b/apps/mobile/src/morph/hono.ts index c507f2d0d..74a3ce624 100644 --- a/apps/mobile/src/morph/hono.ts +++ b/apps/mobile/src/morph/hono.ts @@ -77,7 +77,6 @@ class Morph { ownerUserId: list.owner.id, feedIds: list.feedIds!, fee: list.fee!, - entryIds: [], }) } @@ -97,18 +96,17 @@ class Morph { ownerUserId: data.ownerUserId!, feedIds: data.feedIds!, fee: data.fee!, - entryIds: [], } } - toEntryList(data?: HonoApiClient.Entry_Post): EntryModel[] { + toEntryList(data?: HonoApiClient.Entry_Post | HonoApiClient.Entry_Inbox_Post): EntryModel[] { const entries: EntryModel[] = [] for (const item of data ?? []) { entries.push({ id: item.entries.id, title: item.entries.title, url: item.entries.url, - content: "", + content: null, description: item.entries.description, guid: item.entries.guid, author: item.entries.author, @@ -126,16 +124,18 @@ class Morph { : null, language: item.entries.language, feedId: item.feeds.id, - // TODO: handle inboxHandle - inboxHandle: "", + inboxHandle: item.feeds.type === "inbox" ? item.feeds.id : null, read: item.read, - sources: item.from ?? null, + sources: "from" in item ? (item.from ?? null) : null, }) } return entries } - toCollections(data: HonoApiClient.Entry_Post, view: FeedViewType): CollectionModel[] { + toCollections( + data: HonoApiClient.Entry_Post | HonoApiClient.Entry_Inbox_Post, + view: FeedViewType, + ): CollectionModel[] { if (!data) return [] satisfies CollectionModel[] return data .map((item) => { @@ -152,7 +152,7 @@ class Morph { .filter((i) => i !== null) } - toEntry(data?: HonoApiClient.Entry_Get): EntryModel | null { + toEntry(data?: HonoApiClient.Entry_Get | HonoApiClient.Entry_Inbox_Get): EntryModel | null { if (!data) return null return { diff --git a/apps/mobile/src/morph/types.ts b/apps/mobile/src/morph/types.ts index 0930652a7..b172a2d8b 100644 --- a/apps/mobile/src/morph/types.ts +++ b/apps/mobile/src/morph/types.ts @@ -9,7 +9,9 @@ export namespace HonoApiClient { export type Subscription_Get = ExtractData export type List_Get = ExtractData export type Entry_Post = ExtractData + export type Entry_Inbox_Post = ExtractData export type Entry_Get = ExtractData + export type Entry_Inbox_Get = ExtractData export type List_List_Get = ExtractData[number] export type Feed_Get = ExtractData } diff --git a/apps/mobile/src/screens/(stack)/feeds/[feedId]/index.tsx b/apps/mobile/src/screens/(stack)/feeds/[feedId]/index.tsx index 17e681cac..243176ec6 100644 --- a/apps/mobile/src/screens/(stack)/feeds/[feedId]/index.tsx +++ b/apps/mobile/src/screens/(stack)/feeds/[feedId]/index.tsx @@ -8,23 +8,36 @@ import { EntryListSelector } from "@/src/modules/entry-list/EntryListSelector" import { EntryListContext, useSelectedView } from "@/src/modules/screen/atoms" import { TimelineSelectorProvider } from "@/src/modules/screen/TimelineSelectorProvider" import { useCollectionEntryList } from "@/src/store/collection/hooks" -import { useEntryIdsByCategory, useEntryIdsByFeedId } from "@/src/store/entry/hooks" +import { + useEntryIdsByCategory, + useEntryIdsByFeedId, + useEntryIdsByInboxId, + useEntryIdsByListId, +} from "@/src/store/entry/hooks" import { FEED_COLLECTION_LIST } from "@/src/store/entry/utils" export default function Feed() { const insets = useSafeAreaInsets() - const { feedId: feedIdOrCategory } = useLocalSearchParams() - const entryIdsByFeedId = useEntryIdsByFeedId(feedIdOrCategory as string) - const entryIdsByCategory = useEntryIdsByCategory(feedIdOrCategory as string) - const isCollection = feedIdOrCategory === FEED_COLLECTION_LIST + const { feedId } = useLocalSearchParams() + const feedIdentifier = feedId as string + + const isCollection = feedIdentifier === FEED_COLLECTION_LIST const view = useSelectedView() ?? FeedViewType.Articles const collectionEntryIds = useCollectionEntryList(view) + const entryIdsByFeedId = useEntryIdsByFeedId(feedIdentifier) + const entryIdsByCategory = useEntryIdsByCategory(feedIdentifier) + const entryIdsByListId = useEntryIdsByListId(feedIdentifier) + const entryIdsByInboxId = useEntryIdsByInboxId(feedIdentifier) + const entryIds = isCollection ? collectionEntryIds - : entryIdsByFeedId.length > 0 - ? entryIdsByFeedId - : entryIdsByCategory + : getEntryIdsFromMultiplePlace( + entryIdsByFeedId, + entryIdsByCategory, + entryIdsByListId, + entryIdsByInboxId, + ) return ( ({ type: "feed" }), [])}> @@ -36,3 +49,7 @@ export default function Feed() { ) } + +function getEntryIdsFromMultiplePlace(...entryIds: Array) { + return entryIds.find((ids) => ids?.length) ?? [] +} diff --git a/apps/mobile/src/services/list.ts b/apps/mobile/src/services/list.ts index 9f1b72901..98c7c1ed4 100644 --- a/apps/mobile/src/services/list.ts +++ b/apps/mobile/src/services/list.ts @@ -30,20 +30,6 @@ class ListServiceStatic implements Hydratable, Resetable { set: conflictUpdateAllExcept(listsTable, ["id"]), }) } - async addEntryIds(params: { listId: string; entryIds: string[] }) { - const list = await db.query.listsTable.findFirst({ - where: eq(listsTable.id, params.listId), - }) - - if (!list) return - - await db - .update(listsTable) - .set({ - entryIds: [...new Set([...(list.entryIds ?? []), ...params.entryIds])], - }) - .where(eq(listsTable.id, params.listId)) - } async deleteList(params: { listId: string }) { await db.delete(listsTable).where(eq(listsTable.id, params.listId)) diff --git a/apps/mobile/src/store/entry/hooks.ts b/apps/mobile/src/store/entry/hooks.ts index 7eaa62032..078223633 100644 --- a/apps/mobile/src/store/entry/hooks.ts +++ b/apps/mobile/src/store/entry/hooks.ts @@ -15,10 +15,7 @@ export const usePrefetchEntries = (props: Omit entrySyncServices.fetchEntries({ ...props, pageParam, read: unreadOnly ? false : undefined }), - getNextPageParam: (lastPage) => - listId - ? lastPage.data?.at(-1)?.entries.insertedAt - : lastPage.data?.at(-1)?.entries.publishedAt, + getNextPageParam: (lastPage) => lastPage.data?.at(-1)?.entries.publishedAt, initialPageParam: undefined as undefined | string, refetchOnWindowFocus: false, refetchOnReconnect: false, @@ -105,6 +102,19 @@ export const useEntryIdsByCategory = (category: string) => { ) } +export const useEntryIdsByListId = (listId: string) => { + return useEntryStore( + useCallback( + (state) => { + const ids = state.entryIdByList[listId] + if (!ids) return [] + return Array.from(ids).sort((a, b) => sortEntryIdsByPublishDate(a, b)) + }, + [listId], + ), + ) +} + export const useFetchEntryContentByStream = (remoteEntryIds?: string[]) => { const { mutate: updateEntryContent } = useMutation({ mutationKey: ["stream-entry-content", remoteEntryIds], diff --git a/apps/mobile/src/store/entry/store.ts b/apps/mobile/src/store/entry/store.ts index 2b45adb92..71dd21419 100644 --- a/apps/mobile/src/store/entry/store.ts +++ b/apps/mobile/src/store/entry/store.ts @@ -11,7 +11,6 @@ import { EntryService } from "@/src/services/entry" import { collectionActions } from "../collection/store" import { feedActions } from "../feed/store" 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" @@ -21,6 +20,7 @@ type EntryId = string type FeedId = string type InboxId = string type Category = string +type ListId = string interface EntryState { data: Record @@ -28,6 +28,7 @@ interface EntryState { entryIdByCategory: Record> entryIdByFeed: Record> entryIdByInbox: Record> + entryIdByList: Record> entryIdSet: Set } @@ -44,6 +45,7 @@ const defaultState: EntryState = { entryIdByCategory: {}, entryIdByFeed: {}, entryIdByInbox: {}, + entryIdByList: {}, entryIdSet: new Set(), } @@ -136,6 +138,24 @@ class EntryActions { } } + private addEntryIdToList({ + draft, + listId, + entryId, + }: { + draft: EntryState + listId?: ListId | null + entryId: EntryId + }) { + if (!listId) return + const entryIdSetByList = draft.entryIdByList[listId] + if (!entryIdSetByList) { + draft.entryIdByList[listId] = new Set([entryId]) + } else { + entryIdSetByList.add(entryId) + } + } + upsertManyInSession(entries: EntryModel[], options?: { unreadOnly?: boolean }) { if (entries.length === 0) return const { unreadOnly } = options ?? {} @@ -148,11 +168,19 @@ class EntryActions { const { feedId, inboxHandle, read, sources } = entry if (unreadOnly && read) continue - this.addEntryIdToFeed({ - draft, - feedId, - entryId: entry.id, - }) + if (inboxHandle) { + this.addEntryIdToInbox({ + draft, + inboxHandle, + entryId: entry.id, + }) + } else { + this.addEntryIdToFeed({ + draft, + feedId, + entryId: entry.id, + }) + } this.addEntryIdToView({ draft, @@ -161,17 +189,21 @@ class EntryActions { sources, }) - this.addEntryIdToInbox({ - draft, - inboxHandle, - entryId: entry.id, - }) - this.addEntryIdToCategory({ draft, feedId, entryId: entry.id, }) + + entry.sources + ?.filter((s) => s !== "feed") + .forEach((s) => { + this.addEntryIdToList({ + draft, + listId: s, + entryId: entry.id, + }) + }) } }) } @@ -264,6 +296,20 @@ class EntryActions { draft.entryIdByFeed[feedId] = new Set(entries.map((e) => e.id)) }) } + + resetByInbox({ inboxId, entries }: { inboxId?: InboxId; entries: EntryModel[] }) { + if (!inboxId) return + immerSet((draft) => { + draft.entryIdByInbox[inboxId] = new Set(entries.map((e) => e.id)) + }) + } + + resetByList({ listId, entries }: { listId?: ListId; entries: EntryModel[] }) { + if (!listId) return + immerSet((draft) => { + draft.entryIdByList[listId] = new Set(entries.map((e) => e.id)) + }) + } } class EntrySyncServices { @@ -275,15 +321,26 @@ class EntrySyncServices { listId, view, }) - const res = await apiClient.entries.$post({ - json: { - publishedAfter: pageParam, - read, - limit, - isCollection, - ...params, - }, - }) + const res = params.inboxId + ? await apiClient.entries.inbox.$post({ + json: { + publishedAfter: pageParam, + read, + limit, + isCollection, + inboxId: params.inboxId, + ...params, + }, + }) + : await apiClient.entries.$post({ + json: { + publishedAfter: pageParam, + read, + limit, + isCollection, + ...params, + }, + }) const entries = honoMorph.toEntryList(res.data) const entriesInDB = await EntryService.getEntryMany(entries.map((e) => e.id)) @@ -295,12 +352,6 @@ class EntrySyncServices { } await entryActions.upsertMany(entries) - if (params.listId) { - await listActions.addEntryIds({ - listId: params.listId, - entryIds: entries.map((e) => e.id), - }) - } // After initial fetch, we can reset the state to prefer the entries data from the server if (!pageParam) { @@ -318,6 +369,14 @@ class EntrySyncServices { if (params.feedId) { entryActions.resetByFeed({ feedId: params.feedId, entries }) } + + if (params.inboxId) { + entryActions.resetByInbox({ inboxId: params.inboxId, entries }) + } + + if (params.listId) { + entryActions.resetByList({ listId: params.listId, entries }) + } } if (isCollection && res.data) { @@ -328,13 +387,21 @@ class EntrySyncServices { await collectionActions.upsertMany(collections) } - await feedActions.upsertMany(res.data?.map((e) => honoMorph.toFeed(e.feeds)) || []) + const feeds = + res.data + ?.map((e) => e.feeds) + .filter((f) => f.type === "feed") + .map((f) => honoMorph.toFeed(f)) ?? [] + feedActions.upsertMany(feeds) return res } async fetchEntryContent(entryId: EntryId) { - const res = await apiClient.entries.$get({ query: { id: entryId } }) + const currentEntry = getEntry(entryId) + const res = currentEntry?.inboxHandle + ? await apiClient.entries.inbox.$get({ query: { id: entryId } }) + : await apiClient.entries.$get({ query: { id: entryId } }) const entry = honoMorph.toEntry(res.data) if (!entry) return null if (entry.content && getEntry(entryId)?.content !== entry.content) { diff --git a/apps/mobile/src/store/entry/utils.ts b/apps/mobile/src/store/entry/utils.ts index a4fefeea5..1776339e6 100644 --- a/apps/mobile/src/store/entry/utils.ts +++ b/apps/mobile/src/store/entry/utils.ts @@ -1,5 +1,7 @@ import { FeedViewType } from "@follow/constants" +import type { EntryModel } from "./types" + /// Feed export const FEED_COLLECTION_LIST = "collections" @@ -54,3 +56,13 @@ export function getEntriesParams({ ...params, } } + +export function getInboxFrom(entry?: EntryModel) { + if (isInboxEntry(entry)) { + return entry.authorUrl?.replace("mailto:", "") + } +} + +export function isInboxEntry(entry?: EntryModel): entry is EntryModel { + return !!entry?.inboxHandle +} diff --git a/apps/mobile/src/store/list/getters.ts b/apps/mobile/src/store/list/getters.ts index 47a0f7a7d..15fe9bec6 100644 --- a/apps/mobile/src/store/list/getters.ts +++ b/apps/mobile/src/store/list/getters.ts @@ -1,7 +1,10 @@ -import type { ListModel } from "./store" import { useListStore } from "./store" const get = () => useListStore.getState() export const getList = (id: string) => { - return get().lists[id] as ListModel | undefined + return get().lists[id] +} + +export const getListFeedIds = (id: string) => { + return get().lists[id]?.feedIds } diff --git a/apps/mobile/src/store/list/hooks.ts b/apps/mobile/src/store/list/hooks.ts index 4b75951da..4a4967d40 100644 --- a/apps/mobile/src/store/list/hooks.ts +++ b/apps/mobile/src/store/list/hooks.ts @@ -10,15 +10,15 @@ export const useList = (id: string) => { }) } -export const useIsOwnList = (id: string) => { +export const useListFeedIds = (id: string) => { return useListStore((state) => { - return state.lists[id]?.userId === whoami()?.id + return state.lists[id]?.feedIds }) } -export const useListEntryIds = (id: string) => { +export const useIsOwnList = (id: string) => { return useListStore((state) => { - return state.lists[id]?.entryIds + return state.lists[id]?.userId === whoami()?.id }) } diff --git a/apps/mobile/src/store/list/store.ts b/apps/mobile/src/store/list/store.ts index e993d4668..a0922b40d 100644 --- a/apps/mobile/src/store/list/store.ts +++ b/apps/mobile/src/store/list/store.ts @@ -50,26 +50,6 @@ class ListActions { tx.run() } - addEntryIdsInSession(params: { listId: string; entryIds: string[] }) { - immerSet((draft) => { - const list = draft.lists[params.listId] - if (!list) return - list.entryIds = [...new Set([...(list.entryIds ?? []), ...params.entryIds])] - }) - } - - async addEntryIds(params: { listId: string; entryIds: string[] }) { - const tx = createTransaction() - tx.store(() => { - this.addEntryIdsInSession(params) - }) - - tx.persist(() => { - return ListService.addEntryIds(params) - }) - await tx.run() - } - deleteList(params: { listId: string }) { const tx = createTransaction() tx.store(() => { diff --git a/apps/mobile/src/store/subscription/getter.ts b/apps/mobile/src/store/subscription/getter.ts index 6dd0bf271..18d7e9e14 100644 --- a/apps/mobile/src/store/subscription/getter.ts +++ b/apps/mobile/src/store/subscription/getter.ts @@ -1,5 +1,6 @@ import type { FeedViewType } from "@follow/constants" +import { getListFeedIds } from "../list/getters" import type { SubscriptionModel } from "./store" import { useSubscriptionStore } from "./store" @@ -13,7 +14,7 @@ export const getSubscriptionByView = (view: FeedViewType): string[] => { const state = get() return Array.from(state.feedIdByView[view]) .concat(Array.from(state.inboxIdByView[view])) - .concat(Array.from(state.listIdByView[view])) + .concat(Array.from(state.listIdByView[view]).flatMap((id) => getListFeedIds(id) ?? [])) } export const getFeedSubscriptionByView = (view: FeedViewType): string[] => { diff --git a/apps/mobile/src/store/subscription/store.ts b/apps/mobile/src/store/subscription/store.ts index ade1a578f..fb7cc5b4d 100644 --- a/apps/mobile/src/store/subscription/store.ts +++ b/apps/mobile/src/store/subscription/store.ts @@ -230,7 +230,6 @@ class SubscriptionSyncService { { ...data.list, userId: data.list.ownerUserId, - entryIds: [], }, ]) } diff --git a/apps/mobile/src/store/unread/hooks.ts b/apps/mobile/src/store/unread/hooks.ts index ae4e50ddb..bf222192c 100644 --- a/apps/mobile/src/store/unread/hooks.ts +++ b/apps/mobile/src/store/unread/hooks.ts @@ -2,7 +2,8 @@ import type { FeedViewType } from "@follow/constants" import { useMutation, useQuery } from "@tanstack/react-query" import { useCallback, useEffect } from "react" -import { useFeedSubscriptionByView } from "../subscription/hooks" +import { useListFeedIds } from "../list/hooks" +import { useSubscriptionByView } from "../subscription/hooks" import { unreadSyncService, useUnreadStore } from "./store" export const usePrefetchUnread = () => { @@ -26,6 +27,11 @@ export const useUnreadCount = (subscriptionId: string) => { return useUnreadStore((state) => state.data[subscriptionId]) } +export const useListUnreadCount = (listId: string) => { + const feedIds = useListFeedIds(listId) + return useUnreadCounts(feedIds ?? []) +} + export const useUnreadCounts = (subscriptionIds: string[]): number => { return useUnreadStore( useCallback( @@ -42,17 +48,6 @@ export const useUnreadCounts = (subscriptionIds: string[]): number => { } export const useUnreadCountByView = (view: FeedViewType) => { - const subscriptionIds = useFeedSubscriptionByView(view) - return useUnreadStore( - useCallback( - (state) => { - let count = 0 - for (const subscriptionId of subscriptionIds) { - count += state.data[subscriptionId] ?? 0 - } - return count - }, - [subscriptionIds], - ), - ) + const subscriptionIds = useSubscriptionByView(view) + return useUnreadCounts(subscriptionIds) }