From aaa8215582dce8c06ed86bfa732b8b00af8f72e2 Mon Sep 17 00:00:00 2001 From: dmnyc Date: Sat, 23 May 2026 10:40:46 -0400 Subject: [PATCH 1/6] feat(settings): NIP-78 cross-device sync of Interface preferences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 barrydeen/wisp-ios#70 (Persist user settings with NIP-78); doesn't close it yet — the Wisp Android port mirroring the payload schema is the remaining piece. --- AppSettings.swift | 260 +++++++++++++++++++++++++++++++-- InterfaceSettingsView.swift | 63 ++++++++ Nip78AppSettings.swift | 120 +++++++++++++++ wisp.xcodeproj/project.pbxproj | 4 + wisp/wispApp.swift | 6 + 5 files changed, 437 insertions(+), 16 deletions(-) create mode 100644 Nip78AppSettings.swift diff --git a/AppSettings.swift b/AppSettings.swift index 540a26d..25b70af 100644 --- a/AppSettings.swift +++ b/AppSettings.swift @@ -42,6 +42,7 @@ final class AppSettings { static let autoApproveRelayAuth = "wisp_settings_auto_approve_relay_auth" static let zapIconStyle = "wisp_settings_zap_icon_style" static let videoLoop = "wisp_settings_video_loop" + static let syncSettingsToRelays = "wisp_settings_sync_to_relays" } /// Allowed durations for the post-undo countdown. Picker shows these as @@ -51,38 +52,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 +128,67 @@ 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) } } + /// Master toggle for cross-device sync of UI preferences via NIP-78. + /// When off, mutations no longer schedule a relay publish and the + /// app skips the on-launch restore. Defaults to on — privacy-aware + /// users can opt out in Interface settings. + var syncSettingsToRelays: Bool { + didSet { UserDefaults.standard.set(syncSettingsToRelays, forKey: Keys.syncSettingsToRelays) } + } + + /// 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 @@ -137,6 +214,157 @@ final class AppSettings { let zapRaw = defaults.string(forKey: Keys.zapIconStyle) ?? ZapIconStyle.bitcoin.rawValue self.zapIconStyle = ZapIconStyle(rawValue: zapRaw) ?? .bitcoin self.videoLoop = defaults.object(forKey: Keys.videoLoop) as? Bool ?? true + self.syncSettingsToRelays = defaults.object(forKey: Keys.syncSettingsToRelays) 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 } + } + + /// 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 syncSettingsToRelays, !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 — + /// the InterfaceSettings "Sync now" affordance uses this, and so does + /// any test path. Still gated on the `syncSettingsToRelays` toggle. + func publishSettingsNow() async { + guard syncSettingsToRelays else { return } + 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. + } + } + + /// 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 syncSettingsToRelays else { return } + guard let keypair = NostrKey.load(), !keypair.isWatchOnly 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 syncSettingsToRelays else { return } + 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. diff --git a/InterfaceSettingsView.swift b/InterfaceSettingsView.swift index c09ba80..9d464e4 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,35 @@ struct InterfaceSettingsView: View { } } + section(title: "Cross-device sync") { + Toggle("Sync settings across devices", isOn: $settings.syncSettingsToRelays) + .toggleStyle(SwitchToggleStyle(tint: theme.primary)) + Text("Publishes an encrypted NIP-78 snapshot of your appearance, media, posting, and currency preferences to relays so the same setup follows your account on a new device.") + .font(.system(size: 12)) + .foregroundStyle(theme.palette.onSurfaceVariant) + + if settings.syncSettingsToRelays { + Divider().padding(.vertical, 4) + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text("Restore from relays") + .foregroundStyle(theme.palette.onSurface) + Text(restoreStatusText) + .font(.system(size: 12)) + .foregroundStyle(theme.palette.onSurfaceVariant) + } + Spacer() + if restoreState == .restoring { + ProgressView().controlSize(.small) + } else { + Button("Restore") { performManualRestore() } + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(theme.primary) + } + } + } + } + Spacer(minLength: 40) } .padding(20) @@ -263,6 +305,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/wispApp.swift b/wisp/wispApp.swift index a413e26..7a86188 100644 --- a/wisp/wispApp.swift +++ b/wisp/wispApp.swift @@ -62,5 +62,11 @@ private struct RootContainer: View { 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() + } } } From cb2d49af3aa4491911b9bb8054919e8817fdd752 Mon Sep 17 00:00:00 2001 From: dmnyc Date: Sat, 23 May 2026 15:20:24 -0400 Subject: [PATCH 2/6] fix(settings): reset and re-restore synced prefs on account change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- AppSettings.swift | 83 ++++++++++++++++++++++++++++++++++++++++++ wisp/ContentView.swift | 8 ++++ 2 files changed, 91 insertions(+) diff --git a/AppSettings.swift b/AppSettings.swift index 25b70af..c319f81 100644 --- a/AppSettings.swift +++ b/AppSettings.swift @@ -277,6 +277,47 @@ final class AppSettings { 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, @@ -313,6 +354,48 @@ final class AppSettings { } } + /// 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 + lastHandledAccountPubkey = currentPubkey + if isFirstHandle { return } + guard syncSettingsToRelays else { return } + 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 diff --git a/wisp/ContentView.swift b/wisp/ContentView.swift index 8a753cc..1f60ae0 100644 --- a/wisp/ContentView.swift +++ b/wisp/ContentView.swift @@ -138,6 +138,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() } + } } } From 01146c93c31241c6cb6bae1e393e0f26f4c62866 Mon Sep 17 00:00:00 2001 From: dmnyc Date: Sat, 23 May 2026 15:30:09 -0400 Subject: [PATCH 3/6] fix(settings): defer account-change reset until onboarding completes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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". --- AppSettings.swift | 34 ++++++++++++++++++++++++++++++++-- wisp/ContentView.swift | 5 +++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/AppSettings.swift b/AppSettings.swift index c319f81..71b0551 100644 --- a/AppSettings.swift +++ b/AppSettings.swift @@ -387,9 +387,32 @@ final class AppSettings { 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 + } + + guard syncSettingsToRelays else { + 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 - if isFirstHandle { return } - guard syncSettingsToRelays else { return } resetSyncedSettingsToDefaults() if currentPubkey != nil { await restoreFromRelays() @@ -406,6 +429,13 @@ final class AppSettings { func restoreOnLaunchIfNeeded() async { guard syncSettingsToRelays else { return } 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() diff --git a/wisp/ContentView.swift b/wisp/ContentView.swift index 1f60ae0..2f52f49 100644 --- a/wisp/ContentView.swift +++ b/wisp/ContentView.swift @@ -84,6 +84,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() } } } From 079f6bfdfb01554feeaca5076bc4049811aa6346 Mon Sep 17 00:00:00 2001 From: dmnyc Date: Sat, 23 May 2026 15:37:25 -0400 Subject: [PATCH 4/6] fix(settings): drop ContentView .id remount on theme change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- wisp/wispApp.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/wisp/wispApp.swift b/wisp/wispApp.swift index 7a86188..1ada630 100644 --- a/wisp/wispApp.swift +++ b/wisp/wispApp.swift @@ -59,9 +59,18 @@ 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 From bbf43873352f1410a22a3b5b18155447bf4e5fc0 Mon Sep 17 00:00:00 2001 From: dmnyc Date: Sat, 23 May 2026 15:55:03 -0400 Subject: [PATCH 5/6] fix(settings): force default theme on sign-in surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- wisp/ContentView.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/wisp/ContentView.swift b/wisp/ContentView.swift index 2f52f49..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 From 8e3e605f96797c61a4da05f0a049cea280cc1b8c Mon Sep 17 00:00:00 2001 From: dmnyc Date: Sat, 23 May 2026 17:06:58 -0400 Subject: [PATCH 6/6] fix(settings): drop the cross-device sync toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- AppSettings.swift | 24 +++------------------- InterfaceSettingsView.swift | 40 +++++++++++++++++++------------------ 2 files changed, 24 insertions(+), 40 deletions(-) diff --git a/AppSettings.swift b/AppSettings.swift index 71b0551..3254a86 100644 --- a/AppSettings.swift +++ b/AppSettings.swift @@ -42,7 +42,6 @@ final class AppSettings { static let autoApproveRelayAuth = "wisp_settings_auto_approve_relay_auth" static let zapIconStyle = "wisp_settings_zap_icon_style" static let videoLoop = "wisp_settings_video_loop" - static let syncSettingsToRelays = "wisp_settings_sync_to_relays" } /// Allowed durations for the post-undo countdown. Picker shows these as @@ -166,14 +165,6 @@ final class AppSettings { var videoLoop: Bool { didSet { UserDefaults.standard.set(videoLoop, forKey: Keys.videoLoop) } } - /// Master toggle for cross-device sync of UI preferences via NIP-78. - /// When off, mutations no longer schedule a relay publish and the - /// app skips the on-launch restore. Defaults to on — privacy-aware - /// users can opt out in Interface settings. - var syncSettingsToRelays: Bool { - didSet { UserDefaults.standard.set(syncSettingsToRelays, forKey: Keys.syncSettingsToRelays) } - } - /// 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 @@ -214,7 +205,6 @@ final class AppSettings { let zapRaw = defaults.string(forKey: Keys.zapIconStyle) ?? ZapIconStyle.bitcoin.rawValue self.zapIconStyle = ZapIconStyle(rawValue: zapRaw) ?? .bitcoin self.videoLoop = defaults.object(forKey: Keys.videoLoop) as? Bool ?? true - self.syncSettingsToRelays = defaults.object(forKey: Keys.syncSettingsToRelays) as? Bool ?? true } // MARK: - NIP-78 cross-device sync @@ -324,7 +314,7 @@ final class AppSettings { /// when a restore is in progress, or when no signing keypair is /// available. func scheduleSettingsSync() { - guard syncSettingsToRelays, !isApplyingRestoredPayload else { return } + guard !isApplyingRestoredPayload else { return } settingsSyncTask?.cancel() settingsSyncTask = Task { @MainActor [weak self] in try? await Task.sleep(nanoseconds: Self.settingsSyncDebounceSeconds * 1_000_000_000) @@ -334,10 +324,9 @@ final class AppSettings { } /// Immediate (non-debounced) publish. Bypasses the debounce timer — - /// the InterfaceSettings "Sync now" affordance uses this, and so does - /// any test path. Still gated on the `syncSettingsToRelays` toggle. + /// used by any path that needs an immediate snapshot push (tests, + /// future "sync now" affordances). func publishSettingsNow() async { - guard syncSettingsToRelays else { return } guard let keypair = NostrKey.load(), !keypair.isWatchOnly else { return } do { @@ -396,11 +385,6 @@ final class AppSettings { return } - guard syncSettingsToRelays else { - 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 @@ -427,7 +411,6 @@ final class AppSettings { /// The user can still trigger a manual re-restore from the /// Interface settings screen. func restoreOnLaunchIfNeeded() async { - guard syncSettingsToRelays else { return } guard let keypair = NostrKey.load(), !keypair.isWatchOnly else { return } // Same reasoning as the defer in handleActiveAccountChange — // an applyRestored mid-onboarding would write to AppSettings @@ -446,7 +429,6 @@ final class AppSettings { /// apply it. Safe to call at every app launch — applyRestored is /// idempotent and the suppression flag prevents a re-publish. func restoreFromRelays() async { - guard syncSettingsToRelays else { return } guard let keypair = NostrKey.load(), !keypair.isWatchOnly else { return } let relays = Self.syncRelays(for: keypair.pubkey) diff --git a/InterfaceSettingsView.swift b/InterfaceSettingsView.swift index 9d464e4..725c6f8 100644 --- a/InterfaceSettingsView.swift +++ b/InterfaceSettingsView.swift @@ -250,32 +250,34 @@ struct InterfaceSettingsView: View { } section(title: "Cross-device sync") { - Toggle("Sync settings across devices", isOn: $settings.syncSettingsToRelays) - .toggleStyle(SwitchToggleStyle(tint: theme.primary)) - Text("Publishes an encrypted NIP-78 snapshot of your appearance, media, posting, and currency preferences to relays so the same setup follows your account on a new device.") + 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) - - if settings.syncSettingsToRelays { - Divider().padding(.vertical, 4) - HStack(spacing: 12) { - VStack(alignment: .leading, spacing: 2) { - Text("Restore from relays") - .foregroundStyle(theme.palette.onSurface) - Text(restoreStatusText) - .font(.system(size: 12)) - .foregroundStyle(theme.palette.onSurfaceVariant) - } - Spacer() + Button { + performManualRestore() + } label: { + HStack(spacing: 8) { if restoreState == .restoring { - ProgressView().controlSize(.small) + ProgressView() + .controlSize(.small) + .tint(.white) + Text("Restoring…") } else { - Button("Restore") { performManualRestore() } - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(theme.primary) + 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)