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 | 1029 ++++++++++------- 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, 1111 insertions(+), 507 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 e392ef7dce..5f6bcf4948 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 e41737bc54..0095a8708d 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 067810e821..491b3cb9de 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 0b7df7beca..8a14b5d1e7 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 0000000000..39640f1f45 --- /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 0000000000..f82bdb8de6 --- /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 a1c92d7215..b805533829 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 0000000000..8cc5d9b0ef --- /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 1991e541a6..da7efa9802 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 139f25e980..33fc55aa87 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 0000000000..76e3e5922b --- /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 abf224c1c2..c3bd5c64d9 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 f92598d61f..c90441a300 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 0000000000..b35ce8efd1 --- /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 76e3659b66..9386d8d7ff 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 da40527a3b..0b189724f8 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 0000000000..fca0fb6f8e --- /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: {} })), +}