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:
Innei 2024-09-06 00:46:30 +08:00 committed by GitHub
parent 1531d2b709
commit f1a34812fb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1392 additions and 788 deletions

File diff suppressed because it is too large Load Diff

View File

@ -40,3 +40,9 @@ export const subscribeShouldUseIndexedDB = (
) =>
jotaiStore.sub(__generalSettingAtom, () =>
callback(getGeneralSettings().dataPersist))
export const generalServerSyncWhiteListKeys: (keyof GeneralSettings)[] = [
"appLaunchOnStartup",
"dataPersist",
"sendAnonymousData",
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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