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:
parent
0559ff1846
commit
1e9d20ed47
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue