feat: setting sync (#273)
* init Signed-off-by: Innei <i@innei.in> * feat: offline Signed-off-by: Innei <i@innei.in> * fix: update Signed-off-by: Innei <i@innei.in> * fix: chain 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> * update Signed-off-by: Innei <i@innei.in> * feat: render social media with full text --------- Signed-off-by: Innei <i@innei.in> Co-authored-by: DIYgod <i@diygod.me>
This commit is contained in:
parent
1531d2b709
commit
f1a34812fb
1591
src/hono.ts
1591
src/hono.ts
File diff suppressed because it is too large
Load Diff
|
|
@ -40,3 +40,9 @@ export const subscribeShouldUseIndexedDB = (
|
|||
) =>
|
||||
jotaiStore.sub(__generalSettingAtom, () =>
|
||||
callback(getGeneralSettings().dataPersist))
|
||||
|
||||
export const generalServerSyncWhiteListKeys: (keyof GeneralSettings)[] = [
|
||||
"appLaunchOnStartup",
|
||||
"dataPersist",
|
||||
"sendAnonymousData",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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<string, any>
|
||||
key: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const createSettingAtom = <T extends object>(
|
||||
settingKey: string,
|
||||
createDefaultSettings: () => T,
|
||||
|
|
@ -19,6 +30,7 @@ export const createSettingAtom = <T extends object>(
|
|||
getOnInit: true,
|
||||
},
|
||||
)
|
||||
|
||||
const [, , useSettingValue, , getSettings, setSettings] =
|
||||
createAtomHooks(atom)
|
||||
|
||||
|
|
@ -53,9 +65,18 @@ export const createSettingAtom = <T extends object>(
|
|||
key: K,
|
||||
value: ReturnType<typeof getSettings>[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<SettingItem<any>, "onChange" | "description" | "label" | "hide" | "key">,
|
||||
} & Omit<
|
||||
SettingItem<any>,
|
||||
"onChange" | "description" | "label" | "hide" | "key"
|
||||
>,
|
||||
): any => {
|
||||
const { label, description, onChange, hide, ...rest } = options
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
import type { SVGProps } from "react"
|
||||
|
||||
export function PhCloudCheck(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 256 256"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M160 40a88.09 88.09 0 0 0-78.71 48.67A64 64 0 1 0 72 216h88a88 88 0 0 0 0-176m0 160H72a48 48 0 0 1 0-96c1.1 0 2.2 0 3.29.11A88 88 0 0 0 72 128a8 8 0 0 0 16 0a72 72 0 1 1 72 72m37.66-93.66a8 8 0 0 1 0 11.32l-48 48a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L144 148.69l42.34-42.35a8 8 0 0 1 11.32 0"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import type { SVGProps } from "react"
|
||||
|
||||
export function PhCloudX(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256" {...props}><path fill="currentColor" d="M160 40a88.09 88.09 0 0 0-78.71 48.67A64 64 0 1 0 72 216h88a88 88 0 0 0 0-176m0 160H72a48 48 0 0 1 0-96c1.1 0 2.2 0 3.29.11A88 88 0 0 0 72 128a8 8 0 0 0 16 0a72 72 0 1 1 72 72m29.66-82.34L171.31 136l18.35 18.34a8 8 0 0 1-11.32 11.32L160 147.31l-18.34 18.35a8 8 0 0 1-11.32-11.32L148.69 136l-18.35-18.34a8 8 0 0 1 11.32-11.32L160 124.69l18.34-18.35a8 8 0 0 1 11.32 11.32" /></svg>
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export type DefinedQuery<TQueryKey extends QueryKey, TData> = Readonly<{
|
|||
invalidateRoot: () => void
|
||||
|
||||
refetch: () => Promise<TData | undefined>
|
||||
prefetch: () => Promise<void>
|
||||
prefetch: () => Promise<TData | undefined>
|
||||
|
||||
setData: <Data = TData>(
|
||||
updater: (draft: Draft<Data>) => ValidRecipeReturnType<Draft<Data>>
|
||||
|
|
@ -98,6 +98,7 @@ export function defineQuery<
|
|||
queryKey: key,
|
||||
queryFn: fn,
|
||||
})
|
||||
return queryClient.getQueryData<TData>(key)
|
||||
},
|
||||
cancel: async (keyExtactor) => {
|
||||
const queryKey =
|
||||
|
|
|
|||
|
|
@ -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<T extends keyof EventBusMap>(event: T, data: EventBusMap[T]) {
|
||||
window.dispatchEvent(new EventBusEvent(event, data))
|
||||
}
|
||||
|
||||
subscribe<T extends keyof EventBusMap>(
|
||||
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()
|
||||
|
|
@ -147,7 +147,7 @@ export function formatXml(xml: string, indent = 4) {
|
|||
}
|
||||
|
||||
export const sleep = (ms: number) =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms))
|
||||
new Promise<void>((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<string, any>) => Object.keys(obj).length === 0
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export const CornerPlayer = () => {
|
|||
{show && entry && feed && (
|
||||
<m.div
|
||||
key="corner-player"
|
||||
className="group relative z-10 !mb-0 w-full pr-px"
|
||||
className="group relative z-10 !my-0 w-full pr-px"
|
||||
initial={{ y: 50, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 50, opacity: 0 }}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
import { PhCloudCheck } from "@renderer/components/icons/PhCloudCheck"
|
||||
import { PhCloudX } from "@renderer/components/icons/PhCloudX"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@renderer/components/ui/tooltip"
|
||||
import { useAuthQuery, useIsOnline } from "@renderer/hooks/common"
|
||||
import { settings } from "@renderer/queries/settings"
|
||||
import { useEffect, useRef } from "react"
|
||||
|
||||
import { settingSyncQueue } from "./sync-queue"
|
||||
|
||||
export const SyncIndicator = () => {
|
||||
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 (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="center absolute right-2 size-5">
|
||||
{isOnline ? (
|
||||
<PhCloudCheck className="size-4" />
|
||||
) : (
|
||||
<PhCloudX className="size-4" />
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="text-center text-xs">
|
||||
{isOnline ? "Synced with server" : "Offline"}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 =
|
||||
<T>(atom: PrimitiveAtom<T>) =>
|
||||
(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<SettingMapping[T]>
|
||||
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<T extends SettingSyncTab>(
|
||||
tab: T,
|
||||
payload: Partial<SettingMapping[T]>,
|
||||
) {
|
||||
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<SettingSyncTab, any>
|
||||
|
||||
const referenceMap = {} as Record<
|
||||
SettingSyncTab,
|
||||
Set<SettingSyncQueueItem>
|
||||
>
|
||||
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<any>[]
|
||||
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<any>[]
|
||||
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()
|
||||
|
|
@ -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}
|
||||
>
|
||||
<div className="w-44 border-r px-2 py-6">
|
||||
<div className="flex min-h-0 w-44 flex-col border-r px-2 py-6">
|
||||
<div className="mb-4 flex h-8 items-center gap-2 px-2 font-bold">
|
||||
<Logo className="mr-1 size-6" />
|
||||
{APP_NAME}
|
||||
</div>
|
||||
{settings.map((t) => (
|
||||
<button
|
||||
key={t.path}
|
||||
className={`my-1 flex w-full items-center rounded-lg px-2.5 py-0.5 leading-loose text-theme-foreground/70 transition-colors ${
|
||||
tab === t.path ?
|
||||
"bg-theme-item-active text-theme-foreground/90" :
|
||||
""
|
||||
}`}
|
||||
type="button"
|
||||
onClick={() => setTab(t.path)}
|
||||
>
|
||||
<SettingsSidebarTitle
|
||||
path={t.path}
|
||||
className="text-[0.94rem] font-medium"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
<nav className="flex grow flex-col">
|
||||
{settings.map((t) => (
|
||||
<button
|
||||
key={t.path}
|
||||
className={`my-0.5 flex w-full items-center rounded-lg px-2.5 py-0.5 leading-loose text-theme-foreground/70 transition-colors ${
|
||||
tab === t.path ?
|
||||
"bg-theme-item-active text-theme-foreground/90" :
|
||||
""
|
||||
}`}
|
||||
type="button"
|
||||
onClick={() => setTab(t.path)}
|
||||
>
|
||||
<SettingsSidebarTitle
|
||||
path={t.path}
|
||||
className="text-[0.94rem] font-medium"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="relative -mb-5 h-8 shrink-0">
|
||||
<SyncIndicator />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex h-full flex-1 flex-col bg-theme-background pt-1">
|
||||
<Suspense>{children}</Suspense>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {} })),
|
||||
}
|
||||
Loading…
Reference in New Issue