From 22d9c26ebc063e037106ca498d184a5e0b0835e0 Mon Sep 17 00:00:00 2001 From: The Daniel Date: Thu, 14 May 2026 23:17:29 -0400 Subject: [PATCH 01/13] feat(settings): NIP-78 cross-device sync of UI prefs 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 preferences and quick-reaction state so the same setup follows the account across devices and across the iOS + Android clients. - Nip78Backup.AppSettingsPayload: versioned, all-optional JSON schema. Forward-compatible — fields landing in subsequent PRs (default reaction, quick-zap amount/toggle) are declared in the struct now so later releases only add data, not schema. - AppSettings.syncSettingsToRelays toggle (default on), applyRestored, snapshotForBackup. PR 1 carries zapIconStyle, fiatModeEnabled, fiatCurrency, zapPresetsCSV, plus quickReactions and frequency from EmojiRepository. - EmojiRepository orchestrates publish/restore: refresh() fetches the encrypted blob, decrypts, and merges (frequency max(local, remote); quick-list replaced if local is unmodified defaults, otherwise unioned). scheduleSettingsSync() debounces relay writes by 4 s to coalesce a burst of mutations into one publish. Hooks fire from existing mutators and the zapIconStyle didSet — plus from ZapSheet's preset save paths. - InterfaceSettingsView gets a 'Cross-device sync' section. --- AppSettings.swift | 144 ++++++++++++++++++++++++++++++++---- EmojiRepository.swift | 127 ++++++++++++++++++++++++++++++- InterfaceSettingsView.swift | 29 ++++++++ Nip78Backup.swift | 106 ++++++++++++++++++++++++++ ZapSheet.swift | 4 + 5 files changed, 394 insertions(+), 16 deletions(-) diff --git a/AppSettings.swift b/AppSettings.swift index 540a26d..012ea12 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_settings_to_relays" } /// Allowed durations for the post-undo countdown. Picker shows these as @@ -51,37 +52,70 @@ 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) + EmojiRepository.shared.scheduleSettingsSync() + } } var themeName: String { - didSet { UserDefaults.standard.set(themeName, forKey: Keys.themeName) } + didSet { + UserDefaults.standard.set(themeName, forKey: Keys.themeName) + EmojiRepository.shared.scheduleSettingsSync() + } } var colorScheme: ColorSchemePreference { - didSet { UserDefaults.standard.set(colorScheme.rawValue, forKey: Keys.colorScheme) } + didSet { + UserDefaults.standard.set(colorScheme.rawValue, forKey: Keys.colorScheme) + EmojiRepository.shared.scheduleSettingsSync() + } } var accentColorARGB: Int { - didSet { UserDefaults.standard.set(accentColorARGB, forKey: Keys.accentColorARGB) } + didSet { + UserDefaults.standard.set(accentColorARGB, forKey: Keys.accentColorARGB) + EmojiRepository.shared.scheduleSettingsSync() + } } var autoLoadMedia: Bool { - didSet { UserDefaults.standard.set(autoLoadMedia, forKey: Keys.autoLoadMedia) } + didSet { + UserDefaults.standard.set(autoLoadMedia, forKey: Keys.autoLoadMedia) + EmojiRepository.shared.scheduleSettingsSync() + } } var videoAutoplay: Bool { - didSet { UserDefaults.standard.set(videoAutoplay, forKey: Keys.videoAutoplay) } + didSet { + UserDefaults.standard.set(videoAutoplay, forKey: Keys.videoAutoplay) + EmojiRepository.shared.scheduleSettingsSync() + } } var animateAvatars: Bool { - didSet { UserDefaults.standard.set(animateAvatars, forKey: Keys.animateAvatars) } + didSet { + UserDefaults.standard.set(animateAvatars, forKey: Keys.animateAvatars) + EmojiRepository.shared.scheduleSettingsSync() + } } var mediaLayoutStyle: MediaLayoutStyle { - didSet { UserDefaults.standard.set(mediaLayoutStyle.rawValue, forKey: Keys.mediaLayoutStyle) } + didSet { + UserDefaults.standard.set(mediaLayoutStyle.rawValue, forKey: Keys.mediaLayoutStyle) + EmojiRepository.shared.scheduleSettingsSync() + } } var clientTagEnabled: Bool { - didSet { UserDefaults.standard.set(clientTagEnabled, forKey: Keys.clientTagEnabled) } + didSet { + UserDefaults.standard.set(clientTagEnabled, forKey: Keys.clientTagEnabled) + EmojiRepository.shared.scheduleSettingsSync() + } } var fiatModeEnabled: Bool { - didSet { UserDefaults.standard.set(fiatModeEnabled, forKey: Keys.fiatModeEnabled) } + didSet { + UserDefaults.standard.set(fiatModeEnabled, forKey: Keys.fiatModeEnabled) + EmojiRepository.shared.scheduleSettingsSync() + } } var fiatCurrency: String { - didSet { UserDefaults.standard.set(fiatCurrency, forKey: Keys.fiatCurrency) } + didSet { + UserDefaults.standard.set(fiatCurrency, forKey: Keys.fiatCurrency) + EmojiRepository.shared.scheduleSettingsSync() + } } var notificationSoundsEnabled: Bool { didSet { UserDefaults.standard.set(notificationSoundsEnabled, forKey: Keys.notificationSoundsEnabled) } @@ -90,15 +124,24 @@ 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) + EmojiRepository.shared.scheduleSettingsSync() + } } var postUndoTimerSeconds: Int { - didSet { UserDefaults.standard.set(postUndoTimerSeconds, forKey: Keys.postUndoTimerSeconds) } + didSet { + UserDefaults.standard.set(postUndoTimerSeconds, forKey: Keys.postUndoTimerSeconds) + EmojiRepository.shared.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) + EmojiRepository.shared.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. @@ -106,7 +149,16 @@ final class AppSettings { 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) + EmojiRepository.shared.scheduleSettingsSync() + } + } + /// When true (default), AppSettings + EmojiRepository publish a NIP-78 + /// kind-30078 backup of the user's preferences and quick-reactions so the + /// same setup follows the account across devices/clients. + var syncSettingsToRelays: Bool { + didSet { UserDefaults.standard.set(syncSettingsToRelays, forKey: Keys.syncSettingsToRelays) } } // TODO: Persist to NIP78 (kind 30078) when that feature is available. var videoLoop: Bool { @@ -137,6 +189,68 @@ 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 + } + + /// Apply settings restored from a NIP-78 backup. Only non-default keys + /// present in the payload are overwritten; the rest stay as configured + /// locally. Called from `EmojiRepository.refresh` after a successful + /// remote fetch — see `Nip78Backup.AppSettingsPayload`. + func applyRestored(payload: Nip78Backup.AppSettingsPayload) { + // Currency / zap + if let s = payload.zapIconStyle, let style = ZapIconStyle(rawValue: s) { + zapIconStyle = style + } + if let m = payload.fiatModeEnabled { fiatModeEnabled = m } + if let c = payload.fiatCurrency, !c.isEmpty { fiatCurrency = c } + if let raw = payload.zapPresetsCSV, !raw.isEmpty { + UserDefaults.standard.set(raw, forKey: "zapPresetAmounts") + } + // Appearance + if let b = payload.largeText { largeText = b } + if let n = payload.themeName, !n.isEmpty { themeName = n } + if let s = payload.colorScheme, let pref = ColorSchemePreference(rawValue: s) { + colorScheme = pref + } + if let a = payload.accentColorARGB { accentColorARGB = a } + // Media + if let b = payload.autoLoadMedia { autoLoadMedia = b } + if let b = payload.videoAutoplay { videoAutoplay = b } + if let b = payload.animateAvatars { animateAvatars = b } + if let s = payload.mediaLayoutStyle, let style = MediaLayoutStyle(rawValue: s) { + mediaLayoutStyle = style + } + // Posting + if let b = payload.clientTagEnabled { clientTagEnabled = b } + if let b = payload.postUndoTimerEnabled { postUndoTimerEnabled = b } + if let n = payload.postUndoTimerSeconds, Self.postUndoTimerOptions.contains(n) { + postUndoTimerSeconds = n + } + if let b = payload.postUndoTimerForReplies { postUndoTimerForReplies = b } + } + + /// Build the payload that gets NIP-44 encrypted and published as kind-30078. + /// Mirrors `applyRestored` — every field the backup carries. + func snapshotForBackup() -> Nip78Backup.AppSettingsPayload { + let presetsRaw = UserDefaults.standard.string(forKey: "zapPresetAmounts") + return Nip78Backup.AppSettingsPayload( + zapIconStyle: zapIconStyle.rawValue, + fiatModeEnabled: fiatModeEnabled, + fiatCurrency: fiatCurrency, + zapPresetsCSV: presetsRaw, + largeText: largeText, + themeName: themeName, + colorScheme: colorScheme.rawValue, + accentColorARGB: accentColorARGB, + autoLoadMedia: autoLoadMedia, + videoAutoplay: videoAutoplay, + animateAvatars: animateAvatars, + mediaLayoutStyle: mediaLayoutStyle.rawValue, + clientTagEnabled: clientTagEnabled, + postUndoTimerEnabled: postUndoTimerEnabled, + postUndoTimerSeconds: postUndoTimerSeconds, + postUndoTimerForReplies: postUndoTimerForReplies + ) } /// SF Symbol name for the zap icon. Only valid when `fiatModeEnabled` is false. diff --git a/EmojiRepository.swift b/EmojiRepository.swift index ba30e11..56df32a 100644 --- a/EmojiRepository.swift +++ b/EmojiRepository.swift @@ -73,6 +73,18 @@ final class EmojiRepository { /// without re-renders looping on equality of the dict itself. private(set) var generation: Int = 0 + /// Bumped on every successful publish so we can rate-limit relay writes — + /// see `scheduleSettingsSync`. + @ObservationIgnored private var pendingSettingsPublish: Task? = nil + + /// Last successful kind-30078 publish timestamp. Observed by the Settings + /// UI to render a passive "Last synced Xm ago" indicator. + private(set) var lastSettingsSyncAt: Date? = nil + + /// True while a settings publish is debounced or in flight. The UI uses + /// this to swap the indicator to "Sync pending\u{2026}". + private(set) var isSettingsSyncPending: Bool = false + // MARK: - Internal private var loadedForPubkey: String? @@ -108,11 +120,16 @@ final class EmojiRepository { /// from UserDefaults, seeds in-memory state from the ObjectBox cache, then fetches /// kind-10030 + referenced kind-30030 events from relays in the background. /// Cheap to call repeatedly — subsequent calls for the same pubkey are no-ops. + /// Also kicks off a kind-30078 app-settings restore (NIP-78) so quick-reactions + /// and zap presets follow the account across devices. func refresh(for pubkey: String) async { if loadedForPubkey == pubkey { return } loadedForPubkey = pubkey loadPersisted(pubkey: pubkey) + if let keypair = NostrKey.load(), keypair.pubkey == pubkey { + await restoreSettingsBackup(for: keypair) + } // Seed from ObjectBox first so the UI sees a populated `resolvedCustomMap` // immediately on cold start, before any relay round-trip. The network @@ -179,6 +196,8 @@ final class EmojiRepository { resolvedCustomMap = [:] userListCreatedAt = 0 loadedForPubkey = nil + pendingSettingsPublish?.cancel() + pendingSettingsPublish = nil } // MARK: - Quick-reactions mutators @@ -187,23 +206,29 @@ final class EmojiRepository { guard !key.isEmpty, !quickReactions.contains(key) else { return } quickReactions.append(key) persist() + scheduleSettingsSync() } func removeFromQuickList(_ key: String) { let before = quickReactions.count quickReactions.removeAll { $0 == key } - if quickReactions.count != before { persist() } + if quickReactions.count != before { + persist() + scheduleSettingsSync() + } } func setQuickList(_ keys: [String]) { quickReactions = keys persist() + scheduleSettingsSync() } /// Bump the usage counter for an emoji key (unicode char or `:shortcode:`). Persisted. func recordUse(_ key: String) { frequency[key, default: 0] += 1 persist() + scheduleSettingsSync() } // MARK: - Direct-emoji mutators (publish kind 10030) @@ -481,4 +506,104 @@ final class EmojiRepository { } return ["wss://relay.damus.io", "wss://relay.primal.net", "wss://nos.lol"] } + + // MARK: - NIP-78 app-settings sync + + /// Fetch the encrypted kind-30078 app-settings blob from the account's + /// write relays, decrypt it, and merge in any field that's newer than + /// what we have locally. Called from `refresh(for:)` after the emoji + /// state seeds. + func restoreSettingsBackup(for keypair: Keypair) async { + guard AppSettings.shared.syncSettingsToRelays else { return } + let relays = topWriteRelays(for: keypair.pubkey) + guard !relays.isEmpty else { return } + let events = await RelayPool.query( + relays: relays, + filter: Nip78Backup.appSettingsFilter(pubkey: keypair.pubkey), + timeout: 6 + ) + guard let latest = events.max(by: { $0.createdAt < $1.createdAt }) else { return } + guard let payload = await Nip78Backup.decryptAppSettings(keypair: keypair, event: latest) else { return } + applyRestoredPayload(payload, for: keypair.pubkey) + } + + private func applyRestoredPayload(_ payload: Nip78Backup.AppSettingsPayload, for pubkey: String) { + AppSettings.shared.applyRestored(payload: payload) + + if let remoteFreq = payload.frequency { + // Merge keeps the higher of (local, remote) per key so neither + // device wipes the other's progress on first sync. + for (k, v) in remoteFreq { + if v > (frequency[k] ?? 0) { frequency[k] = v } + } + } + if let remoteList = payload.quickReactions, !remoteList.isEmpty { + // Replace the quick list if the remote one differs from local + // (likely from a fresh install — the local list will be the + // hardcoded defaults at this point). + let localIsDefault = Set(quickReactions) == Set(EmojiData.defaultQuickReactions) + if localIsDefault || quickReactions.isEmpty { + quickReactions = remoteList + } else { + // Already-curated local list: union, preserving local order first. + var merged = quickReactions + for entry in remoteList where !merged.contains(entry) { + merged.append(entry) + } + quickReactions = merged + } + } + persist() + } + + /// Coalesce multiple `recordUse` / mutator calls into a single relay + /// publish. Without this, a user spamming reactions would fan out a + /// publish per tap. The 4 s window matches the publish timeout — long + /// enough to capture a burst, short enough to feel "live." Also called + /// from `AppSettings` property `didSet`s so changes to synced settings + /// round-trip through the same backup. + func scheduleSettingsSync() { + guard AppSettings.shared.syncSettingsToRelays else { return } + pendingSettingsPublish?.cancel() + isSettingsSyncPending = true + pendingSettingsPublish = Task { [weak self] in + try? await Task.sleep(for: .seconds(4)) + guard let self, !Task.isCancelled else { return } + await self.publishSettingsBackup() + } + } + + /// Build and publish the kind-30078 app-settings backup for the active + /// keypair. Quietly no-ops if there's no key, no relays, or the publish + /// fails — this is best-effort cross-device sync, not durable storage. + func publishSettingsBackup() async { + guard let keypair = NostrKey.load() else { return } + guard keypair.pubkey == loadedForPubkey else { return } + guard AppSettings.shared.syncSettingsToRelays else { + isSettingsSyncPending = false + return + } + var payload = AppSettings.shared.snapshotForBackup() + payload.quickReactions = quickReactions + payload.frequency = frequency + do { + let event = try await Nip78Backup.createAppSettingsEvent(keypair: keypair, payload: payload) + let relays = topWriteRelays(for: keypair.pubkey) + guard !relays.isEmpty else { + isSettingsSyncPending = false + return + } + let accepting = await RelayPool.publish(event: event, to: relays, timeout: 8) + // Stamp the success time when at least one relay accepted (the + // returned list is the urls that OK'd). An empty result means the + // publish quietly dropped; leave the timestamp untouched so the + // UI keeps showing the last-known-good time. + if !accepting.isEmpty { lastSettingsSyncAt = Date() } + isSettingsSyncPending = false + } catch { + // Best-effort: silent on encrypt/sign failure. The next mutator + // call will reschedule a fresh publish attempt. + isSettingsSyncPending = false + } + } } diff --git a/InterfaceSettingsView.swift b/InterfaceSettingsView.swift index c09ba80..ef342e5 100644 --- a/InterfaceSettingsView.swift +++ b/InterfaceSettingsView.swift @@ -9,6 +9,7 @@ struct InterfaceSettingsView: View { @State private var showCurrencyPicker = false @State private var rateUpdatedAt: Date? = nil @State private var themesExpanded = false + @State private var emojiRepo = EmojiRepository.shared var body: some View { @Bindable var settings = settings @@ -236,6 +237,20 @@ struct InterfaceSettingsView: View { } } + section(title: "Cross-device sync") { + Toggle("Sync settings via relays", isOn: $settings.syncSettingsToRelays) + .toggleStyle(SwitchToggleStyle(tint: theme.primary)) + Text("Publishes an encrypted NIP-78 backup of your \(settings.fiatModeEnabled ? "payment" : "zap") settings and quick reactions so they follow your account to other devices.") + .font(.system(size: 12)) + .foregroundStyle(theme.palette.onSurfaceVariant) + if settings.syncSettingsToRelays, let status = syncStatusLine { + Text(status) + .font(.system(size: 11)) + .foregroundStyle(theme.palette.onSurfaceVariant) + .padding(.top, 2) + } + } + Spacer(minLength: 40) } .padding(20) @@ -284,6 +299,20 @@ struct InterfaceSettingsView: View { Themes.all.first(where: { $0.id == settings.themeName })?.displayName ?? "Custom" } + /// Passive indicator for the NIP-78 publish lifecycle. Returns nil when + /// there's neither a pending publish nor a prior success, so the row + /// stays clean on first launch before the user has changed anything. + private var syncStatusLine: String? { + if emojiRepo.isSettingsSyncPending { return "Sync pending\u{2026}" } + guard let at = emojiRepo.lastSettingsSyncAt else { return nil } + let seconds = Int(Date().timeIntervalSince(at)) + if seconds < 5 { return "Last synced just now" } + if seconds < 60 { return "Last synced \(seconds)s ago" } + if seconds < 3600 { return "Last synced \(seconds / 60)m ago" } + if seconds < 86_400 { return "Last synced \(seconds / 3600)h ago" } + return "Last synced \(seconds / 86_400)d ago" + } + @ViewBuilder private func themeCard(_ preset: ThemePreset) -> some View { let palette = theme.isDark ? preset.dark : preset.light diff --git a/Nip78Backup.swift b/Nip78Backup.swift index d941bf4..5ec599a 100644 --- a/Nip78Backup.swift +++ b/Nip78Backup.swift @@ -132,6 +132,112 @@ nonisolated enum Nip78Backup { } } +// MARK: - App settings backup (kind 30078, d-tag `wisp-app-settings:v1`) + +extension Nip78Backup { + /// d-tag for the encrypted app-settings payload. Distinct from + /// `spark-wallet-backup:...` so the two coexist on the same account. + static let appSettingsDTag = "wisp-app-settings:v1" + + /// Versioned JSON blob encrypted to self under NIP-44 and stored as + /// kind-30078 `content`. Every field is optional so older / newer clients + /// can round-trip a payload without dropping unknown fields they wrote. + struct AppSettingsPayload: Codable, Equatable { + var defaultReaction: String? + var defaultReactionEnabled: Bool? + var quickZapEnabled: Bool? + var quickZapAmountSats: Int64? + var quickZapAmountFiat: Double? + var quickZapMessage: String? + var zapIconStyle: String? + var fiatModeEnabled: Bool? + var fiatCurrency: String? + var zapPresetsCSV: String? + var quickReactions: [String]? + var frequency: [String: Int]? + // Appearance + var largeText: Bool? + var themeName: String? + var colorScheme: 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? + var version: Int? = 1 + } + + /// Build the kind-30078 app-settings event. NIP-44 encrypts the JSON + /// payload to the user's own pubkey so only they can decrypt it on a fresh + /// install. Routes through the `Signer` facade so remote (NIP-46) + /// accounts dispatch the encrypt + sign over RPC. + @MainActor + static func createAppSettingsEvent( + keypair: Keypair, + payload: AppSettingsPayload + ) async throws -> NostrEvent { + let data = try JSONEncoder().encode(payload) + guard let json = String(data: data, encoding: .utf8) else { + throw NSError(domain: "Nip78Backup", code: 1, userInfo: [NSLocalizedDescriptionKey: "encode failed"]) + } + let encrypted = try await Signer.nip44Encrypt( + keypair: keypair, + peerPubkey: keypair.pubkey, + plaintext: json + ) + var tags: [[String]] = [ + ["d", appSettingsDTag], + ["encryption", "nip44"] + ] + if let clientTag = NostrEvent.clientTagIfEnabled() { + tags.append(clientTag) + } + return try await Signer.sign( + keypair: keypair, + kind: kind, + tags: tags, + content: encrypted + ) + } + + /// Decrypt a kind-30078 app-settings event and return the parsed payload, + /// or nil if the content is empty (tombstoned) or doesn't parse as JSON. + @MainActor + static func decryptAppSettings( + keypair: Keypair, + event: NostrEvent + ) async -> AppSettingsPayload? { + if event.content.isEmpty { return nil } + guard let decrypted = try? await Signer.nip44Decrypt( + keypair: keypair, + peerPubkey: event.pubkey, + payload: event.content + ) else { + backupLog.warning("app-settings decrypt failed for event \(event.id, privacy: .public)") + return nil + } + guard let data = decrypted.data(using: .utf8), + let payload = try? JSONDecoder().decode(AppSettingsPayload.self, from: data) + else { + backupLog.warning("app-settings parse failed for event \(event.id, privacy: .public)") + return nil + } + return payload + } + + /// Filter for fetching the user's app-settings backup. `dTags` narrows + /// the relay reply to just the one addressable event. + static func appSettingsFilter(pubkey: String) -> NostrFilter { + NostrFilter(kinds: [kind], authors: [pubkey], dTags: [appSettingsDTag], limit: 1) + } +} + /// Result of searching relays for spark-wallet backups. enum BackupSearchResult { case notFound diff --git a/ZapSheet.swift b/ZapSheet.swift index 4a0776b..8322f82 100644 --- a/ZapSheet.swift +++ b/ZapSheet.swift @@ -316,6 +316,7 @@ struct ZapSheet: View { .sorted() .map { String($0) } .joined(separator: ",") + EmojiRepository.shared.scheduleSettingsSync() } label: { Label("Save as Preset", systemImage: "star") .font(.subheadline.weight(.medium)) @@ -473,6 +474,9 @@ private struct EditPresetsSheet: View { let valid = drafts.compactMap { Int64($0.trimmingCharacters(in: .whitespaces)) }.filter { $0 > 0 } if !valid.isEmpty { presetsRaw = valid.map { String($0) }.joined(separator: ",") + // Round-trip the new presets through the NIP-78 + // app-settings backup so other devices pick them up. + EmojiRepository.shared.scheduleSettingsSync() } dismiss() } From 4ccf88250d695108b893222cb687078a00967fe4 Mon Sep 17 00:00:00 2001 From: The Daniel Date: Thu, 14 May 2026 23:20:01 -0400 Subject: [PATCH 02/13] feat(zap): Instant zaps + fiat counterpart 'Instant payments' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds AppSettings.quickZapEnabled (default off), plus two independently persisted amounts: quickZapAmountSats (default 21) and quickZapAmountFiat (default 1.00 in fiatCurrency major units). The settings UI surfaces a single section whose label flips with fiat mode — 'Zaps' / 'Instant zaps' in bitcoin mode, 'Payments' / 'Instant payments' in fiat mode — and the amount input switches between sats integer and fiat decimal accordingly. PostCardView's zap button: tap converts the configured amount to sats via ExchangeRateCache.fiatToSats when in fiat mode, then routes through ZapAnimationStore for the in-flight pulse + burst animation. Falls back to the composer sheet when no wallet is set up, the rate cache hasn't loaded (rare), or quick-zap is disabled. Long-press always opens the composer — escape hatch for a different amount, message, or anonymous mode. All three fields (toggle, sats amount, fiat amount) round-trip through the NIP-78 settings backup added in the previous commit so the configuration follows the account across devices, including across the iOS / Android divide once the Android counterpart lands. --- AppSettings.swift | 54 +++++++++++++++++++++++ InterfaceSettingsView.swift | 73 +++++++++++++++++++++++++++++++ PostCardView.swift | 85 ++++++++++++++++++++++++++++++++++++- 3 files changed, 211 insertions(+), 1 deletion(-) diff --git a/AppSettings.swift b/AppSettings.swift index 012ea12..d3cd990 100644 --- a/AppSettings.swift +++ b/AppSettings.swift @@ -43,6 +43,10 @@ final class AppSettings { static let zapIconStyle = "wisp_settings_zap_icon_style" static let videoLoop = "wisp_settings_video_loop" static let syncSettingsToRelays = "wisp_settings_sync_settings_to_relays" + static let quickZapEnabled = "wisp_settings_quick_zap_enabled" + static let quickZapAmountSats = "wisp_settings_quick_zap_amount_sats" + static let quickZapAmountFiat = "wisp_settings_quick_zap_amount_fiat" + static let quickZapMessage = "wisp_settings_quick_zap_message" } /// Allowed durations for the post-undo countdown. Picker shows these as @@ -164,6 +168,42 @@ final class AppSettings { var videoLoop: Bool { didSet { UserDefaults.standard.set(videoLoop, forKey: Keys.videoLoop) } } + /// When true, a single tap of the zap button on a post sends the configured + /// amount immediately. Long-press still opens the zap composer. Surfaces in + /// settings as "Instant zaps" while in bitcoin mode and "Instant payments" + /// while in fiat mode. Disabled by default — the previous behaviour + /// (tap → composer) is preserved unless the user opts in. + var quickZapEnabled: Bool { + didSet { + UserDefaults.standard.set(quickZapEnabled, forKey: Keys.quickZapEnabled) + EmojiRepository.shared.scheduleSettingsSync() + } + } + /// Instant-zap amount in sats, used when `fiatModeEnabled` is false. + var quickZapAmountSats: Int64 { + didSet { + UserDefaults.standard.set(quickZapAmountSats, forKey: Keys.quickZapAmountSats) + EmojiRepository.shared.scheduleSettingsSync() + } + } + /// Instant-payment amount in `fiatCurrency` major units (e.g. 1.00 USD), + /// used when `fiatModeEnabled` is true. Converted to sats at fire time via + /// `ExchangeRateCache.fiatToSats`. + var quickZapAmountFiat: Double { + didSet { + UserDefaults.standard.set(quickZapAmountFiat, forKey: Keys.quickZapAmountFiat) + EmojiRepository.shared.scheduleSettingsSync() + } + } + /// Optional default message included on an instant zap / payment. Empty + /// string means "no message" — the zap fires with `content: ""` exactly + /// as the composer's blank state would produce. Persisted + synced. + var quickZapMessage: String { + didSet { + UserDefaults.standard.set(quickZapMessage, forKey: Keys.quickZapMessage) + EmojiRepository.shared.scheduleSettingsSync() + } + } private init() { let defaults = UserDefaults.standard @@ -190,6 +230,12 @@ final class AppSettings { 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 + self.quickZapEnabled = defaults.object(forKey: Keys.quickZapEnabled) as? Bool ?? false + let storedQuickInt = defaults.integer(forKey: Keys.quickZapAmountSats) + self.quickZapAmountSats = storedQuickInt > 0 ? Int64(storedQuickInt) : 100 + let storedQuickFiat = defaults.double(forKey: Keys.quickZapAmountFiat) + self.quickZapAmountFiat = storedQuickFiat > 0 ? storedQuickFiat : 0.10 + self.quickZapMessage = defaults.string(forKey: Keys.quickZapMessage) ?? "" } /// Apply settings restored from a NIP-78 backup. Only non-default keys @@ -198,6 +244,10 @@ final class AppSettings { /// remote fetch — see `Nip78Backup.AppSettingsPayload`. func applyRestored(payload: Nip78Backup.AppSettingsPayload) { // Currency / zap + if let q = payload.quickZapEnabled { quickZapEnabled = q } + if let a = payload.quickZapAmountSats, a > 0 { quickZapAmountSats = a } + if let f = payload.quickZapAmountFiat, f > 0 { quickZapAmountFiat = f } + if let m = payload.quickZapMessage { quickZapMessage = m } if let s = payload.zapIconStyle, let style = ZapIconStyle(rawValue: s) { zapIconStyle = style } @@ -234,6 +284,10 @@ final class AppSettings { func snapshotForBackup() -> Nip78Backup.AppSettingsPayload { let presetsRaw = UserDefaults.standard.string(forKey: "zapPresetAmounts") return Nip78Backup.AppSettingsPayload( + quickZapEnabled: quickZapEnabled, + quickZapAmountSats: quickZapAmountSats, + quickZapAmountFiat: quickZapAmountFiat, + quickZapMessage: quickZapMessage, zapIconStyle: zapIconStyle.rawValue, fiatModeEnabled: fiatModeEnabled, fiatCurrency: fiatCurrency, diff --git a/InterfaceSettingsView.swift b/InterfaceSettingsView.swift index ef342e5..d211778 100644 --- a/InterfaceSettingsView.swift +++ b/InterfaceSettingsView.swift @@ -162,6 +162,79 @@ struct InterfaceSettingsView: View { } } + // Label and amount input flip with fiat mode so the user + // always configures the value in the denomination they + // think in. Both values persist independently — flipping + // fiat mode preserves the user's bitcoin amount and vice + // versa. + section(title: settings.fiatModeEnabled ? "Payments" : "Zaps") { + Toggle(settings.fiatModeEnabled ? "Instant payments" : "Instant zaps", + isOn: $settings.quickZapEnabled) + .toggleStyle(SwitchToggleStyle(tint: theme.primary)) + Text(settings.fiatModeEnabled + ? "When on, tapping the pay button on a post immediately sends your chosen amount. Long-press to open the payment composer." + : "When on, tapping the zap button on a post immediately sends your chosen amount. Long-press to open the zap composer.") + .font(.system(size: 12)) + .foregroundStyle(theme.palette.onSurfaceVariant) + .padding(.bottom, 4) + + if settings.quickZapEnabled { + if settings.fiatModeEnabled { + HStack { + Text("Amount (\(settings.fiatCurrency))") + .foregroundStyle(theme.palette.onSurface) + Spacer() + TextField( + "0.10", + value: Binding( + get: { settings.quickZapAmountFiat }, + set: { settings.quickZapAmountFiat = max(0.01, $0) } + ), + format: .number.precision(.fractionLength(2)) + ) + .keyboardType(.decimalPad) + .multilineTextAlignment(.trailing) + .frame(width: 120) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(theme.palette.surfaceVariant, in: RoundedRectangle(cornerRadius: 8)) + } + } else { + HStack { + Text("Amount (sats)") + .foregroundStyle(theme.palette.onSurface) + Spacer() + TextField( + "100", + value: Binding( + get: { settings.quickZapAmountSats }, + set: { settings.quickZapAmountSats = max(1, $0) } + ), + format: .number + ) + .keyboardType(.numberPad) + .multilineTextAlignment(.trailing) + .frame(width: 120) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(theme.palette.surfaceVariant, in: RoundedRectangle(cornerRadius: 8)) + } + } + + VStack(alignment: .leading, spacing: 6) { + Text("Message (optional)") + .foregroundStyle(theme.palette.onSurface) + TextField("Add a default note…", text: $settings.quickZapMessage, axis: .vertical) + .textFieldStyle(.plain) + .lineLimit(1...3) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background(theme.palette.surfaceVariant, in: RoundedRectangle(cornerRadius: 8)) + } + .padding(.top, 4) + } + } + section(title: "Currency") { Toggle("Fiat mode", isOn: $settings.fiatModeEnabled) .toggleStyle(SwitchToggleStyle(tint: theme.primary)) diff --git a/PostCardView.swift b/PostCardView.swift index 0979567..8dc82ba 100644 --- a/PostCardView.swift +++ b/PostCardView.swift @@ -70,6 +70,11 @@ struct PostCardView: View { /// short note pinned near a tab bar) ended up clipping the picker /// because the popover gave it less space than the picker's natural size. @State private var reactionPickerMaxHeight: CGFloat = 192 + /// Suppresses the zap button's Button tap action on the release after a + /// long-press completed. SwiftUI's simultaneous gestures both fire by + /// default; without this flag a long-press would open the composer AND + /// fire the configured quick-zap amount. + @State private var zapLongPressFired = false @State private var showDeleteConfirm = false @State private var showMuteUserConfirm = false /// Tap-anchored menus on the action bar. We use `Button` + `.popover` @@ -811,7 +816,24 @@ struct PostCardView: View { let isFlying = zapStore.inFlight.contains(eventId) let isBursting = zapStore.bursting.contains(eventId) return Button { - triggerZapOrWalletSetup() + // Tap: fire the configured instant-zap amount when the user has + // opted in AND a wallet is set up. In fiat mode the configured + // amount is denominated in `fiatCurrency` major units and gets + // converted to sats at fire time via the exchange-rate cache. + // Falls back to the composer sheet when the rate isn't available + // (rare; the cache pre-fetches at app launch) or when quick-zap + // is off / no wallet. Long-press always opens the sheet. + if zapLongPressFired { + zapLongPressFired = false + return + } + if settings.quickZapEnabled, + let store = walletStore, store.mode != nil, + let amount = resolvedInstantZapSats() { + fireQuickZap(amountSats: amount) + } else { + triggerZapOrWalletSetup() + } } label: { ZStack { if isFlying { @@ -830,6 +852,15 @@ struct PostCardView: View { } .buttonStyle(.plain) .disabled(isFlying) + .simultaneousGesture( + // Long-press always opens the composer — escape hatch from + // one-tap mode (different amount, different message, anonymous). + LongPressGesture(minimumDuration: 0.4).onEnded { _ in + zapLongPressFired = true + Haptics.shared.blip() + triggerZapOrWalletSetup() + } + ) .overlay(alignment: .center) { ZapBurstView(isActive: isBursting) .frame(width: 160, height: 160) @@ -1145,6 +1176,58 @@ struct PostCardView: View { } } + /// Resolve the instant-zap amount in sats, taking fiat mode into account. + /// In bitcoin mode this is just `quickZapAmountSats`. In fiat mode the + /// configured `quickZapAmountFiat` major-unit value is converted via the + /// current exchange rate; returns nil when the rate cache hasn't loaded + /// yet, which falls the caller back to the composer sheet. + private func resolvedInstantZapSats() -> Int64? { + if settings.fiatModeEnabled { + guard settings.quickZapAmountFiat > 0 else { return nil } + guard let sats = ExchangeRateCache.shared.fiatToSats( + settings.quickZapAmountFiat, + currency: settings.fiatCurrency + ), sats > 0 else { return nil } + return sats + } + return settings.quickZapAmountSats > 0 ? settings.quickZapAmountSats : nil + } + + /// Fire a one-tap zap of `amountSats` to the displayed post's author. + /// Routed through `ZapAnimationStore` so the in-flight pulse + success + /// burst run on the post card exactly as they do from the full composer. + private func fireQuickZap(amountSats: Int64) { + guard let keypair = NostrKey.load(), let store = walletStore else { return } + let target = resolveRepost().event + let targetProfile = resolveRepost().profile + let pollOptionIdx = zapPollOptionIndex + let extraTags: [[String]] = pollOptionIdx.map { [["poll_option", String($0)]] } ?? [] + ZapAnimationStore.shared.send( + keypair: keypair, + wallet: store, + recipientPubkey: target.pubkey, + recipientLud16: targetProfile?.lud16, + eventId: target.id, + amountSats: amountSats, + message: settings.quickZapMessage.trimmingCharacters(in: .whitespacesAndNewlines), + relayHints: [], + extraTags: extraTags, + isAnonymous: false, + isPrivate: false, + onSuccessSats: { sats in + if target.kind == Nip69.kindZapPoll, let idx = pollOptionIdx { + PollTallyRepository.shared.applyOptimisticZapVote( + pollEvent: target, + optionIndex: idx, + voterPubkey: keypair.pubkey, + sats: sats, + ts: Int(Date().timeIntervalSince1970) + ) + } + } + ) + } + private func sendRepost() { guard let keypair = NostrKey.load() else { return } let target = resolveRepost().event From 57519f53bb73f4bf74f7fb7bb81d91c0c2e4e58e Mon Sep 17 00:00:00 2001 From: The Daniel Date: Wed, 20 May 2026 13:10:19 -0400 Subject: [PATCH 03/13] docs: add ZapSheet redesign spec Captures the planned compact-layout redesign of the zap sheet (recipient row, amount grid, inline message field, dialog presentation) so the upcoming UI work lands on the same branch as one-tap-zap and the redesign decisions stay alongside the implementation. --- ZAP_SHEET_REDESIGN.md | 141 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 ZAP_SHEET_REDESIGN.md diff --git a/ZAP_SHEET_REDESIGN.md b/ZAP_SHEET_REDESIGN.md new file mode 100644 index 0000000..cb96adc --- /dev/null +++ b/ZAP_SHEET_REDESIGN.md @@ -0,0 +1,141 @@ +# ZapSheet Redesign Spec + +## Problem + +The current sheet is a tall scroll view. On a standard iPhone, reaching the message +field or changing zap type requires scrolling — and raising the keyboard pushes +everything above the fold. The hero (icon + "Send Zap" title + giant number) burns +~200 pt before the user can do anything useful. + +## Goal + +Fit the full interaction — recipient, amount selection, message, privacy — in the +visible area above the keyboard, with no scrolling required for the happy path. + +--- + +## Layout (top → bottom) + +### 1. Navigation bar (unchanged) +- Left: `Close` button +- Center: nothing (no title) +- Right: `Edit` (tapping opens `EditPresetsSheet` — same as today) + +### 2. Recipient row (~44 pt) +Compact single-line HStack, no section label: + +``` +[avatar 32pt] corndalorian ··· (overflow icon, tap to copy lud16) + corndalorian@primal.net ← caption, secondary color +``` + +No background card. Light separator line below if desired. Keep it tight. + +### 3. Amount area (~100 pt total) + +**Big tappable number** centered, no icon, no "Send Zap" label: + +``` + 84 + sats ← hidden in fiat mode +``` + +- Font: `system(size: 56, weight: .bold, design: .rounded)`, `wispZapColor` +- Tapping the number focuses the hidden `TextField` (same existing behavior — + `isCustom = true`, seed `customAmountText`, `amountFocused = true`) +- `contentTransition(.numericText)` animation on change (keep existing) + +**Preset pills** — single horizontal `ScrollView(.horizontal, showsIndicators: false)` strip +immediately below the number, instead of the current wrapping `FlowLayout`: + +``` + [ 10 ] [ 21 ] [ 84 ] [ 100 ] [ 500 ] [ 1.0k ] [ 5.0k ] [ Custom ] +``` + +- Selected pill: filled `wispZapColor` capsule, white text (same as today) +- Unselected: `wispSurfaceVariant.opacity(0.5)` capsule +- `Custom` pill stays at the end; when `isCustom && amountSats > 0` it shows the + formatted amount (same as today) +- "Save as Preset" affordance: keep the existing `canSaveAsPreset` logic, but + surface it as a small `+` icon button that appears inside/beside the Custom + pill when applicable — not a separate full-width row + +**Custom amount text field** — always rendered but `opacity(0)` / `frame(height: 0)` +when not `isCustom`. This avoids the layout jump when the field appears. The actual +number pad input goes here (fiat binding and sats binding logic unchanged). + +### 4. Message field (~56 pt) +Always visible single-line `TextField`, no section label: + +``` + [ Message (optional) ] +``` + +- Same `wispSurfaceVariant` rounded-rect background +- `.submitLabel(.done)` to dismiss keyboard + +### 5. Bottom bar (pinned via `safeAreaInset(edge: .bottom)`) + +HStack with two elements side by side: + +``` + [ 👁 ] [ ⚡ Zap 84 sats ──────────────── ] +``` + +Left side — **Privacy chip** (minified): +- Small rounded-rect button showing the current type icon only (`eye` / `eye.slash` / `lock`) +- Tapping cycles `Public → Anonymous → Private → Public` +- Background: `wispSurfaceVariant.opacity(0.4)`, size ~44×44 +- On long-press (or secondary tap): show a small `Menu` with all three options labeled, + so power users can jump directly without cycling + +Right side — **Zap button** (same as today, fills remaining width): +- `⚡ Zap 84 sats` / `Send $0.84` (fiat mode) +- `wispZapColor` fill, white text, `cornerRadius: 14` +- Disabled + dimmed when `!canZap` + +The two elements share the same height (~54 pt) with a small gap (~10 pt) between them. + +--- + +## States & edge cases + +| State | Behavior | +|---|---| +| No `lud16` | Recipient row shows red "No lightning address" text; Zap button disabled | +| Keyboard raised | Preset strip + message field stay above keyboard; no scroll needed | +| `isCustom` | Big number updates live as digits typed; Custom pill goes orange | +| `canSaveAsPreset` | `+` badge appears on Custom pill; tap adds to `presetsRaw` | +| Fiat mode | Big number shows `$0.84`; "sats" label hidden; field uses cents-register binding | + +--- + +## What to remove + +- Lightning bolt icon above the amount (redundant — it's on the Zap button) +- "Send Zap" / "Send Money" title text +- `QUICK AMOUNTS` section label and `Edit` link in header (Edit moves to nav bar) +- `TYPE` section label and full-width `Picker(.segmented)` — replaced by privacy chip +- `RECIPIENT` section label and background card +- `MESSAGE (OPTIONAL)` section label +- "Save as Preset" full-width `HStack` row — replaced by `+` badge on Custom pill + +--- + +## Files to change + +| File | Change | +|---|---| +| `ZapSheet.swift` | Full layout rewrite — body, hero, presets, message, bottom bar. Logic (`send()`, bindings, `heroAmountText`, fiat helpers) **unchanged**. | +| `EditPresetsSheet` | No changes — still presented from nav-bar `Edit` button. | + +--- + +## Implementation notes + +- Keep all existing `@State`, `@AppStorage`, `@FocusState` vars as-is. +- The privacy chip cycling: `zapType = ZapType.allCases[(ZapType.allCases.firstIndex(of: zapType)! + 1) % ZapType.allCases.count]` +- Horizontal preset strip: wrap pills in `ScrollView(.horizontal)` → `HStack(spacing: 8)`. + No `FlowLayout` dependency needed. +- Hidden custom field trick: render outside the visible stack at `frame(width: 0, height: 0).clipped()`, keep `focused($amountFocused)` on it. Tapping the big number still triggers focus via `amountFocused = true`. +- `withAnimation(.easeInOut(duration: 0.15))` on `isCustom` toggle to animate the Custom pill highlight. From 6355f961cec29e350b507f63e954cd0db4769243 Mon Sep 17 00:00:00 2001 From: The Daniel Date: Wed, 20 May 2026 21:26:13 -0400 Subject: [PATCH 04/13] feat(wallet-setup): primary Spark button + More options accordion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wallet mode picker: * Spark renders as a full-bleed orange filled card with a layered zap-color shadow glow, the rest of the row keeps its dark surface treatment. Reads as one clear recommended action while still leaving Nostr Wallet Connect a peer option below it. Spark setup picker: * "Use my default wallet" gets the same primary treatment — filled orange background, white label + key icon, matching zap-color glow — so the recommended path is the same shape on both screens. * Create new wallet / Restore from seed phrase / Restore from relays move under a "More options" disclosure that rotates the chevron and fades the rows in / out so the screen leads with one obvious next step. * GeometryReader-backed ScrollView lets a leading + trailing Spacer push the pick section toward vertical centre when the content fits the viewport. --- SparkSetupView.swift | 124 ++++++++++++++++++++++++++++++++----------- WalletView.swift | 52 +++++++++++++----- 2 files changed, 133 insertions(+), 43 deletions(-) diff --git a/SparkSetupView.swift b/SparkSetupView.swift index 040622f..a7eea58 100644 --- a/SparkSetupView.swift +++ b/SparkSetupView.swift @@ -10,17 +10,26 @@ struct SparkSetupView: View { @State private var restoreEntry: String = "" @State private var restoreError: String? @State private var inFlight = false + @State private var showAdvancedOptions = false enum PickerMode { case pick, create, restoreSeed, restoreRelays } var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 24) { - content + GeometryReader { geo in + ScrollView { + VStack(alignment: .leading, spacing: 24) { + content + } + .padding(.horizontal, 20) + .padding(.top, 8) + .padding(.bottom, 32) + // Stretch the inner VStack to fill the visible scroll area + // so the leading + trailing Spacers in `pickSection` can + // actually push content toward vertical center. Without + // the explicit minHeight the VStack sizes to its content + // and Spacers collapse to zero. + .frame(minHeight: geo.size.height) } - .padding(.horizontal, 20) - .padding(.top, 8) - .padding(.bottom, 32) } .background(Color.wispBackground.ignoresSafeArea()) .navigationTitle(mode == .pick ? "" : subModeTitle) @@ -68,6 +77,7 @@ struct SparkSetupView: View { private var pickSection: some View { VStack(spacing: 24) { + Spacer(minLength: 24) // Logo header VStack(spacing: 12) { Image("SparkBreezLogo") @@ -81,57 +91,109 @@ struct SparkSetupView: View { } .frame(maxWidth: .infinity) - // Option rows + // Option rows — default wallet is the recommended path, so it + // sits alone above the fold. Create / restore live under a + // disclosure so the screen leads with one obvious next step + // but power users can still get to seed and relay-backup flows. VStack(spacing: 12) { if store.canUseDefaultWallet { optionRow( icon: "key.fill", title: "Use my default wallet", subtitle: "Derived from your Nostr key — no extra backup needed.", + primary: true, action: { Task { await useDefault() } } ) + // Soft orange glow marks this as the recommended path, + // matching the primary Spark button on the wallet mode + // picker. Two stacked shadows: tight + wide so the + // halo reads on dark backgrounds without smudging. + .shadow(color: Color.wispZapColor.opacity(0.55), radius: 16, x: 0, y: 0) + .shadow(color: Color.wispZapColor.opacity(0.35), radius: 28, x: 0, y: 6) + } + + Button { + withAnimation(.easeInOut(duration: 0.2)) { + showAdvancedOptions.toggle() + } + } label: { + HStack(spacing: 8) { + Text("More options") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) + Image(systemName: "chevron.down") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + .rotationEffect(.degrees(showAdvancedOptions ? 180 : 0)) + } + .padding(.vertical, 6) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if showAdvancedOptions { + VStack(spacing: 12) { + optionRow( + icon: "plus.circle.fill", + title: "Create new wallet", + subtitle: "Generate a fresh 12-word seed phrase", + action: { startCreate() } + ) + optionRow( + icon: "arrow.uturn.backward.circle.fill", + title: "Restore from seed phrase", + subtitle: "12 words from a Spark-based wallet", + action: { mode = .restoreSeed } + ) + optionRow( + icon: "icloud.and.arrow.down.fill", + title: "Restore from relays", + subtitle: "Encrypted backup from another device", + action: { mode = .restoreRelays; Task { await store.searchRelayBackup() } } + ) + } + .transition(.opacity.combined(with: .move(edge: .top))) } - optionRow( - icon: "plus.circle.fill", - title: "Create new wallet", - subtitle: "Generate a fresh 12-word seed phrase", - action: { startCreate() } - ) - optionRow( - icon: "arrow.uturn.backward.circle.fill", - title: "Restore from seed phrase", - subtitle: "12 words from a Spark-based wallet", - action: { mode = .restoreSeed } - ) - optionRow( - icon: "icloud.and.arrow.down.fill", - title: "Restore from relays", - subtitle: "Encrypted backup from another device", - action: { mode = .restoreRelays; Task { await store.searchRelayBackup() } } - ) } + Spacer(minLength: 24) } } - private func optionRow(icon: String, title: String, subtitle: String, action: @escaping () -> Void) -> some View { + private func optionRow( + icon: String, + title: String, + subtitle: String, + primary: Bool = false, + action: @escaping () -> Void + ) -> some View { Button(action: action) { HStack(spacing: 14) { Image(systemName: icon) .font(.system(size: 22)) - .foregroundStyle(Color.wispZapColor) + .foregroundStyle(primary ? .white : Color.wispZapColor) .frame(width: 28) VStack(alignment: .leading, spacing: 2) { - Text(title).font(.subheadline.weight(.semibold)).foregroundStyle(.primary) - Text(subtitle).font(.caption).foregroundStyle(.secondary) + Text(title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(primary ? .white : .primary) + Text(subtitle) + .font(.caption) + .foregroundStyle(primary ? Color.white.opacity(0.85) : .secondary) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) } + .frame(maxWidth: .infinity, alignment: .leading) Spacer() Image(systemName: "chevron.right") .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(.tertiary) + .foregroundStyle(primary ? AnyShapeStyle(Color.white.opacity(0.85)) : AnyShapeStyle(.tertiary)) } .padding(.horizontal, 16) .padding(.vertical, 14) - .background(Color.wispSurfaceVariant.opacity(0.4), in: RoundedRectangle(cornerRadius: 14)) + .background( + (primary ? Color.wispZapColor : Color.wispSurfaceVariant.opacity(0.4)), + in: RoundedRectangle(cornerRadius: 14) + ) .contentShape(RoundedRectangle(cornerRadius: 14)) } .buttonStyle(.plain) diff --git a/WalletView.swift b/WalletView.swift index 48a651a..31df5ac 100644 --- a/WalletView.swift +++ b/WalletView.swift @@ -510,18 +510,11 @@ struct WalletModeSelectionView: View { Spacer() VStack(spacing: 12) { - modeRow( - title: "Spark wallet", - subtitle: "Self-custody, embedded. Use your default wallet or restore from seed/relays.", - logo: AnyView( - Image("SparkIcon") - .resizable() - .scaledToFit() - .foregroundStyle(Color.wispZapColor) - .frame(width: 28, height: 28) - ), - action: { onPick(.spark) } - ) + // Spark is the recommended wallet — full-bleed orange + // background plus an outer glow draws the eye there first. + // NWC stays as a peer option below so existing-wallet users + // can connect their setup in one tap. + primarySparkRow modeRow( title: "Nostr Wallet Connect", subtitle: "Paste a connection string from Alby, Zeus, Rizful, Minibits, etc.", @@ -540,6 +533,41 @@ struct WalletModeSelectionView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } + private var primarySparkRow: some View { + Button { + onPick(.spark) + } label: { + HStack(spacing: 14) { + Image("SparkIcon") + .resizable() + .scaledToFit() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + VStack(alignment: .leading, spacing: 2) { + Text("Spark wallet") + .font(.subheadline.weight(.bold)) + .foregroundStyle(.white) + Text("Self-custody, embedded. Recommended.") + .font(.caption) + .foregroundStyle(Color.white.opacity(0.85)) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Color.white.opacity(0.85)) + } + .padding(.horizontal, 16) + .padding(.vertical, 16) + .background(Color.wispZapColor, in: RoundedRectangle(cornerRadius: 14)) + .contentShape(RoundedRectangle(cornerRadius: 14)) + .shadow(color: Color.wispZapColor.opacity(0.55), radius: 16, x: 0, y: 0) + .shadow(color: Color.wispZapColor.opacity(0.35), radius: 28, x: 0, y: 6) + } + .buttonStyle(.plain) + } + private func modeRow(title: String, subtitle: String, logo: AnyView, action: @escaping () -> Void) -> some View { Button(action: action) { HStack(spacing: 14) { From 4cfd6ab84fc68164e65892be9e4e91a724151c41 Mon Sep 17 00:00:00 2001 From: The Daniel Date: Wed, 20 May 2026 21:26:43 -0400 Subject: [PATCH 05/13] feat(zap): redesign ZapSheet for compact keyboard-friendly layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layout rewrite (ZapSheet.swift): * Compact recipient row (avatar + name + lud16 + copy-pill icon) in place of the bordered card. Single-line, no section label. * Hero number scales to 56pt rounded; "sats" caption hidden in fiat mode. Tapping the hero pulls focus to the hidden amount field with a seeded register-style digit string. * Presets wrap via FlowLayout instead of horizontal scroll, so every preset is visible. Custom pill carries an inline "+" badge for save-as-preset; the badge disables at the 8-preset cap. * Message field always visible above the privacy dropdown + Instant zaps toggle, so the whole interaction fits in the visible area above the keyboard. * Bottom bar is a full-width Zap button with the privacy chip moved into its own labeled dropdown row above. Drop the pinned-by-safeAreaInset placement so the bar translates with the rest of the sheet during a drag-down dismiss. * Whole sheet wrapped in ScrollView with .scrollDismissesKeyboard(.interactively) so dragging dismisses the keyboard the moment the drag starts — items no longer appear to float loose from the body while the keyboard avoidance is fighting the drag. * Inline copied-pill overlay replaces the shared SuccessToast so the pill renders only on the sheet (the global store also fires on the MainView overlay behind the sheet, producing two pills). Behaviors: * Auto-focus the amount field on appear (deferred 450ms to let the sheet mount past the LazyVStack row's layout change). * Seed `amountSats` from the configured one-tap default amount (treat as the user's "preferred opening amount" even when instant zaps are disabled). First non-empty keystroke replaces the seed; backspace to empty zeroes amountSats and disables Send. Tapping a preset resets the typed-flag so the field can re-focus cleanly. * Hard cap of 1,000,000 sats: Zap button disables above the cap with a red "Max …" hint above it. * Soft confirmation above 10,000 sats: confirmationDialog asks before firing. * Per-user preset storage keyed by `zapPresetAmounts_`, with a one-time migration from the legacy global key on first read. Per-user backup (AppSettings.swift): * `snapshotForBackup` and `applyRestored` look up the per-pubkey key first, falling back to the legacy global key when no active account is loaded. Keeps NIP-78 sync's "this account's preferences" semantics for the presets row. Instant-zap settings cap (InterfaceSettingsView.swift): * Sats field clamps at min(10_000, max(1, value)). Fiat field clamps to the 10K-sats equivalent via the cached rate so an instant zap can never be configured above the confirmation threshold. Friendly error copy (ZapAnimationStore.swift): * `friendlyMessage(for:)` maps raw SDK strings into plain copy — "Not enough sats in your wallet" for insufficient funds, similar treatment for route-not-found, expired-invoice, timeout, missing-lud16, LNURL 400, and min / max amount cases. Falls back to the substring inside `(...)` when no pattern matches, otherwise the original raw text. --- AppSettings.swift | 21 +- InterfaceSettingsView.swift | 16 +- ZapAnimationStore.swift | 55 ++- ZapSheet.swift | 931 +++++++++++++++++++++++++----------- 4 files changed, 727 insertions(+), 296 deletions(-) diff --git a/AppSettings.swift b/AppSettings.swift index d3cd990..a353d78 100644 --- a/AppSettings.swift +++ b/AppSettings.swift @@ -254,7 +254,14 @@ final class AppSettings { if let m = payload.fiatModeEnabled { fiatModeEnabled = m } if let c = payload.fiatCurrency, !c.isEmpty { fiatCurrency = c } if let raw = payload.zapPresetsCSV, !raw.isEmpty { - UserDefaults.standard.set(raw, forKey: "zapPresetAmounts") + // Per-pubkey storage — fall back to the legacy global key when + // there's no active account so the value is at least available + // for the next session. + if let pubkey = NostrKey.load()?.pubkey { + UserDefaults.standard.set(raw, forKey: "zapPresetAmounts_\(pubkey)") + } else { + UserDefaults.standard.set(raw, forKey: "zapPresetAmounts") + } } // Appearance if let b = payload.largeText { largeText = b } @@ -282,7 +289,17 @@ final class AppSettings { /// Build the payload that gets NIP-44 encrypted and published as kind-30078. /// Mirrors `applyRestored` — every field the backup carries. func snapshotForBackup() -> Nip78Backup.AppSettingsPayload { - let presetsRaw = UserDefaults.standard.string(forKey: "zapPresetAmounts") + // Pull the active account's preset row first; fall back to the + // legacy global key for users who haven't yet opened the new + // ZapSheet (which would have migrated the value over). + let defaults = UserDefaults.standard + let presetsRaw: String? + if let pubkey = NostrKey.load()?.pubkey, + let perUser = defaults.string(forKey: "zapPresetAmounts_\(pubkey)") { + presetsRaw = perUser + } else { + presetsRaw = defaults.string(forKey: "zapPresetAmounts") + } return Nip78Backup.AppSettingsPayload( quickZapEnabled: quickZapEnabled, quickZapAmountSats: quickZapAmountSats, diff --git a/InterfaceSettingsView.swift b/InterfaceSettingsView.swift index d211778..ab96f70 100644 --- a/InterfaceSettingsView.swift +++ b/InterfaceSettingsView.swift @@ -188,7 +188,19 @@ struct InterfaceSettingsView: View { "0.10", value: Binding( get: { settings.quickZapAmountFiat }, - set: { settings.quickZapAmountFiat = max(0.01, $0) } + set: { newValue in + // Mirror the sats cap so a one-tap can't + // exceed 10k sats equivalent. Convert + // the entered fiat to sats via the cached + // rate; if it's over 10k, snap the fiat + // value back to the 10k-sats equivalent. + let clamped = max(0.01, newValue) + let cap = ExchangeRateCache.shared.satsToFiat( + 10_000, + currency: settings.fiatCurrency + ) ?? Double.greatestFiniteMagnitude + settings.quickZapAmountFiat = min(clamped, cap) + } ), format: .number.precision(.fractionLength(2)) ) @@ -208,7 +220,7 @@ struct InterfaceSettingsView: View { "100", value: Binding( get: { settings.quickZapAmountSats }, - set: { settings.quickZapAmountSats = max(1, $0) } + set: { settings.quickZapAmountSats = min(10_000, max(1, $0)) } ), format: .number ) diff --git a/ZapAnimationStore.swift b/ZapAnimationStore.swift index 0d1d96d..46ab68c 100644 --- a/ZapAnimationStore.swift +++ b/ZapAnimationStore.swift @@ -107,10 +107,11 @@ final class ZapAnimationStore { } } case .failure(let err): + let friendly = Self.friendlyMessage(for: err.localizedDescription) if let eventId { - self.errors[eventId] = err.localizedDescription + self.errors[eventId] = friendly } else { - self.lastErrorBanner = err.localizedDescription + self.lastErrorBanner = friendly } } @@ -155,4 +156,54 @@ final class ZapAnimationStore { errors.removeAll() lastErrorBanner = nil } + + /// Map raw SDK / sender error strings into user-readable copy. The + /// underlying wallet stack (Spark, NWC) surfaces nested Swift type + /// descriptions like `BreezSdkSpark.SdkError.SparkError("Tree service + /// error: insufficient funds")` which is noise to a non-developer. + /// Pattern-match the well-known failure modes and fall back to a + /// cleaned-up version of the original string for everything else. + static func friendlyMessage(for raw: String) -> String { + let lower = raw.lowercased() + if lower.contains("insufficient funds") || lower.contains("insufficient balance") { + return "Not enough sats in your wallet." + } + if lower.contains("no route") || lower.contains("route not found") || lower.contains("unreachable") { + return "Couldn't find a payment route to the recipient. Try again later." + } + if lower.contains("expired") || lower.contains("invoice has expired") { + return "The lightning invoice expired before it could be paid. Try again." + } + if lower.contains("timeout") || lower.contains("timed out") { + return "The payment timed out. Check your connection and try again." + } + if lower.contains("no lud16") || lower.contains("no lightning address") { + return "This account doesn't have a lightning address." + } + if lower.contains("lnurl") && lower.contains("400") { + return "The recipient's lightning provider rejected this zap. Try a different amount." + } + if lower.contains("amount too small") || lower.contains("below minimum") { + return "Amount is below the recipient's minimum. Try a larger zap." + } + if lower.contains("amount too large") || lower.contains("above maximum") { + return "Amount is above the recipient's maximum. Try a smaller zap." + } + // Strip the SDK noise wrapper if present: + // `Payment failed: BreezSdkSpark.SdkError.SparkError("…")` → `…` + if let inner = extractQuotedReason(in: raw) { + return inner.prefix(1).uppercased() + inner.dropFirst() + (inner.hasSuffix(".") ? "" : ".") + } + return raw + } + + /// Pull the substring between the first `("` and the matching `")` — + /// the conventional shape of a wrapped Swift enum description like + /// `Foo.Bar("the actual message")`. Returns nil when no such wrapper + /// is present. + private static func extractQuotedReason(in raw: String) -> String? { + guard let open = raw.range(of: "(\""), + let close = raw.range(of: "\")", range: open.upperBound..? @FocusState private var amountFocused: Bool - // Persisted preset amounts as a comma-separated string - @AppStorage("zapPresetAmounts") private var presetsRaw: String = "21,100,500,1000,5000" - private static let maxPresets = 8 + private static let defaultPresetsCSV = "21,100,500,1000,5000" + /// Per-user UserDefaults key. Each signed-in account keeps its own + /// preset row, so switching accounts swaps the presets cleanly. + private static func presetsKey(forPubkey pubkey: String) -> String { + "zapPresetAmounts_\(pubkey)" + } + /// Legacy single-account key that pre-dated the per-pubkey split. We + /// read it once as a migration source for the active user, then leave + /// it in place so older builds still see something if they roll back. + private static let legacyPresetsKey = "zapPresetAmounts" + + private var activePubkey: String? { + NostrKey.load()?.pubkey + } + + /// Read the active user's presets, falling back to the legacy global + /// key (so existing users don't lose their preset row on upgrade), and + /// finally to the default set. + private func loadPresetsCSV() -> String { + guard let pubkey = activePubkey else { return Self.defaultPresetsCSV } + let defaults = UserDefaults.standard + if let csv = defaults.string(forKey: Self.presetsKey(forPubkey: pubkey)), + !csv.isEmpty { + return csv + } + if let legacy = defaults.string(forKey: Self.legacyPresetsKey), !legacy.isEmpty { + // One-time migration: copy the global value into this user's + // slot so future reads stay local to the account. + defaults.set(legacy, forKey: Self.presetsKey(forPubkey: pubkey)) + return legacy + } + return Self.defaultPresetsCSV + } + + private func writePresetsCSV(_ csv: String) { + guard let pubkey = activePubkey else { return } + UserDefaults.standard.set(csv, forKey: Self.presetsKey(forPubkey: pubkey)) + } - private var presets: [Int64] { - presetsRaw.split(separator: ",").compactMap { Int64($0.trimmingCharacters(in: .whitespaces)) } + /// Preset entry. `presetsRaw` stores entries as either `""` or + /// `":"` separated by commas — the optional message is a + /// default zap note (e.g. "Thanks ☕") that auto-fills the message field + /// when this preset is tapped. Legacy integer-only entries still parse. + private struct Preset: Hashable, Identifiable { + let sats: Int64 + let message: String? + var id: String { "\(sats):\(message ?? "")" } + /// The pill label — the sats amount itself. We don't render the + /// message text on the pill; it surfaces when the user taps the + /// preset and fills the message field instead. + var displayText: String { + CurrencyFormatter.short(sats: sats) + } + } + + private var parsedPresets: [Preset] { + presetsRaw.split(separator: ",").compactMap { raw -> Preset? in + let parts = raw.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) + guard let satsPart = parts.first, + let sats = Int64(satsPart.trimmingCharacters(in: .whitespaces)) else { return nil } + let trimmedMessage = parts.count > 1 + ? String(parts[1]).trimmingCharacters(in: .whitespaces) + : "" + return Preset(sats: sats, message: trimmedMessage.isEmpty ? nil : trimmedMessage) + } } + /// Legacy convenience for the save-as-preset logic — just the sats. + private var presets: [Int64] { parsedPresets.map(\.sats) } + private var canSaveAsPreset: Bool { isCustom && amountSats > 0 && !presets.contains(amountSats) } + /// Above this, lightning providers reliably reject the invoice and the + /// zap never lands. Treat as a hard cap so the user doesn't waste a + /// publish on something that will fail downstream. + private static let maxZapSats: Int64 = 1_000_000 + /// Threshold for the "confirm large zap" prompt. Below this, Send fires + /// immediately; above, the user gets one tap of "Are you sure?" so a + /// typo'd extra zero doesn't wire 100k sats by accident. + private static let largeZapThresholdSats: Int64 = 10_000 + private var canZap: Bool { - recipientLud16 != nil && store.activeWallet != nil && amountSats > 0 + recipientLud16 != nil + && store.activeWallet != nil + && amountSats > 0 + && amountSats <= Self.maxZapSats + } + + private var exceedsMax: Bool { + amountSats > Self.maxZapSats } /// Big amount shown in the hero. While typing custom in fiat mode the @@ -121,305 +217,517 @@ struct ZapSheet: View { var body: some View { NavigationStack { - ScrollView { - VStack(spacing: 28) { - // Hero - VStack(spacing: 8) { - settings.zapImage - .resizable() - .scaledToFit() - .frame(width: 40, height: 40) - .foregroundStyle(Color.wispZapColor) - - Text(settings.fiatModeEnabled ? "Send Money" : "Send Zap") - .font(.title2.weight(.bold)) - - // Big amount display — tap to edit. Seed the field - // with the unit the user is typing in: cents for fiat - // mode, integer sats otherwise. - Button { - isCustom = true - if customAmountText.isEmpty { - customAmountText = ZapSheet.seedCustomText( - amountSats: amountSats, - fiatMode: settings.fiatModeEnabled, - fiatCurrency: settings.fiatCurrency - ) - } - amountFocused = true - } label: { - VStack(spacing: 2) { - Text(heroAmountText) - .font(.system(size: 48, weight: .bold, design: .rounded)) - .foregroundStyle(Color.wispZapColor) - .contentTransition(.numericText(value: Double(amountSats))) - .animation(.easeInOut(duration: 0.15), value: amountSats) - if !settings.fiatModeEnabled { - Text("sats") - .font(.subheadline.weight(.medium)) - .foregroundStyle(Color.wispZapColor.opacity(0.8)) - } - } - } - .buttonStyle(.plain) + // ScrollView holds the scrollable form rows; the Zap button + // lives in a sibling row pinned below the scroll view so it + // stays in the visible area even when the keyboard is up. + // SwiftUI's keyboard avoidance lifts the whole VStack, and + // because bottomBar is the bottom-most child it ends up + // sitting just above the keyboard rather than scrolling off + // with the form content. + // + // `.scrollDismissesKeyboard(.interactively)` keeps the + // sheet-drag → keyboard-collapse coupling that previously + // lived on the all-in-one ScrollView, so dragging the sheet + // down still drops the keyboard mid-drag and the rows move + // with the sheet as one unit instead of floating loose. + VStack(spacing: 0) { + ScrollView { + VStack(spacing: 16) { + recipientRow + + amountHero + + presetStrip + + // Hidden TextField anchored to the focus state so + // tapping the hero (or onAppear) raises the keyboard. + // Fiat mode reads the register-style cents digits; + // non-fiat reads raw sats. The field has no visible + // footprint — it lives outside the visible layer at + // zero size. + hiddenAmountField + .frame(width: 0, height: 0) + .opacity(0) + .accessibilityHidden(true) + + messageField + + privacyRow + + instantZapRow } - .frame(maxWidth: .infinity) + .padding(.horizontal, 20) .padding(.top, 8) + .padding(.bottom, 8) + } + .scrollDismissesKeyboard(.interactively) + .scrollBounceBehavior(.basedOnSize) - // Recipient - if let lud16 = recipientLud16 { - VStack(alignment: .leading, spacing: 6) { - Text("Recipient") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - .textCase(.uppercase) - .tracking(0.5) - VStack(alignment: .leading, spacing: 2) { - if let name = recipientName { - Text(name).font(.subheadline.weight(.semibold)) - } - Text(lud16).font(.caption.monospaced()).foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(14) - .background(Color.wispSurfaceVariant.opacity(0.4), in: RoundedRectangle(cornerRadius: 12)) + bottomBar + .padding(.horizontal, 20) + .padding(.bottom, 8) + .background(Color(.systemBackground)) + } + // Local copied-pill overlay. Uses a sheet-local @State rather + // than `SuccessToast.shared` so it doesn't also fire on the + // MainView overlay behind this sheet (which would render a + // second pill peeking behind the sheet's top edge). + .overlay(alignment: .top) { copiedPill } + // Persist any preset edit (from EditPresetsSheet or the inline + // save-as-preset chip) to the active user's slot so the change + // survives sheet dismissal and account switches read clean. + .onChange(of: presetsRaw) { _, new in + writePresetsCSV(new) + } + .navigationTitle("") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close", action: dismiss) + } + ToolbarItem(placement: .confirmationAction) { + Button("Presets") { + // Drop focus + defer presentation so the amount-field + // keyboard collapses before the Presets sheet mounts. + // The two-sheet stack flips closed when a keyboard + // dismiss races a sheet present (same shape as the + // drafts/GIF picker race). + amountFocused = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + showEditPresets = true } - .frame(maxWidth: .infinity, alignment: .leading) - } else { - Text("Recipient has no lightning address — they cannot receive zaps.") - .font(.subheadline) - .foregroundStyle(.red) - .multilineTextAlignment(.center) } + .font(.subheadline.weight(.semibold)) + .foregroundStyle(Color.wispZapColor) + } + } + .sheet(isPresented: $showEditPresets) { + EditPresetsSheet(presetsRaw: $presetsRaw) + } + .confirmationDialog( + settings.fiatModeEnabled + ? "Send \(CurrencyFormatter.short(sats: amountSats))?" + : "Zap \(CurrencyFormatter.formatNumber(amountSats)) sats?", + isPresented: $showLargeZapConfirm, + titleVisibility: .visible + ) { + Button( + settings.fiatModeEnabled + ? "Send \(CurrencyFormatter.short(sats: amountSats))" + : "Zap \(CurrencyFormatter.formatNumber(amountSats)) sats" + ) { + performSend() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This is a large amount — double-check before sending.") + } + .onAppear { + // Pull presets for the active account. Per-pubkey storage + // keeps each signed-in user's preset row siloed; the + // migration in `loadPresetsCSV` carries forward any global + // value from prior builds. + presetsRaw = loadPresetsCSV() + // Seed the hero from the configured one-tap default amount + // (treated as the user's preferred zap default even when + // instant zaps are disabled — they still represent + // "what should the sheet open to"). The hidden field + // starts empty, so heroAmountText falls through to the + // formatted `amountSats` value and the keyboard is up; + // the first keystroke replaces the seed because + // customAmountText is "" at that point. + if settings.fiatModeEnabled { + if let sats = ExchangeRateCache.shared + .fiatToSats(settings.quickZapAmountFiat, currency: settings.fiatCurrency), + sats > 0 { + amountSats = sats + } + } else if settings.quickZapAmountSats > 0 { + amountSats = settings.quickZapAmountSats + } + isCustom = true + customAmountText = "" + // Defer focus by one tick so the sheet finishes its + // mount + transition before the keyboard raises. Without + // the hop the keyboard rising during the mount changes + // the parent LazyVStack row's layout (PostCardView lives + // in a lazy feed), the row recycles, the `.sheet` binding + // tied to it unmounts, the sheet dismisses, and SwiftUI + // immediately re-presents — looping until the user + // manages to interact. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { + amountFocused = true + } + } + } + } - // Quick amounts - VStack(alignment: .leading, spacing: 10) { - HStack { - Text("Quick Amounts") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - .textCase(.uppercase) - .tracking(0.5) - Spacer() - Button("Edit") { showEditPresets = true } - .font(.caption.weight(.semibold)) - .foregroundStyle(Color.wispZapColor) - } + // MARK: - Copied pill - FlowLayout(spacing: 10) { - ForEach(presets, id: \.self) { sats in - Button { - amountSats = sats - customAmountText = "" - isCustom = false - } label: { - Text(CurrencyFormatter.short(sats: sats)) - .font(.subheadline.weight(.semibold)) - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background( - amountSats == sats && !isCustom - ? Color.wispZapColor - : Color.wispSurfaceVariant.opacity(0.5), - in: Capsule() - ) - .foregroundStyle(amountSats == sats && !isCustom ? .white : .primary) - } - .buttonStyle(.plain) - } - - // Custom pill - Button { - isCustom = true - } label: { - Text(isCustom && amountSats > 0 - ? CurrencyFormatter.short(sats: amountSats) - : "Custom") - .font(.subheadline.weight(.semibold)) - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background(isCustom ? Color.wispZapColor : Color.wispSurfaceVariant.opacity(0.5), in: Capsule()) - .foregroundStyle(isCustom ? .white : .primary) - } - .buttonStyle(.plain) - } + @ViewBuilder + private var copiedPill: some View { + if copiedToastVisible { + HStack(spacing: 8) { + Image(systemName: "doc.on.doc.fill") + .font(.system(size: 12, weight: .semibold)) + Text("Lightning address copied") + .font(.subheadline.weight(.semibold)) + } + .foregroundStyle(.white) + .padding(.horizontal, 14) + .padding(.vertical, 9) + .background(Color.wispZapColor, in: Capsule()) + .shadow(color: .black.opacity(0.25), radius: 6, y: 2) + .padding(.top, 8) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } - // Custom amount input — shown when Custom is selected. - // Fiat mode types in the currency's major unit (dollars, - // euros, etc.) with up to 2 decimal places; non-fiat is - // plain integer sats. Sanitisation runs inside the - // Binding setter so the canonical value is committed - // BEFORE SwiftUI propagates the change — the previous - // re-entrant `onChange { customAmountText = clean }` - // pattern triggered a navigation pop on some screens - // (search → thread → zap) by feeding SwiftUI a state - // mutation mid-diff. - if isCustom { - if settings.fiatModeEnabled { - let fiatBinding = Binding( - get: { - // Display the field as the formatted dollar - // value so the user reads "$0.21" while - // typing rather than the raw digit string. - customAmountText.isEmpty - ? "" - : ZapSheet.formatRegisterCents( - digits: customAmountText, - currencyCode: settings.fiatCurrency - ) - }, - set: { newValue in - // Strip everything but digits — the - // currency symbol, comma separator, and - // decimal point in the displayed string - // are presentation-only; the canonical - // value is the cents digit string. - let digits = ZapSheet.sanitizeFiatInput(newValue) - customAmountText = digits - let cents = Int64(digits) ?? 0 - if cents > 0 { - amountSats = ExchangeRateCache.shared - .fiatToSats(Double(cents) / 100.0, currency: settings.fiatCurrency) ?? 0 - } else { - amountSats = 0 - } - } - ) - TextField("Amount", text: fiatBinding) - .keyboardType(.numberPad) - .font(.subheadline) - .padding(12) - .background(Color.wispSurfaceVariant.opacity(0.4), in: RoundedRectangle(cornerRadius: 10)) - .focused($amountFocused) - } else { - let satsBinding = Binding( - get: { customAmountText }, - set: { newValue in - let digits = newValue.filter(\.isNumber) - customAmountText = digits - amountSats = Int64(digits) ?? 0 - } - ) - TextField("Amount in sats", text: satsBinding) - .keyboardType(.numberPad) - .font(.subheadline) - .padding(12) - .background(Color.wispSurfaceVariant.opacity(0.4), in: RoundedRectangle(cornerRadius: 10)) - .focused($amountFocused) - } - - if canSaveAsPreset { - let atMax = presets.count >= ZapSheet.maxPresets - HStack { - Button { - presetsRaw = (presets + [amountSats]) - .sorted() - .map { String($0) } - .joined(separator: ",") - EmojiRepository.shared.scheduleSettingsSync() - } label: { - Label("Save as Preset", systemImage: "star") - .font(.subheadline.weight(.medium)) - .foregroundStyle(atMax ? .secondary : Color.wispZapColor) - } - .buttonStyle(.plain) - .disabled(atMax) - if atMax { - Text("(\(ZapSheet.maxPresets) max)") - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - } - } - .frame(maxWidth: .infinity, alignment: .leading) - - // Message - VStack(alignment: .leading, spacing: 6) { - Text("Message (optional)") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - .textCase(.uppercase) - .tracking(0.5) - TextField("Message (optional)", text: $message) - .font(.subheadline) - .padding(12) - .background(Color.wispSurfaceVariant.opacity(0.4), in: RoundedRectangle(cornerRadius: 10)) - } - .frame(maxWidth: .infinity, alignment: .leading) - - // Zap type - VStack(alignment: .leading, spacing: 8) { - Text("Type") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - .textCase(.uppercase) - .tracking(0.5) - Picker("Zap type", selection: $zapType) { - ForEach(ZapType.allCases) { type in - Label(type.rawValue, systemImage: type.icon).tag(type) - } - } - .pickerStyle(.segmented) - if zapType != .public { - Text(zapType == .anonymous - ? "Your identity is hidden from the lightning provider." - : "Hidden identity, receipt routed to your DM inbox relays.") - .font(.caption) - .foregroundStyle(.secondary) - } - } - .frame(maxWidth: .infinity, alignment: .leading) + private func showCopiedPill() { + copiedToastTask?.cancel() + withAnimation(.easeInOut(duration: 0.2)) { + copiedToastVisible = true + } + copiedToastTask = Task { @MainActor in + try? await Task.sleep(for: .seconds(1.6)) + guard !Task.isCancelled else { return } + withAnimation(.easeInOut(duration: 0.22)) { + copiedToastVisible = false + } + } + } + + // MARK: - Recipient row + + private var recipientRow: some View { + HStack(spacing: 10) { + CachedAvatarView(url: ProfileRepository.shared.get(recipientPubkey)?.picture, size: 32) + VStack(alignment: .leading, spacing: 1) { + Text(recipientName ?? Nip19.shortNpub(hex: recipientPubkey)) + .font(.subheadline.weight(.semibold)) + .lineLimit(1) + if let lud16 = recipientLud16 { + Text(lud16) + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + } else { + Text("No lightning address") + .font(.caption2) + .foregroundStyle(.red) } - .padding(.horizontal, 20) - .padding(.bottom, 12) } - .safeAreaInset(edge: .bottom) { - // Pinned send button — always visible above the keyboard / tab bar. - // The sheet dismisses the moment the user taps Send; the in-flight - // pulse + success burst run on the post card via ZapAnimationStore. + Spacer(minLength: 0) + if let lud16 = recipientLud16 { Button { - send() + UIPasteboard.general.string = lud16 + showCopiedPill() } label: { - HStack(spacing: 6) { - settings.zapImage - .resizable() - .scaledToFit() - .frame(width: 18, height: 18) - Text(settings.fiatModeEnabled - ? "Send \(CurrencyFormatter.short(sats: amountSats))" - : "Zap \(CurrencyFormatter.formatNumber(amountSats)) sats") - .fontWeight(.semibold) + Image(systemName: "doc.on.doc") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.secondary) + .frame(width: 32, height: 32) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Copy Lightning Address") + } + } + } + + // MARK: - Amount hero + + private var amountHero: some View { + Button { + isCustom = true + if customAmountText.isEmpty { + customAmountText = ZapSheet.seedCustomText( + amountSats: amountSats, + fiatMode: settings.fiatModeEnabled, + fiatCurrency: settings.fiatCurrency + ) + } + amountFocused = true + } label: { + VStack(spacing: 0) { + Text(heroAmountText.isEmpty ? "0" : heroAmountText) + .font(.system(size: 56, weight: .bold, design: .rounded)) + .foregroundStyle(Color.wispZapColor) + .contentTransition(.numericText(value: Double(amountSats))) + .animation(.easeInOut(duration: 0.15), value: amountSats) + .lineLimit(1) + .minimumScaleFactor(0.5) + if !settings.fiatModeEnabled { + Text("sats") + .font(.subheadline.weight(.medium)) + .foregroundStyle(Color.wispZapColor.opacity(0.8)) + } + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.plain) + } + + // MARK: - Preset strip + + private var presetStrip: some View { + // Wrap rather than scroll so the user can see every preset at once. + // `FlowLayout` lays out pills left-to-right and breaks to a new line + // when the row is full. + FlowLayout(spacing: 8) { + ForEach(parsedPresets, id: \.id) { preset in + presetPill(label: preset.displayText, + selected: amountSats == preset.sats && !isCustom) { + amountSats = preset.sats + customAmountText = "" + hasTypedAmount = false + // Auto-fill the message field if the preset carries one, + // but only when the field is still empty so the user + // doesn't clobber a message they were typing. + if let presetMessage = preset.message, message.isEmpty { + message = presetMessage } - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - .background( - canZap ? Color.wispZapColor : Color.wispSurfaceVariant.opacity(0.4), - in: RoundedRectangle(cornerRadius: 14) + withAnimation(.easeInOut(duration: 0.15)) { isCustom = false } + amountFocused = false + } + } + customPill + } + } + + @ViewBuilder + private func presetPill(label: String, selected: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(label) + .font(.subheadline.weight(.semibold)) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(selected ? Color.wispZapColor : Color.wispSurfaceVariant.opacity(0.5), in: Capsule()) + .foregroundStyle(selected ? .white : .primary) + } + .buttonStyle(.plain) + } + + @ViewBuilder + private var customPill: some View { + let label = isCustom && amountSats > 0 + ? CurrencyFormatter.short(sats: amountSats) + : "Custom" + HStack(spacing: 4) { + Button { + isCustom = true + if customAmountText.isEmpty { + customAmountText = ZapSheet.seedCustomText( + amountSats: amountSats, + fiatMode: settings.fiatModeEnabled, + fiatCurrency: settings.fiatCurrency ) - .foregroundStyle(.white) + } + amountFocused = true + } label: { + Text(label) + .font(.subheadline.weight(.semibold)) + .padding(.leading, 14) + .padding(.trailing, canSaveAsPreset ? 6 : 14) + .padding(.vertical, 8) + .foregroundStyle(isCustom ? .white : .primary) + } + .buttonStyle(.plain) + + if canSaveAsPreset { + let atMax = presets.count >= ZapSheet.maxPresets + Button { + guard !atMax else { return } + let next = (presets + [amountSats]) + .sorted() + .map { String($0) } + .joined(separator: ",") + presetsRaw = next + writePresetsCSV(next) + EmojiRepository.shared.scheduleSettingsSync() + } label: { + Image(systemName: "plus.circle.fill") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(atMax ? Color.white.opacity(0.4) : Color.white) + .padding(.trailing, 8) } .buttonStyle(.plain) - .disabled(!canZap) - .padding(.horizontal, 20) - .padding(.vertical, 12) - .background(.bar) + .disabled(atMax) } - .navigationTitle("") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Close", action: dismiss) + } + .background(isCustom ? Color.wispZapColor : Color.wispSurfaceVariant.opacity(0.5), in: Capsule()) + } + + // MARK: - Hidden amount field + + @ViewBuilder + private var hiddenAmountField: some View { + if settings.fiatModeEnabled { + let fiatBinding = Binding( + get: { customAmountText }, + set: { newValue in + let digits = ZapSheet.sanitizeFiatInput(newValue) + customAmountText = digits + if let cents = Int64(digits), cents > 0 { + amountSats = ExchangeRateCache.shared + .fiatToSats(Double(cents) / 100.0, currency: settings.fiatCurrency) ?? amountSats + hasTypedAmount = true + } else if hasTypedAmount { + // User backspaced the field after typing — collapse + // to zero so the Zap button disables and the hero + // reads "0", matching what's on screen. + amountSats = 0 + } + // Pre-typing empty values are SwiftUI's initial bind + // commit on focus; leave amountSats at its seed. + } + ) + TextField("Amount", text: fiatBinding) + .keyboardType(.numberPad) + .focused($amountFocused) + } else { + let satsBinding = Binding( + get: { customAmountText }, + set: { newValue in + let digits = newValue.filter(\.isNumber) + customAmountText = digits + if let n = Int64(digits), n > 0 { + amountSats = n + hasTypedAmount = true + } else if hasTypedAmount { + amountSats = 0 + } } + ) + TextField("Amount in sats", text: satsBinding) + .keyboardType(.numberPad) + .focused($amountFocused) + } + } + + // MARK: - Message field + + private var messageField: some View { + TextField("Message (optional)", text: $message) + .font(.subheadline) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(Color.wispSurfaceVariant.opacity(0.4), in: RoundedRectangle(cornerRadius: 10)) + .submitLabel(.done) + } + + // MARK: - Privacy row (between message and send) + + private var privacyRow: some View { + HStack(spacing: 8) { + Image(systemName: zapType.icon) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.secondary) + .frame(width: 18) + Menu { + ForEach(ZapType.allCases) { type in + Button { + zapType = type + } label: { + Label(type.rawValue, systemImage: type.icon) + } + } + } label: { + HStack(spacing: 4) { + Text(zapType.rawValue) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + Image(systemName: "chevron.down") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.secondary) + } + .contentShape(Rectangle()) } - .sheet(isPresented: $showEditPresets) { - EditPresetsSheet(presetsRaw: $presetsRaw) + .buttonStyle(.plain) + Spacer(minLength: 0) + if zapType != .public { + Text(zapType == .anonymous + ? "Identity hidden from the lightning provider" + : "Hidden identity, receipt to your DM inbox") + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) } } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(Color.wispSurfaceVariant.opacity(0.4), in: RoundedRectangle(cornerRadius: 10)) + } + + // MARK: - Instant zap toggle + + @ViewBuilder + private var instantZapRow: some View { + @Bindable var settingsBindable = settings + HStack(spacing: 8) { + Image(systemName: "bolt.fill") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.secondary) + .frame(width: 18) + Text(settings.fiatModeEnabled ? "Instant payments" : "Instant zaps") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + Spacer(minLength: 0) + Toggle("", isOn: $settingsBindable.quickZapEnabled) + .labelsHidden() + .tint(Color.wispZapColor) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.wispSurfaceVariant.opacity(0.4), in: RoundedRectangle(cornerRadius: 10)) + } + + // MARK: - Bottom bar (full-width Zap button) + + private var bottomBar: some View { + VStack(spacing: 6) { + if exceedsMax { + Text("Max \(CurrencyFormatter.formatNumber(Self.maxZapSats)) sats per zap") + .font(.caption) + .foregroundStyle(.red) + .frame(maxWidth: .infinity, alignment: .center) + } + Button { + send() + } label: { + HStack(spacing: 6) { + settings.zapImage + .resizable() + .scaledToFit() + .frame(width: 18, height: 18) + Text(settings.fiatModeEnabled + ? "Send \(CurrencyFormatter.short(sats: amountSats))" + : "Zap \(CurrencyFormatter.formatNumber(amountSats)) sats") + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + .frame(height: 54) + .background( + canZap ? Color.wispZapColor : Color.wispSurfaceVariant.opacity(0.4), + in: RoundedRectangle(cornerRadius: 14) + ) + .foregroundStyle(.white) + } + .buttonStyle(.plain) + .disabled(!canZap) + } + .padding(.vertical, 10) } private func send() { + if amountSats > Self.largeZapThresholdSats { + showLargeZapConfirm = true + return + } + performSend() + } + + private func performSend() { guard let key = NostrKey.load() else { return } // Hand off to the global store so the in-flight Task survives sheet // dismissal. The store fires the success haptic + thunder sound, marks @@ -447,23 +755,46 @@ struct ZapSheet: View { private struct EditPresetsSheet: View { @Binding var presetsRaw: String @Environment(\.dismiss) private var dismiss - @State private var drafts: [String] = [] + + /// Editable preset draft. `message` is optional — empty means no default + /// message is associated with this preset. Each draft has its own + /// identity so SwiftUI can keep TextField cursors stable across moves. + private struct Draft: Identifiable { + let id = UUID() + var amount: String + var message: String + } + + @State private var drafts: [Draft] = [] + + /// One blank preset at a time so the Add button can't pile up empty + /// rows. A blank is any draft with no digits in the amount field. + private var hasBlankDraft: Bool { + drafts.contains { $0.amount.trimmingCharacters(in: .whitespaces).isEmpty } + } var body: some View { NavigationStack { List { - ForEach(drafts.indices, id: \.self) { i in - TextField("Amount (sats)", text: $drafts[i]) - .keyboardType(.numberPad) + ForEach($drafts) { $draft in + HStack(spacing: 8) { + TextField("Amount (sats)", text: $draft.amount) + .keyboardType(.numberPad) + .frame(maxWidth: 120) + Divider() + TextField("Message (optional)", text: $draft.message) + } } .onMove { from, to in drafts.move(fromOffsets: from, toOffset: to) } .onDelete { drafts.remove(atOffsets: $0) } Button { - drafts.append("") + drafts.append(Draft(amount: "", message: "")) } label: { Label("Add preset", systemImage: "plus") + .foregroundStyle(hasBlankDraft ? AnyShapeStyle(.secondary) : AnyShapeStyle(Color.wispZapColor)) } + .disabled(hasBlankDraft) } .navigationTitle("Edit Presets") .navigationBarTitleDisplayMode(.inline) @@ -471,9 +802,19 @@ private struct EditPresetsSheet: View { ToolbarItem(placement: .cancellationAction) { EditButton() } ToolbarItem(placement: .confirmationAction) { Button("Done") { - let valid = drafts.compactMap { Int64($0.trimmingCharacters(in: .whitespaces)) }.filter { $0 > 0 } - if !valid.isEmpty { - presetsRaw = valid.map { String($0) }.joined(separator: ",") + let encoded: [String] = drafts.compactMap { d in + guard let sats = Int64(d.amount.trimmingCharacters(in: .whitespaces)), + sats > 0 else { return nil } + let trimmedMessage = d.message.trimmingCharacters(in: .whitespaces) + // Strip commas / colons — they're the delimiters in + // the CSV format and would corrupt the parser. + let safeMessage = trimmedMessage + .replacingOccurrences(of: ",", with: " ") + .replacingOccurrences(of: ":", with: " ") + return safeMessage.isEmpty ? "\(sats)" : "\(sats):\(safeMessage)" + } + if !encoded.isEmpty { + presetsRaw = encoded.joined(separator: ",") // Round-trip the new presets through the NIP-78 // app-settings backup so other devices pick them up. EmojiRepository.shared.scheduleSettingsSync() @@ -483,9 +824,19 @@ private struct EditPresetsSheet: View { } } .onAppear { - drafts = presetsRaw.split(separator: ",").map { String($0.trimmingCharacters(in: .whitespaces)) } + drafts = presetsRaw.split(separator: ",").map { raw in + let parts = raw.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) + let amount = parts.first.map { String($0).trimmingCharacters(in: .whitespaces) } ?? "" + let message = parts.count > 1 + ? String(parts[1]).trimmingCharacters(in: .whitespaces) + : "" + return Draft(amount: amount, message: message) + } } } + // Wisp's zap accent — overrides the system blue applied by default + // to toolbar Edit / Done buttons and the inline Add-preset button. + .tint(Color.wispZapColor) } } From f73f52795f1f0e6b3368f9962a074140f7722353 Mon Sep 17 00:00:00 2001 From: The Daniel Date: Wed, 20 May 2026 21:26:56 -0400 Subject: [PATCH 06/13] feat(zap): tap opens composer, long-press fires instant, disable self-zap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zap button behavior on post cards: * Tap always opens the ZapSheet composer — matches the iOS pattern of "tap to inspect, long-press to act" and prevents an accidental finger from auto-firing a configured zap amount. * Long-press fires the configured instant-zap amount when the user has opted in AND a wallet is set up. Without those, the long-press falls through to the composer so the gesture never feels like a no-op. * Self-zaps are disabled — the button is non-tappable and rendered at 35% opacity on the user's own posts. Self-zapping is a no-op round-trip minus routing fees, so suppress it rather than letting the wallet eat the cost. --- InterfaceSettingsView.swift | 4 ++-- PostCardView.swift | 43 +++++++++++++++++++++---------------- 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/InterfaceSettingsView.swift b/InterfaceSettingsView.swift index ab96f70..db43748 100644 --- a/InterfaceSettingsView.swift +++ b/InterfaceSettingsView.swift @@ -172,8 +172,8 @@ struct InterfaceSettingsView: View { isOn: $settings.quickZapEnabled) .toggleStyle(SwitchToggleStyle(tint: theme.primary)) Text(settings.fiatModeEnabled - ? "When on, tapping the pay button on a post immediately sends your chosen amount. Long-press to open the payment composer." - : "When on, tapping the zap button on a post immediately sends your chosen amount. Long-press to open the zap composer.") + ? "When on, long-pressing the pay button on a post immediately sends your chosen amount. Tap still opens the payment composer." + : "When on, long-pressing the zap button on a post immediately sends your chosen amount. Tap still opens the zap composer.") .font(.system(size: 12)) .foregroundStyle(theme.palette.onSurfaceVariant) .padding(.bottom, 4) diff --git a/PostCardView.swift b/PostCardView.swift index 8dc82ba..f2b9c1f 100644 --- a/PostCardView.swift +++ b/PostCardView.swift @@ -815,25 +815,18 @@ struct PostCardView: View { let eventId = displayEventId let isFlying = zapStore.inFlight.contains(eventId) let isBursting = zapStore.bursting.contains(eventId) + let isOwnPost = (myPubkey != nil) && (myPubkey == resolveRepost().event.pubkey) return Button { - // Tap: fire the configured instant-zap amount when the user has - // opted in AND a wallet is set up. In fiat mode the configured - // amount is denominated in `fiatCurrency` major units and gets - // converted to sats at fire time via the exchange-rate cache. - // Falls back to the composer sheet when the rate isn't available - // (rare; the cache pre-fetches at app launch) or when quick-zap - // is off / no wallet. Long-press always opens the sheet. + // Tap always opens the composer. Long-press fires the + // configured instant-zap amount (when the user has opted in + // AND a wallet is set up). This matches the standard iOS + // pattern of "tap to inspect, long-press to act" and prevents + // an accidental finger from auto-zapping. if zapLongPressFired { zapLongPressFired = false return } - if settings.quickZapEnabled, - let store = walletStore, store.mode != nil, - let amount = resolvedInstantZapSats() { - fireQuickZap(amountSats: amount) - } else { - triggerZapOrWalletSetup() - } + triggerZapOrWalletSetup() } label: { ZStack { if isFlying { @@ -851,14 +844,28 @@ struct PostCardView: View { } } .buttonStyle(.plain) - .disabled(isFlying) + // Disable + dim on the user's own posts — self-zapping is a + // no-op that just round-trips sats minus routing fees. + .disabled(isFlying || isOwnPost) + .opacity(isOwnPost ? 0.35 : 1) .simultaneousGesture( - // Long-press always opens the composer — escape hatch from - // one-tap mode (different amount, different message, anonymous). + // Long-press fires the configured instant zap when the user + // has opted in and a wallet is set up. Without those, fall + // through to the composer — long-press shouldn't feel like + // a no-op for users who haven't enabled instant zaps yet. + // Skip entirely on the user's own posts so the dimmed state + // is truly inert. LongPressGesture(minimumDuration: 0.4).onEnded { _ in + guard !isOwnPost else { return } zapLongPressFired = true Haptics.shared.blip() - triggerZapOrWalletSetup() + if settings.quickZapEnabled, + let store = walletStore, store.mode != nil, + let amount = resolvedInstantZapSats() { + fireQuickZap(amountSats: amount) + } else { + triggerZapOrWalletSetup() + } } ) .overlay(alignment: .center) { From f1a53cd8b7c35e0b0fd807e0ddeb4d1238719731 Mon Sep 17 00:00:00 2001 From: The Daniel Date: Wed, 20 May 2026 23:42:47 -0400 Subject: [PATCH 07/13] feat(zap): white-core glow pulse for the in-flight bolt + DEBUG dev panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LightningPulseView rewrite: * Renders the configured zap icon as an always-white silhouette and layers three stacked zap-color shadows behind it: a tight inner always-on shadow gives the silhouette body, a medium ring fades in 55→100% with the cycle peak, and a wide halo blooms 30→80%. The eye reads this as a luminous core brightening + dimming rather than a tinted bolt fading. * Single sin-eased oscillator drives the whole animation. Scale breathes ±10% centered at 1.0; vertical bounce stays at ±0.5pt so the icon doesn't lift off the action bar's baseline. * Dropped the multi-layer fill compositing the prior version did (outer-stroke + fill + white-hot core) — those layers smeared the bolt silhouette at scale peaks and read as distortion. Earlier iteration shipped a `LightningPulseStyle` enum with six variants (halo, shimmer, colorCycle, wobble, bounce, outline) so they could be A/B'd in the dev panel. We picked the white-core glow and stripped the rest; the rejected styles live in git history if we want to revisit. DEBUG-only developer panel: * `DeveloperToolsView` lives at `wisp/DeveloperToolsView.swift` and is presented from a new "Developer" row in Interface settings, the row + sheet binding both wrapped in `#if DEBUG` so neither ships in a release build. Currently empty — scaffolding for future throwaway experiments to land somewhere out of production code instead of building one-off entry points. --- InterfaceSettingsView.swift | 29 ++++++ PostCardView.swift | 3 +- wisp/Animations/LightningPulseView.swift | 126 +++++++++++------------ wisp/DeveloperToolsView.swift | 36 +++++++ 4 files changed, 126 insertions(+), 68 deletions(-) create mode 100644 wisp/DeveloperToolsView.swift diff --git a/InterfaceSettingsView.swift b/InterfaceSettingsView.swift index db43748..378ae67 100644 --- a/InterfaceSettingsView.swift +++ b/InterfaceSettingsView.swift @@ -10,6 +10,9 @@ struct InterfaceSettingsView: View { @State private var rateUpdatedAt: Date? = nil @State private var themesExpanded = false @State private var emojiRepo = EmojiRepository.shared + #if DEBUG + @State private var showDeveloperTools = false + #endif var body: some View { @Bindable var settings = settings @@ -336,6 +339,27 @@ struct InterfaceSettingsView: View { } } + #if DEBUG + // Developer playground — compiled out of release builds via + // `#if DEBUG`. Park throwaway experiments here. + section(title: "Developer") { + Button { + showDeveloperTools = true + } label: { + HStack { + Text("Developer tools") + .foregroundStyle(theme.palette.onSurface) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(theme.palette.onSurfaceVariant) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + #endif + Spacer(minLength: 40) } .padding(20) @@ -343,6 +367,11 @@ struct InterfaceSettingsView: View { .background(theme.palette.background.ignoresSafeArea()) .navigationTitle("Interface") .navigationBarTitleDisplayMode(.inline) + #if DEBUG + .sheet(isPresented: $showDeveloperTools) { + NavigationStack { DeveloperToolsView() } + } + #endif .sheet(isPresented: $showAccentPicker) { NavigationStack { AccentColorPickerView() diff --git a/PostCardView.swift b/PostCardView.swift index f2b9c1f..1d33379 100644 --- a/PostCardView.swift +++ b/PostCardView.swift @@ -830,10 +830,9 @@ struct PostCardView: View { } label: { ZStack { if isFlying { - LightningPulseView() + LightningPulseView(image: settings.zapImage) .frame(width: 18, height: 18) .frame(height: 28) - .foregroundStyle(Color.wispZapColor) } else { actionItem( image: settings.zapImage, diff --git a/wisp/Animations/LightningPulseView.swift b/wisp/Animations/LightningPulseView.swift index 2f30ecb..d506f83 100644 --- a/wisp/Animations/LightningPulseView.swift +++ b/wisp/Animations/LightningPulseView.swift @@ -1,85 +1,79 @@ import SwiftUI -/// Pulsing lightning bolt shown on the action-bar zap button while a zap is -/// in flight. Three layers (outer glow, fill, white-hot core) modulate alpha -/// 0.5→1.0 and scale 0.92→1.08 on a 600 ms cycle. +/// Pulsing zap icon shown on the action-bar zap button while a zap is in +/// flight. Renders the user's configured `zapImage` (bolt in bitcoin mode, +/// fiat-coin glyph in fiat mode) as an always-white silhouette with three +/// stacked zap-color shadows behind it. Three ingredients run off a single +/// sin-eased oscillator: /// -/// Mirrors Android `ActionBar.kt`'s `LightningAnimation` — same path -/// (`icBoltPath` viewBox 55×94), same cycle, same three-layer stack. +/// * scale breath, centered at 1.0 (0.90 → 1.10 on either side) +/// * 0.5pt vertical bounce centered on the icon's baseline +/// * shadow opacity + radius modulated by the cycle phase +/// +/// White silhouette + breathing warm halo reads as a luminous core +/// brightening and dimming — sharper than tinting the icon directly, +/// which the eye reads as the bolt fading. struct LightningPulseView: View { - /// Continuous time anchor so the sin-curve phase doesn't reset each time - /// SwiftUI rebuilds the view. - private let start = Date() + let image: Image + /// Must be `@State` rather than `let`. SwiftUI re-instantiates the view + /// struct on every parent re-render — and the action bar re-renders + /// repeatedly during a zap flight (zap state changes, repost count + /// updates, etc.). With `let`, `start` snaps to "now" on each re-init, + /// `elapsed` jumps back to ~0, and the sine wave restarts mid-cycle — + /// the visible read is "the pulse speed is inconsistent / stutters". + /// `@State` is owned by SwiftUI's storage and survives view-struct + /// rebuilds, so `elapsed` keeps climbing monotonically. + @State private var start = Date() var body: some View { - TimelineView(.animation) { context in - let phase = currentPhase(at: context.date) - let alpha = 0.5 + 0.5 * phase - let scale = 0.92 + 0.16 * phase - BoltCanvas(alpha: alpha, scale: scale) - } - } + TimelineView(.animation(minimumInterval: 1.0 / 60.0)) { context in + let frame = frame(at: context.date) + let scale = 1.0 + 0.10 * frame.sine + let dy = -0.5 * frame.sine - /// 600 ms cycle, sin-eased so it accelerates into both extrema — - /// close enough to Android's reverse-repeating FastOutSlowInEasing tween - /// that it reads as the same animation. - private func currentPhase(at date: Date) -> CGFloat { - let elapsed = date.timeIntervalSince(start) - let twoPi = 2 * Double.pi - let raw = sin(elapsed / 0.6 * twoPi - .pi / 2) - return CGFloat((raw + 1) / 2) + image + .resizable() + .scaledToFit() + .foregroundStyle(.white) + // Halo radii reduced from the original 1.5 / 4-7 / 8-14pt + // spec — at action-bar size the original outer glow was + // wider than the bolt itself and read as a hot amber + // smear. Inner halo bumped back up to 2.0pt so the bolt + // keeps a visible warm border at rest (without it, the + // glyph reads smaller on iOS than on Android even though + // the asset is the same size). See + // /Users/daniel/GitHub/wisp/ZAP_BOLT_PULSE_NOTES.md for + // the rationale. + .shadow(color: Color.wispZapColor.opacity(0.95), radius: 2.0) + .shadow(color: Color.wispZapColor.opacity(0.55 + 0.45 * frame.phase), + radius: 3 + 2 * frame.phase) + .shadow(color: Color.wispZapColor.opacity(0.3 + 0.5 * frame.phase), + radius: 5 + 3 * frame.phase) + .scaleEffect(scale) + .offset(y: dy) + } } -} -private struct BoltCanvas: View { - let alpha: CGFloat - let scale: CGFloat + // MARK: - Frame math - var body: some View { - Canvas { ctx, size in - let path = boltPath(in: size, scale: scale) - let zap = Color.wispZapColor - - // 1. Soft outer glow — wide round-capped stroke. Stroke width - // scales with view size to keep the glow proportional on every - // frame (matches `w * 0.14` on Android). - ctx.stroke( - path, - with: .color(zap.opacity(alpha * 0.3)), - style: StrokeStyle( - lineWidth: size.width * 0.14, - lineCap: .round, - lineJoin: .round - ) - ) - // 2. Solid bolt fill. - ctx.fill(path, with: .color(zap)) - // 3. White-hot core — semi-transparent white over the fill. - ctx.fill(path, with: .color(.white.opacity(alpha * 0.4))) - } + private struct Frame { + /// -1 … 1, raw sin oscillator. Drives the centered scale + bounce. + let sine: Double + /// 0 … 1, normalised from sine. Drives the one-way shadow growth. + let phase: Double } - private func boltPath(in size: CGSize, scale: CGFloat) -> Path { - let sx = size.width / 55 * scale - let sy = size.height / 94 * scale - let ox = size.width * (1 - scale) / 2 - let oy = size.height * (1 - scale) / 2 - - var p = Path() - p.move(to: CGPoint(x: ox + 35.563 * sx, y: oy)) - p.addLine(to: CGPoint(x: ox + 35.563 * sx, y: oy + 40.406 * sy)) - p.addLine(to: CGPoint(x: ox + 54.969 * sx, y: oy + 40.406 * sy)) - p.addLine(to: CGPoint(x: ox + 21.016 * sx, y: oy + 93.75 * sy)) - p.addLine(to: CGPoint(x: ox + 21.016 * sx, y: oy + 51.719 * sy)) - p.addLine(to: CGPoint(x: ox, y: oy + 51.719 * sy)) - p.closeSubpath() - return p + private func frame(at date: Date) -> Frame { + let elapsed = date.timeIntervalSince(start) + let sine = sin(elapsed / 0.9 * 2 * .pi) + let phase = (sine + 1) / 2 + return Frame(sine: sine, phase: phase) } } #Preview { - LightningPulseView() - .frame(width: 60, height: 60) + LightningPulseView(image: Image(systemName: "bolt.fill")) + .frame(width: 28, height: 28) .padding() .background(Color.black) } diff --git a/wisp/DeveloperToolsView.swift b/wisp/DeveloperToolsView.swift new file mode 100644 index 0000000..746134b --- /dev/null +++ b/wisp/DeveloperToolsView.swift @@ -0,0 +1,36 @@ +#if DEBUG +import SwiftUI + +/// Debug-only developer playground. Wired into `InterfaceSettingsView` +/// under a `#if DEBUG` row so it ships nowhere near a release build. +/// Pin throwaway experiments here — animation styles, prototype layouts, +/// single-shot repros — instead of building a temporary entry in +/// production code. +struct DeveloperToolsView: View { + @Environment(\.theme) private var theme + @Environment(\.dismiss) private var dismiss + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 28) { + Text("Nothing live right now. Add experimental sections here.") + .font(.subheadline) + .foregroundStyle(theme.palette.onSurfaceVariant) + } + .padding(20) + } + .background(theme.palette.background.ignoresSafeArea()) + .navigationTitle("Developer") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close", action: dismiss.callAsFunction) + } + } + } +} + +#Preview { + NavigationStack { DeveloperToolsView() } +} +#endif From c80ca43940a3955f2030adc501497dc5c11dc7c3 Mon Sep 17 00:00:00 2001 From: dmnyc Date: Thu, 21 May 2026 22:13:57 -0400 Subject: [PATCH 08/13] fix(zap): instant-zap haptics + faster long-press recognition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three problems on the long-press-to-instant-zap path, fixed together: 1. No felt haptic on touch-down. The prior `.simultaneousGesture( DragGesture(minimumDistance: 0))` pattern was being swallowed by SwiftUI's gesture arbitration when composed with the `Button`'s internal tap recognizer, so the touch-down `blip()` never fired. Restructured the zap button as a plain `ZStack` with explicit `.onTapGesture` + `.onLongPressGesture(...onPressingChanged:)`, which is the public SwiftUI API specifically designed for a touch-down callback alongside a long-press action. 2. Even after the gesture path was right, the touch-down + long-press-commit haptics still didn't fire on the test device while the network-side `zapBuzz` (CoreHaptics) did. The `UIImpactFeedbackGenerator`-based `pulse()` and `bump()` helpers go silent on devices where Settings → Sounds & Haptics → System Haptics is off, while `CHHapticEngine`-driven patterns play either way. Added two new CoreHaptics-backed helpers — `zapPressTap()` and `zapCommitThump()` — that share the same engine path as `zapBuzz`, with UIImpactFeedbackGenerator fallbacks for devices without CoreHaptics support. 3. The 0.4s long-press minimum duration read as a stall — the press felt dead until recognition. Dropped to 0.25s, which still reliably distinguishes from a quick tap (typical taps are well under 150ms) but cuts 150ms off the perceived zap kickoff. Net effect on press flow: t=0 finger lands → zapPressTap (medium sharpness) t=250 long-press recognised → zapCommitThump (heavy sharpness) + fireQuickZap + in-flight bolt pulse t=net zap success → zapBuzz + success burst --- Haptics.swift | 76 +++++++++++++++++++++++++++++++++++ PostCardView.swift | 99 ++++++++++++++++++++++++++++++---------------- 2 files changed, 140 insertions(+), 35 deletions(-) diff --git a/Haptics.swift b/Haptics.swift index e7feab1..67987c3 100644 --- a/Haptics.swift +++ b/Haptics.swift @@ -18,6 +18,7 @@ final class Haptics { #if canImport(UIKit) && !os(tvOS) private let lightGen = UIImpactFeedbackGenerator(style: .light) private let mediumGen = UIImpactFeedbackGenerator(style: .medium) + private let heavyGen = UIImpactFeedbackGenerator(style: .heavy) private let successGen = UINotificationFeedbackGenerator() #endif @@ -41,6 +42,81 @@ final class Haptics { #endif } + /// Heavy single thump — the most prominent single-impact haptic we offer. + /// Distinct from `pulse` (medium / 0.6) when both fire close together; + /// used at the moment an instant zap kicks off so the user can feel the + /// gesture *commit*, not just be acknowledged. + func bump() { + #if canImport(UIKit) && !os(tvOS) + heavyGen.prepare() + heavyGen.impactOccurred(intensity: 1.0) + #endif + } + + /// CoreHaptics-backed short tap for touch-down acknowledgement on the + /// zap button. Uses the same `CHHapticEngine` path as `zapBuzz` — + /// `UIImpactFeedbackGenerator` can sit silent on devices where the + /// Settings → Sounds & Haptics → System Haptics toggle is off, but + /// CoreHaptics-driven patterns play either way. + func zapPressTap() { + #if canImport(CoreHaptics) && !os(tvOS) + playCoreHapticTap(intensity: 0.55, sharpness: 0.7) { [weak self] in + #if canImport(UIKit) + self?.mediumGen.prepare() + self?.mediumGen.impactOccurred(intensity: 0.6) + #endif + } + #endif + } + + /// CoreHaptics-backed strong tap for the moment a long-press commits + /// to an instant zap. Sharper than `zapPressTap` so the two feel + /// distinct when they fire ~250 ms apart. + func zapCommitThump() { + #if canImport(CoreHaptics) && !os(tvOS) + playCoreHapticTap(intensity: 1.0, sharpness: 0.95) { [weak self] in + #if canImport(UIKit) + self?.heavyGen.prepare() + self?.heavyGen.impactOccurred(intensity: 1.0) + #endif + } + #endif + } + + #if canImport(CoreHaptics) && !os(tvOS) + /// Shared transient-event player for the zap press / commit taps. + /// Falls through to `fallback` (a `UIImpactFeedbackGenerator` call) + /// on devices without CoreHaptics support OR when the engine errors, + /// so we never end up silent regardless of hardware path. + private func playCoreHapticTap( + intensity: Float, + sharpness: Float, + fallback: () -> Void + ) { + guard supportsCoreHaptics else { + fallback() + return + } + do { + try ensureEngineRunning() + let event = CHHapticEvent( + eventType: .hapticTransient, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: intensity), + CHHapticEventParameter(parameterID: .hapticSharpness, value: sharpness) + ], + relativeTime: 0 + ) + let pattern = try CHHapticPattern(events: [event], parameters: []) + let player = try engine?.makePlayer(with: pattern) + try player?.start(atTime: CHHapticTimeImmediate) + } catch { + NSLog("[Haptics] zap-press tap failed: %@", String(describing: error)) + fallback() + } + } + #endif + /// Discrete "operation succeeded" tap — softer than `pulse` so it reads /// as a confirmation, not a separate action. Used after follow / unfollow /// publishes settle. diff --git a/PostCardView.swift b/PostCardView.swift index 1d33379..c7e6cd7 100644 --- a/PostCardView.swift +++ b/PostCardView.swift @@ -816,55 +816,84 @@ struct PostCardView: View { let isFlying = zapStore.inFlight.contains(eventId) let isBursting = zapStore.bursting.contains(eventId) let isOwnPost = (myPubkey != nil) && (myPubkey == resolveRepost().event.pubkey) - return Button { - // Tap always opens the composer. Long-press fires the - // configured instant-zap amount (when the user has opted in - // AND a wallet is set up). This matches the standard iOS - // pattern of "tap to inspect, long-press to act" and prevents - // an accidental finger from auto-zapping. + let isInteractive = !isFlying && !isOwnPost + return ZStack { + if isFlying { + LightningPulseView(image: settings.zapImage) + .frame(width: 18, height: 18) + .frame(height: 28) + } else { + actionItem( + image: settings.zapImage, + label: zapLabel(repoBox.counts.zapSats > 0 ? repoBox.counts.zapSats : (engagement?.zapSats ?? 0)), + tint: iZapped ? Color.wispZapColor : nil + ) + } + } + // Dim on the user's own posts — self-zapping is a no-op that just + // round-trips sats minus routing fees. + .opacity(isOwnPost ? 0.35 : 1) + .contentShape(Rectangle()) + // Tap = open composer. Recorded behind the `zapLongPressFired` + // guard so the same touch sequence that fires an instant zap + // doesn't ALSO open the composer on finger-lift — SwiftUI fires + // both gestures by default when they're applied as siblings. + .onTapGesture { + guard isInteractive else { return } if zapLongPressFired { zapLongPressFired = false return } triggerZapOrWalletSetup() - } label: { - ZStack { - if isFlying { - LightningPulseView(image: settings.zapImage) - .frame(width: 18, height: 18) - .frame(height: 28) - } else { - actionItem( - image: settings.zapImage, - label: zapLabel(repoBox.counts.zapSats > 0 ? repoBox.counts.zapSats : (engagement?.zapSats ?? 0)), - tint: iZapped ? Color.wispZapColor : nil - ) - } - } } - .buttonStyle(.plain) - // Disable + dim on the user's own posts — self-zapping is a - // no-op that just round-trips sats minus routing fees. - .disabled(isFlying || isOwnPost) - .opacity(isOwnPost ? 0.35 : 1) - .simultaneousGesture( - // Long-press fires the configured instant zap when the user - // has opted in and a wallet is set up. Without those, fall - // through to the composer — long-press shouldn't feel like - // a no-op for users who haven't enabled instant zaps yet. - // Skip entirely on the user's own posts so the dimmed state - // is truly inert. - LongPressGesture(minimumDuration: 0.4).onEnded { _ in - guard !isOwnPost else { return } + // Long-press = instant zap (if opted-in + wallet set up). Using + // `.onLongPressGesture(...onPressingChanged:)` instead of + // `.simultaneousGesture(LongPressGesture)` because the modifier + // form is the only public SwiftUI API that exposes a touch-down + // callback — `onPressingChanged(true)` fires the instant the + // finger lands, which is where the medium "I see your press" + // haptic plays. The prior `DragGesture(minimumDistance: 0)` + // simultaneous-gesture pattern was getting swallowed when + // composed with the Button's internal tap recognizer, so the + // touch-down haptic never fired at all. Dropping the Button + // (this view is now a plain ZStack with explicit tap + + // long-press gestures) avoids that arbitration entirely. + // + // 0.25 s recognition window is short enough that the press + // doesn't feel stalled but still long enough to clearly + // distinguish from a quick tap. + .onLongPressGesture( + minimumDuration: 0.25, + maximumDistance: 50, + perform: { + guard isInteractive else { return } zapLongPressFired = true - Haptics.shared.blip() if settings.quickZapEnabled, let store = walletStore, store.mode != nil, let amount = resolvedInstantZapSats() { + // Sharp CoreHaptics tap at the moment of instant-zap + // commit. CoreHaptics is used (not the UIKit + // UIImpactFeedbackGenerator) because the device may + // have System Haptics disabled in Settings → Sounds + // & Haptics, which silences UIFeedbackGenerator but + // not CHHapticEngine. Mirrors the same engine path + // that the success-side `zapBuzz` already uses. + Haptics.shared.zapCommitThump() fireQuickZap(amountSats: amount) } else { + // Composer fall-through: light recognised-tap + // feedback is enough because the sheet rising is + // its own unmistakable visual confirmation. + Haptics.shared.blip() triggerZapOrWalletSetup() } + }, + onPressingChanged: { pressing in + guard pressing, isInteractive else { return } + // CoreHaptics-backed short tap. See `zapCommitThump` + // comment for why we route through CHHapticEngine + // instead of UIImpactFeedbackGenerator. + Haptics.shared.zapPressTap() } ) .overlay(alignment: .center) { From b762655539c7d1ca49a1eafaefcec3c441c5413a Mon Sep 17 00:00:00 2001 From: dmnyc Date: Thu, 21 May 2026 22:14:05 -0400 Subject: [PATCH 09/13] fix(wallet-settings): unify Switch wallet button across NWC and Spark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wallet settings danger row used a different button visual + label for NWC vs the Spark default wallet: NWC: xmark.circle "Disconnect wallet" Spark default: arrow.triangle.swap "Switch to a different wallet" Conceptually both actions do the same thing — they unbind the current wallet so a different one can be connected. The visual mismatch made the two screens read as different features. Standardised on the Spark visual (arrow.triangle.swap icon + "Switch to a different wallet" label) for both providers, and aligned the section header + alert title + footer wording to use "Switch" instead of "Disconnect". The non-default Spark variant (which actually deletes the on-device wallet rather than just disconnecting) keeps its own trash icon + "Delete Wallet" header — that path is a different operation and should look it. --- wisp/WalletSettingsView.swift | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/wisp/WalletSettingsView.swift b/wisp/WalletSettingsView.swift index dc076ef..45601d3 100644 --- a/wisp/WalletSettingsView.swift +++ b/wisp/WalletSettingsView.swift @@ -102,14 +102,14 @@ struct WalletSettingsView: View { .background(Color.wispBackground.ignoresSafeArea()) .navigationTitle("Wallet Settings") .navigationBarTitleDisplayMode(.inline) - .alert("Disconnect wallet?", isPresented: $showDisconnectAlert) { - Button("Disconnect", role: .destructive) { + .alert("Switch to a different wallet?", isPresented: $showDisconnectAlert) { + Button("Switch", role: .destructive) { store.resetToNoWallet() dismiss() } Button("Cancel", role: .cancel) {} } message: { - Text("Your NWC connection will be removed. You can reconnect at any time.") + Text("Your NWC connection will be removed. You can reconnect a different wallet at any time.") } .alert("Delete wallet?", isPresented: $showDeleteAlert) { Button("Delete", role: .destructive) { @@ -586,22 +586,28 @@ struct WalletSettingsView: View { private var dangerSection: some View { VStack(alignment: .leading, spacing: 8) { - Text("Disconnect Wallet") + Text(dangerSectionHeader) .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) .padding(.horizontal, 4) VStack(spacing: 0) { if store.mode == .nwc { + // Matches the Breez/Spark "Switch to a different + // wallet" affordance below — same icon, label, and + // layout — so the wallet-settings danger row reads + // identically across wallet providers. The underlying + // alert still confirms the NWC-specific behavior + // (removing the connection string). Button { showDisconnectAlert = true } label: { HStack(spacing: 12) { - Image(systemName: "xmark.circle") + Image(systemName: "arrow.triangle.swap") .font(.system(size: 15)) .foregroundStyle(.red) .frame(width: 22) - Text("Disconnect wallet") + Text("Switch to a different wallet") .font(.subheadline) .foregroundStyle(.red) Spacer() @@ -660,9 +666,16 @@ struct WalletSettingsView: View { } } + private var dangerSectionHeader: String { + if store.mode == .nwc || store.isDefaultWallet { + return "Switch Wallet" + } + return "Delete Wallet" + } + private var dangerSectionFooter: String { if store.mode == .nwc { - return "Disconnecting removes the NWC connection string. Your wallet provider is unaffected." + return "Switching removes the NWC connection string. Your wallet provider is unaffected." } if store.isDefaultWallet { return "Your default wallet is linked to your key and can always be restored. Switching connects a different wallet instead." From ac21a099020fa63c54a4bc1abccd227bffad2bb1 Mon Sep 17 00:00:00 2001 From: dmnyc Date: Thu, 21 May 2026 22:14:14 -0400 Subject: [PATCH 10/13] fix(zap-presets): swipe-to-delete only (drop EditButton) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EditPresetsSheet's toolbar exposed an EditButton that, when toggled, showed the standard iOS red minus-circle delete affordance next to each row. The minus circles sat right under the amount TextField and were too easy to tap accidentally while editing, deleting a preset the user was still working on. Dropped the EditButton entirely. The standard iOS swipe-left-to-delete gesture (which `.onDelete { ... }` already provides on a List) is the right destructive UX here — it requires a deliberate swipe and then a Delete-button confirm, so accidental deletions go away. Replaced the toolbar slot with a plain Cancel button so the back-out path stays discoverable. Removed the now-dead `.onMove` modifier — move handles only appear in edit mode, and there's no longer a way into edit mode. --- ZapSheet.swift | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/ZapSheet.swift b/ZapSheet.swift index 46870d0..6f5ec9a 100644 --- a/ZapSheet.swift +++ b/ZapSheet.swift @@ -758,7 +758,8 @@ private struct EditPresetsSheet: View { /// Editable preset draft. `message` is optional — empty means no default /// message is associated with this preset. Each draft has its own - /// identity so SwiftUI can keep TextField cursors stable across moves. + /// identity so SwiftUI can keep TextField cursors stable across + /// swipe-to-deletes of other rows. private struct Draft: Identifiable { let id = UUID() var amount: String @@ -776,6 +777,12 @@ private struct EditPresetsSheet: View { var body: some View { NavigationStack { List { + // Swipe-left-to-delete a row. We deliberately do NOT expose + // an `EditButton()` here — the red minus circles iOS shows + // in edit mode sit right under the amount TextField and + // were too easy to tap accidentally while editing. Standard + // iOS swipe (reveal Delete on the trailing edge → tap to + // confirm) is the right destructive UX for this list. ForEach($drafts) { $draft in HStack(spacing: 8) { TextField("Amount (sats)", text: $draft.amount) @@ -785,7 +792,6 @@ private struct EditPresetsSheet: View { TextField("Message (optional)", text: $draft.message) } } - .onMove { from, to in drafts.move(fromOffsets: from, toOffset: to) } .onDelete { drafts.remove(atOffsets: $0) } Button { @@ -799,7 +805,9 @@ private struct EditPresetsSheet: View { .navigationTitle("Edit Presets") .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement: .cancellationAction) { EditButton() } + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } ToolbarItem(placement: .confirmationAction) { Button("Done") { let encoded: [String] = drafts.compactMap { d in From bc82581075890881622d1f483eab6a63f62111d2 Mon Sep 17 00:00:00 2001 From: dmnyc Date: Fri, 22 May 2026 13:12:19 -0400 Subject: [PATCH 11/13] fix(zap-sheet): stop keyboard pumping in/out on auto-focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.scrollDismissesKeyboard(.interactively)` and the on-appear `amountFocused = true` formed a keyboard fight: the focus raised the keyboard, the keyboard rising nudged the ScrollView's position, the interactive dismiss mode read that nudge as a partial dismiss gesture and pulled the keyboard down, `@FocusState` then re-raised it because the field was still focused — and the cycle continued until the user managed to interact. From outside it read as the zap sheet "infinite looping," even though the sheet itself stayed mounted the whole time (only the keyboard pumped). `.immediately` keeps the drag-down sheet dismiss working (that's a sheet gesture, not a scroll one) but stops interpreting the auto-scroll nudge from a keyboard appearance as a partial dismiss. The keyboard now rises once on appear and stays until the user scrolls or dismisses the sheet. --- ZapSheet.swift | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/ZapSheet.swift b/ZapSheet.swift index 6f5ec9a..49587c9 100644 --- a/ZapSheet.swift +++ b/ZapSheet.swift @@ -224,12 +224,6 @@ struct ZapSheet: View { // because bottomBar is the bottom-most child it ends up // sitting just above the keyboard rather than scrolling off // with the form content. - // - // `.scrollDismissesKeyboard(.interactively)` keeps the - // sheet-drag → keyboard-collapse coupling that previously - // lived on the all-in-one ScrollView, so dragging the sheet - // down still drops the keyboard mid-drag and the rows move - // with the sheet as one unit instead of floating loose. VStack(spacing: 0) { ScrollView { VStack(spacing: 16) { @@ -260,7 +254,20 @@ struct ZapSheet: View { .padding(.top, 8) .padding(.bottom, 8) } - .scrollDismissesKeyboard(.interactively) + // `.immediately` not `.interactively`. With + // `.interactively`, the keyboard moves with the scroll + // position — but the keyboard rising in response to + // `amountFocused = true` on appear nudges the scroll + // position too, which the interactive mode reads as a + // partial-dismiss gesture, and a moment later + // `@FocusState` re-raises the keyboard. That fight + // produces a perceived "infinite loop" — the keyboard + // pumping in and out repeatedly with the sheet still + // mounted. `.immediately` only dismisses on an explicit + // scroll gesture, which is what we actually want — the + // drag-down sheet dismiss is a sheet gesture, not a + // scroll one, and still works. + .scrollDismissesKeyboard(.immediately) .scrollBounceBehavior(.basedOnSize) bottomBar From 35c48ed1fe548f216ed7e0bf5aeee98a167d467a Mon Sep 17 00:00:00 2001 From: dmnyc Date: Mon, 25 May 2026 00:30:15 -0400 Subject: [PATCH 12/13] fix(zap): remove scroll-contact haptic on zap button onPressingChanged fired zapPressTap on any touch-down, including accidental contact while scrolling. Haptics on committed actions (long-press instant zap, long-press composer) are unchanged. --- PostCardView.swift | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/PostCardView.swift b/PostCardView.swift index c7e6cd7..cbb6a0d 100644 --- a/PostCardView.swift +++ b/PostCardView.swift @@ -844,6 +844,7 @@ struct PostCardView: View { zapLongPressFired = false return } + Haptics.shared.blip() triggerZapOrWalletSetup() } // Long-press = instant zap (if opted-in + wallet set up). Using @@ -888,13 +889,7 @@ struct PostCardView: View { triggerZapOrWalletSetup() } }, - onPressingChanged: { pressing in - guard pressing, isInteractive else { return } - // CoreHaptics-backed short tap. See `zapCommitThump` - // comment for why we route through CHHapticEngine - // instead of UIImpactFeedbackGenerator. - Haptics.shared.zapPressTap() - } + onPressingChanged: { _ in } ) .overlay(alignment: .center) { ZapBurstView(isActive: isBursting) From 9216dadae0a60bf37d1229ad17134ec1c2a85884 Mon Sep 17 00:00:00 2001 From: dmnyc Date: Mon, 25 May 2026 09:22:45 -0400 Subject: [PATCH 13/13] fix(settings): scope instant-zap defaults to active account Each account now reads and writes its instant-zap settings (enabled, amount in sats/fiat, message) from a per-pubkey UserDefaults key, so switching accounts no longer inherits the previous account's values. Fresh accounts default to 21 sats / 0.10 fiat / disabled / no message. ContentView reloads the per-account values on every keypair change so the correct settings are in place before the user reaches the timeline. --- AppSettings.swift | 53 +++++++++++++++++++++++++++++++----------- wisp/ContentView.swift | 5 ++++ 2 files changed, 45 insertions(+), 13 deletions(-) diff --git a/AppSettings.swift b/AppSettings.swift index a353d78..b537891 100644 --- a/AppSettings.swift +++ b/AppSettings.swift @@ -43,10 +43,18 @@ final class AppSettings { static let zapIconStyle = "wisp_settings_zap_icon_style" static let videoLoop = "wisp_settings_video_loop" static let syncSettingsToRelays = "wisp_settings_sync_settings_to_relays" - static let quickZapEnabled = "wisp_settings_quick_zap_enabled" - static let quickZapAmountSats = "wisp_settings_quick_zap_amount_sats" - static let quickZapAmountFiat = "wisp_settings_quick_zap_amount_fiat" - static let quickZapMessage = "wisp_settings_quick_zap_message" + static func quickZapEnabled(for pubkey: String?) -> String { + pubkey.map { "wisp_settings_quick_zap_enabled_\($0)" } ?? "wisp_settings_quick_zap_enabled" + } + static func quickZapAmountSats(for pubkey: String?) -> String { + pubkey.map { "wisp_settings_quick_zap_amount_sats_\($0)" } ?? "wisp_settings_quick_zap_amount_sats" + } + static func quickZapAmountFiat(for pubkey: String?) -> String { + pubkey.map { "wisp_settings_quick_zap_amount_fiat_\($0)" } ?? "wisp_settings_quick_zap_amount_fiat" + } + static func quickZapMessage(for pubkey: String?) -> String { + pubkey.map { "wisp_settings_quick_zap_message_\($0)" } ?? "wisp_settings_quick_zap_message" + } } /// Allowed durations for the post-undo countdown. Picker shows these as @@ -175,14 +183,16 @@ final class AppSettings { /// (tap → composer) is preserved unless the user opts in. var quickZapEnabled: Bool { didSet { - UserDefaults.standard.set(quickZapEnabled, forKey: Keys.quickZapEnabled) + let pk = NostrKey.load()?.pubkey + UserDefaults.standard.set(quickZapEnabled, forKey: Keys.quickZapEnabled(for: pk)) EmojiRepository.shared.scheduleSettingsSync() } } /// Instant-zap amount in sats, used when `fiatModeEnabled` is false. var quickZapAmountSats: Int64 { didSet { - UserDefaults.standard.set(quickZapAmountSats, forKey: Keys.quickZapAmountSats) + let pk = NostrKey.load()?.pubkey + UserDefaults.standard.set(quickZapAmountSats, forKey: Keys.quickZapAmountSats(for: pk)) EmojiRepository.shared.scheduleSettingsSync() } } @@ -191,7 +201,8 @@ final class AppSettings { /// `ExchangeRateCache.fiatToSats`. var quickZapAmountFiat: Double { didSet { - UserDefaults.standard.set(quickZapAmountFiat, forKey: Keys.quickZapAmountFiat) + let pk = NostrKey.load()?.pubkey + UserDefaults.standard.set(quickZapAmountFiat, forKey: Keys.quickZapAmountFiat(for: pk)) EmojiRepository.shared.scheduleSettingsSync() } } @@ -200,7 +211,8 @@ final class AppSettings { /// as the composer's blank state would produce. Persisted + synced. var quickZapMessage: String { didSet { - UserDefaults.standard.set(quickZapMessage, forKey: Keys.quickZapMessage) + let pk = NostrKey.load()?.pubkey + UserDefaults.standard.set(quickZapMessage, forKey: Keys.quickZapMessage(for: pk)) EmojiRepository.shared.scheduleSettingsSync() } } @@ -230,12 +242,27 @@ final class AppSettings { 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 - self.quickZapEnabled = defaults.object(forKey: Keys.quickZapEnabled) as? Bool ?? false - let storedQuickInt = defaults.integer(forKey: Keys.quickZapAmountSats) - self.quickZapAmountSats = storedQuickInt > 0 ? Int64(storedQuickInt) : 100 - let storedQuickFiat = defaults.double(forKey: Keys.quickZapAmountFiat) + let qzPubkey = NostrKey.load()?.pubkey + self.quickZapEnabled = defaults.object(forKey: Keys.quickZapEnabled(for: qzPubkey)) as? Bool ?? false + let storedQuickInt = defaults.integer(forKey: Keys.quickZapAmountSats(for: qzPubkey)) + self.quickZapAmountSats = storedQuickInt > 0 ? Int64(storedQuickInt) : 21 + let storedQuickFiat = defaults.double(forKey: Keys.quickZapAmountFiat(for: qzPubkey)) self.quickZapAmountFiat = storedQuickFiat > 0 ? storedQuickFiat : 0.10 - self.quickZapMessage = defaults.string(forKey: Keys.quickZapMessage) ?? "" + self.quickZapMessage = defaults.string(forKey: Keys.quickZapMessage(for: qzPubkey)) ?? "" + } + + /// Load per-account instant-zap settings from UserDefaults. Falls back to + /// defaults (21 sats / 0.10 fiat / disabled / no message) when no value + /// has been stored for this pubkey yet. Call on every account switch so + /// each account's preferences are isolated. + func loadQuickZapSettings(for pubkey: String) { + let defaults = UserDefaults.standard + quickZapEnabled = defaults.object(forKey: Keys.quickZapEnabled(for: pubkey)) as? Bool ?? false + let storedSats = defaults.integer(forKey: Keys.quickZapAmountSats(for: pubkey)) + quickZapAmountSats = storedSats > 0 ? Int64(storedSats) : 21 + let storedFiat = defaults.double(forKey: Keys.quickZapAmountFiat(for: pubkey)) + quickZapAmountFiat = storedFiat > 0 ? storedFiat : 0.10 + quickZapMessage = defaults.string(forKey: Keys.quickZapMessage(for: pubkey)) ?? "" } /// Apply settings restored from a NIP-78 backup. Only non-default keys diff --git a/wisp/ContentView.swift b/wisp/ContentView.swift index 8a753cc..d0b8de1 100644 --- a/wisp/ContentView.swift +++ b/wisp/ContentView.swift @@ -138,6 +138,11 @@ struct ContentView: View { } } } + .onChange(of: keypair?.pubkey) { _, newPubkey in + if let pk = newPubkey { + AppSettings.shared.loadQuickZapSettings(for: pk) + } + } } }