Skip to content

feat(settings): NIP-78 cross-device sync of Interface preferences#173

Closed
dmnyc wants to merge 6 commits into
mainfrom
feat/nip78-settings-sync
Closed

feat(settings): NIP-78 cross-device sync of Interface preferences#173
dmnyc wants to merge 6 commits into
mainfrom
feat/nip78-settings-sync

Conversation

@dmnyc

@dmnyc dmnyc commented May 23, 2026

Copy link
Copy Markdown
Collaborator

Refs #70

Summary

Publishes the user's non-sensitive UI preferences to relays as an addressable kind-30078 event (d-tag wisp-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:

Section Synced fields
Appearance largeText, colorScheme, themeName, accentColorARGB
Media autoLoadMedia, videoAutoplay, animateAvatars, mediaLayoutStyle
Posting clientTagEnabled, postUndoTimerEnabled, postUndoTimerSeconds, postUndoTimerForReplies
Currency fiatModeEnabled, fiatCurrency, zapIconStyle

Out 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.swiftAppSettingsPayload Codable struct (all-optional fields + version for forward-compat), publish helper (JSON → NIP-44 self-encrypt → sign kind-30078), fetch + decrypt helpers.
  • AppSettingssnapshotForBackup, applyRestored with 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's didSet calls scheduleSettingsSync.
  • InterfaceSettingsView — new "Cross-device sync" section with a full-width "Restore from relays" primary button and a status line below it.
  • wispApp — one-shot restoreOnLaunchIfNeeded on the root view's .task. Per-pubkey-per-device flag in UserDefaults so 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 from ContentView's .onChange(of: keypair?.pubkey) and from OnboardingView's onComplete. 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).
  • First-bind tracking — the very first account-change event on an app run is a no-op; restoreOnLaunchIfNeeded already covers it under its per-pubkey flag, and running the reset there would cause a rebuild loop. Mirrors the Android side.
  • Deferred during onboarding — the new account's reset waits until NostrKey.isOnboardingComplete(pubkey:) is true. Running it mid-onboarding wrote to AppSettings properties, which (back when RootContainer keyed its .id on themeName / colorScheme / accentColorARGB) remounted ContentView and restarted OnboardingView's welcome-spinner. PR also drops that .id dependency 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.
  • Default-theme on sign-in surface.environment(\.theme, .default) on the .splash case forces the orange Wisp default theme so the prior account's accent never bleeds into the login screen. Same on the NostrLoginSheet and GoogleAuthView overlays.

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 on dmnyc/wisp, mirroring this payload schema exactly.

Test plan

  • Build clean off main
  • Install on simulator
  • Toggle an Appearance setting → observe a kind-30078 event from your pubkey land on relays ~4 s later with d tag wisp-app-settings:v1 and NIP-44-encrypted content
  • On a second device signed in with the same key → fresh launch → settings (theme, accent, media prefs, etc.) appear pre-applied
  • Manual "Restore from relays" button in Interface settings → pulls the latest snapshot and applies it
  • Mid-session account switch → previous account's theme is wiped and the new account's snapshot pulls in (no LoadingView double-spinner)
  • Sign out (last account) → splash renders with default orange theme, not the prior account's accent
  • Add a fresh nsec from the drawer → onboarding spinner with avatar fires exactly once; lands in main with new account's theme
  • Theme switching mid-session via Interface → Themes → UI updates immediately, no full remount

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.
dmnyc added 5 commits May 23, 2026 15:20
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.
@dmnyc

dmnyc commented May 26, 2026

Copy link
Copy Markdown
Collaborator Author

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.

@dmnyc

dmnyc commented May 26, 2026

Copy link
Copy Markdown
Collaborator Author

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.

@dmnyc dmnyc closed this May 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Persist user settings with NIP-78

1 participant