diff --git a/eslint.config.mjs b/eslint.config.mjs index cabac8859..fa04862ad 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -20,6 +20,7 @@ export default defineConfig( "no-console": "warn", "unicorn/consistent-function-scoping": "warn", "unicorn/prefer-module": "off", + "@typescript-eslint/no-floating-promises": "off", }, }, { diff --git a/package.json b/package.json index d11bb8d58..0b272b02d 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "electron-updater": "^6.1.7", "framer-motion": "11.2.9", "hast-util-to-jsx-runtime": "2.3.0", + "idb-keyval": "6.2.1", "immer": "10.1.1", "jotai": "2.8.2", "jotai-dark": "0.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40a96b9cb..24317b402 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -107,6 +107,9 @@ importers: hast-util-to-jsx-runtime: specifier: 2.3.0 version: 2.3.0 + idb-keyval: + specifier: 6.2.1 + version: 6.2.1 immer: specifier: 10.1.1 version: 10.1.1 @@ -3393,6 +3396,9 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + idb-keyval@6.2.1: + resolution: {integrity: sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg==} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -9052,6 +9058,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + idb-keyval@6.2.1: {} + ieee754@1.2.1: {} ignore@5.3.1: {} diff --git a/src/renderer/src/components/discover/form.tsx b/src/renderer/src/components/discover/form.tsx index 55ab152d9..edae30274 100644 --- a/src/renderer/src/components/discover/form.tsx +++ b/src/renderer/src/components/discover/form.tsx @@ -16,7 +16,7 @@ import { } from "@renderer/components/ui/form" import { Image } from "@renderer/components/ui/image" import { Input } from "@renderer/components/ui/input" -import type { DiscoverResponse } from "@renderer/lib/types" +import type { DiscoverResponse } from "@renderer/models" import { apiFetch } from "@renderer/queries/api-fetch" import { DEEPLINK_SCHEME } from "@shared/constants" import { openElectronWindow } from "@shared/electron" diff --git a/src/renderer/src/components/discover/import.tsx b/src/renderer/src/components/discover/import.tsx index 941afc4a1..f859e27f8 100644 --- a/src/renderer/src/components/discover/import.tsx +++ b/src/renderer/src/components/discover/import.tsx @@ -10,8 +10,8 @@ import { FormMessage, } from "@renderer/components/ui/form" import { Input } from "@renderer/components/ui/input" -import type { FeedResponse } from "@renderer/lib/types" import { cn } from "@renderer/lib/utils" +import type { FeedResponse } from "@renderer/models" import { Queries } from "@renderer/queries" import { apiFetch } from "@renderer/queries/api-fetch" import { useMutation } from "@tanstack/react-query" diff --git a/src/renderer/src/components/entry-column/index.tsx b/src/renderer/src/components/entry-column/index.tsx index 6246a6dd6..1b2593a05 100644 --- a/src/renderer/src/components/entry-column/index.tsx +++ b/src/renderer/src/components/entry-column/index.tsx @@ -8,8 +8,8 @@ import { import { views } from "@renderer/lib/constants" import { FeedViewType } from "@renderer/lib/enum" import { buildStorageNS } from "@renderer/lib/ns" -import type { EntryModel } from "@renderer/lib/types" import { cn, getEntriesParams } from "@renderer/lib/utils" +import type { EntryModel } from "@renderer/models" import { apiClient } from "@renderer/queries/api-fetch" import { useEntries } from "@renderer/queries/entries" import { feedActions, useFeedStore } from "@renderer/store" @@ -88,7 +88,7 @@ export function EntryColumn() { if (!idSlice) return - const batchLikeIds = [] as string[] + const batchLikeIds = [] as [string, string][] const entriesId2Map = entryActions.getFlattenMapEntries() for (const id of idSlice) { const entry = entriesId2Map[id] @@ -96,15 +96,16 @@ export function EntryColumn() { if (!entry) continue const isRead = entry.read if (!isRead) { - batchLikeIds.push(id) + batchLikeIds.push([entry.feeds.id, id]) } } if (batchLikeIds.length > 0) { - await apiClient.reads.$post({ json: { entryIds: batchLikeIds } }) + const entryIds = batchLikeIds.map(([, id]) => id) + await apiClient.reads.$post({ json: { entryIds } }) - for (const id of batchLikeIds) { - entryActions.optimisticUpdate(id, { read: true }) + for (const [feedId, id] of batchLikeIds) { + entryActions.markRead(feedId, id, true) } } }, @@ -147,16 +148,14 @@ export function EntryColumn() { onClick={() => setActiveEntry?.(null)} > - {activeList?.view && views[activeList.view].gridMode ? - ( - - ) : - ( - - )} + {activeList?.view && views[activeList.view].gridMode ? ( + + ) : ( + + )} ) } @@ -197,9 +196,9 @@ const ListHeader: FC = () => { return (
-
{activeList?.name}
+
{activeList?.name}
- {entries.data?.pages?.[0].total} + {entries.data?.pages?.[0].total || 0} {" "} {unreadOnly ? "Unread" : ""} {" "} @@ -221,13 +220,11 @@ const ListHeader: FC = () => { onClick={() => setUnreadOnly(!unreadOnly)} active={unreadOnly} > - {unreadOnly ? - ( - - ) : - ( - - )} + {unreadOnly ? ( + + ) : ( + + )} diff --git a/src/renderer/src/components/entry-column/item-wrapper.tsx b/src/renderer/src/components/entry-column/item-wrapper.tsx index 3f78b54d5..627c663ad 100644 --- a/src/renderer/src/components/entry-column/item-wrapper.tsx +++ b/src/renderer/src/components/entry-column/item-wrapper.tsx @@ -34,9 +34,7 @@ export function EntryItemWrapper({ }, }), onMutate: () => { - entryActions.optimisticUpdate(entry.entries.id, { - read: true, - }) + entryActions.markRead(entry.feeds.id, entry.entries.id, true) }, // TODO fallback }) diff --git a/src/renderer/src/components/entry-column/types.ts b/src/renderer/src/components/entry-column/types.ts index f918ceba3..2a19a362e 100644 --- a/src/renderer/src/components/entry-column/types.ts +++ b/src/renderer/src/components/entry-column/types.ts @@ -1,4 +1,4 @@ -import type { EntryModel, FeedModel } from "@renderer/lib/types" +import type { EntryModel, FeedModel } from "@renderer/models" export type UniversalItemProps = { entryId: string diff --git a/src/renderer/src/components/entry-content/index.tsx b/src/renderer/src/components/entry-content/index.tsx index b3c1c156d..19638f20b 100644 --- a/src/renderer/src/components/entry-content/index.tsx +++ b/src/renderer/src/components/entry-content/index.tsx @@ -1,6 +1,6 @@ import { useBizQuery } from "@renderer/hooks/useBizQuery" import { parseHtml } from "@renderer/lib/parse-html" -import type { ActiveEntry } from "@renderer/lib/types" +import type { ActiveEntry } from "@renderer/models" import { Queries } from "@renderer/queries" import { useFeedStore } from "@renderer/store" import { m } from "framer-motion" @@ -9,7 +9,9 @@ import { useEffect, useState } from "react" import { EntryShare } from "./share" export function EntryContent({ entryId }: { entryId: ActiveEntry }) { - const entry = useBizQuery(Queries.entries.byId(entryId)) + const entry = useBizQuery(Queries.entries.byId(entryId), { + enabled: !!entryId, + }) const activeList = useFeedStore((state) => state.activeList) const [content, setContent] = useState() diff --git a/src/renderer/src/components/feed-column/category.tsx b/src/renderer/src/components/feed-column/category.tsx index 39792c8db..0af56ce91 100644 --- a/src/renderer/src/components/feed-column/category.tsx +++ b/src/renderer/src/components/feed-column/category.tsx @@ -7,10 +7,14 @@ import { client } from "@renderer/lib/client" import { levels } from "@renderer/lib/constants" import { showNativeMenu } from "@renderer/lib/native-menu" import { cn } from "@renderer/lib/utils" +import type { FeedListModel } from "@renderer/models" import { Queries } from "@renderer/queries" import { apiFetch } from "@renderer/queries/api-fetch" -import type { Response as SubscriptionsResponse } from "@renderer/queries/subscriptions" -import { feedActions, useFeedActiveList } from "@renderer/store" +import { + feedActions, + useFeedActiveList, + useUnreadStore, +} from "@renderer/store" import { useMutation } from "@tanstack/react-query" import { AnimatePresence, m } from "framer-motion" import { useEffect, useState } from "react" @@ -25,7 +29,7 @@ export function FeedCategory({ view, expansion, }: { - data: SubscriptionsResponse["list"][number] + data: FeedListModel["list"][number] view?: number expansion: boolean }) { @@ -56,15 +60,21 @@ export function FeedCategory({ }, [expansion]) const setCategoryActive = () => { - view !== undefined && - setActiveList?.({ - level: levels.folder, - id: data.list.map((feed) => feed.feedId).join(","), - name: data.name, - view, - }) + if (view !== undefined) { + setActiveList({ + level: levels.folder, + id: data.list.map((feed) => feed.feedId).join(","), + name: data.name, + view, + }) + } } + const unread = useUnreadStore((state) => + data.list.reduce((acc, cur) => state.data[cur.feedId] + acc, 0), + ) + + const sortByUnreadFeedList = useUnreadStore((state) => data.list.sort((a, b) => (state.data[b.feedId] || 0) - (state.data[a.feedId] || 0))) return ( { - if ( - await client?.showConfirmDialog({ - title: `Delete category ${data.name}?`, - message: `This operation will delete your category, but the feeds it contains will be retained and grouped by website.`, - options: { - buttons: ["Delete", "Cancel"], - }, - }) - ) { + const result = await client?.showConfirmDialog({ + title: `Delete category ${data.name}?`, + message: `This operation will delete your category, but the feeds it contains will be retained and grouped by website.`, + options: { + buttons: ["Delete", "Cancel"], + }, + }) + if (result) { deleteMutation.mutate() } }, @@ -126,10 +136,10 @@ export function FeedCategory({ {data.name} )} - {setActiveList && {data.name}} + {!!setActiveList && {data.name}}
- {!!data.unread && ( -
{data.unread}
+ {!!unread && ( +
{unread}
)} - {data.list.map((feed) => ( + {sortByUnreadFeedList.map((feed) => ( - setActiveList?.({ + setActiveList({ level: levels.view, id: active, name: views[active].name, @@ -72,7 +72,7 @@ export function FeedColumn() { className="flex items-center gap-1 text-xl font-bold" onClick={(e) => { e.stopPropagation() - setActiveList?.({ + setActiveList({ level: levels.view, id: active, name: views[active].name, diff --git a/src/renderer/src/components/feed-column/item.tsx b/src/renderer/src/components/feed-column/item.tsx index ba985a467..8ff4fd6f6 100644 --- a/src/renderer/src/components/feed-column/item.tsx +++ b/src/renderer/src/components/feed-column/item.tsx @@ -11,11 +11,15 @@ import { useToast } from "@renderer/components/ui/use-toast" import { levels } from "@renderer/lib/constants" import dayjs from "@renderer/lib/dayjs" import { showNativeMenu } from "@renderer/lib/native-menu" -import type { SubscriptionResponse } from "@renderer/lib/types" import { cn } from "@renderer/lib/utils" +import type { SubscriptionResponse } from "@renderer/models" import { Queries } from "@renderer/queries" import { apiFetch } from "@renderer/queries/api-fetch" -import { feedActions, useFeedActiveList } from "@renderer/store" +import { + feedActions, + useFeedActiveList, + useUnreadStore, +} from "@renderer/store" import { useMutation } from "@tanstack/react-query" export function FeedItem({ @@ -31,8 +35,9 @@ export function FeedItem({ const { setActiveList } = feedActions const setFeedActive = (feed: SubscriptionResponse[number]) => { - view !== undefined && - setActiveList?.({ + if (view === undefined) return + + setActiveList({ level: levels.feed, id: feed.feedId, name: feed.feeds.title || "", @@ -88,6 +93,7 @@ export function FeedItem({ }, }) + const feedUnread = useUnreadStore((state) => state.data[feed.feedId] || 0) return (
window.open( - `${import.meta.env.VITE_WEB_URL}/feed/${feed.feedId}?view=${view}`, + `${import.meta.env.VITE_WEB_URL}/feed/${ + feed.feedId + }?view=${view}`, "_blank", ), }, @@ -136,8 +144,7 @@ export function FeedItem({ type: "text", label: "Open Site in Browser", click: () => - feed.feeds.siteUrl && - window.open(feed.feeds.siteUrl, "_blank"), + feed.feeds.siteUrl && window.open(feed.feeds.siteUrl, "_blank"), }, ], e, @@ -188,8 +195,8 @@ export function FeedItem({ )}
- {!!feed.unread && ( -
{feed.unread}
+ {!!feedUnread && ( +
{feedUnread}
)}
) diff --git a/src/renderer/src/components/feed-column/list.tsx b/src/renderer/src/components/feed-column/list.tsx index 9a8e01901..4d5781c11 100644 --- a/src/renderer/src/components/feed-column/list.tsx +++ b/src/renderer/src/components/feed-column/list.tsx @@ -1,13 +1,83 @@ import { useBizQuery } from "@renderer/hooks/useBizQuery" import { levels, views } from "@renderer/lib/constants" +import type { FeedViewType } from "@renderer/lib/enum" import { cn } from "@renderer/lib/utils" +import type { FeedListModel, SubscriptionResponse } from "@renderer/models" import { Queries } from "@renderer/queries" -import { feedActions } from "@renderer/store" -import { useState } from "react" +import { feedActions, useUnreadStore } from "@renderer/store" +import { useMemo, useState } from "react" import { Link } from "react-router-dom" +import { parse } from "tldts" import { FeedCategory } from "./category" +const useData = (view?: FeedViewType) => { + const subscriptions = useBizQuery(Queries.subscription.byView(view)) + + // TODO Refactor this into category store + return useMemo(() => { + if (!subscriptions.data) return null + const categories = { + list: {}, + } as { + list: Record< + string, + { + list: SubscriptionResponse + } + > + } + const domains: Record = {} + const data = structuredClone(subscriptions.data) + + if (subscriptions) { + for (const subscription of data.subscriptions) { + if (!subscription.category && subscription.feeds.siteUrl) { + const { domain } = parse(subscription.feeds.siteUrl) + if (domain) { + if (!domains[domain]) { + domains[domain] = 0 + } + domains[domain]++ + } + } + } + } + if (subscriptions) { + for (const subscription of data.subscriptions) { + if (!subscription.category) { + if (subscription.feeds.siteUrl) { + const { domain } = parse(subscription.feeds.siteUrl) + if (domain && domains[domain] > 1) { + subscription.category = + domain.slice(0, 1).toUpperCase() + domain.slice(1) + } + } + if (!subscription.category) { + subscription.category = "" + } + } + if (!categories.list[subscription.category]) { + categories.list[subscription.category] = { + list: [], + } + } + + const unread = data.unreads[subscription.feedId] || 0 + subscription.unread = unread + categories.list[subscription.category].list.push(subscription) + } + } + const list = Object.entries(categories.list).map(([name, list]) => ({ + name, + list: list.list, + })) + + return { + list, + } as FeedListModel + }, [subscriptions]) +} export function FeedList({ className, view, @@ -17,10 +87,29 @@ export function FeedList({ view?: number hideTitle?: boolean }) { - const subscriptions = useBizQuery(Queries.subscription.byView(view)) const [expansion, setExpansion] = useState(false) + const data = useData(view) + + const totalUnread = useUnreadStore((state) => { + let unread = 0 + data?.list.forEach((a) => { + a.list.forEach((b) => { + unread += state.data[b.feedId] || 0 + }) + }) + return unread + }) + const { setActiveList } = feedActions + const sortedByUnread = useUnreadStore((state) => + data?.list?.sort( + (a, b) => + b.list.reduce((acc, cur) => (state.data[cur.feedId] || 0) + acc, 0) - + a.list.reduce((acc, cur) => (state.data[cur.feedId] || 0) + acc, 0), + ), + ) + return (
{!hideTitle && ( @@ -31,52 +120,54 @@ export function FeedList({ className="font-bold" onClick={(e) => { e.stopPropagation() - view !== undefined && - setActiveList?.({ - level: levels.view, - id: view, - name: views[view].name, - view, - }) + if (view !== undefined) { + setActiveList({ + level: levels.view, + id: view, + name: views[view].name, + view, + }) + } }} > {view !== undefined && views[view].name}
- {expansion ? - ( - setExpansion(false)} - /> - ) : - ( - setExpansion(true)} - /> - )} - {subscriptions.data?.unread} + {expansion ? ( + setExpansion(false)} + /> + ) : ( + setExpansion(true)} + /> + )} + {totalUnread}
)} - {subscriptions.data?.list?.length ? - subscriptions.data?.list.map((category) => ( + {data?.list?.length ? ( + sortedByUnread?.map((category) => ( - )) : - ( -
- - - Add some feeds - -
- )} + )) + ) : ( +
+ + + Add some feeds + +
+ )} ) } diff --git a/src/renderer/src/components/feed-icon.tsx b/src/renderer/src/components/feed-icon.tsx index c1d209f69..611a565f1 100644 --- a/src/renderer/src/components/feed-icon.tsx +++ b/src/renderer/src/components/feed-icon.tsx @@ -1,7 +1,7 @@ import { SiteIcon } from "@renderer/components/site-icon" import { Image } from "@renderer/components/ui/image" -import type { FeedModel } from "@renderer/lib/types" import { cn } from "@renderer/lib/utils" +import type { FeedModel } from "@renderer/models" export function FeedIcon({ feed, diff --git a/src/renderer/src/components/feed-summary.tsx b/src/renderer/src/components/feed-summary.tsx index 897050b08..99439bfb8 100644 --- a/src/renderer/src/components/feed-summary.tsx +++ b/src/renderer/src/components/feed-summary.tsx @@ -1,6 +1,6 @@ import { FeedIcon } from "@renderer/components/feed-icon" -import type { FeedModel } from "@renderer/lib/types" import { cn } from "@renderer/lib/utils" +import type { FeedModel } from "@renderer/models" export function FollowSummary({ feed, diff --git a/src/renderer/src/components/ui/autocomplete.tsx b/src/renderer/src/components/ui/autocomplete.tsx index e52b81f2d..a740f81a9 100644 --- a/src/renderer/src/components/ui/autocomplete.tsx +++ b/src/renderer/src/components/ui/autocomplete.tsx @@ -60,29 +60,31 @@ export const AutoComplete = React.forwardRef( }} ref={ref} /> - - - + {options.length > 0 && ( + + + + )} ) }, diff --git a/src/renderer/src/hono.ts b/src/renderer/src/hono.ts index d9241a9f0..a049068c6 100644 --- a/src/renderer/src/hono.ts +++ b/src/renderer/src/hono.ts @@ -36,10 +36,7 @@ declare const routes: hono_hono_base.HonoBase; }; outputFormat: "json"; status: 200; @@ -451,7 +448,7 @@ declare const routes: hono_hono_base.HonoBase { if (!entry) return - entryActions.optimisticUpdate(entry.entries.id, { - read: true, - }) + + entryActions.markRead(entry.feeds.id, entry.entries.id, true) }, }) const unread = useMutation({ @@ -97,9 +96,8 @@ export const useEntryActions = ({ }), onMutate: () => { if (!entry) return - entryActions.optimisticUpdate(entry.entries.id, { - read: false, - }) + + entryActions.markRead(entry.feeds.id, entry.entries.id, false) }, }) diff --git a/src/renderer/src/hooks/useUpdateEntry.tsx b/src/renderer/src/hooks/useUpdateEntry.tsx deleted file mode 100644 index bcefae773..000000000 --- a/src/renderer/src/hooks/useUpdateEntry.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import type { EntriesResponse, ListResponse } from "@renderer/lib/types" -import { Queries } from "@renderer/queries" -import type { Response as SubscriptionsResponse } from "@renderer/queries/subscriptions" -import type { InfiniteData, QueryKey } from "@tanstack/react-query" -import { useQueryClient } from "@tanstack/react-query" -import { produce } from "immer" - -/** - * - * @deprecated - */ -export const useUpdateEntry = ({ - entryId, - feedId, -}: { - entryId?: string - feedId?: string -}) => { - const queryClient = useQueryClient() - - const updateEntry = (changed: Partial) => { - const query = Queries.entries.byId(entryId) - - query.setData((draft) => { - if (!draft) return - Object.assign(draft, changed) - }) - - const entriesData = queryClient.getQueriesData({ - queryKey: ["entries"], - }) - entriesData.forEach(([key, data]: [QueryKey, unknown]) => { - const assertData = data as InfiniteData> - const finaldata = produce(assertData, (assertData) => { - const list = assertData?.pages?.[0]?.data - if (list) { - for (const item of list) { - if (item.entries.id === entryId) { - for (const [key, value] of Object.entries(changed)) { - item[key] = value - } - } - } - } - }) - queryClient.setQueryData(key, finaldata) - }) - - if (changed.read !== undefined) { - const entriesData = queryClient.getQueriesData({ - queryKey: ["subscriptions"], - }) - entriesData.forEach(([key, data]: [QueryKey, unknown]) => { - const change = changed.read ? -1 : 1 - const assertData = data as SubscriptionsResponse - const finaldata = produce(assertData, (assertData) => { - for (const list of assertData.list) { - for (const item of list.list) { - if (item.feeds.id === feedId) { - assertData.unread += change - list.unread += change - item.unread = (item.unread || 0) + change - } - } - } - }) - queryClient.setQueryData(key, finaldata) - }) - } - } - - return updateEntry -} diff --git a/src/renderer/src/lib/query-client.ts b/src/renderer/src/lib/query-client.ts index b3920edf3..96c978ff2 100644 --- a/src/renderer/src/lib/query-client.ts +++ b/src/renderer/src/lib/query-client.ts @@ -7,6 +7,7 @@ const queryClient = new QueryClient({ queries: { gcTime: Infinity, refetchInterval: 1000 * 60 * 10, + throwOnError: import.meta.env.DEV, }, }, }) diff --git a/src/renderer/src/models/index.ts b/src/renderer/src/models/index.ts new file mode 100644 index 000000000..51f739d01 --- /dev/null +++ b/src/renderer/src/models/index.ts @@ -0,0 +1 @@ +export * from "./types" diff --git a/src/renderer/src/lib/types.ts b/src/renderer/src/models/types.ts similarity index 89% rename from src/renderer/src/lib/types.ts rename to src/renderer/src/models/types.ts index 2ec240641..f6a1f8ec6 100644 --- a/src/renderer/src/lib/types.ts +++ b/src/renderer/src/models/types.ts @@ -65,3 +65,12 @@ export type DataResponse = { } export type ActiveEntry = string | null + +export type SubscriptionModel = SubscriptionResponse[number] + +export type FeedListModel = { + list: { + list: SubscriptionResponse + name: string + }[] +} diff --git a/src/renderer/src/queries/subscriptions.ts b/src/renderer/src/queries/subscriptions.ts index e36bfb0da..031202e85 100644 --- a/src/renderer/src/queries/subscriptions.ts +++ b/src/renderer/src/queries/subscriptions.ts @@ -1,96 +1,21 @@ import { defineQuery } from "@renderer/lib/defineQuery" -import type { SubscriptionResponse } from "@renderer/lib/types" +import type { FeedViewType } from "@renderer/lib/enum" import { apiClient } from "@renderer/queries/api-fetch" -import { parse } from "tldts" - -export type Response = { - list: { - list: SubscriptionResponse - unread: number - name: string - }[] - unread: number -} +import { subscriptionActions, unreadActions } from "@renderer/store" export const subscription = { - byView: (view?: number) => + byView: (view?: FeedViewType) => defineQuery( ["subscriptions", view], async () => { - const res = await ( - await apiClient.subscriptions.$get({ query: { view: String(view) } }) - ).json() - - const unreads = await ( - await apiClient.reads.$get({ query: { view: String(view) } }) - ).json() - - const subscriptions = res.data as SubscriptionResponse - const categories = { - list: {}, - unread: 0, - } as { - list: Record< - string, - { - list: SubscriptionResponse - unread: number - } - > - unread: number - } - const domains: Record = {} - if (subscriptions) { - for (const subscription of subscriptions) { - if (!subscription.category && subscription.feeds.siteUrl) { - const { domain } = parse(subscription.feeds.siteUrl) - if (domain) { - if (!domains[domain]) { - domains[domain] = 0 - } - domains[domain]++ - } - } - } - } - if (subscriptions) { - for (const subscription of subscriptions) { - if (!subscription.category) { - if (subscription.feeds.siteUrl) { - const { domain } = parse(subscription.feeds.siteUrl) - if (domain && domains[domain] > 1) { - subscription.category = - domain.slice(0, 1).toUpperCase() + domain.slice(1) - } - } - if (!subscription.category) { - subscription.category = "" - } - } - if (!categories.list[subscription.category]) { - categories.list[subscription.category] = { - list: [], - unread: 0, - } - } - const unread = unreads.data.find((x) => x.feedId === subscription.feedId)?.unread || 0 - subscription.unread = unread - categories.list[subscription.category].list.push(subscription) - categories.list[subscription.category].unread += unread - categories.unread += unread - } - } - const list = Object.entries(categories.list) - .map(([name, list]) => ({ - name, - list: list.list.sort((a, b) => (b.unread || 0) - (a.unread || 0)), - unread: list.unread, - })) - .sort((a, b) => b.unread - a.unread) + const [subscriptions, unreads] = await Promise.all([ + subscriptionActions.fetchByView(view), + unreadActions.fetchUnreadByView(view), + ]) return { - list, - unread: categories.unread, - } as Response + subscriptions, + unreads, + } }, { rootKey: ["subscriptions"], diff --git a/src/renderer/src/store/entry.ts b/src/renderer/src/store/entry.ts index af7030ff5..7fdc2fd7c 100644 --- a/src/renderer/src/store/entry.ts +++ b/src/renderer/src/store/entry.ts @@ -1,9 +1,14 @@ -import type { EntryModel } from "@renderer/lib/types" import { getEntriesParams } from "@renderer/lib/utils" +import type { EntryModel } from "@renderer/models" import { apiClient } from "@renderer/queries/api-fetch" import type { InferResponseType } from "hono/client" import { produce } from "immer" +import { omit } from "lodash-es" import { create } from "zustand" +import { persist } from "zustand/middleware" + +import { unreadActions } from "./unread" +import { zustandStorage } from "./utils/helper" type EntriesIdTable = Record> @@ -26,14 +31,15 @@ interface EntryActions { optimisticUpdate: (entryId: string, changed: Partial) => void optimisticUpdateAll: (changed: Partial) => void getFlattenMapEntries: () => Record + markRead: (feedId: string, entryId: string, read: boolean) => void } -export const useEntryStore = create( - (set, get) => ({ - entries: {}, - flatMapEntries: {}, +export const useEntryStore = create( + persist( + (set, get) => ({ + entries: {}, + flatMapEntries: {}, - actions: { fetchEntries: async ({ level, id, @@ -65,7 +71,7 @@ export const useEntryStore = create( if (data.data) { data.data.forEach((entry: EntryModel) => { - get().actions.upsert(entry.feeds.id, entry) + get().upsert(entry.feeds.id, entry) }) } return data @@ -107,11 +113,27 @@ export const useEntryStore = create( }), ) }, + + markRead: (feedId: string, entryId: string, read: boolean) => { + unreadActions.incrementByFeedId(feedId, read ? -1 : 1) + entryActions.optimisticUpdate(entryId, { + read, + }) + }, + }), + { + name: "entry", + storage: zustandStorage, }, - }), + ), ) -export const entryActions = useEntryStore.getState().actions +export const entryActions = { + ...(omit(useEntryStore.getState(), [ + "entries", + "flatMapEntries", + ]) as EntryActions), +} export const useEntriesByFeedId = (feedId: string) => useEntryStore((state) => state.entries[feedId]) diff --git a/src/renderer/src/store/feed.ts b/src/renderer/src/store/feed.ts index 9e77dfca1..e831b540c 100644 --- a/src/renderer/src/store/feed.ts +++ b/src/renderer/src/store/feed.ts @@ -1,8 +1,8 @@ -import type { ActiveEntry, ActiveList } from "@renderer/lib/types" +import type { ActiveEntry, ActiveList } from "@renderer/models" import { create } from "zustand" interface FeedStoreActions { - setActiveList: ((value: ActiveList) => void) | undefined + setActiveList: (value: ActiveList) => void setActiveEntry: (value: ActiveEntry) => void } interface FeedStoreState { @@ -31,4 +31,5 @@ export const useFeedStore = create((set) => ({ export const feedActions = useFeedStore.getState().actions /** Hooks */ -export const useFeedActiveList = () => useFeedStore((state) => state.activeList) +export const useFeedActiveList = () => + useFeedStore((state) => state.activeList) diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 6d2abb2e4..f802785dc 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -1 +1,4 @@ +export * from "./entry" export * from "./feed" +export * from "./subscription" +export * from "./unread" diff --git a/src/renderer/src/store/subscription.ts b/src/renderer/src/store/subscription.ts new file mode 100644 index 000000000..ede0d3500 --- /dev/null +++ b/src/renderer/src/store/subscription.ts @@ -0,0 +1,58 @@ +import type { FeedViewType } from "@renderer/lib/enum" +import type { SubscriptionModel } from "@renderer/models" +import { apiClient } from "@renderer/queries/api-fetch" +import { produce } from "immer" +import { omit } from "lodash-es" +import { create } from "zustand" +import { persist } from "zustand/middleware" + +import { zustandStorage } from "./utils/helper" + +type FeedId = string +interface SubscriptionState { + data: Record +} +interface SubscriptionActions { + upsert: (feedId: FeedId, subscription: SubscriptionModel) => void + fetchByView: (view?: FeedViewType) => Promise +} +export const useSubscriptionStore = create( + persist( + (set) => ({ + data: {}, + + async fetchByView(view) { + const res = await ( + await apiClient.subscriptions.$get({ query: { view: String(view) } }) + ).json() + + res.data.forEach((subscription: SubscriptionModel) => { + set((state) => + produce(state, (state) => { + state.data[subscription.feeds.id] = subscription + return state + }), + ) + }) + + return res.data + }, + upsert: (feedId, subscription) => { + set((state) => + produce(state, (state) => { + state.data[feedId] = subscription + return state + }), + ) + }, + }), + { + name: "subscription", + storage: zustandStorage, + }, + ), +) + +export const subscriptionActions = { + ...omit(useSubscriptionStore.getState(), ["data"]), +} diff --git a/src/renderer/src/store/unread.ts b/src/renderer/src/store/unread.ts new file mode 100644 index 000000000..cffa511ea --- /dev/null +++ b/src/renderer/src/store/unread.ts @@ -0,0 +1,73 @@ +import type { FeedViewType } from "@renderer/lib/enum" +import { apiClient } from "@renderer/queries/api-fetch" +import { produce } from "immer" +import { omit } from "lodash-es" +import { create } from "zustand" +import { persist } from "zustand/middleware" + +import { zustandStorage } from "./utils/helper" + +interface UnreadState { + data: Record +} +interface UnreadActions { + updateByFeedId: (feedId: string, unread: number) => void + fetchUnreadByView: (view?: FeedViewType) => Promise> + incrementByFeedId: (feedId: string, inc: number) => void +} +export const useUnreadStore = create( + persist( + (set) => ({ + data: {}, + + async fetchUnreadByView(view) { + const unread = await ( + await apiClient.reads.$get({ query: { view: String(view) } }) + ).json() + + const { data } = unread + for (const feedId in data) { + set((state) => + produce(state, (state) => { + state.data[feedId] = data[feedId] + return state + }), + ) + } + return data + }, + incrementByFeedId: (feedId, inc: number) => { + set((state) => + produce(state, (state) => { + const cur = state.data[feedId] + if (cur === undefined) return state + state.data[feedId] = (cur || 0) + inc + return state + }), + ) + }, + updateByFeedId: (feedId, unread) => { + set((state) => + produce(state, (state) => { + state.data[feedId] = unread + return state + }), + ) + }, + }), + { + name: "unread", + storage: zustandStorage, + }, + ), +) + +export const unreadActions = { + ...omit(useUnreadStore.getState(), ["data"]), +} + +Object.assign(window, { + __unread() { + return useUnreadStore.getState() + }, +}) diff --git a/src/renderer/src/store/utils/helper.ts b/src/renderer/src/store/utils/helper.ts new file mode 100644 index 000000000..669beeeda --- /dev/null +++ b/src/renderer/src/store/utils/helper.ts @@ -0,0 +1,16 @@ +import { del, get, set } from "idb-keyval" +import type { StateStorage } from "zustand/middleware" +import { createJSONStorage } from "zustand/middleware" + +export const dbStorage: StateStorage = { + getItem: async (name: string): Promise => + (await get(name)) || null, + setItem: async (name: string, value: string): Promise => { + await set(name, value) + }, + removeItem: async (name: string): Promise => { + await del(name) + }, +} + +export const zustandStorage = createJSONStorage(() => dbStorage) as any