feat(settings): NIP-78 cross-device sync of Interface preferences#173
feat(settings): NIP-78 cross-device sync of Interface preferences#173dmnyc wants to merge 6 commits into
Conversation
Publishes an addressable kind-30078 event (d-tag wisp-app-settings:v1), NIP-44 self-encrypted, carrying the user's non-sensitive UI prefs so the same setup follows the account across devices and across the iOS and Android clients. - Nip78AppSettings: AppSettingsPayload Codable struct (all-optional fields + version), publish helper that signs + NIP-44 self-encrypts, fetch helper that filters the user's kind-30078 events by d-tag. - AppSettings: syncSettingsToRelays toggle (default on), snapshotForBackup, applyRestored with suppression flag, scheduleSettingsSync (4s debounce), publishSettingsNow, restoreFromRelays, restoreOnLaunchIfNeeded. - Wires each Interface-screen setting's didSet to scheduleSettingsSync. Scope: appearance (largeText, colorScheme, themeName, accentColorARGB), media (autoLoadMedia, videoAutoplay, animateAvatars, mediaLayoutStyle), posting (clientTagEnabled, postUndoTimerEnabled/Seconds/ForReplies), currency (fiatModeEnabled, fiatCurrency, zapIconStyle). - Out of scope by design: notification sounds, relay auto-AUTH, and video loop are device-local — no relay round-trips. - InterfaceSettingsView gains a "Cross-device sync" section: toggle + inline "Restore from relays" affordance with progress feedback. - RootContainer fires a one-shot restoreOnLaunchIfNeeded on first launch per pubkey-per-device. Tracks #70 (Persist user settings with NIP-78); doesn't close it yet — the Wisp Android port mirroring the payload schema is the remaining piece.
The synced theme / accent / media prefs from the prior account were bleeding across sign-out and account-switch boundaries — the splash screen carried the previous account's theme into the next session, and switching mid-session left the new account staring at the other account's preferences instead of its own. Fix: - AppSettings.resetSyncedSettingsToDefaults writes the defaults for every in-scope synced field, using the existing isApplyingRestoredPayload flag to short-circuit the debounced publish so the reset stays local. - AppSettings.handleActiveAccountChange runs the reset + a force-restore (bypassing the per-pubkey "already restored" flag) whenever the active account changes. Tracked separately from restoreOnLaunchIfNeeded — that one only fires at app launch and doesn't react to mid-session switches. - ContentView .onChange(of: keypair?.pubkey) wires the handler so any pubkey transition (sign-in, sign-out, mid-session switch via drawer or add-account sheet) triggers the same path. - Critical: first-handle tracking. The initial sign-in transition (nil -> first account) MUST be a no-op here, otherwise writing defaults to the synced fields changes RootContainer's .id, which destroys ContentView, which onAppears and re-loads the saved keypair, which fires onChange again — infinite rebuild loop. restoreOnLaunchIfNeeded already covers the initial case under its per-pubkey flag. - Sync-off users are left alone — opting out of cross-device sync also opts into device-stable settings across account changes.
When adding a new account, the reset triggered by the account-change handler was writing to AppSettings properties before the new account had finished onboarding. Those writes flipped RootContainer's .id (which keys on themeName / colorScheme / accentColorARGB), which remounted ContentView, which remounted OnboardingView, which re-fired its startOutboxBuilding task and restarted the welcome-spinner animation — visible to the user as the avatar spinner firing a second time partway through onboarding. Same root cause applied to restoreOnLaunchIfNeeded: any payload applied mid-onboarding would also remount OnboardingView. Defer both paths when the active account hasn't completed onboarding yet. ContentView re-fires handleActiveAccountChange from OnboardingView's onComplete closure so the deferred work runs once the user lands in .main, where a .id swap is harmless (ContentView naturally transitions out of .onboarding either way). Side benefit: the leftover state from the prior account during the new account's onboarding flow is the prior theme — but OnboardingView paints its own dark background, so the bleed is not visually obvious. Reset fires immediately after "Let's go".
RootContainer was forcing a full ContentView remount on every themeName / colorScheme / accentColorARGB change via .id(). That remount cycled the active screen back through .splash → .loading, which surfaced LoadingView's avatar spinner — visually identical to OnboardingView's WaitingStep spinner — as a "second" spinner firing immediately after onboarding. The post-onboarding handleActiveAccountChange reset+restore necessarily writes to those properties, so as long as the .id key included them, the user would see the double-spinner every time they signed in with a new nsec. Theme propagation works without the .id: the @observable settings re-evaluates RootContainer.body on every settings change, which recomputes the resolved theme and updates .environment(\.theme). SwiftUI's normal environment propagation gets the new theme to every child view that reads @Environment(\.theme). The .id was belt-and-braces left over from before AppSettings adopted @observable, and removing it makes the post-onboarding transition smooth.
Override the resolved theme environment to .default on the .splash case so the sign-in surface always renders with the orange-accent default Wisp theme. Otherwise the prior account's accent and theme preset bleed into the login screen after sign-out — a stale-looking read that contradicts the "no user is signed in" state. Applies to the SplashView body, the Nostr-login sheet, and the Google-auth full-screen cover. The override only persists for the .splash case; once the user signs in and the active screen flips away, normal theme resolution from AppSettings takes over.
The toggle wasn't earning its keep. The settings payload is NIP-44
self-encrypted to the user's own pubkey, so there's no third-party
visibility to opt out of, and the data class (theme, accent, fiat
currency, media autoplay, etc.) isn't sensitive enough to warrant
the friction of a master switch. Every sync code path also had to
gate on it, and stripping those branches simplifies the flow.
- Remove syncSettingsToRelays property + Keys.syncSettingsToRelays
UserDefaults key + the toggle UI in InterfaceSettingsView.
- Strip the `guard syncSettingsToRelays` checks from
scheduleSettingsSync, publishSettingsNow, restoreFromRelays,
restoreOnLaunchIfNeeded, and handleActiveAccountChange.
- Re-style the "Cross-device sync" section to a single full-width
primary button ("Restore from relays") with a status line and
the description above. Matches the visual weight of other action
buttons in the app rather than reading like a settings link.
Existing users with the toggle currently OFF will silently re-enter
sync on next launch. Acceptable for non-sensitive data — the user
can always trigger a manual restore to override anything that
flows in.
|
Deferring this work to a future phase. The NIP-78 cross-device sync of Interface / Wallet / Zap preferences is a substantial cross-platform contract (iOS + Android together) and is more scope than we need for this milestone. Will be tracked in a dedicated issue alongside the Android counterpart (barrydeen/wisp#564) so the protocol design + payload schema + UX patterns can be revisited cleanly when we pick this up again. Closing this PR once the issue is filed. iOS one-tap-zap + instant-zap UX work (the non-sync parts) stays — only the cross-device sync infrastructure is deferred. |
|
Closing — the iOS NIP-78 cross-device sync work is deferred along with its Android counterpart. Tracking issue (Android repo, since the schema design has to land in lockstep across both platforms): barrydeen/wisp#579. The per-account scoping piece that this branch also touched shipped separately as iOS PR #195. When this is picked up: schema definition needs to land first in both repos before either side wires the publisher/subscriber. |
Refs #70
Summary
Publishes the user's non-sensitive UI preferences to relays as an addressable kind-30078 event (
d-tagwisp-app-settings:v1), NIP-44 self-encrypted, so the same setup follows the account across devices — and, once the Android counterpart lands, across the iOS and Android clients.Scope is the Interface settings screen:
largeText,colorScheme,themeName,accentColorARGBautoLoadMedia,videoAutoplay,animateAvatars,mediaLayoutStyleclientTagEnabled,postUndoTimerEnabled,postUndoTimerSeconds,postUndoTimerForRepliesfiatModeEnabled,fiatCurrency,zapIconStyleOut of scope by design (device-local):
notificationSoundsEnabled,autoApproveRelayAuth,videoLoop. A device on an untrusted network may want stricter per-relay auto-AUTH posture than another; ringer state differs per device; video loop is a small UX pref that doesn't merit relay round-trips.Mechanism
Nip78AppSettings.swift—AppSettingsPayloadCodable struct (all-optional fields +versionfor forward-compat), publish helper (JSON → NIP-44 self-encrypt → sign kind-30078), fetch + decrypt helpers.AppSettings—snapshotForBackup,applyRestoredwith a suppression flag so the restore path doesn't echo-loop into a fresh publish,scheduleSettingsSync(4 s debounce),publishSettingsNow,restoreFromRelays,restoreOnLaunchIfNeeded. Every in-scope setting'sdidSetcallsscheduleSettingsSync.InterfaceSettingsView— new "Cross-device sync" section with a full-width "Restore from relays" primary button and a status line below it.wispApp— one-shotrestoreOnLaunchIfNeededon the root view's.task. Per-pubkey-per-device flag inUserDefaultsso the first launch on a new device fetches and applies; subsequent launches skip the network hit and the device's local settings become the source of truth.Account-change handling
AppSettings.handleActiveAccountChange— fires fromContentView's.onChange(of: keypair?.pubkey)and fromOnboardingView'sonComplete. Resets the synced fields to defaults so the prior account's theme / accent / media prefs don't leak across a sign-out → sign-in or a mid-session switch, then force-pulls the new account's snapshot (bypassing the per-pubkey flag — that flag's "trust local prefs" invariant only holds for the same account).restoreOnLaunchIfNeededalready covers it under its per-pubkey flag, and running the reset there would cause a rebuild loop. Mirrors the Android side.NostrKey.isOnboardingComplete(pubkey:)is true. Running it mid-onboarding wrote toAppSettingsproperties, which (back whenRootContainerkeyed its.idonthemeName/colorScheme/accentColorARGB) remountedContentViewand restartedOnboardingView's welcome-spinner. PR also drops that.iddependency entirely — theme changes propagate via@Observable+@Environment(\.theme)without forcing a full remount, which was the actual source of a "double spinner" on first sign-in..environment(\.theme, .default)on the.splashcase forces the orange Wisp default theme so the prior account's accent never bleeds into the login screen. Same on theNostrLoginSheetandGoogleAuthViewoverlays.Forward-compatibility
Payload uses optional fields. Future additions (quick-zap presets, default reaction, custom quick reactions, etc.) just append optional properties — older clients ignore unknown keys, newer clients fill in missing keys with their local default. No schema-version bump required for pure additions.
Why this doesn't close #70
The issue is "Persist user settings with NIP-78" cross-iOS-and-Android. iOS gets the publish + restore here. Wisp Android still needs the matching
Nip78AppSettings+ payload struct + restore wiring before #70 can close — without it, settings round-trip iOS ↔ iOS only. The Android counterpart will ship as a follow-up PR ondmnyc/wisp, mirroring this payload schema exactly.Test plan
dtagwisp-app-settings:v1and NIP-44-encrypted content