From be6968cf66ad9ae93b3669c1b335b81ef3f15cfa Mon Sep 17 00:00:00 2001 From: Innei Date: Sat, 29 Jun 2024 01:05:12 +0800 Subject: [PATCH] feat: local db (#97) * feat: local db init v2 Signed-off-by: Innei * feat: hydrate Signed-off-by: Innei * feat: setting Signed-off-by: Innei * fix: read color in dark mode Signed-off-by: Innei * chore: cleanup Signed-off-by: Innei * update Signed-off-by: Innei * update Signed-off-by: Innei * update Signed-off-by: Innei * feat: persist star data Signed-off-by: Innei * Update src/renderer/src/store/entry/hooks.ts --------- Signed-off-by: Innei --- package.json | 2 +- pnpm-lock.yaml | 33 ++--- src/renderer/src/components/feed-icon.tsx | 4 +- src/renderer/src/database/constants.ts | 1 + src/renderer/src/database/db.ts | 55 ++++++++ src/renderer/src/database/db_schema.ts | 7 + src/renderer/src/database/hooks.ts | 16 +++ src/renderer/src/database/index.ts | 4 + src/renderer/src/database/model.ts | 31 +++++ .../src/database/models/entry-related.ts | 10 ++ src/renderer/src/database/models/entry.ts | 10 ++ .../src/database/models/feed-entry.ts | 10 ++ src/renderer/src/database/models/feed.ts | 10 ++ src/renderer/src/database/models/index.ts | 5 + .../src/database/models/subscription.ts | 10 ++ src/renderer/src/database/schemas/entry.ts | 7 + src/renderer/src/database/schemas/index.ts | 1 + src/renderer/src/database/types.ts | 5 + src/renderer/src/hooks/biz/useAsRead.ts | 9 +- src/renderer/src/hooks/biz/useAuth.ts | 2 +- .../src/hooks/biz/useEntryActions.tsx | 26 ++-- src/renderer/src/hooks/biz/useRouteParams.ts | 7 - src/renderer/src/main.tsx | 17 ++- src/renderer/src/models/types.ts | 3 +- .../src/modules/entry-column/index.tsx | 17 ++- .../src/modules/entry-column/item.tsx | 4 +- .../entry-column/list-item-template.tsx | 56 ++++++-- .../src/modules/entry-column/types.ts | 4 +- .../src/modules/feed-column/category.tsx | 2 +- src/renderer/src/modules/feed-column/list.tsx | 1 + src/renderer/src/modules/settings/control.tsx | 4 + src/renderer/src/pages/settings/general.tsx | 18 ++- src/renderer/src/queries/ai.ts | 4 +- src/renderer/src/services/base.ts | 36 +++++ src/renderer/src/services/entry-related.ts | 54 ++++++++ src/renderer/src/services/entry.ts | 52 +++++++ src/renderer/src/services/feed-entry.ts | 20 +++ src/renderer/src/services/feed.ts | 23 ++++ src/renderer/src/services/index.ts | 5 + src/renderer/src/services/subscription.ts | 31 +++++ src/renderer/src/store/entry/hooks.ts | 17 ++- src/renderer/src/store/entry/store.ts | 129 +++++++++++++----- src/renderer/src/store/entry/types.ts | 21 +-- src/renderer/src/store/feed/store.ts | 16 +-- src/renderer/src/store/feed/types.ts | 1 - src/renderer/src/store/subscription.ts | 7 +- src/renderer/src/store/utils/biz.ts | 15 ++ .../src/store/utils/{local.ts => clear.ts} | 20 ++- src/renderer/src/store/utils/helper.ts | 26 +--- src/renderer/src/store/utils/hydrate.ts | 88 ++++++++++++ src/renderer/src/store/utils/init.ts | 19 +++ tsconfig.web.json | 1 + 52 files changed, 800 insertions(+), 176 deletions(-) create mode 100644 src/renderer/src/database/constants.ts create mode 100644 src/renderer/src/database/db.ts create mode 100644 src/renderer/src/database/db_schema.ts create mode 100644 src/renderer/src/database/hooks.ts create mode 100644 src/renderer/src/database/index.ts create mode 100644 src/renderer/src/database/model.ts create mode 100644 src/renderer/src/database/models/entry-related.ts create mode 100644 src/renderer/src/database/models/entry.ts create mode 100644 src/renderer/src/database/models/feed-entry.ts create mode 100644 src/renderer/src/database/models/feed.ts create mode 100644 src/renderer/src/database/models/index.ts create mode 100644 src/renderer/src/database/models/subscription.ts create mode 100644 src/renderer/src/database/schemas/entry.ts create mode 100644 src/renderer/src/database/schemas/index.ts create mode 100644 src/renderer/src/database/types.ts create mode 100644 src/renderer/src/services/base.ts create mode 100644 src/renderer/src/services/entry-related.ts create mode 100644 src/renderer/src/services/entry.ts create mode 100644 src/renderer/src/services/feed-entry.ts create mode 100644 src/renderer/src/services/feed.ts create mode 100644 src/renderer/src/services/index.ts create mode 100644 src/renderer/src/services/subscription.ts create mode 100644 src/renderer/src/store/utils/biz.ts rename src/renderer/src/store/utils/{local.ts => clear.ts} (51%) create mode 100644 src/renderer/src/store/utils/hydrate.ts create mode 100644 src/renderer/src/store/utils/init.ts diff --git a/package.json b/package.json index 12a532e60..7c60c62d3 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "clsx": "2.1.1", "cmdk": "1.0.0", "dayjs": "1.11.11", + "dexie": "4.0.7", "dnum": "^2.13.1", "dotenv": "16.4.5", "electron-updater": "^6.2.1", @@ -96,7 +97,6 @@ "rehype-stringify": "10.0.0", "shiki": "1.9.1", "sonner": "^1.5.0", - "superjson": "2.2.1", "swiper": "11.1.4", "tailwind-merge": "2.3.0", "tailwindcss-animate": "1.0.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d6e89d614..d6fd66f70 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -121,6 +121,9 @@ importers: dayjs: specifier: 1.11.11 version: 1.11.11 + dexie: + specifier: 4.0.7 + version: 4.0.7 dnum: specifier: ^2.13.1 version: 2.13.1 @@ -229,9 +232,6 @@ importers: sonner: specifier: ^1.5.0 version: 1.5.0(patch_hash=6s3tquyt5wnkqaogymn3mkivuq)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - superjson: - specifier: 2.2.1 - version: 2.2.1 swiper: specifier: 11.1.4 version: 11.1.4 @@ -2892,10 +2892,6 @@ packages: resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} engines: {node: '>= 0.6'} - copy-anything@3.0.5: - resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==} - engines: {node: '>=12.13'} - core-js-compat@3.37.1: resolution: {integrity: sha512-9TNiImhKvQqSUkOvk/mMRZzOANTiEVC7WaBNhHcKM7x+/5E1l5NvsysR19zuDQScE8k+kfQXWRN3AtS/eOSHpg==} @@ -3072,6 +3068,9 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dexie@4.0.7: + resolution: {integrity: sha512-M+Lo6rk4pekIfrc2T0o2tvVJwL6EAAM/B78DNfb8aaxFVoI1f8/rz5KTxuAnApkwqTSuxx7T5t0RKH7qprapGg==} + didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} @@ -4029,10 +4028,6 @@ packages: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} - is-what@4.1.16: - resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==} - engines: {node: '>=12.13'} - isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} @@ -5412,10 +5407,6 @@ packages: resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} engines: {node: '>= 8.0'} - superjson@2.2.1: - resolution: {integrity: sha512-8iGv75BYOa0xRJHK5vRLEjE2H/i4lulTjzpUXic3Eg8akftYjkmQDa8JARQ42rlczXyFR3IeRoeFCc7RxHsYZA==} - engines: {node: '>=16'} - supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} @@ -8748,10 +8739,6 @@ snapshots: cookie@0.6.0: {} - copy-anything@3.0.5: - dependencies: - is-what: 4.1.16 - core-js-compat@3.37.1: dependencies: browserslist: 4.23.0 @@ -8940,6 +8927,8 @@ snapshots: dependencies: dequal: 2.0.3 + dexie@4.0.7: {} + didyoumean@1.2.2: {} diff-sequences@29.6.3: {} @@ -10230,8 +10219,6 @@ snapshots: is-unicode-supported@0.1.0: {} - is-what@4.1.16: {} - isarray@1.0.0: {} isbinaryfile@4.0.10: {} @@ -11743,10 +11730,6 @@ snapshots: transitivePeerDependencies: - supports-color - superjson@2.2.1: - dependencies: - copy-anything: 3.0.5 - supports-color@5.5.0: dependencies: has-flag: 3.0.0 diff --git a/src/renderer/src/components/feed-icon.tsx b/src/renderer/src/components/feed-icon.tsx index acb77d157..3b3975203 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 { cn } from "@renderer/lib/utils" -import type { EntryModel, FeedModel } from "@renderer/models" +import type { CombinedEntryModel, FeedModel } from "@renderer/models" export function FeedIcon({ feed, @@ -11,7 +11,7 @@ export function FeedIcon({ size = 20, }: { feed: FeedModel - entry?: EntryModel["entries"] + entry?: CombinedEntryModel["entries"] fallbackUrl?: string className?: string size?: number diff --git a/src/renderer/src/database/constants.ts b/src/renderer/src/database/constants.ts new file mode 100644 index 000000000..c6ed27cd4 --- /dev/null +++ b/src/renderer/src/database/constants.ts @@ -0,0 +1 @@ +export const LOCAL_DB_NAME = "FOLLOW_DB" diff --git a/src/renderer/src/database/db.ts b/src/renderer/src/database/db.ts new file mode 100644 index 000000000..af02fa5aa --- /dev/null +++ b/src/renderer/src/database/db.ts @@ -0,0 +1,55 @@ +import Dexie from "dexie" + +import { LOCAL_DB_NAME } from "./constants" +import { + dbSchemaV1, +} from "./db_schema" +import type { DB_Entry } from "./schemas/entry" +import type { DBModel } from "./types" + +export interface LobeDBSchemaMap { + + entries: DB_Entry + // TODO - Add more schemas here + feeds: DB_Entry + subscriptions: DB_Entry + entryRelated: DB_Entry + feedEntries: DB_Entry +} + +// Define a local DB +export class BrowserDB extends Dexie { + public entries: BrowserDBTable<"entries"> + public feeds: BrowserDBTable<"feeds"> + public subscriptions: BrowserDBTable<"subscriptions"> + public entryRelated: BrowserDBTable<"entryRelated"> + public feedEntries: BrowserDBTable<"feedEntries"> + + constructor() { + super(LOCAL_DB_NAME) + this.version(1).stores(dbSchemaV1) + + this.entries = this.table("entries") + this.feeds = this.table("feeds") + this.subscriptions = this.table("subscriptions") + this.entryRelated = this.table("entryRelated") + this.feedEntries = this.table("feedEntries") + } +} + +export const browserDB = new BrowserDB() + +// ================================================ // +// ================================================ // +// ================================================ // +// ================================================ // +// ================================================ // + +// types helper +export type BrowserDBSchema = { + [t in keyof LobeDBSchemaMap]: { + model: LobeDBSchemaMap[t] + table: Dexie.Table, string> + }; +} +type BrowserDBTable = BrowserDBSchema[T]["table"] diff --git a/src/renderer/src/database/db_schema.ts b/src/renderer/src/database/db_schema.ts new file mode 100644 index 000000000..dd868a4e8 --- /dev/null +++ b/src/renderer/src/database/db_schema.ts @@ -0,0 +1,7 @@ +export const dbSchemaV1 = { + entries: "&id", + feeds: "&id", + subscriptions: "&id", + entryRelated: "&id", + feedEntries: "&feedId", +} diff --git a/src/renderer/src/database/hooks.ts b/src/renderer/src/database/hooks.ts new file mode 100644 index 000000000..8da6b9882 --- /dev/null +++ b/src/renderer/src/database/hooks.ts @@ -0,0 +1,16 @@ +import { createAtomHooks, jotaiStore } from "@renderer/lib/jotai" +import { buildStorageNS } from "@renderer/lib/ns" +import { atomWithStorage } from "jotai/utils" + +const SHOULD_USE_INDEXED_DB_KEY = buildStorageNS("shouldUseIndexedDB") + +export const [ + __shouldUseIndexedDBAtom, + useShouldUseIndexedDB, + useShouldUseIndexedDBValue, + useSetShouldUseIndexedDB, + getShouldUseIndexedDB, + setShouldUseIndexedDB, +] = createAtomHooks(atomWithStorage(SHOULD_USE_INDEXED_DB_KEY, false)) + +export const subscribeShouldUseIndexedDB = (callback: (value: boolean) => void) => jotaiStore.sub(__shouldUseIndexedDBAtom, () => callback(getShouldUseIndexedDB())) diff --git a/src/renderer/src/database/index.ts b/src/renderer/src/database/index.ts new file mode 100644 index 000000000..0b8cd3e3d --- /dev/null +++ b/src/renderer/src/database/index.ts @@ -0,0 +1,4 @@ +export * from "./db" +export * from "./hooks" +export * from "./models" +export * from "./schemas" diff --git a/src/renderer/src/database/model.ts b/src/renderer/src/database/model.ts new file mode 100644 index 000000000..795f53a26 --- /dev/null +++ b/src/renderer/src/database/model.ts @@ -0,0 +1,31 @@ +/** + * @see https://github.com/lobehub/lobe-chat/blob/adebf0a92167faad48581b0b0780cf8faeba362f/src/database/client/core/model.ts + */ + +import type Dexie from "dexie" +import type { ZodObject } from "zod" + +import type { BrowserDB, BrowserDBSchema } from "./db" +import { browserDB } from "./db" + +export class BaseModel< + N extends keyof BrowserDBSchema = any, + // T extends { id: string } = any, + // T = BrowserDBSchema[N]["table"], +> { + protected readonly db: BrowserDB + // used to data validation, but use now + + private readonly schema: ZodObject + private readonly _tableName: keyof BrowserDBSchema + + constructor(table: N, schema: ZodObject, db = browserDB) { + this.db = db + this.schema = schema + this._tableName = table + } + + get table() { + return this.db[this._tableName] as Dexie.Table + } +} diff --git a/src/renderer/src/database/models/entry-related.ts b/src/renderer/src/database/models/entry-related.ts new file mode 100644 index 000000000..dc2d364e0 --- /dev/null +++ b/src/renderer/src/database/models/entry-related.ts @@ -0,0 +1,10 @@ +import { BaseModel } from "../model" +import { DB_EntrySchema } from "../schemas" + +class ModelStatic extends BaseModel<"entryRelated"> { + constructor() { + super("entryRelated", DB_EntrySchema) + } +} + +export const entryRelatedModel = new ModelStatic() diff --git a/src/renderer/src/database/models/entry.ts b/src/renderer/src/database/models/entry.ts new file mode 100644 index 000000000..c619d9403 --- /dev/null +++ b/src/renderer/src/database/models/entry.ts @@ -0,0 +1,10 @@ +import { BaseModel } from "../model" +import { DB_EntrySchema } from "../schemas" + +class EntryModelStatic extends BaseModel<"entries"> { + constructor() { + super("entries", DB_EntrySchema) + } +} + +export const entryModel = new EntryModelStatic() diff --git a/src/renderer/src/database/models/feed-entry.ts b/src/renderer/src/database/models/feed-entry.ts new file mode 100644 index 000000000..98666c3df --- /dev/null +++ b/src/renderer/src/database/models/feed-entry.ts @@ -0,0 +1,10 @@ +import { BaseModel } from "../model" +import { DB_EntrySchema } from "../schemas" + +class ModelStatic extends BaseModel<"feedEntries"> { + constructor() { + super("feedEntries", DB_EntrySchema) + } +} + +export const feedEntriesModel = new ModelStatic() diff --git a/src/renderer/src/database/models/feed.ts b/src/renderer/src/database/models/feed.ts new file mode 100644 index 000000000..1c86b6388 --- /dev/null +++ b/src/renderer/src/database/models/feed.ts @@ -0,0 +1,10 @@ +import { BaseModel } from "../model" +import { DB_EntrySchema } from "../schemas" + +class ModelStatic extends BaseModel<"feeds"> { + constructor() { + super("feeds", DB_EntrySchema) + } +} + +export const feedModel = new ModelStatic() diff --git a/src/renderer/src/database/models/index.ts b/src/renderer/src/database/models/index.ts new file mode 100644 index 000000000..4e626bebf --- /dev/null +++ b/src/renderer/src/database/models/index.ts @@ -0,0 +1,5 @@ +export * from "./entry" +export * from "./entry-related" +export * from "./feed" +export * from "./feed-entry" +export * from "./subscription" diff --git a/src/renderer/src/database/models/subscription.ts b/src/renderer/src/database/models/subscription.ts new file mode 100644 index 000000000..e12af0368 --- /dev/null +++ b/src/renderer/src/database/models/subscription.ts @@ -0,0 +1,10 @@ +import { BaseModel } from "../model" +import { DB_EntrySchema } from "../schemas" + +class ModelStatic extends BaseModel<"subscriptions"> { + constructor() { + super("subscriptions", DB_EntrySchema) + } +} + +export const subscriptionModel = new ModelStatic() diff --git a/src/renderer/src/database/schemas/entry.ts b/src/renderer/src/database/schemas/entry.ts new file mode 100644 index 000000000..dc7f714c2 --- /dev/null +++ b/src/renderer/src/database/schemas/entry.ts @@ -0,0 +1,7 @@ +import { z } from "zod" + +export const DB_EntrySchema = z.object({ + id: z.string(), +}) + +export type DB_Entry = z.infer diff --git a/src/renderer/src/database/schemas/index.ts b/src/renderer/src/database/schemas/index.ts new file mode 100644 index 000000000..6755e4a71 --- /dev/null +++ b/src/renderer/src/database/schemas/index.ts @@ -0,0 +1 @@ +export * from "./entry" diff --git a/src/renderer/src/database/types.ts b/src/renderer/src/database/types.ts new file mode 100644 index 000000000..cc4279c9e --- /dev/null +++ b/src/renderer/src/database/types.ts @@ -0,0 +1,5 @@ +export type DBModel = T & { + createdAt: number + id: string + updatedAt: number +} diff --git a/src/renderer/src/hooks/biz/useAsRead.ts b/src/renderer/src/hooks/biz/useAsRead.ts index 0e684331b..5b47a9770 100644 --- a/src/renderer/src/hooks/biz/useAsRead.ts +++ b/src/renderer/src/hooks/biz/useAsRead.ts @@ -1,11 +1,10 @@ -import { FEED_COLLECTION_LIST, levels } from "@renderer/lib/constants" -import type { EntryModel } from "@renderer/models" +import type { CombinedEntryModel } from "@renderer/models" import { useRouteParamsSelector } from "./useRouteParams" -export function useAsRead(entry?: EntryModel) { - return useRouteParamsSelector(({ feedId, level }) => { +export function useAsRead(entry?: CombinedEntryModel) { + return useRouteParamsSelector(() => { if (!entry) return false - return entry.read && !(level === levels.folder && feedId === FEED_COLLECTION_LIST) + return entry.read }, [entry?.read]) } diff --git a/src/renderer/src/hooks/biz/useAuth.ts b/src/renderer/src/hooks/biz/useAuth.ts index a30120da1..18990ceb9 100644 --- a/src/renderer/src/hooks/biz/useAuth.ts +++ b/src/renderer/src/hooks/biz/useAuth.ts @@ -1,6 +1,6 @@ import { signOut } from "@hono/auth-js/react" import { QUERY_PERSIST_KEY } from "@renderer/lib/constants" -import { clearLocalPersistStoreData } from "@renderer/store/utils/local" +import { clearLocalPersistStoreData } from "@renderer/store/utils/clear" import { useCallback } from "react" export const useSignOut = () => diff --git a/src/renderer/src/hooks/biz/useEntryActions.tsx b/src/renderer/src/hooks/biz/useEntryActions.tsx index e55ff7fa9..f737c3f1e 100644 --- a/src/renderer/src/hooks/biz/useEntryActions.tsx +++ b/src/renderer/src/hooks/biz/useEntryActions.tsx @@ -2,7 +2,7 @@ import { apiClient } from "@renderer/lib/api-fetch" import { tipcClient } from "@renderer/lib/client" import { nextFrame } from "@renderer/lib/dom" import { shortcuts } from "@renderer/lib/shortcuts" -import type { EntryModel } from "@renderer/models" +import type { CombinedEntryModel } from "@renderer/models" import { useTipModal } from "@renderer/modules/wallet/hooks" import { entryActions } from "@renderer/store" import { useMutation, useQuery } from "@tanstack/react-query" @@ -11,7 +11,7 @@ import { ofetch } from "ofetch" import { useMemo } from "react" import { toast } from "sonner" -export const useCollect = (entry: Nullable) => +export const useCollect = (entry: Nullable) => useMutation({ mutationFn: async () => entry && @@ -23,11 +23,7 @@ export const useCollect = (entry: Nullable) => onMutate() { if (!entry) return - entryActions.optimisticUpdate(entry.entries.id, { - collections: { - createdAt: new Date().toISOString(), - }, - }) + entryActions.markStar(entry.entries.id, true) }, onSuccess: () => { toast.success("Starred.", { @@ -36,7 +32,7 @@ export const useCollect = (entry: Nullable) => }, }) -export const useUnCollect = (entry: Nullable) => +export const useUnCollect = (entry: Nullable) => useMutation({ mutationFn: async () => entry && @@ -48,9 +44,7 @@ export const useUnCollect = (entry: Nullable) => onMutate() { if (!entry) return - entryActions.optimisticUpdate(entry.entries.id, { - collections: undefined, - }) + entryActions.markStar(entry.entries.id, false) }, onSuccess: () => { toast.success("Unstarred.", { @@ -61,7 +55,7 @@ export const useUnCollect = (entry: Nullable) => export const useRead = () => useMutation({ - mutationFn: async (entry: Nullable) => + mutationFn: async (entry: Nullable) => entry && apiClient.reads.$post({ json: { @@ -69,7 +63,7 @@ export const useRead = () => }, }), - onMutate: (entry: Nullable) => { + onMutate: (entry: Nullable) => { if (!entry) return entryActions.markRead(entry.feeds.id, entry.entries.id, true) @@ -77,7 +71,7 @@ export const useRead = () => }) export const useUnread = () => useMutation({ - mutationFn: async (entry: Nullable) => + mutationFn: async (entry: Nullable) => entry && apiClient.reads.$delete({ json: { @@ -85,7 +79,7 @@ export const useUnread = () => }, }), - onMutate: (entry: Nullable) => { + onMutate: (entry: Nullable) => { if (!entry) return entryActions.markRead(entry.feeds.id, entry.entries.id, false) @@ -97,7 +91,7 @@ export const useEntryActions = ({ entry, }: { view?: number - entry?: EntryModel | null + entry?: CombinedEntryModel | null }) => { const checkEagle = useQuery({ queryKey: ["check-eagle"], diff --git a/src/renderer/src/hooks/biz/useRouteParams.ts b/src/renderer/src/hooks/biz/useRouteParams.ts index 9968ba171..0b7a9b1ed 100644 --- a/src/renderer/src/hooks/biz/useRouteParams.ts +++ b/src/renderer/src/hooks/biz/useRouteParams.ts @@ -40,13 +40,6 @@ const parseRouteParams = ( FeedViewType.Articles) || FeedViewType.Articles ) - let feedId: string | number = params.feedId! - - // If feedId is a number, it's a FeedViewType - if (feedId && FeedViewTypeValues.includes(feedId as string)) { - feedId = Number.parseInt(feedId as string) - } - return { view, entryId: params.entryId || undefined, diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index e508f8308..063e8f783 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -7,6 +7,9 @@ import ReactDOM from "react-dom/client" import { RouterProvider } from "react-router-dom" import { router } from "./router" +import { initializeDbAndStore } from "./store/utils/init" + +initializeDbAndStore().finally(render) authConfigManager.setConfig({ baseUrl: import.meta.env.VITE_API_URL, @@ -14,9 +17,11 @@ authConfigManager.setConfig({ credentials: "include", }) -ReactDOM.createRoot(document.querySelector("#root") as HTMLElement).render( - - - - , -) +function render() { + ReactDOM.createRoot(document.querySelector("#root") as HTMLElement).render( + + + + , + ) +} diff --git a/src/renderer/src/models/types.ts b/src/renderer/src/models/types.ts index 5c83c62b1..204063883 100644 --- a/src/renderer/src/models/types.ts +++ b/src/renderer/src/models/types.ts @@ -39,11 +39,12 @@ export type EntriesResponse = Array< > >[number] -export type EntryModel = EntriesResponse[number] & { +export type CombinedEntryModel = EntriesResponse[number] & { entries: { content?: string | null } } +export type EntryModel = CombinedEntryModel["entries"] export type DiscoverResponse = Array< Exclude< ExtractBizResponse["data"], diff --git a/src/renderer/src/modules/entry-column/index.tsx b/src/renderer/src/modules/entry-column/index.tsx index ffb1b871e..4a2911c4d 100644 --- a/src/renderer/src/modules/entry-column/index.tsx +++ b/src/renderer/src/modules/entry-column/index.tsx @@ -15,7 +15,7 @@ import { useRouteParms, } from "@renderer/hooks/biz/useRouteParams" import { apiClient } from "@renderer/lib/api-fetch" -import { views } from "@renderer/lib/constants" +import { ROUTE_FEED_PENDING, views } from "@renderer/lib/constants" import { buildStorageNS } from "@renderer/lib/ns" import { shortcuts } from "@renderer/lib/shortcuts" import { cn, getEntriesParams, getOS, isBizId } from "@renderer/lib/utils" @@ -182,9 +182,15 @@ const useEntriesByView = () => { view: activeList?.view, ...(unreadOnly === true && { read: false }), }) - const entries = useEntryIdsByFeedIdOrView(activeList.feedId!, { - unread: unreadOnly, - }) + const entries = useEntryIdsByFeedIdOrView( + activeList.feedId === ROUTE_FEED_PENDING ? + activeList.view : + activeList.feedId!, + { + unread: unreadOnly, + view: activeList.view, + }, + ) useHotkeys( shortcuts.entries.refetch.key, @@ -311,8 +317,7 @@ const ListHeader: FC<{ > {!titleAtBottom && titleInfo}
- {feed?.ownerUserId === user?.id && - isBizId(routerParams.feedId) && ( + {feed?.ownerUserId === user?.id && isBizId(routerParams.feedId) && ( return ( -
+
-
+
{entry.feeds.title} ยท @@ -42,23 +55,50 @@ export function ListItem({ .humanize()}
-
+
{entry.entries.title ? ( - + ) : ( - + )} {!!entry.collections && ( )}
{withDetails && ( -
- +
+
)} {withAudio && entry.entries?.enclosures?.[0].url && ( -
{withDetails && entry.entries.images?.[0] && ( diff --git a/src/renderer/src/modules/entry-column/types.ts b/src/renderer/src/modules/entry-column/types.ts index 7d9bcf81f..1a21128e5 100644 --- a/src/renderer/src/modules/entry-column/types.ts +++ b/src/renderer/src/modules/entry-column/types.ts @@ -1,8 +1,8 @@ -import type { EntryModel, FeedModel } from "@renderer/models" +import type { CombinedEntryModel, FeedModel } from "@renderer/models" export type UniversalItemProps = { entryId: string - entryPreview?: EntryModel & { + entryPreview?: CombinedEntryModel & { feeds: FeedModel } translation?: { diff --git a/src/renderer/src/modules/feed-column/category.tsx b/src/renderer/src/modules/feed-column/category.tsx index 5e313de8c..17336ab4a 100644 --- a/src/renderer/src/modules/feed-column/category.tsx +++ b/src/renderer/src/modules/feed-column/category.tsx @@ -69,7 +69,7 @@ function FeedCategoryImpl({ const isActive = useRouteParamsSelector( (routerParams) => routerParams?.level === levels.folder && - routerParams.feedId === data.list.map((feed) => feed.feedId).join(","), + (routerParams.feedId === data.list.map((feed) => feed.feedId).join(",") || data.name === routerParams?.category), ) const { present } = useModalStack() diff --git a/src/renderer/src/modules/feed-column/list.tsx b/src/renderer/src/modules/feed-column/list.tsx index b5f5f737b..e3cca0591 100644 --- a/src/renderer/src/modules/feed-column/list.tsx +++ b/src/renderer/src/modules/feed-column/list.tsx @@ -177,6 +177,7 @@ export function FeedList({ feedId: FEED_COLLECTION_LIST, level: levels.folder, view, + category: "", }) } }} diff --git a/src/renderer/src/modules/settings/control.tsx b/src/renderer/src/modules/settings/control.tsx index 66e03e576..68911efe9 100644 --- a/src/renderer/src/modules/settings/control.tsx +++ b/src/renderer/src/modules/settings/control.tsx @@ -39,3 +39,7 @@ export const SettingSwitch: Component<{
) } + +export const SettingDescription: Component<{ children: string }> = ({ + children, +}) => {children} diff --git a/src/renderer/src/pages/settings/general.tsx b/src/renderer/src/pages/settings/general.tsx index 4bb5d7d75..e74fd2e0f 100644 --- a/src/renderer/src/pages/settings/general.tsx +++ b/src/renderer/src/pages/settings/general.tsx @@ -1,8 +1,8 @@ +import { useShouldUseIndexedDB } from "@renderer/database" import { tipcClient } from "@renderer/lib/client" -import { SettingSwitch } from "@renderer/modules/settings/control" -import { - SettingsTitle, -} from "@renderer/modules/settings/title" +import { SettingDescription, SettingSwitch } from "@renderer/modules/settings/control" +import { SettingSectionTitle } from "@renderer/modules/settings/section" +import { SettingsTitle } from "@renderer/modules/settings/title" import { defineSettingPage } from "@renderer/modules/settings/utils" import { useCallback, useEffect, useState } from "react" @@ -29,6 +29,7 @@ export function Component() { setLoginSetting(checked) }, []) + const [shouldUseIndexedDB, setShouldUseIndexedDB] = useShouldUseIndexedDB() return ( <> @@ -40,6 +41,15 @@ export function Component() { onCheckedChange={saveLoginSetting} /> )} + + + + Data will be stored locally on your device for offline usage and speed up the data loading of the first screen. If you disable this, all local data will be removed. +
) diff --git a/src/renderer/src/queries/ai.ts b/src/renderer/src/queries/ai.ts index b102004a1..195f77470 100644 --- a/src/renderer/src/queries/ai.ts +++ b/src/renderer/src/queries/ai.ts @@ -1,7 +1,7 @@ import { apiClient } from "@renderer/lib/api-fetch" import { views } from "@renderer/lib/constants" import { defineQuery } from "@renderer/lib/defineQuery" -import type { EntryModel, SupportedLanguages } from "@renderer/models" +import type { CombinedEntryModel, SupportedLanguages } from "@renderer/models" import { franc } from "franc-min" const LanguageMap: Record< @@ -31,7 +31,7 @@ export const ai = { language, extraFields, }: { - entry: EntryModel + entry: CombinedEntryModel view?: number language?: SupportedLanguages extraFields?: string[] diff --git a/src/renderer/src/services/base.ts b/src/renderer/src/services/base.ts new file mode 100644 index 000000000..ba4b5a7d5 --- /dev/null +++ b/src/renderer/src/services/base.ts @@ -0,0 +1,36 @@ +import type Dexie from "dexie" +import type { UpdateSpec } from "dexie" + +export abstract class BaseService { + constructor(public readonly table: Dexie.Table) {} + + async create(data: T) { + return this.table.add(data) + } + + async insertMany(data: T[]) { + return this.table.bulkAdd(data) + } + + // @ts-expect-error + async upsert(data: T): Promise | void { + this.table.put(data) + } + + async upsertMany(data: T[]) { + this.table.bulkPut(data) + } + + async findAll() { + return this.table.toArray() + } + + async patch(id: string, data: Partial) { + const oldData = await this.table.get(id) + if (!oldData) return + await this.table.update(id, { + ...oldData, + ...data, + } as unknown as UpdateSpec) + } +} diff --git a/src/renderer/src/services/entry-related.ts b/src/renderer/src/services/entry-related.ts new file mode 100644 index 000000000..5c1ddd750 --- /dev/null +++ b/src/renderer/src/services/entry-related.ts @@ -0,0 +1,54 @@ +import { entryRelatedModel } from "@renderer/database" + +export enum EntryRelatedKey { + READ = "READ", + FEED_ID = "FEED_ID", + COLLECTION = "COLLECTION", +} + +// eslint-disable-next-line unused-imports/no-unused-vars +type Model = { + id: EntryRelatedKey + data: Record +} +class ServiceStatic { + async findAll( + type: EntryRelatedKey, + ): Promise> { + const data = await entryRelatedModel.table.get(type) + return data ? data.data : {} + } + + /** + * + * @param data key is entryId, value is read status + * @returns + */ + async upsert(type: EntryRelatedKey, data: Record) { + const oldData = await this.findAll(type) + + return entryRelatedModel.table.put({ + data: { ...oldData, ...data }, + id: type, + }) + } + + async deleteItem( + type: EntryRelatedKey, + key: string, + ) { + const oldData = await this.findAll(type) + delete oldData[key] + + return entryRelatedModel.table.put({ + data: oldData, + id: type, + }) + } + + async clear() { + return entryRelatedModel.table.clear() + } +} + +export const EntryRelatedService = new ServiceStatic() diff --git a/src/renderer/src/services/entry.ts b/src/renderer/src/services/entry.ts new file mode 100644 index 000000000..36d86d365 --- /dev/null +++ b/src/renderer/src/services/entry.ts @@ -0,0 +1,52 @@ +import { entryModel } from "@renderer/database/models" +import type { + CombinedEntryModel, + EntryModel, + FeedModel, +} from "@renderer/models/types" + +import { BaseService } from "./base" +import { EntryRelatedKey, EntryRelatedService } from "./entry-related" +import { FeedService } from "./feed" + +type EntryCollection = { + createdAt: string +} +class EntryServiceStatic extends BaseService { + constructor() { + super(entryModel.table) + } + + pour(data: CombinedEntryModel[]) { + const entries = [] as EntryModel[] + const feeds = [] as FeedModel[] + for (const entry of data) { + entries.push(entry.entries) + + feeds.push(entry.feeds) + } + + return Promise.all([ + this.upsertMany(entries), + FeedService.upsertMany(feeds), + ]) + } + + bulkStoreReadStatus(record: Record) { + return EntryRelatedService.upsert(EntryRelatedKey.READ, record) + } + + bulkStoreFeedId(record: Record) { + return EntryRelatedService.upsert(EntryRelatedKey.FEED_ID, record) + } + + async bulkStoreCollection(record: Record) { + return EntryRelatedService.upsert(EntryRelatedKey.COLLECTION, record) + } + + async deleteCollection(entryId: string) { + return EntryRelatedService.deleteItem(EntryRelatedKey.COLLECTION, entryId) + } +} + +export const EntryService = new EntryServiceStatic() diff --git a/src/renderer/src/services/feed-entry.ts b/src/renderer/src/services/feed-entry.ts new file mode 100644 index 000000000..c1880bd5e --- /dev/null +++ b/src/renderer/src/services/feed-entry.ts @@ -0,0 +1,20 @@ +import { feedEntriesModel } from "@renderer/database" + +class ServiceStatic { + constructor() {} + + updateFeed(feedId: string, entryIds: string[]) { + return feedEntriesModel.table.put({ + feedId, + entryIds, + }) + } + + getAll() { + return feedEntriesModel.table.toArray() + } +} +/** + * Not used yet + */ +export const FeedEntryService = new ServiceStatic() diff --git a/src/renderer/src/services/feed.ts b/src/renderer/src/services/feed.ts new file mode 100644 index 000000000..8e69c314f --- /dev/null +++ b/src/renderer/src/services/feed.ts @@ -0,0 +1,23 @@ +import { feedModel } from "@renderer/database/models" +import type { FeedModel } from "@renderer/models/types" + +import { BaseService } from "./base" + +type FeedModelWithId = FeedModel & { id: string } +class ServiceStatic extends BaseService { + constructor() { + super(feedModel.table) + } + + override async upsertMany(data: FeedModel[]) { + const filterData = data.filter((d) => d.id) + this.table.bulkPut(filterData as FeedModelWithId[]) + } + + override upsert(data: FeedModel) { + if (!data.id) return + this.table.put(data as FeedModelWithId) + } +} + +export const FeedService = new ServiceStatic() diff --git a/src/renderer/src/services/index.ts b/src/renderer/src/services/index.ts new file mode 100644 index 000000000..4e626bebf --- /dev/null +++ b/src/renderer/src/services/index.ts @@ -0,0 +1,5 @@ +export * from "./entry" +export * from "./entry-related" +export * from "./feed" +export * from "./feed-entry" +export * from "./subscription" diff --git a/src/renderer/src/services/subscription.ts b/src/renderer/src/services/subscription.ts new file mode 100644 index 000000000..9a109f128 --- /dev/null +++ b/src/renderer/src/services/subscription.ts @@ -0,0 +1,31 @@ +import { subscriptionModel } from "@renderer/database" +import type { SubscriptionPlainModel } from "@renderer/store" + +import { BaseService } from "./base" + +type SubscriptionModelWithId = SubscriptionPlainModel & { id: string } + +class SubscriptionServiceStatic extends BaseService { + constructor() { + super(subscriptionModel.table) + } + + override async upsertMany(data: SubscriptionPlainModel[]) { + this.table.bulkPut( + data.map((d) => ({ ...d, id: this.uniqueId(d.userId, d.feedId) })), + ) + } + + override upsert(data: SubscriptionModelWithId) { + this.table.put({ + ...data, + id: this.uniqueId(data.userId, data.feedId), + }) + } + + private uniqueId(userId: string, feedId: string) { + return `${userId}/${feedId}` + } +} + +export const SubscriptionService = new SubscriptionServiceStatic() diff --git a/src/renderer/src/store/entry/hooks.ts b/src/renderer/src/store/entry/hooks.ts index 6c96dc2a0..29f7d3d7e 100644 --- a/src/renderer/src/store/entry/hooks.ts +++ b/src/renderer/src/store/entry/hooks.ts @@ -1,16 +1,18 @@ import { FEED_COLLECTION_LIST } from "@renderer/lib/constants" import type { FeedViewType } from "@renderer/lib/enum" -import type { EntryModel } from "@renderer/models" +import type { CombinedEntryModel } from "@renderer/models" import { useShallow } from "zustand/react/shallow" import { useFeedIdByView } from "../subscription" +import { getEntryIsInView } from "../utils/biz" import { useEntryStore } from "./store" interface EntryFilter { unread?: boolean + view?: FeedViewType } -export const useEntry = (entryId: Nullable): EntryModel | null => +export const useEntry = (entryId: Nullable): CombinedEntryModel | null => useEntryStore(useShallow((state) => entryId ? state.flatMapEntries[entryId] : null)) // feedId: single feedId, multiple feedId joint by `,`, and `collections` export const useEntryIdsByFeedId = (feedId: string, filter?: EntryFilter) => @@ -26,8 +28,14 @@ export const useEntryIdsByFeedId = (feedId: string, filter?: EntryFilter) => } return result } else if (feedId === FEED_COLLECTION_LIST) { - // TODO we can't filter collections in local mode by view - return [] + const result = [] as string[] + state.starIds.forEach((entryId) => { + if (getEntryIsInView(entryId)?.toString() === filter?.view?.toString()) { + result.push(entryId) + } + }) + + return result } else { return getSingle(feedId) } @@ -51,6 +59,7 @@ export const useEntryIdsByFeedId = (feedId: string, filter?: EntryFilter) => export const useEntryIdsByView = (view: FeedViewType, filter?: EntryFilter) => { const feedIds = useFeedIdByView(view) + return useEntryStore( useShallow((state) => { const data = [] as string[] diff --git a/src/renderer/src/store/entry/store.ts b/src/renderer/src/store/entry/store.ts index 7847ae53d..25028e8c4 100644 --- a/src/renderer/src/store/entry/store.ts +++ b/src/renderer/src/store/entry/store.ts @@ -1,31 +1,41 @@ import { apiClient } from "@renderer/lib/api-fetch" import { getEntriesParams } from "@renderer/lib/utils" import type { EntryModel, FeedModel } from "@renderer/models" +import { EntryService } from "@renderer/services" import { produce } from "immer" import { merge, omit } from "lodash-es" import { feedActions } from "../feed" import { unreadActions } from "../unread" import { createZustandStore, getStoreActions } from "../utils/helper" +import { isHydrated } from "../utils/hydrate" import type { EntryActions, EntryState } from "./types" +const createState = (): EntryState => ({ + entries: {}, + flatMapEntries: {}, + internal_feedId2entryIdSet: {}, + starIds: new Set(), + // viewToStarIds: { + // [FeedViewType.Articles]: new Set(), + // [FeedViewType.Audios]: new Set(), + // [FeedViewType.Videos]: new Set(), + // [FeedViewType.Notifications]: new Set(), + // [FeedViewType.SocialMedia]: new Set(), + // [FeedViewType.Pictures]: new Set(), + // }, +}) + export const useEntryStore = createZustandStore( "entry", { version: 1, - disablePersist: true, }, )((set, get) => ({ - entries: {}, - flatMapEntries: {}, - internal_feedId2entryIdSet: {}, + ...createState(), clear: () => { - set({ - entries: {}, - flatMapEntries: {}, - internal_feedId2entryIdSet: {}, - }) + set(createState) }, fetchEntryById: async (entryId: string) => { @@ -72,7 +82,7 @@ export const useEntryStore = createZustandStore( getFlattenMapEntries() { return get().flatMapEntries }, - optimisticUpdate(entryId: string, changed: Partial) { + patch(entryId, changed) { set((state) => produce(state, (draft) => { const entry = draft.flatMapEntries[entryId] @@ -82,7 +92,7 @@ export const useEntryStore = createZustandStore( }), ) }, - optimisticUpdateManyByFeedId(feedId, changed) { + patchManyByFeedId(feedId, changed) { set((state) => produce(state, (draft) => { const ids = draft.entries[feedId] @@ -96,7 +106,7 @@ export const useEntryStore = createZustandStore( }), ) }, - optimisticUpdateAll(changed) { + patchAll(changed) { set((state) => produce(state, (draft) => { for (const entry of Object.values(draft.flatMapEntries)) { @@ -107,50 +117,78 @@ export const useEntryStore = createZustandStore( ) }, - upsertMany(entries) { + upsertMany(data) { + const feeds = [] as FeedModel[] + const entries = [] as EntryModel[] + const entry2Read = {} as Record + const entryFeedMap = {} as Record + const entryCollection = {} as Record set((state) => produce(state, (draft) => { - const feeds = [] as FeedModel[] - for (const entry of entries) { - if (!draft.entries[entry.feeds.id]) { - draft.entries[entry.feeds.id] = [] + for (const item of data) { + if (!draft.entries[item.feeds.id]) { + draft.entries[item.feeds.id] = [] } - if (!draft.internal_feedId2entryIdSet[entry.feeds.id]) { - draft.internal_feedId2entryIdSet[entry.feeds.id] = new Set() + if (!draft.internal_feedId2entryIdSet[item.feeds.id]) { + draft.internal_feedId2entryIdSet[item.feeds.id] = new Set() } if ( - !draft.internal_feedId2entryIdSet[entry.feeds.id].has( - entry.entries.id, + !draft.internal_feedId2entryIdSet[item.feeds.id].has( + item.entries.id, ) ) { - draft.entries[entry.feeds.id].push(entry.entries.id) - draft.internal_feedId2entryIdSet[entry.feeds.id].add( - entry.entries.id, + draft.entries[item.feeds.id].push(item.entries.id) + draft.internal_feedId2entryIdSet[item.feeds.id].add( + item.entries.id, ) } - draft.flatMapEntries[entry.entries.id] = merge( - draft.flatMapEntries[entry.entries.id] || {}, - entry, + draft.flatMapEntries[item.entries.id] = merge( + draft.flatMapEntries[item.entries.id] || {}, + item, ) // Push feed - feeds.push(entry.feeds) + feeds.push(item.feeds) + // Push entry + entries.push(item.entries) + // Push entry2Read + entry2Read[item.entries.id] = item.read || false + // Push entryFeedMap + entryFeedMap[item.entries.id] = item.feeds.id + // Push entryCollection + if (item.collections) { + entryCollection[item.entries.id] = item.collections + } } - // Insert to feed store - feedActions.upsertMany(feeds) - return draft }), ) + // Insert to feed store + feedActions.upsertMany(feeds) + const newStarIds = new Set(get().starIds) + for (const entryId in entryCollection) { + newStarIds.add(entryId) + } + set((state) => ({ + ...state, + starIds: newStarIds, + })) + // Update database + if (isHydrated()) { + EntryService.upsertMany(entries) + EntryService.bulkStoreReadStatus(entry2Read) + EntryService.bulkStoreFeedId(entryFeedMap) + EntryService.bulkStoreCollection(entryCollection) + } }, markRead: (feedId: string, entryId: string, read: boolean) => { unreadActions.incrementByFeedId(feedId, read ? -1 : 1) - entryActions.optimisticUpdate(entryId, { + entryActions.patch(entryId, { read, }) }, @@ -162,6 +200,33 @@ export const useEntryStore = createZustandStore( }) unreadActions.updateByFeedId(feedId, 0) }, + + markStar: (entryId, star) => { + entryActions.patch(entryId, { + collections: star ? + { + createdAt: new Date().toISOString(), + } : + undefined, + }) + + set((state) => + produce(state, (state) => { + star ? state.starIds.add(entryId) : state.starIds.delete(entryId) + }), + ) + + if (!isHydrated()) return + if (star) { + EntryService.bulkStoreCollection({ + [entryId]: { + createdAt: new Date().toISOString(), + }, + }) + } else { + EntryService.deleteCollection(entryId) + } + }, })) export const entryActions = getStoreActions(useEntryStore) diff --git a/src/renderer/src/store/entry/types.ts b/src/renderer/src/store/entry/types.ts index a479ceba4..d5b09df5b 100644 --- a/src/renderer/src/store/entry/types.ts +++ b/src/renderer/src/store/entry/types.ts @@ -1,5 +1,5 @@ import type { apiClient } from "@renderer/lib/api-fetch" -import type { EntryModel } from "@renderer/models" +import type { CombinedEntryModel } from "@renderer/models" type FeedId = string type EntryId = string @@ -13,13 +13,15 @@ export interface EntryState { /** * A map of entryId to entry */ - flatMapEntries: Record + flatMapEntries: Record /** * A map of feedId to entryId set, to quickly check if an entryId is in the feed * The array is used to keep the order of the entries, and this set is used to quickly check if an entryId is in the feed */ internal_feedId2entryIdSet: Record> + // viewToStarIds: Record> + starIds: Set // internal_updateSortIds: Record } @@ -32,18 +34,19 @@ export interface EntryActions { pageParam?: string }) => Promise>> - fetchEntryById: (entryId: string) => Promise - upsertMany: (entries: EntryModel[]) => void + fetchEntryById: (entryId: string) => Promise + upsertMany: (entries: CombinedEntryModel[]) => void - optimisticUpdate: (entryId: string, changed: Partial) => void - optimisticUpdateManyByFeedId: ( + patch: (entryId: string, changed: Partial) => void + patchManyByFeedId: ( feedId: string, - changed: Partial + changed: Partial ) => void - optimisticUpdateAll: (changed: Partial) => void - getFlattenMapEntries: () => Record + patchAll: (changed: Partial) => void + getFlattenMapEntries: () => Record markRead: (feedId: string, entryId: string, read: boolean) => void markReadByFeedId: (feedId: string) => void + markStar: (entryId: string, starred: boolean) => void clear: () => void } diff --git a/src/renderer/src/store/feed/store.ts b/src/renderer/src/store/feed/store.ts index 79a40b84e..d037b99d0 100644 --- a/src/renderer/src/store/feed/store.ts +++ b/src/renderer/src/store/feed/store.ts @@ -1,7 +1,9 @@ import type { FeedModel } from "@renderer/models" +import { FeedService } from "@renderer/services" import { produce } from "immer" import { createZustandStore, getStoreActions } from "../utils/helper" +import { isHydrated } from "../utils/hydrate" import type { FeedActions, FeedState } from "./types" export const useFeedStore = createZustandStore( @@ -15,6 +17,9 @@ export const useFeedStore = createZustandStore( set({ feeds: {} }) }, upsertMany(feeds) { + if (isHydrated()) { + FeedService.upsertMany(feeds) + } set((state) => produce(state, (state) => { for (const feed of feeds) { @@ -26,17 +31,6 @@ export const useFeedStore = createZustandStore( ) }, - optimisticUpdate(feedId, changed) { - set((state) => - produce(state, (state) => { - const feed = state.feeds[feedId] - if (!feed) return - - Object.assign(feed, changed) - }), - ) - }, - patch(feedId, patch) { set((state) => produce(state, (state) => { diff --git a/src/renderer/src/store/feed/types.ts b/src/renderer/src/store/feed/types.ts index a52c9703a..5a5a691fc 100644 --- a/src/renderer/src/store/feed/types.ts +++ b/src/renderer/src/store/feed/types.ts @@ -8,7 +8,6 @@ export interface FeedState { export interface FeedActions { upsertMany: (feeds: FeedModel[]) => void - optimisticUpdate: (feedId: FeedId, changed: Partial) => void clear: () => void patch: (feedId: FeedId, patch: Partial) => void } diff --git a/src/renderer/src/store/subscription.ts b/src/renderer/src/store/subscription.ts index ed42ec77d..d3d201f9c 100644 --- a/src/renderer/src/store/subscription.ts +++ b/src/renderer/src/store/subscription.ts @@ -1,6 +1,7 @@ import { apiClient } from "@renderer/lib/api-fetch" import { FeedViewType } from "@renderer/lib/enum" import type { SubscriptionModel } from "@renderer/models" +import { SubscriptionService } from "@renderer/services" import { produce } from "immer" import { omit } from "lodash-es" @@ -8,6 +9,7 @@ import { entryActions } from "./entry/store" import { feedActions } from "./feed" import { unreadActions } from "./unread" import { createZustandStore, getStoreActions } from "./utils/helper" +import { isHydrated } from "./utils/hydrate" type FeedId = string export type SubscriptionPlainModel = Omit @@ -75,6 +77,9 @@ export const useSubscriptionStore = createZustandStore< return res.data }, upsertMany: (subscriptions) => { + if (isHydrated()) { + SubscriptionService.upsertMany(subscriptions) + } set((state) => produce(state, (state) => { subscriptions.forEach((subscription) => { @@ -91,7 +96,7 @@ export const useSubscriptionStore = createZustandStore< for (const feedId in state.data) { if (state.data[feedId].view === view) { unreadActions.updateByFeedId(feedId, 0) - entryActions.optimisticUpdateManyByFeedId(feedId, { read: true }) + entryActions.patchManyByFeedId(feedId, { read: true }) } } }, diff --git a/src/renderer/src/store/utils/biz.ts b/src/renderer/src/store/utils/biz.ts new file mode 100644 index 000000000..cbf929b06 --- /dev/null +++ b/src/renderer/src/store/utils/biz.ts @@ -0,0 +1,15 @@ +import { useEntryStore } from "../entry" +import { useFeedStore } from "../feed" +import { useSubscriptionStore } from "../subscription" + +export const getEntryIsInView = (entryId: string) => { + const state = useEntryStore.getState() + const entry = state.flatMapEntries[entryId] + if (!entry) return + const feedId = entry.feeds.id + const feed = useFeedStore.getState().feeds[feedId] + if (!feed?.id) return + const subscription = useSubscriptionStore.getState().data[feed.id] + if (!subscription) return + return subscription.view +} diff --git a/src/renderer/src/store/utils/local.ts b/src/renderer/src/store/utils/clear.ts similarity index 51% rename from src/renderer/src/store/utils/local.ts rename to src/renderer/src/store/utils/clear.ts index eb304c4ae..87c4f45c3 100644 --- a/src/renderer/src/store/utils/local.ts +++ b/src/renderer/src/store/utils/clear.ts @@ -1,4 +1,6 @@ -import { entryActions } from "../entry/store" +import { browserDB } from "@renderer/database" + +import { entryActions } from "../entry" import { feedActions } from "../feed" import { subscriptionActions } from "../subscription" import { uiActions } from "../ui" @@ -6,9 +8,15 @@ import { unreadActions } from "../unread" export const clearLocalPersistStoreData = () => { // All clear and reset method will aggregate here - [entryActions, subscriptionActions, unreadActions, uiActions, feedActions].forEach( - (actions) => { - actions.clear() - }, - ) + [ + entryActions, + subscriptionActions, + unreadActions, + uiActions, + feedActions, + ].forEach((actions) => { + actions.clear() + }) + + browserDB.delete() } diff --git a/src/renderer/src/store/utils/helper.ts b/src/renderer/src/store/utils/helper.ts index e4d4f550a..79ef71959 100644 --- a/src/renderer/src/store/utils/helper.ts +++ b/src/renderer/src/store/utils/helper.ts @@ -1,6 +1,4 @@ -import { del, get, set } from "idb-keyval" import { enableMapSet } from "immer" -import superjson from "superjson" // can use anything: serialize-javascript, devalue, etc. import type { StateCreator, StoreApi } from "zustand" import type { PersistOptions, @@ -12,23 +10,6 @@ import type { UseBoundStoreWithEqualityFn } from "zustand/traditional" import { createWithEqualityFn } from "zustand/traditional" declare const window: any -export const dbStorage: PersistStorage = { - getItem: async (name: string) => { - const data = (await get(name)) || null - - if (data === null) { - return null - } - - return superjson.parse(data) - }, - setItem: async (name, value) => { - await set(name, superjson.stringify(value)) - }, - removeItem: async (name: string): Promise => { - await del(name) - }, -} export const localStorage: PersistStorage = { getItem: (name: string) => { const data = window.localStorage.getItem(name) @@ -47,7 +28,6 @@ export const localStorage: PersistStorage = { }, } enableMapSet() -export const zustandStorage = dbStorage export const createZustandStore = < @@ -60,15 +40,15 @@ export const createZustandStore = >( name: string, options?: Partial & { - disablePersist?: boolean + persist?: boolean }>, ) => (store: T) => { - const newStore = !options?.disablePersist ? + const newStore = options?.persist ? createWithEqualityFn( persist(store, { name, - storage: zustandStorage, + storage: localStorage, ...options, }), shallow, diff --git a/src/renderer/src/store/utils/hydrate.ts b/src/renderer/src/store/utils/hydrate.ts new file mode 100644 index 000000000..28453e04c --- /dev/null +++ b/src/renderer/src/store/utils/hydrate.ts @@ -0,0 +1,88 @@ +import type { CombinedEntryModel, FeedModel } from "@renderer/models" +import { + EntryRelatedKey, + EntryRelatedService, + EntryService, + FeedService, + SubscriptionService, +} from "@renderer/services" + +import { entryActions, useEntryStore } from "../entry/store" +import { feedActions, useFeedStore } from "../feed" +import { subscriptionActions } from "../subscription" + +// This flag controls write data in indexedDB, if it's false, pass data insert to db +// When app not ready, it's false, after hydrate data, it's true +// Or set is false when disable indexedDB in setting +let _isHydrated = false + +export const setHydrated = (v: boolean) => { + _isHydrated = v +} + +export const isHydrated = () => _isHydrated + +export const hydrateDatabaseToStore = async () => { + const now = Date.now() + const [feeds] = await Promise.all([hydrateFeed(), hydrateSubscription()]) + + await hydrateEntry(feeds) + _isHydrated = true + console.info("hydrate data done, cast:", Date.now() - now) +} + +async function hydrateFeed() { + const feeds = await FeedService.findAll() + feedActions.upsertMany(feeds) + return useFeedStore.getState().feeds +} +async function hydrateEntry(feedMap: Record) { + const [entries, entryRelated, feedEntries, collections] = await Promise.all([ + EntryService.findAll(), + + EntryRelatedService.findAll(EntryRelatedKey.READ), + EntryRelatedService.findAll(EntryRelatedKey.FEED_ID), + EntryRelatedService.findAll(EntryRelatedKey.COLLECTION), + ]) + + const storeValue = [] as CombinedEntryModel[] + for (const entry of entries) { + const entryRelatedFeedId = feedEntries[entry.id] + if (!entryRelatedFeedId) { + logHydrateError(`Entry ${entry.id} has no related feed id`) + continue + } + const feed = feedMap[entryRelatedFeedId] + + if (!feed) { + logHydrateError(`Entry related feed ${entryRelatedFeedId} is missing`) + continue + } + + storeValue.push({ + entries: entry, + // @ts-expect-error + // FIXME server provided feed type is not match, but it's ok + feeds: feed, + read: entryRelated[entry.id] || false, + collections: collections[entry.id], + }) + } + entryActions.upsertMany(storeValue) + useEntryStore.setState({ + starIds: new Set(Object.keys(collections)), + }) +} + +async function hydrateSubscription() { + const subscriptions = await SubscriptionService.findAll() + + subscriptionActions.upsertMany(subscriptions) +} + +const logHydrateError = (message: string) => { + // eslint-disable-next-line no-console + console.debug( + `Hydrate error: ${message}, maybe local database data is dirty.`, + ) +} diff --git a/src/renderer/src/store/utils/init.ts b/src/renderer/src/store/utils/init.ts new file mode 100644 index 000000000..261a53e34 --- /dev/null +++ b/src/renderer/src/store/utils/init.ts @@ -0,0 +1,19 @@ +import { browserDB, getShouldUseIndexedDB, subscribeShouldUseIndexedDB } from "@renderer/database" + +import { hydrateDatabaseToStore, setHydrated } from "./hydrate" + +const cleanup = subscribeShouldUseIndexedDB((value) => { + if (!value) { + browserDB.delete() + setHydrated(false) + return + } + setHydrated(true) +}) +export const initializeDbAndStore = async () => { + if (getShouldUseIndexedDB()) { + await hydrateDatabaseToStore() + } +} + +import.meta.hot?.dispose(cleanup) diff --git a/tsconfig.web.json b/tsconfig.web.json index 3c72b6c6e..78bc8384f 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -12,6 +12,7 @@ "compilerOptions": { "composite": true, "jsx": "react-jsx", + "noUnusedLocals": false, "baseUrl": ".", "paths": { "@renderer/*": [