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:
Innei 2024-06-29 01:05:12 +08:00 committed by GitHub
parent e1ae88f691
commit be6968cf66
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
52 changed files with 800 additions and 176 deletions

View File

@ -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",

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
export const LOCAL_DB_NAME = "FOLLOW_DB"

View File

@ -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"]

View File

@ -0,0 +1,7 @@
export const dbSchemaV1 = {
entries: "&id",
feeds: "&id",
subscriptions: "&id",
entryRelated: "&id",
feedEntries: "&feedId",
}

View File

@ -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()))

View File

@ -0,0 +1,4 @@
export * from "./db"
export * from "./hooks"
export * from "./models"
export * from "./schemas"

View File

@ -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
}
}

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -0,0 +1,5 @@
export * from "./entry"
export * from "./entry-related"
export * from "./feed"
export * from "./feed-entry"
export * from "./subscription"

View File

@ -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()

View File

@ -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>

View File

@ -0,0 +1 @@
export * from "./entry"

View File

@ -0,0 +1,5 @@
export type DBModel<T> = T & {
createdAt: number
id: string
updatedAt: number
}

View File

@ -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])
}

View File

@ -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 = () =>

View File

@ -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"],

View File

@ -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,

View File

@ -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>,
)
}

View File

@ -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"],

View File

@ -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}

View File

@ -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,

View File

@ -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] && (

View File

@ -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?: {

View File

@ -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()

View File

@ -177,6 +177,7 @@ export function FeedList({
feedId: FEED_COLLECTION_LIST,
level: levels.folder,
view,
category: "",
})
}
}}

View File

@ -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>

View File

@ -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>
</>
)

View File

@ -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[]

View File

@ -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>)
}
}

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -0,0 +1,5 @@
export * from "./entry"
export * from "./entry-related"
export * from "./feed"
export * from "./feed-entry"
export * from "./subscription"

View File

@ -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()

View File

@ -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[]

View File

@ -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)

View File

@ -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
}

View File

@ -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) => {

View File

@ -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
}

View File

@ -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 })
}
}
},

View File

@ -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
}

View File

@ -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()
}

View File

@ -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,

View File

@ -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.`,
)
}

View File

@ -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)

View File

@ -12,6 +12,7 @@
"compilerOptions": {
"composite": true,
"jsx": "react-jsx",
"noUnusedLocals": false,
"baseUrl": ".",
"paths": {
"@renderer/*": [