Adds a "Send on-chain instead" link button at the bottom of
ZapCustomDialog. Tapping it opens OnchainZapSendDialog with the entered
amount and message prefilled, the note as zappedEvent, and the same
split-detection / dust-preview / fee-tier picker that the Reactions
zap chip uses.
This also fixes the participant zap path in nests audio rooms
(ParticipantHostActionsSheet long-press → "Zap") since that menu item
opens ZapCustomDialog under the hood — fixing the custom dialog covers
both entry points transitively.
OnchainZapSendDialog gains a `prefillComment: String = ""` parameter so
the message field carries over from the LN dialog.
Poll-note zaps (FilteredZapAmountChoicePopup) intentionally stay
Lightning-only — the poll_option vote is encoded in the kind:9734 zap
request and counted via kind:9735 receipts; kind:8333 on-chain
receipts have no poll_option analog and the count infrastructure
doesn't index them. This is a protocol design constraint, not a UI
oversight.
Coverage audit summary — every user-facing zap initiation point now
has on-chain support except the poll-voting path:
- ZapReaction (reactions row) ✅
- ZapAmountChoicePopup (popup chips) ✅
- ZapCustomDialog (custom amount + message) ✅ (this commit)
- ReusableZapButton (Zap the Devs + DVM buttons) ✅
- NestActionBar (audio room) ✅
- ParticipantHostActionsSheet (audio room participant) ✅ (transitive)
- ChatMessageCompose / live-activity headers (use ZapReaction) ✅
- FilteredZapAmountChoicePopup (poll votes) ⚠️ LN-only by protocol
ReusableZapButton now passes the user's on-chain zap amount choices to
ZapAmountChoicePopup and renders OnchainZapSendDialog when a chip is
tapped. The dialog receives the release note as the zappedEvent, so
the existing split detection picks up the kind:1 release notes' zap
splits and pays the dev team via one Bitcoin tx with N receipts.
The donation card is the canonical multi-recipient on-chain zap flow —
release notes are tagged with weighted splits across the team, the
sender's own pubkey gets filtered out, and the per-recipient share
preview shows live as the amount is typed.
Other callers of ReusableZapButton (DVM zap buttons, etc.) get the
on-chain row automatically since it's driven by the user's settings;
empty on-chain amounts list hides the row, matching prior behavior.
Audit findings from an independent code review:
- HIGH: When the user zaps their own post (a common flow), every split
that included the post author put the sender on the recipient list,
and OnchainZapBuilder.buildSplit refused the whole tx with "cannot
zap yourself". Fix: new OnchainZapSplitter.prepare() filters the
sender's pubkey out of the splits before they reach the builder.
- HIGH: NIP-57 lets the same pubkey appear in zap-split tags more than
once (additive weights). buildSplit rejected duplicate recipients.
Same prepare() helper merges duplicates by summing weights, in
first-seen order.
- HIGH: The dialog's live preview only showed amounts for recipients
whose share was BELOW dust (because DustRecipientException only
carries belowDust). Fix: parent composable computes shares with a
zero dust threshold for the preview, gating the Send button on a
separate belowDustShares check so the user can see all amounts and
can't tap Send into a guaranteed BUILDING-stage failure.
- MEDIUM: OnchainZapSendResult.Failure didn't carry the ids of
receipts that successfully published before a partial-publish
failure. Added publishedReceiptEventIds: List<HexKey>.
- LOW: useSplits state was keyed by zappedEvent reference; re-emitted
bundles would silently reset the toggle. Now keyed on the event id.
Tests added:
- splitter: prepare() drops sender, merges duplicates, filters
non-positive weights; floating-point weights (0.1 + 0.2) sum exactly
- builder: buildSplit produces N recipient outputs + 1 change at index
N, conserves sats, rejects duplicates and below-dust shares
- sender: sendSplit publishes one receipt per recipient sharing the
txid with correct per-recipient amount; partial-publish failure
carries the broadcast txid and the ids of receipts that did publish
Extends NIP-BC onchain zaps to honor a note's NIP-57 zap-split tags: one
Bitcoin transaction pays every pubkey-based recipient atomically, and
one kind:8333 receipt is published per recipient (each receipt carries
the recipient's pubkey + sat share and shares the same i:<txid>).
quartz / OnchainZapBuilder
- new buildSplit(recipients = listOf(pubkey to sats), ...) produces a
PSBT with one output per recipient + optional change output
- existing build(...) now delegates to buildSplit; coin selection and
change-vs-dust logic are unchanged for the single-recipient path
commons / new OnchainZapSplitter
- distribute(totalSats, splits, dustThreshold) does the weighted
integer-math allocation, dropping the rounding remainder onto the
largest-weight recipient first so the per-recipient sats sum exactly
to totalSats
- throws DustRecipientException if any share lands below dust; the
caller surfaces that as a build-stage failure before the tx is built
- unit tests cover equal weights, fractional weights, remainder
distribution, dust rejection, and input-order preservation
commons / OnchainZapSender.sendSplit
- mirrors send() but takes the precomputed shares, builds via
buildSplit, and publishes N receipts using the same txid; if one
receipt publish fails the broadcast txid + already-published receipt
ids are surfaced in the Failure result
amethyst / Account.sendOnchainZapWithSplits
- thin wrapper that hands off to OnchainZapSender.sendSplit using the
signer's pubkey
amethyst / OnchainZapSendDialog
- detects pubkey-based zap splits on the zappedEvent and, when present,
defaults to split mode: a SplitsRecipientSection renders one row per
recipient with weight % and live per-recipient sats preview
- lnAddress-only splits are filtered out (no pubkey -> no Taproot
address); a short note tells the user how many recipients were
skipped
- the send button label switches to "Send X sats, N ways"; an opt-out
button lets the user fall back to single-recipient mode
- on send: shares are recomputed via OnchainZapSplitter; below-dust
configurations surface as a BUILDING-stage failure before signing
Adds a second row of bitcoin-orange chips to the zap amount popup that
opens the existing OnchainZapSendDialog prefilled with the chosen amount
and the note's author + zappedEvent. The on-chain stack (kind 8333,
OnchainZapSender, Account.sendOnchainZap, OnchainZapSendDialog) already
existed end-to-end; only the entry point from the reactions row and the
on-chain preset amounts were missing.
Settings:
- AccountZapPreferencesInternal: new onchainZapAmountChoices (defaults
10k/50k/250k sats); back-compat via kotlinx.serialization defaults
- AccountSyncedSettings / AccountSettings / Account.updateZapAmounts:
wired through end-to-end alongside the existing Lightning list
- UpdateZapAmountDialog: new "Quick On-chain Zap Amounts" section with
its own chip list and add-amount field; the existing zap-privacy
dropdown stays Lightning-only since on-chain has no zap type
Reactions UI:
- ZapAmountChoicePopup now collects both choice lists and offers an
on-chain row in addition to the Lightning one
- New on-chain callback opens OnchainZapSendDialog with the prefilled
amount; the existing dialog gains a prefillAmountSats parameter and
now reads the on-chain preset list for its own quick-pick chips
- Other zap entry points (ReusableZapButton, NestActionBar,
FilteredZapAmountChoicePopup) are unchanged: on-chain row defaults
off so they keep their existing Lightning-only behavior
Move the chip label, dialog title/body, and confirm button text out of
the composable and into strings.xml under wallet_onchain_public_* so they
can be translated via Crowdin.
Replace the vague "private channels" wording with concrete advice: fund
and drain via non-private accounts (e.g. exchanges), never mix with cold
wallets, and treat the balance as funds anyone with the nsec can spend.
- Extract PaymentTargetsDialog from PaymentButton so the reactions row
can render the same QR/Copy/Pay layout instead of the legacy two-row
M3ActionRow per target.
- PayReaction now subscribes via EventFinderFilterAssemblerSubscription
and observes the PaymentTargetsEvent reactively, so targets actually
load when the wallet icon is tapped.
- Correct the QrCode2 codepoint (U+E00A) and re-subset the bundled
Material Symbols Outlined font so the glyph is no longer a tofu box.
https://claude.ai/code/session_01JtJuvSYMusKiDMWo7N8Aj8
The onchain wallet's Taproot address is derived from the account's Nostr
pubkey, so anyone with the npub can see its balance and transaction
history on-chain. Surface that fact directly on the card with a tappable
"(i) Public" chip that opens a dialog explaining the privacy implication
and recommending private (non-Nostr) channels for funding and draining.
Each target now renders in a single row with the payment method and
address on the left and three trailing icon buttons: QR (opens a dialog
showing the raw address as a QR code), Copy (clipboard + toast), and
Pay (existing payto:// intent). Adds QrCode2 to MaterialSymbols.
https://claude.ai/code/session_01JtJuvSYMusKiDMWo7N8Aj8
srcDirs(vararg) on AGP 9 source-set Resources is deprecated; switch to
the directories MutableSet API. Preserves the original behavior of
including quartz/src/androidDeviceTest/resources in :benchmark's
androidTest java resources (verified bip39.vectors.json shows up in
processBenchmarkAndroidTestJavaRes output).
Converts the last four Groovy build scripts to Kotlin DSL so the entire
build is consistent with the rest of the modules (commons, quartz, etc.).
- settings.gradle -> settings.gradle.kts
- build.gradle (root) -> build.gradle.kts
- amethyst/build.gradle -> amethyst/build.gradle.kts
- benchmark/build.gradle -> benchmark/build.gradle.kts
Notes:
- Switched the per-subproject spotless format from groovyGradle to
kotlinGradle now that the only gradle scripts are .gradle.kts.
- benchmark module: moved targetSdk from defaultConfig to testOptions —
AGP 9 removed targetSdk from library defaultConfig.
- Reimplemented getCurrentBranch() with ProcessBuilder (Groovy's
String.execute() isn't available in Kotlin DSL).
Accounts whose reactionRowItems were persisted before ReactionRowAction.Pay
existed still load a 5-item list, so Pay never appears in Reaction Settings.
Add mergeWithDefaultReactionRowItems mirroring mergeWithDefaultVideoPlayerButtons
and apply it where reaction rows enter the StateFlow (constructor + updateFrom),
appending any default actions missing from the saved list while preserving
the user's existing order and toggles.
Capture the crashing thread's name from the UncaughtExceptionHandler and
surface it in the report (both in the device-info table and at the top of
the stack-trace block), so main-thread crashes can be distinguished from
coroutine-worker crashes at a glance.
The new Tune button inside the zap popup already opens the quick-zap
amounts editor, so the long-press shortcut from the zap button itself
(and from each chip inside the popup) is now redundant. Drop those
gestures and replace them with the custom-amount dialog that used to
live behind double-click:
- ZapReaction's button: long-press now opens ZapCustomDialog;
double-click is gone.
- Each chip in the popup: long-press now opens ZapCustomDialog
(via a new onCustomAmount callback threaded through the popup
overloads).
- NestActionBar's zap button wires onCustomAmount to its existing
wantsToSetCustomZap state.
- ReusableZapButton has no custom-zap dialog, so chip long-press
just dismisses the popup there.
The Tune button used the 40dp reactionBox with a 28dp icon, which was
visibly larger than the surrounding bolt chips. Use a compact 32dp box
with a 18dp icon so it lines up with the bolt glyph inside each chip.
The bolt-icon pill chips and the Tune settings button have different
intrinsic heights, so FlowRow's default top alignment leaves the
settings button visually higher than the chips. Set itemVerticalAlignment
so every item is centered within the row.
Media3's MediaSessionService.onStartCommand calls stopSelfSafely() when
delivered an intent (e.g. MEDIA_BUTTON from a headset) while no playback
is active. stopSelfSafely() invokes startForeground() to detach the
notification before stopping, which Android 12+ rejects when the app is
backgrounded with ForegroundServiceStartNotAllowedException.
Swallow that case in onStartCommand and stopSelf, matching the pattern
already used in NotificationRelayService.
https://claude.ai/code/session_01KTZDxK7zacrhFfqMHqt8dm
Wrap the zap-amount choices in the same elevated card / surfaceVariant
container used by the reactions popup, replace the flat primary-colored
material Buttons with pill-shaped Bitcoin-orange chips that pair a bolt
icon with the amount, and add a Tune action at the end of the row (the
zap equivalent of the AddReaction button in the reactions popup) so the
user can jump straight to editing their quick-zap amounts.
Use LocalConfiguration.current.locales[0] so the date formatter recomposes
when the device locale changes, satisfying the NonObservableLocale lint
check.
The Notifications feed could occasionally render as two stacked
LazyColumns — visible as ghosted text and "only the top one scrolls"
because both LazyColumns share the same `listState`. Reproduced by
double-tapping the bottom-nav Notifications button (each tap calls
`invalidateDataAndSendToTop(true)` → `clear()` + `refreshSuspended()`).
`RenderCardFeed` wrapped its `when (feedState)` in `CrossfadeIfEnabled`
→ `MyCrossfade`. `CardFeedState.Loaded` is a plain class with identity
equality, so back-to-back refreshes that transit through `Empty` or
`Loading` and back to `Loaded` inside the 100 ms animation can leave
two `Loaded` instances pinned in `currentlyVisible`. Each one composes
its own `FeedLoaded { LazyColumn(...) }`, both bound to the same
`LazyListState`, and only the topmost gets scroll input.
Switch directly on `feedState` with no crossfade — the animation was
only 100 ms and barely perceptible. Other CrossfadeIfEnabled callers
are untouched.
The remember(...) call returned Unit, which tripped the
RememberReturnType lint. Use LaunchedEffect, which is the
idiomatic Compose API for keyed side effects.
Two things turned out to be the same bug:
1. New calendar events arriving from relays didn't show up in either
feed without a manual pull-to-refresh. The screen was stuck on
whatever the last full refresh saw.
2. After a top-nav filter switch the feed looked like it was reflecting
"a previous state" — same root cause: filter switch ran a full
refresh from LocalCache, but events that the new subscription
subsequently delivered were dropped on the floor.
AccountFeedContentStates.updateFeedsWith / deleteNotes had calls for
every other feed but neither calendarAppointmentsFeed nor
calendarCollectionsFeed — so LocalCache.live.newEventBundles flowed
past them. Added both calls (and the matching deleteFromFeed entries)
so new appointments/collections insert into the visible feed live.
Also dropped the redundant "Calendar Lists" label above the top-nav
filter spinner on the collections screen — it duplicated the screen
title shown by the navigation chrome.
When loadRelayInfo was invoked from a main-thread Compose coroutine and
the call got cancelled, okhttp's executeAsync cancellation handler
closes the Response on the resuming dispatcher (AndroidUiDispatcher).
Closing the response drains any unread bytes from the SSL socket, which
triggers a blocking read on the main thread and throws
NetworkOnMainThreadException — surfaced to the user as
CompletionHandlerException.
The previous withContext(Dispatchers.IO) was nested inside the .use {}
block, so neither the initial suspension at executeAsync() nor the
implicit response close in the cancellation handler ran on IO. Moving
the dispatcher switch to wrap the entire function ensures the
continuation is dispatched on IO and the cancellation/close path runs
there too.