Skip to content

feat(zap): instant zaps, ZapSheet redesign, per-account presets#159

Closed
dmnyc wants to merge 14 commits into
mainfrom
feat/one-tap-zap
Closed

feat(zap): instant zaps, ZapSheet redesign, per-account presets#159
dmnyc wants to merge 14 commits into
mainfrom
feat/one-tap-zap

Conversation

@dmnyc

@dmnyc dmnyc commented May 21, 2026

Copy link
Copy Markdown
Collaborator

Summary

End-to-end refresh of the zap experience, plus the wallet setup screens that lead into it.

  • NIP-78 cross-device sync of UI prefs — kind-30078 backup carries every Interface setting (appearance, media, posting, currency, zap, quick reactions) so the same setup follows the account across devices. Toggle in a new "Cross-device sync" section.
  • Instant zaps / Instant payments — opt-in toggle in Interface settings. Configured amount denominated in sats (bitcoin mode) or fiat major units (fiat mode), each persisted independently. Settings field clamps at 10,000 sats / equivalent fiat — the same threshold the in-sheet confirmation uses, so an instant zap can never bypass it.
  • Wallet setup screens — Spark renders as a full-bleed orange glowing primary button; Nostr Wallet Connect stays as a peer option. The Spark setup picker drops "Use my default wallet" into the same primary treatment, with Create / Restore seed / Restore relays collapsed under a "More options" disclosure. Pick section vertically centers in the viewport.
  • Wallet settings — unified Switch wallet row — the danger-row button for the NWC variant now matches the Spark default variant: same arrow.triangle.swap icon 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.
  • ZapSheet redesign — compact one-screen layout that fits above the keyboard with no scrolling on the happy path. Recipient row, hero number, wrapping preset strip, message field, privacy dropdown, instant-zaps toggle, full-width Zap button. Form rows scroll inside an inner ScrollView with .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.
  • EditPresetsSheet — swipe-to-delete only — dropped the 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.
  • Auto-focus on appear — keyboard rises 450 ms after mount (past the parent LazyVStack row's layout change), hero seeded from the configured one-tap amount, first keystroke replaces the seed, backspace to empty disables Zap.
  • Per-account presets — preset CSV moves from a single global UserDefaults key to 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.
  • Optional preset messages — preset format extends to <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.
  • Friendly zap errorsZapAnimationStore.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.
  • Soft 10K confirmation + hard 1M cap> 10,000 sats routes through a "Zap N sats? — large amount" confirmation dialog. > 1,000,000 sats disables the Zap button entirely with a red max-amount caption.
  • Self-zap disabled — the zap button on the user's own posts is non-tappable and rendered at 35% opacity. Reposting your own note also disables; reposting someone else's stays active.
  • Tap / long-press swap with three-stage haptic feedback — tap on a post's zap button opens the composer. Long-press fires the configured instant amount (when enabled + wallet set up), falling through to the composer otherwise. The instant-zap path now drives a two-stage haptic sequence: a heavy tap when the long-press commits at 250 ms (zapCommitThump) and the existing zapBuzz on 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 through CHHapticEngine so they fire reliably even on devices with System Haptics disabled in iOS Settings — the prior UIImpactFeedbackGenerator-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.
  • White-core glow pulse — in-flight bolt rendered as an always-white silhouette with three stacked zap-color shadows that breathe with the cycle. Single sin-eased oscillator drives a ±10% scale + ±0.5pt centered bounce. Halo radii tuned down to 2 / 3-5 / 5-8pt (from 1.5 / 4-7 / 8-14pt) so the glow stays tucked around the glyph instead of smearing wider than the bolt itself at action-bar size — matches the Wisp Android-tuned reference. Oscillator timestamp lives in @State so it survives parent re-renders during a zap flight and the 0.9s period stays steady.
  • DEBUG-only developer panel — empty DeveloperToolsView reachable from Interface settings under a #if DEBUG row. 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.md in the Wisp Android repo. A separate Wisp Android PR will land the wallet-settings Switch-wallet button parity.

Test plan

  • Wallet setup mode picker: Spark glows orange + NWC visible below; Spark setup pick screen vertically centers, "Use my default wallet" primary, More options expands.
  • Wallet settings danger row: NWC and Spark-default variants render identical arrow.triangle.swap "Switch to a different wallet" button; non-default Spark still shows trash + "Delete Wallet" header.
  • Open ZapSheet from a feed post: keyboard auto-rises, hero shows configured one-tap amount, first keystroke replaces seed, backspace zeroes Zap.
  • Zap button stays visible above the keyboard while typing a custom amount or composing a message (does not scroll off-screen).
  • Tap a preset → amount updates; if preset has a message, auto-fills the message field (only when empty).
  • Custom + + chip → saves the current amount to this account's preset row.
  • EditPresetsSheet: no Edit button / red minus circles. Swipe-left on a row reveals Delete; Add preset disabled while blank row exists; Done persists per-account; Cancel dismisses without saving.
  • Privacy dropdown cycles Public / Anonymous / Private.
  • Instant zaps toggle on the sheet writes through to the Interface setting.
  • Copy lud16 → local pill drops in from the top of the sheet (single pill).
  • Drag sheet down → keyboard collapses, body translates as one unit (no floating).
  • > 10,000 sats → confirmation dialog. > 1,000,000 sats → Zap disabled + red caption.
  • Insufficient funds → "Not enough sats in your wallet."
  • Settings → Instant zaps → entering > 10,000 sats clamps; fiat field clamps at equivalent.
  • Post card: tap opens composer, long-press fires instant (when configured), self-post zap dimmed and inert.
  • Long-press instant zap: feel three distinct haptics — medium when finger lands, heavy at commit (~250 ms), success buzz when the receipt lands. Holds up even with iOS Settings → Sounds & Haptics → System Haptics toggled off.
  • In-flight bolt: tight warm border around the white core, breathing halo that does not smear wider than the glyph, steady cadence (no stutter when the parent action bar re-renders).
  • Sign in as Account A, set custom presets, switch to Account B — B's row is its own. Switch back to A — A's row intact.
  • Settings → Interface (debug build) → Developer → Developer tools opens (empty placeholder).

dmnyc added 10 commits May 22, 2026 06:01
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.
@dmnyc dmnyc force-pushed the feat/one-tap-zap branch from 12fc1d2 to ac21a09 Compare May 22, 2026 10:01
dmnyc and others added 4 commits May 22, 2026 13:12
`.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
@barrydeen

Copy link
Copy Markdown
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)

@dmnyc

dmnyc commented May 26, 2026

Copy link
Copy Markdown
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.

@dmnyc dmnyc closed this May 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants