feat(zap): instant zaps, ZapSheet redesign, per-account presets#159
Closed
dmnyc wants to merge 14 commits into
Closed
feat(zap): instant zaps, ZapSheet redesign, per-account presets#159dmnyc wants to merge 14 commits into
dmnyc wants to merge 14 commits into
Conversation
5c47075 to
e791b42
Compare
10 tasks
Publishes an addressable kind-30078 event (d-tag 'wisp-app-settings:v1') NIP-44 self-encrypted, carrying the user's non-sensitive UI preferences and quick-reaction state so the same setup follows the account across devices and across the iOS + Android clients. - Nip78Backup.AppSettingsPayload: versioned, all-optional JSON schema. Forward-compatible — fields landing in subsequent PRs (default reaction, quick-zap amount/toggle) are declared in the struct now so later releases only add data, not schema. - AppSettings.syncSettingsToRelays toggle (default on), applyRestored, snapshotForBackup. PR 1 carries zapIconStyle, fiatModeEnabled, fiatCurrency, zapPresetsCSV, plus quickReactions and frequency from EmojiRepository. - EmojiRepository orchestrates publish/restore: refresh() fetches the encrypted blob, decrypts, and merges (frequency max(local, remote); quick-list replaced if local is unmodified defaults, otherwise unioned). scheduleSettingsSync() debounces relay writes by 4 s to coalesce a burst of mutations into one publish. Hooks fire from existing mutators and the zapIconStyle didSet — plus from ZapSheet's preset save paths. - InterfaceSettingsView gets a 'Cross-device sync' section.
Adds AppSettings.quickZapEnabled (default off), plus two independently persisted amounts: quickZapAmountSats (default 21) and quickZapAmountFiat (default 1.00 in fiatCurrency major units). The settings UI surfaces a single section whose label flips with fiat mode — 'Zaps' / 'Instant zaps' in bitcoin mode, 'Payments' / 'Instant payments' in fiat mode — and the amount input switches between sats integer and fiat decimal accordingly. PostCardView's zap button: tap converts the configured amount to sats via ExchangeRateCache.fiatToSats when in fiat mode, then routes through ZapAnimationStore for the in-flight pulse + burst animation. Falls back to the composer sheet when no wallet is set up, the rate cache hasn't loaded (rare), or quick-zap is disabled. Long-press always opens the composer — escape hatch for a different amount, message, or anonymous mode. All three fields (toggle, sats amount, fiat amount) round-trip through the NIP-78 settings backup added in the previous commit so the configuration follows the account across devices, including across the iOS / Android divide once the Android counterpart lands.
Captures the planned compact-layout redesign of the zap sheet (recipient row, amount grid, inline message field, dialog presentation) so the upcoming UI work lands on the same branch as one-tap-zap and the redesign decisions stay alongside the implementation.
Wallet mode picker: * Spark renders as a full-bleed orange filled card with a layered zap-color shadow glow, the rest of the row keeps its dark surface treatment. Reads as one clear recommended action while still leaving Nostr Wallet Connect a peer option below it. Spark setup picker: * "Use my default wallet" gets the same primary treatment — filled orange background, white label + key icon, matching zap-color glow — so the recommended path is the same shape on both screens. * Create new wallet / Restore from seed phrase / Restore from relays move under a "More options" disclosure that rotates the chevron and fades the rows in / out so the screen leads with one obvious next step. * GeometryReader-backed ScrollView lets a leading + trailing Spacer push the pick section toward vertical centre when the content fits the viewport.
Layout rewrite (ZapSheet.swift): * Compact recipient row (avatar + name + lud16 + copy-pill icon) in place of the bordered card. Single-line, no section label. * Hero number scales to 56pt rounded; "sats" caption hidden in fiat mode. Tapping the hero pulls focus to the hidden amount field with a seeded register-style digit string. * Presets wrap via FlowLayout instead of horizontal scroll, so every preset is visible. Custom pill carries an inline "+" badge for save-as-preset; the badge disables at the 8-preset cap. * Message field always visible above the privacy dropdown + Instant zaps toggle, so the whole interaction fits in the visible area above the keyboard. * Bottom bar is a full-width Zap button with the privacy chip moved into its own labeled dropdown row above. Drop the pinned-by-safeAreaInset placement so the bar translates with the rest of the sheet during a drag-down dismiss. * Whole sheet wrapped in ScrollView with .scrollDismissesKeyboard(.interactively) so dragging dismisses the keyboard the moment the drag starts — items no longer appear to float loose from the body while the keyboard avoidance is fighting the drag. * Inline copied-pill overlay replaces the shared SuccessToast so the pill renders only on the sheet (the global store also fires on the MainView overlay behind the sheet, producing two pills). Behaviors: * Auto-focus the amount field on appear (deferred 450ms to let the sheet mount past the LazyVStack row's layout change). * Seed `amountSats` from the configured one-tap default amount (treat as the user's "preferred opening amount" even when instant zaps are disabled). First non-empty keystroke replaces the seed; backspace to empty zeroes amountSats and disables Send. Tapping a preset resets the typed-flag so the field can re-focus cleanly. * Hard cap of 1,000,000 sats: Zap button disables above the cap with a red "Max …" hint above it. * Soft confirmation above 10,000 sats: confirmationDialog asks before firing. * Per-user preset storage keyed by `zapPresetAmounts_<pubkey>`, with a one-time migration from the legacy global key on first read. Per-user backup (AppSettings.swift): * `snapshotForBackup` and `applyRestored` look up the per-pubkey key first, falling back to the legacy global key when no active account is loaded. Keeps NIP-78 sync's "this account's preferences" semantics for the presets row. Instant-zap settings cap (InterfaceSettingsView.swift): * Sats field clamps at min(10_000, max(1, value)). Fiat field clamps to the 10K-sats equivalent via the cached rate so an instant zap can never be configured above the confirmation threshold. Friendly error copy (ZapAnimationStore.swift): * `friendlyMessage(for:)` maps raw SDK strings into plain copy — "Not enough sats in your wallet" for insufficient funds, similar treatment for route-not-found, expired-invoice, timeout, missing-lud16, LNURL 400, and min / max amount cases. Falls back to the substring inside `(...)` when no pattern matches, otherwise the original raw text.
…-zap Zap button behavior on post cards: * Tap always opens the ZapSheet composer — matches the iOS pattern of "tap to inspect, long-press to act" and prevents an accidental finger from auto-firing a configured zap amount. * Long-press fires the configured instant-zap amount when the user has opted in AND a wallet is set up. Without those, the long-press falls through to the composer so the gesture never feels like a no-op. * Self-zaps are disabled — the button is non-tappable and rendered at 35% opacity on the user's own posts. Self-zapping is a no-op round-trip minus routing fees, so suppress it rather than letting the wallet eat the cost.
…anel LightningPulseView rewrite: * Renders the configured zap icon as an always-white silhouette and layers three stacked zap-color shadows behind it: a tight inner always-on shadow gives the silhouette body, a medium ring fades in 55→100% with the cycle peak, and a wide halo blooms 30→80%. The eye reads this as a luminous core brightening + dimming rather than a tinted bolt fading. * Single sin-eased oscillator drives the whole animation. Scale breathes ±10% centered at 1.0; vertical bounce stays at ±0.5pt so the icon doesn't lift off the action bar's baseline. * Dropped the multi-layer fill compositing the prior version did (outer-stroke + fill + white-hot core) — those layers smeared the bolt silhouette at scale peaks and read as distortion. Earlier iteration shipped a `LightningPulseStyle` enum with six variants (halo, shimmer, colorCycle, wobble, bounce, outline) so they could be A/B'd in the dev panel. We picked the white-core glow and stripped the rest; the rejected styles live in git history if we want to revisit. DEBUG-only developer panel: * `DeveloperToolsView` lives at `wisp/DeveloperToolsView.swift` and is presented from a new "Developer" row in Interface settings, the row + sheet binding both wrapped in `#if DEBUG` so neither ships in a release build. Currently empty — scaffolding for future throwaway experiments to land somewhere out of production code instead of building one-off entry points.
Three problems on the long-press-to-instant-zap path, fixed together:
1. No felt haptic on touch-down. The prior `.simultaneousGesture(
DragGesture(minimumDistance: 0))` pattern was being swallowed by
SwiftUI's gesture arbitration when composed with the `Button`'s
internal tap recognizer, so the touch-down `blip()` never fired.
Restructured the zap button as a plain `ZStack` with explicit
`.onTapGesture` + `.onLongPressGesture(...onPressingChanged:)`,
which is the public SwiftUI API specifically designed for a
touch-down callback alongside a long-press action.
2. Even after the gesture path was right, the touch-down +
long-press-commit haptics still didn't fire on the test device
while the network-side `zapBuzz` (CoreHaptics) did. The
`UIImpactFeedbackGenerator`-based `pulse()` and `bump()`
helpers go silent on devices where Settings → Sounds &
Haptics → System Haptics is off, while `CHHapticEngine`-driven
patterns play either way. Added two new CoreHaptics-backed
helpers — `zapPressTap()` and `zapCommitThump()` — that share
the same engine path as `zapBuzz`, with UIImpactFeedbackGenerator
fallbacks for devices without CoreHaptics support.
3. The 0.4s long-press minimum duration read as a stall — the press
felt dead until recognition. Dropped to 0.25s, which still
reliably distinguishes from a quick tap (typical taps are well
under 150ms) but cuts 150ms off the perceived zap kickoff.
Net effect on press flow:
t=0 finger lands → zapPressTap (medium sharpness)
t=250 long-press recognised → zapCommitThump (heavy sharpness)
+ fireQuickZap + in-flight bolt pulse
t=net zap success → zapBuzz + success burst
The wallet settings danger row used a different button visual + label for NWC vs the Spark default wallet: NWC: xmark.circle "Disconnect wallet" Spark default: arrow.triangle.swap "Switch to a different wallet" Conceptually both actions do the same thing — they unbind the current wallet so a different one can be connected. The visual mismatch made the two screens read as different features. Standardised on the Spark visual (arrow.triangle.swap icon + "Switch to a different wallet" label) for both providers, and aligned the section header + alert title + footer wording to use "Switch" instead of "Disconnect". The non-default Spark variant (which actually deletes the on-device wallet rather than just disconnecting) keeps its own trash icon + "Delete Wallet" header — that path is a different operation and should look it.
EditPresetsSheet's toolbar exposed an EditButton that, when toggled,
showed the standard iOS red minus-circle delete affordance next to
each row. The minus circles sat right under the amount TextField and
were too easy to tap accidentally while editing, deleting a preset
the user was still working on.
Dropped the EditButton entirely. The standard iOS swipe-left-to-delete
gesture (which `.onDelete { ... }` already provides on a List) is the
right destructive UX here — it requires a deliberate swipe and then a
Delete-button confirm, so accidental deletions go away. Replaced the
toolbar slot with a plain Cancel button so the back-out path stays
discoverable.
Removed the now-dead `.onMove` modifier — move handles only appear in
edit mode, and there's no longer a way into edit mode.
`.scrollDismissesKeyboard(.interactively)` and the on-appear `amountFocused = true` formed a keyboard fight: the focus raised the keyboard, the keyboard rising nudged the ScrollView's position, the interactive dismiss mode read that nudge as a partial dismiss gesture and pulled the keyboard down, `@FocusState` then re-raised it because the field was still focused — and the cycle continued until the user managed to interact. From outside it read as the zap sheet "infinite looping," even though the sheet itself stayed mounted the whole time (only the keyboard pumped). `.immediately` keeps the drag-down sheet dismiss working (that's a sheet gesture, not a scroll one) but stops interpreting the auto-scroll nudge from a keyboard appearance as a partial dismiss. The keyboard now rises once on appear and stays until the user scrolls or dismisses the sheet.
onPressingChanged fired zapPressTap on any touch-down, including accidental contact while scrolling. Haptics on committed actions (long-press instant zap, long-press composer) are unchanged.
Each account now reads and writes its instant-zap settings (enabled, amount in sats/fiat, message) from a per-pubkey UserDefaults key, so switching accounts no longer inherits the previous account's values. Fresh accounts default to 21 sats / 0.10 fiat / disabled / no message. ContentView reloads the per-account values on every keypair change so the correct settings are in place before the user reaches the timeline.
…tion fix(settings): isolate instant-zap defaults per account
Owner
|
suggest we close this PR and only pull out the instant zap related code without all the relay sync (most of this pr is all relay sync) |
Collaborator
Author
|
Closing in favor of #211 (feat/instant-zaps), which contains only the instant-zap and ZapSheet changes. The NIP-78 relay-sync that made up the bulk of this PR has been removed and is tracked separately at barrydeen/wisp#579. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
End-to-end refresh of the zap experience, plus the wallet setup screens that lead into it.
arrow.triangle.swapicon and "Switch to a different wallet" label, same section header + alert wording. The non-default Spark variant keeps its own "Delete Wallet" treatment since that path actually deletes the on-device wallet rather than just unbinding it..scrollDismissesKeyboard(.interactively)so dragging dismisses the keyboard before the sheet body translates; the Zap button itself lives in a sibling row pinned below the scroll view so it stays in the visible area even with the keyboard up.EditButton()that exposed iOS's red minus-circle affordance; the minus sat right under the amount TextField and was too easy to mis-tap. Standard iOS swipe-left-to-delete (already wired via.onDelete) is the destructive UX here; replaced the toolbar slot with a plain Cancel button.zapPresetAmounts_<pubkey>, with one-time migration. The NIP-78 backup pulls + restores from the active user's slot so each signed-in account has its own preset row.<sats>or<sats>:<message>. Tapping a preset auto-fills the composer's message field (only when empty). EditPresetsSheet adds a Message column. "Add preset" disabled while a blank row already exists.ZapAnimationStore.friendlyMessage(for:)maps raw SDK strings ("BreezSdkSpark.SdkError.SparkError(...)") into plain English: "Not enough sats in your wallet", "Couldn't find a payment route", etc.> 10,000 satsroutes through a "Zap N sats? — large amount" confirmation dialog.> 1,000,000 satsdisables the Zap button entirely with a red max-amount caption.zapCommitThump) and the existingzapBuzzon success. The initial touch-down haptic (zapPressTap) was removed — it fired on accidental scroll contact, producing spurious feedback while scrolling past posts. All three route throughCHHapticEngineso they fire reliably even on devices with System Haptics disabled in iOS Settings — the priorUIImpactFeedbackGenerator-based path went silent in that configuration. Long-press recognition dropped from 0.4 s to 0.25 s so the press no longer feels stalled.@Stateso it survives parent re-renders during a zap flight and the 0.9s period stays steady.DeveloperToolsViewreachable from Interface settings under a#if DEBUGrow. Scaffolding for future throwaway experiments to land out of production code.The GIF picker keyboard-race fix that previously rode here was moved into its own branch (
fix/compose-picker-modals) and bundled with the photo-picker auto-dismiss fix that addresses the same class of issue.The Wisp Android port guide for this branch lives at
ANDROID_PORT_one_tap_zap.mdin the Wisp Android repo. A separate Wisp Android PR will land the wallet-settings Switch-wallet button parity.Test plan
arrow.triangle.swap"Switch to a different wallet" button; non-default Spark still shows trash + "Delete Wallet" header.+chip → saves the current amount to this account's preset row.