Declare every persisted local store with synced in its owner module and await their ready promises at boot

This commit is contained in:
Jon Staab 2026-09-26 00:09:35 -07:00
parent 602ac09293
commit 5343a28e6c
20 changed files with 103 additions and 103 deletions

View file

@ -156,7 +156,8 @@ size from `localStorage` before first paint. Then, in order:
1. `restoreSession()` restores the saved session, if there is one, which builds a user-scoped
`App` through `login`.
2. The device, wallet and notification stores sync to `kv`/`ss`.
2. It awaits `.ready` on the `synced` stores boot reads (device, wallet, push and notification
settings, `shouldUnwrap`, `needsKeyBackup`).
3. It waits for storage, then `setupDeepLinks()` listens for warm-start links and handles a
cold-start one.
4. Each long-running subscription goes onto one `unsubscribers` list: `setupHistory`,

View file

@ -275,18 +275,27 @@ To persist another kind, add it to `kinds` in `storage.ts`. To persist a new map
add a `TABLES` entry and an `init*` method shaped like `initHandles`: load the rows, subscribe
to `onItem`, and batch the writes.
### kv, ss and the two ways to bind them
### kv, ss and synced stores
`kv` wraps Capacitor `Preferences` and `ss` wraps `SecureStorage`. Both are exported from
`storage.ts`, queue their writes, and JSON-encode values. Neither is namespaced per user, so
anything in them outlives a login and is cleared only by logout. Secrets go in `ss`.
- `synced({key, storage, defaultValue})` creates a store that persists itself. It emits the
default first, and the stored value arrives later (`.ready`). `theme` and `flTheme`
(`theme.ts`), `checked` (`notifications.ts`) and `shouldUnwrap` (`sync.ts`) use it.
- `sync({key, store, storage})` binds a store that already exists. The root layout awaits it
for `device`, `wallet`, `notificationSettings`, `pushState` and `needsKeyBackup` before first
render, so boot code sees the restored values. It also binds `shouldUnwrap`, which `synced` already persists.
Each persisted store is declared in its owner module with `synced({key, storage, defaultValue})`,
or `syncedWithGetter` from `storage.ts` when callers need `.get()` (`withGetter`'s type drops
`.ready`). The store emits the default first and loads the stored value in the background, so
`.ready` resolves once it has arrived:
- `theme`, `flTheme` (`theme.ts`), `checked` (`notifications.ts`), `shouldUnwrap` (`sync.ts`),
`device` (`device.ts`), `needsKeyBackup` (`healthChecks.ts`) in `kv`
- `pushState` (key `notificationState`) and `notificationSettings` in `kv`, both in
`push/adapters/common.ts` and re-exported from `@app/push`
- `wallet` (`lightning.ts`) in `ss`
The root layout awaits `.ready` for the stores boot code reads (`device`, `shouldUnwrap`,
`wallet`, `notificationSettings`, `pushState`, `needsKeyBackup`) before `Push.sync()` and
background sync start. A new store that boot reads joins that list. Never change a key, since
it is where existing installs keep their value.
Raw `localStorage` holds only `theme`, `fl-theme` and `font-size`. The root layout mirrors them
there to apply them synchronously before `kv` loads, which avoids a flash of the wrong theme.
@ -305,7 +314,7 @@ Settings are an encrypted app-data event with d-tag `flotilla/settings`, read th
A preference that should follow the user across devices goes in `SettingsValues` and
`defaultSettings`. One that belongs to a device goes in a `kv` store, as push, sound and badge do
in `notificationSettings`. Per-space alert preferences are published (`alerts`); the device's
in `notificationSettings` (`@app/push`). Per-space alert preferences are published (`alerts`); the device's
push permission is not.
`checked`, the read markers behind badges, lives in `kv`, and `syncCheckedRemote` mirrors it to
@ -413,8 +422,8 @@ NIP-86 calls (`relayManagement.get().forUrl(url)`) are not thunks. They return
- **UI signals.** `toast` in `toast.ts`, and `relaysPendingTrust` in `policies.ts`.
- **A controller per flow.** `Nip46Controller` (`nip46.ts`) is a class a component instantiates.
It holds the writables and actions for a multi-step flow.
- **Module-owned values.** `wallet` in `lightning.ts` is a `withGetter(writable(...))` that the
root layout persists.
- **Module-owned values.** `wallet` in `lightning.ts` persists itself to `ss` with
`syncedWithGetter`.
## Runes and stores

