diff --git a/AppSettings.swift b/AppSettings.swift index 540a26d..b537891 100644 --- a/AppSettings.swift +++ b/AppSettings.swift @@ -42,6 +42,19 @@ 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" + 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 @@ -51,37 +64,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 +136,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,12 +161,61 @@ 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 { 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 { + 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 { + let pk = NostrKey.load()?.pubkey + UserDefaults.standard.set(quickZapAmountSats, forKey: Keys.quickZapAmountSats(for: pk)) + 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 { + let pk = NostrKey.load()?.pubkey + UserDefaults.standard.set(quickZapAmountFiat, forKey: Keys.quickZapAmountFiat(for: pk)) + 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 { + let pk = NostrKey.load()?.pubkey + UserDefaults.standard.set(quickZapMessage, forKey: Keys.quickZapMessage(for: pk)) + EmojiRepository.shared.scheduleSettingsSync() + } + } private init() { let defaults = UserDefaults.standard @@ -137,6 +241,114 @@ 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 + 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(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 + /// 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 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 + } + if let m = payload.fiatModeEnabled { fiatModeEnabled = m } + if let c = payload.fiatCurrency, !c.isEmpty { fiatCurrency = c } + if let raw = payload.zapPresetsCSV, !raw.isEmpty { + // 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 } + 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 { + // 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, + quickZapAmountFiat: quickZapAmountFiat, + quickZapMessage: quickZapMessage, + 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/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/InterfaceSettingsView.swift b/InterfaceSettingsView.swift index c09ba80..378ae67 100644 --- a/InterfaceSettingsView.swift +++ b/InterfaceSettingsView.swift @@ -9,6 +9,10 @@ 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 + #if DEBUG + @State private var showDeveloperTools = false + #endif var body: some View { @Bindable var settings = settings @@ -161,6 +165,91 @@ 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, 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) + + 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: { 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)) + ) + .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 = min(10_000, 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)) @@ -236,6 +325,41 @@ 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) + } + } + + #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) @@ -243,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() @@ -284,6 +413,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/PostCardView.swift b/PostCardView.swift index 0979567..cbb6a0d 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` @@ -810,26 +815,82 @@ struct PostCardView: View { let eventId = displayEventId let isFlying = zapStore.inFlight.contains(eventId) let isBursting = zapStore.bursting.contains(eventId) - return Button { + let isOwnPost = (myPubkey != nil) && (myPubkey == resolveRepost().event.pubkey) + 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 + } + Haptics.shared.blip() triggerZapOrWalletSetup() - } label: { - ZStack { - if isFlying { - LightningPulseView() - .frame(width: 18, height: 18) - .frame(height: 28) - .foregroundStyle(Color.wispZapColor) + } + // 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 + 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 { - actionItem( - image: settings.zapImage, - label: zapLabel(repoBox.counts.zapSats > 0 ? repoBox.counts.zapSats : (engagement?.zapSats ?? 0)), - tint: iZapped ? Color.wispZapColor : nil - ) + // Composer fall-through: light recognised-tap + // feedback is enough because the sheet rising is + // its own unmistakable visual confirmation. + Haptics.shared.blip() + triggerZapOrWalletSetup() } - } - } - .buttonStyle(.plain) - .disabled(isFlying) + }, + onPressingChanged: { _ in } + ) .overlay(alignment: .center) { ZapBurstView(isActive: isBursting) .frame(width: 160, height: 160) @@ -1145,6 +1206,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 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) { 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. 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,304 +217,524 @@ 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. + 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) + } + // `.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) - // 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: ",") - } 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 @@ -446,42 +762,96 @@ 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 + /// swipe-to-deletes of other rows. + 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) + // 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) + .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) .toolbar { - ToolbarItem(placement: .cancellationAction) { EditButton() } + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } 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() } dismiss() } } } .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) } } 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/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) + } + } } } 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 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."