feat: local db (#97)
* feat: local db init v2 Signed-off-by: Innei <i@innei.in> * feat: hydrate Signed-off-by: Innei <i@innei.in> * feat: setting Signed-off-by: Innei <i@innei.in> * fix: read color in dark mode Signed-off-by: Innei <i@innei.in> * chore: cleanup Signed-off-by: Innei <i@innei.in> * update Signed-off-by: Innei <i@innei.in> * update Signed-off-by: Innei <i@innei.in> * update Signed-off-by: Innei <i@innei.in> * feat: persist star data Signed-off-by: Innei <i@innei.in> * Update src/renderer/src/store/entry/hooks.ts --------- Signed-off-by: Innei <i@innei.in>
This commit is contained in:
parent
e1ae88f691
commit
be6968cf66
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
export const LOCAL_DB_NAME = "FOLLOW_DB"
|
||||
|
|
@ -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<DBModel<LobeDBSchemaMap[t]>, string>
|
||||
};
|
||||
}
|
||||
type BrowserDBTable<T extends keyof LobeDBSchemaMap> = BrowserDBSchema[T]["table"]
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
export const dbSchemaV1 = {
|
||||
entries: "&id",
|
||||
feeds: "&id",
|
||||
subscriptions: "&id",
|
||||
entryRelated: "&id",
|
||||
feedEntries: "&feedId",
|
||||
}
|
||||
|
|
@ -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()))
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export * from "./db"
|
||||
export * from "./hooks"
|
||||
export * from "./models"
|
||||
export * from "./schemas"
|
||||
|
|
@ -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<any>
|
||||
private readonly _tableName: keyof BrowserDBSchema
|
||||
|
||||
constructor(table: N, schema: ZodObject<any>, db = browserDB) {
|
||||
this.db = db
|
||||
this.schema = schema
|
||||
this._tableName = table
|
||||
}
|
||||
|
||||
get table() {
|
||||
return this.db[this._tableName] as Dexie.Table
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export * from "./entry"
|
||||
export * from "./entry-related"
|
||||
export * from "./feed"
|
||||
export * from "./feed-entry"
|
||||
export * from "./subscription"
|
||||
|
|
@ -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()
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { z } from "zod"
|
||||
|
||||
export const DB_EntrySchema = z.object({
|
||||
id: z.string(),
|
||||
})
|
||||
|
||||
export type DB_Entry = z.infer<typeof DB_EntrySchema>
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from "./entry"
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export type DBModel<T> = T & {
|
||||
createdAt: number
|
||||
id: string
|
||||
updatedAt: number
|
||||
}
|
||||
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = () =>
|
||||
|
|
|
|||
|
|
@ -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<EntryModel>) =>
|
||||
export const useCollect = (entry: Nullable<CombinedEntryModel>) =>
|
||||
useMutation({
|
||||
mutationFn: async () =>
|
||||
entry &&
|
||||
|
|
@ -23,11 +23,7 @@ export const useCollect = (entry: Nullable<EntryModel>) =>
|
|||
|
||||
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<EntryModel>) =>
|
|||
},
|
||||
})
|
||||
|
||||
export const useUnCollect = (entry: Nullable<EntryModel>) =>
|
||||
export const useUnCollect = (entry: Nullable<CombinedEntryModel>) =>
|
||||
useMutation({
|
||||
mutationFn: async () =>
|
||||
entry &&
|
||||
|
|
@ -48,9 +44,7 @@ export const useUnCollect = (entry: Nullable<EntryModel>) =>
|
|||
|
||||
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<EntryModel>) =>
|
|||
|
||||
export const useRead = () =>
|
||||
useMutation({
|
||||
mutationFn: async (entry: Nullable<EntryModel>) =>
|
||||
mutationFn: async (entry: Nullable<CombinedEntryModel>) =>
|
||||
entry &&
|
||||
apiClient.reads.$post({
|
||||
json: {
|
||||
|
|
@ -69,7 +63,7 @@ export const useRead = () =>
|
|||
},
|
||||
}),
|
||||
|
||||
onMutate: (entry: Nullable<EntryModel>) => {
|
||||
onMutate: (entry: Nullable<CombinedEntryModel>) => {
|
||||
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<EntryModel>) =>
|
||||
mutationFn: async (entry: Nullable<CombinedEntryModel>) =>
|
||||
entry &&
|
||||
apiClient.reads.$delete({
|
||||
json: {
|
||||
|
|
@ -85,7 +79,7 @@ export const useUnread = () =>
|
|||
},
|
||||
}),
|
||||
|
||||
onMutate: (entry: Nullable<EntryModel>) => {
|
||||
onMutate: (entry: Nullable<CombinedEntryModel>) => {
|
||||
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"],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<React.StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
<ClickToComponent />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
function render() {
|
||||
ReactDOM.createRoot(document.querySelector("#root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
<ClickToComponent />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<typeof apiClient.discover.$post>["data"],
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
<div className="relative z-[1] flex items-center gap-1 self-baseline text-zinc-500">
|
||||
{feed?.ownerUserId === user?.id &&
|
||||
isBizId(routerParams.feedId) && (
|
||||
{feed?.ownerUserId === user?.id && isBizId(routerParams.feedId) && (
|
||||
<ActionButton
|
||||
tooltip="Refresh"
|
||||
// shortcut={shortcuts.entries.toggleUnreadOnly.key}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { views } from "@renderer/lib/constants"
|
|||
import { FeedViewType } from "@renderer/lib/enum"
|
||||
import { showNativeMenu } from "@renderer/lib/native-menu"
|
||||
import { cn } from "@renderer/lib/utils"
|
||||
import type { EntryModel } from "@renderer/models"
|
||||
import type { CombinedEntryModel } from "@renderer/models"
|
||||
import { Queries } from "@renderer/queries"
|
||||
import { useEntry } from "@renderer/store/entry/hooks"
|
||||
import type { FC } from "react"
|
||||
|
|
@ -24,7 +24,7 @@ interface EntryItemProps {
|
|||
entryId: string
|
||||
view?: number
|
||||
}
|
||||
function EntryItemImpl({ entry, view }: { entry: EntryModel, view?: number }) {
|
||||
function EntryItemImpl({ entry, view }: { entry: CombinedEntryModel, view?: number }) {
|
||||
const { items } = useEntryActions({
|
||||
view,
|
||||
entry,
|
||||
|
|
|
|||
|
|
@ -27,10 +27,23 @@ export function ListItem({
|
|||
if (!entry) return <ReactVirtuosoItemPlaceholder />
|
||||
|
||||
return (
|
||||
<div className={cn("relative flex py-3 pl-3 pr-2", !asRead && "before:absolute before:-left-0.5 before:top-[18px] before:block before:size-2 before:rounded-full before:bg-theme-accent")}>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex py-3 pl-3 pr-2",
|
||||
!asRead &&
|
||||
"before:absolute before:-left-0.5 before:top-[18px] before:block before:size-2 before:rounded-full before:bg-theme-accent",
|
||||
|
||||
)}
|
||||
>
|
||||
<FeedIcon feed={entry.feeds} entry={entry.entries} />
|
||||
<div className="-mt-0.5 line-clamp-4 flex-1 text-sm leading-tight">
|
||||
<div className={cn("flex gap-1 text-[10px] font-bold", asRead ? "text-zinc-400" : "text-zinc-500")}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-1 text-[10px] font-bold",
|
||||
asRead ? "text-zinc-400" : "text-zinc-500",
|
||||
entry.collections && "text-zinc-600 dark:text-zinc-400",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{entry.feeds.title}</span>
|
||||
<span>·</span>
|
||||
<span className="shrink-0">
|
||||
|
|
@ -42,23 +55,50 @@ export function ListItem({
|
|||
.humanize()}
|
||||
</span>
|
||||
</div>
|
||||
<div className={cn("relative my-0.5 break-words", !!entry.collections && "pr-4", entry.entries.title ? (withDetails && "font-medium") : "text-[13px]")}>
|
||||
<div
|
||||
className={cn(
|
||||
"relative my-0.5 break-words",
|
||||
!!entry.collections && "pr-4",
|
||||
entry.entries.title ? withDetails && "font-medium" : "text-[13px]",
|
||||
asRead ? "text-zinc-400 dark:text-neutral-500" : "text-zinc-500",
|
||||
entry.collections && "text-neutral-700 dark:text-zinc-300",
|
||||
)}
|
||||
>
|
||||
{entry.entries.title ? (
|
||||
<EntryTranslation source={entry.entries.title} target={translation?.title} />
|
||||
<EntryTranslation
|
||||
source={entry.entries.title}
|
||||
target={translation?.title}
|
||||
/>
|
||||
) : (
|
||||
<EntryTranslation source={entry.entries.description} target={translation?.description} />
|
||||
<EntryTranslation
|
||||
source={entry.entries.description}
|
||||
target={translation?.description}
|
||||
/>
|
||||
)}
|
||||
{!!entry.collections && (
|
||||
<i className="i-mgc-star-cute-fi absolute right-0 top-[3px] text-orange-400" />
|
||||
)}
|
||||
</div>
|
||||
{withDetails && (
|
||||
<div className={cn("text-[13px]", asRead ? "text-zinc-400" : "text-zinc-500")}>
|
||||
<EntryTranslation source={entry.entries.description} target={translation?.description} />
|
||||
<div
|
||||
className={cn(
|
||||
"text-[13px]",
|
||||
asRead ? "text-zinc-400 dark:text-neutral-500" : "text-zinc-500",
|
||||
entry.collections && "text-neutral-700 dark:text-zinc-300",
|
||||
)}
|
||||
>
|
||||
<EntryTranslation
|
||||
source={entry.entries.description}
|
||||
target={translation?.description}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{withAudio && entry.entries?.enclosures?.[0].url && (
|
||||
<audio className="mt-2 h-10 w-full" controls src={entry.entries?.enclosures?.[0].url} />
|
||||
<audio
|
||||
className="mt-2 h-10 w-full"
|
||||
controls
|
||||
src={entry.entries?.enclosures?.[0].url}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{withDetails && entry.entries.images?.[0] && (
|
||||
|
|
|
|||
|
|
@ -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?: {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -177,6 +177,7 @@ export function FeedList({
|
|||
feedId: FEED_COLLECTION_LIST,
|
||||
level: levels.folder,
|
||||
view,
|
||||
category: "",
|
||||
})
|
||||
}
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -39,3 +39,7 @@ export const SettingSwitch: Component<{
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SettingDescription: Component<{ children: string }> = ({
|
||||
children,
|
||||
}) => <small className="!mt-2 block w-4/5 text-balance text-[13px] leading-tight text-gray-400 dark:text-neutral-500">{children}</small>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
<SettingsTitle />
|
||||
|
|
@ -40,6 +41,15 @@ export function Component() {
|
|||
onCheckedChange={saveLoginSetting}
|
||||
/>
|
||||
)}
|
||||
<SettingSectionTitle title="Data control" />
|
||||
<SettingSwitch
|
||||
checked={shouldUseIndexedDB}
|
||||
onCheckedChange={setShouldUseIndexedDB}
|
||||
label="Persist data to offline usage"
|
||||
/>
|
||||
<SettingDescription>
|
||||
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.
|
||||
</SettingDescription>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
import type Dexie from "dexie"
|
||||
import type { UpdateSpec } from "dexie"
|
||||
|
||||
export abstract class BaseService<T extends { id: string }> {
|
||||
constructor(public readonly table: Dexie.Table<T, string>) {}
|
||||
|
||||
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> | 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<T>) {
|
||||
const oldData = await this.table.get(id)
|
||||
if (!oldData) return
|
||||
await this.table.update(id, {
|
||||
...oldData,
|
||||
...data,
|
||||
} as unknown as UpdateSpec<T>)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, any>
|
||||
}
|
||||
class ServiceStatic {
|
||||
async findAll(
|
||||
type: EntryRelatedKey,
|
||||
): Promise<Record<string, any>> {
|
||||
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<string, any>) {
|
||||
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()
|
||||
|
|
@ -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<EntryModel> {
|
||||
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<string, boolean>) {
|
||||
return EntryRelatedService.upsert(EntryRelatedKey.READ, record)
|
||||
}
|
||||
|
||||
bulkStoreFeedId(record: Record<string, string>) {
|
||||
return EntryRelatedService.upsert(EntryRelatedKey.FEED_ID, record)
|
||||
}
|
||||
|
||||
async bulkStoreCollection(record: Record<string, EntryCollection>) {
|
||||
return EntryRelatedService.upsert(EntryRelatedKey.COLLECTION, record)
|
||||
}
|
||||
|
||||
async deleteCollection(entryId: string) {
|
||||
return EntryRelatedService.deleteItem(EntryRelatedKey.COLLECTION, entryId)
|
||||
}
|
||||
}
|
||||
|
||||
export const EntryService = new EntryServiceStatic()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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<FeedModelWithId> {
|
||||
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()
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export * from "./entry"
|
||||
export * from "./entry-related"
|
||||
export * from "./feed"
|
||||
export * from "./feed-entry"
|
||||
export * from "./subscription"
|
||||
|
|
@ -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<SubscriptionModelWithId> {
|
||||
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()
|
||||
|
|
@ -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<string >): EntryModel | null =>
|
||||
export const useEntry = (entryId: Nullable<string >): 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[]
|
||||
|
|
|
|||
|
|
@ -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<EntryState & EntryActions>(
|
||||
"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<EntryState & EntryActions>(
|
|||
getFlattenMapEntries() {
|
||||
return get().flatMapEntries
|
||||
},
|
||||
optimisticUpdate(entryId: string, changed: Partial<EntryModel>) {
|
||||
patch(entryId, changed) {
|
||||
set((state) =>
|
||||
produce(state, (draft) => {
|
||||
const entry = draft.flatMapEntries[entryId]
|
||||
|
|
@ -82,7 +92,7 @@ export const useEntryStore = createZustandStore<EntryState & EntryActions>(
|
|||
}),
|
||||
)
|
||||
},
|
||||
optimisticUpdateManyByFeedId(feedId, changed) {
|
||||
patchManyByFeedId(feedId, changed) {
|
||||
set((state) =>
|
||||
produce(state, (draft) => {
|
||||
const ids = draft.entries[feedId]
|
||||
|
|
@ -96,7 +106,7 @@ export const useEntryStore = createZustandStore<EntryState & EntryActions>(
|
|||
}),
|
||||
)
|
||||
},
|
||||
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<EntryState & EntryActions>(
|
|||
)
|
||||
},
|
||||
|
||||
upsertMany(entries) {
|
||||
upsertMany(data) {
|
||||
const feeds = [] as FeedModel[]
|
||||
const entries = [] as EntryModel[]
|
||||
const entry2Read = {} as Record<string, boolean>
|
||||
const entryFeedMap = {} as Record<string, string>
|
||||
const entryCollection = {} as Record<string, any>
|
||||
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<EntryState & EntryActions>(
|
|||
})
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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<FeedId, EntryModel>
|
||||
flatMapEntries: Record<FeedId, CombinedEntryModel>
|
||||
/**
|
||||
* 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<FeedId, Set<EntryId>>
|
||||
// viewToStarIds: Record<FeedViewType, Set<EntryId>>
|
||||
starIds: Set<EntryId>
|
||||
// internal_updateSortIds: Record<FeedId, EntryId[]>
|
||||
}
|
||||
|
||||
|
|
@ -32,18 +34,19 @@ export interface EntryActions {
|
|||
|
||||
pageParam?: string
|
||||
}) => Promise<Awaited<ReturnType<typeof apiClient.entries.$post>>>
|
||||
fetchEntryById: (entryId: string) => Promise<EntryModel | undefined>
|
||||
upsertMany: (entries: EntryModel[]) => void
|
||||
fetchEntryById: (entryId: string) => Promise<CombinedEntryModel | undefined>
|
||||
upsertMany: (entries: CombinedEntryModel[]) => void
|
||||
|
||||
optimisticUpdate: (entryId: string, changed: Partial<EntryModel>) => void
|
||||
optimisticUpdateManyByFeedId: (
|
||||
patch: (entryId: string, changed: Partial<CombinedEntryModel>) => void
|
||||
patchManyByFeedId: (
|
||||
feedId: string,
|
||||
changed: Partial<EntryModel>
|
||||
changed: Partial<CombinedEntryModel>
|
||||
) => void
|
||||
optimisticUpdateAll: (changed: Partial<EntryModel>) => void
|
||||
getFlattenMapEntries: () => Record<string, EntryModel>
|
||||
patchAll: (changed: Partial<CombinedEntryModel>) => void
|
||||
getFlattenMapEntries: () => Record<string, CombinedEntryModel>
|
||||
markRead: (feedId: string, entryId: string, read: boolean) => void
|
||||
markReadByFeedId: (feedId: string) => void
|
||||
markStar: (entryId: string, starred: boolean) => void
|
||||
|
||||
clear: () => void
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<FeedState & FeedActions>(
|
||||
|
|
@ -15,6 +17,9 @@ export const useFeedStore = createZustandStore<FeedState & FeedActions>(
|
|||
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<FeedState & FeedActions>(
|
|||
)
|
||||
},
|
||||
|
||||
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) => {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ export interface FeedState {
|
|||
|
||||
export interface FeedActions {
|
||||
upsertMany: (feeds: FeedModel[]) => void
|
||||
optimisticUpdate: (feedId: FeedId, changed: Partial<FeedModel>) => void
|
||||
clear: () => void
|
||||
patch: (feedId: FeedId, patch: Partial<FeedModel>) => void
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<SubscriptionModel, "feeds">
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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<any> = {
|
||||
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<void> => {
|
||||
await del(name)
|
||||
},
|
||||
}
|
||||
export const localStorage: PersistStorage<any> = {
|
||||
getItem: (name: string) => {
|
||||
const data = window.localStorage.getItem(name)
|
||||
|
|
@ -47,7 +28,6 @@ export const localStorage: PersistStorage<any> = {
|
|||
},
|
||||
}
|
||||
enableMapSet()
|
||||
export const zustandStorage = dbStorage
|
||||
|
||||
export const createZustandStore =
|
||||
<
|
||||
|
|
@ -60,15 +40,15 @@ export const createZustandStore =
|
|||
>(
|
||||
name: string,
|
||||
options?: Partial<PersistOptions<S> & {
|
||||
disablePersist?: boolean
|
||||
persist?: boolean
|
||||
}>,
|
||||
) =>
|
||||
(store: T) => {
|
||||
const newStore = !options?.disablePersist ?
|
||||
const newStore = options?.persist ?
|
||||
createWithEqualityFn(
|
||||
persist<S>(store, {
|
||||
name,
|
||||
storage: zustandStorage,
|
||||
storage: localStorage,
|
||||
...options,
|
||||
}),
|
||||
shallow,
|
||||
|
|
|
|||
|
|
@ -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<string, FeedModel>) {
|
||||
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.`,
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -12,6 +12,7 @@
|
|||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"jsx": "react-jsx",
|
||||
"noUnusedLocals": false,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@renderer/*": [
|
||||
|
|
|
|||
Loading…
Reference in New Issue