View file

@ -11,7 +11,7 @@
import {messagingRelayLists, user} from "@app/core"
import {pushModal} from "@app/modal"
import {setChecked} from "@app/notifications"
import {notificationSettings} from "@app/settings"
import {notificationSettings} from "@app/push"
type Props = {
onClick: () => void

View file

@ -1,8 +1,7 @@
<script lang="ts">
import {onMount} from "svelte"
import {Capacitor} from "@capacitor/core"
import {notificationSettings} from "@app/settings"
import {onNotification} from "@app/push"
import {notificationSettings, onNotification} from "@app/push"
let audioElement: HTMLAudioElement

View file

@ -22,7 +22,8 @@
import {deriveSpacePermissions} from "@app/management"
import {userSpaceUrls} from "@app/rooms"
import {deriveSpaceActionItems} from "@app/actionItems"
import {notificationSettings, deriveShouldNotify, setSpaceNotifications} from "@app/settings"
import {deriveShouldNotify, setSpaceNotifications} from "@app/settings"
import {notificationSettings} from "@app/push"
import {pushModal, popModal} from "@app/modal"
import {makeSpacePath, goToChat} from "@app/routes"

View file

@ -10,7 +10,8 @@
import SpaceMenuActions from "@app/components/SpaceMenuActions.svelte"
import RelayName from "@app/components/RelayName.svelte"
import {deriveSpaceActionItems} from "@app/actionItems"
import {notificationSettings, deriveShouldNotify} from "@app/settings"
import {deriveShouldNotify} from "@app/settings"
import {notificationSettings} from "@app/push"
import {pushModal} from "@app/modal"
type Props = {

View file

@ -1,5 +1,4 @@
import {withGetter} from "@welshman/store"
import {writable} from "svelte/store"
import {randomId} from "@welshman/lib"
import {kv, syncedWithGetter} from "@app/storage"
export const device = withGetter(writable(randomId()))
export const device = syncedWithGetter({key: "device", storage: kv, defaultValue: randomId()})

View file

@ -1,10 +1,12 @@
import {derived, writable} from "svelte/store"
import {derived} from "svelte/store"
import {sample} from "@welshman/lib"
import {normalizeRelayUrl} from "@welshman/util"
import {synced} from "@welshman/store"
import {MessagingRelayLists, RelayLists, Relays, SearchRelayLists, projection} from "@welshman/app"
import type {Command, IApp, Projection} from "@welshman/app"
import {usePlugin} from "@app/core"
import {DEFAULT_RELAYS} from "@app/env"
import {kv} from "@app/storage"
export type HealthCheckContext = {
readRelays: string[]
@ -93,7 +95,7 @@ const tooManyRelaysCheck = (spec: RelayListSpec): HealthCheck => ({
})
// Set when a key is generated in-app, and cleared once the user has saved a backup of it
export const needsKeyBackup = writable(false)
export const needsKeyBackup = synced({key: "needsKeyBackup", storage: kv, defaultValue: false})
export class HealthChecks {
context: Projection<HealthCheckContext>

View file

@ -1,10 +1,9 @@
import {writable} from "svelte/store"
import {nwc} from "@getalby/sdk"
import {bech32ToHex, displayUrl, tryCatch} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
import {isNWCWallet, isWebLNWallet, toMsats} from "@welshman/util"
import type {Wallet} from "@welshman/util"
import {withGetter} from "@welshman/store"
import {ss, syncedWithGetter} from "@app/storage"
/** Both lud06 and lud16 normalize into the bech32 lnurl zapping needs, so decode it before display. */
export const displayLnurl = (lnurl: string) => {
@ -20,7 +19,11 @@ export const displayLnurl = (lnurl: string) => {
return url ? displayUrl(url) : lnurl
}
export const wallet = withGetter(writable<Maybe<Wallet>>(undefined))
export const wallet = syncedWithGetter<Maybe<Wallet>>({
key: "wallet",
storage: ss,
defaultValue: undefined,
})
export const getWebLn = () => (window as any).webln

View file

@ -14,27 +14,26 @@ import {
makeHttpAuthHeader,
} from "@welshman/util"
import {Comment} from "@welshman/domain"
import {synced, throttled, withGetter} from "@welshman/store"
import {throttled} from "@welshman/store"
import {Events, Relays} from "@welshman/app"
import {app, fromApp, reader} from "@app/core"
import {makeRoomPath, makeSpaceChatPath, makeChatPath, makeContentPath} from "@app/routes"
import {CONTENT_KINDS, makeCommentFilter} from "@app/content"
import {getIsMuted, notificationSettings, userSettingsValues} from "@app/settings"
import {getIsMuted, userSettingsValues} from "@app/settings"
import {Chats} from "@app/chats"
import {dufflepud, DUFFLEPUD_URL} from "@app/env"
import {isGroupSpace} from "@app/relays"
import {activeSpaceUrls} from "@app/rooms"
import {kv} from "@app/storage"
import {kv, syncedWithGetter} from "@app/storage"
import {notificationSettings} from "@app/push/adapters/common"
// Checked state
export const checked = withGetter(
synced<Record<string, number>>({
key: "checked",
defaultValue: {},
storage: kv,
}),
)
export const checked = syncedWithGetter<Record<string, number>>({
key: "checked",
defaultValue: {},
storage: kv,
})
export const getChecked = (key: string) => checked.get()[key]

View file

@ -1,4 +1,3 @@
import {writable} from "svelte/store"
import type {Subscriber, Unsubscriber} from "svelte/store"
import {Capacitor} from "@capacitor/core"
import {call, ms, now, on, parseJson, poll, spec, throttle, uniq} from "@welshman/lib"
@ -15,13 +14,14 @@ import {
type Filter,
type TrustedEvent,
} from "@welshman/util"
import {merged, withGetter} from "@welshman/store"
import {merged} from "@welshman/store"
import {User} from "@welshman/app"
import {navigate} from "@app/modal"
import {app, messagingRelayLists, network, roomLists} from "@app/core"
import {DM_KINDS} from "@app/content"
import {getMutedRooms, notificationSettings, shouldNotify, userSettingsValues} from "@app/settings"
import {getMutedRooms, shouldNotify, userSettingsValues} from "@app/settings"
import {makeEventPath, goToChat, goToSpace} from "@app/routes"
import {kv, syncedWithGetter} from "@app/storage"
export type PushSubscription = {
key: string
@ -38,7 +38,26 @@ export type PushState = {
export const requiresFallback =
Capacitor.getPlatform() === "android" && !Capacitor.isPluginAvailable("PushNotifications")
export const pushState = withGetter(writable<PushState>({useFallback: requiresFallback}))
export const pushState = syncedWithGetter<PushState>({
key: "notificationState",
storage: kv,
defaultValue: {useFallback: requiresFallback},
})
export const notificationSettings = syncedWithGetter({
key: "notificationSettings",
storage: kv,
defaultValue: {
push: false,
sound: true,
badge: false,
spaces: true,
mentions: true,
messages: true,
events: true,
reminder_minutes: [5],
},
})
export interface IPushAdapter {
request: (prompt?: boolean) => Promise<string>

View file

@ -3,9 +3,8 @@ import {ellipsize, maybe} from "@welshman/lib"
import {hexTags, matchFilter, tagValues, type TrustedEvent} from "@welshman/util"
import {DM_KINDS} from "@app/content"
import {app} from "@app/core"
import {notificationSettings} from "@app/settings"
import type {IPushAdapter} from "@app/push/adapters/common"
import {onNotification} from "@app/push/adapters/common"
import {notificationSettings, onNotification} from "@app/push/adapters/common"
import {renderEventAsSummary} from "@app/render"
import {goToEvent} from "@app/routes"

View file

@ -1,15 +1,15 @@
import {Capacitor} from "@capacitor/core"
import {notificationSettings, setSpaceNotifications} from "@app/settings"
import {setSpaceNotifications} from "@app/settings"
import {clearBadges} from "@app/notifications"
import {syncEventReminders} from "@app/reminders"
import {pushState} from "@app/push/adapters/common"
import {notificationSettings, pushState} from "@app/push/adapters/common"
import {ElectronNotifications} from "@app/push/adapters/electron"
import {WebNotifications} from "@app/push/adapters/web"
import {CapacitorNotifications} from "@app/push/adapters/capacitor"
import {AndroidFallbackNotifications} from "@app/push/adapters/android"
import type {IPushAdapter} from "@app/push/adapters/common"
export {onNotification} from "@app/push/adapters/common"
export {notificationSettings, onNotification} from "@app/push/adapters/common"
export class Push {
static _adapter: IPushAdapter | undefined

View file

@ -6,8 +6,7 @@ import {buildUrl} from "@lib/util"
import {getEventStart, getEventTitle, isAttending} from "@app/calendar"
import {app, network, reader, roomLists} from "@app/core"
import {PUSH_SERVER} from "@app/env"
import {pushState} from "@app/push/adapters/common"
import {notificationSettings} from "@app/settings"
import {notificationSettings, pushState} from "@app/push/adapters/common"
type Reminder = {
at: number

View file

@ -159,19 +159,6 @@ export const shouldNotify = (url: string, h?: string) =>
export const deriveShouldNotify = (url: string, h?: string) =>
derived(userSettingsValues, $settings => getShouldNotify($settings, url, h))
export const notificationSettings = withGetter(
writable({
push: false,
sound: true,
badge: false,
spaces: true,
mentions: true,
messages: true,
events: true,
reminder_minutes: [5],
}),
)
export const publishSettings = async (params: Partial<SettingsValues>) => {
const $app = app.get()
const reader = await settings.get().forceLoad(User.require($app).pubkey)

View file

@ -42,7 +42,8 @@ import {
verifiedSymbol,
} from "@welshman/util"
import type {Handle, TrustedEvent} from "@welshman/util"
import {withGetter} from "@welshman/store"
import {synced, withGetter} from "@welshman/store"
import type {SyncedConfig} from "@welshman/store"
import type {RepositoryUpdate, WrapItem} from "@welshman/net"
import {Relay, Zapper} from "@welshman/domain"
import type {RelayInfo, ZapperValues} from "@welshman/domain"
@ -52,6 +53,13 @@ import {IDB} from "@lib/indexeddb"
import {appPolicies} from "@app/core"
import {DM_KINDS} from "@app/content"
// withGetter's type drops the ready promise synced adds
export const syncedWithGetter = <T>(config: SyncedConfig<T>) => {
const store = synced(config)
return {...withGetter(store), ready: store.ready}
}
export const kv = call(() => {
const enqueue = makeQueue()

View file

@ -31,7 +31,7 @@ import {
} from "@welshman/util"
import type {Filter} from "@welshman/util"
import type {FollowListReader} from "@welshman/domain"
import {merged, synced, throttled, withGetter} from "@welshman/store"
import {merged, throttled} from "@welshman/store"
import {FollowLists, MessagingRelayLists, RelayLists, SearchRelayLists, Sync} from "@welshman/app"
import {
app,
@ -57,13 +57,15 @@ import {FEATURED_CONTENT_D} from "@app/featured"
import {decodeRelay} from "@app/relays"
import {activeSpaceUrls, userSpaceUrls} from "@app/rooms"
import {Settings} from "@app/settings"
import {kv} from "@app/storage"
import {kv, syncedWithGetter} from "@app/storage"
import {hasBlossomSupport} from "@app/uploads"
// Unwrapping is unconditional, so this is what keeps a signer from decrypting the whole DM history.
export const shouldUnwrap = withGetter(
synced({key: "shouldUnwrap", storage: kv, defaultValue: false}),
)
export const shouldUnwrap = syncedWithGetter({
key: "shouldUnwrap",
storage: kv,
defaultValue: false,
})
// Utils

View file

@ -10,7 +10,6 @@
import {dev} from "$app/environment"
import {page} from "$app/stores"
import {context as pomadeContext} from "@pomade/core"
import {sync} from "@welshman/store"
import {setNip55Plugin} from "@welshman/signer"
import * as util from "@welshman/util"
import * as lib from "@welshman/lib"
@ -28,9 +27,8 @@
import {restoreSession} from "@app/session"
import {syncSignerAlerts} from "@app/signer"
import {wallet} from "@app/lightning"
import {kv, ss, storage} from "@app/storage"
import {kv, storage} from "@app/storage"
import {device} from "@app/device"
import {notificationSettings} from "@app/settings"
import {needsKeyBackup} from "@app/healthChecks"
import {setupShareIntents} from "@app/share"
import {shouldUnwrap, syncApplicationData} from "@app/sync"
@ -38,7 +36,7 @@
import {applySavedTheme, flTheme, syncTheme, theme} from "@app/theme"
import * as notifications from "@app/notifications"
import {notificationCount, backgroundNotificationCount} from "@app/notifications"
import {Push} from "@app/push"
import {Push, notificationSettings} from "@app/push"
import {pushState} from "@app/push/adapters/common"
import {setupDeepLinks} from "@app/deepLinks"
import {syncKeyboard} from "@app/keyboard"
@ -83,38 +81,14 @@
// Set up logging
unsubscribers.push(setupLogging())
// Sync stuff to storage
// Boot code reads these, e.g. Push picks its adapter from pushState, so wait for stored values
await Promise.all([
sync({
key: "device",
store: device,
storage: kv,
}),
sync({
key: "shouldUnwrap",
store: shouldUnwrap,
storage: kv,
}),
sync({
key: "wallet",
store: wallet,
storage: ss,
}),
sync({
key: "notificationSettings",
store: notificationSettings,
storage: kv,
}),
sync({
key: "notificationState",
store: pushState,
storage: kv,
}),
sync({
key: "needsKeyBackup",
store: needsKeyBackup,
storage: kv,
}),
device.ready,
shouldUnwrap.ready,
wallet.ready,
notificationSettings.ready,
pushState.ready,
needsKeyBackup.ready,
])
// The key backup reminder used to live in a forceHealthChecks record

View file

@ -14,9 +14,8 @@
import {append, uniq} from "@welshman/lib"
import Chat from "@app/components/Chat.svelte"
import {splitChatId} from "@app/chats"
import {notificationSettings} from "@app/settings"
import {pushToast} from "@app/toast"
import {Push} from "@app/push"
import {Push, notificationSettings} from "@app/push"
import {user} from "@app/core"
import type {PageProps} from "./$types"

View file

@ -14,8 +14,7 @@
import Button from "@lib/components/Button.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import {pushToast} from "@app/toast"
import {saveNotificationSettings} from "@app/push"
import {notificationSettings} from "@app/settings"
import {notificationSettings, saveNotificationSettings} from "@app/push"
const reset = () => {
settings = {...notificationSettings.get()}