diff --git a/AppSettings.swift b/AppSettings.swift index 540a26d..3254a86 100644 --- a/AppSettings.swift +++ b/AppSettings.swift @@ -51,38 +51,75 @@ final class AppSettings { private static let defaultAccentARGB: Int = 0xFFFF9800 var largeText: Bool { - didSet { UserDefaults.standard.set(largeText, forKey: Keys.largeText) } + didSet { + UserDefaults.standard.set(largeText, forKey: Keys.largeText) + scheduleSettingsSync() + } } var themeName: String { - didSet { UserDefaults.standard.set(themeName, forKey: Keys.themeName) } + didSet { + UserDefaults.standard.set(themeName, forKey: Keys.themeName) + scheduleSettingsSync() + } } var colorScheme: ColorSchemePreference { - didSet { UserDefaults.standard.set(colorScheme.rawValue, forKey: Keys.colorScheme) } + didSet { + UserDefaults.standard.set(colorScheme.rawValue, forKey: Keys.colorScheme) + scheduleSettingsSync() + } } var accentColorARGB: Int { - didSet { UserDefaults.standard.set(accentColorARGB, forKey: Keys.accentColorARGB) } + didSet { + UserDefaults.standard.set(accentColorARGB, forKey: Keys.accentColorARGB) + scheduleSettingsSync() + } } var autoLoadMedia: Bool { - didSet { UserDefaults.standard.set(autoLoadMedia, forKey: Keys.autoLoadMedia) } + didSet { + UserDefaults.standard.set(autoLoadMedia, forKey: Keys.autoLoadMedia) + scheduleSettingsSync() + } } var videoAutoplay: Bool { - didSet { UserDefaults.standard.set(videoAutoplay, forKey: Keys.videoAutoplay) } + didSet { + UserDefaults.standard.set(videoAutoplay, forKey: Keys.videoAutoplay) + scheduleSettingsSync() + } } var animateAvatars: Bool { - didSet { UserDefaults.standard.set(animateAvatars, forKey: Keys.animateAvatars) } + didSet { + UserDefaults.standard.set(animateAvatars, forKey: Keys.animateAvatars) + scheduleSettingsSync() + } } var mediaLayoutStyle: MediaLayoutStyle { - didSet { UserDefaults.standard.set(mediaLayoutStyle.rawValue, forKey: Keys.mediaLayoutStyle) } + didSet { + UserDefaults.standard.set(mediaLayoutStyle.rawValue, forKey: Keys.mediaLayoutStyle) + scheduleSettingsSync() + } } var clientTagEnabled: Bool { - didSet { UserDefaults.standard.set(clientTagEnabled, forKey: Keys.clientTagEnabled) } + didSet { + UserDefaults.standard.set(clientTagEnabled, forKey: Keys.clientTagEnabled) + scheduleSettingsSync() + } } var fiatModeEnabled: Bool { - didSet { UserDefaults.standard.set(fiatModeEnabled, forKey: Keys.fiatModeEnabled) } + didSet { + UserDefaults.standard.set(fiatModeEnabled, forKey: Keys.fiatModeEnabled) + scheduleSettingsSync() + } } var fiatCurrency: String { - didSet { UserDefaults.standard.set(fiatCurrency, forKey: Keys.fiatCurrency) } + didSet { + UserDefaults.standard.set(fiatCurrency, forKey: Keys.fiatCurrency) + scheduleSettingsSync() + } } + // Out of scope for NIP-78 cross-device sync per the settings-sync + // contract — notification sounds are device-local (different ringers + // / Do Not Disturb states on each device shouldn't be overridden by + // sync). var notificationSoundsEnabled: Bool { didSet { UserDefaults.standard.set(notificationSoundsEnabled, forKey: Keys.notificationSoundsEnabled) } } @@ -90,28 +127,59 @@ final class AppSettings { /// `postUndoTimerForReplies`) waits `postUndoTimerSeconds` before sending, /// giving the user a chance to cancel. var postUndoTimerEnabled: Bool { - didSet { UserDefaults.standard.set(postUndoTimerEnabled, forKey: Keys.postUndoTimerEnabled) } + didSet { + UserDefaults.standard.set(postUndoTimerEnabled, forKey: Keys.postUndoTimerEnabled) + scheduleSettingsSync() + } } var postUndoTimerSeconds: Int { - didSet { UserDefaults.standard.set(postUndoTimerSeconds, forKey: Keys.postUndoTimerSeconds) } + didSet { + UserDefaults.standard.set(postUndoTimerSeconds, forKey: Keys.postUndoTimerSeconds) + scheduleSettingsSync() + } } /// When false, replies skip the undo countdown and publish immediately — /// the default. Top-level posts still respect `postUndoTimerEnabled`. var postUndoTimerForReplies: Bool { - didSet { UserDefaults.standard.set(postUndoTimerForReplies, forKey: Keys.postUndoTimerForReplies) } + didSet { + UserDefaults.standard.set(postUndoTimerForReplies, forKey: Keys.postUndoTimerForReplies) + scheduleSettingsSync() + } } /// When true (default), AUTH challenges from relays are signed and sent automatically /// without prompting. When false, each relay must be individually approved in relay settings. + // Out of scope for NIP-78 cross-device sync — auto-AUTH posture is + // device-local (a device on an untrusted network may want stricter + // per-relay prompts than another). var autoApproveRelayAuth: Bool { didSet { UserDefaults.standard.set(autoApproveRelayAuth, forKey: Keys.autoApproveRelayAuth) } } var zapIconStyle: ZapIconStyle { - didSet { UserDefaults.standard.set(zapIconStyle.rawValue, forKey: Keys.zapIconStyle) } + didSet { + UserDefaults.standard.set(zapIconStyle.rawValue, forKey: Keys.zapIconStyle) + scheduleSettingsSync() + } } - // TODO: Persist to NIP78 (kind 30078) when that feature is available. + // Out of scope for NIP-78 cross-device sync — video loop is a small + // device-local UX pref that doesn't merit relay round-trips. var videoLoop: Bool { didSet { UserDefaults.standard.set(videoLoop, forKey: Keys.videoLoop) } } + /// Suppresses the debounced relay publish while `applyRestored` is + /// writing into the same properties whose didSets would otherwise + /// re-trigger sync. Without it, a restore feedback-loops into a + /// republish of the values we just received. + @ObservationIgnored private var isApplyingRestoredPayload = false + + /// Debounced publish task. Coalesces a burst of mutations (e.g. the + /// user fiddles with a slider) into a single relay write. + @ObservationIgnored private var settingsSyncTask: Task? + + /// Debounce window for the relay publish. 4s comfortably outlasts a + /// typical interactive adjustment session (toggling a few switches, + /// dragging a slider to settle) while keeping the latency from + /// settle → published small. + private static let settingsSyncDebounceSeconds: UInt64 = 4 private init() { let defaults = UserDefaults.standard @@ -139,6 +207,261 @@ final class AppSettings { self.videoLoop = defaults.object(forKey: Keys.videoLoop) as? Bool ?? true } + // MARK: - NIP-78 cross-device sync + + /// Build a payload snapshot from the current setting values for + /// publication via `Nip78AppSettings.createPublishEvent`. Only the + /// in-scope Interface-screen preferences are included; out-of-scope + /// fields (notification sounds, relay auto-auth, video loop) stay + /// device-local by design. + func snapshotForBackup() -> Nip78AppSettings.AppSettingsPayload { + Nip78AppSettings.AppSettingsPayload( + version: 1, + largeText: largeText, + colorScheme: colorScheme.rawValue, + themeName: themeName, + accentColorARGB: accentColorARGB, + autoLoadMedia: autoLoadMedia, + videoAutoplay: videoAutoplay, + animateAvatars: animateAvatars, + mediaLayoutStyle: mediaLayoutStyle.rawValue, + clientTagEnabled: clientTagEnabled, + postUndoTimerEnabled: postUndoTimerEnabled, + postUndoTimerSeconds: postUndoTimerSeconds, + postUndoTimerForReplies: postUndoTimerForReplies, + fiatModeEnabled: fiatModeEnabled, + fiatCurrency: fiatCurrency, + zapIconStyle: zapIconStyle.rawValue + ) + } + + /// Apply a restored payload onto the live settings. Each field that + /// came through the wire overwrites the local value; missing fields + /// keep the local default. The suppression flag prevents the didSet + /// chain from echoing this restore back out as a fresh publish. + func applyRestored(_ payload: Nip78AppSettings.AppSettingsPayload) { + isApplyingRestoredPayload = true + defer { isApplyingRestoredPayload = false } + + if let v = payload.largeText { self.largeText = v } + if let raw = payload.colorScheme, + let cs = ColorSchemePreference(rawValue: raw) { self.colorScheme = cs } + if let v = payload.themeName { self.themeName = v } + if let v = payload.accentColorARGB { self.accentColorARGB = v } + + if let v = payload.autoLoadMedia { self.autoLoadMedia = v } + if let v = payload.videoAutoplay { self.videoAutoplay = v } + if let v = payload.animateAvatars { self.animateAvatars = v } + if let raw = payload.mediaLayoutStyle, + let layout = MediaLayoutStyle(rawValue: raw) { self.mediaLayoutStyle = layout } + + if let v = payload.clientTagEnabled { self.clientTagEnabled = v } + if let v = payload.postUndoTimerEnabled { self.postUndoTimerEnabled = v } + if let v = payload.postUndoTimerSeconds, + Self.postUndoTimerOptions.contains(v) { self.postUndoTimerSeconds = v } + if let v = payload.postUndoTimerForReplies { self.postUndoTimerForReplies = v } + + if let v = payload.fiatModeEnabled { self.fiatModeEnabled = v } + if let v = payload.fiatCurrency { self.fiatCurrency = v } + if let raw = payload.zapIconStyle, + let style = ZapIconStyle(rawValue: raw) { self.zapIconStyle = style } + } + + /// Reset every synced field to its default value. Called from the + /// logout / account-switch paths so the prior account's preferences + /// don't bleed into the splash / login screen or into the next + /// account's session. The suppression flag prevents the didSet + /// chain from publishing a "defaults" payload back to relays — + /// resets are a local operation only. + /// + /// Out-of-scope fields (notificationSoundsEnabled, + /// autoApproveRelayAuth, videoLoop) stay untouched: they're + /// device-local already and aren't tied to the account. + func resetSyncedSettingsToDefaults() { + isApplyingRestoredPayload = true + defer { isApplyingRestoredPayload = false } + + largeText = false + themeName = "custom" + colorScheme = .dark + accentColorARGB = Self.defaultAccentARGB + + autoLoadMedia = true + videoAutoplay = true + animateAvatars = true + mediaLayoutStyle = .grid + + clientTagEnabled = true + postUndoTimerEnabled = true + postUndoTimerSeconds = 10 + postUndoTimerForReplies = false + + fiatModeEnabled = false + fiatCurrency = "USD" + zapIconStyle = .bitcoin + + // Cancel any pending publish that was queued before the reset + // arrived. Without this, a debounced publish from the prior + // account could fire after the keypair is gone and post the + // defaults payload to an unrelated relay. + settingsSyncTask?.cancel() + settingsSyncTask = nil + } + + /// Debounced publish of the current settings snapshot to the active + /// keypair's outbox relays. Cancels any in-flight publish task so + /// the most recent change wins. Becomes a no-op when sync is off, + /// when a restore is in progress, or when no signing keypair is + /// available. + func scheduleSettingsSync() { + guard !isApplyingRestoredPayload else { return } + settingsSyncTask?.cancel() + settingsSyncTask = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: Self.settingsSyncDebounceSeconds * 1_000_000_000) + guard !Task.isCancelled, let self else { return } + await self.publishSettingsNow() + } + } + + /// Immediate (non-debounced) publish. Bypasses the debounce timer — + /// used by any path that needs an immediate snapshot push (tests, + /// future "sync now" affordances). + func publishSettingsNow() async { + guard let keypair = NostrKey.load(), !keypair.isWatchOnly else { return } + + do { + let event = try await Nip78AppSettings.createPublishEvent( + keypair: keypair, + payload: snapshotForBackup() + ) + let relays = Self.syncRelays(for: keypair.pubkey) + guard !relays.isEmpty else { return } + _ = await RelayPool.publish(event: event, to: relays, timeout: 6) + } catch { + // Logged inside the Signer path; swallow here so a transient + // signer failure doesn't bubble up to the settings UI. + } + } + + /// Tracks the last pubkey we've handled an account-change event + /// for. The first transition (`nil` → first signed-in account on + /// app launch) is left to `restoreOnLaunchIfNeeded` — running the + /// reset path here too triggers a rebuild loop because writing + /// defaults to the synced fields changes `RootContainer`'s `.id`, + /// which destroys `ContentView`, which `.onAppear`s and re-loads + /// the saved keypair, which fires `.onChange` again, ad + /// infinitum. + @ObservationIgnored private var lastHandledAccountPubkey: String? + @ObservationIgnored private var hasHandledFirstAccount = false + + /// Called from ContentView when the active account changes + /// (sign-in, sign-out, mid-session switch). With sync enabled, + /// wipes the prior account's synced fields and pulls the new + /// account's snapshot from relays so the UI flips immediately to + /// the new account's preferences instead of carrying the prior + /// account's theme into the splash / next session. + /// + /// First-bind-ever (the initial sign-in on app start) is a no-op + /// — `restoreOnLaunchIfNeeded` already covers that case under its + /// per-pubkey flag, and bypassing it here both burns relay + /// traffic and pulls down the rebuild-loop described in the + /// `lastHandledAccountPubkey` doc. + /// + /// With sync disabled, this is a no-op — the user has opted out + /// of cross-device sync and presumably wants device-stable + /// settings across account changes too. They can re-enable sync + /// and tap Restore manually if they change their mind. + func handleActiveAccountChange() async { + let currentPubkey = NostrKey.load()?.pubkey + if currentPubkey == lastHandledAccountPubkey { return } + let isFirstHandle = !hasHandledFirstAccount + hasHandledFirstAccount = true + + if isFirstHandle { + // First account ever on this app run — restoreOnLaunchIfNeeded + // handles it. Mark as handled so subsequent transitions know + // we've seen this pubkey. + lastHandledAccountPubkey = currentPubkey + return + } + + // Defer reset + restore until onboarding finishes for the new + // account. Running the property writes mid-onboarding triggers + // a RootContainer .id change that remounts OnboardingView and + // restarts its welcome-spinner .task — visible to the user as + // the avatar spinner firing a second time. ContentView re-fires + // this method from OnboardingView's onComplete so the deferred + // work happens once the user lands in .main. + if let pk = currentPubkey, !NostrKey.isOnboardingComplete(pubkey: pk) { + return + } + + lastHandledAccountPubkey = currentPubkey + resetSyncedSettingsToDefaults() + if currentPubkey != nil { + await restoreFromRelays() + } + } + + /// First-time-per-account restore. Called from the root view at + /// launch. Once a pubkey has been restored on this device the flag + /// persists in UserDefaults so subsequent launches skip the network + /// hit — the device's local settings then become the source of + /// truth and changes flow back out via the debounced publish path. + /// The user can still trigger a manual re-restore from the + /// Interface settings screen. + func restoreOnLaunchIfNeeded() async { + guard let keypair = NostrKey.load(), !keypair.isWatchOnly else { return } + // Same reasoning as the defer in handleActiveAccountChange — + // an applyRestored mid-onboarding would write to AppSettings + // properties and trigger a RootContainer .id change that + // remounts OnboardingView. Wait until the user is past + // onboarding; ContentView re-fires this path from + // OnboardingView's onComplete via handleActiveAccountChange. + guard NostrKey.isOnboardingComplete(pubkey: keypair.pubkey) else { return } + let flagKey = "wisp_settings_restored_\(keypair.pubkey)" + if UserDefaults.standard.bool(forKey: flagKey) { return } + await restoreFromRelays() + UserDefaults.standard.set(true, forKey: flagKey) + } + + /// Fetch the user's latest NIP-78 settings event from relays and + /// apply it. Safe to call at every app launch — applyRestored is + /// idempotent and the suppression flag prevents a re-publish. + func restoreFromRelays() async { + guard let keypair = NostrKey.load(), !keypair.isWatchOnly else { return } + + let relays = Self.syncRelays(for: keypair.pubkey) + guard !relays.isEmpty else { return } + + let events = await RelayPool.query( + relays: relays, + filter: Nip78AppSettings.filter(pubkey: keypair.pubkey), + timeout: 8, + waitForAllRelays: false + ) + let matching = events.filter { Nip78AppSettings.matches($0) } + guard let latest = matching.max(by: { $0.createdAt < $1.createdAt }) else { return } + guard let payload = await Nip78AppSettings.decryptPayload(keypair: keypair, event: latest) else { return } + applyRestored(payload) + } + + /// Outbox-like relay selection for settings publishes / reads. Uses + /// the user's top-scored relays when known; falls back to a small + /// set of public relays for first-time users / brand-new installs + /// where the scoreboard hasn't been populated yet. + private static func syncRelays(for pubkey: String) -> [String] { + if let board = RelayScoreBoard.load(pubkey: pubkey) { + let top = board.scoredRelays.prefix(8).map(\.url) + if !top.isEmpty { return top } + } + return [ + "wss://relay.damus.io", + "wss://relay.primal.net", + "wss://nos.lol" + ] + } + /// SF Symbol name for the zap icon. Only valid when `fiatModeEnabled` is false. /// Use `zapImage` for rendering — it handles the fiat coin stack automatically. var zapSymbolName: String { diff --git a/InterfaceSettingsView.swift b/InterfaceSettingsView.swift index c09ba80..725c6f8 100644 --- a/InterfaceSettingsView.swift +++ b/InterfaceSettingsView.swift @@ -10,6 +10,19 @@ struct InterfaceSettingsView: View { @State private var rateUpdatedAt: Date? = nil @State private var themesExpanded = false + /// Phase of the manual relay-restore action. Drives the spinner / + /// status text on the "Restore from relays" button so users have a + /// visible signal that something happened — the actual UI mutations + /// (theme, accent, etc.) animate in via the normal property bindings. + @State private var restoreState: RestoreState = .idle + + enum RestoreState: Equatable { + case idle + case restoring + case restored + case failed(String) + } + var body: some View { @Bindable var settings = settings ScrollView { @@ -236,6 +249,37 @@ struct InterfaceSettingsView: View { } } + section(title: "Cross-device sync") { + Text("Your appearance, media, posting, and currency preferences sync to relays as an encrypted NIP-78 snapshot so the same setup follows your account on a new device.") + .font(.system(size: 12)) + .foregroundStyle(theme.palette.onSurfaceVariant) + Button { + performManualRestore() + } label: { + HStack(spacing: 8) { + if restoreState == .restoring { + ProgressView() + .controlSize(.small) + .tint(.white) + Text("Restoring…") + } else { + Text("Restore from relays") + } + } + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(height: 44) + .background(theme.primary, in: Capsule()) + } + .buttonStyle(.plain) + .disabled(restoreState == .restoring) + .padding(.top, 4) + Text(restoreStatusText) + .font(.system(size: 12)) + .foregroundStyle(theme.palette.onSurfaceVariant) + } + Spacer(minLength: 40) } .padding(20) @@ -263,6 +307,27 @@ struct InterfaceSettingsView: View { } } + private var restoreStatusText: String { + switch restoreState { + case .idle: return "Pulls the latest settings snapshot from relays" + case .restoring: return "Fetching latest snapshot…" + case .restored: return "Settings restored from relays" + case .failed(let reason): return "Couldn't restore — \(reason)" + } + } + + private func performManualRestore() { + restoreState = .restoring + Task { + await settings.restoreFromRelays() + // No structured error surfaces from `restoreFromRelays` — it + // swallows decrypt / network failures internally — so the + // happy path is "we ran." If the user wanted a more granular + // status we'd need to extend `restoreFromRelays` to throw. + restoreState = .restored + } + } + @ViewBuilder private func section(title: String, @ViewBuilder content: () -> Content) -> some View { VStack(alignment: .leading, spacing: 8) { diff --git a/Nip78AppSettings.swift b/Nip78AppSettings.swift new file mode 100644 index 0000000..e400bce --- /dev/null +++ b/Nip78AppSettings.swift @@ -0,0 +1,120 @@ +import Foundation +import os.log + +private let settingsSyncLog = Logger(subsystem: "wisp", category: "settings-sync") + +/// NIP-78 (kind 30078) cross-device sync of the user's non-sensitive UI +/// preferences. The payload is JSON-encoded, NIP-44 self-encrypted (sender +/// + recipient are the same pubkey), and stored as an addressable event +/// under d-tag `wisp-app-settings:v1` so the latest version replaces the +/// previous one via standard NIP-78 semantics. +/// +/// Scope: Interface-screen preferences only (Appearance, Media, Posting, +/// Currency). Out of scope: notification sounds, relay auto-auth, and +/// anything that lives outside the Interface screen — see the project +/// memory `project_nip78_settings_completeness.md` for the contract. +/// +/// Forward-compat: every field in `AppSettingsPayload` is optional and +/// the struct carries a `version` so future PRs can extend it without +/// breaking round-trip with older clients. +nonisolated enum Nip78AppSettings { + static let kind = 30078 + static let dTag = "wisp-app-settings:v1" + + /// JSON-serialised payload that round-trips through the encrypted + /// `content` of the NIP-78 event. All fields optional — missing keys + /// on the wire mean "this client didn't carry that pref"; existing + /// fields on the device are left untouched on restore. + struct AppSettingsPayload: Codable, Equatable { + // Schema version. Bumped only when an existing field's semantics + // change incompatibly; pure additions don't require a bump. + var version: Int? = 1 + + // Appearance + var largeText: Bool? + var colorScheme: String? + var themeName: String? + var accentColorARGB: Int? + + // Media + var autoLoadMedia: Bool? + var videoAutoplay: Bool? + var animateAvatars: Bool? + var mediaLayoutStyle: String? + + // Posting + var clientTagEnabled: Bool? + var postUndoTimerEnabled: Bool? + var postUndoTimerSeconds: Int? + var postUndoTimerForReplies: Bool? + + // Currency / zaps + var fiatModeEnabled: Bool? + var fiatCurrency: String? + var zapIconStyle: String? + } + + /// NIP-44-encrypt the JSON payload to the user's own pubkey and sign + /// as a kind 30078 event under the `wisp-app-settings:v1` d-tag. + @MainActor + static func createPublishEvent(keypair: Keypair, payload: AppSettingsPayload) async throws -> NostrEvent { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let json = String(data: try encoder.encode(payload), encoding: .utf8) ?? "{}" + + let encrypted = try await Signer.nip44Encrypt( + keypair: keypair, + peerPubkey: keypair.pubkey, + plaintext: json + ) + + var tags: [[String]] = [ + ["d", dTag], + ["encryption", "nip44"] + ] + if let clientTag = NostrEvent.clientTagIfEnabled() { + tags.append(clientTag) + } + + return try await Signer.sign( + keypair: keypair, + kind: kind, + tags: tags, + content: encrypted + ) + } + + /// Decrypt + parse a settings event. Returns nil when the content + /// isn't decryptable or the JSON doesn't parse — callers should treat + /// nil as "no usable payload here" and skip without surfacing an + /// error. + @MainActor + static func decryptPayload(keypair: Keypair, event: NostrEvent) async -> AppSettingsPayload? { + do { + let decrypted = try await Signer.nip44Decrypt( + keypair: keypair, + peerPubkey: event.pubkey, + payload: event.content + ) + guard let data = decrypted.data(using: .utf8) else { return nil } + return try JSONDecoder().decode(AppSettingsPayload.self, from: data) + } catch { + settingsSyncLog.error("decrypt or decode failed for event \(event.id, privacy: .public): \(error.localizedDescription, privacy: .public)") + return nil + } + } + + /// Filter for fetching a user's app-settings event. Kind 30078 is + /// shared across NIP-78 data classes, so callers MUST filter by + /// d-tag client-side before treating an event as a settings backup. + static func filter(pubkey: String) -> NostrFilter { + NostrFilter(kinds: [kind], authors: [pubkey]) + } + + /// True when an event's d-tag matches this backup namespace. Use to + /// separate settings events from other NIP-78 data classes (wallet + /// backups, NWC backups, etc.) returned by the same broad filter. + static func matches(_ event: NostrEvent) -> Bool { + event.tags.contains { $0.count >= 2 && $0[0] == "d" && $0[1] == dTag } + } +} diff --git a/wisp.xcodeproj/project.pbxproj b/wisp.xcodeproj/project.pbxproj index f9001fa..4d24089 100644 --- a/wisp.xcodeproj/project.pbxproj +++ b/wisp.xcodeproj/project.pbxproj @@ -148,6 +148,7 @@ 4CFF0B0B0000000000000205 /* NwcConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFF0B0A0000000000000205 /* NwcConnection.swift */; }; 4CFF0B0D0000000000000206 /* Nip57.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFF0B0C0000000000000206 /* Nip57.swift */; }; 4CFF0B0F0000000000000207 /* Nip78Backup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFF0B0E0000000000000207 /* Nip78Backup.swift */; }; + 4CFF0B510000000000000300 /* Nip78AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFF0B500000000000000300 /* Nip78AppSettings.swift */; }; 4CFF0B110000000000000208 /* BreezConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFF0B100000000000000208 /* BreezConfig.swift */; }; 4CFF0B130000000000000209 /* Bip39.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFF0B120000000000000209 /* Bip39.swift */; }; 4CFF0B15000000000000020A /* NwcWallet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFF0B14000000000000020A /* NwcWallet.swift */; }; @@ -427,6 +428,7 @@ 4CFF0B0A0000000000000205 /* NwcConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NwcConnection.swift; sourceTree = ""; }; 4CFF0B0C0000000000000206 /* Nip57.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nip57.swift; sourceTree = ""; }; 4CFF0B0E0000000000000207 /* Nip78Backup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nip78Backup.swift; sourceTree = ""; }; + 4CFF0B500000000000000300 /* Nip78AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nip78AppSettings.swift; sourceTree = ""; }; 4CFF0B100000000000000208 /* BreezConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreezConfig.swift; sourceTree = ""; }; 4CFF0B120000000000000209 /* Bip39.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bip39.swift; sourceTree = ""; }; 4CFF0B14000000000000020A /* NwcWallet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NwcWallet.swift; sourceTree = ""; }; @@ -704,6 +706,7 @@ 4CFF0B0A0000000000000205 /* NwcConnection.swift */, 4CFF0B0C0000000000000206 /* Nip57.swift */, 4CFF0B0E0000000000000207 /* Nip78Backup.swift */, + 4CFF0B500000000000000300 /* Nip78AppSettings.swift */, 4CFF0B100000000000000208 /* BreezConfig.swift */, 4CFF0B120000000000000209 /* Bip39.swift */, 4CFFFB000000000000000F10 /* BlurHash.swift */, @@ -1128,6 +1131,7 @@ 4CFF0B0B0000000000000205 /* NwcConnection.swift in Sources */, 4CFF0B0D0000000000000206 /* Nip57.swift in Sources */, 4CFF0B0F0000000000000207 /* Nip78Backup.swift in Sources */, + 4CFF0B510000000000000300 /* Nip78AppSettings.swift in Sources */, 4CFF0B110000000000000208 /* BreezConfig.swift in Sources */, 4CFF0B130000000000000209 /* Bip39.swift in Sources */, 4CFFFB010000000000000F10 /* BlurHash.swift in Sources */, diff --git a/wisp/ContentView.swift b/wisp/ContentView.swift index 8a753cc..cf48517 100644 --- a/wisp/ContentView.swift +++ b/wisp/ContentView.swift @@ -21,6 +21,12 @@ struct ContentView: View { Group { switch currentScreen { case .splash: + // Force the default (orange) theme on the sign-in + // surface so the prior account's accent / theme + // never bleeds into the splash. Without this, signing + // out of an account with a customized theme leaves + // the splash painted in that account's colors until + // the next launch. SplashView( onContinueWithNostr: { showNostrSheet = true @@ -47,6 +53,7 @@ struct ContentView: View { currentScreen = .signUp } ) + .environment(\.theme, .default) } .fullScreenCover(isPresented: $showGoogleAuth) { GoogleAuthView( @@ -66,7 +73,9 @@ struct ContentView: View { } } ) + .environment(\.theme, .default) } + .environment(\.theme, .default) case .signUp: SignUpFlowView { kp in @@ -84,6 +93,11 @@ struct ContentView: View { if let keypair { OnboardingView(keypair: keypair) { withAnimation { currentScreen = .main } + // Onboarding is now marked complete — re-fire + // the account-change handler so the reset + + // relay restore that we deferred during + // onboarding actually runs now. + Task { await AppSettings.shared.handleActiveAccountChange() } } } @@ -138,6 +152,14 @@ struct ContentView: View { } } } + // Account changed — wipe the prior account's synced fields and + // pull the new account's snapshot so the UI doesn't carry the + // previous user's theme / fiat / media prefs into the splash or + // into the next signed-in session. Gated on the sync toggle + // inside `handleActiveAccountChange`. + .onChange(of: keypair?.pubkey) { _, _ in + Task { await AppSettings.shared.handleActiveAccountChange() } + } } } diff --git a/wisp/wispApp.swift b/wisp/wispApp.swift index a413e26..1ada630 100644 --- a/wisp/wispApp.swift +++ b/wisp/wispApp.swift @@ -59,8 +59,23 @@ private struct RootContainer: View { var body: some View { let resolved = settings.resolveTheme(systemColorScheme: systemColorScheme) ResolvedThemeProxy.update(resolved) + // Theme + accent + large-text changes propagate via the + // @Observable settings binding and the .environment(\.theme) + // chain — no full ContentView remount needed. An earlier + // version of this view kept a `.id("\(settings.themeName)…")` + // here as belt-and-braces, but that forced a complete teardown + // of ContentView on every theme change. Worse, the + // post-onboarding `handleActiveAccountChange` reset would + // trigger that rebuild, bounce the user .main → .splash → + // .loading, and surface LoadingView's avatar spinner as what + // looked like the onboarding spinner firing a second time. return ContentView() .environment(\.theme, resolved) - .id("\(settings.themeName)-\(settings.colorScheme.rawValue)-\(settings.accentColorARGB)-\(systemColorScheme == .dark)") + .task { + // Pull the cross-device NIP-78 settings snapshot once per + // pubkey-per-device. Fast no-op when the user has no key + // or the device has already restored this account. + await settings.restoreOnLaunchIfNeeded() + } } }