Replace the placeholder "N assets bundled" line with a real list of
compact rows for each `e`-tagged Software Asset in the release.
Each row loads the referenced asset event id through
`LoadAssetNote` (uses LocalCache first, falls back to
`checkGetOrCreateNote` for ids never seen) and then
`observeNoteEvent<SoftwareAssetEvent>` — which both observes the
LocalCache flow and registers the note with `EventFinder` so the
relay round-trips the missing asset event. When the asset arrives
the row recomposes with MIME, version, optional variant, size,
platform chips, and a Download link to the asset url.
The standalone `RenderSoftwareAsset` card (kind 3063 in a feed or
thread) is unchanged; this only fills out the release detail view.
Wire NIP-82 Software Applications (kind 32267), Releases (kind 30063)
and Assets (kind 3063) into the Quartz event model and surface them
through a dedicated rendering path in Amethyst.
Quartz: extend the existing experimental NIP-82 builders with topic
(`t`) and NIP-34 app-link (`a`) helpers, and add a small detector
(`isNip82SoftwareRelease`/`asSoftwareRelease`) so kind 30063 events
can be disambiguated from NIP-51 ReleaseArtifactSetEvent at the
renderer layer. Pin behavior with unit tests covering build paths,
disambiguation, and the real-world Amethyst NIP-82 description event.
Amethyst: add modern card visualizations for each kind — application
header with icon/screenshots/platforms/topics/links, release header
with channel pill and bundled-asset count, and asset row with MIME,
size and platforms — and dispatch to them from both `NoteCompose`
and `NoteMaster` (`ThreadFeedView`).
A new "Apps" feed (left nav drawer) mirrors the Picture Feeds shape:
`SoftwareAppsFeedFilter` reads kind 32267 from `LocalCache`, a
`PerUserEoseManager`-backed subscription pulls applications and
releases from outbox relays, and a dedicated screen renders them in
a `LazyColumn` of `RenderSoftwareApplication` cards.
Native-speaker review surfaced consistency and wording fixes:
- Calendar collection naming: use "kalender" for action labels
(New / Edit / Delete) matching the English source's user-facing
simplification; use "kalenderlista" only in the empty-state body
and route header (matches route_calendar_collections =
"Kalenderlistor" and English's "Calendar lists"). Same hybrid as
Czech, pt-BR, and German.
- Capitalize "Nostr" in calendar_share_nostr.
- "on-chain-backend" over "chain-backend" in wallet_onchain_no_backend
(rest of batch already uses the on-chain- compound).
- "blockchainen" (definite form, matches existing ots_info_description)
over bare "blockchain" in wallet_onchain_public_dialog_body.
- Tighten Lightning comparison: "större än för Lightning" over
"större än Lightning" (the bare comparison was elliptical).
- wallet_onchain_public_dialog_body: clearer privacy wording
("fyll på från icke-privata konton och skicka tillbaka medel dit"
over "fyll på och töm från och till"); make antecedent of "spendera"
explicit ("spendera medlen" over ambiguous "spendera dem").
- Symmetric RSVP pair: "Kommer" / "Kanske" / "Kommer inte"
(instead of mixed "Kommer" / "Kanske" / "Kan inte"). Matches the
same fix applied for German and pt-BR.
Note: calendar_rsvp_section / calendar_rsvp_none already use the
established Swedish "OSA" acronym (matching kind_appt_rsvp =
"OSA för möte"), so no rename needed unlike de/pt-BR where the
section noun ("Zusagen"/"Confirmações") had to be replaced.
Native-speaker review surfaced consistency and wording fixes:
- Calendar collection naming: use "Kalender" for action labels
(New / Edit / Delete) matching the English source's user-facing
simplification; use "Kalenderliste" only in the empty-state body
and route header (matches route_calendar_collections =
"Kalenderlisten" and English's "Calendar lists"). Same hybrid as
Czech and pt-BR.
- On-Chain hyphenation: "On-Chain-Backend" over "Chain-Backend"
in wallet_onchain_no_backend; "Stattdessen On-Chain senden" over
"Stattdessen on-chain senden" in send_onchain_instead. Matches the
rest of the batch's German compound-noun convention.
- "Nowhere Event" over "Nowhere Veranstaltung" — aligns with the
English source's all-English Nowhere brand prefixes (Drop / Forum /
Petition were already English).
- "RSVPs" / "Antworten" over "Zusagen" for calendar_rsvp_section and
calendar_rsvp_none — section also includes "Vielleicht" and "Komme
nicht", so "Zusagen" was misleading. Aligns with the existing
"RSVP für Termin" usage in kind_appt_rsvp.
- Symmetric RSVP pair: "Komme" / "Vielleicht" / "Komme nicht"
(instead of mixed "Komme" / "Vielleicht" / "Kann nicht").
- wallet_onchain_public_dialog_body: clearer privacy wording
("lade auf und sende zurück" over "befülle und entleere von und
zu"); make antecedent of "ausgeben" explicit ("die Mittel
ausgeben" over ambiguous "es").
- contact_list_containing_label: rephrase to dodge German declension
for n=1 ("Folgenliste (%1$d Nutzer):" over "Folgenliste mit %1$d
Nutzern:" which is dative-plural-only).
Native-speaker review surfaced consistency and wording fixes:
- Capitalize "Nostr" in calendar_share_nostr.
- "no blockchain" (masculine, matches ots_info_description) over
"na blockchain" in wallet_onchain_public_dialog_body.
- Calendar collection naming: use "calendário" for action labels
(New / Edit / Delete) matching the English source's user-facing
simplification; use "lista de calendário" only in the empty-state
body and route header (matches route_calendar_collections = "Listas
de calendário" and English's "Calendar lists"). Same hybrid we
resolved for Czech.
- Symmetric RSVP pair: "Vou" / "Talvez" / "Não vou" (instead of mixed
"Vou" / "Talvez" / "Não posso").
- "RSVPs" over "Confirmações" for calendar_rsvp_section and
calendar_rsvp_none — the section also includes "Talvez" / "Não vou"
responses, so "Confirmações" was misleading. Aligns with the
existing "RSVP do Compromisso" usage in kind_appt_rsvp.
- wallet_onchain_public_dialog_body: clearer privacy wording
("abasteça a partir de contas não privadas e envie os fundos de
volta" over "abasteça e esvazie a partir de e para"); make antecedent
of "gastar" explicit ("gastar os fundos" over ambiguous "-lo").
- calendar_empty_feed: reorder to drop trailing "ainda" — "Ainda não
há eventos…" reads more naturally than "…ainda" at end.
- calendar_collection_delete_confirm_message: align tenses (future +
future "será removido / não serão excluídos" over future + present).
- "backend on-chain" over "backend de chain" in wallet_onchain_no_backend.
Note: contact_list_* strings use the semantically-correct "seguidos"
(people you follow, per kind:3), even though existing
follow_list_selection / follow_sets use the inverted "seguidores".
Recommended follow-up: clean up legacy strings to match.
Native-speaker review surfaced several consistency and wording fixes
in the Czech translations from this branch:
- Use "Kanál" for "feed" (matches existing feed/home_feed/global_feed).
- Use "sats" not "satoshi" (matches new_amount_in_sats / amount_in_sats).
- Match Lightning sibling string's "odstraníte" (vs "odeberete") on
quick_zap_amounts_onchain_explainer.
- Capitalize "Nostr" in calendar_share_nostr.
- Calendar collection naming: use "kalendář" for actions (New / Edit /
Delete) matching the English source's user-facing simplification;
keep the disambiguating "seznamy" wording for the empty-state body
and title (matches route_calendar_collections = "Seznamy kalendářů"
and English's "No calendar collections yet").
- Rephrase contact_list_containing_label to dodge Czech count
inflection ("Seznam sledování (%1$d):" rather than "obsahující %1$d
uživatelů" which is only correct for n ≥ 5).
- Wallet privacy dialog: "vkládejte do peněženky a vybírejte z ní"
(deposit/withdraw verbs) over "plňte a vybírejte"; "váš nsec" (masc.
inanimate) over "vaše nsec"; "nesoukromých účtů" over "neprivátních".
- "on-chain backend" over "chain backend" in wallet_onchain_no_backend.
The OnchainZapSendDialog promised a sticky bottom Send button, but the
inner scrollable Column had no weight and the outer Column did not fill
the sheet height. In split mode, SplitsRecipientSection grew the content
past the sheet viewport and pushed the Send button off-screen.
Add ChannelMessageEvent.KIND (NIP-28 kind 42) to the Notifications feed's
allow-list so channel messages that p-tag the user surface in the in-app
Notifications screen, alongside text-note mentions and DMs.
The relay subscription already requests kind 42 with #p (see
FilterNotificationsToPubkey.NotificationsPerKeyKinds), and push notifications
already route ChannelMessageEvent through notifyMention. Only the in-app
feed filter was rejecting them at the kind check; the existing
tagsAnEventByUser gate handles the per-kind "is this for me" rule via the
BaseNoteEvent branch (reply-to-me, cited-author, citation-in-content).
ReactionsRow zap popup
- Merge the Lightning and on-chain chips into a single FlowRow so all
amounts wrap together instead of stacking on two lines.
- Drop the on-chain row's own Tune (settings) button; the Lightning
row's Tune is the single entry point to the settings screen.
UpdateZapAmountDialog
- Reorder sections: Quick Zap Amounts, Zap Privacy, Quick On-chain Zap
Amounts, Nostr Wallet Connect — privacy now sits next to the
Lightning amounts it actually applies to, and on-chain (which has no
privacy concept) is grouped further down.
OnchainZapSendDialog
- Move the preset amount chips above the sats text field in
AmountSection so the user sees the quick picks first and the
free-form input second.
Defaults
- DefaultOnchainZapAmounts is now [10_000] (just one chip) so a fresh
install / first-upgrade shows 4 chips total in the popup (3
Lightning defaults + 1 on-chain default), not 6. kotlinx
serialization defaults handle the back-compat for existing users
who never set their on-chain choices explicitly.
Migrating the mention TextFields to BasicTextField(state) routed them
through ThinPaddingTextField, which uses TextFieldDefaults.DecorationBox
(filled, surfaceVariant container). That replaced the original
OutlinedTextField look — visible rounded border + transparent inside —
with a filled gray rectangle. On ForwardZapTo the change was especially
visible since the original outlined border was the only visual cue that
the search row was an input.
Add OutlinedThinPaddingTextField, a sibling of ThinPaddingTextField that
wraps the same TextFieldState pipeline with
OutlinedTextFieldDefaults.DecorationBox + Container. Same thin padding,
same onTextChanged/inputTransformation/outputTransformation surface, but
genuinely outlined (notched label, transparent inside).
- ForwardZapTo: switch to the new component, drop the
focused/unfocusedIndicatorColor=Transparent overrides — defaults give
the proper outlined border the original OutlinedTextField had.
- EditPostView.MessageField: switch to the new component and use
OutlinedTextFieldDefaults.colors(focusedBorderColor=Transparent,
unfocusedBorderColor=Transparent) to keep the inner border invisible
so the existing 1dp Modifier.border(surface, RoundedCornerShape(8.dp))
remains the only visible frame, matching the original.
The lead-time chip label "%1$d min" pairs a count with a noun, which
inflects in Slavic / Baltic languages. Switch it from <string> to
<plurals> in the default locale and in the three locales that already
have a translation (pl-rPL, zh-rCN, hu-rHU), and update the single
caller in CalendarReminderSettingsScreen to use pluralStringResource.
The abbreviation "min" / "min." / "perc" / "分钟" doesn't decline with
the count in any of these specific languages, but the resource shape
is now correct for future locales that do (e.g. Russian, Ukrainian).
Mocks a kind-3 ContactListEvent with nine p-tags and renders
DisplayContactList in a ThemeComparisonColumn so the card can be
inspected in the IDE preview pane.
Captures the rule we just hit twice in a row (hashtag-limit + calendar
count): any count-bearing string must be <plurals>, every locale must
include the CLDR categories it actually uses, and don't hardcode "1" in
quantity="one" items. Loads automatically whenever a file under res/ is
edited.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Taproot address is derived from the Nostr pubkey, so copying it out
is the moment a user is about to share an account-linked, publicly
auditable wallet. Gate the Copy button on the Onchain card with the same
explanation shown by the Public chip, and offer a "Don't show again"
opt-out persisted in the existing UI preferences DataStore.
The NIP-52 calendar feature shipped two count-bearing strings as <string>
resources ("%1$d events" and "Starts in %1$d minutes"), which forces the
wrong noun form in Slavic languages where the declension depends on the
threshold integer. Convert both to <plurals> in the default locale and in
zh-rCN/pl-rPL/hu-rHU (the locales that already have translations), and
update the 4 callsites — 3 Composables use pluralStringResource and the
CalendarReminderWorker uses the pluralStringRes helper.
pl-rPL and hu-rHU keep only the "other" quantity, preserving existing text
without regression; proper CLDR fan-out (one/few/many for Polish, one for
Hungarian) will come from Crowdin or a native translator.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a NoteCompose render for kind-3 ContactListEvent showing a label with
follow count and a row of the first 6 user avatars (with a "+N" overflow
chip). Tapping the row opens a new ContactListUsersScreen that lists every
followed user with the same UserCompose row used on the search screen.
From an independent audit + my own pass, addressing concrete issues:
OnchainZapSendDialog
- Fee estimate fetch now retries with bounded backoff (4 tries, 1/2/3s
spacing) instead of giving up after one attempt. Covers two real
boot races: LocalCache.onchainBackend not yet wired at first
composition, and a flaky feeEstimates() call. Without retry the
Send button stayed permanently disabled.
- SplitsRecipientSection now indexes preview shares by pubkey once
via remember(previewShares) { associateBy { ... } } instead of an
O(N²) firstOrNull lookup per split row.
- belowDustShares is now wrapped in remember(previewShares) so it
doesn't re-filter the list on every recomposition.
- canSend now also requires resolvedRecipient != senderPubKey in
single-recipient mode, so the user can't tap Send when the only
fallback recipient is themselves (would fail at the builder's
"cannot zap yourself" check).
- formatWeight no longer prints "50.0%" for whole-percent shares —
trailing ".0" is stripped (was a Double->String artifact).
OnchainZapSplitter
- Added distributeUnchecked(): same allocation as distribute() but
never throws on dust; returns every share so the UI preview can
render the full shape in one pass. distribute() (used by the
build/send path) still throws via DustRecipientException so the
real send keeps its dust gate.
- Added check(remainder < splits.size) before the remainder loop to
pin the invariant that bounds remainder.toInt() and the k % size
defensive mod.
- Test for distributeUnchecked.
ReactionsRow / ReusableZapButton / ZapCustomDialog
- baseNote.toEventHint<Event>() is now wrapped in remember(baseNote)
in all three dialog launchers so it's not allocated on every
parent recomposition.
The legacy VisualTransformation paired with TextField(value, onValueChange)
goes through Compose Foundation's LegacyCursorAnchorInfoBuilder, which
throws "endOffset must be greater than startOffset" when the IME has
requested CURSOR_UPDATE_MONITOR and the field's visible bounds momentarily
collapse to zero width. The OutputTransformation API paired with
BasicTextField(state: TextFieldState) routes through a different,
non-legacy cursor anchor info controller and does not hit that bug.
Migrate the two remaining VisualTransformation call sites
(EditPostView.MessageField and ForwardZapTo) to the OutputTransformation
+ TextFieldState path. This requires:
- IZapField: forwardZapToEditting becomes TextFieldState; the
TextFieldValue-shaped updateZapForwardTo() callback is replaced by
onForwardZapTextChanged() which reads the current state.
- 8 ViewModels (ChannelNewMessage, ChatNewMessage, NestNewMessage,
NewProduct, LongFormPost, NewPublicMessage, ShortNotePost,
CommentPost): clear via clearText() instead of reassigning a new
TextFieldValue; rewrite onForwardZapTextChanged() to read from
forwardZapToEditting directly.
- ForwardZapTo: switch from OutlinedTextField to ThinPaddingTextField
with MentionPreservingInputTransformation +
UrlUserTagOutputTransformation.
- EditPostViewModel: message becomes TextFieldState; updateMessage()
becomes onMessageChanged() (the state updates itself); the in-place
replaceCurrentWord/insertUrlAtCursor TextFieldState overloads
replace the value-based ones.
- EditPostView: MessageField now uses ThinPaddingTextField with the
OutputTransformation. Also drops a stale commented-out subject row
that still referenced the legacy transformation.
- Delete UrlUserTagTransformation and its androidTest (no callers
remain).
- Remove MainThreadCrashGuard and its hook in Amethyst.onCreate: the
legacy cursor-anchor-info crash surface is gone with the remaining
mention TextFields, so the framework workaround is no longer
earning its keep.
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
When pickRelaysToLoadUsers exhausts every candidate tier (outbox / hints /
index / search / connected / common) for a user without finding kind 10002,
it returns an empty map and the REQ closes. A late-published relay list
from that user would never reach the UI for the rest of the session
(unless EOSEAccountFast's LRU evicts the entry).
Compute the set of users we couldn't place this pass and add a permanent
fallback filter on DefaultIndexerRelayList + DefaultSearchRelayList. The
filter is deterministic (sorted authors, set-based relays) so the relay
client de-duplicates it across passes, keeping the REQ open without
re-sending — any future kind 0 / kind 10002 publication will be pushed
to us live.
Offline relays are subtracted via failureTracker.cannotConnectRelays.
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
Compose Foundation's LegacyCursorAnchorInfoBuilder.addCharacterBounds
computes a TextRange(startOffset, endOffset) over the visible portion of
a TextField when the IME has requested CURSOR_UPDATE_MONITOR. During a
layout transition (sheet animation, keyboard insets settling, etc.) the
visible region can momentarily collapse so startOffset == endOffset, and
MultiParagraph.fillBoundingBoxes throws "endOffset must be greater than
startOffset". This is a framework bug we can't fix from app code, and
it crashes the whole process from an onGloballyPositioned callback
during dispatchDraw.
Install a main-thread crash guard at app startup that wraps Looper.loop()
and swallows only this very specific signature (IllegalArgumentException
with the exact message AND a stack frame in LegacyCursorAnchorInfo* /
MultiParagraph* / AndroidParagraph / TextLayout). Anything else
re-throws into the default uncaught handler so UnexpectedCrashSaver
still records real bugs. The IME just misses a cursor update on the
swallowed frame — no user-visible impact.