From 1e9d20ed47dad436480e7cab158be3bc46a0ee6b Mon Sep 17 00:00:00 2001 From: Innei Date: Mon, 7 Apr 2025 18:04:29 +0800 Subject: [PATCH] feat(mobile): streamline settings management and sync functionality - Updated general and UI settings to utilize shared default settings from the new shared package. - Introduced a new sync queue for managing local and remote settings synchronization. - Removed outdated settings interfaces that are now integrated into the shared package. - Enhanced event handling for setting changes to improve responsiveness and data consistency. Signed-off-by: Innei --- apps/mobile/src/atoms/settings/general.ts | 53 ++-- .../src/atoms/settings/internal/helper.ts | 6 + apps/mobile/src/atoms/settings/ui.ts | 27 +- apps/mobile/src/initialize/index.ts | 6 + .../mobile/src/interfaces/settings/general.ts | 25 -- apps/mobile/src/interfaces/settings/ui.ts | 13 - .../mobile/src/modules/settings/sync-queue.ts | 283 ++++++++++++++++++ packages/shared/src/settings/defaults.ts | 5 + packages/shared/src/settings/interface.ts | 6 + 9 files changed, 335 insertions(+), 89 deletions(-) delete mode 100644 apps/mobile/src/interfaces/settings/general.ts delete mode 100644 apps/mobile/src/interfaces/settings/ui.ts create mode 100644 apps/mobile/src/modules/settings/sync-queue.ts diff --git a/apps/mobile/src/atoms/settings/general.ts b/apps/mobile/src/atoms/settings/general.ts index fb7c39f48..4087949cd 100644 --- a/apps/mobile/src/atoms/settings/general.ts +++ b/apps/mobile/src/atoms/settings/general.ts @@ -1,41 +1,18 @@ -import type { GeneralSettings } from "@/src/interfaces/settings/general" +import { defaultGeneralSettings } from "@follow/shared/src/settings/defaults" +import type { GeneralSettings } from "@follow/shared/src/settings/interface" + import { getDeviceLanguage } from "@/src/lib/i18n" import { createSettingAtom } from "./internal/helper" -const createDefaultSettings = (): GeneralSettings => ({ - // App - language: getDeviceLanguage(), - - // Action - summary: false, - translation: false, - actionLanguage: getDeviceLanguage(), - - // Data control - - sendAnonymousData: true, - - autoGroup: true, - - // view - unreadOnly: true, - // mark unread - scrollMarkUnread: true, - - renderMarkUnread: false, - // UX - groupByDate: true, - autoExpandLongSocialMedia: false, - - // Secure - jumpOutLinkWarn: true, - // TTS - voice: "en-US-AndrewMultilingualNeural", - - // Content - openLinksInApp: true, -}) +const createDefaultSettings = (): GeneralSettings => { + const deviceLanguage = getDeviceLanguage() + return { + ...defaultGeneralSettings, + actionLanguage: deviceLanguage, + language: deviceLanguage, + } +} export const { useSettingKey: useGeneralSettingKey, @@ -49,3 +26,11 @@ export const { settingAtom: __generalSettingAtom, } = createSettingAtom("general", createDefaultSettings) + +export const generalServerSyncWhiteListKeys: (keyof GeneralSettings)[] = [ + "sendAnonymousData", + "language", + "appLaunchOnStartup", + "dataPersist", + "voice", +] diff --git a/apps/mobile/src/atoms/settings/internal/helper.ts b/apps/mobile/src/atoms/settings/internal/helper.ts index d6d37fce4..f7b311476 100644 --- a/apps/mobile/src/atoms/settings/internal/helper.ts +++ b/apps/mobile/src/atoms/settings/internal/helper.ts @@ -1,5 +1,6 @@ import { useRefValue } from "@follow/hooks" import { createAtomHooks } from "@follow/utils" +import { EventBus } from "@follow/utils/src/event-bus" import type { SetStateAction, WritableAtom } from "jotai" import { atom as jotaiAtom, useAtomValue } from "jotai" import { atomWithStorage, selectAtom } from "jotai/utils" @@ -116,6 +117,11 @@ export const createSettingAtom = ( value: ReturnType[K], ) => { const updated = Date.now() + + EventBus.dispatch("SETTING_CHANGE_EVENT", { + key: key as "general" | "ui", + payload: value, + }) setSettings({ ...getSettings(), [key]: value, diff --git a/apps/mobile/src/atoms/settings/ui.ts b/apps/mobile/src/atoms/settings/ui.ts index 2959059a5..ecd498739 100644 --- a/apps/mobile/src/atoms/settings/ui.ts +++ b/apps/mobile/src/atoms/settings/ui.ts @@ -1,23 +1,9 @@ -import type { UISettings } from "@/src/interfaces/settings/ui" +import { defaultUISettings } from "@follow/shared/src/settings/defaults" +import type { UISettings } from "@follow/shared/src/settings/interface" import { createSettingAtom } from "./internal/helper" -export const createDefaultSettings = (): UISettings => ({ - // Subscription - - hideExtraBadge: false, - - subscriptionShowUnreadCount: true, - thumbnailRatio: "square", - - // Content - readerRenderInlineStyle: false, - codeHighlightThemeLight: "github-light", - codeHighlightThemeDark: "github-dark", - guessCodeLanguage: true, - hideRecentReader: false, - customCSS: "", -}) +export const createDefaultSettings = (): UISettings => defaultUISettings export const { useSettingKey: useUISettingKey, @@ -30,3 +16,10 @@ export const { useSettingValue: useUISettingValue, settingAtom: __uiSettingAtom, } = createSettingAtom("ui", createDefaultSettings) + +export const uiServerSyncWhiteListKeys: (keyof UISettings)[] = [ + "uiFontFamily", + "readerFontFamily", + "opaqueSidebar", + // "customCSS", +] diff --git a/apps/mobile/src/initialize/index.ts b/apps/mobile/src/initialize/index.ts index 9956f27ff..f9dba3258 100644 --- a/apps/mobile/src/initialize/index.ts +++ b/apps/mobile/src/initialize/index.ts @@ -2,6 +2,7 @@ import { tracker } from "@follow/tracker" import { nativeApplicationVersion } from "expo-application" import { initializeDb } from "../database" +import { settingSyncQueue } from "../modules/settings/sync-queue" import { initAnalytics } from "./analytics" import { initializeAppCheck } from "./app-check" import { initCrashlytics } from "./crashlytics" @@ -29,6 +30,11 @@ export const initializeApp = async () => { await apm("initializeAppCheck", initializeAppCheck) await apm("initializePlayer", initializePlayer) + apm("setting sync", () => { + settingSyncQueue.init() + settingSyncQueue.syncLocal() + }) + await initAnalytics() const loadingTime = Date.now() - now tracker.appInit({ diff --git a/apps/mobile/src/interfaces/settings/general.ts b/apps/mobile/src/interfaces/settings/general.ts deleted file mode 100644 index 00118ff58..000000000 --- a/apps/mobile/src/interfaces/settings/general.ts +++ /dev/null @@ -1,25 +0,0 @@ -export interface GeneralSettings { - language: string - - summary: boolean - translation: boolean - actionLanguage: string - - sendAnonymousData: boolean - unreadOnly: boolean - scrollMarkUnread: boolean - - renderMarkUnread: boolean - groupByDate: boolean - jumpOutLinkWarn: boolean - // TTS - voice: string - autoGroup: boolean - - /** - * Auto expand long social media - */ - autoExpandLongSocialMedia: boolean - - openLinksInApp: boolean -} diff --git a/apps/mobile/src/interfaces/settings/ui.ts b/apps/mobile/src/interfaces/settings/ui.ts deleted file mode 100644 index 03df9f2e0..000000000 --- a/apps/mobile/src/interfaces/settings/ui.ts +++ /dev/null @@ -1,13 +0,0 @@ -export interface UISettings { - subscriptionShowUnreadCount: boolean - hideExtraBadge: boolean - thumbnailRatio: "square" | "original" - - // Content - readerRenderInlineStyle: boolean - codeHighlightThemeLight: string - codeHighlightThemeDark: string - guessCodeLanguage: boolean - hideRecentReader: boolean - customCSS: string -} diff --git a/apps/mobile/src/modules/settings/sync-queue.ts b/apps/mobile/src/modules/settings/sync-queue.ts new file mode 100644 index 000000000..13df5d45d --- /dev/null +++ b/apps/mobile/src/modules/settings/sync-queue.ts @@ -0,0 +1,283 @@ +import type { GeneralSettings, UISettings } from "@follow/shared/src/settings/interface" +import { isEmptyObject, jotaiStore, sleep } from "@follow/utils" +import { EventBus } from "@follow/utils/src/event-bus" +import { omit } from "es-toolkit/compat" +import type { PrimitiveAtom } from "jotai" + +import { + __generalSettingAtom, + generalServerSyncWhiteListKeys, + getGeneralSettings, +} from "@/src/atoms/settings/general" +import { __uiSettingAtom, getUISettings, uiServerSyncWhiteListKeys } from "@/src/atoms/settings/ui" +import { apiClient } from "@/src/lib/api-fetch" + +type SettingMapping = { + appearance: UISettings + general: GeneralSettings +} + +const omitKeys: string[] = [] + +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 { + tab: T + payload: Partial + date: number +} + +declare module "@follow/utils/src/event-bus" { + interface CustomEvent { + SETTING_CHANGE_EVENT: { + key: keyof typeof bizSettingKeyToTabMapping + payload: any + } + } +} + +class SettingSyncQueue { + queue: SettingSyncQueueItem[] = [] + + private disposers: (() => void)[] = [] + async init() { + this.teardown() + + this.load() + + const d1 = EventBus.subscribe("SETTING_CHANGE_EVENT", (data) => { + const tab = bizSettingKeyToTabMapping[data.key] as SettingSyncTab + if (!tab) return + + const nextPayload = omit(data.payload, omitKeys, settingWhiteListMap[tab]) + if (isEmptyObject(nextPayload)) return + this.enqueue(tab, nextPayload) + }) + + this.disposers.push(d1) + } + + teardown() { + for (const disposer of this.disposers) { + disposer() + } + this.queue = [] + } + + private readonly storageKey = "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> + 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 as SettingSyncTab], + omitKeys, + settingWhiteListMap[tab as SettingSyncTab], + ) + + if (isEmptyObject(json)) { + continue + } + const promise = apiClient.settings[":tab"] + .$patch({ + param: { + tab, + }, + json, + }) + .then(() => { + // remove from queue + for (const item of referenceMap[tab as SettingSyncTab]) { + 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 as SettingSyncTab]() + 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 + } + } + + private pendingPromise: Promise<{ + code: 0 + settings: Record + updated: Record + }> | null = null + + private fetchSettingRemote() { + if (this.pendingPromise) { + return this.pendingPromise + } + const promise = apiClient.settings.$get({ query: {} }) + this.pendingPromise = promise.finally(() => { + this.pendingPromise = null + }) + return promise + } + async syncLocal() { + const remoteSettings = await this.fetchSettingRemote() + + 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 as SettingSyncTab]() + const localSettingsUpdated = (localSettings as { updated: number }).updated + + if (!localSettingsUpdated || remoteUpdatedDate > localSettingsUpdated) { + // Use remote and update local + const nextPayload = omit( + remoteSettingPayload, + omitKeys, + settingWhiteListMap[tab as SettingSyncTab], + ) + + if (isEmptyObject(nextPayload)) { + continue + } + + const setter = localSettingSetterMap[tab as SettingSyncTab] + + nextPayload.updated = remoteUpdatedDate + + setter(nextPayload as any) + } + } + } +} + +export const settingSyncQueue = new SettingSyncQueue() diff --git a/packages/shared/src/settings/defaults.ts b/packages/shared/src/settings/defaults.ts index c376d1f5c..200f21751 100644 --- a/packages/shared/src/settings/defaults.ts +++ b/packages/shared/src/settings/defaults.ts @@ -34,6 +34,9 @@ export const defaultGeneralSettings: GeneralSettings = { // Pro feature enhancedSettings: false, + + // @mobile + openLinksInApp: true, } export const defaultUISettings: UISettings = { @@ -80,6 +83,8 @@ export const defaultUISettings: UISettings = { main: [], more: [], }, + + subscriptionShowUnreadCount: true, } export const defaultIntegrationSettings: IntegrationSettings = { diff --git a/packages/shared/src/settings/interface.ts b/packages/shared/src/settings/interface.ts index b81b98f59..a5251b798 100644 --- a/packages/shared/src/settings/interface.ts +++ b/packages/shared/src/settings/interface.ts @@ -27,6 +27,9 @@ export interface GeneralSettings { // Pro feature enhancedSettings: boolean + + // @mobile + openLinksInApp: boolean } export interface UISettings { @@ -65,6 +68,9 @@ export interface UISettings { main: (string | number)[] more: (string | number)[] } + + // @mobile + subscriptionShowUnreadCount: boolean } export interface IntegrationSettings {