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 <tukon479@gmail.com>
This commit is contained in:
Innei 2025-04-07 18:04:29 +08:00
parent 0559ff1846
commit 1e9d20ed47
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
9 changed files with 335 additions and 89 deletions

View File

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

View File

@ -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 = <T extends object>(
value: ReturnType<typeof getSettings>[K],
) => {
const updated = Date.now()
EventBus.dispatch("SETTING_CHANGE_EVENT", {
key: key as "general" | "ui",
payload: value,
})
setSettings({
...getSettings(),
[key]: value,

View File

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

View File

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

View File

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

View File

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

View File

@ -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 =
<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
}
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<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 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<any>[]
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<string, any>
updated: Record<string, string>
}> | 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()

View File

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

View File

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