Wires the Phase A.2 quartz send-side foundation into a working end-to-end
send: build → sign → broadcast → publish kind:8333.
commons/onchain/OnchainZapSender — stateless orchestrator. Loads the
sender's UTXOs, builds the unsigned PSBT via OnchainZapBuilder, signs it
through NostrSigner.signPsbt, finalizes + broadcasts via OnchainBackend,
then publishes the kind:8333 receipt through an injected publish callback.
Per-stage failures surface as OnchainZapSendResult.Failure (with the
broadcast txid preserved when only the receipt publish failed) instead of
throwing; CancellationException always propagates.
Account.sendOnchainZap — thin wrapper binding the account's signer, the
LocalCache.onchainBackend, and signAndComputeBroadcast into the
orchestrator.
amethyst wallet UI:
- OnchainZapSendDialog — collects recipient npub (or a fixed recipient when
launched from a note's zap menu), amount, fee tier (slow/normal/fast from
the backend's fee estimates), and an optional comment; runs the send and
shows progress + success/failure.
- OnchainSection — the Bitcoin card on the wallet screen gains a "Send"
button next to "Copy address" that opens the dialog for a profile zap.
Tests: OnchainZapSenderTest covers the happy path (receipt references the
broadcast txid, recipient, and amount), insufficient-funds failure at the
build stage, broadcast failure (no txid leaked), and publish failure (txid
preserved for retry).
Pending: Phase B (NIP-55 sign_psbt Intent) — external/remote signers still
throw UnsupportedMethodException; note-zap-menu entry point can reuse
OnchainZapSendDialog's recipientPubKey/zappedEvent parameters once wired.
Marks the receive + display phases done and spells out exactly what
Phase A.2 (send-side PSBT foundation) needs before send can ship —
notably the fund-safety-critical BIP-174 codec and BIP-341 tweaked-key
signing path.
OnchainSection now fetches UTXOs for the derived Taproot address from the
configured Esplora backend and displays the sum as the section balance.
Loading / unavailable / error states are surfaced inline. No retry on
failure — the user refreshes by re-entering the screen.
Balance fetch runs in a LaunchedEffect keyed on the derived address (which
is keyed on the account pubkey), so it kicks off once per account view.
Phase C of the onchain zaps plan (amethyst/plans/2026-05-14-onchain-zaps.md).
Plumbs the Quartz receive foundation from Phase A.1 into the Android app so
incoming kind 8333 zaps show up in note totals and the wallet screen.
Subscription / fetch path (kind 8333 rides existing zap filters, no new
assemblers per the plan):
- FilterRepliesAndReactionsToNotes: OnchainZapEvent.KIND in
RepliesAndReactionsKinds (#e on notes)
- FilterUserProfileZapReceived: OnchainZapEvent.KIND in
UserProfileZapReceiverKinds (#p on profiles)
- UserProfileZapsViewModel: kinds = LnZap + Onchain inline
- FilterNotificationsToPubkey: OnchainZapEvent.KIND in SummaryKinds
- NotificationFeedFilter / NotificationDispatcher: kind 8333 in
NOTIFICATION_KINDS
- FilterMessagesToLiveStream / FilterGoalForLiveActivity: kind 8333
alongside 9735
Display path (Note.zapsAmount fold-in — no UI changes needed downstream):
- commons/model/Note.kt: new `onchainZaps: Map<String, OnchainZapAmount>`
(keyed by txid for `(txid, target)` dedup); `addOnchainZap(txid, sats,
confirmed)` mutator; `updateZapTotal` now also sums confirmed onchain
sats into `zapsAmount`. Pending entries are tracked but excluded from
the aggregate total per the NIP-BC spec.
- commons/model/OnchainZapAmount.kt: new data class for per-tx state.
- LocalCache: new `onchainBackend: OnchainBackend?` slot; new
`consume(OnchainZapEvent)` handler that runs the same sig-verify and
computeReplyTo pipeline as `consume(LnZapEvent)`, then kicks off async
verification on `applicationIOScope`. Self-zaps are rejected
synchronously per the spec. Verification failures, mempool-only txs,
and confirmed txs all route to `Note.addOnchainZap` with the correct
flag. Without a backend wired, events are still cached (so subscriptions
see them) but skip the total contribution.
- LocalCache.computeReplyTo: new `is OnchainZapEvent ->` branch that
enumerates `e`/`a` targets — profile-only zaps return an empty list and
flow through profile zap queries instead.
Wallet UI:
- Wallet screen now renders a "Bitcoin" Card above the existing NIP-47
NWC wallet list. Single card (one onchain wallet per Nostr identity,
derived from the pubkey). Shows the bc1p taproot address + Copy button.
Balance, recent incoming zaps, and tap-to-detail come in later phases.
Settings + wiring:
- AccountSettings: new `onchainEsploraEndpoint: MutableStateFlow<String>`,
default mempool.space, plumbed for a future settings UI.
- AppModules: builds a single shared `EsploraBackend` at app init using
the role-based money-flavored OkHttp client (Tor-aware), installs it on
`LocalCache.onchainBackend` so verification can run.
Secp256k1Instance: new `pubKeyTweakAdd` was added in Phase A.1.
No PSBT, no signing, no broadcasting — send-side (Phase A.2 + D) is still
pending.
Phase A.1 of the onchain zaps plan (amethyst/plans/2026-05-14-onchain-zaps.md):
the verify/receive path of NIP-BC. Send-side (PSBT, builder, signPsbt) is the
next phase.
Adds:
- nipBCOnchainZaps/taproot/SegwitAddress: BIP-173 / BIP-350 native segwit
address encoder/decoder, witness v0..v16, on top of the existing Bech32 util.
- nipBCOnchainZaps/taproot/TaprootAddress: BIP-341 key-path-only derivation
from a Nostr pubkey. Computes the TapTweak tagged hash, applies the additive
tweak via Secp256k1Instance.pubKeyTweakAdd, and encodes as bc1p... bech32m.
- Secp256k1Instance: new pubKeyTweakAdd primitive wired through commonMain
expect + jvm / android / native actuals.
- nipBCOnchainZaps/chain: pluggable OnchainBackend interface plus BitcoinTx,
BitcoinTxOutput, Utxo, FeeEstimates data models.
- nipBCOnchainZaps/chain/EsploraBackend (jvmAndroid): OkHttp-based
Esplora-compatible client (mempool.space / blockstream.info) covering
/tx, /address/{addr}/utxo, /tx broadcast, /blocks/tip/height, and
/v1/fees/recommended.
- nipBCOnchainZaps/verify/OnchainZapVerifier: enforces every NIP-BC client
rule -- reject self-zap, fetch tx, derive recipient Taproot scriptPubKey,
sum only outputs paying the recipient (excluding change back to sender),
cap claimed amount at verified amount, mark unconfirmed as Pending.
Tests (commonTest, runs on jvmTest):
- SegwitAddressTest: BIP-350 known-good vectors for v0/v16 + round-trip for
v1 (P2TR).
- TaprootAddressTest: BIP-341 wallet-test-vectors tagged hash + tweaked
output key + scriptPubKey, plus address round-trip and prefix/length
sanity.
- OnchainZapVerifierTest: confirmed / pending / self-zap / missing tx /
zero-verified-amount / multi-output-sum / amount-capping with an
in-memory FakeBackend wired to real TaprootAddress-derived scripts.
Plan: amethyst/plans/2026-05-14-onchain-zaps.md documents the full v1 scope,
the merge into the existing NIP-47 wallet UI, the subscription kind-list
edits, and the Note.zapsAmount fold-in approach for display.
Switch FavoriteAlgoFeedsListScreen from a plain Material Scaffold to
DisappearingScaffold and host an AppBottomBar bound to
Route.EditFavoriteAlgoFeeds, matching the pattern used by every other
tab-root feed (Badges, Articles, Bookmark Groups, ...). Hoist the
LazyColumn state so re-tapping the bar item scrolls to top.
When the user pins this screen as a bottom-bar entry, navigation lands
here via nav.navBottomBar() which marks the entry as a tab root, so
AppBottomBar renders and TopBarWithBackButton hides its back arrow.
When reached as a pushed screen from elsewhere, AppBottomBar's existing
canPop() guard hides the bar and the back arrow stays — preserving the
old behaviour for non-bottom-bar entry points.
The page indicator in the image/video zoom dialog sat too low on
Android devices, getting covered by the gesture bar or 3-button nav.
Apply navigationBarsPadding() to the indicator surface so it floats
above the system bar inset.
The upstream NIP draft for kind:34551 community rules
(nostr-protocol/nips#2331) was renumbered from 9A to 9B per maintainer
feedback that slot 9a is already claimed by the push-notifications
draft (nostr-protocol/nips#2194):
https://github.com/nostr-protocol/nips/pull/2331#issuecomment-4442813289
This commit updates all human-readable references in the merged
community-rules code:
- 7 Kotlin files in quartz (CommunityRulesEvent, CommunityRulesValidator,
5 tag classes) — Kdoc comments
- 13 Kotlin files in amethyst (composer, feed filter, rules editor,
Account, AccountSettings, tests) — code comments + Kdoc
- 1 English strings file (values/strings.xml) — 2 user-facing strings
- 54 translation strings files (values-*-r*/strings.xml) — same two
strings, untranslated "NIP-9A" token replaced with "NIP-9B"
Kind number (34551), schema, tag names, and behaviour are unchanged.
No public API or DTO field renames. Pure docs/strings.
Verified: :quartz:spotlessCheck, :amethyst:spotlessCheck,
:commons:spotlessCheck all clean.
- Wrap the when-branches in SelectableUserList, HiddenWordsList, and
MutedThreadsList in Box(modifier.fillMaxSize()) so the Scaffold
padding is applied to every state — Loading/Error/Empty/Loaded — not
just the LazyColumn. Fixes a regression where the loading spinner
and error UI rendered under the top bar.
- SettingsStepper now clamps `value` once into `[min, max]` and uses
the raw `value` (re-clamped) in the +/- handlers. If the model
starts below `min`, the first tap of `+` resyncs it to `min`
instead of jumping `min+1` (skipping a step). Display still falls
back to `unsetLabel` when `value <= 0`.
- WarnReportsTile no longer pre-coerces threshold to >= 1 at the call
site; the stepper handles it.
- EmptyState drops its now-unused modifier parameter.
- Restore pull-to-refresh on BlockedUsers and SpammingUsers by wrapping
SelectableUserList in RefresheableBox (regression from the first
refactor; HiddenWords and MutedThreads remain non-pull-refresh to
match the original behavior).
- Drop the redundant transientHiddenUsers subscription in
InvalidateOnBlockListChange — hiddenUsers.flow already combines it.
Also drop the meaningless accountViewModel key on LaunchedEffect.
- Move CountBadge and Stepper into SettingsSectionCard as
SettingsCountBadge and SettingsStepper so other settings screens can
reuse them. Widen the stepper value cell to min 40.dp so three-digit
values like 999 fit comfortably.
- Drop the unused `enabled` flag from SettingsControlRow (sub-controls
use SettingsSubControlRow for dimming). Tighten the docstring.
- Unify SwitchTile to take @StringRes Int params, matching the @StringRes
annotations now applied to SettingsItem, SettingsSection, BlockListTopBar,
SelectableUserList, EmptyState.
- Shorten property chains by binding `val security = …syncedSettings.security`
per tile when more than one field is read; pass method references
(accountViewModel::updateFilterSpam etc.) where possible.
- Hoist WarningType.entries to a local val so the segmented button row
reads `count = options.size` instead of evaluating twice.
- MutedThreadsScreen: replace itemsIndexed with items (unused index) and
use MaterialTheme.colorScheme.onPrimary instead of hardcoded Color.White
for the "Unmute" button text.
- Add a @Preview for SecurityFiltersScreen, mirroring AllSettingsScreen.
- Extract SettingsSection/SettingsItem/SettingsDivider/SettingsControlRow/
SettingsBlockTile/SettingsSubControlRow into SettingsSectionCard.kt.
AllSettingsScreen now consumes the shared components instead of carrying
its own private copies, and SecurityFiltersScreen drops the parallel set
it used to declare.
- BlockedContentRow folds into SettingsItem(trailing = { CountBadge(...) }),
removing the bespoke navigation row.
- Replace the Spinner/Switch tile mix with WarnReportsTile = SwitchTile +
SettingsSubControlRow(stepper), avoiding the nullable-icon code smell.
- Move the sensitive-content SegmentedButtonRow out of a cramped trailing
slot and into SettingsBlockTile so it has full-width room.
- HiddenWordsScreen: replace the eager Column.forEach with LazyColumn, and
add Modifier.imePadding() to the Scaffold so the keyboard no longer
covers the Add-word field.
- Move SelectableUserList from BlockedUsersScreen.kt to SecurityListsCommon
and drop the nullable onToggle (both call sites pass one).
- BlockListTopBar now takes selectedCount: Int instead of Set<*>; its
UserFeedState branches are exhaustive (no else -> Unit fallthrough).
- CountBadge reserves min width so 0↔n transitions don't shift the row.
- Stepper relies on Material 3's enabled handling instead of computing
alpha by hand and tinting LocalContentColor in three places.
- Distinct icons per tile/category (Visibility, Code, VisibilityOff, etc.)
so the same MaterialSymbols.Tag isn't reused for unrelated rows.
Replaces the cramped single-screen layout (header settings + four tabs)
with a hub that uses the same grouped-card pattern as AllSettingsScreen,
plus a dedicated screen per blocked-content category. The tile control
mix is also modernized:
- "Show sensitive content" is now a 3-way SegmentedButton instead of a
free-floating spinner sitting awkwardly next to switches.
- Numeric prefs (report threshold, max hashtags) use a stepper (- / + )
instead of free OutlinedTextFields with no commit affordance.
- The report-threshold tile renders inline under its toggle and dims
when the toggle is off, instead of appearing/disappearing and shifting
layout.
- Each tile is a row with leading icon, title, description, trailing
control - so the spinner/switch/stepper visual rhythm stays consistent.
The hub's "Blocked content" section shows a count badge per category and
routes to its own screen (Routes.BlockedUsers, SpammingUsers, HiddenWords,
MutedThreads). The hidden-words screen now docks the "Add word" field as
a bottom bar so it doesn't compete with the list for vertical space, and
selection mode is scoped per-screen instead of shared across tabs.
viewedTab was a MutableState read inside RenderScreen at
visibleTabs.indexOf(viewedTab), so every pager swipe — which writes to
viewedTab from snapshotFlow — invalidated the enclosing restart group
even though rememberPagerState's initialPage is never re-applied after
the first composition.
Move the tracker to a plain holder class (ViewedTabRef). Reads inside
the key(visibleTabs) block no longer subscribe to snapshot changes, so
swipes only recompose the tab row / pager content as intended.
- Gate WatchLifecycleAndUpdateModel(appRecommendations) on the
showProfileAppRecommendations toggle so the viewmodel no longer
refreshes when the section is hidden.
- Thread loadFollowers / loadZapsReceived through UserProfileQueryState
and the matching sub-assemblers. When the user hides those tabs,
UserProfileFilterAssembler stops emitting their relay filters and
the live REQs drop.
- Re-pin the profile pager to the tab the user was viewing whenever
the visible-tabs list changes, using key(visibleTabs) +
rememberPagerState(initialPage = ...) and a snapshotFlow that tracks
the currently-viewed ProfileTab. Prevents the pager from silently
shifting to a different tab when one is toggled off in settings.
New "Profile UI" settings page lets users toggle which profile-screen
sections are loaded and displayed. All toggles default to on.
Toggles:
- Profile Badges (NIP-58 badges row)
- App Recommendations (apps row in profile header)
- Zap Received Feed (received zaps tab)
- Followers Feed (followers tab)
Tabs are filtered from the profile pager so disabled feeds no longer
allocate a page or fire their subscriptions.
Three related bugs when a host removes a speaker from the stage on
nostrnests by editing the kind-30312 to drop their p-tag:
1. NestRoomFilterAssembler didn't subscribe to the kind-30312 itself
while in-room — only chat/presence/reactions (#a) and admin
commands. Once joined, the room event was frozen on whatever
version loaded the screen, so demotions never propagated. Added
a per-relay filter on `kinds=[30312], authors=[host], #d=[dTag]`.
2. Even if the demotion did propagate, `BroadcastHandle` kept
running. Only the manual Leave Stage button called
`stopBroadcast()`. Added a LaunchedEffect that tears down the
broadcast when the local user falls off `participantGrid.onStage`
while still publishing — covers both demote-by-host and
leave-stage-on-another-client.
3. The auto-stop reliably exercised a pre-existing
NestForegroundService bug: when `startListening` was called to
demote from mic+media to media-only, `intent.action == null`
fell through to `else -> promoted`, keeping mic=true. The service
then asked startForeground for FOREGROUND_SERVICE_TYPE_MICROPHONE
after the mic had been released, threw SecurityException on
Android 14+, runCatching swallowed it, stopSelf ran without
startForeground → ForegroundServiceDidNotStartInTimeException.
The explicit-demote branch now returns false; only intent==null
(OS sticky-restart) preserves the prior promoted state.
nostrnests publishes kind-30312 without a self-`p`-tag for the room
author — they're the implicit host. The lobby card already handles
this (NestJoinCard) but the in-room code did not: the author fell
into the pure-audience branch with role=null AND was missing from
the audio subscription set, so the host appeared as a regular
listener and no sound played.
buildParticipantGrid now takes hostPubkey and synthesizes a virtual
`["p", host, "", "host"]` when absent, so the existing presence-aware
onstage/audience rules apply uniformly — including "host leaves
stage" (onstage=0 → drops to audience, role stays HOST).
NestActivityContent owns the grid now and derives onStageKeys from
it, so the StageGrid render and the MoQ subscription set stay in
lockstep when anyone (host or speaker) steps off stage.
Each binary setting (auto-create drafts, AI writing help, tracked
broadcasts) becomes a single-tap Switch instead of a two-tap
Always/Never spinner, and a shared BooleanSwitchRow helper removes the
per-toggle boilerplate.
https://claude.ai/code/session_019b6cF7Ukkv7GL9A3m6bXym
Audit follow-up — the toggle behaviour now lives in a single
`ToggleableTimeAgoText` core in `ui/note/elements/TimeAgo.kt`. `TimeAgo`,
`NormalTimeAgo`, `ChatTimeAgo`, and `ChatroomHeaderCompose.TimeAgo` are
thin wrappers that pick a `TimeAgoStyle` (Dotted / Short) and pass
colour/font params; no per-site duplication of state + clickable +
derivedStateOf.
Performance fixes that matter for a feed with hundreds of timestamps:
- `rememberSaveable` → `remember`. Persisting a transient peek-toggle to
the SavedStateRegistry for every visible+scrolled-past note was pure
memory bloat. Recycling now resets to relative, which is the expected
behaviour for a transient inspect action.
- The relative-mode `derivedStateOf` lambda reads `nowState.value` only
when displaying a relative time. An item the user has frozen to its
absolute date no longer re-evaluates every 30-second tick.
- Core composable takes only stable primitive params (Long, Color,
TextUnit, TextOverflow, enum) so Compose can skip it entirely when
inputs don't change.
- Desktop wrapper dropped the redundant `derivedStateOf`: its formatter
reads no State, so derivedStateOf had nothing to observe.
https://claude.ai/code/session_01AuPon9VQeRfKV1BTVQuKGC
- sendDraftSync no longer short-circuits the delete-on-blank path when
automatic draft creation is disabled. Clearing a composer now always
cleans up any existing draft; the setting only suppresses creation.
- Physically move AiWritingHelpChoice and TrackedBroadcastsChoice from
AppSettingsScreen.kt into ComposeSettingsScreen.kt so the composable
definitions live alongside the screen that hosts them.
https://claude.ai/code/session_019b6cF7Ukkv7GL9A3m6bXym
Every TimeAgo composable (Android NoteCompose timestamp, NormalTimeAgo,
ChatTimeAgo, ChatroomHeaderCompose last-message time) and every Desktop
timestamp Text (NoteCard, NotificationsScreen, ChatPane, ConversationListPane)
is now clickable and toggles to a scale-adjusted absolute date/time:
- same day → time only (e.g. "14:32"), locale-aware
- same year → "MMM dd, HH:mm"
- older → "MMM dd, yyyy"
State is hoisted per-call site via rememberSaveable so the toggle survives
scroll-induced disposal in lazy lists. Desktop call sites share a small
ToggleableTimeAgoText wrapper; Android keeps its existing composable shapes
and just gains a clickable modifier + state.
https://claude.ai/code/session_01AuPon9VQeRfKV1BTVQuKGC
Introduces a new Compose Settings screen that groups composer-related
preferences. Adds a new "Automatically create drafts" toggle that gates
the automatic draft creation triggered on back-press / cancel across
every composer (short notes, public messages, long-form, classifieds,
public channels, private DMs, nests).
Moves "Propose text improvements" and "Tracked broadcasts" out of
Application Preferences and into the new Compose Settings screen.
https://claude.ai/code/session_019b6cF7Ukkv7GL9A3m6bXym
Adds Czech, German, Brazilian Portuguese, and Swedish translations
for the 6 new muted-threads keys introduced with the mute-thread
feature (action_unmute, quick_action_mute_thread,
quick_action_unmute_thread, settings_muted_threads_empty,
settings_muted_threads_title, settings_muted_threads_unknown).
- tighten placeholder dictionary after review
- Skip the placeholder scan when the source has no `{` (avoids Matcher
allocation on the common path).
- Clamp `firstFreeIndex` to PLACEHOLDER_LIMIT so adversarial user text
like `{99999}` can't push our counter past the table limit and make
placeholder() throw for the rest of the note. New JVM test covers it.
- split clamp and max-update in firstFreeIndex
PUA codepoint placeholders (+) were being dropped by ML Kit's
Japanese/Chinese/Korean models, eliding image URLs from translated text
(issue #1180). On-device probing across 8 source languages and 12
placeholder styles shows ICU MessageFormat tokens `{0}`, `{1}`, … are
the only style preserved intact by every model we tested.
build() now scans the source for any user-supplied `{N}` and starts the
dictionary counter above the max, so a note containing `printf("%s", arg{0})`
can't be clobbered by encode/decode. Adds an on-device regression test
with the exact post from the bounty issue and JVM tests for the new
collision-avoidance behaviour.
The previous attempt lit the dot whenever *any* enabled home tab had unread
items, so a user with all three tabs enabled who only scrolled through the
Everything tab still saw the dot — HomeFollows / HomeFollowsReplies stayed at
their old lastRead even though the same content had been read via Everything.
Switch to "every enabled tab still has unread" — equivalently, reaching the
top of any single enabled tab clears the dot. This matches the pre-bug
behavior (where only the New Threads tab gated the dot) while still respecting
users who only enable the Everything tab.
BUD-01 defines a Blossom URL as `<server>/<sha256>[.<ext>]` — the blob hash is
always the last path segment. Walking the path right-to-left for any hex
match was too permissive: a non-Blossom URL like `https://example.com/<sha>/avatar.jpg`
(sha appears in an intermediate segment) would get incorrectly bridged.
Both parsers now look at the last path segment only and skip the URL entirely
when it isn't a sha256. The earlier hex-prefix case (share.yabu.me's
`<cache-prefix>/<blob>.ext`) still works because the blob is still the last
segment; the prefix flows into `xs` via the existing `buildServerBase` /
`extractServerBase` logic. Adds negative tests covering the
sha-in-non-last-segment case in both modules.
Building the URL from extracted prefix/blob constants could mask a parser bug
that splits the path the same way the test constructs it. Use the full URL
from the bug report as a single literal so the test only agrees with the
parser if the parser actually parses the URL correctly.
CDNs like share.yabu.me serve blobs under `<cache-prefix-sha>/<blob-sha>.<ext>`
where both path segments are 64-char hex. The bridge previously locked onto the
first match (the cache prefix), dropping the blob segment from `xs` and asking
the local cache for a non-existent blob. Walking the path right-to-left makes
the rightmost sha — the one that carries the file extension — win, while the
prefix segments stay in `xs` so the cache can fetch upstream on miss.
Behaviour is unchanged when the path has a single sha; tests cover the new
two-hash layout in both the Coil-model path and the OkHttp interceptor path.
Sonar — convert 'if (A) {…} else if (B) {…} else {…}' chains into
'when { A -> …; B -> …; else -> … }' across 33 files (~44 sites).
The change is mechanically equivalent: same conditions, same branch
order, same short-circuit evaluation, no public-signature impact.
The 70-branch event-type dispatch in ThreadFeedView.kt:594 is intentionally
left as if-else for now — the conversion would balloon to a 280-line diff
with no behavioural change, making review impractical.
Sonar — collapse 'if (X) { ...; return A } else { ...; return B }' to
'return if (X) { ...; A } else { ...; B }' across 5 files. Mechanically equivalent.
PlaybackService.lazyPool keeps its in-branch non-local returns from .let{} blocks
(those are early-out cache hits, not tail returns).