From f1a34812fbb7ef0a503fc4e1c8ebdc46e376e391 Mon Sep 17 00:00:00 2001 From: Innei Date: Fri, 6 Sep 2024 00:46:30 +0800 Subject: [PATCH] feat: setting sync (#273) * init Signed-off-by: Innei * feat: offline Signed-off-by: Innei * fix: update Signed-off-by: Innei * fix: chain Signed-off-by: Innei * update Signed-off-by: Innei * update Signed-off-by: Innei * update Signed-off-by: Innei * update Signed-off-by: Innei * feat: render social media with full text --------- Signed-off-by: Innei Co-authored-by: DIYgod --- src/hono.ts | 1591 +++++++++-------- src/renderer/src/atoms/settings/general.ts | 6 + src/renderer/src/atoms/settings/helper.ts | 27 +- src/renderer/src/atoms/settings/ui.ts | 8 + .../src/components/icons/PhCloudCheck.tsx | 18 + .../src/components/icons/PhCloudX.tsx | 7 + src/renderer/src/initialize/index.ts | 48 +- src/renderer/src/initialize/migrates/index.ts | 43 + src/renderer/src/initialize/queue.ts | 4 +- src/renderer/src/lib/defineQuery.ts | 3 +- src/renderer/src/lib/event-bus.ts | 35 + src/renderer/src/lib/utils.ts | 4 +- .../src/modules/feed-column/corner-player.tsx | 2 +- .../modules/settings/helper/SyncIndicator.tsx | 49 + .../src/modules/settings/helper/sync-queue.ts | 283 +++ .../src/modules/settings/modal/layout.tsx | 43 +- .../settings/{modules => sections}/fonts.tsx | 0 .../src/modules/settings/tabs/apperance.tsx | 2 +- src/renderer/src/queries/settings.ts | 7 + 19 files changed, 1392 insertions(+), 788 deletions(-) create mode 100644 src/renderer/src/components/icons/PhCloudCheck.tsx create mode 100644 src/renderer/src/components/icons/PhCloudX.tsx create mode 100644 src/renderer/src/initialize/migrates/index.ts create mode 100644 src/renderer/src/lib/event-bus.ts create mode 100644 src/renderer/src/modules/settings/helper/SyncIndicator.tsx create mode 100644 src/renderer/src/modules/settings/helper/sync-queue.ts rename src/renderer/src/modules/settings/{modules => sections}/fonts.tsx (100%) create mode 100644 src/renderer/src/queries/settings.ts diff --git a/src/hono.ts b/src/hono.ts index e392ef7dc..5f6bcf494 100644 --- a/src/hono.ts +++ b/src/hono.ts @@ -82,11 +82,11 @@ declare const actionsItemOpenAPISchema: z.ZodObject<{ value: z.ZodString; }, "strip", z.ZodTypeAny, { value: string; - field: "title" | "view" | "site_url" | "category" | "feed_url"; + field: "title" | "view" | "site_url" | "feed_url" | "category"; operator: "contains" | "not_contains" | "eq" | "not_eq" | "gt" | "lt" | "regex"; }, { value: string; - field: "title" | "view" | "site_url" | "category" | "feed_url"; + field: "title" | "view" | "site_url" | "feed_url" | "category"; operator: "contains" | "not_contains" | "eq" | "not_eq" | "gt" | "lt" | "regex"; }>, "many">; result: z.ZodObject<{ @@ -108,11 +108,11 @@ declare const actionsItemOpenAPISchema: z.ZodObject<{ value: z.ZodUnion<[z.ZodString, z.ZodNumber]>; }, "strip", z.ZodTypeAny, { value: string | number; - field: "title" | "content" | "url" | "all" | "author" | "order"; + field: "title" | "content" | "all" | "author" | "url" | "order"; operator: "contains" | "not_contains" | "eq" | "not_eq" | "gt" | "lt" | "regex"; }, { value: string | number; - field: "title" | "content" | "url" | "all" | "author" | "order"; + field: "title" | "content" | "all" | "author" | "url" | "order"; operator: "contains" | "not_contains" | "eq" | "not_eq" | "gt" | "lt" | "regex"; }>, "many">>; }, "strip", z.ZodTypeAny, { @@ -124,7 +124,7 @@ declare const actionsItemOpenAPISchema: z.ZodObject<{ }[] | undefined; blockRules?: { value: string | number; - field: "title" | "content" | "url" | "all" | "author" | "order"; + field: "title" | "content" | "all" | "author" | "url" | "order"; operator: "contains" | "not_contains" | "eq" | "not_eq" | "gt" | "lt" | "regex"; }[] | undefined; }, { @@ -136,7 +136,7 @@ declare const actionsItemOpenAPISchema: z.ZodObject<{ }[] | undefined; blockRules?: { value: string | number; - field: "title" | "content" | "url" | "all" | "author" | "order"; + field: "title" | "content" | "all" | "author" | "url" | "order"; operator: "contains" | "not_contains" | "eq" | "not_eq" | "gt" | "lt" | "regex"; }[] | undefined; }>; @@ -144,7 +144,7 @@ declare const actionsItemOpenAPISchema: z.ZodObject<{ name: string; condition: { value: string; - field: "title" | "view" | "site_url" | "category" | "feed_url"; + field: "title" | "view" | "site_url" | "feed_url" | "category"; operator: "contains" | "not_contains" | "eq" | "not_eq" | "gt" | "lt" | "regex"; }[]; result: { @@ -156,7 +156,7 @@ declare const actionsItemOpenAPISchema: z.ZodObject<{ }[] | undefined; blockRules?: { value: string | number; - field: "title" | "content" | "url" | "all" | "author" | "order"; + field: "title" | "content" | "all" | "author" | "url" | "order"; operator: "contains" | "not_contains" | "eq" | "not_eq" | "gt" | "lt" | "regex"; }[] | undefined; }; @@ -164,7 +164,7 @@ declare const actionsItemOpenAPISchema: z.ZodObject<{ name: string; condition: { value: string; - field: "title" | "view" | "site_url" | "category" | "feed_url"; + field: "title" | "view" | "site_url" | "feed_url" | "category"; operator: "contains" | "not_contains" | "eq" | "not_eq" | "gt" | "lt" | "regex"; }[]; result: { @@ -176,7 +176,7 @@ declare const actionsItemOpenAPISchema: z.ZodObject<{ }[] | undefined; blockRules?: { value: string | number; - field: "title" | "content" | "url" | "all" | "author" | "order"; + field: "title" | "content" | "all" | "author" | "url" | "order"; operator: "contains" | "not_contains" | "eq" | "not_eq" | "gt" | "lt" | "regex"; }[] | undefined; }; @@ -197,11 +197,11 @@ declare const actionsOpenAPISchema: z.ZodObject, "many">; result: z.ZodObject<{ @@ -223,11 +223,11 @@ declare const actionsOpenAPISchema: z.ZodObject; }, "strip", z.ZodTypeAny, { value: string | number; - field: "title" | "content" | "url" | "all" | "author" | "order"; + field: "title" | "content" | "all" | "author" | "url" | "order"; operator: "contains" | "not_contains" | "eq" | "not_eq" | "gt" | "lt" | "regex"; }, { value: string | number; - field: "title" | "content" | "url" | "all" | "author" | "order"; + field: "title" | "content" | "all" | "author" | "url" | "order"; operator: "contains" | "not_contains" | "eq" | "not_eq" | "gt" | "lt" | "regex"; }>, "many">>; }, "strip", z.ZodTypeAny, { @@ -239,7 +239,7 @@ declare const actionsOpenAPISchema: z.ZodObject; @@ -259,7 +259,7 @@ declare const actionsOpenAPISchema: z.ZodObject; @@ -805,10 +805,10 @@ declare const entriesOpenAPISchema: z.ZodObject; }>; +declare const settings: drizzle_orm_pg_core.PgTableWithColumns<{ + name: "settings"; + schema: undefined; + columns: { + id: drizzle_orm_pg_core.PgColumn<{ + name: "id"; + tableName: "settings"; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: true; + isAutoincrement: false; + hasRuntimeDefault: true; + enumValues: [string, ...string[]]; + baseColumn: never; + generated: undefined; + }, {}, {}>; + userId: drizzle_orm_pg_core.PgColumn<{ + name: "user_id"; + tableName: "settings"; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + generated: undefined; + }, {}, {}>; + tab: drizzle_orm_pg_core.PgColumn<{ + name: "tab"; + tableName: "settings"; + dataType: "string"; + columnType: "PgText"; + data: "general" | "appearance" | "integration"; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: ["general", "appearance", "integration"]; + baseColumn: never; + generated: undefined; + }, {}, {}>; + payload: drizzle_orm_pg_core.PgColumn<{ + name: "payload"; + tableName: "settings"; + dataType: "json"; + columnType: "PgJsonb"; + data: Record; + driverParam: unknown; + notNull: false; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + generated: undefined; + }, {}, {}>; + updateAt: drizzle_orm_pg_core.PgColumn<{ + name: "update_at"; + tableName: "settings"; + dataType: "date"; + columnType: "PgTimestamp"; + data: Date; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + generated: undefined; + }, {}, {}>; + version: drizzle_orm_pg_core.PgColumn<{ + name: "version"; + tableName: "settings"; + dataType: "number"; + columnType: "PgInteger"; + data: number; + driverParam: string | number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + generated: undefined; + }, {}, {}>; + }; + dialect: "pg"; +}>; + declare const subscriptions: drizzle_orm_pg_core.PgTableWithColumns<{ name: "subscriptions"; schema: undefined; @@ -1466,17 +1570,17 @@ declare const subscriptionsOpenAPISchema: zod.ZodObject<{ isPrivate: zod.ZodBoolean; }, zod.UnknownKeysParam, zod.ZodTypeAny, { title: string | null; - view: number; userId: string; - feedId: string; + view: number; category: string | null; + feedId: string; isPrivate: boolean; }, { title: string | null; - view: number; userId: string; - feedId: string; + view: number; category: string | null; + feedId: string; isPrivate: boolean; }>; declare const subscriptionsRelations: drizzle_orm.Relations<"subscriptions", { @@ -1612,21 +1716,21 @@ declare const timelineOpenAPISchema: zod.ZodObject<{ view: zod.ZodNumber; read: zod.ZodNullable; }, zod.UnknownKeysParam, zod.ZodTypeAny, { - view: number; userId: string; + view: number; feedId: string; - read: boolean | null; insertedAt: string; publishedAt: string; entryId: string; + read: boolean | null; }, { - view: number; userId: string; + view: number; feedId: string; - read: boolean | null; insertedAt: string; publishedAt: string; entryId: string; + read: boolean | null; }>; declare const timelineRelations: drizzle_orm.Relations<"timeline", { entries: drizzle_orm.One<"entries", true>; @@ -2193,15 +2297,15 @@ declare const walletsOpenAPISchema: zod.ZodObject<{ dailyPowerToken: zod.ZodString; cashablePowerToken: zod.ZodString; }, zod.UnknownKeysParam, zod.ZodTypeAny, { - userId: string; createdAt: string; + userId: string; address: string | null; addressIndex: number; dailyPowerToken: string; cashablePowerToken: string; }, { - userId: string; createdAt: string; + userId: string; address: string | null; addressIndex: number; dailyPowerToken: string; @@ -2377,9 +2481,9 @@ declare const transactionsOpenAPISchema: zod.ZodObject<{ }, zod.UnknownKeysParam, zod.ZodTypeAny, { type: "tip" | "mint" | "burn" | "withdraw" | "purchase"; createdAt: string; - hash: string; fromUserId: string | null; toUserId: string | null; + hash: string; toFeedId: string | null; toEntryId: string | null; powerToken: string; @@ -2387,9 +2491,9 @@ declare const transactionsOpenAPISchema: zod.ZodObject<{ }, { type: "tip" | "mint" | "burn" | "withdraw" | "purchase"; createdAt: string; - hash: string; fromUserId: string | null; toUserId: string | null; + hash: string; toFeedId: string | null; toEntryId: string | null; powerToken: string; @@ -2456,100 +2560,6 @@ declare const feedPowerTokensRelations: drizzle_orm.Relations<"feedPowerTokens", }>; declare const _routes: hono_hono_base.HonoBase; }; output: { code: 0; @@ -2905,8 +2931,8 @@ declare const _routes: hono_hono_base.HonoBase; type AppType = typeof _routes; -export { type ActionsModel, type AppType, type AttachmentsModel, type EntriesModel, type EntryReadHistoriesModel, type FeedModel, type MediaModel, type SettingsModel, accounts, actions, actionsItemOpenAPISchema, actionsOpenAPISchema, actionsRelations, collections, collectionsOpenAPISchema, collectionsRelations, entries, entriesOpenAPISchema, entriesRelations, entryReadHistories, entryReadHistoriesOpenAPISchema, entryReadHistoriesRelations, feedPowerTokens, feedPowerTokensOpenAPISchema, feedPowerTokensRelations, feeds, feedsInputSchema, feedsOpenAPISchema, feedsRelations, invitations, invitationsOpenAPISchema, invitationsRelations, languageSchema, sessions, subscriptions, subscriptionsOpenAPISchema, subscriptionsRelations, timeline, timelineOpenAPISchema, timelineRelations, transactionType, transactions, transactionsOpenAPISchema, transactionsRelations, users, usersOpenApiSchema, usersRelations, verificationTokens, wallets, walletsOpenAPISchema, walletsRelations }; +export { type ActionsModel, type AppType, type AttachmentsModel, type EntriesModel, type EntryReadHistoriesModel, type FeedModel, type MediaModel, type SettingsModel, accounts, actions, actionsItemOpenAPISchema, actionsOpenAPISchema, actionsRelations, collections, collectionsOpenAPISchema, collectionsRelations, entries, entriesOpenAPISchema, entriesRelations, entryReadHistories, entryReadHistoriesOpenAPISchema, entryReadHistoriesRelations, feedPowerTokens, feedPowerTokensOpenAPISchema, feedPowerTokensRelations, feeds, feedsInputSchema, feedsOpenAPISchema, feedsRelations, invitations, invitationsOpenAPISchema, invitationsRelations, languageSchema, sessions, settings, subscriptions, subscriptionsOpenAPISchema, subscriptionsRelations, timeline, timelineOpenAPISchema, timelineRelations, transactionType, transactions, transactionsOpenAPISchema, transactionsRelations, users, usersOpenApiSchema, usersRelations, verificationTokens, wallets, walletsOpenAPISchema, walletsRelations }; diff --git a/src/renderer/src/atoms/settings/general.ts b/src/renderer/src/atoms/settings/general.ts index e41737bc5..0095a8708 100644 --- a/src/renderer/src/atoms/settings/general.ts +++ b/src/renderer/src/atoms/settings/general.ts @@ -40,3 +40,9 @@ export const subscribeShouldUseIndexedDB = ( ) => jotaiStore.sub(__generalSettingAtom, () => callback(getGeneralSettings().dataPersist)) + +export const generalServerSyncWhiteListKeys: (keyof GeneralSettings)[] = [ + "appLaunchOnStartup", + "dataPersist", + "sendAnonymousData", +] diff --git a/src/renderer/src/atoms/settings/helper.ts b/src/renderer/src/atoms/settings/helper.ts index 067810e82..491b3cb9d 100644 --- a/src/renderer/src/atoms/settings/helper.ts +++ b/src/renderer/src/atoms/settings/helper.ts @@ -1,4 +1,5 @@ import { useRefValue } from "@renderer/hooks/common" +import { EventBus } from "@renderer/lib/event-bus" import { createAtomHooks } from "@renderer/lib/jotai" import { getStorageNS } from "@renderer/lib/ns" import type { SettingItem } from "@renderer/modules/settings/setting-builder" @@ -7,6 +8,16 @@ import { useAtomValue } from "jotai" import { atomWithStorage, selectAtom } from "jotai/utils" import { useMemo } from "react" +declare module "@renderer/lib/event-bus" { + interface CustomEvent { + SETTING_CHANGE_EVENT: { + updated: number + payload: Record + key: string + } + } +} + export const createSettingAtom = ( settingKey: string, createDefaultSettings: () => T, @@ -19,6 +30,7 @@ export const createSettingAtom = ( getOnInit: true, }, ) + const [, , useSettingValue, , getSettings, setSettings] = createAtomHooks(atom) @@ -53,9 +65,18 @@ export const createSettingAtom = ( key: K, value: ReturnType[K], ) => { + const updated = Date.now() setSettings({ ...getSettings(), [key]: value, + + updated, + }) + + EventBus.dispatch("SETTING_CHANGE_EVENT", { + payload: { [key]: value }, + updated, + key: settingKey, }) } @@ -104,8 +125,10 @@ export const createDefineSettingItem = description?: string | JSX.Element onChange?: (value: T[K]) => void hide?: boolean - - } & Omit, "onChange" | "description" | "label" | "hide" | "key">, + } & Omit< + SettingItem, + "onChange" | "description" | "label" | "hide" | "key" + >, ): any => { const { label, description, onChange, hide, ...rest } = options diff --git a/src/renderer/src/atoms/settings/ui.ts b/src/renderer/src/atoms/settings/ui.ts index 0b7df7bec..8a14b5d1e 100644 --- a/src/renderer/src/atoms/settings/ui.ts +++ b/src/renderer/src/atoms/settings/ui.ts @@ -43,4 +43,12 @@ export const { initializeDefaultSettings: initializeDefaultUISettings, getSettings: getUISettings, useSettingValue: useUISettingValue, + settingAtom: __uiSettingAtom, } = createSettingAtom("ui", createDefaultSettings) + +export const uiServerSyncWhiteListKeys: (keyof UISettings)[] = [ + "uiFontFamily", + "readerFontFamily", + "opaqueSidebar", + "voice", +] diff --git a/src/renderer/src/components/icons/PhCloudCheck.tsx b/src/renderer/src/components/icons/PhCloudCheck.tsx new file mode 100644 index 000000000..39640f1f4 --- /dev/null +++ b/src/renderer/src/components/icons/PhCloudCheck.tsx @@ -0,0 +1,18 @@ +import type { SVGProps } from "react" + +export function PhCloudCheck(props: SVGProps) { + return ( + + + + ) +} diff --git a/src/renderer/src/components/icons/PhCloudX.tsx b/src/renderer/src/components/icons/PhCloudX.tsx new file mode 100644 index 000000000..f82bdb8de --- /dev/null +++ b/src/renderer/src/components/icons/PhCloudX.tsx @@ -0,0 +1,7 @@ +import type { SVGProps } from "react" + +export function PhCloudX(props: SVGProps) { + return ( + + ) +} diff --git a/src/renderer/src/initialize/index.ts b/src/renderer/src/initialize/index.ts index a1c92d721..b80553382 100644 --- a/src/renderer/src/initialize/index.ts +++ b/src/renderer/src/initialize/index.ts @@ -4,8 +4,11 @@ import { repository } from "@pkg" import { getUISettings } from "@renderer/atoms/settings/ui" import { isElectronBuild } from "@renderer/constants" import { browserDB } from "@renderer/database" -import { getStorageNS } from "@renderer/lib/ns" -import { ElectronCloseEvent, ElectronShowEvent } from "@renderer/providers/invalidate-query-provider" +import { settingSyncQueue } from "@renderer/modules/settings/helper/sync-queue" +import { + ElectronCloseEvent, + ElectronShowEvent, +} from "@renderer/providers/invalidate-query-provider" import { CleanerService } from "@renderer/services/cleaner" import { registerGlobalContext } from "@shared/bridge" import dayjs from "dayjs" @@ -13,7 +16,6 @@ import duration from "dayjs/plugin/duration" import localizedFormat from "dayjs/plugin/localizedFormat" import relativeTime from "dayjs/plugin/relativeTime" import { enableMapSet } from "immer" -import { createElement } from "react" import { toast } from "sonner" import { subscribeNetworkStatus } from "../atoms/network" @@ -27,8 +29,8 @@ import { hydrateSettings, setHydrated, } from "./hydrate" +import { doMigration } from "./migrates" import { initPostHog } from "./posthog" -import { pushAfterReadyCallback } from "./queue" import { initSentry } from "./sentry" const cleanup = subscribeShouldUseIndexedDB((value) => { @@ -48,8 +50,6 @@ declare global { } } -const appVersionKey = getStorageNS("app_version") - export const initializeApp = async () => { appLog(`${APP_NAME}: Next generation information browser`, repository.url) appLog(`Initialize ${APP_NAME}...`) @@ -62,37 +62,7 @@ export const initializeApp = async () => { "electron" : "web" - const lastVersion = localStorage.getItem(appVersionKey) - - if (lastVersion && lastVersion !== APP_VERSION) { - appLog(`Upgrade from ${lastVersion} to ${APP_VERSION}`) - - pushAfterReadyCallback(() => { - setTimeout(() => { - toast.success( - // `App is upgraded to ${APP_VERSION}, enjoy the new features! 🎉`, - createElement("div", { - children: [ - "App is upgraded to ", - createElement( - "a", - { - href: `${repository.url}/releases/tag/${APP_VERSION}`, - target: "_blank", - className: "underline", - }, - createElement("strong", { - children: APP_VERSION, - }), - ), - ", enjoy the new features! 🎉", - ], - }), - ) - }, 1000) - }) - } - localStorage.setItem(appVersionKey, APP_VERSION) + doMigration() // Initialize dayjs dayjs.extend(duration) @@ -122,6 +92,10 @@ export const initializeApp = async () => { }) hydrateSettings() + + settingSyncQueue.init() + settingSyncQueue.syncLocal() + // should after hydrateSettings const { dataPersist: enabledDataPersist, sendAnonymousData } = getGeneralSettings() diff --git a/src/renderer/src/initialize/migrates/index.ts b/src/renderer/src/initialize/migrates/index.ts new file mode 100644 index 000000000..8cc5d9b0e --- /dev/null +++ b/src/renderer/src/initialize/migrates/index.ts @@ -0,0 +1,43 @@ +import { repository } from "@pkg" +import { appLog } from "@renderer/lib/log" +import { getStorageNS } from "@renderer/lib/ns" +import { createElement } from "react" +import { toast } from "sonner" + +import { waitAppReady } from "../queue" + +const appVersionKey = getStorageNS("app_version") + +export const doMigration = () => { + const lastVersion = localStorage.getItem(appVersionKey) + + if (lastVersion && lastVersion !== APP_VERSION) { + appLog(`Upgrade from ${lastVersion} to ${APP_VERSION}`) + + waitAppReady(() => { + toast.success( + // `App is upgraded to ${APP_VERSION}, enjoy the new features! 🎉`, + createElement("div", { + children: [ + "App is upgraded to ", + createElement( + "a", + { + href: `${repository.url}/releases/tag/${APP_VERSION}`, + target: "_blank", + className: "underline", + }, + createElement("strong", { + children: APP_VERSION, + }), + ), + ", enjoy the new features! 🎉", + ], + }), + ) + }, 1000) + + // NOTE: Add migration logic here + } + localStorage.setItem(appVersionKey, APP_VERSION) +} diff --git a/src/renderer/src/initialize/queue.ts b/src/renderer/src/initialize/queue.ts index 1991e541a..da7efa980 100644 --- a/src/renderer/src/initialize/queue.ts +++ b/src/renderer/src/initialize/queue.ts @@ -2,9 +2,9 @@ import { appIsReady } from "@renderer/atoms/app" const afterReadyCallbackQueue = [] as Array<() => void> -export const pushAfterReadyCallback = (callback: () => void) => { +export const waitAppReady = (callback: () => void, delay = 0) => { if (appIsReady()) { - callback() + delay ? callback() : setTimeout(callback, delay) } else { afterReadyCallbackQueue.push(callback) } diff --git a/src/renderer/src/lib/defineQuery.ts b/src/renderer/src/lib/defineQuery.ts index 139f25e98..33fc55aa8 100644 --- a/src/renderer/src/lib/defineQuery.ts +++ b/src/renderer/src/lib/defineQuery.ts @@ -20,7 +20,7 @@ export type DefinedQuery = Readonly<{ invalidateRoot: () => void refetch: () => Promise - prefetch: () => Promise + prefetch: () => Promise setData: ( updater: (draft: Draft) => ValidRecipeReturnType> @@ -98,6 +98,7 @@ export function defineQuery< queryKey: key, queryFn: fn, }) + return queryClient.getQueryData(key) }, cancel: async (keyExtactor) => { const queryKey = diff --git a/src/renderer/src/lib/event-bus.ts b/src/renderer/src/lib/event-bus.ts new file mode 100644 index 000000000..76e3e5922 --- /dev/null +++ b/src/renderer/src/lib/event-bus.ts @@ -0,0 +1,35 @@ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface CustomEvent {} +export interface EventBusMap extends CustomEvent {} + +class EventBusEvent extends Event { + static type = "EventBusEvent" + constructor(public _type: string, public data: any) { + super(EventBusEvent.type) + } +} +class EventBusStatic { + dispatch(event: T, data: EventBusMap[T]) { + window.dispatchEvent(new EventBusEvent(event, data)) + } + + subscribe( + event: T, + callback: (data: EventBusMap[T]) => void, + ) { + const handler = (e: any) => { + if (e instanceof EventBusEvent && e._type === event) { + callback(e.data) + } + } + window.addEventListener(EventBusEvent.type, handler) + + return this.unsubscribe.bind(this, event, handler) + } + + unsubscribe(event: string, handler: (e: any) => void) { + window.removeEventListener(EventBusEvent.type, handler) + } +} + +export const EventBus = new EventBusStatic() diff --git a/src/renderer/src/lib/utils.ts b/src/renderer/src/lib/utils.ts index abf224c1c..c3bd5c64d 100644 --- a/src/renderer/src/lib/utils.ts +++ b/src/renderer/src/lib/utils.ts @@ -147,7 +147,7 @@ export function formatXml(xml: string, indent = 4) { } export const sleep = (ms: number) => - new Promise((resolve) => setTimeout(resolve, ms)) + new Promise((resolve) => setTimeout(resolve, ms)) export const capitalizeFirstLetter = (string: string) => string.charAt(0).toUpperCase() + string.slice(1) @@ -247,3 +247,5 @@ export const getUrlIcon = (url: string, fallback?: boolean | undefined) => { return ret } + +export const isEmptyObject = (obj: Record) => Object.keys(obj).length === 0 diff --git a/src/renderer/src/modules/feed-column/corner-player.tsx b/src/renderer/src/modules/feed-column/corner-player.tsx index f92598d61..c90441a30 100644 --- a/src/renderer/src/modules/feed-column/corner-player.tsx +++ b/src/renderer/src/modules/feed-column/corner-player.tsx @@ -40,7 +40,7 @@ export const CornerPlayer = () => { {show && entry && feed && ( { + const { data: remoteSettings, isLoading } = useAuthQuery(settings.get(), {}) + + const isOnline = useIsOnline() + const onceRef = useRef(false) + useEffect(() => { + if (!isLoading && remoteSettings && !onceRef.current) { + const hasSetting = JSON.stringify(remoteSettings.settings) !== "{}" + onceRef.current = true + if (hasSetting) { + return + } + // Replace local to remote + settingSyncQueue.replaceRemote() + } + }, [remoteSettings, isLoading]) + + return ( + + +
+ {isOnline ? ( + + ) : ( + + )} +
+
+ +
+ {isOnline ? "Synced with server" : "Offline"} +
+
+
+ ) +} diff --git a/src/renderer/src/modules/settings/helper/sync-queue.ts b/src/renderer/src/modules/settings/helper/sync-queue.ts new file mode 100644 index 000000000..b35ce8efd --- /dev/null +++ b/src/renderer/src/modules/settings/helper/sync-queue.ts @@ -0,0 +1,283 @@ +import { + __generalSettingAtom, + generalServerSyncWhiteListKeys, + getGeneralSettings, +} from "@renderer/atoms/settings/general" +import { + __uiSettingAtom, + getUISettings, + uiServerSyncWhiteListKeys, +} from "@renderer/atoms/settings/ui" +import { apiClient } from "@renderer/lib/api-fetch" +import { EventBus } from "@renderer/lib/event-bus" +import { jotaiStore } from "@renderer/lib/jotai" +import { getStorageNS } from "@renderer/lib/ns" +import { isEmptyObject, sleep } from "@renderer/lib/utils" +import { settings } from "@renderer/queries/settings" +import type { GeneralSettings, UISettings } from "@shared/interface/settings" +import type { PrimitiveAtom } from "jotai" +import { omit } from "lodash-es" + +type SettingMapping = { + appearance: UISettings + general: GeneralSettings +} + +const omitKeys = ["updated"] + +const localSettingGetterMap = { + appearance: () => omit(getUISettings(), uiServerSyncWhiteListKeys, omitKeys), + general: () => + omit(getGeneralSettings(), generalServerSyncWhiteListKeys, omitKeys), +} + +const createInternalSetter = + (atom: PrimitiveAtom) => + (payload: T) => { + const current = jotaiStore.get(atom) + jotaiStore.set(atom, { ...current, ...payload }) + } + +const localsettingSetterMap = { + appearance: createInternalSetter(__uiSettingAtom), + general: createInternalSetter(__generalSettingAtom), +} +const settingWhiteListMap = { + appearance: uiServerSyncWhiteListKeys, + general: generalServerSyncWhiteListKeys, +} + +const bizSettingKeyToTabMapping = { + ui: "appearance", + general: "general", +} + +export type SettingSyncTab = keyof SettingMapping +export interface SettingSyncQueueItem< + T extends SettingSyncTab = SettingSyncTab, +> { + tab: T + payload: Partial + date: number +} +class SettingSyncQueue { + queue: SettingSyncQueueItem[] = [] + + private disposers: (() => void)[] = [] + async init() { + this.teardown() + + this.load() + this.flush() + + const d1 = EventBus.subscribe("SETTING_CHANGE_EVENT", (data) => { + const tab = bizSettingKeyToTabMapping[data.key] + if (!tab) return + + const nextPayload = omit( + data.payload, + omitKeys, + settingWhiteListMap[tab], + ) + if (isEmptyObject(nextPayload)) return + this.enqueue(tab, nextPayload) + }) + const onlineHandler = () => + (this.chain = this.chain.finally(() => this.flush())) + + window.addEventListener("online", onlineHandler) + const d2 = () => window.removeEventListener("online", onlineHandler) + + const unloadHandler = () => this.persist() + + window.addEventListener("beforeunload", unloadHandler) + const d3 = () => window.removeEventListener("beforeunload", unloadHandler) + + this.disposers.push(d1, d2, d3) + } + + teardown() { + for (const disposer of this.disposers) { + disposer() + } + this.queue = [] + } + + private readonly storageKey = getStorageNS("setting_sync_queue") + private persist() { + if (this.queue.length === 0) { + return + } + localStorage.setItem(this.storageKey, JSON.stringify(this.queue)) + } + + private load() { + const queue = localStorage.getItem(this.storageKey) + localStorage.removeItem(this.storageKey) + if (!queue) { + return + } + + try { + this.queue = JSON.parse(queue) + } catch { + /* empty */ + } + } + + private chain = Promise.resolve() + + private threshold = 1000 + private enqueueTime = Date.now() + + async enqueue( + tab: T, + payload: Partial, + ) { + const now = Date.now() + if (isEmptyObject(payload)) { + return + } + this.queue.push({ + tab, + payload, + date: now, + }) + + if (now - this.enqueueTime > this.threshold) { + this.chain = this.chain + .then(() => sleep(this.threshold)) + .finally(() => this.flush()) + this.enqueueTime = Date.now() + } + } + + private async flush() { + if (navigator.onLine === false) { + return + } + + const groupedTab = {} as Record + + const referenceMap = {} as Record< + SettingSyncTab, + Set + > + for (const item of this.queue) { + if (!groupedTab[item.tab]) { + groupedTab[item.tab] = {} + } + + referenceMap[item.tab] ||= new Set() + referenceMap[item.tab].add(item) + + groupedTab[item.tab] = { + ...groupedTab[item.tab], + ...item.payload, + } + } + + const promises = [] as Promise[] + for (const tab in groupedTab) { + const json = omit(groupedTab[tab], omitKeys, settingWhiteListMap[tab]) + + if (isEmptyObject(json)) { + continue + } + const promise = apiClient.settings[":tab"] + .$patch({ + param: { + tab, + }, + json, + }) + .then(() => { + // remove from queue + for (const item of referenceMap[tab]) { + const index = this.queue.indexOf(item) + if (index !== -1) { + this.queue.splice(index, 1) + } + } + }) + // TODO rollback or retry + promises.push(promise) + } + + await Promise.all(promises) + } + + replaceRemote(tab?: SettingSyncTab) { + if (!tab) { + const promises = [] as Promise[] + for (const tab in localSettingGetterMap) { + const payload = localSettingGetterMap[tab]() + const promise = apiClient.settings[":tab"].$patch({ + param: { + tab, + }, + json: payload, + }) + + promises.push(promise) + } + + this.chain = this.chain.finally(() => Promise.all(promises)) + return this.chain + } else { + const payload = localSettingGetterMap[tab]() + + this.chain = this.chain.finally(() => + apiClient.settings[":tab"].$patch({ + param: { + tab, + }, + json: payload, + }), + ) + + return this.chain + } + } + + async syncLocal() { + const remoteSettings = await settings.get().prefetch() + + if (!remoteSettings) return + + if (isEmptyObject(remoteSettings.settings)) return + + for (const tab in remoteSettings.settings) { + const remoteSettingPayload = remoteSettings.settings[tab] + const updated = remoteSettings.updated[tab] + + if (!updated) { + continue + } + + const remoteUpdatedDate = new Date(updated).getTime() + + const localSettings = localSettingGetterMap[tab]() + const localSettingsUpdated = localSettings.updated + + if (!localSettingsUpdated || remoteUpdatedDate > localSettingsUpdated) { + // Use remote and update local + const nextPayload = omit( + remoteSettingPayload, + omitKeys, + settingWhiteListMap[tab], + ) + + if (isEmptyObject(nextPayload)) { + continue + } + + const setter = localsettingSetterMap[tab] + + setter(nextPayload) + } + } + } +} + +export const settingSyncQueue = new SettingSyncQueue() diff --git a/src/renderer/src/modules/settings/modal/layout.tsx b/src/renderer/src/modules/settings/modal/layout.tsx index 76e3659b6..9386d8d7f 100644 --- a/src/renderer/src/modules/settings/modal/layout.tsx +++ b/src/renderer/src/modules/settings/modal/layout.tsx @@ -10,6 +10,7 @@ import type { PointerEventHandler, PropsWithChildren } from "react" import { Suspense, useCallback, useEffect, useRef } from "react" import { settings } from "../constants" +import { SyncIndicator } from "../helper/SyncIndicator" import { SettingsSidebarTitle } from "../title" import { useSetSettingTab, useSettingTab } from "./context" @@ -102,28 +103,34 @@ export function SettingModalLayout( className="flex h-0 flex-1 bg-theme-modal-background-opaque" ref={elementRef} > -
+
{APP_NAME}
- {settings.map((t) => ( - - ))} + + +
+ +
{children} diff --git a/src/renderer/src/modules/settings/modules/fonts.tsx b/src/renderer/src/modules/settings/sections/fonts.tsx similarity index 100% rename from src/renderer/src/modules/settings/modules/fonts.tsx rename to src/renderer/src/modules/settings/sections/fonts.tsx diff --git a/src/renderer/src/modules/settings/tabs/apperance.tsx b/src/renderer/src/modules/settings/tabs/apperance.tsx index da40527a3..0b189724f 100644 --- a/src/renderer/src/modules/settings/tabs/apperance.tsx +++ b/src/renderer/src/modules/settings/tabs/apperance.tsx @@ -18,7 +18,7 @@ import { getOS } from "@renderer/lib/utils" import { bundledThemes } from "shiki/themes" import { SettingTabbedSegment } from "../control" -import { ContentFontSelector, UIFontSelector } from "../modules/fonts" +import { ContentFontSelector, UIFontSelector } from "../sections/fonts" import { createSettingBuilder } from "../setting-builder" import { SettingsTitle } from "../title" diff --git a/src/renderer/src/queries/settings.ts b/src/renderer/src/queries/settings.ts new file mode 100644 index 000000000..fca0fb6f8 --- /dev/null +++ b/src/renderer/src/queries/settings.ts @@ -0,0 +1,7 @@ +import { apiClient } from "@renderer/lib/api-fetch" +import { defineQuery } from "@renderer/lib/defineQuery" + +export const settings = { + get: () => + defineQuery(["settings"], async () => await apiClient.settings.$get({ query: {} })), +}