Compare commits

...

286 Commits

Author SHA1 Message Date
Vitor Pamplona
e0abc4a224 v1.11.0 2026-05-20 18:59:05 -04:00
Vitor Pamplona
ee67ba6408 Merge pull request #3025 from vitorpamplona/claude/plan-onchain-zap-events-t1cPq
feat: tighten the zap UI
2026-05-20 18:28:50 -04:00
Vitor Pamplona
7415b1db39 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2026-05-20 18:27:15 -04:00
Vitor Pamplona
f19a47007e Merge pull request #3024 from vitorpamplona/claude/fix-illegal-argument-exception-H4omh
fix(ui): restore outlined visual on EditPostView + ForwardZapTo TextF…
2026-05-20 18:26:45 -04:00
Claude
55e536dfd0 feat: tighten the zap UI
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.
2026-05-20 22:26:34 +00:00
Claude
05d958d182 fix(ui): restore outlined visual on EditPostView + ForwardZapTo TextFields
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.
2026-05-20 22:21:20 +00:00
David Kaspar
a4310317a0 Merge pull request #3017 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-21 00:04:24 +02:00
Crowdin Bot
40eb0f0b61 New Crowdin translations by GitHub Action 2026-05-20 22:02:32 +00:00
davotoula
b04cf3aedc i18n: convert calendar_reminder_settings_lead_choice to <plurals>
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).
2026-05-20 23:59:28 +02:00
Vitor Pamplona
924101c3f4 Fixes follow list rendering in the top note 2026-05-20 17:56:54 -04:00
Vitor Pamplona
0cc435aa02 Merge pull request #3023 from vitorpamplona/claude/add-notecompose-contactlist-sFMff
Add Contact List display and navigation screen
2026-05-20 17:49:40 -04:00
Vitor Pamplona
4fba293d60 Merge pull request #3020 from davotoula/feat/calendar-plurals-i18n
i18n: convert calendar count + reminder strings to <plurals>
2026-05-20 17:40:21 -04:00
Claude
a1cb5cb50d feat: add preview for DisplayContactList
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.
2026-05-20 21:40:02 +00:00
Vitor Pamplona
15474cfff4 Merge pull request #3022 from vitorpamplona/claude/fix-illegal-argument-exception-H4omh
Migrate text input to new Compose TextFieldState API
2026-05-20 17:39:06 -04:00
Vitor Pamplona
8bf1040f18 Merge pull request #3021 from vitorpamplona/claude/add-public-chip-confirmation-2Q7sh
Add warning dialog for copying public onchain addresses
2026-05-20 17:38:45 -04:00
davotoula
63e6283354 docs(claude): add res/CLAUDE.md note on <plurals> for Slavic locales
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>
2026-05-20 23:32:24 +02:00
Claude
d7a1b631b2 feat: confirm onchain copy with public-address warning
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.
2026-05-20 21:26:02 +00:00
davotoula
c7226d3293 i18n: convert calendar count + reminder strings to <plurals>
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>
2026-05-20 23:25:14 +02:00
Claude
6ecedb72f5 feat: render ContactListEvent in NoteCompose with tap-through user list
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.
2026-05-20 21:18:14 +00:00
Vitor Pamplona
995ec3dab2 Merge pull request #3018 from vitorpamplona/claude/plan-onchain-zap-events-t1cPq
Add on-chain split zap support with per-recipient distribution
2026-05-20 16:54:26 -04:00
Vitor Pamplona
b7e3ba27ac Merge pull request #3019 from vitorpamplona/claude/debug-metadata-relay-loading-opoto
Add fallback relay logic for abandoned users in metadata queries
2026-05-20 16:52:26 -04:00
Claude
f6db678249 fix: audit follow-ups (fee retry, perf, self-pay gate)
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.
2026-05-20 20:49:33 +00:00
Claude
b2959dcb75 refactor: migrate UrlUserTagTransformation to UrlUserTagOutputTransformation
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.
2026-05-20 20:46:09 +00:00
Claude
e6b6269314 feat: include indexer and proxy relays in trusted relay union 2026-05-20 20:41:33 +00:00
Vitor Pamplona
9d0397080f Adds Payment target to the usual user download 2026-05-20 16:39:05 -04:00
Claude
85dfe93ea7 feat: on-chain handoff from the custom-zap dialog
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
2026-05-20 20:35:01 +00:00
Claude
a90dd47ed4 feat: on-chain option on the Zap the Devs button
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.
2026-05-20 20:20:21 +00:00
Claude
dfd06e0dfd fix: skip offline relays when fetching user metadata 2026-05-20 20:18:56 +00:00
Claude
45aa6044b7 fix: on-chain zap splits — drop sender from splits, merge duplicates, gate Send on dust
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
2026-05-20 20:14:44 +00:00
Claude
13a599e282 fix: keep listening on default index+search relays for users with no kind 10002
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.
2026-05-20 19:57:38 +00:00
Claude
3ed2245d8c feat: on-chain zap splits
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
2026-05-20 19:55:15 +00:00
Vitor Pamplona
c19f0be57e Merge pull request #3013 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-20 15:53:53 -04:00
Vitor Pamplona
96883dbec1 Merge pull request #3015 from quentintaranpino/add-nostrcheck-media-server
Re-add Nostrcheck.me Blossom server to defaults
2026-05-20 15:53:42 -04:00
Claude
b0b0a13650 fix: swallow LegacyCursorAnchorInfo IllegalArgumentException
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.
2026-05-20 19:36:33 +00:00
quentintaranpino
0f92abd633 Re-add Nostrcheck.me Blossom server to defaults
The Blossom entry for nostrcheck.me was dropped in bcd2bc06
("Removes nip96 and updates Blossom recommendations", v1.06.0,
Feb 2026) along with the NIP-96 cleanup. This re-adds only the
Blossom endpoint.
2026-05-20 21:35:14 +02:00
Claude
360dea9de9 feat: surface on-chain zaps from the reactions zap button
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
2026-05-20 19:24:10 +00:00
Crowdin Bot
030c982a01 New Crowdin translations by GitHub Action 2026-05-20 19:11:26 +00:00
Vitor Pamplona
5962f7d659 Merge pull request #3014 from vitorpamplona/claude/add-icon-tool-instruction-oZv6P
docs: add Material Symbols font subset regeneration guide
2026-05-20 15:09:22 -04:00
Claude
9b6d65c9f1 docs: require running material-symbols-subset.sh when adding new icons
The bundled Material Symbols font ships only the codepoints referenced
from MaterialSymbols.kt. Without regenerating the subset, newly added
icons render as tofu at runtime. Document this as a mandatory step in
CLAUDE.md so agents pick it up automatically.
2026-05-20 19:00:38 +00:00
Vitor Pamplona
872bb4f245 Add new information icon 2026-05-20 14:44:59 -04:00
Vitor Pamplona
99e98e0213 Merge pull request #3012 from vitorpamplona/claude/social-wallet-info-popup-4O6s6
Add public wallet warning chip and dialog to onchain section
2026-05-20 13:32:50 -04:00
Vitor Pamplona
0c2a5720c1 Merge pull request #3007 from vitorpamplona/claude/gradle-to-kotlin-dsl-sOUlg
Migrate build scripts from Groovy to Kotlin DSL
2026-05-20 13:30:58 -04:00
Vitor Pamplona
e0f29aefb0 Merge pull request #3011 from greenart7c3/claude/update-payment-modal-ui-cFqYN
Refactor payment targets UI
2026-05-20 13:30:34 -04:00
Claude
89b47da42a feat: extract onchain Public chip strings to resources
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.
2026-05-20 17:20:16 +00:00
Claude
339ad6dae7 feat: clarify privacy guidance on onchain wallet popup
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.
2026-05-20 17:10:02 +00:00
Claude
7232456e9e fix: share payment targets dialog with reactions row, restore QR glyph
- 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
2026-05-20 16:53:11 +00:00
Claude
fc8f057e3e feat: add Public chip to onchain wallet card
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.
2026-05-20 16:39:34 +00:00
Claude
ec28d4eb4c feat: redesign payment targets modal with QR, copy and pay buttons
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
2026-05-20 16:35:47 +00:00
Vitor Pamplona
baf043343d Merge pull request #3009 from greenart7c3/claude/fix-reaction-payment-targets-UhsYP
fix: surface Pay row in Reaction Settings for existing accounts
2026-05-20 11:59:29 -04:00
Claude
59fd1f2244 build: use directories.add for benchmark androidTest resources
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).
2026-05-20 15:56:53 +00:00
Claude
ebfd066d5c build: convert remaining .gradle files to Kotlin DSL
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).
2026-05-20 14:17:47 +00:00
Claude
5841ca652c fix: surface Pay row in Reaction Settings for existing accounts
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.
2026-05-20 14:13:03 +00:00
Vitor Pamplona
fb4dfcefec Merge pull request #3006 from vitorpamplona/claude/add-thread-name-crashes-HsFy0
Include thread name in crash reports
2026-05-20 10:10:44 -04:00
Claude
c0a22e5c0b feat: include crashing thread name in crash report
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.
2026-05-20 13:53:11 +00:00
Vitor Pamplona
a4be1d21fe Merge pull request #3003 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-20 09:49:27 -04:00
Crowdin Bot
ce9a7da265 New Crowdin translations by GitHub Action 2026-05-20 13:46:35 +00:00
Vitor Pamplona
33e1790348 Merge pull request #3005 from vitorpamplona/claude/fix-motorola-runtime-exception-Grtj3
Handle ForegroundServiceStartNotAllowedException in PlaybackService
2026-05-20 09:44:56 -04:00
Vitor Pamplona
071b88c7eb Merge pull request #3004 from vitorpamplona/claude/modernize-zap-popup-QZWMV
Refactor ZapAmountChoicePopup UI with extracted components
2026-05-20 09:44:47 -04:00
Claude
754e03767a Revert "feat: replace zap button long-press-to-edit with long-press-to-custom"
This reverts commit 86a8fb0743.
2026-05-20 13:37:18 +00:00
Claude
86a8fb0743 feat: replace zap button long-press-to-edit with long-press-to-custom
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.
2026-05-20 13:33:01 +00:00
Claude
7403ccc825 fix: shrink Tune settings icon in zap popup to match chip size
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.
2026-05-20 13:18:58 +00:00
Claude
6be8f2db9e fix: vertically center items in zap amount popup row
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.
2026-05-20 13:17:37 +00:00
Claude
dde539f0fd fix: catch ForegroundServiceStartNotAllowedException in PlaybackService
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
2026-05-20 13:06:35 +00:00
davotoula
e131b0fec2 Show on chain zaps even if only reaction 2026-05-20 08:23:10 +02:00
Vitor Pamplona
4f094929d6 Merge pull request #3002 from vitorpamplona/claude/fix-locale-observable-by5fc
fix: observe locale in CalendarDateTimePickerButton
2026-05-19 20:10:17 -04:00
Claude
446345b35e feat: modernize zap amount choice popup to match reactions popup style
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.
2026-05-20 00:01:17 +00:00
Vitor Pamplona
d4f7f9f178 Merge pull request #3001 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-19 19:46:46 -04:00
Crowdin Bot
d4f7f745af New Crowdin translations by GitHub Action 2026-05-19 23:34:03 +00:00
Vitor Pamplona
3a07b1879e Merge pull request #2996 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-19 19:32:34 -04:00
Crowdin Bot
9099fd4b84 New Crowdin translations by GitHub Action 2026-05-19 23:27:44 +00:00
Vitor Pamplona
6f4978f149 Merge pull request #3000 from vitorpamplona/claude/revert-commit-32f7bda-ObZBH
Remove I2P support and privacy routing abstraction
2026-05-19 19:25:50 -04:00
Claude
ec3be14cf8 fix: observe locale in CalendarDateTimePickerButton
Use LocalConfiguration.current.locales[0] so the date formatter recomposes
when the device locale changes, satisfying the NonObservableLocale lint
check.
2026-05-19 23:24:57 +00:00
Vitor Pamplona
2ff67479a3 Merge pull request #2999 from vitorpamplona/claude/fix-notification-feed-bug-YNke0
Fix notification feed composition issue by removing CrossfadeIfEnabled
2026-05-19 19:16:13 -04:00
Claude
6a0801427b Revert "Merge pull request #2990 from vitorpamplona/claude/add-i2p-privacy-option-nK2X7"
This reverts commit d42482ff56, reversing
changes made to a8b6766f49.
2026-05-19 23:10:56 +00:00
Claude
9b94874d01 fix(notifications): drop crossfade around the card feed
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.
2026-05-19 23:00:50 +00:00
Vitor Pamplona
679c5a89e0 Merge pull request #2994 from vitorpamplona/claude/nip52-calendar-screens-yQc3j
Add NIP-52 calendar events support with UI and reminder system
2026-05-19 18:30:59 -04:00
Vitor Pamplona
297c2adffa Merge pull request #2998 from vitorpamplona/claude/fix-remember-return-type-l5SvB
Fix PrivacyOptionsScreen initialization with LaunchedEffect
2026-05-19 18:28:42 -04:00
Claude
952aa24001 fix(privacy): use LaunchedEffect instead of remember for VM reset
The remember(...) call returned Unit, which tripped the
RememberReturnType lint. Use LaunchedEffect, which is the
idiomatic Compose API for keyed side effects.
2026-05-19 22:27:13 +00:00
Vitor Pamplona
bef3bd61c5 Merge pull request #2997 from vitorpamplona/claude/fix-completion-handler-exception-6WASO
Simplify Nip11Retriever by removing nested withContext
2026-05-19 18:23:42 -04:00
Claude
97a6d4bcbd fix(calendars): wire feeds into updateFeedsWith so new events stream in live + drop redundant Calendar Lists label
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.
2026-05-19 22:21:23 +00:00
Claude
62da33f02b fix(relay-info): switch to Dispatchers.IO around the whole executeAsync call
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.
2026-05-19 22:13:58 +00:00
Crowdin Bot
5045ef9dcf New Crowdin translations by GitHub Action 2026-05-19 22:08:49 +00:00
David Kaspar
3a84aa0168 Merge pull request #2991 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-20 00:07:19 +02:00
Vitor Pamplona
1909fe7e4c Merge pull request #2995 from vitorpamplona/claude/test-mls-reply-notifications-SnaIM
Add reply support for Marmot/MLS group messages
2026-05-19 18:07:17 -04:00
Claude
c7a4796b25 refactor(marmot): audit follow-ups on MLS reply paths
Self-audit of the previous MLS reply commit surfaced four concrete
improvements:

1. Push-notification cold-start replies now thread reliably. The
   receiver previously rebuilt the parent inner event by looking it up
   in LocalCache; in a cold-process broadcast that cache hasn't been
   re-hydrated yet (Account.restoreAll runs async on init), so the
   q-tag silently dropped. Carry the parent's inner event id AND
   author pubkey through the Intent extras and feed them straight to
   MarmotManager.buildTextMessage, which now takes (eventId, author)
   instead of a full Event. The cold-start reply is now always
   threaded, not just the warm-cache case.

2. marmotGroupRelays() is no longer duplicated. The receiver was
   reimplementing what AccountViewModel had as a private fun (which
   itself was used 9× inside the VM). Lifted to Account so headless
   callers can reach it without spinning up a ViewModel; both sites
   now share one implementation.

3. Dropped the dead editFromDraft / draftId plumbing. Added for route
   symmetry with NIP-17 but Marmot has no draft persistence, so the
   parameter rode all the way through MarmotGroupChatView only to be
   ignored under @Suppress("UNUSED_PARAMETER"). Per CLAUDE.md, don't
   pre-emptively abstract; reinstate when drafts actually land.

4. MarmotGroupMessageComposer no longer has default-param `remember`
   blocks. There's exactly one caller and it always passes both
   messageState and replyTo — the defaults were just noise.

The in-chat send path also captures (id, pubKey) under the replyTo
guard before launching the send coroutine, so a slow send + a user-
cleared reply state can't race into a partially-threaded message.

No protocol or behavioral change for the warm-cache happy path; the
threading improvement is observable only on cold-start push-reply.
2026-05-19 22:04:14 +00:00
Claude
cc78744a10 fix(calendars): tapping a Calendar List opens the dedicated detail screen, not the generic thread view
Calendar collections (kind 31924) on the Calendar Lists screen routed
through Route.Note → ThreadView, which rendered them as a regular note
with the calendar metadata block at the top — no idea that the
collection is a curated list of appointments.

Fix:
- CalendarCollectionsView: route taps to Route.CalendarEventDetail
  (using the collection's own Address) like every other calendar surface.
- CalendarEventDetailScreen: branch on event kind. CalendarEvent (31924)
  now renders a new CollectionBody — title, description, ReactionsRow,
  and a list of the collection's member appointments. Each member row
  uses observeNote so an appointment that arrives later in the session
  fills in without leaving the screen; tapping a row routes to its
  appointment detail.
2026-05-19 21:58:35 +00:00
Claude
382729520c chore(commons): regenerate Material Symbols font subset to include EventAvailable
The "Add to phone calendar" icon I added on the calendar detail screen
top bar mapped to MaterialSymbols.EventAvailable (\\uE614), but the
checked-in TTF was a subset built before that codepoint existed in
MaterialSymbols.kt — so the third icon from the right rendered as the
.notdef placeholder.

Regenerated via tools/material-symbols-subset/subset.sh. The subset is
now 214 codepoints (up from 213); file size unchanged at 420K.
2026-05-19 21:58:35 +00:00
Claude
1b563fb3f0 fix(calendars): clear EOSE cursor on list switch so evicted notes get re-fetched
LocalCache stores notes via WeakReference (LargeSoftCache), so while the
user is on People List B the GC can reclaim the global calendar notes
that aren't strong-referenced from B's UI. When the user flips back to
Global, the relay subscription rebuilds with `since = lastEose` for
Global — which still says "you already have everything up to T" from the
prior visit. The relay won't re-send events with createdAt < T, and the
ones that were evicted from LocalCache stay gone. Symptom: switching
back to Global shows fewer events, or only past events, or nothing.

Fix: when the calendar listName changes, drop the EOSE cursor for the
list we're switching to before invalidating filters. The next assembly
runs with a fresh `since` (defaultSince / oneMonthAgo) and the relay
re-streams the events the cache lost. Added a `clear` method on
EOSEByKey + EOSEAccountKey and a protected `clearEoseFor(key)` helper
on PerUserAndFollowListEoseManager.

Same shape of bug likely affects pictures/discovery/etc. that share this
assembler pattern — left those alone for now and can apply the same
fix if reported.
2026-05-19 21:58:35 +00:00
Claude
d96209b20d fix(calendars): top-bar filter switch resets scroll; week/month/day headers scroll with the disappearing bar
Two related fixes for the calendar surface:

1. Filter switch leaving the user in the past section. The feed-content
   state already calls sendToTop() when feedKey changes, but the calendar
   feed's LazyColumn never subscribed to the scrollToTop signal — so a
   filter switch (e.g. People List → Global) preserved the previous
   scroll position. Often that landed the user mid-feed in the "Past"
   section of the much larger Global list, looking like "the events
   didn't come back". Added a LazyListState + WatchScrollToTop wiring.

2. Week/month/day headers stayed pinned when the DisappearingScaffold's
   top bar collapsed on scroll. The views had a Column with
   disappearingScaffoldPadding() that reserved a fixed top inset, so when
   the bar slid up the headers stayed put and a visual gap opened. Pulled
   the nav header (and for the week view, the WeekStrip + DaySummaryHeader)
   into the same LazyColumn as the events, with rememberFeedContentPadding
   as contentPadding — now the whole stack scrolls under the bar together.
2026-05-19 21:58:34 +00:00
Claude
638e22c262 fix(calendars): tighten ReactionsRow spacing on the detail screen
The outer scroll Column had verticalArrangement.spacedBy(16.dp), which
compounded with the ReactionsRow's own internal vertical padding to make
the row look like it had huge top/bottom margins (~22dp on each side).

Drop the blanket spacedBy and place spacing deliberately: small explicit
Spacers between divider-separated sections, none around the ReactionsRow
itself so its 6dp internal padding is what shows.
2026-05-19 21:58:34 +00:00
Claude
fc1da467e7 feat(calendars): user-search participant picker + hide createdAt on cards
- The calendar event/collection cards' top user header showed the note's
  createdAt time-ago, which read as "the event time" at a glance — but
  it's actually the publication time. Added `showTimeAgo` to UserCardHeader
  and pass `false` from both calendar card surfaces so the only timestamp
  on the card is the event's own start/end.
- Replaced the New Calendar Event participant input (paste an npub or
  64-char hex) with the standard UserSuggestionState + ShowUserSuggestionList
  pattern used by the badge-award / DM / new-post screens. Typing a name,
  npub, hex, or NIP-05 surfaces matching users live; tapping one adds
  them.
2026-05-19 21:58:34 +00:00
Claude
5dfabf667f feat(calendars): route URL locations to the browser, not the maps app
NIP-52 \`location\` is free-text — some events stash a Zoom / Jitsi /
livestream URL there instead of a place name. The detail row was sending
every value through the geo: intent, which on most devices punts to the
maps app and renders the URL as a search query.

Now: if the trimmed value starts with http:// or https://, open it with
ACTION_VIEW on the URL itself (which the browser claims). Otherwise the
geo: path is unchanged. The leading icon and content description flip to
Link / "Open link" when it's a URL so the affordance is clear before the
tap.
2026-05-19 21:58:34 +00:00
Claude
0dea41602a fix(calendars): apply scaffold padding so month/week/day headers and collections list don't render behind the top app bar
The DisappearingScaffold places content at y=0 by design (so feeds scroll
behind the bar) and exposes the bar height via [LocalDisappearingScaffoldPadding]
for inner scrollables to consume. The static-headered grid views and the
collections list weren't reading that local, so:

- The week view's WeekStrip (day-number row) rendered behind the top bar.
- The month/year title and grid header sat behind the bar similarly.
- The collections LazyColumn's first card was clipped under the bar.

Fix:
- Add a small `Modifier.disappearingScaffoldPadding()` helper that applies
  the local padding. Month/week/day views opt in with one line.
- Collections LazyColumn uses `rememberFeedContentPadding(FeedPadding)`
  so cards inset on first render but still scroll behind the bar (matching
  the feed view's behaviour).
2026-05-19 21:58:34 +00:00
Claude
5116bc21ae refactor(calendars): extract DAL to commons + a11y + inline-FQN cleanup
DAL extraction (commons/src/jvmAndroid/.../model/nip52Calendar/):
- CalendarSortKeys, CalendarAppointmentView, MonthGridBars, IcsExport now
  live in commons so desktop and the future CLI can consume them. Package
  changed to com.vitorpamplona.amethyst.commons.model.nip52Calendar.
- IcsExportTest moved to commons/jvmTest so it can see the `internal`
  escapeText helper without exposing it as public API.
- Feed-filter classes (CalendarAppointmentsFeedFilter,
  CalendarCollectionsFeedFilter) stay in amethyst — they depend on Account
  and LocalCache. The ViewModels stay for the same reason; lifting them
  requires moving Account/LocalCache too, out of scope here.
- A few smart-cast call sites needed local bindings because cross-module
  properties don't support implicit smart-cast.

Accessibility:
- Each month-grid cell announces a full content description ("Wednesday,
  January 15, 2025, 2 events, today, selected") via mergeDescendants so
  TalkBack reads the cell as one item with role=Button.
- Week-strip cells get the same treatment with role=Tab.
- Header title now announces both the title and "jump to today" so the
  affordance is discoverable.
- The expanding FAB describes itself as a toggle, and each sub-FAB as
  the concrete create action.

Inline-FQN cleanup pass:
- CalendarEventDetailScreen (already in a prior pass), CalendarCollectionsView,
  NewCalendarEventScreen, NewCalendarCollectionScreen: hoisted
  fully-qualified androidx.compose / com.vitorpamplona references into
  proper imports per the codebase style.

All 56 calendar tests still pass (48 in amethyst + 8 IcsExport in commons).
2026-05-19 21:58:34 +00:00
Claude
ab5d884b34 feat(calendars): "Add to phone calendar" intent + multi-day bars in month grid
- New IconButton in the detail screen opens the system event composer
  (Google Calendar / Samsung / iCloud) pre-filled with title, range,
  location, and description via Intent.ACTION_INSERT on CalendarContract.
  Falls back to the .ics share path if no calendar app is installed.
- Replaced the per-cell event-dot row with horizontal bars that span the
  cells a multi-day event covers. Bars are laid out by a greedy
  lowest-lane assignment so overlapping events stack rather than collide;
  cells removed their horizontal padding so adjacent bars merge into one
  uninterrupted line.
- Bars round their ends only at the event's actual start/end (or at week
  boundaries), so a 3-day conference reads as one continuous pill across
  the row.
- Added MonthGridBarsTest (7 tests) covering single/multi-day, lane
  collision, longer-event tiebreak, and the empty/ghost edge cases.
2026-05-19 21:58:34 +00:00
Claude
971e9e4846 feat(calendars): reactions/zaps row, multi-day events, swipe nav, prefetch
- ReactionsRow on the detail screen so zap/like/repost/reply use the same
  shared component every other note surface uses (no calendar-specific
  reinvention)
- Multi-day events now appear on every day they cover in month/week/day
  grids (groupByDayKeyExpanded), with "Day X of Y" / "Continues" markers
  on continuation rows
- Horizontal swipe gestures on month/week/day views via a shared
  calendarSwipeNavigation modifier
- Deep-link prefetch: notification taps on a calendar naddr route
  directly to CalendarEventDetail (skipping EventRedirect) and the
  detail screen issues a per-event relay subscription via observeNote
- "Happening now · ends in X" relative-time label for ongoing events
- Richer empty-state copy with a shared CalendarEmptyState composable
  (feed / day / week / collections each get a title + actionable hint)
- Cleanup: hoist inline fully-qualified imports across the detail screen
2026-05-19 21:58:34 +00:00
Claude
eeda5810ae feat(calendars): share-as-nostr-link, reactive picker, calendar filter
#8 Share-as-nostr-link:
- Second share IconButton on the event-detail top bar (next to the
  existing .ics share). Builds a nostr:naddr1… URI via NAddress.create
  and hands it to the system share sheet as text/plain. Lets users
  paste a calendar event into DMs, posts, or any other nostr client
  that understands naddr URIs.

#10 Reactive availableAppointments in collection editor:
- NewCalendarCollectionViewModel now subscribes to
  LocalCache.live.newEventBundles in a viewModelScope job and
  re-scans loadOwnedAppointments() on every emit. New appointments
  published from another screen (or arriving from relays) while the
  editor is open now appear in the picker without dismissing.
- onCleared() cancels the subscription so the VM doesn't leak the
  collector after the screen closes.

#11 Calendar-as-filter overlay:
- New CalendarFilterChip composable: an AssistChip in the top-bar
  trailing slot showing "All" or the selected calendar's title. Tap
  opens a ModalBottomSheet with radio rows for "All" + each of the
  user's own kind-31924 calendars. Sheet is reactive so newly-
  published calendars appear immediately.
- rememberCalendarFilterAddresses() resolves the selected calendar's
  member-address set (or null when "All"). The Set lives one
  composition above the view bodies and reacts to LocalCache changes
  via produceState.
- Feed / Month / Week / Day views each take an optional
  filterAddresses parameter. A new List<Note>.applyCalendarFilter()
  extension does the membership check. Client-side filtering means
  flipping the filter doesn't trigger a relay refetch.
- CalendarsTopBar grew a generic trailing slot so future controls
  can plug in alongside the view-mode chips without further surgery.

https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
2026-05-19 21:58:34 +00:00
Claude
3315ccccec feat(calendars): reminder settings (enable + lead time) and store tests
#5 Configurable reminder lead time + #6 enable/disable toggle:
- CalendarReminderPrefs is a device-level SharedPreferences wrapper
  with isEnabled / leadMinutes accessors. Device scope (rather than
  per-account) because the worker that consults it runs globally —
  per-account preferences would require account-context plumbing into
  WorkManager that the rest of the app doesn't have today.
- CalendarReminderWorker now reads enabled + lead-minutes on each
  cycle. When disabled the worker short-circuits to Result.success()
  rather than cancelling itself — flipping the toggle back on takes
  effect immediately without a relaunch.
- New CalendarReminderSettingsScreen: a Switch for enabled, a row of
  FilterChips for the 5/15/30/60-minute lead-time choices. The lead-
  time row disables when reminders are off.
- New Route.CalendarReminderSettings wired through AppNavigation and
  surfaced as an entry in the existing AllSettingsScreen list under
  the notification settings divider.

#7 Tests:
- CalendarReminderPrefsTest exercises the prefs round-trip
  (defaults, set/get of enabled and lead-minutes) and the store
  contract (wasNotified false-by-default, true after markNotified,
  false again when start changes, and forgetBefore pruning).
- Backed by an in-memory FakeSharedPreferences so the tests run on
  the JVM without Robolectric. mockk stubs the Context to hand back
  the fake prefs.

https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
2026-05-19 21:58:33 +00:00
Claude
a5f03b21cc feat(calendars): gallery image picker + participant picker on create
#3 Image upload from gallery:
- NewCalendarEventViewModel.uploadAndSetImage(uri, mime, context)
  picks up the user's configured default file server (Blossom / NIP-96
  / NIP-95), uploads via UploadOrchestrator with MEDIUM compression and
  metadata stripping, then writes the resulting URL into imageUrl. On
  failure the screen surfaces a toast and the URL field stays usable
  for the manual fallback.
- New ImageRow on the create screen: keeps the URL OutlinedTextField
  but adds an AddPhotoAlternate icon button next to it. While the
  upload runs the field disables and a small CircularProgressIndicator
  takes the icon's slot.

#9 Participant picker:
- New `participants` mutableStateListOf on the VM, plumbed through
  publish() as `p` tags via the existing dayParticipants/timeParticipants
  TagArrayBuilder extensions. Edit-mode load pre-populates from the
  event's existing p-tags.
- New ParticipantsRow on the create screen: an OutlinedTextField that
  accepts an npub or a 64-char hex pubkey, with an Add button that
  resolves via Nip19Parser. Existing participants render as the
  standard avatar + display-name row with an X button to remove.
  Invalid input shows an inline error.

https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
2026-05-19 21:58:33 +00:00
Claude
993810aa2f feat(calendars): add-to-calendar picker, delete collections, fix maps row
#1 Add-to-calendar bottom sheet:
- New AddToCalendarSheet shows the user's own kind-31924 calendars with
  a checkbox per calendar. Tapping a row toggles membership of the
  current event in that calendar and re-signs with the updated `a` tag
  list. Reactive — collects LocalCache.live.newEventBundles so own-just-
  published edits and incoming calendar events update the sheet
  without dismissing.
- Trigger is a + icon next to the "In calendars" section title on the
  event-detail screen. Empty-state shows a hint when the user has no
  own calendars yet.

#2 Delete collections:
- NewCalendarCollectionViewModel.deleteLoaded() builds a NIP-09
  deletion via account.delete(). No-op outside edit mode.
- Edit screen now shows a red OutlinedButton "Delete calendar" below
  the form. Tapping opens an AlertDialog with a confirmation — the
  confirmation clarifies that only the calendar list is removed; the
  individual events inside continue to exist.

#4 Maps row:
- LocationRow lost the nested-but-non-functional "Open in maps"
  TextButton. The row is the single click target with a trailing
  ChevronRight icon to signal "tap to navigate". Content description
  on the LocationOn icon now carries the action name for TalkBack.

https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
2026-05-19 21:58:33 +00:00
Claude
cc5ae46f5b feat(calendars): modernize UI to match the rest of the app
Visual: every calendar surface now uses the same avatar + display-name
pattern as the picture feed / shorts / video cards — UserCardHeader at
the top of each list card, ClickableUserPicture + UsernameDisplay in
the detail screen's people sections. Truncated pubkeys (`pub…XX`) only
appear as a fallback while LoadUser resolves a real metadata record.

Cards:
- CalendarEventListCard now starts with UserCardHeader (avatar, name,
  time-ago, more-options) above the date badge + body. Layout matches
  PictureCardCompose / VideoCardCompose so the calendar feed reads as
  part of the same product, not a tacked-on tab.
- CalendarCollectionCard same treatment.

Detail screen:
- ParticipantsSection now renders a 35dp avatar + display name per
  p-tag, tappable to the user's profile.
- RsvpsSection renders the RSVP author the same way with the status
  badge as a trailing element (Going / Maybe / Can't go in the matching
  scheme colour).
- InCalendarsSection renders each calendar's author avatar alongside
  the calendar title — clicking the row still navigates to that
  calendar's detail.
- RSVPs + calendars sections are now reactive: produceState collects
  LocalCache.live.newEventBundles and re-runs the scan, so RSVPs that
  arrive from relays while the screen is open appear without leaving
  and returning.

Notifications:
- Tap the notification → opens the calendar event detail. The reminder
  PendingIntent now carries the `nostr:naddr…` URI as ACTION_VIEW data;
  AppNavigation's existing uriToRoute pipeline resolves it via NAddress
  → RouteMaker, where new branches map CalendarTimeSlotEvent /
  CalendarDateSlotEvent to Route.CalendarEventDetail. Previously the
  tap just opened the home screen.
- CalendarReminderStore now keys on (eventId, startSeconds). If the
  author updates the appointment with a new start time, the stored
  value won't match and the worker fires a fresh reminder for the new
  time — previously a moved meeting would be silently skipped because
  the eventId already had a record.

https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
2026-05-19 21:58:33 +00:00
Claude
55147b7781 feat(calendars): "starting soon" notifications for attended events
Adds a 15-minute periodic WorkManager job that scans LocalCache for
kind-31925 ACCEPTED RSVPs whose target appointment starts within the
next 15 minutes, and posts a notification per event.

- CalendarReminderNotifier: posts notifications on a dedicated
  "Calendar reminders" channel; pattern-matches the existing
  ScheduledPostNotifier shape so the two notification surfaces share
  conventions. Per-event ID derived from the appointment's event id so
  a re-notification for the same event collapses rather than stacks.
- CalendarReminderStore: SharedPreferences-backed "already notified"
  set keyed by event id, with the recorded start time as the value so
  the entry can be pruned when the event has ended (forgetBefore()).
  Without persistence, every worker run after a restart would re-fire
  the same reminders until the event started — LocalCache has no
  memory of past reminders.
- CalendarReminderWorker: 15-minute periodic CoroutineWorker that
  walks accepted RSVPs in LocalCache, resolves the target appointment
  via its addressable address, and posts the reminder if it's in the
  lead window and not previously notified. Skips multi-account
  coordination — accepts any RSVP in cache regardless of which
  account authored it.
- Hooked into AppModules alongside the scheduled-posts worker
  (independent of the always-on notification toggle so reminders fire
  even when that's disabled). POST_NOTIFICATIONS is already declared
  in the manifest.
- Strings: channel id/name/description, default title fallback, and
  "Starts in N minutes" body template.

https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
2026-05-19 21:58:33 +00:00
Claude
8d35a73416 feat(calendars): relative-time labels and iCalendar (.ics) export
Relative time:
- New relativeTimeLabel() wraps Android's DateUtils.getRelativeTimeSpanString
  for locale-aware "in 2 hours" / "yesterday" / "in 3 days" phrasing. Uses
  DAY resolution for all-day events so a 31922 doesn't get a misleading
  hour-precision phrase.
- Special-cased to "Happening now" when an event has started but its end
  is still in the future — DateUtils would otherwise produce "started 5
  minutes ago" which reads wrong while the user is mid-event.
- Wired into the appointment list card (below the date range) and the
  event detail screen (between the range and the location).

iCalendar export:
- New IcsExport object generates RFC 5545 text. Produces a single-event
  VCALENDAR for appointments and a multi-VEVENT VCALENDAR for kind-31924
  collections (member events that haven't arrived from relays yet are
  skipped). Date-slot events emit DTSTART;VALUE=DATE so calendar apps
  don't render them as midnight events. Text escaping handles backslash,
  comma, semicolon, and newline per §3.3.11.
- Share button (MaterialSymbols.Share) on the event detail screen and
  on each collection card. shareIcs() writes the file to cacheDir/calendar
  and hands it to the system share sheet via the existing FileProvider,
  with FLAG_GRANT_READ_URI_PERMISSION scoped to the chooser.

Tests:
- 8 new IcsExportTest cases covering time-slot vs date-slot output,
  escaping, hashtag→CATEGORIES, multi-event calendar wrapping, and
  filename safety/fallback.

https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
2026-05-19 21:58:32 +00:00
Claude
18512e11ff feat(calendars): edit existing appointments
Adds an edit flow for kind-31922/31923 appointments authored by the
current account. The detail screen surfaces a pencil icon in the top
bar when isOwnEvent is true; tapping it navigates to the new
Route.EditCalendarEvent(kind, pubKeyHex, dTag), which routes to the
existing NewCalendarEventScreen in edit mode.

ViewModel:
- NewCalendarEventViewModel.loadForEdit() pre-populates all fields
  (title, summary, location, image, hashtags, start/end seconds) from
  the cached event. It's idempotent across recompositions and a no-op
  if the address isn't in LocalCache yet.
- publish() now preserves the addressable's d-tag and kind in edit
  mode so the broadcast replaces the original rather than minting a
  new event.
- The all-day toggle is disabled while editing — switching kinds mid-
  edit would leave a stale event under the original kind/d-tag
  combination. The UI shows an explanatory subtitle.

Also escapes the leading `?` in calendar_rsvp_maybe_prefixed (aapt was
parsing it as a theme-attribute reference and refusing to link).

Tests:
- New CalendarEditLoadTest covers the round-trip parsing from packed
  tags back to the fields the VM reads — time-slot, date-slot, and an
  empty-optional-tags variant.

https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
2026-05-19 21:58:32 +00:00
Claude
390d750638 i18n(calendars): replace hardcoded user-facing strings with resources
Removes the English literals that were leaking through user-facing
surfaces of the calendar feature:
- Empty-state copy in week and day views ("No events", "No events on
  this day").
- Navigation content descriptions ("Previous/Next month/week/day")
  used by accessibility services and TalkBack.
- DatePicker / TimePicker dialog buttons ("OK", "Cancel") and the
  TimePicker dialog title ("Pick time"); the OK side now reuses the
  app-wide R.string.confirm / R.string.cancel.
- "All-day" label in the day view's time column and the collection
  editor's appointment picker rows.
- "(untitled)" fallback shown in the picker and in the event-detail
  "In calendars" section.
- The RSVP-list status badges in the detail screen (✓ Going, ? Maybe,
  ✗ Can't go) — the glyph stays in the string so translators can keep
  or replace it per locale.

NewCalendarCollectionViewModel no longer embeds "(untitled)"; the
fallback moves to the picker row composable so the VM stays free of
string resources.

https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
2026-05-19 21:58:32 +00:00
Claude
bf380ec015 feat(calendars): event detail screen, string cleanup, unit tests
Detail screen (item 3):
- Adds Route.CalendarEventDetail(kind, pubKey, dTag) and a dedicated
  CalendarEventDetailScreen that loads the appointment by address. The
  screen renders the hero image, full title/range/location/summary,
  the RSVP button row, participants, all RSVPs from LocalCache that
  a-tag the event, and the calendars (kind 31924) this event belongs
  to. Tapping a parent calendar navigates back into the detail screen
  with that calendar's address.
- The "Open in maps" affordance fires a geo: intent for the location
  string, falling back gracefully when no maps app is installed.
- Tapping a card now navigates to the dedicated detail screen instead
  of the generic Route.Note when the event is addressable.

Strings cleanup (item 6):
- Removed unused entries that the audit flagged and that didn't end up
  wired into the detail screen: calendar_section_today,
  calendar_event_pick_time, calendar_event_publish,
  calendar_event_publishing, calendar_collection_save,
  calendar_rsvp_responded, calendar_rsvp_sending.

Unit tests (item 8):
- 27 tests covering the pure helpers — parseIsoDateToUnixSeconds,
  Note.calendarStartSeconds / calendarEndSeconds / calendarLocalDayKey,
  appointmentView, groupByDayKey, partitionUpcomingPast,
  upcomingFirstCalendarOrder transitivity, and rsvpDTagFor/Address.
- Made partitionUpcomingPast take `nowSeconds` as a parameter (default
  TimeUtils.now()) so the split is deterministic in tests — same
  pattern as upcomingFirstCalendarOrder.

https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
2026-05-19 21:58:32 +00:00
Claude
e901364e87 feat(calendars): RSVP dedupe + collection editing with event picker
RSVP (item 4):
- CalendarRsvpRow now uses a deterministic d-tag of the form
  'rsvp:<kind>:<pubkey>:<dtag>' derived from the target appointment's
  address. Each tap replaces the user's single addressable RSVP for that
  event rather than appending another, eliminating the previous footgun
  where rapid taps spammed relays with parallel kind-31925 events.
- Buttons now reflect the current RSVP status reactively: the matching
  status renders as filled-tonal with the status colour; the others
  render outlined. Reads `LocalCache.getOrCreateAddressableNote()` and
  observes the metadata flow so the row updates as soon as the new
  event lands in cache (own broadcast or relay echo).

Collections (item 5):
- Lifted NewCalendarCollectionScreen onto NewCalendarCollectionViewModel
  with title/description/selected-event state. Editing mode pre-populates
  from an existing kind-31924 by dTag (already supported by
  Route.NewCalendarCollection but previously unused), preserves the
  d-tag so the publish replaces the addressable, and seeds the
  selected-event list from the existing `a` tags.
- Added a multi-select picker that lists the user's own appointments
  (kinds 31922/31923 authored by `account.userProfile()`), sorted
  upcoming-first. Toggling a row adds or removes its address from the
  outgoing `a` tag list.
- Wired Route.NewCalendarCollection.dTag through to the screen so the
  edit flow becomes reachable from anywhere that has the calendar's
  dTag (the event-detail screen will plug into this in a follow-up).

https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
2026-05-19 21:58:32 +00:00
Claude
447301599c refactor(calendars): rename appointments feed, split collections to own route
Naming:
- CalendarsFeedFilter → CalendarAppointmentsFeedFilter, calendarsFeed →
  calendarAppointmentsFeed. NIP-52 calls kind 31924 the "calendar" (a
  list of events); kinds 31922/31923 are appointments that *go into*
  calendars. The old name made `calendarsFeed` look like "the feed of
  calendars" when it was actually the feed of appointments.

Architecture:
- Dropped CalendarsViewMode.COLLECTIONS. Calendar collections are a
  sibling feed, not a view mode of the appointment timeline. They now
  live on a dedicated CalendarCollectionsScreen reached via the new
  Route.CalendarCollections, with their own drawer entry and bottom-bar
  slot under NavBarItem.CALENDAR_COLLECTIONS.
- Both screens share the same CalendarsFilterAssembler subscription
  (which already pulled all four kinds), so opening either keeps the
  relay subscription warm for the other.

https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
2026-05-19 21:58:32 +00:00
Claude
87f9a346cc refactor(calendars): DST-safe nav, shared header, appointment-view adapter
Correctness:
- Replaced millisecond arithmetic with LocalDate.plus/minusDays in day
  and week views. Day stepping with MILLIS_IN_DAY drifts at DST
  transitions (a day is 23h or 25h), so after a couple of spring/fall
  crossings 'next day' landed on the wrong calendar date.

DRY:
- Introduced CalendarAppointmentView, a small projection that exposes
  title/image/summary/location/start/end/isAllDay for both 31922 and
  31923 events. Three call sites previously did a 4-block
  `when (event) { is Time -> e.x(); is Date -> e.x() }` per accessor;
  they're now single linear reads.
- Extracted CalendarNavigationHeader for the shared [◀] title [▶]
  pattern used identically by month, week and day views.
- Moved groupByDayKey from CalendarMonthView.kt into the dal package
  alongside calendarLocalDayKey — it's used by all three grid views,
  not just the month view.

Cleanup:
- `Modifier.size(width = 72.dp, height = Dp.Unspecified)` in DayRow
  was the residue of an earlier failed `width()` helper; replaced with
  the native `Modifier.width(72.dp)`.
- Removed obsolete startOfWeekMs / dayKeyForMs / MILLIS_IN_DAY /
  MILLIS_PER_DAY / MILLIS_PER_WEEK helpers along with their imports.

Line counts: MonthView 341→273, WeekView 299→234, DayView 270→196,
EventListCard 236→201.

https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
2026-05-19 21:58:32 +00:00
Claude
802f89723e fix(calendars): audit pass on day-keying, threading and recomposition
Correctness:
- Day-cell grouping now uses local calendar dates (LocalDate.toEpochDay)
  consistently across feed, month, week and day views. The previous
  implementation keyed events by UTC year/month/day while cells used
  local year/month/day, so a time-slot event at 2025-01-15 00:00 UTC
  silently disappeared from the user's Jan-14 cell in zones west of UTC.
- 31922 date-slot events anchor at local midnight instead of UTC, so
  "Jan 15" lands on Jan 15 in every viewer's grid (previously appeared
  a day early west of UTC).
- Switched ISO date parsing to DateTimeFormatter; the SimpleDateFormat
  predecessor was shared by sort (background) and grouping (UI) and is
  not thread-safe — concurrent reads could throw or return garbage.
- Snapshot TimeUtils.now() once per sort; reading the clock inside
  the comparator could violate transitivity on boundary elements and
  trigger IllegalArgumentException from the JDK sort.
- Multi-day events that have started but not ended are now classified
  as "upcoming" — previously a 3-day conference starting yesterday was
  silently dropped into the past section.

Performance:
- Hoisted MonthShortFormatter as a file-level DateTimeFormatter; was
  allocating a SimpleDateFormat per CalendarDateBadge recompose.
- Removed `derivedStateOf` over-keying on year/month/weekStart/dayMs;
  groupByDayKey only depends on the note list.
- Dropped the wasted `remember { Any() }` indirection driving
  LaunchedEffect — feed the keys directly.

Cleanups: removed unused UnusedShape, ChipPad, FeedCardPadding,
IconTintPlaceholder, startOfDayLocal, dayKeyUtc, startOfWeekMsForNote,
and the dead `dayCal` line inside MonthGrid.

https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
2026-05-19 21:58:32 +00:00
Claude
809f21252c feat(calendars): add create flows for events and collections
Adds NewCalendarEventScreen (Material3 form with all-day toggle,
DatePicker/TimePicker chain, location, summary, image, hashtags)
and NewCalendarCollectionScreen (title + description). Both publish
via the standard signAndComputeBroadcast pipeline and are wired
into AppNavigation as bottom-up routes.

https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
2026-05-19 21:58:31 +00:00
Claude
5343c7c636 feat(calendars): inline RSVP buttons and 31924/31925 renders
Calendar appointment cards now show Going/Maybe/Can't-go buttons that
publish a NIP-52 RSVP (kind 31925) when tapped. Calendar collections
(kind 31924) and RSVP events (kind 31925) get dedicated inline renders
wired into NoteCompose so they no longer fall through to the generic
unknown-kind path.

https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
2026-05-19 21:58:31 +00:00
Claude
b5d5cbed03 feat(calendars): add screens, navigation and view modes
Adds the parent CalendarsScreen with Feed/Month/Week/Day/Collections
view-mode chips, a drawer + bottom-bar slot, top-bar follow-list
filter, and a FAB menu that opens the create flows. Feed view splits
events into Upcoming / Past sections; Month view is a 7-column grid
with event-dot indicators per day; Week view is a 7-day chip strip
with the selected day's events listed below; Day view stacks events
on a vertical timeline. Calendar collections (kind 31924) get their
own list view.

https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
2026-05-19 21:58:31 +00:00
Claude
fcab611bc4 feat(calendars): add NIP-52 feed foundation, DAL and relay subscription
Wires per-account calendar follow-list settings, two feed filters
(appointments 31922/31923 and collections 31924), and a relay
subscription assembler that pulls all four calendar kinds. Sorts
the appointment feed with upcoming events first, past events after,
so the calendar timeline behaves like a calendar rather than a chat
feed.

https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
2026-05-19 21:58:31 +00:00
Claude
2326738b84 fix(marmot): route reply button on MLS messages to the encrypted group
Tapping reply on a Marmot/MLS (kind:445) message in the Notifications
screen previously fell through routeReplyTo()'s `else` branch and opened
the generic public-comment composer (Route.GenericCommentPost). Sending
it would publish a plaintext kind:1111 that references the encrypted
inner event id, leaking that the user has decrypted that group message.

Mirror the NIP-17 pattern (Route.Room carries replyId, draftId,
draftMessage; the same chat screen renders the "replying to" quote
above the input) for MLS:

- routeReplyTo() now detects MarmotGroupChatroom in note.inGatherers,
  matching how routeFor() at line 67 already finds the parent group, and
  returns Route.MarmotGroupChat(groupId, replyId = note.idHex).
- Route.MarmotGroupChat gains message/replyId/draftId fields.
- MarmotGroupChatView resolves the replyId into a Note, shows
  DisplayReplyingToNote above the composer, wires onWantsToReply for
  in-chat replies, and threads the parent inner event through
  AccountViewModel.sendMarmotGroupMessage into
  MarmotManager.buildTextMessage, which now adds a NIP-18 q-tag on the
  inner kind:9 (the same convention ChatEvent.reply() uses).

Push notifications: notifyGroupMessage previously passed
chatroomMembers=null, so the inline Reply action was never attached for
MLS group notifications. Add a parallel MARMOT_REPLY_ACTION wired with
the group id + parent inner event id; NotificationReplyReceiver loads
the account, rebuilds the parent inner event from LocalCache (or sends
unthreaded if the cache was pruned), and publishes the reply through
the same MarmotManager path — so the inline notification reply stays
encrypted inside the group instead of taking the NIP-17 PTag fallback.
2026-05-19 21:45:53 +00:00
Crowdin Bot
333a3f92ed New Crowdin translations by GitHub Action 2026-05-19 21:44:44 +00:00
Vitor Pamplona
6e37133b27 Merge pull request #2993 from vitorpamplona/claude/fix-geohash-loading-EJTQY
Refactor geolocation retry logic with suspend coroutines
2026-05-19 17:43:12 -04:00
Claude
6c4ff97cf1 fix(location): stop spinning Around Me when Geocoder is unavailable
On AOSP / GrapheneOS without Google Play Services (or microG), Android's
system Geocoder has no backing IGeocodeProvider, so `Geocoder.isPresent()`
returns false. Two compounding bugs left the Around Me top-bar spinner
running forever in that case:

1. `CachedReversedGeoLocations.geoLocate` only invoked `onReady` when
   Geocoder was present AND returned a non-null, non-blank city name. In
   the not-present and empty-result paths the callback never fired.
2. `LoadCityName` looped while `notReady`, but `notReady` was only flipped
   off when `newCityName != cityName`. With both null the loop never
   terminated, doubling its delay each pass.

Now `geoLocate` always calls `onReady` exactly once, short-circuiting
with `null` when no Geocoder backend exists. `LoadCityName` bridges the
callback to a suspending call, caps retries at 5, and falls back to
rendering the raw geohash so the spinner always stops. The "no geocoder
backend" check short-circuits the whole retry path on devices that will
never be able to resolve a name.
2026-05-19 21:32:11 +00:00
Vitor Pamplona
53d4a7ecae Merge pull request #2992 from vitorpamplona/claude/reorder-nav-filter-popup-b3WLj
Reorder FeedGroup enum for improved UX
2026-05-19 17:29:19 -04:00
Claude
77a9bc8754 feat: move DVMs before Communities in filter popup 2026-05-19 21:28:51 +00:00
Claude
b82ad10ca5 feat: place interest sets next to hashtags in filter popup 2026-05-19 21:25:12 +00:00
Claude
a568cbbc74 feat: reorder feed filter popup sections
Reorder sections in the top nav feed filter popup to: Feeds, Relays,
Hashtags, Locations, Communities, Lists, Interest Sets, DVMs.
2026-05-19 21:21:38 +00:00
Vitor Pamplona
d42482ff56 Merge pull request #2990 from vitorpamplona/claude/add-i2p-privacy-option-nK2X7
Add I2P support with unified privacy routing
2026-05-19 16:56:54 -04:00
Claude
40995b9670 refactor(privacy): drop I2pType.INTERNAL — EXTERNAL is the permanent answer
Earlier commits kept I2pType.INTERNAL as a placeholder for a follow-up
embedded daemon. We're not shipping that: I2P bootstrap on Android is
structurally minutes-long (no equivalent of Tor's hardcoded directory
authorities — NetDB peer discovery is protocol-inherent), so an embedded
router would mean a permanently-warming UX. Users who want I2P run i2pd /
Java I2P independently and point Amethyst at its SOCKS port.

Changes:
- commons I2pType drops the INTERNAL variant; parseI2pType collapses unknown
  codes to OFF
- I2pManager loses its INTERNAL switch arm
- I2pSettingsDialog drops the "treat INTERNAL as OFF" mapping it used to
  carry — the enum no longer has the case
- Android UI I2pSettings.resourceId drops the i2p_internal branch
- strings.xml drops the now-unused i2p_internal string
- I2pSharedPreferences hardens load: a stored "INTERNAL" from an earlier
  branch is no longer a valid I2pType, so runCatching swallows the
  IllegalArgumentException and the user lands on OFF
- PrivacyRouterTest fixtures use I2pType.EXTERNAL throughout
2026-05-19 20:37:53 +00:00
Vitor Pamplona
a8b6766f49 Merge pull request #2989 from vitorpamplona/claude/fix-zoomable-dialog-animation-3U3DK
Fix zoomed image dismiss animation to start from actual bounds
2026-05-19 15:59:18 -04:00
Claude
76c4771b58 fix(ui): start zoomable dialog close animation from zoomed bounds
The close animation lerped from the image's unzoomed layout bounds to
the source thumbnail rect, so dismissing a zoomed-in image jumped: the
image was visible at the inner zoomable's transformed bounds but the
exit animation rewound from the layout bounds instead.

Hoist the per-page ZoomState up to the dialog and feed its live scale +
offset into the outer graphicsLayer, so the grow/shrink animation
matches whatever the user is currently seeing on screen. At identity
zoom (entry case) behavior is unchanged.
2026-05-19 19:42:07 +00:00
Vitor Pamplona
1f0eab5f1d Merge pull request #2985 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-19 15:36:57 -04:00
Vitor Pamplona
bcb6f131dd Merge pull request #2988 from vitorpamplona/claude/review-bitcoin-blockchain-plan-6NGZ5
Plan: local Bitcoin headers explorer for NIP-03 OTS (revised 2026-05-19)
2026-05-19 15:36:42 -04:00
Claude
24cf3fdc5f docs: update onchain-zap + headers-explorer plans for NIP-BC inline SPV tags
nostr-protocol/nips#2332 adds optional ["block", …] and ["proof", …] tags
on kind:8333 that ship the SPV proof inline. The data-model side is already
shipped in Quartz (BlockTag, ProofTag, OnchainZapEvent.block()/.proof() and
the TagArray helpers), but no production code path produces or consumes
either tag yet.

amethyst/plans/2026-05-14-onchain-zaps.md — onchain zaps plan:
- Update the "Chain backend" decision bullet to flag the spec change and
  the two production gaps (send-side emit, receive-side consume).
- Add a dedicated "Inline SPV proofs" section covering: spec status and
  the merkle-proof encoding ambiguity flagged back to the PR; per-layer
  status matrix (shipped vs gap, with file locations); send-side
  two-publish design (Design B — keep instant receipt, add post-confirm
  republish with block+proof; dedupe by (txid, target)); receive-side
  fast-path with fall-through on proof failure (never hard-reject);
  phased delivery G.1–G.7 with effort estimates (~3–4 d after S1 ships
  and spec encoding lands).
- Add the two new pending items to the existing "What's still pending"
  list to keep that section authoritative.

quartz/plans/2026-05-08-local-headers-explorer.md — headers-explorer plan:
- Rewrite §19 (follow-up onchain-zap verification) to reflect the spec
  change. Original section assumed we'd need BIP-37 merkleblock or
  full-block fetch over P2P; with the proof inline none of that
  infrastructure is required. Estimate collapses from 10–15 d to ~3–4 d.
- Point §19 at the full implementation plan in the onchain-zaps file,
  keeping S1 focused on OTS while the follow-up details live with the
  rest of the NIP-BC work.
2026-05-19 19:17:36 +00:00
Vitor Pamplona
3f458f8316 Merge pull request #2987 from vitorpamplona/claude/sync-notification-filters-RbvPY
Align push notifications with in-app feed filtering
2026-05-19 14:55:27 -04:00
Claude
d19bcf07b0 docs(quartz): add interop-test-vectors section to local-headers-explorer plan
Every consensus-relevant layer of the planned headers explorer (BlockHeader80
parser, DifficultyTarget compact↔target, CalculateNextWorkRequired retarget,
MedianTimePast, header validator end-to-end, P2P wire codecs, reorg/chain
selection, OTS proofs) is pinned to upstream test vectors committed under
quartz/src/commonTest/resources/bitcoin/, matching the existing
nip44.vectors.json / bip39.vectors.json / mls/*.json pattern.

The single highest-value test is a nightly differential check asserting
LocalHeadersBitcoinExplorer.blockHash(h) == OkHttpBitcoinExplorer.blockHash(h)
for every height in [checkpoint, tip] — any consensus drift surfaces as a
disagreeing height.

Maps cleanly onto the existing Phase 1/3/4/5/7/9 work, adding ~5–7
engineer-days total to the plan budget. No new top-level phase needed.
2026-05-19 18:35:30 +00:00
Claude
c75c2013fb fix(notifications): resolve addressable events to their replaceable note
LocalCache.getNoteIfExists(event.id) returns the id-keyed version note,
which has its replyTo moved to the replaceable note during insertion
(consumeBaseReplaceable). The id-keyed lookup therefore returns an empty
replyTo for AddressableEvent kinds — LongTextNoteEvent, WikiNoteEvent,
LiveChess*, VideoHorizontal/Vertical — and NotificationFeedFilter's
replyTo-based check in tagsAnEventByUser silently fails for replies into
long-form articles or wiki notes.

Switch to LocalCache.getNoteIfExists(event), which dispatches on
AddressableEvent and returns the address-keyed note with proper replyTo.
Applied in three places: the dispatcher predicate, consumeFromCache's
per-event match, and dispatchForAccount's muted-thread check (the last
one only handles non-addressable kinds today but is updated for
consistency).
2026-05-19 18:32:49 +00:00
Claude
f3637dd7c1 docs(quartz): revise local-headers-explorer plan — SQLite, no bundle, OTS-only
Lock the design choices from the 2026-05-19 review against the intervening
2026-05-14 onchain-zaps work:

- Move the module from quartz/.../nip03Timestamp/bitcoin/ to a sibling
  quartz/.../bitcoin/ package so the headers explorer, header validator,
  peer pool and store can be reused by the future onchain-zap merkle-proof
  work without an inverted import path.
- Use androidx.sqlite + BundledSQLiteDriver for HeaderStore, matching the
  existing SQLiteEventStore. Schema lives in commonMain via IModule. Drops
  the hand-rolled flat-file + sidecar height index.
- Drop the bundled headers blob. Ship a single hardcoded PinnedCheckpoint
  constant; first-run sync starts from the checkpoint and pulls forward
  over P2P. APK growth: 0 bytes.
- Pre-checkpoint OTS heights fall through to OkHttpBitcoinExplorer via
  BitcoinExplorerEndpoint (shared with the onchain-zap EsploraBackend).
  Strict-mode users get an explicit error instead of a network call.
- Mark trustless NIP-BC onchain-zap verification as out of scope and
  capture it as a follow-up plan (BIP-37 merkleblock or full-block fetch
  on top of this stack).

Resolves open questions Q1, Q2 and Q4 from the original plan; Q3 (Quartz
public API vs internal) left open for Phase 0.
2026-05-19 18:14:30 +00:00
Claude
36e285f684 fix(notifications): WakeUp bypass + lookup hoist + comment cleanup
- WakeUpEvent.notifies is unconditionally true (wake every signed-in
  account). The previous commit's isTaggedUser-only routing dropped
  WakeUps that didn't happen to p-tag a logged-in pubkey, breaking the
  wake-the-device-for-everyone semantic. Both the dispatcher predicate
  and consumeFromCache now bypass the feed-style match for WakeUpEvent.

- LocalCache.getNoteIfExists(event.id) is hoisted out of the per-pubkey
  any-loop in the dispatcher predicate and out of the per-account forEach
  in consumeFromCache. The note is the same for every account, so one
  lookup per event is enough.

- Drop RepostEvent / GenericRepostEvent from the new muted-thread check
  in dispatchForAccount: they aren't routed below to any notify(), so the
  guard was partial dead code. Comment updated to call out that push has
  no repost notifier today (the feed does — separate gap).

- Comment on the dispatcher predicate now flags the two behavior deltas
  vs. the old event.notifies routing: WakeUp bypass, and NIP-22 Comments
  where the user is only the root author (uppercase `P`) no longer push.
2026-05-19 18:12:44 +00:00
Vitor Pamplona
8d4089e7e9 Merge pull request #2986 from vitorpamplona/claude/fix-illegal-argument-exception-V4AAM
Fix feed sorting race condition with snapshot-based comparator
2026-05-19 14:05:33 -04:00
Claude
df87557ef3 fix: stable sort in ShortsFeedFilter to avoid TimSort contract crash
A newer AddressableEvent (e.g. VideoVerticalEvent) arriving on a relay
thread can swap a Note's event mid-sort, changing the createdAt value
the comparator returned moments earlier. TimSort then trips
"Comparison method violates its general contract!" and the feed
refresh crashes.

Snapshot createdAt once per note before sorting via a new
sortedByDefaultFeedOrder() helper.
2026-05-19 17:57:15 +00:00
Claude
49b244f026 fix: align push notifications with Notifications feed filter
Push notification routing now uses the same recipient + per-kind logic the
in-app Notifications feed uses (isTaggedUser + tagsAnEventByUser), so the
two surfaces agree on what counts as a mention, reply, citation, fork, or
community moderation. Adds the explicit muted-thread check for
reactions/zaps/reposts that the feed performs on the target note.

tagsAnEventByUser is hoisted to NotificationFeedFilter's companion so the
dispatcher and consumer can call it without depending on a feed instance.
2026-05-19 17:26:33 +00:00
Crowdin Bot
8b3ed48fb6 New Crowdin translations by GitHub Action 2026-05-19 17:21:37 +00:00
Vitor Pamplona
ed686b71ed Merge pull request #2981 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-19 13:20:01 -04:00
Vitor Pamplona
37ea5dea01 Merge pull request #2984 from vitorpamplona/claude/fix-zap-validation-6CbFV
NIP-57 Appendix F zap receipt validation with LNURL provider verification
2026-05-19 13:19:52 -04:00
Crowdin Bot
100533dfd7 New Crowdin translations by GitHub Action 2026-05-19 17:14:23 +00:00
Vitor Pamplona
88fd15eefc Merge pull request #2982 from vitorpamplona/claude/fix-video-notification-clickable-O811N
Fix playback notification tap target in warm-pool fast path
2026-05-19 13:12:37 -04:00
Vitor Pamplona
872533f102 Merge pull request #2983 from vitorpamplona/claude/fix-empty-transaction-chips-edyYE
Improve onchain transaction filter UX with separate empty states
2026-05-19 13:12:13 -04:00
Claude
0c2eecf62c fix: make playback notification tap open the note on warm-pool resume
The playback notification's tap target (MediaSession.setSessionActivity)
was only bound from MediaSessionCallback.onAddMediaItems, which fires
when the in-app MediaController calls setMediaItem. GetVideoController's
warm-pool fast path skips setMediaItem when the acquired ExoPlayer was
retained with the same mediaId, so the new MediaSession was left with
no session activity and tapping the notification did nothing.

Bind the PendingIntent in newSession() from the acquired player's
current MediaItem extras, and share the construction with onAddMediaItems
via a single helper.
2026-05-19 16:33:55 +00:00
Claude
4346427e9f Validate zap receipts against LNURL provider's nostrPubkey (NIP-57 Appendix F)
Receipts were only being checked for a valid event signature — anyone could
sign a kind:9735 and have it counted toward another user's zap totals. NIP-57
Appendix F mandates three additional checks: receipt.pubkey == LNURL
provider's nostrPubkey (MUST), bolt11 invoice amount == zap request "amount"
tag (MUST), and lnurl tag == recipient's lnurl (SHOULD).

- Adds LnZapReceiptValidator + LnurlForm in quartz commonMain (pure logic).
- Adds LnurlEndpointCache (jvmAndroid) and the LnurlEndpointResolver
  interface for async lookup. The cache is primed by outbound zaps (existing
  LightningAddressResolver fetches now extract nostrPubkey) and on demand for
  inbound receipts when no entry is present.
- Adds OkHttpLnurlEndpointResolver in commons, wired into LocalCache via
  AppModules using the existing money-tier OkHttp builder (so Tor settings
  apply).
- LocalCache.consume(LnZapEvent) now: drops receipts that fail MUST checks
  synchronously when the cache is warm, defers credit until async resolution
  finishes on cache miss, and falls back to legacy signature-only behavior
  when no resolver is wired (tests).
- LnZapRequestEvent.create() now accepts amountMillisats + lnurl; both are
  threaded through Account.createZapRequestFor and emitted as tags so future
  receipts can be validated against them.

21 new tests cover validator reasons, lnurl form canonicalization across
lud16/URL/bech32, and cache eviction.
2026-05-19 16:19:29 +00:00
Claude
cb6e1fe696 fix: keep filter chips visible on empty onchain transactions list
When a filter (e.g. Non-Zaps) excluded every loaded transaction, the
empty-state composable replaced the whole screen body — including the
filter chip row — so the user couldn't switch back to All or Zaps
without leaving the screen. Expose `hasAnyTransactions` from the
ViewModel so the screen distinguishes "no chain rows at all" from "no
rows for this filter": the chips stay rendered as long as any
transaction has loaded, and the LazyColumn shows a per-filter empty
message inline beneath them. Also drop the bc1 address header from the
list since the screen title already identifies the wallet.
2026-05-19 16:18:33 +00:00
Vitor Pamplona
1afa86cb26 Merge pull request #2974 from vitorpamplona/claude/clickable-wallet-card-hHTbM
Add on-chain transaction history screen with pagination
2026-05-19 10:35:31 -04:00
Vitor Pamplona
facdd3dc8f Merge pull request #2978 from davotoula/feat/hashtag-limit-plurals-i18n
Convert hashtag-limit message to <plurals>
2026-05-19 10:35:05 -04:00
Vitor Pamplona
439331722a Merge pull request #2973 from vitorpamplona/claude/stop-video-background-timeout-jSLCO
Release MediaController after 30s background timeout
2026-05-19 10:34:44 -04:00
Vitor Pamplona
7b720a9a8d Merge pull request #2977 from davotoula/feat/onchain-zaps-reactions-gallery
Show on-chain Bitcoin zappers as a dedicated ₿ row in the expanded reactions gallery
2026-05-19 10:34:19 -04:00
Vitor Pamplona
e94e1782c1 Merge pull request #2975 from nrobi144/fix/desktop-deb-launch-crash
test(desktop): add Compose UI smoke test + release .deb launch CI
2026-05-19 10:33:49 -04:00
Vitor Pamplona
a747e20c86 Merge pull request #2980 from vitorpamplona/claude/debug-longpress-root-note-4UR5I
Fix popup menu positioning and parameter naming
2026-05-19 10:01:12 -04:00
Claude
f2f02ab5cb fix(thread): restore long-press on root note + anchor popup to the card
Two related fixes to the thread quick-action popup:

- ThreadFeedView.FullBleedNoteCompose declared a `modifier` parameter
  that NoteMaster used to attach `combinedClickable(onLongClick = showPopup)`,
  but the body built a fresh `Modifier.fillMaxWidth().padding(top = 10.dp)`
  for its root Column and discarded the incoming modifier. Long-press on
  the root note in thread view never fired. Spread the incoming modifier
  onto the Column.

- LongPressToQuickAction emitted the Popup as a sibling of the content
  with no wrapping layout. The Popup's `parentLayoutCoordinates` then
  resolved to the enclosing LazyColumn, so `alignment = Alignment.Center`
  centered the menu on the whole list (visually middle of the screen)
  instead of the long-pressed card. Wrap content + Popup in a Box so the
  popup's parent bounds match the note card.
2026-05-19 13:45:56 +00:00
David Kaspar
61a282ba28 Merge branch 'main' into feat/hashtag-limit-plurals-i18n 2026-05-19 15:39:24 +02:00
David Kaspar
5973429860 Merge pull request #2979 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-19 15:38:46 +02:00
Crowdin Bot
e1e185a6f2 New Crowdin translations by GitHub Action 2026-05-19 13:26:50 +00:00
Vitor Pamplona
de222d152b Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2026-05-19 09:24:34 -04:00
davotoula
8c3868bc32 i18n: convert hashtag-limit message to <plurals> and add cs/pt-BR/sv/de translations 2026-05-19 14:36:17 +02:00
davotoula
cf44c092dd Code review:
- invalidate zaps flow when removeAllChildNotes clears onchainZaps
- simplify on-chain zap gallery after review
2026-05-19 13:10:02 +02:00
davotoula
91ded74636 Show on-chain zappers in expanded reactions gallery
- Add OnchainZappedIcon and PendingClockBadge
- move PendingClockBadge to TopStart to avoid follow-dot clash
2026-05-19 13:10:02 +02:00
David Kaspar
d41cf6d75b Merge pull request #2976 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-19 12:34:53 +02:00
nrobi144
5ad21b6acd fix(desktop): disable ProGuard optimization entirely — fixes kmp-tor crash
Disabling individual sub-passes (method/specialization/returntype,
method/marking/static) was not sufficient. The interaction between
multiple optimization passes causes IncompatibleClassChangeError in
kmp-tor's AsyncFs.of() at launch. Shrink (dead code removal) stays
ON for the size win; only optimize is disabled.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-19 10:11:11 +03:00
Crowdin Bot
7c3399438d New Crowdin translations by GitHub Action 2026-05-19 06:50:42 +00:00
David Kaspar
733e2945bd Merge pull request #2972 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-19 08:49:16 +02:00
nrobi144
487dd3f2ac fix(desktop): disable method/marking/static ProGuard optimization
The smoke test discovered that ProGuard's method/marking/static pass
converts kmp-tor's AsyncFs.of() from instance to static. The JVM
then throws IncompatibleClassChangeError at launch because callers
still use invokevirtual.

Also updates smoke-test-desktop.yml trigger: runs on any PR touching
desktopApp/ (not just build config files).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-19 09:48:01 +03:00
nrobi144
c0c055e771 fix(desktop): restore java.management module — confirms #2819 fix
Reverts the intentional breakage from d0a6bbc96. The smoke test
proved it catches the crash: removing java.management from jlink
modules causes a fatal IncompatibleClassChangeError in kmp-tor
at launch (the module is needed for ManagementFactory + tor runtime).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-19 09:26:26 +03:00
nrobi144
d0a6bbc96f test(ci): intentionally remove java.management to verify smoke test catches #2819
This commit should FAIL the release-deb-launch CI job, proving the
smoke test works. Will be reverted in the next commit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-19 09:05:10 +03:00
nrobi144
2010e41b25 fix(ci): allow dpkg post-install error, verify binary extracted
dpkg -i exits non-zero when the post-install script fails even with
--force-all. Allow the error with || true, then verify the binary
was actually extracted to /opt before proceeding.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-19 07:59:33 +03:00
nrobi144
cca9c2ff3a fix(ci): use dpkg --force-all to skip xdg-desktop-menu error on runner
jpackage's post-install script calls xdg-desktop-menu which fails on
GitHub Actions runners with "No writable system menu directory found".
Menu registration is irrelevant for smoke testing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-19 07:39:34 +03:00
nrobi144
cf6541a1e1 test(desktop): add Compose UI smoke test + release .deb launch CI
Fixes #2819 — v1.08.0 .deb crashed on Ubuntu with ManagementFactory
error because the build had no jlink modules() declaration. Current
main already has the fix; this PR adds CI to prevent regressions.

- Add compose.desktop.uiTestJUnit4 dependency
- DesktopLaunchSmokeTest: renders LoginScreen, asserts title text
- build.yml: xvfb for Linux leg so UI test runs on every PR
- smoke-test-desktop.yml: builds release .deb, installs it, launches
  under xvfb, verifies process stays alive 10s

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-19 07:09:16 +03:00
Claude
7dce3a420f feat(wallet): time-windowed relay sub + coalesce zap bursts
Two improvements to onchain history attribution:

1. Coalesce UI updates. The OnchainZapEvent observation in the
   ViewModel now passes through .sample(500), so a relay flooding
   a backlog of historical zaps in quick succession produces at
   most one map update per 500ms instead of one per event. The
   downstream combine + StateFlow conflation handles the rest.

2. Bound relay queries to the visible window. New
   OnchainZapsFilterAssembler (SingleSubEoseManager-based) wakes
   only while the screen is on top and asks the user's inbox/
   outbox relays for kind-8333 events with since = the oldest
   visible blockTime — incoming (p-tag = user) and outgoing
   (authors = user) — so we don't drag the whole NIP-BC history
   when the user only scrolled through last week. As the user
   pages back, oldestBlockTime drops, the assembler re-subscribes
   with a wider window.

OnchainTransactionsScreen now wires OnchainZapsFilterAssemblerSubscription
with the StateFlow-derived window, and the coordinator owns the
parent assembler so the lifecycle matches other screen subs.
2026-05-19 00:52:12 +00:00
Claude
020d5195b5 feat(wallet): observe onchain zaps reactively, no more per-row scan
OnchainTransactionsViewModel now subscribes to LocalCache via
observeEvents for kind-8333 zaps that either p-tag the user
(incoming) or are signed by the user (outgoing), merging both into
a txid -> OnchainZapEvent map. The FilterIndex inside LocalCache
narrows the fanout so we only wake on relevant events.

The list of chain rows and the zap map are now independent
StateFlows combined into the displayed views, so a zap event
arriving after the page has loaded immediately attributes the
existing row to its Nostr counterparty without a refresh.

Replaces the per-row LocalCache.notes scan from the previous commit.
2026-05-19 00:35:31 +00:00
Claude
4e796c8e8b feat(privacy): plug remaining HTTP paths into the route-aware stack
Three callers were still bypassing PrivacyRouter and would route Tor-only or
direct-only regardless of the picker:

- Coil ImageLoader (AppModules): called
  okHttpClients.getHttpClient(shouldUseTorForImageDownload(it)), which
  collapses to a boolean and can't pick I2P. Now uses
  roleBasedHttpClientBuilder.okHttpClientForImage(it) so images route through
  the user's preferred clearnet transport and hidden-service hostnames
  hard-pin.
- ExoPlayer pool (PlaybackService): had separate poolNoProxy / poolWithProxy
  named fields and bound them to getDynamicCallFactory(useProxy: Boolean) —
  i.e. with-proxy was always the Tor-proxied client. Reshaped to a
  poolsByPort: Map<Int, MediaSessionPool> keyed by the SOCKS port returned
  by proxyPortForVideo(url). DualHttpClientManager gains getHttpClientForPort
  and PortBasedCallFactory to resolve the right OkHttpClient (direct / Tor /
  I2P) live based on port.
- Nostr relay websocket builder (AppModules): only consulted torEvaluator,
  which has no notion of .i2p. Now hard-pins .onion to Tor and .i2p to I2P
  (throwing BlockedRouteException via the fail-closed contract when the
  matching daemon isn't ready) and falls through to torEvaluator for
  clearnet relays — the existing TorRelayEvaluation per-relay-class booleans
  stay as-is for clearnet routing.
2026-05-19 00:16:13 +00:00
Claude
19c750dede feat(privacy): BlockedRouteException — typed fail-closed signal
Replaces the inline IOException thrown by DualHttpClientManager.getHttpClient
when a hidden-service URL is requested while its matching daemon is off.
The new exception extends IOException so existing HTTP error paths (Coil,
OkHttp call factories, etc.) propagate it unchanged, but carries the
BlockReason so future UI work can surface a clear "Enable Tor / I2P to view
this content" hint instead of just a broken-image placeholder.

DualHttpClientManager.blockedException(...) now returns BlockedRouteException
rather than a bare IOException; the messages move into the typed exception's
companion.

Tests cover the message wording (mentions Tor/.onion and I2P/.i2p) and pin
the factory's return type so the typed information can't be silently widened
back to a bare IOException without breaking the test.

Surfacing this to the UI (snackbar on image-load fail, etc.) is left to a
follow-up — that work is cross-cutting (Coil event listeners, NIP-05 error
paths, etc.) and orthogonal to the routing decision itself.
2026-05-19 00:12:55 +00:00
Claude
2a9514d657 refactor(privacy): RoleBasedHttpClientBuilder routes via PrivacyRouter
All ad-hoc HTTP traffic now consults PrivacyRoutingFlow → PrivacyRouter:
images, videos, URL previews, NIP-05 lookups, Money operations, uploads, push
registration. Each call computes a PrivacyRoute (Direct / Tor / I2p /
Blocked) and asks DualHttpClientManager for the matching OkHttpClient,
which in turn picks the right SOCKS-attached client or — for Blocked
hidden-service routes — throws IOException so the request fails closed
instead of silently leaking to the clearnet.

Constructor switched from TorSettingsFlow to PrivacyRoutingFlow. AppModules
swaps the wiring; the flow construction order now puts privacyRouting
before the builder.

Legacy shouldUseTorFor* booleans are kept but now return true iff the route
would actually end up on Tor — they no longer drive routing themselves.
External method-references in AppModules (OtsResolver, etc.) keep working
with the same semantics they had before for clearnet calls.

Push registration still routes through the URL_PREVIEW role (closest analogue
to the old trustedRelaysViaTor global flag, which had no URL to consult).
That's fine for now — trusted-relay push services live on the clearnet and
hard-pin behavior is unchanged for hidden-service push URLs.
2026-05-19 00:11:23 +00:00
Claude
e977b96ee6 revert(wallet): drop onchain txid index, scan notes instead
The dedicated ConcurrentHashMap<txid, OnchainZapEvent> in LocalCache
didn't pay for itself at current NIP-BC volumes. The on-chain
transactions view goes back to scanning LocalCache.notes for the
matching OnchainZapEvent per row.
2026-05-19 00:10:53 +00:00
Claude
5da20a9b44 feat(privacy): I2pManager + tri-state DualHttpClientManager
I2pManager: EXTERNAL-only mirror of TorManager. When the persisted I2pType is
EXTERNAL it emits Active(externalSocksPort); OFF and INTERNAL both surface as
Off (no embedded daemon in this branch). Exposes activePortOrNull as a
StateFlow<Int?> with the same shape Tor uses, so the http client managers can
keep the proxy-port wiring symmetric.

DualHttpClientManager + DualHttpClientManagerForRelays:
- New constructor param i2pProxyPortProvider: StateFlow<Int?> (defaults to a
  closed null flow so existing tests / callers compile unchanged).
- Third StateFlow<OkHttpClient> built against the I2P SOCKS port.
- New route-aware methods:
    getHttpClient(route: PrivacyRoute): OkHttpClient
    getCurrentProxyPort(route: PrivacyRoute): Int?
  Direct → no-proxy client, Tor → Tor-proxy client, I2p → I2P-proxy client,
  Blocked → throws IOException with a user-readable reason so the call fails
  closed instead of silently leaking through the clearnet.
- Existing boolean-based methods (getHttpClient/useProxy) untouched.

AppModules wires i2pManager.activePortOrNull into both http managers. No
caller switched onto the new methods yet — that's the next chunk
(RoleBasedHttpClientBuilder reshape).
2026-05-19 00:09:29 +00:00
Vitor Pamplona
ab3d1dd7e5 fix(quartz/sqlite): set busy_timeout to deflake reader+writer races
Without busy_timeout SQLite returns SQLITE_BUSY immediately when
BEGIN IMMEDIATE can't acquire a lock — e.g. during a WAL
auto-checkpoint or a reader briefly upgrading its snapshot — instead
of retrying. ParallelInsertTest's reader+writer test hit this ~10%
of runs. 5s matches Room's default and adds no overhead in the
uncontended case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:02:35 -04:00
Claude
a42a156f13 feat(privacy): extend PrivacyOptionsScreen with I2P section and clearnet picker
Same Privacy Options screen as before, now also surfaces:
- A three-way "Privacy Network" picker (Direct / Tor / I2P) for clearnet
  traffic. Hidden services aren't affected — .onion / .i2p still hard-pin to
  their matching daemon (and fail closed via PrivacyRouter when it's off).
- An I2P engine + per-feature toggle section mirroring the existing Tor
  section. Only OFF and EXTERNAL are offered in the picker until an embedded
  daemon ships; persisted INTERNAL is treated as OFF in the UI but kept in the
  enum so a future commit can light it up without a migration.
- Save action posts all three settings — Tor, I2P, preferred transport — to
  their separate DataStore-backed writers in one click.

New UI pieces:
- I2pDialogViewModel — mirror of TorDialogViewModel, no preset DSL since
  there's no canonical I2P configuration yet
- I2pSettingsBody — composable mirror of PrivacySettingsBody, shares
  SwitchSettingsRow with the Tor body
- PrivacyTransportPickerViewModel + PreferredTransportPicker — global picker
- I2pType.resourceId — Android string-id extension matching TorType.resourceId

Strings: i2p_* per-feature toggles, i2p_off/internal/external, i2p_socks_port,
privacy_clearnet_transport (+ direct / tor / i2p labels).

UI is wired against the live flows from AppModules (torPrefs / i2pPrefs /
privacyPrefs from the previous commits). No HTTP routing change — those flows
still go through TorManager only; the new toggles only have routing impact
once DualHttpClientManager grows tri-state in the daemon chunk.
2026-05-18 23:35:37 +00:00
Crowdin Bot
cfa19ce7da New Crowdin translations by GitHub Action 2026-05-18 23:22:26 +00:00
Vitor Pamplona
0d91245e02 Merge pull request #2971 from vitorpamplona/claude/add-nowhere-links-LPMnM
Add Nowhere link detection and branded card rendering
2026-05-18 19:20:34 -04:00
Claude
4d428025d3 feat(voice): pause voice notes on background like videos
Voice notes go through GetVideoController, so the 30s background
release timer + warm-pool reattach already applied to them. What was
missing was the immediate ON_PAUSE handler: voice didn't use
ControlWhenPlayerIsActive, so playback kept running for the full 30s
before the release timer hit and cut it off via the warm-pool pause.

Extract a small PauseControllerWhenInBackground composable from the
ON_PAUSE arm of ControlWhenPlayerIsActive and call it from VoiceTrack.
Result matches video: pause on ON_PAUSE, release at 30s, reassemble
the controller (paused, position preserved) when the user returns —
no auto-resume since voice has no autoplay setting.

PiP exemption (BackgroundMedia.isMutex) is preserved.

https://claude.ai/code/session_01RoEUbAN8ejF21Ns3eM6xad
2026-05-18 23:18:51 +00:00
Claude
b16d44721c perf(richtext): collapse nowhere-link classifier into the existing URL branches 2026-05-18 22:54:35 +00:00
Claude
acf3daaf75 feat(wallet): tappable onchain rows + txid index in LocalCache
- LocalCache now keeps a ConcurrentHashMap<txid, OnchainZapEvent>
  populated in consume(OnchainZapEvent). First sender to claim a
  txid wins. The on-chain transactions ViewModel switches to
  LocalCache.getOnchainZapByTxid(txid), replacing the per-row scan
  of notes.
- Each row in OnchainTransactionsScreen is now clickable: rows with
  a matched zap event navigate to the zap's note thread; the rest
  open the transaction on mempool.space in the system browser.
2026-05-18 22:54:04 +00:00
Claude
b71b7cb420 fix(video): skip background release timer for PiP
The 30s release timer fires on ON_PAUSE, but PipVideoActivity enters
PiP mode (which dispatches ON_PAUSE) immediately after onCreate. The
MediaController is built asynchronously and RegisterBackgroundMedia
runs only after inner() mounts, so there's a window where the timer's
BackgroundMedia.isMutex check sees a stale (or null) bgInstance and
releases a controller that was about to start background playback —
blanking the PiP window.

Add an opt-out parameter on GetVideoController and disable the timer
from PipVideoActivity. PiP IS the explicit background-playback opt-in,
so the timer doesn't apply by design.

https://claude.ai/code/session_01RoEUbAN8ejF21Ns3eM6xad
2026-05-18 22:50:33 +00:00
Vitor Pamplona
8b31e60f52 Merge pull request #2965 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-18 18:36:46 -04:00
Claude
a2da9ae2c6 i18n: extract NowhereLinkCard tool labels to string resources 2026-05-18 22:33:23 +00:00
Claude
fdd954c2ed feat(privacy): PrivacyRoutingFlow facade reading from live pref flows
Read-side seam that snapshots TorSettingsFlow + I2pSettingsFlow + the
preferredClearnetTransport StateFlow into a PrivacySettings, then delegates
to PrivacyRouter.route. Gives the I2P daemon manager and the eventual HTTP
routing rewrite a single stable API to consult instead of poking every flow
themselves.

DualHttpClientManager is not touched in this commit — that grows to tri-state
in the chunk where the I2P daemon lands a real proxy port.

AppModules:
- Constructs `privacyRouting` alongside roleBasedHttpClientBuilder
- Uses torPrefs.value + i2pPrefs.value + privacyPrefs (added last commit)

No callers wired yet — this is the seam those callers will move onto.
2026-05-18 22:31:26 +00:00
Crowdin Bot
37f6f92c04 New Crowdin translations by GitHub Action 2026-05-18 22:14:57 +00:00
Vitor Pamplona
68c081602f Merge pull request #2966 from vitorpamplona/claude/fix-video-mime-type-sqkiz
Fix MediaStore MIME type compatibility for video files
2026-05-18 18:13:19 -04:00
Claude
09857b8924 Merge remote-tracking branch 'origin/main' into claude/fix-video-mime-type-sqkiz 2026-05-18 22:04:15 +00:00
Vitor Pamplona
af4835aff3 Merge pull request #2967 from vitorpamplona/claude/notification-settings-refactor-FZ1bx
Extract notification settings to dedicated screen
2026-05-18 17:58:04 -04:00
Claude
b2b4895570 refactor(notifications): cleaner Compose patterns in Categories section
- Replace `remember { side effect }` with lazy `entry.ensure(context)` in
  the row's onClick; the system per-channel page only needs the channel
  to exist at open time, not before, and the ensure call is idempotent.
- Replace the `refreshKey++` invalidation trick with a real
  `Map<channelId, ChannelStatus>` state holder updated inside
  `LifecycleResumeEffect`; downstream reads are direct map lookups.
- Replace `Triple`-with-destructuring in `ChannelStatusBadge` with three
  direct `when` branches calling `StatusChip` — no tuples, no
  temporaries.
- Drop the unused `notification_settings_open_system` string.
2026-05-18 21:53:32 +00:00
Claude
b4ef70176e test: cover MIME type normalization for MediaStore
Verify video/x-m4v maps to video/mp4 (the failing case), normalization
is case-insensitive, and supported image/video/audio types pass through
unchanged. Required making normalizeMimeTypeForMediaStore internal.
2026-05-18 21:51:58 +00:00
Claude
4d88444a59 feat(notifications): split delivery vs display, add Categories section
Reorganize Notification settings into three sections that reflect what
each control actually does:

- Delivery: how notifications reach the device — push provider (fdroid)
  and the always-on relay service live together here.
- In-app display: how the notifications screen renders incoming activity —
  currently the Split-by-Follows toggle.
- Categories: one row per user-facing Android NotificationChannel (DMs,
  Mentions, Replies, Reactions, Zaps, Chess, Scheduled posts, Calls),
  showing the current importance (On / Silent / Off) and opening the
  system per-channel settings page on tap. Foreground-service channels
  are intentionally omitted — disabling them breaks the service contract.

The screen ensures every listed channel exists on first open and
re-reads importance via LifecycleResumeEffect so the badge reflects
changes made in system settings.

API surface bumped for the channel registry:
- CallNotifier.CALL_CHANNEL_ID is now public.
- ScheduledPostNotifier.ensureChannel is now public.
2026-05-18 21:49:09 +00:00
Vitor Pamplona
d3c9f5a4f0 Merge pull request #2970 from vitorpamplona/claude/fix-timedout-exception-TJ2te
Refactor media upload to use AccountViewModel.launchSigner
2026-05-18 17:47:47 -04:00
Vitor Pamplona
54a6109d83 Merge pull request #2969 from vitorpamplona/claude/hashtag-limit-warning-FQwtJ
Add excessive hashtag detection to hidden note UI
2026-05-18 17:44:54 -04:00
Vitor Pamplona
2fff2c137c Merge pull request #2968 from vitorpamplona/claude/fix-image-loading-layout-DlLYr
Support floating-point dimensions in NIP-92 imeta tags
2026-05-18 17:43:29 -04:00
Claude
6be26e1412 fix(media): route media-upload signing through launchSigner
Replaces the previous narrow try/catch fix. The earlier patch silently
swallowed SignerExceptions in NewMediaModel / EditPostViewModel — no
toast, no error dialog — so the user would tap Post, see the dialog
close, and never learn that the signer prompt timed out or was
rejected. It also bypassed the project's standard signer-error
pipeline.

This version routes the sign+publish phase through
AccountViewModel.launchSigner, which is the same path every other
signing entry point uses:
- ReadOnly / SignerNotFound / UnauthorizedDecryption / IllegalState →
  toastManager surfaces a localized alert.
- TimedOut / ManuallyUnauthorized / etc. → logged silently (no crash),
  matching the rest of the app's behavior when a user dismisses the
  external signer.

NewMediaModel.upload now takes an AccountViewModel parameter; the three
inner viewModelScope.launch(Dispatchers.IO) blocks become
accountViewModel.launchSigner { ... }. The joinAll wait is preserved
(launchSigner returns Job).

EditPostViewModel.uploadUnsafe routes its single launch the same way
and wraps the body in try/finally so mediaUploadTracker.finishUpload()
still runs if a signer throws mid-flow.

launchSigner is changed from Unit to Job (= viewModelScope.launch ...)
so callers can join — non-breaking for the ~190 existing callsites that
ignore the return value.
2026-05-18 21:35:38 +00:00
Claude
48d9e80e20 refactor: address self-audit on notification settings
- Promote the duplicate `SwitchTile` from SecurityFiltersScreen and
  NotificationSettingsScreen into a shared `SettingsSwitchTile` in
  SettingsSectionCard so all settings switches share one implementation.
- Render BatteryOptimizationBanner outside the in-app section card
  instead of nesting a Card inside a Card and splitting the section's
  divider away from the row it explains.
- Refresh the battery-optimization exemption on LifecycleResumeEffect
  so the banner disappears after the user returns from the system
  settings page; drop the racy post-button re-read.
- Demote `HasPushNotificationProvider` to a plain `hasPushNotificationProvider`
  since it returns a per-flavor constant and reads no Compose state.
2026-05-18 21:34:32 +00:00
Vitor Pamplona
90272fea6f Merge pull request #2964 from vitorpamplona/claude/fix-classcastexception-android-CYKoy
Replace AtomicReference with LargeCache in RelayAuthenticator
2026-05-18 17:23:51 -04:00
Claude
669199ab7e refactor(quartz): use LargeCache for RelayAuthenticator authStatus
#2946 fixed the ClassCastException with a bespoke AtomicReference<Map>
+ CAS copy-on-write helper. Quartz already has a concurrent-map
abstraction for exactly this purpose — LargeCache — with platform-tuned
actuals (ConcurrentSkipListMap on jvmAndroid, CacheMap on Apple, custom
on Linux). Swap to it.

Removes the bespoke putAuthStatus/removeAuthStatus helpers, the
ExperimentalAtomicApi opt-in, and the AtomicReference imports.

The RelayAuthenticatorConcurrencyTest from #2946 still passes against
the new implementation.
2026-05-18 21:21:02 +00:00
Vitor Pamplona
a3b2d270c7 Merge pull request #2957 from mstrofnone/feat/search-bar-namecoin-resolution-indicator
feat(search): inline Namecoin resolution indicator in global search bar
2026-05-18 16:40:52 -04:00
m
1c5230cfc5 feat(search): inline Namecoin resolution indicator in global search bar
Reuses the NamecoinResolutionRow composable already shipping for the
onchain-zap recipient field, promoting it from
ui/screen/loggedIn/wallet/ to a generic ui/components/namecoin/
location so it can be mounted anywhere a .bit-shaped search input may
race the local-cache prefix search.

In the global search bar, typing a bare ".bit" host (e.g.
"testls.bit") used to surface a cached sibling profile like
"m@testls.bit" first (LocalCache.findUsersStartingWith hits the
prefix) and only several seconds later be corrected by the slower
on-chain ElectrumX resolution from
SearchBarViewModel.directNip05Resolver. No in-flight indicator and no
feedback on hard failures (timeout, malformed record, etc.).

Changes:

  - git-rename NamecoinResolutionRow.kt and its test from
    ui/screen/loggedIn/wallet/ to ui/components/namecoin/, updating
    the package declaration only.
  - Add an optional `modifier: Modifier = Modifier` parameter to the
    composable (standard Compose convention) and wrap the spinner /
    result / error rows in a Column taking the caller-provided
    modifier. No visual change in OnchainZapSendDialog.
  - Update OnchainZapSendDialog import to the new package location.
  - Mount NamecoinResolutionRow in SearchScreen.SearchBar between
    SearchTextField and SearchFilterRow, with horizontal padding to
    match the rest of the bar. onUserResolved navigates to the user
    and clears the field, matching the bech32 auto-resolve path in
    SearchBarViewModel.directRouteResolver.

State is held in the shared
commons.NamecoinResolveState (no new state class introduced) and
diagnostic wording comes from the existing mapOutcomeToResolveState
helper, so every Namecoin surface continues to produce the same
message for the same outcome.
2026-05-19 06:33:47 +10:00
Vitor Pamplona
34cb4eb2ad Merge pull request #2956 from mstrofnone/feat/onchain-zap-namecoin-resolution-indicator
feat(onchain-zaps): inline Namecoin resolution indicator + result row
2026-05-18 16:26:02 -04:00
mstrofnone
012dae31d0 feat(onchain-zaps): inline Namecoin resolution indicator + result row
The local-cache suggestion dropdown can momentarily show a stale match
when the user types a .bit identifier. For example, after resolving
"m@testls.bit" earlier in the session, typing the bare host
"testls.bit" causes findUsersStartingWith() to return the cached
m@... profile first; the correct _@testls.bit profile only appears a
few seconds later when ElectrumX resolution finishes. There is no
visual hint that a Namecoin lookup is in flight, and on hard failures
(timeout, malformed record, no nostr field, etc.) the user gets no
feedback at all.

This adds a dedicated NamecoinResolutionRow composable mounted between
the recipient field and the local-cache dropdown:

  - "Resolving <name> on Namecoin…" spinner while the on-chain
    lookup is in flight (after a 300 ms debounce that matches the
    dropdown's own debounce).
  - On success, a tappable row showing the resolved profile with a
    distinct "Namecoin" badge (MaterialSymbols.Link), so the user
    can pick the on-chain-verified profile unambiguously.
  - On failure, a single explanatory error line covering all
    NamecoinResolveOutcome variants the resolver already produces:
    NameNotFound, NoNostrField, MalformedRecord (with the underlying
    parser error verbatim), ServersUnreachable, InvalidIdentifier and
    Timeout.

State is held in the shared NamecoinResolveState sealed class already
used by NamecoinNameService and the desktop SearchScreen — no new
state model is introduced. A small mapOutcomeToResolveState() helper
mirrors the same wording desktop ships, so all Namecoin surfaces
produce the same diagnostic string for the same outcome.

The row owns its own LaunchedEffect keyed on the typed query, so it
cancels in-flight lookups whenever the user keeps typing, and it
unmounts cleanly once a recipient is selected. It uses the existing
Amethyst.instance.namecoinResolver instance, so no DI plumbing or new
network calls beyond what was already wired up for .bit resolution.

The dropdown is left untouched: local-cache results are still valid
hits (just not necessarily the *intended* on-chain identity), so they
remain available below the new row.

Pure helpers (looksLikeNamecoinIdentifier + the outcome-to-state
mapper) are covered by unit tests in NamecoinResolutionRowTest.
2026-05-19 06:11:39 +10:00
Vitor Pamplona
67e2ee6bd7 Merge pull request #2955 from mstrofnone/feat/onchain-zap-bit-recipient
feat(onchain-zaps): enable Send when typed name resolves via NIP-05
2026-05-18 15:33:04 -04:00
Vitor Pamplona
994923fb84 Merge pull request #2958 from mstrofnone/feat/profile-fields-long-press-copy
feat(profile): long-press to copy Nostr Address, Website, LN Address, identities, payment targets
2026-05-18 15:31:13 -04:00
Vitor Pamplona
869447cfb1 Merge pull request #2962 from davotoula/fix/relay-authenticator-concurrent-map
Thread-safe authStatus in RelayAuthenticator (#2946)
2026-05-18 15:30:36 -04:00
Vitor Pamplona
f57facab06 Merge pull request #2963 from vitorpamplona/claude/fix-git-repo-ui-overlap-LWtb9
Apply scaffold padding to GitRepositoryOverview
2026-05-18 15:24:44 -04:00
Claude
ee18490761 fix: pad GitRepositoryOverview content to clear top/bottom bars
The Overview tab is a plain vertical-scroll Column inside DisappearingScaffold,
which lays content under the top app bar + tab row and bottom nav. The Issues
and Patches tabs go through RefresheableFeedView and already consume
LocalDisappearingScaffoldPadding internally, but the Overview tab did not, so
its top and bottom were hidden behind the surrounding UI.
2026-05-18 19:16:32 +00:00
davotoula
fcf704a3c6 fix(quartz): make RelayAuthenticator authStatus thread-safe (#2946)
OkHttp dispatches WebSocket callbacks on one thread per relay socket,
so RelayAuthenticator's plain LinkedHashMap was mutated concurrently
from many threads during connection storms. When a bucket crossed
HashMap's TREEIFY_THRESHOLD the racing treeify corrupted internal
state and threw ClassCastException: LinkedHashMap$Entry cannot be
cast to HashMap$TreeNode from onDisconnected.
2026-05-18 18:34:17 +02:00
David Kaspar
5cd9d3d873 Merge pull request #2950 from mstrofnone/feat/electrumx-add-ethicnology
feat(electrumx): add electrum.nmc.ethicnology.com to default server set
2026-05-18 18:26:21 +02:00
davotoula
67fc5608cc sonar fixes 2026-05-18 16:54:19 +02:00
David Kaspar
6c71eec2fa Merge pull request #2961 from nrobi144/fix/desktop-proguard-jackson-crash
fix(desktop): fix release build crashes (ProGuard, VLC, jlink modules)
2026-05-18 16:39:47 +02:00
David Kaspar
b51de1ae19 Merge pull request #2960 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-18 16:38:59 +02:00
nrobi144
de5dc34cb7 fix(desktop): fix macOS VLC bundled discovery and video rendering
MacOsVlcDiscoverer.setPluginPath() called LibC.INSTANCE.setenv() via
JNA, but macOS 13+ uses versioned symbols (setenv$3b99ba0d) that JNA
can't resolve, breaking all video playback without system VLC.

Changes:
- Replace LibC.setenv with direct JNA Function.getFunction("c","setenv")
  call that bypasses the problematic interface binding
- Store discoveredPluginPath for --plugin-path factory arg fallback
- Add -Dvlc.plugin.path JVM property as ultimate fallback
- Delete stale VLC plugin cache on macOS before factory creation
- Pass --plugin-path to audio factory too when env var fails
- Add jdk.unsupported module to jlink (VLCJ ByteBufferFactory needs
  sun.misc.Unsafe for video frame buffer allocation)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-18 16:52:43 +03:00
nrobi144
935ad84ac3 fix(desktop): fix release build ProGuard rules that crash app on launch
ProGuard in release builds (packageReleaseDmg/Deb/Rpm) strips classes
accessed via reflection, JNI, or service loading — causing multiple
runtime failures:

- Jackson ExceptionInInitializerError: enum constants stripped, NPE in
  MapperConfig.collectFeatureDefaults() (#2929, 5000 sats bounty)
- JNA/VLCJ: static methods stripped, SIGABRT in native callbacks
- secp256k1/SQLite/kmp-tor: JNI loader classes stripped
- Coil/okhttp/okio: image loading and networking broken
- Kotlin metadata stripped: Jackson can't call default constructors

Changes:
- Upgrade ProGuard 7.7.0 → 7.9.1 (Kotlin 2.3 metadata support)
- Disable optimization (-dontoptimize) to prevent bytecode rewriting
  that produces VerifyError (Guardsquare/proguard#460)
- Add -keep rules for all JNI/reflection-dependent libraries
- Keep Kotlin @Metadata annotations for Jackson deserialization
- Suppress Kotlin 2.3 compile-time stub warnings

Tested: release DMG builds and launches without crash on macOS.

Closes #2929

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-18 16:52:27 +03:00
Crowdin Bot
543c4dfeb8 New Crowdin translations by GitHub Action 2026-05-18 12:50:46 +00:00
Vitor Pamplona
34f5dcac69 Merge pull request #2959 from greenart7c3/claude/fix-blossom-cache-url-8zolt
fix(blossom): only bridge to local cache when URL is BUD-01 layout
2026-05-18 08:49:22 -04:00
mstrofnone
69190e7810 feat(profile): long-press to copy Nostr Address, Website, LN Address, identities, payment targets
Currently the only profile fields with an explicit copy affordance are
npub and nprofile (small ContentCopy IconButtons next to the keys).
The other identifier-shaped fields rendered by DrawAdditionalInfo and
DisplayLNAddress (NIP-05 Nostr Address, Website URL, lud16 Lightning
Address, NIP-39 external identities, NIP-A3 payment targets) all have
a tap action that *uses* the value (opens the URL, expands the zap
sheet, etc.) but no way to actually copy the value itself short of
selecting the underlying "View source" or opening the link and
copying from the browser. Closes that gap with the platform-native
long-press gesture.

Adds a small reusable LongPressCopyText composable in
ui/components/util/ that:

  - renders plain Text styled in primary colour (visual match for the
    previous ClickableTextPrimary),
  - wires onClick through Modifier.combinedClickable (so existing tap
    behaviour is preserved),
  - adds onLongClick to copy the supplied raw value to the system
    Clipboard and surface a Toast.makeText "Copied to clipboard"
    confirmation,
  - announces the long-press action to TalkBack via onLongClickLabel.

The composable deliberately uses a plain Text + outer
combinedClickable rather than an AnnotatedString with an inline
LinkAnnotation.Clickable: an annotation-level click can't see a
parent combinedClickable's long-press, because the parent's tap area
sits above the annotation's hit-test region and would consume the
tap before the annotation could fire it. Plain Text + outer modifier
keeps both gestures behaving correctly.

Wired into:
  - DisplayNip05ProfileStatus (NIP-05 Nostr Address) — tap opens the
    domain in the browser, long-press copies the full
    user@domain value.
  - The website row in DrawAdditionalInfo — tap opens the URL,
    long-press copies the *raw* website value as stored in the
    profile (the displayed form is stripped of the scheme + trailing
    slash for readability; the copy preserves the original).
  - The external-identity rows (Twitter, Mastodon, Telegram, GitHub)
    — tap opens the proof URL, long-press copies the identity handle.
  - DisplayLNAddress lud16 — tap toggles the zap sheet (unchanged),
    long-press copies the lud16 address.
  - PaymentTargetRow authority — tap opens the payto: URI, long-press
    copies the authority.

The existing inline ContentCopy IconButtons on npub and nprofile are
left in place; the long-press gesture is purely additive for the
other rows.

Two notable side-effects from the ClickableTextPrimary -> Text +
combinedClickable rewrite:
  - Touch ripple feedback now appears on tap (combinedClickable's
    default indication). Previously the inline LinkAnnotation rendered
    no ripple. This is consistent with how other clickable rows in the
    app behave and provides better tap feedback.
  - The NIP-05 row no longer renders the click region as a styled
    inline span; the entire text behaves as one tap+long-press target.
    Visually identical for short addresses; for any address long
    enough to wrap, the click region now matches the visible bounds
    rather than the per-glyph bounds.

Adds a new copied_to_clipboard string. Picks up the existing
copy_to_clipboard string for the TalkBack long-press hint.
2026-05-18 15:53:16 +10:00
mstrofnone
1f47a63470 feat(onchain-zaps): enable Send when typed name resolves via NIP-05
The Send-onchain-zap Recipient field already supported typing a NIP-05
or bare .bit name and tapping the resolved suggestion. If a user typed
a fully-qualified name (e.g. "m@testls.bit" or "testls.bit") and hit
Send without first tapping the suggestion, the eager resolver only ran
decodePublicKeyAsHexOrNull(), which returns null for non-npub inputs,
so the button stayed disabled with no inline feedback.

Mirror the dropdown's existing NIP-05 / .bit resolution into the dialog
state via UserSuggestionState.nip05ResolutionFlow, and use the resolved
User's pubkey as one more fallback in resolvedRecipient. The flow is
already debounced (300ms) and shared with the suggestion list, so this
adds zero extra network traffic and zero new code paths -- just enables
Send 300ms after a valid name resolves.

Behaviour:
 - Typing "m@testls.bit" + waiting 300ms -> Send enables.
 - Typing "testls.bit" + waiting 300ms -> Send enables (same path the
   dropdown uses for bare .bit names; resolves as _@testls.bit).
 - Typing an npub or hex pubkey -> unchanged (already worked).
 - Typing an unrecognised string -> Send stays disabled, unchanged.
 - Tapping the suggestion before Send -> unchanged (selectedUser wins).

No behaviour change anywhere outside the onchain zap dialog.
2026-05-18 13:50:47 +10:00
Vitor Pamplona
447c662dff Merge pull request #2954 from vitorpamplona/claude/fix-illegal-argument-exception-b572Q
Fix public chat deduplication by channel ID extraction
2026-05-17 22:12:58 -04:00
Claude
cc83c24427 fix: dedupe public-channel rows in chatroom list updates
When the initial chatroom list picked a ChannelMetadataEvent or
ChannelCreateEvent as the representative note for a public channel
(because no ChannelMessageEvent had been seen yet), the arrival of a
later ChannelMessageEvent caused updateListWith to append a second
entry for the same channel — its match check only recognized old
notes whose event was a ChannelMessageEvent. Two notes for the same
channelId then produced the same PublicChannelLazyKey in the LazyColumn
and crashed with IllegalArgumentException: Key was already used.

Extract the channel id from any of the three public-chat event types
when matching the existing entry so the new message replaces the
placeholder instead of duplicating it.
2026-05-18 01:43:08 +00:00
Claude
da2145459e fix: normalize video/x-m4v to video/mp4 when saving to MediaStore
Android's MediaStore rejects video/x-m4v (returned by MimeTypeMap for .m4v
URLs) with IllegalArgumentException. M4V is an MP4 container variant, so
map it to video/mp4 before the ContentResolver.insert call.
2026-05-18 01:40:00 +00:00
Vitor Pamplona
51e136abdd Merge pull request #2953 from greenart7c3/claude/fix-payment-targets-loading-fx9jo
fix: subscribe and observe PaymentTargetsEvent for other users
2026-05-17 21:03:59 -04:00
Claude
961c649901 feat: explain hashtag-limit hide reason in HiddenNote
When a post is filtered out by the max-hashtag-per-post setting, the
"Show Anyway" card previously displayed the generic "Post was muted or
reported by" text with no avatars below it, which was confusing.

Plumb a hasExcessiveHashtags flag and the configured limit through
NoteComposeReportState and BlockReportChecker so HiddenNote can render a
hashtag-specific message ("This post has more than N hashtags"). When
both reports and the hashtag limit apply, show both reasons.
2026-05-18 00:58:36 +00:00
Claude
e719d9ef00 fix(imeta): accept floating-point dimensions so image space is reserved pre-load
Primal-style clients emit "dim 317.0x498.0" in NIP-92 imeta tags. DimensionTag.parse
called Int.parseInt on each component, threw NumberFormatException, and returned
null. With dim==null and no cached aspect ratio, GifVideoView / UrlImageView built
the container without an aspectRatio modifier, so the inline image collapsed to
zero height and the post body looked empty until Coil delivered the bitmap.

Parse each component as Double then truncate to Int. Adds DimensionTagTest covering
integer, float, truncation, 0x0 and malformed inputs.

https://claude.ai/code/session_01W1crao6Hwip8k5ByoLxVrc
2026-05-17 23:22:10 +00:00
Claude
27488f8f34 fix: subscribe and observe PaymentTargetsEvent for other users
PaymentButton and DisplayPaymentTargets read note?.event inside a
remember(note) keyed on the AddressableNote reference, which is stable
across event arrival. They also rely solely on LoadAddressableNote's
cache lookup with no relay subscription, so when viewing a profile
outside the path that activates UserProfileMetadataFilterSubAssembler
(or before the event arrives) the UI never updates.

Match the canonical pattern from ProfileBadgesScreen: pair
LoadAddressableNote with EventFinderFilterAssemblerSubscription to
request the event from relays, and observeNoteEvent<PaymentTargetsEvent>
to recompose when it arrives.
2026-05-17 22:20:48 +00:00
David Kaspar
358bd6a325 Merge pull request #2952 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-17 22:36:21 +02:00
Claude
9390c25b2c feat: extract notification settings into dedicated screen
Group every notification-related preference under a new
"Notifications" entry in the settings menu, rendered with the modern
card/section design used by SecurityFiltersScreen.

Moves out of "UI preferences":
- Push notification provider (UnifiedPush, fdroid)
- Always-on notification service + battery optimization banner
- Split notifications by Follows

The fdroid PushNotificationSettingsRow becomes PushNotificationProviderTile
built around SettingsBlockTile; the play stub gains a matching
HasPushNotificationProvider() so the push section is hidden on Play builds.
2026-05-17 17:49:45 +00:00
Crowdin Bot
7b6293b945 New Crowdin translations by GitHub Action 2026-05-17 15:49:38 +00:00
David Kaspar
4044c9341c Merge pull request #2951 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-17 17:48:02 +02:00
Crowdin Bot
92a25e3a38 New Crowdin translations by GitHub Action 2026-05-17 12:34:52 +00:00
davotoula
53c6d5d14d i18n: translate call-permission and git-repo strings (cs, pt-BR, sv, de)
17 strings: 5 for the call-permission-denied dialog plus 12 for the new
git repository overview / issues / patches screen and status pills.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:26:26 +02:00
m
968a396779 feat(electrumx): add electrum.nmc.ethicnology.com to default server set
Adds a third public Namecoin ElectrumX server to the default and
Tor-preferred lists in DEFAULT_ELECTRUMX_SERVERS / TOR_ELECTRUMX_SERVERS:

  electrum.nmc.ethicnology.com:50002  (IPv4 142.44.246.181, OVH Canada)

Operated by @ethicnology, who ships the namecoind + ElectrumX + mempool
podman stack at github.com/ethicnology/namecoin-compose. Probed live:

  - server.version      -> ElectrumX 1.19.0, protocol 1.4
  - server.features     -> Namecoin mainnet genesis 000000000062b72c...c770
  - scripthash.get_history for d/testls -> full history (heights up to
    822885), and blockchain.transaction.get decodes the OP_NAME_UPDATE
    output correctly. Same code path used by ElectrumXClient against all
    other public servers, no client changes required.

TLS uses a publicly-trusted Let's Encrypt cert, so usePinnedTrustStore
is left at the default (false). This makes it the first entry in the
list whose TLS does NOT depend on PINNED_ELECTRUMX_CERTS, and adds
useful diversity:

  - electrumx.testls.space      (self-signed, pinned, often ECONNRESETs)
  - nmc2.bitcoins.sk / 46.229.238.187  (self-signed, pinned)
  - relay.testls.bit / 23.158.233.10   (self-signed, pinned)
  - electrum.nmc.ethicnology.com       (LE cert, system trust store)

If every self-signed peer is unreachable (e.g. corporate networks that
strip unknown CAs but allow LE chains), resolution can still succeed.

No bare-IP companion entry is added for 142.44.246.181: unlike the
46.229.238.187 / 23.158.233.10 pinned peers (which use DER-SHA256
pinning that ignores hostname verification), an IP-literal endpoint
against the LE cert would fail standard hostname verification under
the system trust manager (SAN covers only the hostname). The IP is
captured in this commit message and the source comment for reference.

Verification on this branch:
  - :quartz:spotlessCheck   OK
  - :quartz:jvmTest         OK (BitRelayResolverTest etc. unchanged)
2026-05-17 17:09:42 +10:00
Vitor Pamplona
0cf8ecef13 Merge pull request #2949 from vitorpamplona/claude/add-zaps-to-stats-Z2qHO
feat(notifications): include onchain zaps in summary stats
2026-05-16 19:07:39 -04:00
Vitor Pamplona
57acfe200a Merge pull request #2948 from vitorpamplona/claude/change-hashtag-limit-Jujia
Raises the default maximum hashtag limit from 5 to 8
2026-05-16 19:06:22 -04:00
Vitor Pamplona
55802fa21b Merge pull request #2947 from vitorpamplona/claude/restore-lightning-url-field-LdXOc
feat(profile): restore Lightning Address + LNURL fields in Edit Profile
2026-05-16 19:05:53 -04:00
Claude
09c4d70048 fix(blossom): only bridge to local cache when URL is BUD-01 layout
`bridgeUrl` accepted the imeta `x` hash as a sufficient signal to route
a media URL through the local Blossom cache, even when the URL itself
didn't have the sha256 in its last path segment. For URLs like
https://i.nostr.build/M5AwJ.gif the upstream serves the blob under an
opaque filename, so the resulting `xs=https://i.nostr.build` hint sent
the local cache after https://i.nostr.build/<sha>.gif on miss — which
404s because the real blob isn't at that path.

Match the behaviour `bridgeProfilePictureUrl` already had: require
`extractSha256FromUrlPath(url)` to succeed before bridging. The imeta
hash is still preferred for canonical casing when present, but is no
longer enough on its own.
2026-05-16 22:45:06 +00:00
Claude
92b1930228 feat(notifications): include onchain zaps in summary stats
Add OnchainZapEvent handling to NotificationSummaryState so NIP-BC
onchain zaps targeting the current user contribute their claimed sats
to the daily zap total and chart alongside LnZapEvent receipts.
2026-05-16 22:28:30 +00:00
Claude
e5cf575602 feat(video): release MediaController after 30s in background
Pause-on-background (0de70e51) stops audio/video from leaking past the
foreground, but the MediaController itself stays bound — so the
underlying ExoPlayer keeps holding its codec and decoder buffer the
whole time the app is away.

Schedule a 30s timer on ON_PAUSE; if the activity is still backgrounded
when it fires, flip a keepAlive gate that swaps the controllerAsFlow
out for flowOf(null). collectAsState cancels the previous collection,
awaitClose releases the MediaController, the session disconnects, and
the ExoPlayer goes back to the pool (warm slot keyed by URI). ON_RESUME
cancels any pending timer and flips the gate back, so the flow rebuilds
a fresh MediaController. The onEach warm-pool fast path then re-attaches
to the same paused player — currentMediaItem still matches, no
setMediaItem call, no re-prepare, so position + buffer come back intact.

PiP is exempt via the existing BackgroundMedia.isMutex check, same
pattern the pause-on-background handler uses.

https://claude.ai/code/session_01RoEUbAN8ejF21Ns3eM6xad
2026-05-16 22:25:19 +00:00
Claude
8ffe1aee4f feat(wallet): clickable onchain card opens transaction history
Tapping the Bitcoin card on the wallet screen now navigates to a new
OnchainTransactionsScreen that lists transactions touching the account's
Taproot address, mirroring the NWC transactions view.

- OnchainBackend gains getTxsForAddress(address, afterTxid) returning
  BitcoinAddressTx rows (netValueSats, confirmations, blockHeight,
  blockTime, counterparty addresses). EsploraBackend implements it via
  GET /address/{addr}/txs and /address/{addr}/txs/chain/{last_seen}
  for pagination; CachingOnchainBackend passes through.
- OnchainTransactionsViewModel loads the address from the account
  signer + LocalCache.onchainBackend, paginates, and for each chain
  row scans LocalCache for an OnchainZapEvent with a matching txid so
  the UI can render the Nostr counterparty (sender pubkey for
  incoming, p-tagged recipient for outgoing).
- ALL / ZAPS / NON-ZAPS filter chips reuse the existing
  TransactionFilter enum. Mempool rows are flagged "Pending" in
  bitcoin-orange.
2026-05-16 22:19:47 +00:00
Claude
44234aa648 feat(profile): inline Lightning Address + LNURL fields in Edit Profile
Promote the two lightning fields out of an expandable section so they
sit alongside name/about/nip05/website as regular always-visible inputs.

https://claude.ai/code/session_01R2E6rMfhxKA8csVarjxKJd
2026-05-16 22:16:52 +00:00
Claude
7e750d2517 feat(profile): restore Lightning Address + LNURL fields in Edit Profile
Lightning fields had moved entirely into the NWC wallet setup screen
(NIP47SetupScreen), so a user with no NWC connection had no way to set
their lud16/lud06 — required for receiving zaps. Add an expandable
Lightning section in the profile editor that reads/writes lud16 and
lud06, auto-expanded when either is set. NIP47SetupScreen keeps its
lud16 field; both flows go through sendNewUserMetadata which already
no-ops null params, so editing only one side never wipes the other.

https://claude.ai/code/session_01R2E6rMfhxKA8csVarjxKJd
2026-05-16 22:00:30 +00:00
Claude
4a23ecf7ef Raises the default maximum hashtag limit from 5 to 8 2026-05-16 21:38:58 +00:00
Claude
ca9f7233f2 test: verify real-world 5.5KB nowhr.xyz/s store link round-trips through the parser 2026-05-16 21:16:45 +00:00
Claude
a1e37109c3 test: cover ~5KB nowhere-link fragments end-to-end through the parser 2026-05-16 21:01:53 +00:00
Vitor Pamplona
280f21159f v1.10.0 2026-05-16 16:53:00 -04:00
Claude
637bb4aba5 feat(privacy): persist I2pSettings and preferredClearnetTransport
Mirrors the existing Tor persistence stack so I2P settings + the global
clearnet-transport preference survive process restart.

New on Android:
- I2pSettingsFlow — StateFlow-per-field shape matching TorSettingsFlow,
  including the propertyWatchFlow that the prefs class listens to.
- I2pSharedPreferences — DataStore-backed load/save with `i2p.*` pref keys,
  debounced-save wired to propertyWatchFlow.
- PrivacySharedPreferences — single MutableStateFlow<PrivacyTransport> for
  preferredClearnetTransport, persisted under `privacy.preferredClearnetTransport`.
  Kept separate from Tor/I2p prefs because the decision spans both transports.

AppModules:
- Parallel-load i2pPrefs and privacyPrefs alongside torPrefs in async/await pairs
  so cold-start blocking time stays at ~max() not sum().
- Lazy accessors mirror torPrefs/uiPrefs.

No routing wired yet — RoleBasedHttpClientBuilder still consumes torPrefs only.
That swap is the next chunk.
2026-05-16 20:44:28 +00:00
Claude
318e4250de feat: render nowhere links inline as branded cards
Nowhere (https://github.com/5t34k/nowhere) encodes whole mini-sites in
a URL fragment, so the OpenGraph preview path returns nothing useful and
hostednowhere.com 403s scrapers. Detect URLs on hostednowhere.com and
nowhr.xyz that carry a fragment as a NowhereLinkSegment and render them
through a NowhereLinkCard that labels the tool type (Event, Store,
Message, ...) and opens the URL in the browser on click.
2026-05-16 20:35:37 +00:00
Vitor Pamplona
463c61c61e Removes marmot debug lines 2026-05-16 16:31:27 -04:00
Vitor Pamplona
b96f1b5847 Merge pull request #2944 from vitorpamplona/claude/nip88-polls-quartz-p5fBa
Add NIP-BC onchain Bitcoin zaps support (send, receive, display)
2026-05-16 16:27:31 -04:00
Claude
627b75681f feat(wallet): redesign onchain card to match NWC WalletCard
OnchainSection now mirrors WalletCard's layout structurally so both
payment rails read as the same kind of object on the wallet screen,
with bitcoin-orange (BitcoinDark #F7931A / BitcoinLight #B66605)
replacing NWC's primary purple to keep them distinct at a glance:

  - RoundedCornerShape(16.dp), 2.dp bitcoinColor border,
    bitcoinColor.copy(alpha = 0.12f) container — same idiom NWC uses
    for the 'default wallet' card.
  - Header row: small orange ₿ chip + 'Bitcoin' title + 'Onchain ·
    Taproot' subtitle on the left, big 24sp bold balance + 'sats'
    label on the right (same typography as NWC).
  - Loading shows an orange CircularProgressIndicator in the balance
    slot. Error / no-backend states render an em-dash placeholder
    with a small status caption.
  - Address block: 'Your Taproot address' caption + monospace
    truncated address.
  - Action row: outlined 'Copy' with copy icon on the left, filled
    bitcoin-orange 'Send' with send icon on the right — same 36dp
    height + RoundedCornerShape(8.dp) as the NWC card actions.
2026-05-16 20:21:25 +00:00
Claude
e4b8e7ccc8 feat(onchain-zaps): redesign send sheet + render in thread NoteMaster
OnchainZapSendDialog moves from AlertDialog to ModalBottomSheet —
matches the design language already used by CreateNestSheet,
EditNestSheet and the participant-action sheets. The form gets
imePadding + navigationBarsPadding, a scrollable content area, and a
sticky full-width Send button at the bottom.

Layout changes:
  - Header with Bitcoin ₿ glyph + title + close.
  - Recipient picker: the suggestion dropdown now sits flush under
    its text field (was: separated by the outer Column's spacedBy gap).
  - Amount section: big number field with 'sats' suffix + FlowRow of
    SuggestionChips bound to AccountViewModel.zapAmountChoices (same
    quick picks the LN zap dialog uses, formatted with showAmount).
  - Fee priority: FlowRow of FilterChips with explicit vertical
    padding around the row and inside each chip; each chip shows
    'Slow / Normal / Fast', the sat/vB rate, and a rough ETA.
    Selected chip uses bitcoinColor.
  - State machine: idle → sending (orange spinner) → success
    (bitcoinColor checkmark) / failure (error tint), each with a
    'Done' / 'Close' bottom button.

Also wire RenderOnchainZap into ThreadFeedView.NoteMaster's event-type
dispatch right after RenderLnZap, so kind 8333 receipts get the full
rich render on the thread screen instead of the default text body.
2026-05-16 20:16:21 +00:00
Claude
a011421ff5 refactor(privacy): single preferred clearnet transport, fail-closed hidden services
Drop the per-feature 3-way picker — no splitting clearnet traffic between Tor
and I2P at the same time. Both daemons can still run side-by-side so .onion
and .i2p hidden services stay reachable independently, but only one transport
carries clearnet traffic at a time.

Routing model:
- .onion → Tor required; Blocked when Tor is OFF (no clearnet fallback)
- .i2p   → I2P required; Blocked when I2P is OFF (no clearnet fallback)
- clearnet → preferredClearnetTransport (NONE/TOR/I2P) picks the active
  transport; that transport's own per-feature toggle decides this request;
  otherwise Direct

Commons:
- Add PrivacyRoute sealed type { Direct, Tor, I2p, Blocked(BlockReason) } so
  fail-closed has somewhere to land — callers must surface Blocked instead of
  silently leaking over clearnet
- PrivacySettings drops `features`, adds preferredClearnetTransport
- I2pSettings gains imagesViaI2p / videosViaI2p / urlPreviewsViaI2p /
  profilePicsViaI2p / nip05VerificationsViaI2p / moneyOperationsViaI2p /
  mediaUploadsViaI2p — mirrors TorSettings; only effective when I2P is the
  preferred clearnet transport
- PrivacyRouter rewritten to the new model
- Delete FeatureTransportChoices and TransportChoice
- Keep FeatureRole as the per-request hint that selects which toggle to read

Tests: PrivacyRouterTest rewritten to cover the new outcomes, including
both-daemons-running clearnet preference, fail-closed for .onion / .i2p, and
that the non-preferred transport's toggles have no effect on clearnet.
2026-05-16 20:06:35 +00:00
Claude
5aedcd0a19 feat(onchain-zaps): user search + chip wrapping in send dialog
Recipient field now uses ShowUserSuggestionList + UserSuggestionState
(same plumbing as the post composer @-mention and AwardBadgeScreen).
The user can type a display name, NIP-05, or paste an npub/hex; picked
recipients render as a chip with avatar + name + clear button. Raw
npub paste still works as a fallback when no result is picked.

Fee tier chips moved from Row to FlowRow so 'Slow · 1 sat/vB',
'Normal · 12 sat/vB', 'Fast · 50 sat/vB' wrap to a second line on
narrow dialog widths instead of clipping the rightmost chip.
2026-05-16 20:05:09 +00:00
Vitor Pamplona
3f12bdc2d7 Merge pull request #2943 from vitorpamplona/claude/fix-show-more-gradient-WruXm
Fix gradient rendering with transparent background colors
2026-05-16 15:59:04 -04:00
Claude
1d5ce994a6 fix(onchain-zaps): UserProfileZapsViewModel crashed casting kind 8333 as LnZapEvent
observeEvents<LnZapEvent>(filterAcceptingBothKinds) is generic-erased
at runtime, so an OnchainZapEvent landing in the cache (e.g. right
after sending an onchain zap from the wallet) flowed through and
crashed in sumAmountsByUser's forEach checkcast.

Observe as <Event> and dispatch on type: LnZapEvent keeps its
private-zap decryption path; OnchainZapEvent attributes the
claimedAmountInSats to event.pubKey (no zap-request envelope).
2026-05-16 19:56:22 +00:00
Claude
e6a5ba9e15 fix: use theme background when ShowMore gradient color is transparent
Several callers pass Color.Transparent as the rich-text backgroundColor.
getGradient was copying that color and forcing alpha=1, which produced
opaque black (Color(0,0,0,1)) as the gradient end — so on a light theme
the fade behind the "Show more" button looked pitch black.

Fall back to MaterialTheme.colorScheme.background when the supplied
background has alpha 0, so the gradient fades from transparent into the
actual surface color.
2026-05-16 19:46:39 +00:00
Claude
6361fb1c9d feat(onchain-zaps): rich NoteCompose render for kind 8333
Adds RenderOnchainZap, dispatched from RenderNoteRow alongside
RenderLnZap. The card shows a pulsing Bitcoin badge, sender→recipient
avatars, big sats amount in Bitcoin orange, an animated 'ON-CHAIN'
pill, a live confirmation pill (Verifying → In mempool → Confirmed at
block N) sourced from LocalCache.onchainBackend.getTx, and a
tap-to-copy mempool.space tx link.
2026-05-16 19:42:01 +00:00
Claude
fb2f05d9cb feat: scaffold I2P as a parallel privacy transport to Tor
Foundational types for offering I2P alongside the existing internal Tor.
No wiring yet — HTTP managers, RoleBasedHttpClientBuilder, the Android
I2P service and the Privacy settings UI follow in later commits.

Quartz (relay URL classifier):
- Add isI2p() and classifyHidden() to RelayUrlNormalizer
- Add HiddenServiceKind { CLEARNET, LOCALHOST, ONION, I2P }
- Add NormalizedRelayUrl.isI2p() / classifyHidden() extensions
- Extend the scheme-default branch so .i2p hosts default to ws:// like .onion

Commons (transport-agnostic types):
- PrivacyTransport enum { DIRECT, TOR, I2P }
- TransportChoice (UI-facing per-feature picker, screen-coded for persistence)
- FeatureRole + FeatureTransportChoices: per-feature picks for clearnet traffic
- PrivacySettings aggregate { tor, i2p, features }
- PrivacyRouter.route(url, role, settings): hostname pin for hidden services,
  per-feature choice for clearnet, downgrades to DIRECT if backing transport is OFF

Commons (I2P settings model, mirrors tor/):
- I2pSettings, I2pType (OFF/INTERNAL/EXTERNAL), I2pRelaySettings
- I2pRelayEvaluation, I2pServiceStatus
- II2pManager, II2pSettingsPersistence (platform-agnostic interfaces)
- PrivacyRelayEvaluation composing TorRelayEvaluation + I2pRelayEvaluation

Tests:
- PrivacyRouterTest covers localhost bypass, onion pin, i2p pin, hostname-wins-over-picker,
  per-feature picks routing independently, downgrade-when-transport-OFF, .b32.i2p
2026-05-16 19:37:06 +00:00
Vitor Pamplona
f1d02f3c60 Merge pull request #2942 from vitorpamplona/claude/fix-call-button-permission-63OLL
Handle denied call permissions with settings dialog
2026-05-16 15:18:18 -04:00
Claude
8a5d0019c1 Merge remote-tracking branch 'origin/main' into claude/nip88-polls-quartz-p5fBa 2026-05-16 19:15:32 +00:00
Vitor Pamplona
05467582b4 Merge pull request #2941 from vitorpamplona/claude/fix-notecompose-read-status-rslby
Sync inner note highlight fade with parent background color
2026-05-16 15:14:31 -04:00
Vitor Pamplona
f901db699d Merge pull request #2940 from vitorpamplona/claude/fix-ci-build-desktop-2qHSV
Replace linuxdeploy with appimagetool for AppImage packaging
2026-05-16 15:08:44 -04:00
Claude
19309c4587 fix: also fade reply previews together with the parent NoteCompose
Restores ReplyNoteComposition's parentBackgroundColor parameter (reverting
part of the previous commit). With calculateBackgroundColor now returning
the parent State directly when routeForLastRead is null, every inner
NoteCompose call site (replies, reposts, reactions, reports, approvals,
attestations, multi-set / message-set notification cards, and rich-text
nostr:event/nostr:note quotes) shares the level-0 bgColor State and fades
in lockstep with the outer note.
2026-05-16 19:07:26 +00:00
Claude
7ed93bcbc2 fix(desktop): package AppImage with appimagetool, not linuxdeploy
linuxdeploy auto-walks every binary in the staged AppDir with ldd to
bundle their shared-library deps. That fights jpackage's self-contained
output on two axes and aborts the createReleaseAppImage task before any
AppImage is produced:

  - The bundled JRE puts libjvm.so at usr/lib/runtime/lib/server/, while
    its sibling libs (libmanagement.so, libawt_xawt.so, libfontmanager.so
    and others in usr/lib/runtime/lib/) have RPATH=$ORIGIN. ldd cannot
    resolve libjvm.so from those libs without help, so linuxdeploy errors:
        ERROR: Could not find dependency: libjvm.so
        ERROR: Failed to deploy dependencies for existing files

  - The bundled libvlc plugins under usr/lib/app/resources/vlc/ are
    UPX-compressed by the vlc-setup plugin. patchelf cannot read their
    section headers, so linuxdeploy aborts:
        ERROR: Call to patchelf failed:
        patchelf: no section headers. The input file is probably a
        statically linked, self-decompressing binary

  - Even after working around libjvm.so via LD_LIBRARY_PATH, several VLC
    libs have RUNPATH pointing at the wrong directory, so ldd fails on
    libva.so.2 / libvlccore.so.9 next.

This is why v1.09.2 shipped .deb + .rpm from the linux leg but no
.AppImage and no linux .tar.gz from the linux-portable leg — the gradle
step failed on linuxdeploy, so the subsequent "Build portable archives"
step never ran.

Swap to appimagetool, which only embeds the AppDir as-is into a
SquashFS-backed, runtime-prepended AppImage and never touches the
contents. jpackage already bundles a complete, self-contained app —
we don't need linuxdeploy's dep-discovery. AppRun continues to handle
LD_LIBRARY_PATH at launch.

Verified locally on ubuntu-24.04: a fresh
:desktopApp:createReleaseAppImage now produces a valid 254 MB
Amethyst-1.09.2-x86_64.AppImage in ~30 s on top of an up-to-date
createReleaseDistributable.

Side changes:
- Workflow installs desktop-file-utils (appimagetool 1.9.0 calls
  desktop-file-validate on the .desktop entry).
- BUILDING.md updated with the new local-dev fetch command.
- .gitignore replaces the linuxdeploy paths with appimagetool's.
2026-05-16 19:04:07 +00:00
Claude
e885192f51 fix: surface call-permission denial instead of silent failure
When the user denied RECORD_AUDIO or CAMERA for a DM call, rememberCallWithPermission's
launcher callback only re-checked permissions and called onCall() on success — the denial
branch was empty. On the next button press Android skips the system dialog (permanently
denied) and immediately returns deny, so the call button appeared dead with no UI feedback.

Now the denial path opens an AlertDialog with an "Open settings" deep-link, matching the
NestActionBar pattern. Also splits the optional BLUETOOTH_CONNECT request onto its own
launcher so its result callback no longer triggers a second onCall().
2026-05-16 18:49:26 +00:00
Claude
523b0616cf fix: quoted NoteCompose stuck on the "new item" purple background
calculateBackgroundColor captured parentBackgroundColor?.value once inside
remember(createdAt), so an inner NoteCompose (repost or reply preview) that
first composed while the outer was still highlighted would snapshot the
purple color and never observe the outer's fade back to the default
background.

- Repost inner notes now share the parent's bgColor State directly when
  they have no own read-tracking, so they fade in lockstep with the outer.
- Reply previews no longer receive parentBackgroundColor at all; they rely
  on replyModifier for their own visual style and never inherit the
  purple "new item" highlight from the surrounding post.
2026-05-16 18:45:26 +00:00
Vitor Pamplona
63ddb5159f Merge pull request #2939 from vitorpamplona/claude/remove-mac-13-cio-1mGRB
ci: remove macos-13 x64 build legs
2026-05-16 14:41:01 -04:00
Claude
25a49d1050 ci: remove macos-13 x64 build legs
GitHub's macos-13 runner image is being retired. Drop the x64 macOS
legs from both the desktop and CLI release matrices; macos-14 arm64
continues to ship the macOS builds.
2026-05-16 17:18:51 +00:00
Claude
d77f2f7333 docs(onchain-zaps): record the hand-rolled Bitcoin consensus code decision
Decision: keep the hand-rolled psbt/ + taproot/ consensus layer rather than
adopting fr.acinq.bitcoin-kmp. Rationale: a deliberately small single-key-path
P2TR subset, pinned to authoritative BIP-341/350 test vectors at every layer
(sighash, tweak, witness signature bytes, addresses, tx serialization), and
consistent with the project's minimal-dependency stance.

Recorded in amethyst/plans/2026-05-14-onchain-zaps.md with the consequence
spelled out (we own correctness; revisit if scope expands past single-key-path
P2TR). The psbt/ and taproot/ packages now carry a pointer back to that
decision so a future reader doesn't reflexively swap in a library.

Doc-comment + plan-doc only; no logic change.
2026-05-14 18:16:30 +00:00
Claude
07724e410b fix(onchain-zaps): share the OTS Tor-aware explorer endpoint + server setting
The NIP-BC EsploraBackend was wired with a hardcoded mempool.space URL,
silently bypassing the user's Tor preference and ignoring the explorer
server they may have configured for OpenTimestamps.

- New BitcoinExplorerEndpoint: the single source of truth for the Bitcoin
  explorer base URL, shared by OTS verification and onchain zaps. A
  user-configured custom URL always wins; otherwise mempool.space when Tor
  is active, blockstream.info when not.
- TorAwareOkHttpOtsResolverBuilder.getAPI now delegates to it (behaviour
  unchanged for OTS).
- AppModules wires EsploraBackend through BitcoinExplorerEndpoint using the
  same OTS server setting (otsPrefs) and the same money-flavoured Tor-aware
  OkHttp client OTS uses — so onchain zaps honour Tor and the user's
  explorer choice.
- Removed the dead AccountSettings.onchainEsploraEndpoint field and
  DEFAULT_ESPLORA_ENDPOINT const: the OTS explorer setting is now the single
  configuration point. The field was never persisted or read.

Adds BitcoinExplorerEndpointTest. amethyst compiles; the new test passes.
2026-05-14 17:26:44 +00:00
Claude
db6254872e fix(onchain-zaps): recover the OnchainZapBuilder package + re-audit fixes
The big one: the source package was named `nipBCOnchainZaps/build/`, which
the repo .gitignore (`build/`) silently matched — so OnchainZapBuilder.kt
(the main source, not just its test) was NEVER committed. The pushed branch
did not compile; the pre-commit hook only checks the working tree, so this
went unnoticed. Renamed the package `build` -> `builder` (a source package
must never be named `build`) and updated all references; the recovered files
are now actually tracked.

Re-audit findings on the previous fix commit:
- CachingOnchainBackend.txCache was unbounded -> memory leak in a long
  session. Added a bounded cache (maxCachedTxs, oldest-entry eviction).
- OnchainZapSender still trusted the signer's returned PSBT for witness
  UTXOs and tap internal keys (used to compute the sighash + the verified
  output keys). Now it copies ONLY the PSBT_IN_TAP_KEY_SIG records back onto
  the PSBT we built, so a signer can contribute signatures and nothing else;
  verification and finalization run entirely on our own PSBT.

Test coverage added:
- OnchainZapBuilderTest: confirmed-UTXO filter — unconfirmed UTXOs excluded
  by default, spendable only with allowUnconfirmed, confirmed preferred.
- CachingOnchainBackendTest: confirmed-tx cached forever, unconfirmed
  re-fetched after TTL, not-found never cached, tip/fee TTL, bounded
  eviction.

All quartz / commons jvmTest suites pass; quartz android + amethyst compile.
2026-05-14 13:55:18 +00:00
Claude
1d7be1d444 fix(onchain-zaps): address audit findings — fund-safety, interop, robustness
CRITICAL
- PsbtSignatureVerifier: independently verifies every key-path signature in a
  PSBT (BIP-340 sig over the BIP-341 sighash, against the tweaked output key).
- OnchainZapSender now rejects a signer that returns a different transaction
  than it was asked to sign (byte-compares the unsigned tx) and verifies all
  signatures before broadcasting — closes a substitution attack where a
  malicious/buggy external signer could redirect funds.

HIGH — break test circularity
- Pin the full BIP-341 keyPathSpending input-0 witness: signing the vector
  sighash with the vector tweaked key reproduces the vector signature
  byte-for-byte (also pins BIP-340 nonce determinism).
- Pin TaprootAddress.fromPubKey + SegwitAddress.encodeP2TR against all seven
  BIP-341 wallet-test-vector P2TR mainnet addresses.
- EsploraBackendTest: JSON-parsing coverage for /tx, /address/{}/utxo, the
  mempool.space and blockstream fee formats, and schema-fallback.

MEDIUM
- OnchainZapBuilder filters to confirmed UTXOs by default (allowUnconfirmed
  opt-in) and signals BIP-125 RBF (nSequence 0xFFFFFFFD).
- CachingOnchainBackend: TTL-caching decorator (confirmed tx forever,
  unconfirmed/tip/fees short TTL) so a feed of onchain zaps doesn't fan out
  into one HTTP request per event. Wired in AppModules.
- OnchainZapVerifier computes real confirmation depth from the chain tip and
  asserts the backend echoed the requested txid.
- EsploraBackend falls back to the standard Esplora /fee-estimates endpoint
  (blockstream.info) when /v1/fees/recommended 404s.

LOW
- BitcoinTransaction.parse caps input/output/witness-item counts to stop a
  hostile varint from triggering a giant pre-allocation.
- OnchainZapEventTest: asserts kind:8333 on-the-wire tag structure against the
  NIP-BC spec.
- alt tag now includes the amount ("Onchain zap: N sats"), matching the spec
  example.

All quartz / commons / androidHostTest suites pass; amethyst compiles.
2026-05-14 13:29:48 +00:00
Claude
5f16ebc060 feat(quartz): NIP-BC sign_psbt over NIP-55 external signer (Phase B)
Wires the NIP-55 Android external-signer contract for sign_psbt, so
NostrSignerExternal.signPsbt is real instead of a stub.

- CommandType.SIGN_PSBT ("sign_psbt") — also available in NIP-55 `perms`
  lists, since Permission wraps CommandType directly.
- SignPsbtResult result type.
- SignPsbtQuery (background ContentResolver) + SignPsbtRequest /
  SignPsbtResponse (foreground Intent), modeled on the derive_key
  string-in/string-out shape: the PSBT hex rides the `nostrsigner:` URI,
  the signed (not finalized) PSBT comes back in the `result` field.
- BackgroundRequestHandler.signPsbt / ForegroundRequestHandler.signPsbt.
- NostrSignerExternal.signPsbt now runs the background-then-foreground
  query. Signer apps that predate sign_psbt reply with no `result`, which
  surfaces as CouldNotPerformException — the send dialog shows it as a
  failure ("update your signer").

NIP-46 (NostrSignerRemote) still throws UnsupportedMethodException — the
bunker-side command isn't standardized yet.
2026-05-14 12:50:47 +00:00
Claude
bb0f41d320 docs: mark onchain zaps Phase D (send flow) shipped 2026-05-14 12:31:11 +00:00
Claude
40ccffee35 feat: NIP-BC onchain zap send flow (Phase D)
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.
2026-05-14 12:29:31 +00:00
Claude
f5e7240425 docs: mark onchain zaps Phase A.2 (send-side foundation) shipped 2026-05-14 11:32:15 +00:00
Claude
a42b1e0f32 feat(quartz): NIP-BC onchain zap send-side foundation (Phase A.2)
Implements the send half of the onchain zaps plan: a minimal, fund-safe
BIP-174 PSBT pipeline plus the OnchainZapBuilder and `NostrSigner.signPsbt`.
Every cryptographic step is validated against the BIP-341 wallet test
vectors.

nipBCOnchainZaps/psbt/:
- BitcoinIO: little-endian Bitcoin consensus byte reader/writer (u16/u32/u64,
  compact-size varint, var-bytes).
- BitcoinTransaction: OutPoint / TxIn / TxOut / transaction model with legacy
  and BIP-144 segwit serialization, segwit-aware parsing, and witness-stripped
  txid. Validated against the genesis coinbase tx and the BIP-341 9-input
  unsigned tx.
- TaprootSigHash: BIP-341 SigMsg + TapSighash tagged hash for key-path spends.
  All six base sighash types plus both ANYONECANPAY variants verified against
  the seven BIP-341 keyPathSpending vectors.
- Psbt: BIP-174 container subset (global unsigned-tx, per-input witness-utxo /
  tap-internal-key / tap-key-sig / sighash-type, per-output tap-internal-key).
  Unknown records round-trip verbatim. Typed accessor extensions.
- PsbtSigner: signs the key-path P2TR inputs a given private key controls —
  derives the BIP-341 tweaked key, computes the all-inputs sighash, produces a
  BIP-340 Schnorr signature. Leaves inputs the key does not control untouched.
- PsbtFinalizer: moves tap-key-sigs into the witness stack, yields a
  broadcastable transaction.

nipBCOnchainZaps/build/:
- OnchainZapBuilder: largest-first coin selection + unsigned-PSBT assembly for
  a NIP-BC zap. Sender spends from / changes back to the single Taproot address
  derived from their Nostr pubkey; recipient output pays the recipient's
  derived address. Dust-aware change handling, InsufficientFundsException,
  self-zap and dust-amount guards.

nipBCOnchainZaps/taproot/:
- TaprootAddress.tweakSecretKey: BIP-341 taproot_tweak_seckey (key-path-only),
  including the odd-y internal-key negation. Validated against the BIP-341
  vector's tweakedPrivkey.

Secp256k1Instance: new `privKeyNegate` primitive across commonMain expect +
jvm / android / native actuals.

Signer hierarchy — new `NostrSigner.signPsbt(psbtHex): String`:
- NostrSignerSync / NostrSignerInternal: real implementation.
- NostrSignerWithClientTag: delegates to the wrapped signer.
- NostrSignerRemote (NIP-46) and NostrSignerExternal (NIP-55): throw
  SignerExceptions.UnsupportedMethodException — the bunker command and the
  Android Intent contract are Phase B, and depend on signer-app support.

Tests: 31 new tests across the psbt/build/signers packages, anchored on the
BIP-341 wallet-test-vectors so the tweak, sighash, signature, and finalized
transaction are all checked against an authoritative source.

Still pending: Phase B (NIP-55 sign_psbt Intent) and Phase D (send UI +
broadcast + publish kind 8333).
2026-05-14 11:29:47 +00:00
Claude
bb5613ca8b docs: update onchain zaps plan — A.1 + C shipped, A.2/B/D pending
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.
2026-05-14 04:59:23 +00:00
Claude
6ce193d46a feat(amethyst): show onchain balance on wallet screen Bitcoin section
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.
2026-05-14 04:58:18 +00:00
Claude
031159b3f6 feat(amethyst): NIP-BC onchain zap receive + display
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.
2026-05-14 04:54:40 +00:00
Claude
79f1d43581 feat(quartz): NIP-BC onchain zap receive-side foundation
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.
2026-05-14 04:34:30 +00:00
Claude
d095dc790d feat(quartz): add NIP-BC onchain zaps (kind 8333)
Implements NIP-BC onchain zap events analogous to NIP-57 lightning
zap receipts but for Bitcoin transactions. The recipient's Nostr
pubkey is used directly as the internal key of a BIP-341 P2TR output.

Mirrors the nip88Polls package structure with a single `zap/` subpackage:
- OnchainZapEvent (kind 8333) with profile / event / addressable-event
  zap builders
- Tags: BitcoinTxIdTag (i = bitcoin:tx:<txid>), AmountTag (sats),
  BlockTag (hash + height), ProofTag (raw-tx + merkle proof) for
  optional SPV verification
- TagArrayExt / TagArrayBuilderExt for parsing and assembly
- EventFactory wired so kind 8333 deserializes into OnchainZapEvent

Reuses standard ETag/ATag/PTag/KindTag from nip01Core. Does not yet
implement client-side transaction verification or PSBT signer methods.
2026-05-14 03:25:44 +00:00
367 changed files with 27101 additions and 2225 deletions

View File

@@ -306,6 +306,29 @@ actual fun openExternalUrl(url: String) {
}
```
## Icons
The Material Symbols font bundled at
`commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf`
is a **subset** that only contains the glyphs referenced from
`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt`.
**MANDATORY:** Whenever you add a new icon — i.e. introduce a
`MaterialSymbol("\uXXXX")` codepoint that wasn't already referenced anywhere in
`MaterialSymbols.kt` — you MUST regenerate the subset font by running:
```bash
./tools/material-symbols-subset/subset.sh
```
Commit the regenerated `material_symbols_outlined.ttf` alongside your
`MaterialSymbols.kt` change. Without this step the new icon renders as tofu (□)
at runtime because the glyph is not in the bundled font.
Reusing a codepoint already present in `MaterialSymbols.kt` does NOT require
regenerating. See `tools/material-symbols-subset/README.md` for details and
prerequisites (`pip install fonttools brotli`).
## Code Formatting
After completing any task that modifies Kotlin files, always run:
```

View File

@@ -745,8 +745,8 @@ android {
applicationId = "com.vitorpamplona.amethyst"
minSdk = 26 // Android 8.0 (Oreo)
targetSdk = 36 // Android 15
versionCode = 444
versionName = "1.09.2"
versionCode = 447
versionName = "1.11.0"
vectorDrawables {
useSupportLibrary = true

View File

@@ -7,7 +7,7 @@ description: Integration guide for using the Quartz Nostr KMP library in externa
Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr KMP projects.
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.09.2` (Maven Central)
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.11.0` (Maven Central)
**Targets**: JVM 21+, Android (minSdk 21+), iOS (XCFramework `quartz-kmpKit`)
**License**: MIT
@@ -19,7 +19,7 @@ Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr
```toml
[versions]
quartz = "1.09.2"
quartz = "1.11.0"
[libraries]
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
@@ -41,7 +41,7 @@ kotlin {
```kotlin
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.09.2")
implementation("com.vitorpamplona.quartz:quartz:1.11.0")
}
```

View File

@@ -3,7 +3,7 @@
## Current version
```
com.vitorpamplona.quartz:quartz:1.09.2
com.vitorpamplona.quartz:quartz:1.11.0
```
Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz
@@ -16,7 +16,7 @@ Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/qua
```toml
[versions]
quartz = "1.09.2"
quartz = "1.11.0"
[libraries]
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
@@ -55,7 +55,7 @@ kotlin {
```kotlin
// build.gradle.kts (app module)
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.09.2")
implementation("com.vitorpamplona.quartz:quartz:1.11.0")
}
```
@@ -70,7 +70,7 @@ plugins {
}
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.09.2")
implementation("com.vitorpamplona.quartz:quartz:1.11.0")
// JNA needed for libsodium (NIP-44) on JVM
implementation("net.java.dev.jna:jna:5.18.1")
}

View File

@@ -17,7 +17,7 @@ The Quartz library was successfully converted from Android-only to full KMP supp
## Current artifact
```
com.vitorpamplona.quartz:quartz:1.09.2
com.vitorpamplona.quartz:quartz:1.11.0
```
See `.claude/skills/quartz-integration/SKILL.md` for full integration guide.

View File

@@ -155,8 +155,20 @@ jobs:
;;
esac
# Compose UI smoke test (DesktopLaunchSmokeTest) uses Skiko which needs
# a display server on Linux. xvfb provides a virtual framebuffer.
- name: Install xvfb (Linux)
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y xvfb
- name: Test + Build Desktop (gradle)
run: ./gradlew :quartz:jvmTest :commons:jvmTest :nestsClient:jvmTest :cli:test :desktopApp:test :desktopApp:${{ matrix.desktop-task }}
run: |
CMD="./gradlew :quartz:jvmTest :commons:jvmTest :nestsClient:jvmTest :cli:test :desktopApp:test :desktopApp:${{ matrix.desktop-task }}"
if [ "${{ runner.os }}" = "Linux" ]; then
xvfb-run --auto-servernum $CMD
else
$CMD
fi
# jpackage pins libicu to the build host's version (libicu74 on
# ubuntu-24.04). Rewrite the .deb so testers on other Debian/Ubuntu

View File

@@ -21,9 +21,13 @@ permissions:
env:
# Asset naming contract: amethyst-desktop-<version>-<family>-<arch>.<ext>
# Single source of truth in scripts/asset-name.sh.
# linuxdeploy pinned release — bump via Dependabot, verify SHA256 via env var below.
LINUXDEPLOY_URL: https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20240109-1/linuxdeploy-x86_64.AppImage
LINUXDEPLOY_SHA256: c86d6540f1df31061f02f539a2d3445f8d7f85cc3994eee1e74cd1ac97b76df0
# appimagetool pinned release — bump via Dependabot, verify SHA256 via env var below.
# We used to use linuxdeploy here, but it auto-walks the AppDir with ldd to
# bundle deps — that fights jpackage's self-contained JRE (libjvm.so RPATH
# mismatch) and the UPX-compressed VLC plugins. appimagetool only embeds the
# AppDir as-is, which is what we actually want.
APPIMAGETOOL_URL: https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage
APPIMAGETOOL_SHA256: 46fdd785094c7f6e545b61afcfb0f3d98d8eab243f644b4b17698c01d06083d1
jobs:
# ---------------------------------------------------------------------------
@@ -35,7 +39,6 @@ jobs:
fail-fast: false
matrix:
include:
- { os: macos-13, arch: x64, family: macos, tasks: "packageReleaseDmg" }
- { os: macos-14, arch: arm64, family: macos, tasks: "packageReleaseDmg" }
- { os: windows-latest, arch: x64, family: windows, tasks: "packageReleaseMsi createReleaseDistributable" }
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" }
@@ -84,17 +87,19 @@ jobs:
if: matrix.family == 'linux'
run: sudo apt-get update && sudo apt-get install -y rpm fakeroot
- name: Fetch linuxdeploy (linux-portable only, SHA-verified)
- name: Fetch appimagetool (linux-portable only, SHA-verified)
if: matrix.family == 'linux-portable'
run: |
set -euo pipefail
curl -fsSL --retry 3 "$LINUXDEPLOY_URL" -o desktopApp/packaging/appimage/linuxdeploy-x86_64.AppImage
actual=$(sha256sum desktopApp/packaging/appimage/linuxdeploy-x86_64.AppImage | awk '{print $1}')
if [[ "$actual" != "$LINUXDEPLOY_SHA256" ]]; then
echo "::error::linuxdeploy SHA256 mismatch. Expected $LINUXDEPLOY_SHA256, got $actual"
# appimagetool 1.9.0 validates the .desktop file via desktop-file-validate.
sudo apt-get update && sudo apt-get install -y desktop-file-utils
curl -fsSL --retry 3 "$APPIMAGETOOL_URL" -o desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
actual=$(sha256sum desktopApp/packaging/appimage/appimagetool-x86_64.AppImage | awk '{print $1}')
if [[ "$actual" != "$APPIMAGETOOL_SHA256" ]]; then
echo "::error::appimagetool SHA256 mismatch. Expected $APPIMAGETOOL_SHA256, got $actual"
exit 1
fi
chmod +x desktopApp/packaging/appimage/linuxdeploy-x86_64.AppImage
chmod +x desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
- name: Build desktop artifacts
uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
@@ -204,7 +209,6 @@ jobs:
fail-fast: false
matrix:
include:
- { os: macos-13, arch: x64, family: macos, tasks: "amyImage" }
- { os: macos-14, arch: arm64, family: macos, tasks: "amyImage" }
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" }
runs-on: ${{ matrix.os }}

173
.github/workflows/smoke-test-desktop.yml vendored Normal file
View File

@@ -0,0 +1,173 @@
name: Desktop Smoke Test
on:
workflow_dispatch:
pull_request:
paths:
- 'desktopApp/**'
- '.github/workflows/smoke-test-desktop.yml'
permissions:
contents: read
concurrency:
group: smoke-desktop-${{ github.ref }}
cancel-in-progress: true
jobs:
# -------------------------------------------------------------------------
# 1) Compose UI test — verifies the composable tree renders (login screen)
# under the dev classpath. Catches missing string resources, broken
# composables, and basic dependency-graph issues.
# -------------------------------------------------------------------------
compose-ui-test:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up JDK 21
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: 21
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
with:
cache-read-only: true
- name: Install xvfb
run: sudo apt-get update && sudo apt-get install -y xvfb
- name: Run desktop tests (including UI smoke test)
run: xvfb-run --auto-servernum ./gradlew :desktopApp:test
# -------------------------------------------------------------------------
# 2) Release .deb build + launch — builds the ProGuard'd, jlink'd .deb
# package, installs it, and verifies the process stays alive for 10s.
# Catches ProGuard stripping (JNI, reflection), missing jlink modules
# (java.management, java.prefs), and native lib bundling issues.
# -------------------------------------------------------------------------
release-deb-launch:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up JDK 21
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: 21
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
with:
cache-read-only: true
- name: Cache vlc-setup downloads
uses: actions/cache@v4
with:
path: ~/.gradle/vlcSetup
key: vlcsetup-Linux-${{ hashFiles('desktopApp/build.gradle.kts') }}
restore-keys: |
vlcsetup-Linux-
- name: Pre-fetch VLC + UPX archives
env:
VLC_VERSION: "3.0.20"
UPX_VERSION: "4.2.4"
run: |
set -euo pipefail
DEST="$HOME/.gradle/vlcSetup"
mkdir -p "$DEST"
fetch() {
local url="$1" out="$2"
if [[ -s "$out" ]]; then echo "cached: $out"; return 0; fi
echo "fetching: $url"
curl -fL --retry 10 --retry-delay 5 --retry-all-errors \
--retry-max-time 900 --connect-timeout 30 \
-o "$out.part" "$url"
mv "$out.part" "$out"
}
fetch "https://repo1.maven.org/maven2/ir/mahozad/vlc-plugins-linux/${VLC_VERSION}/vlc-plugins-linux-${VLC_VERSION}.jar" \
"$DEST/vlc-${VLC_VERSION}.jar"
fetch "https://github.com/upx/upx/releases/download/v${UPX_VERSION}/upx-${UPX_VERSION}-amd64_linux.tar.xz" \
"$DEST/upx-${UPX_VERSION}.tar.xz"
- name: Install xvfb + packaging deps
run: sudo apt-get update && sudo apt-get install -y xvfb fakeroot
- name: Build release .deb
uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
with:
max_attempts: 2
timeout_minutes: 20
command: ./gradlew --no-daemon :desktopApp:packageReleaseDeb
- name: Relax libicu dependency
run: |
set -euo pipefail
chmod +x scripts/relax-deb-libicu.sh
scripts/relax-deb-libicu.sh desktopApp/build/compose/binaries/main-release/deb/*.deb
- name: Install .deb
run: |
# jpackage's post-install script runs xdg-desktop-menu which fails
# on CI runners ("No writable system menu directory"). The files are
# extracted successfully; only the menu registration fails. Allow the
# dpkg error, then verify the binary was actually installed.
sudo dpkg -i desktopApp/build/compose/binaries/main-release/deb/*.deb || true
echo "Installed files:"
dpkg -L amethyst | head -30
# Fail if the binary wasn't actually extracted
test -d /opt/amethyst || test -d /opt/Amethyst || {
echo "FAIL: /opt/amethyst not found after dpkg -i"
exit 1
}
- name: Smoke test — app launches and stays alive
run: |
set -euo pipefail
# Find the launcher binary
LAUNCHER=$(find /opt -name "Amethyst" -type f -executable 2>/dev/null | head -1)
if [[ -z "$LAUNCHER" ]]; then
# Fallback: search dpkg file list
LAUNCHER=$(dpkg -L amethyst | grep -E '/bin/[Aa]methyst$' | head -1)
fi
if [[ -z "$LAUNCHER" ]]; then
echo "FAIL: could not find Amethyst launcher binary"
dpkg -L amethyst
exit 1
fi
echo "Launcher: $LAUNCHER"
# Launch under xvfb with a timeout safety net
xvfb-run --auto-servernum timeout 30 "$LAUNCHER" &
APP_PID=$!
echo "PID: $APP_PID"
# Wait 10s — if the process is still alive, the app launched successfully
sleep 10
if kill -0 "$APP_PID" 2>/dev/null; then
echo "PASS: Application launched and stayed alive for 10s"
kill "$APP_PID" || true
wait "$APP_PID" 2>/dev/null || true
else
wait "$APP_PID" 2>/dev/null
EXIT_CODE=$?
echo "FAIL: Application exited with code $EXIT_CODE within 10s"
exit 1
fi
- name: Upload .deb artifact (for manual testing)
if: always()
uses: actions/upload-artifact@v7
with:
name: Release DEB (smoke-tested)
path: desktopApp/build/compose/binaries/main-release/deb/*.deb

3
.gitignore vendored
View File

@@ -167,8 +167,7 @@ desktopApp/src/jvmMain/appResources/macos/
desktopApp/src/jvmMain/appResources/windows/
# CI-fetched AppImage tooling (downloaded by create-release workflow; not committed)
desktopApp/packaging/appimage/linuxdeploy-x86_64.AppImage
desktopApp/packaging/appimage/linuxdeploy-extracted/
desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
desktopApp/packaging/appimage/squashfs-root/
# Git worktrees

View File

@@ -27,7 +27,8 @@ Platform-specific:
- **macOS**: Xcode Command Line Tools (`xcode-select --install`)
- **Windows**: WiX Toolset 3.x on PATH (for MSI). `winget install WiXToolset.WiXToolset`
- **Linux (all)**: nothing extra for `.deb`; `rpm` + `fakeroot` for `.rpm`; `linuxdeploy` for AppImage
- **Linux (all)**: nothing extra for `.deb`; `rpm` + `fakeroot` for `.rpm`;
`appimagetool` + `desktop-file-utils` for AppImage
Install Linux RPM tooling:
@@ -39,12 +40,15 @@ sudo apt-get install -y rpm fakeroot
sudo dnf install -y rpm-build
```
Install linuxdeploy locally (CI fetches its own — SHA-verified):
Install appimagetool locally (CI fetches its own — SHA-verified):
```bash
curl -fsSL -o desktopApp/packaging/appimage/linuxdeploy-x86_64.AppImage \
https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20240109-1/linuxdeploy-x86_64.AppImage
chmod +x desktopApp/packaging/appimage/linuxdeploy-x86_64.AppImage
# Debian/Ubuntu — appimagetool calls desktop-file-validate on the .desktop entry
sudo apt-get install -y desktop-file-utils
curl -fsSL -o desktopApp/packaging/appimage/appimagetool-x86_64.AppImage \
https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage
chmod +x desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
```
---
@@ -109,7 +113,7 @@ Where:
| Field | Values |
|---|---------------------------------------------------------|
| `<version>` | Tag stripped of leading `v` (e.g. `1.09.2`) |
| `<version>` | Tag stripped of leading `v` (e.g. `1.11.0`) |
| `<family>` | `macos`, `windows`, `linux` |
| `<arch>` | `x64`, `arm64` |
| `<ext>` | `dmg`, `msi`, `zip`, `deb`, `rpm`, `AppImage`, `tar.gz` |
@@ -120,10 +124,10 @@ any change is a breaking contract.
Examples:
- `amethyst-desktop-1.09.2-macos-x64.dmg`
- `amethyst-desktop-1.09.2-macos-arm64.dmg`
- `amethyst-desktop-1.09.2-windows-x64.msi`
- `amethyst-desktop-1.09.2-linux-x64.AppImage`
- `amethyst-desktop-1.11.0-macos-x64.dmg`
- `amethyst-desktop-1.11.0-macos-arm64.dmg`
- `amethyst-desktop-1.11.0-windows-x64.msi`
- `amethyst-desktop-1.11.0-linux-x64.AppImage`
---
@@ -143,7 +147,7 @@ Quartz library in one pipeline.
must increment even for same `versionName`):
```groovy
versionCode = 443
versionCode = 447
versionName = generateVersionName(libs.versions.app.get())
```
@@ -219,8 +223,8 @@ or equivalent issue tracker. On rotation, paste new token and run
```bash
brew bump-cask-pr amethyst-nostr \
--version 1.09.2 \
--url "https://github.com/vitorpamplona/amethyst/releases/download/v1.09.2/amethyst-desktop-1.09.2-macos-arm64.dmg"
--version 1.11.0 \
--url "https://github.com/vitorpamplona/amethyst/releases/download/v1.11.0/amethyst-desktop-1.11.0-macos-arm64.dmg"
```
The cask filename is `amethyst-nostr` (not `amethyst` — that's taken by a
@@ -231,7 +235,7 @@ auto-submits new version bumps on each stable release.
```bash
wingetcreate new \
https://github.com/vitorpamplona/amethyst/releases/download/v1.09.2/amethyst-desktop-1.09.2-windows-x64.msi
https://github.com/vitorpamplona/amethyst/releases/download/v1.11.0/amethyst-desktop-1.11.0-windows-x64.msi
```
Set `PackageIdentifier = VitorPamplona.Amethyst`. After the first manifest is

View File

@@ -1,3 +1,108 @@
<a id="v1.11.0"></a> NIP-52 Calendars, On-Chain Zap Polish
Highlights:
- NIP-52 Calendars — feed, day/week/month views, event/collection editors, RSVP, ICS export, "starting soon" notifications, share-as-nostr-link, gallery picker.
- On-Chain zaps polish — splits with dust gating, payment-targets modal redesign (QR + copy + pay), public-address chip + confirmation, on-chain handoff from custom-zap dialog, on-chain zappers in the reactions gallery.
- Privacy groundwork — I2P scaffolded as a parallel transport to Tor, route-aware HTTP stack, typed fail-closed `BlockedRouteException`.
- Notifications — dedicated settings screen, delivery vs display split, on-chain zaps in summary stats, push filters aligned with the Notifications feed.
- Inline nowhere-link cards in rich text.
- Wallet — clickable on-chain rows, tappable wallet card opens transaction history, reactive on-chain zap observation, time-windowed relay sub with burst coalescing.
PRs:
- Restore Lightning Address + LNURL fields in Edit Profile
- Raise the default maximum hashtag limit from 5 to 8
- Include on-chain zaps in notification summary stats
- Add electrum.nmc.ethicnology.com to default ElectrumX server set
- Translate call-permission and git-repo strings (cs, pt-BR, sv, de) + New Crowdin translations
- Subscribe and observe PaymentTargetsEvent for other users
- Only bridge to local cache when URL is BUD-01 layout; dedupe public-channel rows; normalize video/x-m4v to video/mp4
- Enable Send when typed name resolves via NIP-05; on-chain Bit recipient
- Inline Namecoin resolution indicator + result row on on-chain zaps
- Inline Namecoin resolution indicator in global search bar
- Long-press to copy Nostr Address, Website, LN Address, identities, payment targets
- macOS VLC bundled discovery and video rendering
- Make RelayAuthenticator authStatus thread-safe
- Pad GitRepositoryOverview content to clear top/bottom bars
- Use LargeCache for RelayAuthenticator authStatus
- MIME-type normalization for MediaStore
- Split delivery vs display, add Categories section in notifications
- Accept floating-point dimensions in imeta so image space is reserved pre-load
- Explain hashtag-limit hide reason in HiddenNote
- Route media-upload signing through launchSigner
- Render nowhere links inline as branded cards
- Release MediaController after 30s in background; skip timer for PiP
- Clickable on-chain card opens transaction history; tappable on-chain rows + txid index in LocalCache
- Disable ProGuard optimization entirely on desktop (fixes kmp-tor crash); .deb launch CI smoke test
- Show on-chain zappers in expanded reactions gallery
- Convert hashtag-limit message to <plurals> and add cs/pt-BR/sv/de translations
- Restore long-press on root note + anchor popup to the card
- Keep filter chips visible on empty on-chain transactions list
- Make playback notification tap open the note on warm-pool resume
- Validate zap receipts against LNURL provider's nostrPubkey (NIP-57 Appendix F)
- Stable sort in ShortsFeedFilter to avoid TimSort contract crash
- Align push notifications with Notifications feed filter; resolve addressable events to their replaceable note; WakeUp bypass + lookup hoist
- Update on-chain-zap + headers-explorer plans for NIP-BC inline SPV tags
- Start zoomable dialog close animation from zoomed bounds
- Scaffold I2P as a parallel privacy transport to Tor; route-aware HTTP stack; BlockedRouteException
- Reorder feed filter popup sections (DVMs before Communities, interest sets next to hashtags)
- Stop spinning Around Me when Geocoder is unavailable
- NIP-52 calendars — feed, day/week/month views, RSVP, ICS export, reminders, edit, share-as-nostr-link, participant picker, multi-day bars
- Audit follow-ups on MLS reply paths; route reply button on MLS messages to the encrypted group
- Use LaunchedEffect instead of remember for VM reset
- Switch to Dispatchers.IO around the whole executeAsync call in relay-info
- Drop crossfade around the card feed in notifications
- Observe locale in CalendarDateTimePickerButton
- Modernize zap amount choice popup to match reactions popup style
- Catch ForegroundServiceStartNotAllowedException in PlaybackService
- Include crashing thread name in crash report
- Convert remaining .gradle files to Kotlin DSL
- Redesign payment targets modal with QR, copy and pay buttons
- Add Public chip to on-chain wallet card; clarify privacy guidance on on-chain wallet popup
- Require running material-symbols-subset.sh when adding new icons; add new information icon
- Re-add Nostrcheck.me Blossom server to defaults
- Surface on-chain zaps from the reactions zap button
- On-chain zap splits — drop sender from splits, merge duplicates, gate Send on dust
- On-chain option on the Zap the Devs button
- On-chain handoff from the custom-zap dialog
- Keep listening on default index+search relays for users with no kind 10002
- Skip offline relays when fetching user metadata
- Include indexer and proxy relays in the trusted relay list to avoid Tor
- Confirm on-chain copy with public-address warning
- Swallow LegacyCursorAnchorInfo IllegalArgumentException
- Render ContactListEvent in NoteCompose with tap-through user list; add preview for DisplayContactList
## Contributors
- @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z
- @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
- @npub12cfje6nl2nuxplcqfvhg7ljt89fmpj0n0fd24zxsukja5qm9wmtqd7y76c
- @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5
- @npub1a3tx8wcrt789skl6gg7rqwj4wey0j53eesr4z6asd4h4jwrd62jq0wkq4k
- @npub1dn0tej4a5806qk9ts56j572sndvjk27l5qmsxf0z3mquknccve7s4k8tfp
## Translations
- Czech, German, Swedish, and Portuguese by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
- Hungarian by @npub1dnvslq0vvrs8d603suykc4harv94yglcxwna9sl2xu8grt2afm3qgfh0tp
- French by @npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz
- Dutch by @npub1w4la29u3zv09r6crx5u8yxax0ffxgekzdm2egzjkjckef7xc83fs0ftxcd
- Polish by @npub16gjyljum0ksrrm28zzvejydgxwfm7xse98zwc4hlgq8epxeuggushqwyrm
- Hindi by @npub1ww6huwu3xye6r05n3qkjeq62wds5pq0jswhl7uc59lchc0n0ns4sdtw5e6
- Slovenian by @npub1qqqqqqz7nhdqz3uuwmzlflxt46lyu7zkuqhcapddhgz66c4ddynswreecw
- Bengali by @npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t
- Spanish by @npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903
- Chinese by hypnotichemionus4 and @npub1gd8e0xfkylc7v8c5a6hkpj4gelwwcy99jt90lqjseqjj2t253s2s6ch58h
- Russian by Anton Zhao
<a id="v1.10.0"></a> On-Chain zaps
- ci: remove macos-13 x64 build legs by @vitorpamplona in #2939
- Replace linuxdeploy with appimagetool for AppImage packaging by @vitorpamplona in #2940
- Sync inner note highlight fade with parent background color by @vitorpamplona in #2941
- Handle denied call permissions with settings dialog by @vitorpamplona in #2942
- Fix gradient rendering with transparent background colors by @vitorpamplona in #2943
- Add NIP-BC onchain Bitcoin zaps support (send, receive, display) by @vitorpamplona in #2944
<a id="v1.09.2"></a> Fixes
- Fix Blossom blob detection to reject non-compliant filenames by @greenart7c3 in #2919

View File

@@ -1,435 +0,0 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.googleServices)
alias(libs.plugins.jetbrainsComposeCompiler)
alias(libs.plugins.serialization)
}
def getCurrentBranch() {
try {
def branch = 'git rev-parse --abbrev-ref HEAD'.execute().text.trim()
return branch
} catch (Exception e) {
println "Could not determine git branch: ${e.message}"
return "unknown"
}
}
def generateVersionName(String baseVersion) {
def currentBranch = getCurrentBranch()
if (currentBranch == "main" || currentBranch == "master" || currentBranch == "unknown" || currentBranch == "HEAD") {
return baseVersion
} else {
// Clean branch name for version (replace special characters)
def cleanBranch = currentBranch.replaceAll(/[^a-zA-Z0-9\-_]/, "-")
// Limit branch name to maximum 20 characters
if (cleanBranch.length() > 20) {
cleanBranch = cleanBranch.substring(0, 20)
}
return "${baseVersion}-${cleanBranch}"
}
}
// Workaround: stability.analyzer plugin doesn't declare task dependencies properly for Gradle 9.x
afterEvaluate {
def stabilityNames = tasks.names.findAll { it.contains("StabilityCheck") }
def compileNames = tasks.names.findAll { it.matches("compile.*UnitTestKotlin") }
stabilityNames.each { scName ->
compileNames.each { ctName ->
tasks.named(scName).configure { mustRunAfter(tasks.named(ctName)) }
}
}
}
android {
namespace = 'com.vitorpamplona.amethyst'
compileSdk = libs.versions.android.compileSdk.get().toInteger()
defaultConfig {
applicationId = "com.vitorpamplona.amethyst"
minSdk = libs.versions.android.minSdk.get().toInteger()
targetSdk = libs.versions.android.targetSdk.get().toInteger()
versionCode = 445
versionName = generateVersionName(libs.versions.app.get())
buildConfigField "String", "RELEASE_NOTES_ID", "\"b1b91d7ee0c5da9d081d1a53470248ee4585b058b11aa34fe28c0e3e07ac1e0a\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
resourceConfigurations += [
'ar',
'ar-rSA',
'bn-rBD',
'cs',
'cs-rCZ',
'cy-rGB',
'da-rDK',
'de',
'de-rDE',
'el-rGR',
'en-rGB',
'eo',
'eo-rUY',
'es',
'es-rES',
'es-rMX',
'es-rUS',
'et-rEE',
'fa',
'fa-rIR',
'fi-rFI',
'fo-rFO',
'fr',
'fr-rCA',
'fr-rFR',
'gu-rIN',
'hi-rIN',
'hr-rHR',
'hu',
'hu-rHU',
'in',
'in-rID',
'it-rIT',
'iw-rIL',
'ja',
'ja-rJP',
'kk-rKZ',
'ko-rKR',
'ks-rIN',
'ku-rTR',
'lt-rLT',
'ne-rNP',
'nl',
'nl-rBE',
'nl-rNL',
'pcm-rNG',
'pl-rPL',
'pt-rBR',
'pt-rPT',
'ru',
'ru-rRU',
'ru-rUA',
'sa-rIN',
'sl-rSI',
'so-rSO',
'sr-rSP',
'ss-rZA',
'sv-rSE',
'sw-rKE',
'sw-rTZ',
'ta',
'ta-rIN',
'th',
'th-rTH',
'tr',
'tr-rTR',
'uk',
'uk-rUA',
'ur-rIN',
'uz-rUZ',
'vi-rVN',
'zh',
'zh-rCN',
'zh-rHK',
'zh-rSG',
'zh-rTW'
]
}
// Opt-in fast-build flags. Default behavior is unchanged.
//
// -PdisableAbiSplits=true skip per-ABI APK splits; produces a single
// APK per (flavor, buildType) instead of 5.
// Cuts ~600 MB of intermediates and several
// minutes off CI.
// -PdisableUniversalApk=true when ABI splits are enabled, skip the
// extra universal APK output. (No effect
// when disableAbiSplits is also set, since
// there are no splits to add to.)
// -Pamethyst.skipMapping=true disable R8 minification on release and
// benchmark. APK is larger, but builds are
// much faster and outputs/mapping/ (~260MB)
// is not produced. Local-dev and PR-CI use
// only — release pipelines must not set it.
def disableAbiSplits = providers.gradleProperty("disableAbiSplits")
.map { it.toBoolean() }.getOrElse(false)
def disableUniversalApk = providers.gradleProperty("disableUniversalApk")
.map { it.toBoolean() }.getOrElse(false)
def skipMapping = providers.gradleProperty("amethyst.skipMapping")
.map { it.toBoolean() }.getOrElse(false)
buildTypes {
release {
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), 'proguard-rules.pro'
minifyEnabled = !skipMapping
}
debug {
applicationIdSuffix '.debug'
versionNameSuffix '-DEBUG'
resValue "string", "app_name", "@string/app_name_debug"
}
create("benchmark") {
initWith(getByName("release"))
applicationIdSuffix '.benchmark'
versionNameSuffix '-BENCHMARK'
resValue "string", "app_name", "@string/app_name_benchmark"
profileable = true
signingConfig = signingConfigs.debug
}
}
// TODO: remove this when lightcompressor uses one MP4 parser only
packaging {
resources {
resources.pickFirsts.add('builddef.lst')
resources.pickFirsts.add('META-INF/LICENSE.md')
resources.pickFirsts.add('META-INF/LICENSE-notice.md')
}
}
flavorDimensions = ["channel"]
productFlavors {
play {
getIsDefault().set(true)
dimension "channel"
buildConfigField "boolean", "IS_CASTING_AVAILABLE", "true"
}
fdroid {
dimension "channel"
buildConfigField "boolean", "IS_CASTING_AVAILABLE", "false"
}
}
splits {
abi {
enable = !disableAbiSplits
reset()
include "x86", "x86_64", "arm64-v8a", "armeabi-v7a"
universalApk = !disableUniversalApk
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
buildFeatures {
compose = true
buildConfig = true
resValues = true
}
packagingOptions {
resources {
excludes += ['/META-INF/{AL2.0,LGPL2.1}', '**/libscrypt.dylib']
}
}
lint {
disable 'MissingTranslation'
}
testOptions {
unitTests.returnDefaultValues = true
}
}
// TODO: until google merges and unifiedpush updates https://github.com/tink-crypto/tink-java-apps/pull/5
configurations.all {
def tink = "com.google.crypto.tink:tink-android:1.17.0"
resolutionStrategy {
force(tink)
dependencySubstitution {
substitute module('com.google.crypto.tink:tink') using module(tink)
}
}
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_21
}
}
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_compiler")
metricsDestination = layout.buildDirectory.dir("compose_compiler")
}
dependencies {
implementation platform(libs.androidx.compose.bom)
implementation project(path: ':quartz')
implementation project(path: ':commons')
implementation project(path: ':nestsClient')
implementation libs.androidx.core.ktx
implementation libs.androidx.activity.compose
implementation libs.androidx.ui
implementation libs.androidx.ui.graphics
implementation libs.androidx.ui.tooling.preview
// Needs this to open gallery / image upload
implementation libs.androidx.fragment.ktx
// Navigation
implementation libs.androidx.navigation.compose
// Material 3 Design
implementation libs.androidx.material3
// Adaptive Layout / Two Pane
implementation libs.androidx.material3.windowSize
implementation libs.accompanist.adaptive
// Lifecycle
implementation libs.androidx.lifecycle.runtime.ktx
implementation libs.androidx.lifecycle.runtime.compose
implementation libs.androidx.lifecycle.viewmodel.compose
// Zoomable images
implementation libs.zoomable
// Biometrics
implementation libs.androidx.biometric.ktx
// Background Work
implementation libs.androidx.work.runtime.ktx
// Websockets API
implementation libs.okhttp
implementation libs.okhttpCoroutines
// Encrypted Key Storage
implementation libs.androidx.security.crypto.ktx
implementation libs.androidx.datastore.preferences
// view videos
implementation libs.androidx.media3.exoplayer
implementation libs.androidx.media3.exoplayer.hls
implementation libs.androidx.media3.ui.compose.material3
implementation libs.androidx.media3.session
// important for proxy / tor
implementation libs.androidx.media3.datasource.okhttp
// Load images from the web.
implementation libs.coil.compose
// view gifs
implementation libs.coil.gif
// view svgs
implementation libs.coil.svg
// enables network for coil
implementation libs.coil.okhttp
// loads thumbnails for media3
// TODO: Replace this to the FrameExtractor in media 3
// when FrameExtractor accepts custom data sources.
implementation(libs.coil.video)
// Permission to upload pictures:
implementation libs.accompanist.permissions
// For QR generation
implementation libs.zxing
implementation libs.zxing.embedded
// Markdown
//implementation "com.halilibo.compose-richtext:richtext-ui:0.16.0"
//implementation "com.halilibo.compose-richtext:richtext-ui-material:0.16.0"
//implementation "com.halilibo.compose-richtext:richtext-commonmark:0.16.0"
// Markdown (With fix for full-image bleeds)
implementation libs.markdown.ui
implementation libs.markdown.ui.material3
implementation libs.markdown.commonmark
// Language picker and Theme chooser
implementation libs.androidx.appcompat
// Dynamically adjust between phone and tablet UI
implementation libs.androidx.window.core.android
// Local model for language identification
playImplementation libs.google.mlkit.language.id
// Google services model the translate text
playImplementation libs.google.mlkit.translate
// On-device AI writing assistance (Gemini Nano via AICore)
playImplementation libs.google.mlkit.genai.proofreading
playImplementation libs.google.mlkit.genai.prompt
playImplementation libs.google.mlkit.genai.rewriting
// On-device alt-text suggestions: genai image description (preferred, descriptive sentences)
// with image-labeling as a keyword-join fallback for devices without AICore.
playImplementation libs.google.mlkit.genai.image.description
// PushNotifications
playImplementation platform(libs.firebase.bom)
playImplementation libs.firebase.messaging
//PushNotifications(FDroid)
fdroidImplementation libs.unifiedpush
// Google Cast SDK — Chromecast support. Play flavor only because the
// framework hard-depends on Google Play services, which is unavailable
// on de-Googled / GrapheneOS devices that ship the F-Droid build.
playImplementation libs.play.services.cast.framework
// Charts
implementation libs.vico.charts.compose
implementation libs.vico.charts.m3
// Waveform visualizer
implementation libs.audiowaveform
// Video compression lib
implementation libs.abedElazizShe.video.compressor.fork
// Image compression lib
implementation libs.zelory.image.compressor
// EXIF metadata stripping
implementation libs.androidx.exifinterface
// Voice anonymization DSP
implementation libs.tarsosdsp
// WebRTC for voice/video calls
implementation libs.stream.webrtc.android
// Cbor for cashuB format
implementation libs.kotlinx.serialization.cbor
// Kotlin serialization for the times where we need the Json tree and performance is not that important.
implementation(libs.kotlinx.serialization.json)
testImplementation libs.junit
testImplementation libs.mockk
testImplementation libs.kotlinx.coroutines.test
androidTestImplementation platform(libs.androidx.compose.bom)
androidTestImplementation libs.androidx.junit
androidTestImplementation libs.androidx.junit.ktx
androidTestImplementation libs.androidx.espresso.core
androidTestImplementation libs.androidx.ui.test.junit4
androidTestImplementation libs.mockk.android
debugImplementation platform(libs.androidx.compose.bom)
debugImplementation libs.androidx.ui.tooling
debugImplementation libs.androidx.ui.test.manifest
implementation libs.androidx.camera.core
implementation libs.androidx.camera.camera2
implementation libs.androidx.camera.lifecycle
implementation libs.androidx.camera.view
implementation libs.androidx.camera.extensions
}

463
amethyst/build.gradle.kts Normal file
View File

@@ -0,0 +1,463 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.googleServices)
alias(libs.plugins.jetbrainsComposeCompiler)
alias(libs.plugins.serialization)
}
fun getCurrentBranch(): String =
try {
val process =
ProcessBuilder("git", "rev-parse", "--abbrev-ref", "HEAD")
.redirectErrorStream(true)
.start()
val branch =
process.inputStream
.bufferedReader()
.use { it.readText() }
.trim()
process.waitFor()
branch
} catch (e: Exception) {
println("Could not determine git branch: ${e.message}")
"unknown"
}
fun generateVersionName(baseVersion: String): String {
val currentBranch = getCurrentBranch()
if (currentBranch == "main" || currentBranch == "master" || currentBranch == "unknown" || currentBranch == "HEAD") {
return baseVersion
}
// Clean branch name for version (replace special characters)
var cleanBranch = currentBranch.replace(Regex("[^a-zA-Z0-9\\-_]"), "-")
// Limit branch name to maximum 20 characters
if (cleanBranch.length > 20) {
cleanBranch = cleanBranch.substring(0, 20)
}
return "$baseVersion-$cleanBranch"
}
// Workaround: stability.analyzer plugin doesn't declare task dependencies properly for Gradle 9.x
afterEvaluate {
val stabilityNames = tasks.names.filter { it.contains("StabilityCheck") }
val compileNames = tasks.names.filter { it.matches(Regex("compile.*UnitTestKotlin")) }
stabilityNames.forEach { scName ->
compileNames.forEach { ctName ->
tasks.named(scName).configure { mustRunAfter(tasks.named(ctName)) }
}
}
}
android {
namespace = "com.vitorpamplona.amethyst"
compileSdk =
libs.versions.android.compileSdk
.get()
.toInt()
defaultConfig {
applicationId = "com.vitorpamplona.amethyst"
minSdk =
libs.versions.android.minSdk
.get()
.toInt()
targetSdk =
libs.versions.android.targetSdk
.get()
.toInt()
versionCode = 447
versionName = generateVersionName(libs.versions.app.get())
buildConfigField("String", "RELEASE_NOTES_ID", "\"8ec0d94550b5538115226c6858159b1115713c9c6ed942173bd4fd5d292d8ba6\"")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
@Suppress("UnstableApiUsage")
resourceConfigurations +=
listOf(
"ar",
"ar-rSA",
"bn-rBD",
"cs",
"cs-rCZ",
"cy-rGB",
"da-rDK",
"de",
"de-rDE",
"el-rGR",
"en-rGB",
"eo",
"eo-rUY",
"es",
"es-rES",
"es-rMX",
"es-rUS",
"et-rEE",
"fa",
"fa-rIR",
"fi-rFI",
"fo-rFO",
"fr",
"fr-rCA",
"fr-rFR",
"gu-rIN",
"hi-rIN",
"hr-rHR",
"hu",
"hu-rHU",
"in",
"in-rID",
"it-rIT",
"iw-rIL",
"ja",
"ja-rJP",
"kk-rKZ",
"ko-rKR",
"ks-rIN",
"ku-rTR",
"lt-rLT",
"ne-rNP",
"nl",
"nl-rBE",
"nl-rNL",
"pcm-rNG",
"pl-rPL",
"pt-rBR",
"pt-rPT",
"ru",
"ru-rRU",
"ru-rUA",
"sa-rIN",
"sl-rSI",
"so-rSO",
"sr-rSP",
"ss-rZA",
"sv-rSE",
"sw-rKE",
"sw-rTZ",
"ta",
"ta-rIN",
"th",
"th-rTH",
"tr",
"tr-rTR",
"uk",
"uk-rUA",
"ur-rIN",
"uz-rUZ",
"vi-rVN",
"zh",
"zh-rCN",
"zh-rHK",
"zh-rSG",
"zh-rTW",
)
}
// Opt-in fast-build flags. Default behavior is unchanged.
//
// -PdisableAbiSplits=true skip per-ABI APK splits; produces a single
// APK per (flavor, buildType) instead of 5.
// Cuts ~600 MB of intermediates and several
// minutes off CI.
// -PdisableUniversalApk=true when ABI splits are enabled, skip the
// extra universal APK output. (No effect
// when disableAbiSplits is also set, since
// there are no splits to add to.)
// -Pamethyst.skipMapping=true disable R8 minification on release and
// benchmark. APK is larger, but builds are
// much faster and outputs/mapping/ (~260MB)
// is not produced. Local-dev and PR-CI use
// only — release pipelines must not set it.
val disableAbiSplits =
providers
.gradleProperty("disableAbiSplits")
.map { it.toBoolean() }
.getOrElse(false)
val disableUniversalApk =
providers
.gradleProperty("disableUniversalApk")
.map { it.toBoolean() }
.getOrElse(false)
val skipMapping =
providers
.gradleProperty("amethyst.skipMapping")
.map { it.toBoolean() }
.getOrElse(false)
buildTypes {
getByName("release") {
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
isMinifyEnabled = !skipMapping
}
getByName("debug") {
applicationIdSuffix = ".debug"
versionNameSuffix = "-DEBUG"
resValue("string", "app_name", "@string/app_name_debug")
}
create("benchmark") {
initWith(getByName("release"))
applicationIdSuffix = ".benchmark"
versionNameSuffix = "-BENCHMARK"
resValue("string", "app_name", "@string/app_name_benchmark")
isProfileable = true
signingConfig = signingConfigs.getByName("debug")
}
}
// TODO: remove this when lightcompressor uses one MP4 parser only
packaging {
resources {
pickFirsts.add("builddef.lst")
pickFirsts.add("META-INF/LICENSE.md")
pickFirsts.add("META-INF/LICENSE-notice.md")
}
}
flavorDimensions += "channel"
productFlavors {
create("play") {
isDefault = true
dimension = "channel"
buildConfigField("boolean", "IS_CASTING_AVAILABLE", "true")
}
create("fdroid") {
dimension = "channel"
buildConfigField("boolean", "IS_CASTING_AVAILABLE", "false")
}
}
splits {
abi {
isEnable = !disableAbiSplits
reset()
include("x86", "x86_64", "arm64-v8a", "armeabi-v7a")
isUniversalApk = !disableUniversalApk
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
buildFeatures {
compose = true
buildConfig = true
resValues = true
}
packaging {
resources {
excludes += listOf("/META-INF/{AL2.0,LGPL2.1}", "**/libscrypt.dylib")
}
}
lint {
disable += "MissingTranslation"
}
testOptions {
unitTests.isReturnDefaultValues = true
}
}
// TODO: until google merges and unifiedpush updates https://github.com/tink-crypto/tink-java-apps/pull/5
configurations.all {
val tink = "com.google.crypto.tink:tink-android:1.17.0"
resolutionStrategy {
force(tink)
dependencySubstitution {
substitute(module("com.google.crypto.tink:tink")).using(module(tink))
}
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
}
}
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_compiler")
metricsDestination = layout.buildDirectory.dir("compose_compiler")
}
dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(project(":quartz"))
implementation(project(":commons"))
implementation(project(":nestsClient"))
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
// Needs this to open gallery / image upload
implementation(libs.androidx.fragment.ktx)
// Navigation
implementation(libs.androidx.navigation.compose)
// Material 3 Design
implementation(libs.androidx.material3)
// Adaptive Layout / Two Pane
implementation(libs.androidx.material3.windowSize)
implementation(libs.accompanist.adaptive)
// Lifecycle
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
// Zoomable images
implementation(libs.zoomable)
// Biometrics
implementation(libs.androidx.biometric.ktx)
// Background Work
implementation(libs.androidx.work.runtime.ktx)
// Websockets API
implementation(libs.okhttp)
implementation(libs.okhttpCoroutines)
// Encrypted Key Storage
implementation(libs.androidx.security.crypto.ktx)
implementation(libs.androidx.datastore.preferences)
// view videos
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.exoplayer.hls)
implementation(libs.androidx.media3.ui.compose.material3)
implementation(libs.androidx.media3.session)
// important for proxy / tor
implementation(libs.androidx.media3.datasource.okhttp)
// Load images from the web.
implementation(libs.coil.compose)
// view gifs
implementation(libs.coil.gif)
// view svgs
implementation(libs.coil.svg)
// enables network for coil
implementation(libs.coil.okhttp)
// loads thumbnails for media3
// TODO: Replace this to the FrameExtractor in media 3
// when FrameExtractor accepts custom data sources.
implementation(libs.coil.video)
// Permission to upload pictures:
implementation(libs.accompanist.permissions)
// For QR generation
implementation(libs.zxing)
implementation(libs.zxing.embedded)
// Markdown
// implementation "com.halilibo.compose-richtext:richtext-ui:0.16.0"
// implementation "com.halilibo.compose-richtext:richtext-ui-material:0.16.0"
// implementation "com.halilibo.compose-richtext:richtext-commonmark:0.16.0"
// Markdown (With fix for full-image bleeds)
implementation(libs.markdown.ui)
implementation(libs.markdown.ui.material3)
implementation(libs.markdown.commonmark)
// Language picker and Theme chooser
implementation(libs.androidx.appcompat)
// Dynamically adjust between phone and tablet UI
implementation(libs.androidx.window.core.android)
// Local model for language identification
"playImplementation"(libs.google.mlkit.language.id)
// Google services model the translate text
"playImplementation"(libs.google.mlkit.translate)
// On-device AI writing assistance (Gemini Nano via AICore)
"playImplementation"(libs.google.mlkit.genai.proofreading)
"playImplementation"(libs.google.mlkit.genai.prompt)
"playImplementation"(libs.google.mlkit.genai.rewriting)
// On-device alt-text suggestions: genai image description (preferred, descriptive sentences)
// with image-labeling as a keyword-join fallback for devices without AICore.
"playImplementation"(libs.google.mlkit.genai.image.description)
// PushNotifications
"playImplementation"(platform(libs.firebase.bom))
"playImplementation"(libs.firebase.messaging)
// PushNotifications(FDroid)
"fdroidImplementation"(libs.unifiedpush)
// Google Cast SDK — Chromecast support. Play flavor only because the
// framework hard-depends on Google Play services, which is unavailable
// on de-Googled / GrapheneOS devices that ship the F-Droid build.
"playImplementation"(libs.play.services.cast.framework)
// Charts
implementation(libs.vico.charts.compose)
implementation(libs.vico.charts.m3)
// Waveform visualizer
implementation(libs.audiowaveform)
// Video compression lib
implementation(libs.abedElazizShe.video.compressor.fork)
// Image compression lib
implementation(libs.zelory.image.compressor)
// EXIF metadata stripping
implementation(libs.androidx.exifinterface)
// Voice anonymization DSP
implementation(libs.tarsosdsp)
// WebRTC for voice/video calls
implementation(libs.stream.webrtc.android)
// Cbor for cashuB format
implementation(libs.kotlinx.serialization.cbor)
// Kotlin serialization for the times where we need the Json tree and performance is not that important.
implementation(libs.kotlinx.serialization.json)
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.junit.ktx)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.ui.test.junit4)
androidTestImplementation(libs.mockk.android)
debugImplementation(platform(libs.androidx.compose.bom))
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
implementation(libs.androidx.camera.core)
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.view)
implementation(libs.androidx.camera.extensions)
}

View File

@@ -0,0 +1,367 @@
# Onchain Zaps in Amethyst
**Date:** 2026-05-14
**Status:** Active
Implementation plan for NIP-BC (kind 8333) onchain Bitcoin zaps in Amethyst Android.
Quartz now ships the `OnchainZapEvent` data model; this document describes the
rest of the system.
## Design decisions
- **Wallet model.** Non-custodial. The user's Nostr pubkey IS the BIP-341
internal key of a P2TR output, so every account has exactly one onchain
address derived from its identity. No seed phrase, no separate wallet
creation.
- **Signers.** v1 supports `NostrSignerInternal` (local keypair) and
`NostrSignerExternal` (NIP-55 Android external signer). The NIP-55 path is
broken until Amber implements `sign_psbt`; surface "update your signer"
there. `NostrSignerRemote` (NIP-46) is deferred.
- **Chain backend.** User-configured Esplora-compatible API
(mempool.space, blockstream.info, self-hosted). The configured server sees
the user's UTXO queries — accepted tradeoff for v1. **Spec PR
nostr-protocol/nips#2332 adds optional `["block", …]` and
`["proof", …]` tags on `kind:8333` that let a verifier with only the
Bitcoin header chain check a zap end-to-end without trusting the
explorer. Amethyst already parses both tags (see `BlockTag.kt` /
`ProofTag.kt`) but no production code path produces or consumes them
yet — see the "Inline SPV proofs" section below.** The explorer
endpoint is shared with OpenTimestamps via `BitcoinExplorerEndpoint`:
same user-configured server, same Tor-aware default selection.
- **Scope.** Full send + receive + display loop on Android. Desktop is out of
scope for v1.
### Architecture decision: hand-rolled Bitcoin consensus code (2026-05-14)
**Decision:** keep the hand-rolled Bitcoin consensus layer in
`quartz/.../nipBCOnchainZaps/{psbt,taproot}/` — the transaction codec,
serialization/txid, BIP-341 sighash, BIP-174 PSBT codec/signer/finalizer, and
BIP-341/350 address derivation. Do **not** pull in `fr.acinq.bitcoin-kmp`.
**Considered alternative:** replace the `psbt/` + transaction + sighash layer
with `fr.acinq.bitcoin-kmp` (mature, same vendor as the `secp256k1` binding
already in the build).
**Rationale for keeping it hand-rolled:**
- It is a deliberately small, constrained subset — single-key-path P2TR only,
no script trees, one transaction shape.
- It is pinned to authoritative external test vectors at *every* layer:
BIP-341 sighash (all 7 vectors + ANYONECANPAY), the BIP-341 tweak, the full
BIP-341 witness *signature bytes*, the 7 BIP-341/350 P2TR mainnet addresses,
and tx serialization against the genesis coinbase. It is "matches the
authoritative vectors," not "trust our code."
- Consistent with the project's stance on minimal dependencies (cf. the
from-scratch `quic` module).
- No new transitive dependencies or version-conflict surface.
**Consequence / what this commits us to:** we own the correctness of this code
forever. If the scope ever expands beyond single-key-path P2TR (script-path
spends, multisig, PSBT fields we don't model), revisit this decision — at that
point a vetted library is the better trade. The `nipBCOnchainZaps/{psbt,taproot}/`
packages carry a pointer back to this section.
## Architecture
| Layer | Concerns | Location |
|---|---|---|
| Quartz | Address derivation, PSBT codec, Esplora client, NIP-BC verifier, `signPsbt` on signer hierarchy | `quartz/.../nipBCOnchainZaps/{taproot,psbt,chain,build,verify}/` |
| Commons | Send/wallet ViewModels, shared composables | `commons/.../onchain/` |
| Amethyst | `OnchainSection` in existing `WalletScreen`, `LocalCache.consume(OnchainZapEvent)`, kind list edits in 8 filter files, NIP-55 intent plumbing | `amethyst/.../ui/onchain/`, `amethyst/.../model/LocalCache.kt`, existing filter files |
## Merging into the existing wallet UI
The existing `WalletScreen` is a NIP-47 NWC multi-wallet manager. Onchain wallet
fits as a separate top section, since it has different semantics (single
deterministic wallet per account, no NWC URI, chain-backed).
```
WalletScreen
├── TopAppBar
├── BitcoinSection ← NEW, single card
│ └── OnchainWalletCard (balance, bc1p address, tap → OnchainWalletDetailScreen)
└── LightningSection ← existing MultiWalletHomeContent
├── NoWalletSetup
└── NwcWalletCard × N
```
The "+" `Add wallet` icon still adds NWC entries only — onchain is implicit.
Section labels: "Bitcoin" and "Lightning".
## Subscription edits — extend existing kind lists
No new assemblers. Add `OnchainZapEvent.KIND` (8333) to the existing
`LnZapEvent.KIND` (9735) sites:
| File | Edit |
|---|---|
| `amethyst/.../FilterRepliesAndReactionsToNotes.kt:48-60` | Add to `RepliesAndReactionsKinds` (note `#e`) |
| `amethyst/.../FilterUserProfileZapReceived.kt:30` | Add to `UserProfileZapReceiverKinds` (profile `#p`) |
| `amethyst/.../zaps/dal/UserProfileZapsViewModel.kt:55` | Add to inline `kinds = listOf(...)` |
| `amethyst/.../FilterNotificationsToPubkey.kt:58-65` | Add to `SummaryKinds` (notifications `#p`) |
| `amethyst/.../NotificationFeedFilter.kt:123` | Add to `NOTIFICATION_KINDS` |
| `amethyst/.../NotificationDispatcher.kt:98` | Add to `NOTIFICATION_KINDS` |
| `amethyst/.../FilterMessagesToLiveStream.kt:46` | Add to live-activity zap kinds (`#a`) |
| `amethyst/.../FilterGoalForLiveActivity.kt:58` | Add to goal-zap kinds (`#e`) |
## Display path — fold into `Note.zapsAmount`
Today: `LocalCache.consume(LnZapEvent)``Note.addZap()``Note.updateZapTotal()`
sums lightning amounts into `Note.zapsAmount`, which `ReactionsRow` /
`ObserveZapAmountText` / `SlidingAnimationAmount` render. We add onchain zap
sats to the same `Note.zapsAmount` — no UI changes required.
- `commons/.../model/Note.kt:154-157, 621-632`
- Add `var onchainZaps = mapOf<...>()` (separate map from `zaps`).
- Extend `updateZapTotal()` to add **verified** onchain sats. Unverified or
pending tx amounts are NOT counted.
- `amethyst/.../model/LocalCache.kt` (after the `consume(LnZapEvent)` block ~line 1667)
- New `consume(event: OnchainZapEvent)` handler.
- Reject self-zap.
- Enqueue verification against the configured `OnchainBackend`.
- On success: `Note.addOnchainZap(event, verifiedSats)` on each `repliesTo`.
- On failure or zero verified amount: discard.
- Dedupe by `(txid, target)`.
## Quartz additions
- `nipBCOnchainZaps/taproot/`
- `SegwitAddress.kt` — bech32m segwit address encoder/decoder
- `TaprootAddress.kt` — Nostr pubkey → bc1p P2TR address via BIP-341
key-path-only tweak (uses `Secp256k1Instance.pubKeyTweakAdd`)
- `nipBCOnchainZaps/chain/`
- `OnchainBackend` interface — `getTx`, `getUtxos`, `broadcast`,
`tipHeight`, `feeEstimates`
- `BitcoinTx`, `BitcoinTxOutput`, `Utxo` data models
- `BitcoinTxParser` — minimal raw-tx parser (just enough for verification)
- `EsploraBackend` (jvmAndroid) — OkHttp impl
- `nipBCOnchainZaps/verify/`
- `OnchainZapVerifier` — implements all spec rules: reject self-zap, sum
only outputs paying the derived recipient address, dedupe `(txid, target)`,
cap claimed amount at verified amount
- `nipBCOnchainZaps/psbt/` (Phase A.2)
- Minimal BIP-174 codec
- `PsbtTaprootKeyPathSigner` — BIP-341 TapTweak + Schnorr sign
- `nipBCOnchainZaps/build/` (Phase A.2)
- `OnchainZapBuilder` — given (sender, recipient, sats, feeRate, utxos),
build a PSBT with change back to sender's Taproot
- `NostrSigner.signPsbt` (Phase A.2)
- Internal: signs directly
- External (NIP-55): launches `sign_psbt` intent — needs Amber update
- Remote (NIP-46): `NotSupportedException` stub
## Commons additions
- `OnchainWalletViewModel` — derived address, balance, UTXO list
- `OnchainZapSendViewModel` — build → sign → broadcast → publish kind 8333
- `OnchainZapSendDialog`, `OnchainAddressQrCard`, `FeeRatePicker`
## Account state
In `AccountSettings.kt` next to `nwcWallets`:
```kotlin
val onchainEsploraEndpoint: MutableStateFlow<String> // default mempool.space
val onchainDefaultFeeTier: MutableStateFlow<FeeTier> // SLOW / NORMAL / FAST
```
Derived `Account.onchainBalance: StateFlow<Long?>` populated by
`OnchainWalletViewModel`.
## Send-from-note merge
Existing `ZapAmountChoicePopup` (`ReactionsRow.kt:1877-1977`) stays as the
Lightning fast path. Two minimal hooks:
1. Append `[ ⛓ Onchain… ]` row to the popup. Tapping it opens
`OnchainZapSendDialog` (fee picker + confirmation), separate from the
instant-tap Lightning UX.
2. Optional settings toggle "Show onchain zap option" — defaults off until a
balance is observed.
## Phased delivery
| Phase | Deliverable | Status |
|---|---|---|
| **A.1** | Quartz foundation (receive side): taproot address, bech32m segwit, Esplora client, verifier + tests | **Shipped** |
| **C** | Receive + display: `OnchainSection` in `WalletScreen` (address + live balance), Esplora sync, `LocalCache.consume(OnchainZapEvent)`, `updateZapTotal` fold-in, kind-list edits in all 7 in-scope filter files | **Shipped** |
| **A.2** | Quartz foundation (send side): BIP-174 PSBT codec, BIP-341 sighash, `OnchainZapBuilder`, `signPsbt` on the `NostrSigner` hierarchy. All crypto validated against BIP-341 wallet test vectors (31 tests). | **Shipped** |
| **D** | Send flow: `OnchainZapSender` orchestrator, `Account.sendOnchainZap`, `OnchainZapSendDialog`, "Send" button on the wallet `OnchainSection`. | **Shipped** |
| **B** | NIP-55 `sign_psbt` Intent + ContentResolver contract, wired through `NostrSignerExternal.signPsbt`. Works once the external signer app (Amber etc.) ships `sign_psbt` support — older signers reply with no `result`, surfaced as a send failure. | **Shipped** |
### Phase A.2 — shipped
- `nipBCOnchainZaps/psbt/`: `BitcoinIO` (LE byte codec + varint), `BitcoinTransaction`
(legacy + segwit serialization, witness-stripped txid), `TaprootSigHash`
(BIP-341 SigMsg + TapSighash, all sighash types), `Psbt` (BIP-174 subset,
unknown-record-preserving), `PsbtSigner` (key-path signing), `PsbtFinalizer`.
- `nipBCOnchainZaps/build/OnchainZapBuilder`: largest-first coin selection +
unsigned-PSBT assembly with dust-aware change.
- `TaprootAddress.tweakSecretKey`: BIP-341 `taproot_tweak_seckey`.
- `Secp256k1Instance.privKeyNegate`: new primitive (commonMain + 3 actuals).
- `NostrSigner.signPsbt`: real impl on internal signer; delegated by the
client-tag wrapper; `UnsupportedMethodException` stubs on NIP-46 / NIP-55.
### Phase D — shipped
- `commons/onchain/OnchainZapSender`: stateless orchestrator —
load UTXOs → `OnchainZapBuilder.build``signer.signPsbt`
`PsbtFinalizer``OnchainBackend.broadcast` → publish kind:8333 via an
injected callback. Per-stage `OnchainZapSendResult.Failure`; broadcast txid
preserved when only the receipt-publish stage fails.
- `Account.sendOnchainZap`: binds the account signer, `LocalCache.onchainBackend`,
and `signAndComputeBroadcast` into the orchestrator.
- `OnchainZapSendDialog`: recipient npub (or fixed recipient + `zappedEvent`
for a future note-zap-menu entry), amount, fee tier from the backend's
estimates, comment; runs the send and shows progress + result.
- `OnchainSection`: "Send" button on the wallet-screen Bitcoin card.
### Phase B — shipped
- `CommandType.SIGN_PSBT` (`sign_psbt`) — also usable in NIP-55 `perms` lists
via `Permission`, which wraps `CommandType` directly.
- `SignPsbtResult` result type; `SignPsbtQuery` (background ContentResolver),
`SignPsbtRequest` / `SignPsbtResponse` (foreground Intent), mirroring the
`derive_key` string-in/string-out shape — the PSBT hex rides the
`nostrsigner:` URI, the signed PSBT comes back in `result`.
- `BackgroundRequestHandler.signPsbt` / `ForegroundRequestHandler.signPsbt`;
`NostrSignerExternal.signPsbt` now does the real background-then-foreground
query instead of throwing. External signers that predate `sign_psbt` reply
with no `result``CouldNotPerformException`, surfaced by the send dialog.
### What's still pending
1. **Note-zap-menu entry point.** `OnchainZapSendDialog` already accepts
`recipientPubKey` + `zappedEvent`; wiring an "Onchain" option into the
existing `ZapAmountChoicePopup` is the remaining UI hook for event zaps.
2. **NIP-46 `sign_psbt`.** `NostrSignerRemote.signPsbt` still throws
`UnsupportedMethodException` — the bunker-side command is not standardized
yet.
3. **Send-side `block` + `proof` emit.** See "Inline SPV proofs" below.
4. **Receive-side SPV verification.** See "Inline SPV proofs" below.
## Inline SPV proofs (`block` + `proof` tags) — pending
### Spec status (2026-05-19)
`nostr-protocol/nips#2332` adds two new optional tags on `kind:8333`:
```
["block", "<block-hash-hex>", "<height>"]
["proof", "<raw-tx-hex>", "<merkle-proof-hex>"]
```
Together they let a verifier with only the Bitcoin header chain check a
zap end-to-end without trusting the chain backend. The recipient hashes
`raw-tx-hex` to recover the txid, walks the merkle proof from that leaf
up to the header's `merkleRoot`, parses outputs from `raw-tx-hex`, and
sums those that pay the recipient's derived Taproot script.
**One spec ambiguity outstanding** (raised back to the PR author):
direction-at-each-step encoding for `<merkle-proof-hex>` — Bitcoin's
merkle tree is tx-order-sensitive, so concatenated siblings alone don't
specify left vs right. Suggested resolution: `1-byte direction || 32-byte
sibling` per step (33 B stride). This blocks Phase G.2 (walker
implementation), nothing else.
### Current state in Amethyst
| Layer | Status | Location |
|---|---|---|
| `BlockTag(blockHashHex, height: Long)` | ✅ shipped | `quartz/.../nipBCOnchainZaps/zap/tags/BlockTag.kt` |
| `ProofTag(rawTxHex, merkleProofHex)` | ✅ shipped | `…/zap/tags/ProofTag.kt` |
| `OnchainZapEvent.block()` / `.proof()` accessors | ✅ shipped | `…/zap/OnchainZapEvent.kt:89-93` |
| TagArray + TagArrayBuilder helpers | ✅ shipped | `…/zap/TagArrayExt.kt`, `…/zap/TagArrayBuilderExt.kt` |
| Tx parser + witness-stripped txid (reusable for the walker) | ✅ shipped | `…/psbt/BitcoinTransaction.kt` |
| Recipient scriptPubKey derivation | ✅ shipped | `taproot/TaprootAddress.scriptPubKeyHexForRecipient` |
| `OnchainZapSender` populates `block` / `proof` after broadcast | ❌ gap | `commons/.../onchain/OnchainZapSender.kt:209-214` (calls `build()` / `buildProfileZap()` with no SPV tags) |
| `OnchainBackend.getMerkleProof(txid)` to source the proof on send | ❌ gap | `chain/OnchainBackend.kt` |
| Merkle-proof parser + walker | ❌ gap | new `verify/MerkleProofVerifier.kt` |
| `OnchainZapVerifier` prefers inline proof when present | ❌ gap | `verify/OnchainZapVerifier.kt` |
| Validated `header.merkleRoot` lookup by block hash | ❌ gap | depends on the headers-explorer plan (S1) at `quartz/plans/2026-05-08-local-headers-explorer.md` |
### Send-side: Design B (two-publish)
`OnchainZapSender` publishes the `kind:8333` receipt **immediately** after
`backend.broadcast(rawTxHex)` returns, when confirmations = 0. The chosen
design keeps that instant UX and adds a second publish after the tx
confirms:
1. Today's path stays. Publish `kind:8333` right after broadcast, with
`i`/`p`/`amount`/`alt` only — no `block`, no `proof`.
2. A new post-confirmation worker watches the tx via the backend. When it
sees ≥1 confirmation:
- Call `backend.getMerkleProof(txid)``(block_hash, height, siblings, pos)`.
- Encode `siblings + pos` into the spec's `<merkle-proof-hex>` wire format
(depends on the resolved spec encoding).
- Build a second `kind:8333` referencing the same `i` tag, with `block`
and `proof` filled in. Publish.
3. Verifiers MUST dedupe by `(txid, target)` and prefer the variant
carrying a valid SPV proof. (This dedupe rule is also in the suggested
spec amendment.)
Two relay broadcasts per zap, ~2 KB extra each. The post-confirmation
worker is per-account, persists across app restarts, and exits cleanly
once each pending tx is either confirmed (publish second receipt) or
abandoned (after some timeout — TBD).
### Receive-side
`OnchainZapVerifier.verify(event)` gets a new fast path:
1. If `event.block() != null && event.proof() != null`:
- Look up header by hash via `LocalHeadersBitcoinExplorer.byHash()` (S1).
If unavailable yet, fall through to the existing `backend.getTx()` path.
- `BitcoinTransaction.parse(rawTxHex)` → tx; recompute
`dsha256(stripWitness(tx))` → expected_txid; check it matches the
`i` tag's txid.
- `MerkleProofVerifier.verify(expected_txid, proofBytes, header.merkleRoot)` → boolean.
- Parse outputs from the same parsed tx; sum outputs paying the
recipient's `scriptPubKeyHexForRecipient`, skipping outputs paying
the sender's own derived script (change).
- Confirmations = `tip.height block.height + 1`.
- Return `Confirmed`.
2. If the inline path fails verification (bad proof, missing header,
tx-mismatch), the event is treated as if `block` / `proof` were
absent — fall back to the existing Esplora path. **Never** treat a
failed SPV proof as a hard-reject of the whole event; that would let
a buggy producer poison legitimate zaps.
3. If neither tag is present (old-shape events), the existing
`backend.getTx()` path runs unchanged.
The end state: a strict-mode user with HTTP fallback disabled can verify
any zap whose sender included `block` + `proof`, and only those.
### Phased delivery for the SPV work
| Phase | Deliverable | Depends on | Effort |
|---|---|---|---|
| **G.1** | `MerkleProofVerifier` in `nipBCOnchainZaps/verify/`; test vectors per the resolved spec encoding | spec encoding locked | ½ d |
| **G.2** | `OnchainZapVerifier` consumes inline proof, falls back on failure; needs `LocalHeadersBitcoinExplorer.byHash()` from S1 | S1 Phase 2 (storage) | ½ d |
| **G.3** | `OnchainBackend.getMerkleProof(txid)` interface + `EsploraBackend` impl (`/tx/{txid}/merkle-proof`) | — | ½ d |
| **G.4** | Post-confirmation worker + second-publish flow in `OnchainZapSender` | G.3 | 1 d |
| **G.5** | Dedupe by `(txid, target)` preferring SPV-attached variant; `Note.zapsAmount` only counts the winner | — | ½ d |
| **G.6** | End-to-end tests: real mainnet block + proof + zap; negative cases (wrong root, truncated, wrong direction) | G.1 | 1 d |
| **G.7** | (Optional) Settings toggle "Verify zaps trustlessly when possible" — off by default until S1 ships | S1 Phase 7 | ½ d |
| **Total** | | | ~34 d (after S1 ships and spec encoding lands) |
S1 (the headers explorer at `quartz/plans/2026-05-08-local-headers-explorer.md`)
is the long pole. The SPV work itself is small — every primitive needed
already exists in the codebase, the only new thing is the merkle walker.
## Risks / open questions
- **secp256k1-kmp tweak coverage** — pubKeyTweakAdd exists on JVM/Android/JNI
but not on the pure-Kotlin native impl. iOS support deferred until upstream
ships it or we contribute.
- **PSBT correctness** — single-key-path-only is a small surface; still needs
thorough testing against BIP-174 test vectors before any user funds move.
- **Esplora privacy** — the configured server sees user UTXO queries. Default
to a reputable provider, make user-configurable, consider future Tor option.
- **NIP-55 ecosystem** — Amber must implement `sign_psbt` for external signer
accounts to use this feature.
- **`Note.zaps` shape** — separate `onchainZaps` map vs sealed-type fold-in.
Leaning separate map for v1.
- **Verification network calls from `LocalCache`** — `consume` runs on the
relay thread; verification needs a coroutine scope + `OnchainBackend`
instance injected from `Account` on app start.

View File

@@ -1,225 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.actions.buildAnnotatedStringWithUrlHighlighting
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class UrlUserTagTransformationTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.vitorpamplona.amethyst", appContext.packageName.removeSuffix(".debug"))
}
@Test
fun testKeepTransformedIndexFullyInsideTransformedText() {
val user =
LocalCache.getOrCreateUser(
decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z")
.toHexKey(),
)
user.metadata().newMetadata(
UserMetadata().also {
it.displayName = "Vitor Pamplona"
},
MetadataEvent(
id = "",
pubKey = "",
createdAt = 0,
tags = emptyArray(),
content = "",
sig = "",
),
)
val original = "@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"
val transformedText =
buildAnnotatedStringWithUrlHighlighting(
AnnotatedString(original),
Color.Red,
)
val expected = "@Vitor Pamplona"
assertEquals(expected, transformedText.text.text)
// The mention is treated as an atomic wedge: any cursor strictly inside the
// underlying npub snaps to the trailing edge of the displayed "@Vitor Pamplona"
// (and vice versa). This prevents an IME from placing the cursor in the middle
// of the bech32 and corrupting it on backspace.
assertEquals(0, transformedText.offsetMapping.originalToTransformed(0))
for (i in 1..63) {
assertEquals("originalToTransformed($i)", 15, transformedText.offsetMapping.originalToTransformed(i))
}
assertEquals(15, transformedText.offsetMapping.originalToTransformed(64))
assertEquals(0, transformedText.offsetMapping.transformedToOriginal(0))
for (i in 1..14) {
assertEquals("transformedToOriginal($i)", 64, transformedText.offsetMapping.transformedToOriginal(i))
}
assertEquals(64, transformedText.offsetMapping.transformedToOriginal(15))
}
@Test
fun transformationText() {
val user =
LocalCache.getOrCreateUser(
decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z")
.toHexKey(),
)
user.metadata().newMetadata(
UserMetadata().also {
it.displayName = "Vitor Pamplona"
},
MetadataEvent(
id = "",
pubKey = "",
createdAt = 0,
tags = emptyArray(),
content = "",
sig = "",
),
)
val transformedText =
buildAnnotatedStringWithUrlHighlighting(
AnnotatedString("New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"),
Color.Red,
)
assertEquals("New Hey @Vitor Pamplona", transformedText.text.text)
// Outside the wedge: identity mapping.
assertEquals(0, transformedText.offsetMapping.originalToTransformed(0)) // Before N
assertEquals(4, transformedText.offsetMapping.originalToTransformed(4)) // Before H
assertEquals(8, transformedText.offsetMapping.originalToTransformed(8)) // Before @ (boundary)
// Strictly inside the underlying npub: snaps to the end of "@Vitor Pamplona" (offset 23).
assertEquals(23, transformedText.offsetMapping.originalToTransformed(9)) // Before n
assertEquals(23, transformedText.offsetMapping.originalToTransformed(12)) // Before b
assertEquals(23, transformedText.offsetMapping.originalToTransformed(13)) // Before 1
assertEquals(23, transformedText.offsetMapping.originalToTransformed(71)) // Before z
// End-of-wedge boundary maps to end of displayed mention.
assertEquals(23, transformedText.offsetMapping.originalToTransformed(72))
// Outside the wedge in displayed: identity.
assertEquals(0, transformedText.offsetMapping.transformedToOriginal(0))
assertEquals(4, transformedText.offsetMapping.transformedToOriginal(4))
assertEquals(8, transformedText.offsetMapping.transformedToOriginal(8)) // Before @ (boundary)
// Strictly inside displayed "@Vitor Pamplona": snaps to end of underlying npub (offset 72).
assertEquals(72, transformedText.offsetMapping.transformedToOriginal(9))
assertEquals(72, transformedText.offsetMapping.transformedToOriginal(22))
// End-of-wedge boundary maps to end of underlying mention; past it shifts by deltas.
assertEquals(72, transformedText.offsetMapping.transformedToOriginal(23))
assertEquals(73, transformedText.offsetMapping.transformedToOriginal(24))
}
@Test
fun transformationTextTwoKeys() {
val user =
LocalCache.getOrCreateUser(
decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z")
.toHexKey(),
)
user.metadata().newMetadata(
UserMetadata().also {
it.displayName = "Vitor Pamplona"
},
MetadataEvent(
id = "",
pubKey = "",
createdAt = 0,
tags = emptyArray(),
content = "",
sig = "",
),
)
val transformedText =
buildAnnotatedStringWithUrlHighlighting(
AnnotatedString(
"New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z and @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z",
),
Color.Red,
)
assertEquals("New Hey @Vitor Pamplona and @Vitor Pamplona", transformedText.text.text)
// Strictly inside the first underlying npub [8, 72): snap to end of first
// displayed "@Vitor Pamplona" (offset 23).
assertEquals(23, transformedText.offsetMapping.originalToTransformed(11))
assertEquals(23, transformedText.offsetMapping.originalToTransformed(12))
assertEquals(23, transformedText.offsetMapping.originalToTransformed(13))
assertEquals(23, transformedText.offsetMapping.originalToTransformed(70))
assertEquals(23, transformedText.offsetMapping.originalToTransformed(71))
// Boundary at end of first wedge: end of first displayed mention.
assertEquals(23, transformedText.offsetMapping.originalToTransformed(72)) // Before <space>
assertEquals(24, transformedText.offsetMapping.originalToTransformed(73)) // Before a
assertEquals(25, transformedText.offsetMapping.originalToTransformed(74)) // Before n
assertEquals(26, transformedText.offsetMapping.originalToTransformed(75)) // Before d
assertEquals(27, transformedText.offsetMapping.originalToTransformed(76)) // Before <space>
assertEquals(28, transformedText.offsetMapping.originalToTransformed(77)) // Before @ (boundary, second wedge)
// Strictly inside the second underlying npub [77, 141): snap to end of second
// displayed "@Vitor Pamplona" (offset 43).
assertEquals(43, transformedText.offsetMapping.originalToTransformed(78)) // Before n
assertEquals(43, transformedText.offsetMapping.originalToTransformed(140))
// Strictly inside first displayed "@Vitor Pamplona" [8, 23): snap to end of
// first underlying npub (offset 72).
assertEquals(72, transformedText.offsetMapping.transformedToOriginal(22)) // Before a (display)
assertEquals(72, transformedText.offsetMapping.transformedToOriginal(23)) // Before <space>
assertEquals(73, transformedText.offsetMapping.transformedToOriginal(24)) // Before a
assertEquals(74, transformedText.offsetMapping.transformedToOriginal(25)) // Before n
assertEquals(75, transformedText.offsetMapping.transformedToOriginal(26)) // Before d
assertEquals(76, transformedText.offsetMapping.transformedToOriginal(27)) // Before <space>
assertEquals(77, transformedText.offsetMapping.transformedToOriginal(28)) // Before @ (boundary, second wedge)
// Strictly inside second displayed "@Vitor Pamplona" [28, 43): snap to end of
// second underlying npub (offset 141).
assertEquals(141, transformedText.offsetMapping.transformedToOriginal(29))
assertEquals(141, transformedText.offsetMapping.transformedToOriginal(42))
}
}

View File

@@ -59,7 +59,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.UiSettingsFlow
import com.vitorpamplona.amethyst.service.notifications.PushDistributorHandler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsBlockTile
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.Log
import kotlinx.collections.immutable.ImmutableList
@@ -207,24 +207,34 @@ fun LoadDistributors(onInner: @Composable (String, ImmutableList<String>, Immuta
)
}
fun hasPushNotificationProvider(): Boolean = true
@Composable
fun PushNotificationSettingsRow(sharedPrefs: UiSettingsFlow) {
fun PushNotificationProviderTile(sharedPrefs: UiSettingsFlow) {
val context = LocalContext.current
LoadDistributors { currentDistributor, list, readableListWithExplainer ->
SettingsRow(
R.string.push_server_title,
R.string.push_server_explainer,
selectedItems = readableListWithExplainer,
selectedIndex = list.indexOf(currentDistributor),
) { index ->
if (list[index] == "None") {
sharedPrefs.dontAskForNotificationPermissions()
sharedPrefs.dontShowPushNotificationSelector()
PushDistributorHandler.forceRemoveDistributor(context)
} else {
PushDistributorHandler.saveDistributor(list[index])
}
val selectedIndex = list.indexOf(currentDistributor).coerceAtLeast(0)
SettingsBlockTile(
icon = MaterialSymbols.CloudSync,
title = stringRes(R.string.push_server_title),
description = stringRes(R.string.push_server_explainer),
) {
TextSpinner(
label = null,
placeholder = readableListWithExplainer[selectedIndex].title,
options = readableListWithExplainer,
onSelect = { index ->
if (list[index] == "None") {
sharedPrefs.dontAskForNotificationPermissions()
sharedPrefs.dontShowPushNotificationSelector()
PushDistributorHandler.forceRemoveDistributor(context)
} else {
PushDistributorHandler.saveDistributor(list[index])
}
},
modifier = Modifier.fillMaxWidth(),
)
}
}
}

View File

@@ -26,11 +26,13 @@ import coil3.disk.DiskCache
import coil3.memory.MemoryCache
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
import com.vitorpamplona.amethyst.commons.services.lnurl.OkHttpLnurlEndpointResolver
import com.vitorpamplona.amethyst.commons.tor.TorSettings
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.UiSettings
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
import com.vitorpamplona.amethyst.model.nip03Timestamp.BitcoinExplorerEndpoint
import com.vitorpamplona.amethyst.model.nip03Timestamp.IncomingOtsEventVerifier
import com.vitorpamplona.amethyst.model.nip03Timestamp.TorAwareOkHttpOtsResolverBuilder
import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever
@@ -91,6 +93,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineT
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.stats.RelayReqStats
import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats
import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache
import com.vitorpamplona.quartz.nip03Timestamp.okhttp.OkHttpBitcoinExplorer
import com.vitorpamplona.quartz.nip03Timestamp.ots.OtsBlockHeightCache
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client
import com.vitorpamplona.quartz.nip05DnsIdentifiers.OkHttpNip05Fetcher
@@ -99,6 +102,8 @@ import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumXClient
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.TOR_ELECTRUMX_SERVERS
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.CachingOnchainBackend
import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.EsploraBackend
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
@@ -330,6 +335,40 @@ class AppModules(
// Caches all events in Memory
val cache: LocalCache = LocalCache
// NIP-BC onchain zap verification backend. Wired up once at app init so
// LocalCache.consume(OnchainZapEvent) can sum the on-chain output values
// that pay the recipient's derived Taproot address. Wrapped in a caching
// decorator so a feed full of onchain zaps doesn't fan out into one HTTP
// request per event.
//
// The explorer endpoint is shared with OpenTimestamps: it honours the same
// user-configured server (OTS settings) and the same Tor-aware default
// selection, via BitcoinExplorerEndpoint — onchain zaps must not silently
// bypass the user's Tor preference.
init {
cache.onchainBackend =
CachingOnchainBackend(
EsploraBackend(
baseUrl = {
BitcoinExplorerEndpoint.resolveNormalized(
customExplorerUrl = otsPrefs.current.normalizedUrl(),
usingTor =
roleBasedHttpClientBuilder.shouldUseTorForMoneyOperations(
OkHttpBitcoinExplorer.MEMPOOL_API_URL,
),
)
},
client = roleBasedHttpClientBuilder.okHttpClientForMoney(OkHttpBitcoinExplorer.MEMPOOL_API_URL),
),
)
// NIP-57 Appendix F: validates incoming zap receipts against the
// recipient's LNURL provider's advertised `nostrPubkey`. Reuses the
// money-tier http client so Tor preferences and proxy settings apply.
cache.lnurlEndpointResolver =
OkHttpLnurlEndpointResolver(roleBasedHttpClientBuilder::okHttpClientForMoney)
}
// Provides a relay pool
val client: INostrClient = NostrClient(websocketBuilder, applicationIOScope)
@@ -609,6 +648,12 @@ class AppModules(
ScheduledPostWorker.schedule(appContext)
ScheduledPostWorker.scheduleCatchUp(appContext)
// Periodic scan that posts "starting soon" notifications for NIP-52 appointments the
// user has RSVP'd to as ACCEPTED. 15-minute cadence matches both the WorkManager
// periodic minimum and the lead-time window.
com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker
.schedule(appContext)
// Watch for account login and start/stop always-on notification service
applicationIOScope.launch {
sessionManager.accountContent.collectLatest { state ->

View File

@@ -104,6 +104,7 @@ private object PrefKeys {
const val DEFAULT_DISCOVERY_FOLLOW_LIST = "defaultDiscoveryFollowList"
const val DEFAULT_POLLS_FOLLOW_LIST = "defaultPollsFollowList"
const val DEFAULT_PICTURES_FOLLOW_LIST = "defaultPicturesFollowList"
const val DEFAULT_CALENDARS_FOLLOW_LIST = "defaultCalendarsFollowList"
const val DEFAULT_PRODUCTS_FOLLOW_LIST = "defaultProductsFollowList"
const val DEFAULT_SHORTS_FOLLOW_LIST = "defaultShortsFollowList"
const val DEFAULT_PUBLIC_CHATS_FOLLOW_LIST = "defaultPublicChatsFollowList"
@@ -361,6 +362,7 @@ object LocalPreferences {
putString(PrefKeys.DEFAULT_POLLS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPollsFollowList.value))
putString(PrefKeys.DEFAULT_PICTURES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPicturesFollowList.value))
putString(PrefKeys.DEFAULT_CALENDARS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultCalendarsFollowList.value))
putString(PrefKeys.DEFAULT_PRODUCTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultProductsFollowList.value))
putString(PrefKeys.DEFAULT_SHORTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultShortsFollowList.value))
putString(PrefKeys.DEFAULT_PUBLIC_CHATS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPublicChatsFollowList.value))
@@ -641,6 +643,7 @@ object LocalPreferences {
defaultDiscoveryFollowList = MutableStateFlow(followListPrefs.discovery),
defaultPollsFollowList = MutableStateFlow(followListPrefs.polls),
defaultPicturesFollowList = MutableStateFlow(followListPrefs.pictures),
defaultCalendarsFollowList = MutableStateFlow(followListPrefs.calendars),
defaultProductsFollowList = MutableStateFlow(followListPrefs.products),
defaultShortsFollowList = MutableStateFlow(followListPrefs.shorts),
defaultPublicChatsFollowList = MutableStateFlow(followListPrefs.publicChats),
@@ -712,6 +715,7 @@ object LocalPreferences {
val discovery: TopFilter,
val polls: TopFilter,
val pictures: TopFilter,
val calendars: TopFilter,
val products: TopFilter,
val shorts: TopFilter,
val publicChats: TopFilter,
@@ -733,6 +737,7 @@ object LocalPreferences {
discovery = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null), TopFilter.Global),
polls = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_POLLS_FOLLOW_LIST, null), TopFilter.Global),
pictures = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PICTURES_FOLLOW_LIST, null), TopFilter.Global),
calendars = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_CALENDARS_FOLLOW_LIST, null), TopFilter.Global),
products = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PRODUCTS_FOLLOW_LIST, null), TopFilter.AroundMe),
shorts = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_SHORTS_FOLLOW_LIST, null), TopFilter.Global),
publicChats = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PUBLIC_CHATS_FOLLOW_LIST, null), TopFilter.Global),

View File

@@ -36,6 +36,10 @@ import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListS
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.UserStatusAction
import com.vitorpamplona.amethyst.commons.model.nip56Reports.ReportAction
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendStage
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSender
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapShare
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.logTime
import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator
@@ -401,7 +405,7 @@ class Account(
val dmRelays = DmInboxRelayState(dmRelayList, nip65RelayList, privateStorageRelayList, localRelayList, scope)
val notificationRelays = NotificationInboxRelayState(nip65RelayList, localRelayList, scope)
val trustedRelays = TrustedRelayListsState(nip65RelayList, privateStorageRelayList, localRelayList, dmRelayList, searchRelayList, trustedRelayList, broadcastRelayList, scope)
val trustedRelays = TrustedRelayListsState(nip65RelayList, privateStorageRelayList, localRelayList, dmRelayList, searchRelayList, indexerRelayList, proxyRelayList, trustedRelayList, broadcastRelayList, scope)
// Follows Relays
val followOutboxesOrProxy = FollowListOutboxOrProxyRelays(kind3FollowList, blockedRelayList, proxyRelayList, cache, scope)
@@ -486,6 +490,9 @@ class Account(
val livePicturesFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultPicturesFollowList)
val livePicturesFollowListsPerRelay = OutboxLoaderState(livePicturesFollowLists, cache, scope).flow
val liveCalendarsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultCalendarsFollowList)
val liveCalendarsFollowListsPerRelay = OutboxLoaderState(liveCalendarsFollowLists, cache, scope).flow
val liveProductsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultProductsFollowList)
val liveProductsFollowListsPerRelay = OutboxLoaderState(liveProductsFollowLists, cache, scope).flow
@@ -597,12 +604,14 @@ class Account(
suspend fun updateZapAmounts(
amountSet: List<Long>,
onchainAmountSet: List<Long>,
selectedZapType: LnZapEvent.ZapType,
nip47Update: Nip47WalletConnect.Nip47URINorm?,
) {
var changed = false
if (settings.changeZapAmounts(amountSet)) changed = true
if (settings.changeOnchainZapAmounts(onchainAmountSet)) changed = true
if (settings.changeDefaultZapType(selectedZapType)) changed = true
if (settings.changeZapPaymentRequest(nip47Update)) changed = true
@@ -693,6 +702,8 @@ class Account(
zapType: LnZapEvent.ZapType,
toUser: User?,
additionalRelays: Set<NormalizedRelayUrl>? = null,
amountMillisats: Long? = null,
lnurl: String? = null,
) = LnZapRequestEvent.create(
zappedEvent = event,
relays = nip65RelayList.inboxFlow.value + (additionalRelays ?: emptySet()),
@@ -701,6 +712,8 @@ class Account(
message = message,
zapType = zapType,
toUserPubHex = toUser?.pubkeyHex,
amountMillisats = amountMillisats,
lnurl = lnurl,
)
suspend fun calculateIfNoteWasZappedByAccount(
@@ -740,6 +753,8 @@ class Account(
user: User,
message: String = "",
zapType: LnZapEvent.ZapType,
amountMillisats: Long? = null,
lnurl: String? = null,
): LnZapRequestEvent {
val zapRequest =
LnZapRequestEvent.create(
@@ -748,12 +763,73 @@ class Account(
signer = signer,
message = message,
zapType = zapType,
amountMillisats = amountMillisats,
lnurl = lnurl,
)
cache.justConsumeMyOwnEvent(zapRequest)
return zapRequest
}
/**
* Send a NIP-BC onchain zap: build a Bitcoin transaction paying the recipient's
* derived Taproot address, sign it, broadcast it, and publish the kind:8333
* zap receipt. Pass [zappedEvent] to attribute the zap to a specific event, or
* leave it null for a profile zap.
*/
suspend fun sendOnchainZap(
recipientPubKey: HexKey,
amountSats: Long,
feeRateSatPerVByte: Double,
comment: String = "",
zappedEvent: EventHintBundle<out Event>? = null,
): OnchainZapSendResult {
val backend =
cache.onchainBackend
?: return OnchainZapSendResult.Failure(
OnchainZapSendStage.LOADING_UTXOS,
"Bitcoin chain backend is not configured",
)
return OnchainZapSender.send(
backend = backend,
signer = signer,
senderPubKey = signer.pubKey,
recipientPubKey = recipientPubKey,
amountSats = amountSats,
feeRateSatPerVByte = feeRateSatPerVByte,
comment = comment,
zappedEvent = zappedEvent,
) { template -> signAndComputeBroadcast(template) }
}
/**
* Send a NIP-BC onchain split zap: a single Bitcoin transaction paying
* each recipient their precomputed share, plus one kind:8333 receipt per
* recipient. See [OnchainZapSender.sendSplit] for failure semantics.
*/
suspend fun sendOnchainZapWithSplits(
recipients: List<OnchainZapShare>,
feeRateSatPerVByte: Double,
comment: String = "",
zappedEvent: EventHintBundle<out Event>? = null,
): OnchainZapSendResult {
val backend =
cache.onchainBackend
?: return OnchainZapSendResult.Failure(
OnchainZapSendStage.LOADING_UTXOS,
"Bitcoin chain backend is not configured",
)
return OnchainZapSender.sendSplit(
backend = backend,
signer = signer,
senderPubKey = signer.pubKey,
recipients = recipients,
feeRateSatPerVByte = feeRateSatPerVByte,
comment = comment,
zappedEvent = zappedEvent,
) { template -> signAndComputeBroadcast(template) }
}
suspend fun report(
note: Note,
type: ReportType,
@@ -2090,6 +2166,28 @@ class Account(
// --- Marmot Group Messaging ---
/**
* Resolve the relay set for a Marmot group. Prefer the relays carried in
* the MLS GroupContext metadata so every member converges on the same
* canonical set; fall back to the account's outbox relays if the group
* has none (e.g. a group joined before MIP-01 metadata existed).
*
* Lives on Account (not AccountViewModel) so that headless callers —
* notifications' BroadcastReceiver, background workers — can resolve
* relays without spinning up a ViewModel.
*/
fun marmotGroupRelays(nostrGroupId: HexKey): Set<NormalizedRelayUrl> {
val groupRelays =
marmotManager
?.groupMetadata(nostrGroupId)
?.relays
?.mapNotNull {
com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
.normalizeOrNull(it)
}?.toSet()
return if (!groupRelays.isNullOrEmpty()) groupRelays else outboxRelays.flow.value
}
/**
* Send a message to a Marmot MLS group.
* Encrypts the inner event and publishes the GroupEvent to group relays.
@@ -3033,8 +3131,10 @@ class Account(
fun isKnown(user: HexKey): Boolean = user in allFollows.flow.value.authors
private fun hasExcessiveHashtags(note: Note): Boolean {
val limit = settings.syncedSettings.security.maxHashtagLimit.value
fun maxHashtagLimit(): Int = settings.syncedSettings.security.maxHashtagLimit.value
fun hasExcessiveHashtags(note: Note): Boolean {
val limit = maxHashtagLimit()
return limit > 0 && note.event?.hasMoreHashtagsThan(limit) == true
}

View File

@@ -163,6 +163,7 @@ class AccountSettings(
val defaultDiscoveryFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultPollsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultPicturesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultCalendarsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultProductsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AroundMe),
val defaultShortsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultPublicChatsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
@@ -266,6 +267,15 @@ class AccountSettings(
return false
}
fun changeOnchainZapAmounts(newAmounts: List<Long>): Boolean {
if (syncedSettings.zaps.onchainZapAmountChoices.value != newAmounts) {
syncedSettings.zaps.onchainZapAmountChoices.tryEmit(newAmounts.toImmutableList())
saveAccountSettings()
return true
}
return false
}
fun changeReactionTypes(newTypes: List<String>): Boolean {
if (syncedSettings.reactions.reactionChoices.value != newTypes) {
syncedSettings.reactions.reactionChoices.tryEmit(newTypes.toImmutableList())
@@ -529,6 +539,17 @@ class AccountSettings(
}
}
fun changeDefaultCalendarsFollowList(name: FeedDefinition) {
changeDefaultCalendarsFollowList(name.code)
}
fun changeDefaultCalendarsFollowList(name: TopFilter) {
if (defaultCalendarsFollowList.value != name) {
defaultCalendarsFollowList.tryEmit(name)
saveAccountSettings()
}
}
fun changeDefaultProductsFollowList(name: FeedDefinition) {
changeDefaultProductsFollowList(name.code)
}

View File

@@ -35,11 +35,12 @@ class AccountSyncedSettings(
val reactions =
AccountReactionPreferences(
MutableStateFlow(internalSettings.reactions.reactionChoices.toImmutableList()),
MutableStateFlow(internalSettings.reactions.reactionRowItems.toImmutableList()),
MutableStateFlow(mergeWithDefaultReactionRowItems(internalSettings.reactions.reactionRowItems).toImmutableList()),
)
val zaps =
AccountZapPreferences(
MutableStateFlow(internalSettings.zaps.zapAmountChoices.toImmutableList()),
MutableStateFlow(internalSettings.zaps.onchainZapAmountChoices.toImmutableList()),
MutableStateFlow(internalSettings.zaps.defaultZapType),
)
val languages =
@@ -69,6 +70,7 @@ class AccountSyncedSettings(
zaps =
AccountZapPreferencesInternal(
zaps.zapAmountChoices.value,
zaps.onchainZapAmountChoices.value,
zaps.defaultZapType.value,
),
languages =
@@ -96,7 +98,8 @@ class AccountSyncedSettings(
reactions.reactionChoices.tryEmit(newReactionChoices)
}
val newReactionRowItems = syncedSettingsInternal.reactions.reactionRowItems.toImmutableList()
val newReactionRowItems =
mergeWithDefaultReactionRowItems(syncedSettingsInternal.reactions.reactionRowItems).toImmutableList()
if (!equalImmutableLists(reactions.reactionRowItems.value, newReactionRowItems)) {
reactions.reactionRowItems.tryEmit(newReactionRowItems)
}
@@ -106,6 +109,11 @@ class AccountSyncedSettings(
zaps.zapAmountChoices.tryEmit(newZapChoices)
}
val newOnchainZapChoices = syncedSettingsInternal.zaps.onchainZapAmountChoices.toImmutableList()
if (!equalImmutableLists(zaps.onchainZapAmountChoices.value, newOnchainZapChoices)) {
zaps.onchainZapAmountChoices.tryEmit(newOnchainZapChoices)
}
if (zaps.defaultZapType.value != syncedSettingsInternal.zaps.defaultZapType) {
zaps.defaultZapType.tryEmit(syncedSettingsInternal.zaps.defaultZapType)
}
@@ -174,6 +182,7 @@ class AccountVideoPlayerPreferences(
@Stable
class AccountZapPreferences(
var zapAmountChoices: MutableStateFlow<ImmutableList<Long>>,
var onchainZapAmountChoices: MutableStateFlow<ImmutableList<Long>>,
val defaultZapType: MutableStateFlow<LnZapEvent.ZapType>,
)
@@ -245,7 +254,7 @@ class AccountSecurityPreferences(
val warnAboutPostsWithReports: MutableStateFlow<Boolean> = MutableStateFlow(true),
val reportWarningThreshold: MutableStateFlow<Int> = MutableStateFlow(DefaultReportWarningThreshold),
var filterSpamFromStrangers: MutableStateFlow<Boolean> = MutableStateFlow(true),
val maxHashtagLimit: MutableStateFlow<Int> = MutableStateFlow(5),
val maxHashtagLimit: MutableStateFlow<Int> = MutableStateFlow(8),
var sendKind0EventsToLocalRelay: MutableStateFlow<Boolean> = MutableStateFlow(false),
val addClientTag: MutableStateFlow<Boolean> = MutableStateFlow(true),
) {

View File

@@ -38,6 +38,7 @@ val DefaultReactions =
)
val DefaultZapAmounts = listOf(100L, 500L, 1000L)
val DefaultOnchainZapAmounts = listOf(10_000L)
val DefaultReportWarningThreshold = 5
@Serializable
@@ -67,6 +68,16 @@ val DefaultReactionRowItems =
ReactionRowItem(ReactionRowAction.Share, showCounter = false),
)
// Existing accounts have a reaction-row list serialized before some actions
// existed (e.g. Pay was added later). Append any default items the saved list
// is missing so new actions surface without forcing a settings reset — the
// user's existing order/toggles for actions they already have are preserved.
fun mergeWithDefaultReactionRowItems(saved: List<ReactionRowItem>): List<ReactionRowItem> {
val knownActions = saved.mapTo(mutableSetOf()) { it.action }
val missing = DefaultReactionRowItems.filter { it.action !in knownActions }
return if (missing.isEmpty()) saved else saved + missing
}
@Serializable
enum class VideoPlayerAction {
Fullscreen,
@@ -143,6 +154,7 @@ class AccountReactionPreferencesInternal(
@Serializable
class AccountZapPreferencesInternal(
var zapAmountChoices: List<Long> = DefaultZapAmounts,
var onchainZapAmountChoices: List<Long> = DefaultOnchainZapAmounts,
val defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC,
)
@@ -159,7 +171,7 @@ class AccountSecurityPreferencesInternal(
var warnAboutPostsWithReports: Boolean = true,
val reportWarningThreshold: Int = DefaultReportWarningThreshold,
var filterSpamFromStrangers: Boolean = true,
val maxHashtagLimit: Int = 5,
val maxHashtagLimit: Int = 8,
var sendKind0EventsToLocalRelay: Boolean = false,
var addClientTag: Boolean = true,
)

View File

@@ -189,6 +189,10 @@ import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip57Zaps.validate.LnZapReceiptValidator
import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlEndpointCache
import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlEndpointResolver
import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlForm
import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
@@ -245,6 +249,10 @@ import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.OnchainBackend
import com.vitorpamplona.quartz.nipBCOnchainZaps.verify.OnchainZapVerifier
import com.vitorpamplona.quartz.nipBCOnchainZaps.verify.VerifiedOnchainZap
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import com.vitorpamplona.quartz.utils.DualCase
@@ -291,6 +299,25 @@ object LocalCache : ILocalCache, ICacheProvider {
val paymentTracker = NwcPaymentTracker()
/**
* Bitcoin chain backend used by [consume]`(OnchainZapEvent)` to verify NIP-BC zaps
* against the actual on-chain transaction. `null` disables verification (incoming
* onchain zap events still get cached, but no `Note.zapsAmount` contribution).
* Set during account init.
*/
@Volatile
var onchainBackend: OnchainBackend? = null
/**
* Resolver for LNURL provider metadata used by [consume]`(LnZapEvent)` to
* validate NIP-57 Appendix F. `null` skips the receipt-signer check (the
* receipt is still accepted on signature verification alone — matches legacy
* behavior); set this in app init so receipts can be verified against the
* recipient's provider's advertised `nostrPubkey`.
*/
@Volatile
var lnurlEndpointResolver: LnurlEndpointResolver? = null
val relayHints = HintIndexer()
val deletionIndex = DeletionIndex()
@@ -909,6 +936,18 @@ object LocalCache : ILocalCache, ICacheProvider {
(event.zapRequest?.taggedAddresses()?.map { getOrCreateAddressableNote(it) } ?: emptyList())
}
is OnchainZapEvent -> {
// NIP-BC zaps can target an event id (e), an addressable event (a),
// or just the recipient profile (p). Profile-only zaps have no
// Note target and are surfaced through profile zap queries.
buildList {
event.zappedEvent()?.let { checkGetOrCreateNote(it)?.let { add(it) } }
event.zappedAddress()?.let { coord ->
Address.parse(coord)?.let { add(getOrCreateAddressableNote(it)) }
}
}
}
is LnZapRequestEvent -> {
event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().map { getOrCreateAddressableNote(it) }
@@ -1684,37 +1723,177 @@ object LocalCache : ILocalCache, ICacheProvider {
return false
}
if (wasVerified || justVerify(event)) {
val existingZapRequest = event.zapRequest?.id?.let { getNoteIfExists(it) }
if (existingZapRequest == null || existingZapRequest.event == null) {
// tries to add it
event.zapRequest?.let {
checkDeletionAndConsume(it, relay, false)
}
if (!(wasVerified || justVerify(event))) return false
val existingZapRequest = event.zapRequest?.id?.let { getNoteIfExists(it) }
if (existingZapRequest == null || existingZapRequest.event == null) {
// tries to add it
event.zapRequest?.let {
checkDeletionAndConsume(it, relay, false)
}
}
val zapRequest = event.zapRequest?.id?.let { getNoteIfExists(it) }
val zapRequest = event.zapRequest?.id?.let { getNoteIfExists(it) }
if (zapRequest == null || zapRequest.event !is LnZapRequestEvent) {
Log.d("ZP") { "Zap Request not found. Unable to process Zap {${event.toJson()}}" }
if (zapRequest == null || zapRequest.event !is LnZapRequestEvent) {
Log.d("ZP") { "Zap Request not found. Unable to process Zap {${event.toJson()}}" }
return false
}
// NIP-57 Appendix F validation. Resolve the recipient's lnurl from their
// profile metadata, look up the LNURL provider's `nostrPubkey`, and check
// the receipt against it (signer + invoice amount + lnurl tag).
//
// Synchronous path: cache hit, or no resolver wired (fallback to legacy
// signature-only behavior). Failed MUST-checks drop the receipt entirely.
//
// Async path: cache miss + resolver available. Load the event so feeds
// and live channels see it, but defer the `addZap` credit until the
// resolver returns. On async MUST-fail we never credit; the receipt
// stays in cache as a visible artifact but contributes 0 to zap totals.
val recipientLnurl = recipientLnurl(event)
val recipientLnurlpUrl = recipientLnurl?.let { LnurlForm.toUrl(it) }
val cachedInfo = recipientLnurlpUrl?.let { LnurlEndpointCache.get(it) }
val author = getOrCreateUser(event.pubKey)
val repliesTo = computeReplyTo(event)
if (cachedInfo != null) {
val result =
LnZapReceiptValidator.validate(
receipt = event,
expectedNostrPubkey = cachedInfo.nostrPubkey,
expectedLnurl = recipientLnurl,
)
if (result is LnZapReceiptValidator.Result.Invalid &&
result.reason != LnZapReceiptValidator.Result.Reason.MISMATCHED_LNURL
) {
Log.w("ZP", "dropping zap receipt ${event.id}: ${result.reason} ${result.detail ?: ""}")
return false
}
val author = getOrCreateUser(event.pubKey)
val repliesTo = computeReplyTo(event)
if (result is LnZapReceiptValidator.Result.Invalid) {
Log.w("ZP", "zap receipt ${event.id} has mismatched lnurl tag (accepting per SHOULD)")
}
note.loadEvent(event, author, repliesTo)
repliesTo.forEach { it.addZap(zapRequest, note) }
attachZapToLiveActivityChannel(event, note, relay)
refreshNewNoteObservers(note)
return true
}
return false
val resolver = lnurlEndpointResolver
if (resolver == null || recipientLnurlpUrl == null) {
// No resolver configured, or recipient has no lud16/lud06 on file.
// Legacy behavior: accept the receipt on signature verification alone.
note.loadEvent(event, author, repliesTo)
repliesTo.forEach { it.addZap(zapRequest, note) }
attachZapToLiveActivityChannel(event, note, relay)
refreshNewNoteObservers(note)
return true
}
// Async validation. Make the event visible immediately, but only credit
// it to repliesTo after the resolver confirms the LNURL provider's pubkey.
note.loadEvent(event, author, repliesTo)
attachZapToLiveActivityChannel(event, note, relay)
refreshNewNoteObservers(note)
Amethyst.instance.applicationIOScope.launch {
try {
val info = resolver.resolve(recipientLnurlpUrl)
if (info == null) {
Log.w("ZP", "could not fetch lnurlp for ${event.id}; not crediting")
return@launch
}
val result =
LnZapReceiptValidator.validate(
receipt = event,
expectedNostrPubkey = info.nostrPubkey,
expectedLnurl = recipientLnurl,
)
if (result is LnZapReceiptValidator.Result.Invalid &&
result.reason != LnZapReceiptValidator.Result.Reason.MISMATCHED_LNURL
) {
Log.w("ZP", "dropping zap receipt ${event.id}: ${result.reason} ${result.detail ?: ""}")
return@launch
}
if (result is LnZapReceiptValidator.Result.Invalid) {
Log.w("ZP", "zap receipt ${event.id} has mismatched lnurl tag (accepting per SHOULD)")
}
repliesTo.forEach { it.addZap(zapRequest, note) }
} catch (t: Throwable) {
Log.w("ZP", "validation failed for ${event.id}", t)
}
}
return true
}
/**
* Look up the recipient's lnurl from their kind:0 metadata. The recipient is
* the receipt's first `p` tag (which the LNURL provider sets to the original
* zap target). Returns null if we have no metadata, or the user has neither
* lud16 nor lud06.
*/
private fun recipientLnurl(event: LnZapEvent): String? {
val recipientPubkey = event.zappedAuthor().firstOrNull() ?: return null
val user = getUserIfExists(recipientPubkey) ?: return null
return user.lnAddress()?.takeIf { it.isNotBlank() }
}
fun consume(
event: OnchainZapEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean {
val note = getOrCreateNote(event.id)
if (note.event != null) return false
if (!(wasVerified || justVerify(event))) return false
// Anti-spoofing: NIP-BC requires rejecting self-zaps.
val recipient = event.recipient() ?: return false
if (event.pubKey.equals(recipient, ignoreCase = true)) return false
val author = getOrCreateUser(event.pubKey)
val repliesTo = computeReplyTo(event)
note.loadEvent(event, author, repliesTo)
refreshNewNoteObservers(note)
// Verification needs a chain backend. Without one (e.g. before Account
// wires its EsploraBackend) the event is still cached so subscriptions
// and profile zap views see it, but it can't contribute to Note totals.
val backend = onchainBackend ?: return true
val verifier = OnchainZapVerifier(backend)
Amethyst.instance.applicationIOScope.launch {
try {
when (val result = verifier.verify(event)) {
is VerifiedOnchainZap.Confirmed -> {
repliesTo.forEach {
it.addOnchainZap(note, result.txid, result.verifiedSats, confirmed = true)
}
}
is VerifiedOnchainZap.Pending -> {
repliesTo.forEach {
it.addOnchainZap(note, result.txid, result.verifiedSats, confirmed = false)
}
}
is VerifiedOnchainZap.Rejected -> {
Log.d("OnchainZap") {
"rejected ${result.txid}: ${result.reason}"
}
}
}
} catch (t: Throwable) {
Log.w("OnchainZap", "verification failed for ${event.id}", t)
}
}
return true
}
private fun attachZapToLiveActivityChannel(
@@ -3170,6 +3349,10 @@ object LocalCache : ILocalCache, ICacheProvider {
consume(event, relay, wasVerified)
}
is OnchainZapEvent -> {
consume(event, relay, wasVerified)
}
is NIP90StatusEvent -> {
consumeRegularEvent(event, relay, wasVerified)
}

View File

@@ -52,6 +52,7 @@ data class UiSettings(
val showProfileAppRecommendations: Boolean = true,
val showProfileZapReceivedFeed: Boolean = true,
val showProfileFollowersFeed: Boolean = true,
val dontShowOnchainPublicWarning: Boolean = false,
)
enum class ThemeType(

View File

@@ -52,6 +52,7 @@ class UiSettingsFlow(
val showProfileAppRecommendations: MutableStateFlow<Boolean> = MutableStateFlow(true),
val showProfileZapReceivedFeed: MutableStateFlow<Boolean> = MutableStateFlow(true),
val showProfileFollowersFeed: MutableStateFlow<Boolean> = MutableStateFlow(true),
val dontShowOnchainPublicWarning: MutableStateFlow<Boolean> = MutableStateFlow(false),
) {
val listOfFlows: List<Flow<Any?>> =
listOf<Flow<Any?>>(
@@ -78,6 +79,7 @@ class UiSettingsFlow(
showProfileAppRecommendations,
showProfileZapReceivedFeed,
showProfileFollowersFeed,
dontShowOnchainPublicWarning,
)
// emits at every change in any of the propertyes.
@@ -108,6 +110,7 @@ class UiSettingsFlow(
flows[20] as Boolean,
flows[21] as Boolean,
flows[22] as Boolean,
flows[23] as Boolean,
)
}
@@ -136,6 +139,7 @@ class UiSettingsFlow(
showProfileAppRecommendations.value,
showProfileZapReceivedFeed.value,
showProfileFollowersFeed.value,
dontShowOnchainPublicWarning.value,
)
fun update(torSettings: UiSettings): Boolean {
@@ -233,6 +237,10 @@ class UiSettingsFlow(
showProfileFollowersFeed.tryEmit(torSettings.showProfileFollowersFeed)
any = true
}
if (dontShowOnchainPublicWarning.value != torSettings.dontShowOnchainPublicWarning) {
dontShowOnchainPublicWarning.tryEmit(torSettings.dontShowOnchainPublicWarning)
any = true
}
return any
}
@@ -249,6 +257,12 @@ class UiSettingsFlow(
}
}
fun dontShowOnchainPublicWarning() {
if (!dontShowOnchainPublicWarning.value) {
dontShowOnchainPublicWarning.tryEmit(true)
}
}
companion object {
fun build(uiSettings: UiSettings): UiSettingsFlow =
UiSettingsFlow(
@@ -275,6 +289,7 @@ class UiSettingsFlow(
MutableStateFlow(uiSettings.showProfileAppRecommendations),
MutableStateFlow(uiSettings.showProfileZapReceivedFeed),
MutableStateFlow(uiSettings.showProfileFollowersFeed),
MutableStateFlow(uiSettings.dontShowOnchainPublicWarning),
)
}
}

View File

@@ -0,0 +1,64 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.nip03Timestamp
import com.vitorpamplona.quartz.nip03Timestamp.okhttp.OkHttpBitcoinExplorer
/**
* Picks the Bitcoin block-explorer (Esplora) base URL.
*
* This is the single source of truth shared by two features that both talk to
* the same Esplora API:
* - OpenTimestamps verification ([TorAwareOkHttpOtsResolverBuilder]).
* - NIP-BC onchain zaps (the `EsploraBackend` wired in `AppModules`).
*
* So a user who configures a custom explorer in the OTS settings gets it for
* onchain zaps too, and both honour the same Tor preference: a configured
* [OtsSettings] custom URL always wins; otherwise mempool.space is used when
* Tor is active (it is reachable over Tor) and blockstream.info when it is not.
*/
object BitcoinExplorerEndpoint {
/**
* @param customExplorerUrl A user-configured explorer base URL, or null/blank
* for the automatic Tor-aware default. Typically
* `OtsSettings.normalizedUrl()`.
* @param usingTor Whether Bitcoin/"money" traffic is currently routed over Tor.
*/
fun resolve(
customExplorerUrl: String?,
usingTor: Boolean,
): String =
customExplorerUrl?.takeIf { it.isNotBlank() }
?: if (usingTor) {
OkHttpBitcoinExplorer.MEMPOOL_API_URL
} else {
OkHttpBitcoinExplorer.BLOCKSTREAM_API_URL
}
/**
* Same as [resolve] but with any trailing slash stripped, for callers that
* join request paths with a leading `/` (e.g. `"$base/tx/$txid"`).
*/
fun resolveNormalized(
customExplorerUrl: String?,
usingTor: Boolean,
): String = resolve(customExplorerUrl, usingTor).trimEnd('/')
}

View File

@@ -33,13 +33,7 @@ class TorAwareOkHttpOtsResolverBuilder(
val cache: OtsBlockHeightCache,
val customExplorerUrl: () -> String? = { null },
) : OtsResolverBuilder {
fun getAPI(usingTor: Boolean): String =
customExplorerUrl()
?: if (usingTor) {
OkHttpBitcoinExplorer.MEMPOOL_API_URL
} else {
OkHttpBitcoinExplorer.BLOCKSTREAM_API_URL
}
fun getAPI(usingTor: Boolean): String = BitcoinExplorerEndpoint.resolve(customExplorerUrl(), usingTor)
override fun build(): OtsResolver =
OtsResolver(

View File

@@ -45,7 +45,7 @@ class Nip11Retriever(
relay: NormalizedRelayUrl,
onInfo: (Nip11RelayInformation) -> Unit,
onError: (NormalizedRelayUrl, ErrorCode, String?) -> Unit,
) {
) = withContext(Dispatchers.IO) {
val url = relay.toHttp()
try {
val request: Request =
@@ -58,27 +58,25 @@ class Nip11Retriever(
val client = okHttpClient(relay)
client.newCall(request).executeAsync().use { response ->
withContext(Dispatchers.IO) {
val body = response.body.string()
try {
if (response.isSuccessful) {
if (body.startsWith("{")) {
onInfo(Nip11RelayInformation.fromJson(body))
} else {
onError(relay, ErrorCode.FAIL_TO_PARSE_RESULT, body)
}
val body = response.body.string()
try {
if (response.isSuccessful) {
if (body.startsWith("{")) {
onInfo(Nip11RelayInformation.fromJson(body))
} else {
onError(relay, ErrorCode.FAIL_WITH_HTTP_STATUS, response.code.toString())
onError(relay, ErrorCode.FAIL_TO_PARSE_RESULT, body)
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e(
"RelayInfoFail",
"Resulting Message from Relay ${relay.url} in not parseable: $body",
e,
)
onError(relay, ErrorCode.FAIL_TO_PARSE_RESULT, e.message)
} else {
onError(relay, ErrorCode.FAIL_WITH_HTTP_STATUS, response.code.toString())
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e(
"RelayInfoFail",
"Resulting Message from Relay ${relay.url} in not parseable: $body",
e,
)
onError(relay, ErrorCode.FAIL_TO_PARSE_RESULT, e.message)
}
}
} catch (e: Exception) {

View File

@@ -116,6 +116,7 @@ class UiSharedPreferences(
val UI_SHOW_PROFILE_APP_RECOMMENDATIONS = booleanPreferencesKey("ui.show_profile_app_recommendations")
val UI_SHOW_PROFILE_ZAP_RECEIVED_FEED = booleanPreferencesKey("ui.show_profile_zap_received_feed")
val UI_SHOW_PROFILE_FOLLOWERS_FEED = booleanPreferencesKey("ui.show_profile_followers_feed")
val UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING = booleanPreferencesKey("ui.dont_show_onchain_public_warning")
suspend fun uiPreferences(context: Context): UiSettings? =
try {
@@ -150,6 +151,7 @@ class UiSharedPreferences(
showProfileAppRecommendations = preferences[UI_SHOW_PROFILE_APP_RECOMMENDATIONS] ?: true,
showProfileZapReceivedFeed = preferences[UI_SHOW_PROFILE_ZAP_RECEIVED_FEED] ?: true,
showProfileFollowersFeed = preferences[UI_SHOW_PROFILE_FOLLOWERS_FEED] ?: true,
dontShowOnchainPublicWarning = preferences[UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING] ?: false,
)
} catch (e: Exception) {
if (e is CancellationException) throw e
@@ -197,6 +199,7 @@ class UiSharedPreferences(
preferences[UI_SHOW_PROFILE_APP_RECOMMENDATIONS] = sharedSettings.showProfileAppRecommendations
preferences[UI_SHOW_PROFILE_ZAP_RECEIVED_FEED] = sharedSettings.showProfileZapReceivedFeed
preferences[UI_SHOW_PROFILE_FOLLOWERS_FEED] = sharedSettings.showProfileFollowersFeed
preferences[UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING] = sharedSettings.dontShowOnchainPublicWarning
}
} catch (e: Exception) {
if (e is CancellationException) throw e

View File

@@ -24,6 +24,8 @@ import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState
import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState
import com.vitorpamplona.amethyst.model.nip17Dms.DmRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays.IndexerRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays.ProxyRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.searchRelays.SearchRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.trustedRelays.TrustedRelayListState
import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState
@@ -43,6 +45,8 @@ class TrustedRelayListsState(
val localRelayList: LocalRelayListState,
val dmRelayList: DmRelayListState,
val searchRelayListState: SearchRelayListState,
val indexerRelayList: IndexerRelayListState,
val proxyRelayList: ProxyRelayListState,
val trustedRelayList: TrustedRelayListState,
val broadcastRelayList: BroadcastRelayListState,
val scope: CoroutineScope,
@@ -57,6 +61,8 @@ class TrustedRelayListsState(
localRelayList.flow,
dmRelayList.flow,
searchRelayListState.flowNoDefaults,
indexerRelayList.flowNoDefaults,
proxyRelayList.flow,
trustedRelayList.flow,
broadcastRelayList.flow,
),
@@ -70,6 +76,8 @@ class TrustedRelayListsState(
localRelayList.flow.value,
dmRelayList.flow.value,
searchRelayListState.flowNoDefaults.value,
indexerRelayList.flowNoDefaults.value,
proxyRelayList.flow.value,
trustedRelayList.flow.value,
broadcastRelayList.flow.value,
),
@@ -86,6 +94,8 @@ class TrustedRelayListsState(
localRelayList.flow.value,
dmRelayList.flow.value,
searchRelayListState.flowNoDefaults.value,
indexerRelayList.flowNoDefaults.value,
proxyRelayList.flow.value,
trustedRelayList.flow.value,
broadcastRelayList.flow.value,
),

View File

@@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup
import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetupLnAddress
import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup
import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlForm
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.utils.mapNotNullAsync
import kotlinx.collections.immutable.ImmutableList
@@ -178,7 +179,7 @@ class ZapPaymentHandler(
onProgress(0.02f)
val splitZapRequests = signAllZapRequests(note, pollOption, message, zapType, zapsToSend)
val splitZapRequests = signAllZapRequests(note, pollOption, message, zapType, zapsToSend, amountMilliSats)
if (splitZapRequests.isEmpty()) {
onProgress(0.00f)
@@ -237,8 +238,10 @@ class ZapPaymentHandler(
message: String,
zapType: LnZapEvent.ZapType,
zapsToSend: List<MyZapSplitSetup>,
): List<ZapRequestReady> =
mapNotNullAsync(zapsToSend) { next: MyZapSplitSetup ->
totalAmountMilliSats: Long,
): List<ZapRequestReady> {
val totalWeight = zapsToSend.sumOf { it.weight }
return mapNotNullAsync(zapsToSend) { next: MyZapSplitSetup ->
// makes sure the author receives the zap event
val authorRelayList = note.author?.inboxRelays()?.toSet() ?: emptySet()
@@ -247,15 +250,31 @@ class ZapPaymentHandler(
val noteEvent = note.event
// Per NIP-57 §6: the zap request SHOULD carry `amount` (in msats) and
// `lnurl` so the recipient's LNURL provider — and clients reading the
// resulting receipt — can validate them under Appendix F.
val splitAmount = calculateZapValue(totalAmountMilliSats, next.weight, totalWeight)
val splitLnurl = LnurlForm.toUrl(next.lnAddress)?.let(LnurlForm::urlToBech32)
val zapRequest =
if (zapType != LnZapEvent.ZapType.NONZAP && noteEvent != null) {
account.createZapRequestFor(noteEvent, pollOption, message, zapType, next.user, userRelayList + authorRelayList)
account.createZapRequestFor(
event = noteEvent,
pollOption = pollOption,
message = message,
zapType = zapType,
toUser = next.user,
additionalRelays = userRelayList + authorRelayList,
amountMillisats = splitAmount,
lnurl = splitLnurl,
)
} else {
null
}
ZapRequestReady(next, zapRequest)
}
}
suspend fun assembleAllInvoices(
requests: List<ZapRequestReady>,

View File

@@ -0,0 +1,119 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.calendar
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.stringRes
/**
* Posts user-visible "starting soon" notifications for NIP-52 appointments the user has RSVP'd
* to as ACCEPTED. Mirrors the shape of [com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostNotifier]
* so the two notification surfaces stay consistent.
*/
object CalendarReminderNotifier {
@Volatile
private var channel: NotificationChannel? = null
private const val REMINDER_NOT_ID_BASE = 0x80000
/**
* @param eventId the appointment's event id — used to derive a stable notification id so a
* second reminder for the same event collapses rather than stacking.
* @param title the appointment title (or a fallback string).
* @param body a short pre-formatted body, e.g. "Starts in 15 minutes".
* @param deepLink a `nostr:naddr…` URI for the calendar event. Tapping the notification
* hands this to MainActivity, which routes it via `uriToRoute` → the
* calendar detail screen (CalendarTimeSlotEvent / CalendarDateSlotEvent
* branches in RouteMaker resolve to Route.CalendarEventDetail).
*/
fun notifyReminder(
context: Context,
eventId: String,
title: String,
body: String,
deepLink: String,
) {
ensureChannel(context)
val notId = idFor(eventId)
val channelId = stringRes(context, R.string.calendar_reminder_channel_id)
val tapIntent =
Intent(context, MainActivity::class.java).apply {
action = Intent.ACTION_VIEW
data = android.net.Uri.parse(deepLink)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
val tapPendingIntent =
PendingIntent.getActivity(
context,
notId,
tapIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val nm = NotificationManagerCompat.from(context)
// POST_NOTIFICATIONS is runtime-granted on Android 13+; bail when denied so the lint
// call below doesn't flag and we don't log a misleading no-op.
if (!nm.areNotificationsEnabled()) return
val notification =
NotificationCompat
.Builder(context, channelId)
.setSmallIcon(R.drawable.amethyst)
.setContentTitle(title)
.setContentText(body)
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setContentIntent(tapPendingIntent)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setCategory(NotificationCompat.CATEGORY_EVENT)
.setAutoCancel(true)
.setWhen(System.currentTimeMillis())
.build()
try {
nm.notify(notId, notification)
} catch (_: SecurityException) {
// Race: permission revoked between the check above and notify().
}
}
fun ensureChannel(context: Context) {
if (channel != null) return
channel =
NotificationChannel(
stringRes(context, R.string.calendar_reminder_channel_id),
stringRes(context, R.string.calendar_reminder_channel_name),
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = stringRes(context, R.string.calendar_reminder_channel_description)
}
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.createNotificationChannel(channel!!)
}
// Distinct id per event so two reminders for the same event collapse onto one row while
// separate events render side by side.
private fun idFor(eventId: String): Int = REMINDER_NOT_ID_BASE xor eventId.hashCode()
}

View File

@@ -0,0 +1,65 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.calendar
import android.content.Context
import android.content.SharedPreferences
/**
* Device-wide preferences for the calendar reminder worker.
*
* Stored at device scope (rather than per-account) because the worker that consults them runs
* globally — multiplexing per-account preferences would require account-context plumbing into
* WorkManager that the rest of the app doesn't have. A user who flips between two accounts on
* the same device shares the same lead-time and enabled-state. Per-account preferences could be
* a follow-up if anyone asks.
*/
class CalendarReminderPrefs(
context: Context,
) {
private val prefs: SharedPreferences = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
fun isEnabled(): Boolean = prefs.getBoolean(KEY_ENABLED, DEFAULT_ENABLED)
fun setEnabled(enabled: Boolean) {
prefs.edit().putBoolean(KEY_ENABLED, enabled).apply()
}
fun leadMinutes(): Int = prefs.getInt(KEY_LEAD_MINUTES, DEFAULT_LEAD_MINUTES)
fun setLeadMinutes(minutes: Int) {
prefs.edit().putInt(KEY_LEAD_MINUTES, minutes).apply()
}
companion object {
const val DEFAULT_LEAD_MINUTES = 15
const val DEFAULT_ENABLED = true
// Choices presented in the settings UI. Anchored to the worker cadence — lead times
// smaller than the cadence (15 min) can't be honoured reliably; 60 is the largest the
// UX shape supports without an extra "hours" picker.
val LEAD_TIME_CHOICES = listOf(5, 15, 30, 60)
private const val PREF_NAME = "amethyst_calendar_reminder_prefs"
private const val KEY_ENABLED = "enabled"
private const val KEY_LEAD_MINUTES = "lead_minutes"
}
}

View File

@@ -0,0 +1,83 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.calendar
import android.content.Context
import android.content.SharedPreferences
/**
* Persistent "I've already notified for this event" set. Backed by [SharedPreferences] because
* the worker that consults it runs in the app process and the dataset is tiny (≤ a few hundred
* IDs at most). Without persistence, every worker run after a restart would re-notify for the
* same upcoming event until it started, since LocalCache has no memory of past reminders.
*
* Keys are event ids (the 32-byte hex from a 31922/31923 appointment). Values aren't used; only
* presence in the set matters. Entries are pruned by [forgetBefore] when the worker has just
* fired so the store doesn't grow unbounded over time.
*/
class CalendarReminderStore(
context: Context,
) {
private val prefs: SharedPreferences =
context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
/**
* Returns true when we've previously notified for this exact event-start pairing. If the
* author updates the appointment to a new start time, the stored value won't match and
* we'll fire a fresh reminder for the new time — that's the desired behaviour: a moved
* meeting shouldn't be silently skipped.
*/
fun wasNotified(
eventId: String,
eventStartSeconds: Long,
): Boolean = prefs.getLong(keyFor(eventId), Long.MIN_VALUE) == eventStartSeconds
fun markNotified(
eventId: String,
eventStartSeconds: Long,
) {
prefs.edit().putLong(keyFor(eventId), eventStartSeconds).apply()
}
/**
* Drops any entry whose recorded event-start time is older than [cutoffSeconds]. Called
* after each worker run so the store stays bounded — events that have long since ended
* can't fire a second reminder, so their entries are dead weight.
*/
fun forgetBefore(cutoffSeconds: Long) {
val editor = prefs.edit()
var changed = false
prefs.all.forEach { (key, value) ->
if (value is Long && value < cutoffSeconds) {
editor.remove(key)
changed = true
}
}
if (changed) editor.apply()
}
companion object {
private const val PREF_NAME = "amethyst_calendar_reminders"
private const val KEY_PREFIX = "notified:"
private fun keyFor(eventId: String) = KEY_PREFIX + eventId
}
}

View File

@@ -0,0 +1,140 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.calendar
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import java.util.concurrent.TimeUnit
/**
* Periodic scan that posts "starting soon" notifications for appointments the user has RSVP'd
* to as ACCEPTED.
*
* The work is bounded — scans LocalCache (which is bounded by the relay subscription) and
* consults [CalendarReminderStore] to skip events that have already been notified for. Run as
* a 15-minute periodic worker: that's the WorkManager minimum and matches the resolution of
* the reminder UI ("starts in ~15 min" is the smallest interval users perceive as "soon").
*/
class CalendarReminderWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
val prefs = CalendarReminderPrefs(applicationContext)
if (!prefs.isEnabled()) {
Log.d(TAG) { "Reminders disabled; skipping scan." }
return Result.success()
}
val now = TimeUtils.now()
val windowEnd = now + prefs.leadMinutes() * 60L
val store = CalendarReminderStore(applicationContext)
// Walk every kind-31925 RSVP authored by an account on this device. We don't have a
// multi-account "all logged-in pubkeys" view here, so we accept any RSVP that's
// present in cache — the alternative (looking only at the foreground account) would
// silently break notifications for account switching during the lead window.
val acceptedRsvps =
LocalCache.addressables
.filterIntoSet { _, note ->
val e = note.event
e is CalendarRSVPEvent && e.status() == RSVPStatusTag.STATUS.ACCEPTED
}.mapNotNull { it.event as? CalendarRSVPEvent }
Log.d(TAG) { "Worker scanning ${acceptedRsvps.size} accepted RSVPs (now=$now, lead=${prefs.leadMinutes()}m)" }
acceptedRsvps.forEach { rsvp ->
val targetAddress = rsvp.calendarEventAddress() ?: return@forEach
val targetNote = LocalCache.addressables.get(targetAddress) ?: return@forEach
val view = targetNote.appointmentView() ?: return@forEach
val start = view.startSeconds ?: return@forEach
val eventId =
(targetNote.event as? CalendarTimeSlotEvent)?.id
?: (targetNote.event as? CalendarDateSlotEvent)?.id
?: return@forEach
if (start !in now..windowEnd) return@forEach
// Keyed on (eventId, start) so a moved appointment re-fires when the new start
// enters the lead window — the old notification stays valid in the system tray.
if (store.wasNotified(eventId, start)) return@forEach
val title = view.title ?: stringRes(applicationContext, R.string.calendar_reminder_default_title)
val minutesAway = ((start - now).coerceAtLeast(0L) / 60L).toInt()
val body =
pluralStringRes(
applicationContext,
R.plurals.calendar_reminder_body,
minutesAway,
minutesAway,
)
val deepLink =
"nostr:" +
com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
.create(targetAddress.kind, targetAddress.pubKeyHex, targetAddress.dTag, null)
CalendarReminderNotifier.notifyReminder(applicationContext, eventId, title, body, deepLink)
store.markNotified(eventId, start)
Log.d(TAG) { "Notified $eventId (starts in ${minutesAway}m)" }
}
// Prune entries for events that ended more than a day ago — they can't fire again.
store.forgetBefore(now - PRUNE_AGE_SECONDS)
return Result.success()
}
companion object {
private const val TAG = "CalendarReminderWorker"
private const val WORK_NAME = "calendar_reminder_worker"
// Don't bother remembering "I notified for this" entries for events whose start was
// more than a day ago; they can't fire again so the entry is pure overhead.
private const val PRUNE_AGE_SECONDS = 24L * 60L * 60L
fun schedule(context: Context) {
val request =
PeriodicWorkRequestBuilder<CalendarReminderWorker>(15, TimeUnit.MINUTES)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
WORK_NAME,
ExistingPeriodicWorkPolicy.KEEP,
request,
)
Log.d(TAG) { "schedule(): enqueueUniquePeriodicWork($WORK_NAME, 15 MIN, KEEP)" }
}
fun cancel(context: Context) {
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
}
}
}

View File

@@ -60,7 +60,7 @@ import kotlinx.coroutines.withContext
*/
object CallNotifier {
private var callChannel: NotificationChannel? = null
private const val CALL_CHANNEL_ID = "com.vitorpamplona.amethyst.CALL_CHANNEL"
const val CALL_CHANNEL_ID = "com.vitorpamplona.amethyst.CALL_CHANNEL"
private const val CALL_NOTIFICATION_ID = 0x50000
fun getOrCreateCallChannel(applicationContext: Context): NotificationChannel {

View File

@@ -24,7 +24,10 @@ import android.os.Build
import com.vitorpamplona.amethyst.BuildConfig
class ReportAssembler {
fun buildReport(e: Throwable): String =
fun buildReport(
e: Throwable,
threadName: String = Thread.currentThread().name,
): String =
buildString {
append(e.javaClass.simpleName)
append(": ")
@@ -70,9 +73,14 @@ class ReportAssembler {
append("| User | ")
append(Build.USER)
appendLine(" |")
append("| Thread | ")
append(threadName)
appendLine(" |")
appendLine()
appendLine("```")
append("Thread: ")
appendLine(threadName)
appendLine(e.toString())
e.stackTrace.forEach {
append(" ")

View File

@@ -35,8 +35,9 @@ class UnexpectedCrashSaver(
) {
if (e !is OutOfMemoryError) {
// OOM reports are junk
val threadName = t.name
scope.launch {
cache.writeReport(ReportAssembler().buildReport(e))
cache.writeReport(ReportAssembler().buildReport(e, threadName))
}
}
defaultUEH!!.uncaughtException(t, e)

View File

@@ -28,6 +28,8 @@ import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
import com.vitorpamplona.quartz.lightning.Lud06
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlEndpointCache
import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlEndpointInfo
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -210,6 +212,8 @@ class LightningAddressResolver {
): String {
val mapper = jacksonObjectMapper()
val lnurlpUrl = assembleUrl(lnAddress)
val lnAddressJson =
fetchLightningAddressJson(
lnAddress,
@@ -248,6 +252,17 @@ class LightningAddressResolver {
}
val allowsNostr = lnurlp.get("allowsNostr")?.asBoolean() ?: false
val nostrPubkey = lnurlp.get("nostrPubkey")?.asText()?.ifBlank { null }
// Prime the receipt-validation cache so incoming zap receipts can be
// checked against this provider's nostrPubkey (NIP-57 Appendix F)
// without re-fetching the lnurlp endpoint.
if (lnurlpUrl != null) {
LnurlEndpointCache.put(
lnurlpUrl,
LnurlEndpointInfo(nostrPubkey = nostrPubkey, allowsNostr = allowsNostr),
)
}
val invoice =
fetchLightningInvoice(

View File

@@ -32,6 +32,14 @@ import com.vitorpamplona.quartz.utils.Log
object CachedReversedGeoLocations {
val locationNames = LruCache<String, String>(20)
// Geocoder.isPresent() reflects whether the system has a backing
// IGeocodeProvider registered — fixed at boot, safe to memoize. On AOSP /
// GrapheneOS without Google Play Services or microG configured no provider
// is registered and this returns false.
private val geocoderAvailable: Boolean by lazy { Geocoder.isPresent() }
fun isGeocoderAvailable(): Boolean = geocoderAvailable
fun cached(geoHashStr: String): String? = locationNames[geoHashStr]
fun geoLocate(
@@ -42,31 +50,24 @@ object CachedReversedGeoLocations {
) {
locationNames[geoHashStr]?.let {
onReady(it)
return
}
if (Geocoder.isPresent()) {
ReverseGeolocation.execute(location, context) { cityNames ->
if (cityNames != null) {
val cityName =
cityNames.firstNotNullOfOrNull {
val name = it.toCityCountry()
if (!name.isBlank()) {
name
} else {
null
}
}
if (cityName != null) {
locationNames.put(geoHashStr, cityName)
onReady(cityName)
}
} else {
// error
onReady(null)
if (!geocoderAvailable) {
Log.d("ReverseGeoLocation") { "Geocoder service not present on this device" }
onReady(null)
return
}
ReverseGeolocation.execute(location, context) { cityNames ->
val cityName =
cityNames?.firstNotNullOfOrNull {
it.toCityCountry().takeIf { name -> name.isNotBlank() }
}
if (cityName != null) {
locationNames.put(geoHashStr, cityName)
}
} else {
Log.d("ReverseGeoLocation", "Geocoder not present")
onReady(cityName)
}
}
}

View File

@@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFind
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.note.showAmount
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent
import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent
@@ -56,6 +57,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
@@ -147,15 +149,34 @@ class EventNotificationConsumer(
if (!notificationManager().areNotificationsEnabled()) return@withWakeLock
// Ask the event which of our signing accounts it's notifying.
// Each kind declares its own notification semantics in
// [Event.notifies] (lowercase `p` by default, NIP-22 also checks
// uppercase `P`, etc.), so we don't hard-code tag names here.
// Match the in-app Notifications feed exactly: the event must
// p-tag the account AND pass NotificationFeedFilter.tagsAnEventByUser
// — the latter is the per-kind "is this actually for me" rule
// (reply-to-me, citation of my post, fork of my content, community
// moderation, reaction targeting my note, etc.). WakeUpEvent is the
// one exception: its [Event.notifies] is `true` unconditionally so
// every signed-in account processes it (the dispatcher bypasses
// the same way).
//
// One LocalCache lookup per event regardless of account count —
// the note is the same for every saved account. Use the Event
// overload so AddressableEvent kinds resolve to their replaceable
// note (id-keyed version has empty replyTo after insertion).
val matchingNote: Note? =
if (event is WakeUpEvent) {
null
} else {
LocalCache.getNoteIfExists(event) ?: return@withWakeLock
}
LocalPreferences.allSavedAccounts().forEach { savedAccount ->
if (!savedAccount.hasPrivKey && !savedAccount.loggedInWithExternalSigner) return@forEach
val accountHex = npubToHexOrNull(savedAccount.npub) ?: return@forEach
if (!event.notifies(accountHex)) return@forEach
if (matchingNote != null) {
if (!event.isTaggedUser(accountHex)) return@forEach
if (!NotificationFeedFilter.tagsAnEventByUser(matchingNote, accountHex)) return@forEach
}
val accountSettings = LocalPreferences.loadAccountConfigFromEncryptedStorage(savedAccount.npub) ?: return@forEach
try {
@@ -204,6 +225,17 @@ class EventNotificationConsumer(
// to repeat either check.
if (event.pubKey == account.signer.pubKey) return
// Mirror NotificationFeedFilter: reactions and zaps target a note via
// `replyTo`, not via thread-root tags on the wrapper event, so checking
// the wrapper's own thread misses them. Resolve the target and drop if
// its thread is muted. (The feed extends this to reposts too, but
// RepostEvent / GenericRepostEvent aren't routed below — push doesn't
// notify on reposts at all today.)
if (event is ReactionEvent || event is LnZapEvent) {
val target = LocalCache.getNoteIfExists(event)?.replyTo?.lastOrNull()
if (target != null && account.isThreadMuted(account.resolveThreadRoot(target))) return
}
when (event) {
is PrivateDmEvent -> notify(event, account)
@@ -554,6 +586,9 @@ class EventNotificationConsumer(
accountNpub = accountNpub,
accountPictureUrl = account.userProfile().profilePicture(),
chatroomMembers = null,
marmotNostrGroupId = nostrGroupId,
marmotReplyToInnerEventId = innerEvent.id,
marmotReplyToInnerAuthor = innerEvent.pubKey,
)
}

View File

@@ -0,0 +1,166 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.notifications
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.provider.Settings
import androidx.core.app.NotificationManagerCompat
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.service.call.notification.CallNotifier
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostNotifier
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.Log
/**
* Registry of user-facing notification channels and helpers to read their
* current importance / open the system settings page for them.
*
* Android (post-Oreo) owns channel state — the app cannot toggle channel
* importance directly. The Notifications settings screen surfaces the
* channels here and routes the user to the system per-channel page.
*
* Foreground-service channels (relay-connection, nests audio) are
* intentionally omitted: they're functional indicators, not content
* notifications, and disabling them breaks the foreground service contract.
*/
object NotificationChannels {
private const val TAG = "NotificationChannels"
enum class ChannelStatus { ON, SILENT, OFF }
/**
* A single content-bearing notification channel exposed in the settings UI.
* [ensure] creates the channel if missing — needed so the system per-channel
* settings page has something to open even before the first notification fires.
*/
data class Entry(
val nameRes: Int,
val icon: MaterialSymbol,
val channelId: (Context) -> String,
val ensure: (Context) -> Unit,
)
val contentChannels: List<Entry> =
listOf(
Entry(
nameRes = R.string.app_notification_dms_channel_name,
icon = MaterialSymbols.Mail,
channelId = { stringRes(it, R.string.app_notification_dms_channel_id) },
ensure = { NotificationUtils.getOrCreateDMChannel(it) },
),
Entry(
nameRes = R.string.app_notification_mentions_channel_name,
icon = MaterialSymbols.AlternateEmail,
channelId = { stringRes(it, R.string.app_notification_mentions_channel_id) },
ensure = { NotificationUtils.getOrCreateMentionChannel(it) },
),
Entry(
nameRes = R.string.app_notification_replies_channel_name,
icon = MaterialSymbols.Chat,
channelId = { stringRes(it, R.string.app_notification_replies_channel_id) },
ensure = { NotificationUtils.getOrCreateReplyChannel(it) },
),
Entry(
nameRes = R.string.app_notification_reactions_channel_name,
icon = MaterialSymbols.Favorite,
channelId = { stringRes(it, R.string.app_notification_reactions_channel_id) },
ensure = { NotificationUtils.getOrCreateReactionChannel(it) },
),
Entry(
nameRes = R.string.app_notification_zaps_channel_name,
icon = MaterialSymbols.Bolt,
channelId = { stringRes(it, R.string.app_notification_zaps_channel_id) },
ensure = { NotificationUtils.getOrCreateZapChannel(it) },
),
Entry(
nameRes = R.string.app_notification_chess_channel_name,
icon = MaterialSymbols.ChessKnight,
channelId = { stringRes(it, R.string.app_notification_chess_channel_id) },
ensure = { NotificationUtils.getOrCreateChessChannel(it) },
),
Entry(
nameRes = R.string.app_notification_scheduled_posts_channel_name,
icon = MaterialSymbols.Schedule,
channelId = { stringRes(it, R.string.app_notification_scheduled_posts_channel_id) },
ensure = { ScheduledPostNotifier.ensureChannel(it) },
),
Entry(
nameRes = R.string.app_notification_calls_channel_name,
icon = MaterialSymbols.Call,
channelId = { CallNotifier.CALL_CHANNEL_ID },
ensure = { CallNotifier.getOrCreateCallChannel(it) },
),
)
fun statusOf(
context: Context,
channelId: String,
): ChannelStatus {
if (!NotificationManagerCompat.from(context).areNotificationsEnabled()) return ChannelStatus.OFF
val nm = context.getSystemService(NotificationManager::class.java) ?: return ChannelStatus.OFF
val channel = nm.getNotificationChannel(channelId) ?: return ChannelStatus.ON
return when (channel.importance) {
NotificationManager.IMPORTANCE_NONE -> ChannelStatus.OFF
NotificationManager.IMPORTANCE_MIN, NotificationManager.IMPORTANCE_LOW -> ChannelStatus.SILENT
else -> ChannelStatus.ON
}
}
/**
* Opens the system per-channel notification settings page. Falls back to
* the app-level notification settings if the per-channel intent isn't
* supported (e.g. the channel was never created, or on stripped-down ROMs).
*/
fun openChannelSettings(
context: Context,
channelId: String,
) {
try {
val intent =
Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
putExtra(Settings.EXTRA_CHANNEL_ID, channelId)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
} catch (e: Exception) {
Log.w(TAG, "Per-channel intent failed, falling back to app notification settings", e)
openAppNotificationSettings(context)
}
}
fun openAppNotificationSettings(context: Context) {
try {
val intent =
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
} catch (e: Exception) {
Log.e(TAG, "Failed to open app notification settings", e)
}
}
}

View File

@@ -24,10 +24,12 @@ import android.content.Context
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter
import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent
import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
@@ -51,6 +53,7 @@ import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -96,6 +99,7 @@ class NotificationDispatcher(
// Direct-arrival
PrivateDmEvent.KIND,
LnZapEvent.KIND,
OnchainZapEvent.KIND,
ReactionEvent.KIND,
TextNoteEvent.KIND,
CommentEvent.KIND,
@@ -171,15 +175,37 @@ class NotificationDispatcher(
// matches the downstream per-channel policy. Calls
// use a stricter 20s check in notifyIncomingCall so
// they still pass through.
// - event.notifies(pubkey) for any of our accounts —
// each kind decides which tag(s) name its recipients
// (lowercase `p` by default, plus uppercase `P` for
// NIP-22 root authors, etc.).
val predicate = { event: Event ->
event.kind in NOTIFICATION_KINDS &&
event.createdAt >= dispatcherSince &&
event.createdAt >= TimeUtils.fifteenMinutesAgo() &&
pubkeys.any { event.notifies(it) }
// - WakeUpEvent bypasses recipient matching: its
// [Event.notifies] is `true` unconditionally, meaning
// "wake every signed-in account" (p-tags on a WakeUp
// name referenced-event authors, not recipients).
// - Everything else uses isTaggedUser + tagsAnEventByUser
// — same pair the in-app Notifications feed uses, so
// push and feed agree on what counts as a mention,
// reply, citation, fork, or community moderation.
// Note: NIP-22 CommentEvents where the user is only
// the root author (uppercase `P`) no longer match —
// intentional alignment with the feed, which gates
// on lowercase `p` only.
val predicate = predicate@{ event: Event ->
if (event.kind !in NOTIFICATION_KINDS) return@predicate false
if (event.createdAt < dispatcherSince) return@predicate false
if (event.createdAt < TimeUtils.fifteenMinutesAgo()) return@predicate false
if (event is WakeUpEvent) return@predicate true
// getNoteIfExists(event) — not (event.id) — so
// AddressableEvent kinds (LongTextNote, WikiNote,
// LiveChess*, VideoHorizontal/Vertical) resolve to
// their address-keyed replaceable note. The id-keyed
// version note has its replyTo moved away during
// insertion (LocalCache.consumeBaseReplaceable), so
// tagsAnEventByUser's replyTo check would otherwise
// always miss for replies into addressable posts.
val note = LocalCache.getNoteIfExists(event) ?: return@predicate false
pubkeys.any { pubkey ->
event.isTaggedUser(pubkey) &&
NotificationFeedFilter.tagsAnEventByUser(note, pubkey)
}
}
LocalCache

View File

@@ -94,6 +94,25 @@ class NotificationReplyReceiver : BroadcastReceiver() {
sendPublicReply(accountNpub, targetEventId, replyText)
}
}
NotificationUtils.MARMOT_REPLY_ACTION -> {
val replyText =
RemoteInput
.getResultsFromIntent(intent)
?.getCharSequence(NotificationUtils.KEY_REPLY_TEXT)
?.toString()
if (replyText.isNullOrBlank()) return
val accountNpub = intent.getStringExtra(NotificationUtils.KEY_ACCOUNT_NPUB) ?: return
val nostrGroupId = intent.getStringExtra(NotificationUtils.KEY_MARMOT_GROUP_ID) ?: return
val replyToInnerId = intent.getStringExtra(NotificationUtils.KEY_MARMOT_REPLY_TO_INNER_ID)
val replyToInnerAuthor = intent.getStringExtra(NotificationUtils.KEY_MARMOT_REPLY_TO_INNER_AUTHOR)
runOnRelay(notificationManager, notificationId) {
sendMarmotReply(accountNpub, nostrGroupId, replyToInnerId, replyToInnerAuthor, replyText)
}
}
}
}
@@ -140,6 +159,34 @@ class NotificationReplyReceiver : BroadcastReceiver() {
account.sendNip17PrivateMessage(template)
}
private suspend fun sendMarmotReply(
accountNpub: String,
nostrGroupId: String,
replyToInnerEventId: String?,
replyToInnerAuthor: String?,
replyText: String,
) {
val accountSettings = LocalPreferences.loadAccountConfigFromEncryptedStorage(accountNpub) ?: return
val account = Amethyst.instance.accountsCache.loadAccount(accountSettings)
val manager = account.marmotManager ?: return
// Use id+author from the Intent so the reply is threaded even when
// LocalCache hasn't been rehydrated yet (cold-process broadcast
// receiver: Account.restoreAll runs async on init and may not have
// finished by the time we get here).
val bundle =
manager.buildTextMessage(
nostrGroupId = nostrGroupId,
text = replyText,
replyToEventId = replyToInnerEventId,
replyToAuthorPubKey = replyToInnerAuthor,
persistOwn = false,
)
account.sendMarmotGroupMessage(nostrGroupId, bundle.innerEvent, account.marmotGroupRelays(nostrGroupId))
}
private suspend fun sendPublicReply(
accountNpub: String,
targetEventId: String,

View File

@@ -60,12 +60,16 @@ object NotificationUtils {
const val REPLY_ACTION = "com.vitorpamplona.amethyst.REPLY_ACTION"
const val PUBLIC_REPLY_ACTION = "com.vitorpamplona.amethyst.PUBLIC_REPLY_ACTION"
const val MARMOT_REPLY_ACTION = "com.vitorpamplona.amethyst.MARMOT_REPLY_ACTION"
const val MARK_READ_ACTION = "com.vitorpamplona.amethyst.MARK_READ_ACTION"
const val KEY_REPLY_TEXT = "key_reply_text"
const val KEY_NOTIFICATION_ID = "key_notification_id"
const val KEY_ACCOUNT_NPUB = "key_account_npub"
const val KEY_CHATROOM_MEMBERS = "key_chatroom_members"
const val KEY_TARGET_EVENT_ID = "key_target_event_id"
const val KEY_MARMOT_GROUP_ID = "key_marmot_group_id"
const val KEY_MARMOT_REPLY_TO_INNER_ID = "key_marmot_reply_to_inner_id"
const val KEY_MARMOT_REPLY_TO_INNER_AUTHOR = "key_marmot_reply_to_inner_author"
private const val DM_SUMMARY_ID = 0x10000
private const val ZAP_SUMMARY_ID = 0x20000
@@ -374,6 +378,9 @@ object NotificationUtils {
accountNpub: String? = null,
accountPictureUrl: String? = null,
chatroomMembers: String? = null,
marmotNostrGroupId: String? = null,
marmotReplyToInnerEventId: String? = null,
marmotReplyToInnerAuthor: String? = null,
) {
getOrCreateDMChannel(applicationContext)
val channelId = stringRes(applicationContext, R.string.app_notification_dms_channel_id)
@@ -390,6 +397,9 @@ object NotificationUtils {
accountNpub = accountNpub,
accountPictureUrl = accountPictureUrl,
chatroomMembers = chatroomMembers,
marmotNostrGroupId = marmotNostrGroupId,
marmotReplyToInnerEventId = marmotReplyToInnerEventId,
marmotReplyToInnerAuthor = marmotReplyToInnerAuthor,
)
}
@@ -425,6 +435,9 @@ object NotificationUtils {
accountNpub: String?,
accountPictureUrl: String?,
chatroomMembers: String?,
marmotNostrGroupId: String? = null,
marmotReplyToInnerEventId: String? = null,
marmotReplyToInnerAuthor: String? = null,
) {
val notId = id.hashCode()
@@ -522,6 +535,50 @@ object NotificationUtils {
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
.build()
builder.addAction(replyAction)
} else if (accountNpub != null && marmotNostrGroupId != null) {
// Marmot/MLS Reply action: sends the user's text as an encrypted
// kind:9 inside the Marmot group, replying to the inner event
// that triggered this notification. Mirrors the NIP-17 path
// above but routes through NotificationReplyReceiver's
// MARMOT_REPLY_ACTION branch so we never publish a plaintext
// public reply for an encrypted group message.
val remoteInput =
RemoteInput
.Builder(KEY_REPLY_TEXT)
.setLabel(stringRes(applicationContext, R.string.app_notification_reply_label))
.build()
val replyIntent =
Intent(applicationContext, NotificationReplyReceiver::class.java).apply {
action = MARMOT_REPLY_ACTION
putExtra(KEY_NOTIFICATION_ID, notId)
putExtra(KEY_ACCOUNT_NPUB, accountNpub)
putExtra(KEY_MARMOT_GROUP_ID, marmotNostrGroupId)
if (marmotReplyToInnerEventId != null) {
putExtra(KEY_MARMOT_REPLY_TO_INNER_ID, marmotReplyToInnerEventId)
}
if (marmotReplyToInnerAuthor != null) {
putExtra(KEY_MARMOT_REPLY_TO_INNER_AUTHOR, marmotReplyToInnerAuthor)
}
}
val replyPendingIntent =
PendingIntent.getBroadcast(
applicationContext,
notId,
replyIntent,
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val replyAction =
NotificationCompat.Action
.Builder(R.drawable.amethyst, stringRes(applicationContext, R.string.app_notification_reply_label), replyPendingIntent)
.addRemoteInput(remoteInput)
.setAllowGeneratedReplies(true)
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
.build()
builder.addAction(replyAction)
}

View File

@@ -77,7 +77,7 @@ class SurgeDnsStore(
readRecords(file)
} catch (t: Throwable) {
Log.w(TAG) { "Dropping corrupt DNS cache blob: ${t.message}" }
file.delete()
if (!file.delete()) Log.w(TAG) { "Failed to delete corrupt DNS cache blob at ${file.path}" }
return
}
// restore() uses putIfAbsent and never marks dirty, so we deliberately do NOT clear the
@@ -104,8 +104,8 @@ class SurgeDnsStore(
file.parentFile?.mkdirs()
writeRecords(tmp, records)
if (!tmp.renameTo(file)) {
file.delete()
if (!tmp.renameTo(file)) {
// If delete fails the second rename will fail too; skip straight to the copy fallback.
if (!file.delete() || !tmp.renameTo(file)) {
tmp.copyTo(file, overwrite = true)
}
}
@@ -117,13 +117,13 @@ class SurgeDnsStore(
} finally {
// Cleans up after both happy paths (copyTo fallback) and failure paths (writeRecords
// crashed partway, leaving a partial blob) so a corrupt tmp can't accumulate.
if (tmp.exists()) tmp.delete()
if (tmp.exists() && !tmp.delete()) Log.w(TAG) { "Failed to delete DNS cache tmp file at ${tmp.path}" }
}
}
/** Force-clear the on-disk cache. Useful for diagnostics or when the user wipes data. */
fun clear() {
file.delete()
if (file.exists() && !file.delete()) Log.w(TAG) { "Failed to clear DNS cache blob at ${file.path}" }
}
private fun writeRecords(

View File

@@ -118,6 +118,37 @@ fun ControlWhenPlayerIsActive(
}
}
/**
* Pauses [mediaControllerState] when the host activity leaves the foreground.
*
* Standalone version of the ON_PAUSE arm of [ControlWhenPlayerIsActive] for
* callers (e.g. voice notes) that don't have the visibility mutex / auto-resume
* logic but still need to stop playback when the app backgrounds. Pairs with
* the 30s release timer in [GetVideoController] — pause immediately, release
* the controller after the timeout, reassemble on resume.
*
* Skips the explicit BackgroundMedia (PiP) instance: that one is opted in to
* keep-playing.
*/
@Composable
fun PauseControllerWhenInBackground(mediaControllerState: MediaControllerState) {
val controller = mediaControllerState.controller
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner, mediaControllerState) {
val observer =
LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_PAUSE &&
controller.isPlaying &&
!BackgroundMedia.isMutex(mediaControllerState)
) {
controller.pause()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
}
class PlayerEventListener(
val view: View,
) : Player.Listener {

View File

@@ -21,74 +21,172 @@
package com.vitorpamplona.amethyst.service.playback.composable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.media3.common.Player
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
import com.vitorpamplona.amethyst.service.playback.service.PlaybackServiceClient
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
internal const val BACKGROUND_RELEASE_TIMEOUT_MS = 30_000L
@Composable
fun GetVideoController(
mediaItem: LoadedMediaItem,
muted: Boolean = false,
play: Boolean = false,
// Opt-out for callers whose lifecycle owner is already a background-playback
// surface (PiP): there, the 30s timer would fire as soon as the activity
// enters PiP mode and race the controller build / `RegisterBackgroundMedia`
// registration, killing the just-attached controller and blanking the
// window. The opt-out skips the timer entirely for those callers.
releaseOnBackgroundTimeout: Boolean = true,
inner: @Composable (mediaControllerState: MediaControllerState) -> Unit,
) {
val context = LocalContext.current
val controllerState by remember(mediaItem) {
PlaybackServiceClient
.controllerAsFlow(
videoUri = mediaItem.src.videoUri,
proxyPort = mediaItem.src.proxyPort,
keepPlaying = mediaItem.src.keepPlaying,
context = context,
).onEach { state ->
Log.d("PlaybackService") { "Controller instance: ${state.controller}" }
// The default ExoPlayer volume is 1f and the MediaSessionPool reset lambda
// sets it to 0f when the player is acquired, so the controller arrives at 0f.
// Read first and only push an IPC if the value actually needs to change —
// with several feed videos preloading at once each volume= write was a
// round-trip to the service for nothing.
val targetVolume =
when {
BackgroundMedia.isPlaying() -> 0f
muted -> 0f
else -> 1f
// After the app has been in the background for BACKGROUND_RELEASE_TIMEOUT_MS,
// drop the MediaController so the underlying ExoPlayer + codec/buffer can be
// returned to the pool. On resume the flow is rebuilt, the new session reuses
// the same paused player from the warm pool (keyed by URI), and the onEach
// warm-pool fast path keeps position and buffered data intact.
val keepAlive = remember { mutableStateOf(true) }
val controllerState by remember(mediaItem, keepAlive.value) {
if (keepAlive.value) {
PlaybackServiceClient
.controllerAsFlow(
videoUri = mediaItem.src.videoUri,
proxyPort = mediaItem.src.proxyPort,
keepPlaying = mediaItem.src.keepPlaying,
context = context,
).onEach { state ->
Log.d("PlaybackService") { "Controller instance: ${state.controller}" }
// The default ExoPlayer volume is 1f and the MediaSessionPool reset lambda
// sets it to 0f when the player is acquired, so the controller arrives at 0f.
// Read first and only push an IPC if the value actually needs to change —
// with several feed videos preloading at once each volume= write was a
// round-trip to the service for nothing.
val targetVolume =
when {
BackgroundMedia.isPlaying() -> 0f
muted -> 0f
else -> 1f
}
if (state.controller.volume != targetVolume) {
state.controller.volume = targetVolume
Log.d("PlaybackService") { "OnEach volume=$targetVolume" }
}
if (state.controller.volume != targetVolume) {
state.controller.volume = targetVolume
Log.d("PlaybackService") { "OnEach volume=$targetVolume" }
}
if (play) {
state.controller.playWhenReady = true
}
if (play) {
state.controller.playWhenReady = true
}
// Warm-pool fast path: when the underlying ExoPlayer was retained paused-with-
// buffer for this exact MediaItem, the MediaController's local mirror already
// shows the matching mediaId. Calling setMediaItem in that case would reset the
// player and discard the buffer — exactly what the warm pool exists to avoid.
// We still re-prepare if the player ended up IDLE somehow (e.g. it was demoted
// to cold and resurfaced, or hit an error before we attached).
val targetMediaId = mediaItem.item.mediaId
val needsLoad = state.controller.currentMediaItem?.mediaId != targetMediaId
if (needsLoad) {
state.controller.setMediaItem(mediaItem.item)
state.controller.prepare()
} else if (state.controller.playbackState == Player.STATE_IDLE) {
Log.d("PlaybackService") { "Warm controller in STATE_IDLE — re-preparing" }
state.controller.prepare()
// Warm-pool fast path: when the underlying ExoPlayer was retained paused-with-
// buffer for this exact MediaItem, the MediaController's local mirror already
// shows the matching mediaId. Calling setMediaItem in that case would reset the
// player and discard the buffer — exactly what the warm pool exists to avoid.
// We still re-prepare if the player ended up IDLE somehow (e.g. it was demoted
// to cold and resurfaced, or hit an error before we attached).
val targetMediaId = mediaItem.item.mediaId
val needsLoad = state.controller.currentMediaItem?.mediaId != targetMediaId
if (needsLoad) {
state.controller.setMediaItem(mediaItem.item)
state.controller.prepare()
} else if (state.controller.playbackState == Player.STATE_IDLE) {
Log.d("PlaybackService") { "Warm controller in STATE_IDLE — re-preparing" }
state.controller.prepare()
}
}
}
} else {
flowOf<MediaControllerState?>(null)
}
}.collectAsState(null)
if (releaseOnBackgroundTimeout) {
ReleaseControllerWhenBackgroundedFor(
timeoutMs = BACKGROUND_RELEASE_TIMEOUT_MS,
controllerState = controllerState,
keepAlive = keepAlive,
)
}
controllerState?.let {
inner(it)
}
}
/**
* Flips [keepAlive] to `false` after the host activity has been at ON_PAUSE for
* [timeoutMs], so the gated flow upstream releases the MediaController. ON_RESUME
* cancels any pending timer and flips it back to `true` so the controller is
* reacquired.
*
* The BackgroundMedia (PiP) controller is exempt — it's opted into background
* playback and must keep its MediaController alive past the timeout.
*/
@Composable
private fun ReleaseControllerWhenBackgroundedFor(
timeoutMs: Long,
controllerState: MediaControllerState?,
keepAlive: MutableState<Boolean>,
) {
val lifecycleOwner = LocalLifecycleOwner.current
val currentControllerState by rememberUpdatedState(controllerState)
DisposableEffect(lifecycleOwner, keepAlive) {
val scope = CoroutineScope(Dispatchers.Main)
var timeoutJob: Job? = null
val observer =
LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_PAUSE -> {
timeoutJob?.cancel()
timeoutJob =
scope.launch {
delay(timeoutMs)
val cs = currentControllerState
if (cs == null || !BackgroundMedia.isMutex(cs)) {
keepAlive.value = false
}
}
}
Lifecycle.Event.ON_RESUME -> {
timeoutJob?.cancel()
timeoutJob = null
keepAlive.value = true
}
else -> Unit
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
timeoutJob?.cancel()
lifecycleOwner.lifecycle.removeObserver(observer)
scope.cancel()
}
}
}

View File

@@ -52,7 +52,13 @@ class PipVideoActivity : ComponentActivity() {
val muted = remember(mediaItemData) { DEFAULT_MUTED_SETTING.value }
GetMediaItem(mediaItemData) { mediaItem ->
GetVideoController(mediaItem, muted, true) { controllerState ->
GetVideoController(
mediaItem = mediaItem,
muted = muted,
play = true,
// PiP IS the opt-in for background playback — never release on background.
releaseOnBackgroundTimeout = false,
) { controllerState ->
// PiP window is small, keep bandwidth low by forcing the lowest
// rendition. User can still manually change quality via controls.
ApplyInitialVideoQuality(

View File

@@ -143,11 +143,33 @@ class MediaSessionPool(
reset(mediaSession, keepPlaying)
// Warm-pool fast path acquires a player that still holds its MediaItem, so the
// client side skips setMediaItem (see GetVideoController) and onAddMediaItems
// never fires for this fresh session — leaving the notification's tap target
// unset. Re-bind it from the player's current item so tapping the playback
// notification opens the originating nostr URI.
bindSessionActivity(mediaSession, mediaSession.player.currentMediaItem)
cache.put(mediaSession.id, SessionListener(mediaSession, listener))
return mediaSession
}
fun bindSessionActivity(
session: MediaSession,
mediaItem: MediaItem?,
) {
val callbackUri = mediaItem?.mediaMetadata?.extras?.getString(MediaItemCache.EXTRA_CALLBACK_URI) ?: return
session.setSessionActivity(
PendingIntent.getActivity(
appContext,
0,
Intent(Intent.ACTION_VIEW, callbackUri.toUri(), appContext, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
),
)
}
fun releaseSession(session: MediaSession) {
val listener = playingMap.get(session.id) ?: cache.get(session.id)
if (listener != null) {
@@ -220,16 +242,7 @@ class MediaSessionPool(
mediaItems: List<MediaItem>,
): ListenableFuture<List<MediaItem>> {
// set up return call when clicking on the Notification bar
mediaItems.firstOrNull()?.mediaMetadata?.extras?.getString(MediaItemCache.EXTRA_CALLBACK_URI)?.let {
mediaSession.setSessionActivity(
PendingIntent.getActivity(
appContext,
0,
Intent(Intent.ACTION_VIEW, it.toUri(), appContext, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
),
)
}
pool.bindSessionActivity(mediaSession, mediaItems.firstOrNull())
return Futures.immediateFuture(mediaItems)
}

View File

@@ -20,7 +20,10 @@
*/
package com.vitorpamplona.amethyst.service.playback.service
import android.app.ForegroundServiceStartNotAllowedException
import android.content.Intent
import android.net.Uri
import android.os.Build
import androidx.annotation.OptIn
import androidx.core.net.toUri
import androidx.media3.common.C
@@ -137,6 +140,29 @@ class PlaybackService : MediaSessionService() {
Log.d("PlaybackService", "PlaybackService.onCreate")
}
override fun onStartCommand(
intent: Intent?,
flags: Int,
startId: Int,
): Int =
try {
super.onStartCommand(intent, flags, startId)
} catch (e: IllegalStateException) {
// Media3's MediaSessionService.onStartCommand can call stopSelfSafely() when there is
// no active playback to handle a delivered intent (e.g. a MEDIA_BUTTON from a headset
// arriving while the app is backgrounded). stopSelfSafely() invokes startForeground()
// to detach the foreground notification before stopping, which Android 12+ rejects
// from the background with ForegroundServiceStartNotAllowedException. There is no
// playback to keep alive in this path, so swallow it and stop the service.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && e is ForegroundServiceStartNotAllowedException) {
Log.w("PlaybackService") { "Foreground service start not allowed; stopping PlaybackService" }
stopSelf()
START_NOT_STICKY
} else {
throw e
}
}
override fun onDestroy() {
Log.d("PlaybackService", "PlaybackService.onDestroy")

View File

@@ -55,6 +55,17 @@ abstract class PerUserAndFollowListEoseManager<T, U : Any>(
fun since(key: T) = latestEOSEs.since(user(key), list(key))
/**
* Drops the EOSE state for the (user, list) pair this key resolves to so the next subscription
* assembly will ask the relay from scratch instead of carrying over a stale `since` cursor.
* Use when the in-memory note store may have evicted previously-loaded events (e.g.
* [LargeSoftCache]'s WeakReference store under memory pressure) and a list switch needs to
* re-fetch them rather than rely on a cache that's no longer there.
*/
protected fun clearEoseFor(key: T) {
latestEOSEs.clear(user(key), list(key))
}
fun newEose(
key: T,
relay: NormalizedRelayUrl,

View File

@@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.datasource.ArticlesFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource.BadgesFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile.datasource.ProfileBadgesFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssembler
@@ -60,6 +61,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.datasource.RelayInfo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.datasource.ShortsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.datasource.OnchainZapsFilterAssembler
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.IAuthStatus
@@ -110,6 +112,7 @@ class RelaySubscriptionsCoordinator(
val polls = PollsFilterAssembler(client)
val pictures = PicturesFilterAssembler(client)
val calendars = CalendarsFilterAssembler(client)
val products = ProductsFilterAssembler(client)
val shorts = ShortsFilterAssembler(client)
val publicChats = PublicChatsFilterAssembler(client)
@@ -127,6 +130,9 @@ class RelaySubscriptionsCoordinator(
// active when sending zaps via NWC
val nwc = NWCPaymentFilterAssembler(client)
// active when the wallet's on-chain transactions screen is on top.
val onchainZaps = OnchainZapsFilterAssembler(client)
val all =
listOf(
account,
@@ -137,6 +143,7 @@ class RelaySubscriptionsCoordinator(
discovery,
polls,
pictures,
calendars,
products,
shorts,
publicChats,
@@ -167,6 +174,7 @@ class RelaySubscriptionsCoordinator(
relayInfoNip66,
chess,
nwc,
onchainZaps,
)
fun destroy() = all.forEach { it.destroy() }

View File

@@ -54,6 +54,7 @@ import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
val SummaryKinds =
listOf(
@@ -62,6 +63,7 @@ val SummaryKinds =
RepostEvent.KIND,
GenericRepostEvent.KIND,
LnZapEvent.KIND,
OnchainZapEvent.KIND,
)
val NotificationsPerKeyKinds =

View File

@@ -333,7 +333,10 @@ fun observeNoteReferences(
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note, accountViewModel)
// Subscribe in the LocalCache for changes that arrive in the device
// Subscribe in the LocalCache for changes that arrive in the device.
// On-chain zaps piggyback `note.flow().zaps.stateFlow` because Note.addOnchainZap
// invalidates the same `flowSet.zaps`. If that ever moves to a dedicated flow,
// add it here too — otherwise on-chain-only notes will stop pinging the chevron.
val flow =
remember(note) {
combine(

View File

@@ -43,6 +43,7 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent
import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
import com.vitorpamplona.quartz.utils.mapOfSet
val RepliesAndReactionsKinds =
@@ -53,6 +54,7 @@ val RepliesAndReactionsKinds =
GenericRepostEvent.KIND,
ReportEvent.KIND,
LnZapEvent.KIND,
OnchainZapEvent.KIND,
OtsEvent.KIND,
TextNoteModificationEvent.KIND,
CommentEvent.KIND,

View File

@@ -48,7 +48,7 @@ class UserFinderFilterAssembler(
val group =
listOf(
UserOutboxFinderSubAssembler(client, cache, failureTracker, ::allKeys),
UserWatcherSubAssembler(client, cache, ::allKeys),
UserWatcherSubAssembler(client, cache, failureTracker, ::allKeys),
UserReportsSubAssembler(client, cache, ::allKeys),
UserCardsSubAssembler(client, cache, ::allKeys),
)

View File

@@ -20,12 +20,15 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.loaders
import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList
import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.follows.pickRelaysToLoadUsers
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker
@@ -107,16 +110,46 @@ class UserOutboxFinderSubAssembler(
hasTried,
)
return perRelayKeysBoth.mapNotNull {
val sortedUsers = it.value.sorted()
if (sortedUsers.isNotEmpty()) {
RelayBasedFilter(
relay = it.key,
filter = Filter(kinds = relayListKinds, authors = sortedUsers),
)
} else {
null
val activeFilters =
perRelayKeysBoth.mapNotNull {
val sortedUsers = it.value.sorted()
if (sortedUsers.isNotEmpty()) {
RelayBasedFilter(
relay = it.key,
filter = Filter(kinds = relayListKinds, authors = sortedUsers),
)
} else {
null
}
}
}
// Users that pickRelaysToLoadUsers could not place anywhere this pass:
// every candidate relay tier (outbox/hints/index/search/connected/common)
// is already in hasTried. Without a fallback they'd be silently dropped
// and a late-published kind 10002 would never reach the UI.
val placedPubkeys =
perRelayKeysBoth.values
.asSequence()
.flatten()
.toSet()
val abandonedPubkeys =
noOutboxList.mapNotNullTo(mutableSetOf<HexKey>()) { user ->
user.pubkeyHex.takeIf { it !in placedPubkeys }
}
if (abandonedPubkeys.isEmpty()) return activeFilters
val sortedAbandoned = abandonedPubkeys.sorted()
val fallbackRelays =
(DefaultIndexerRelayList + DefaultSearchRelayList) - failureTracker.cannotConnectRelays
val fallbackFilters =
fallbackRelays.map { relay ->
RelayBasedFilter(
relay = relay,
filter = Filter(kinds = relayListKinds, authors = sortedAbandoned),
)
}
return activeFilters + fallbackFilters
}
}

View File

@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
@@ -43,11 +44,13 @@ val UserMetadataForKeyKinds =
AdvertisedRelayListEvent.KIND,
ChatMessageRelayListEvent.KIND,
KeyPackageRelayListEvent.KIND,
PaymentTargetsEvent.KIND,
)
fun filterUserMetadataForKey(
authors: Set<User>,
indexRelays: Set<NormalizedRelayUrl>,
cannotConnectRelays: Set<NormalizedRelayUrl>,
since: EOSEAccountFast<User>,
): List<RelayBasedFilter> {
val perRelayUsers =
@@ -57,7 +60,7 @@ fun filterUserMetadataForKey(
key.outboxRelays()
?: (key.allUsedRelays() + LocalCache.relayHints.hintsForKey(key.pubkeyHex) + indexRelays)
relays.forEach {
(relays - cannotConnectRelays).forEach {
add(it, key)
}
}

View File

@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinder
import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
@@ -37,6 +38,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils
class UserWatcherSubAssembler(
client: INostrClient,
val cache: LocalCache,
val failureTracker: RelayOfflineTracker,
allKeys: () -> Set<UserFinderQueryState>,
) : BaseEoseManager<UserFinderQueryState>(client, allKeys) {
/**
@@ -99,7 +101,13 @@ class UserWatcherSubAssembler(
)
}
val newFilters = filterUserMetadataForKey(users, indexRelays, latestEOSEs).ifEmpty { null }
val newFilters =
filterUserMetadataForKey(
users,
indexRelays,
failureTracker.cannotConnectRelays,
latestEOSEs,
).ifEmpty { null }
sub.updateFilters(newFilters?.groupByRelay())
}

View File

@@ -51,6 +51,11 @@ open class EOSEByKey<U : Any>(
fun since(listCode: U) = followList[listCode]?.relayList
/** Drops the EOSE state for [listCode] so the next filter assembly starts fresh. */
fun clear(listCode: U) {
followList.remove(listCode)
}
fun newEose(
listCode: U,
relayUrl: NormalizedRelayUrl,
@@ -83,6 +88,19 @@ open class EOSEAccountKey<U : Any>(
users.remove(user)
}
/**
* Drops the EOSE state for the (user, list) pair so the next assembly will ask the relay
* from scratch. Used by feeds that need to force a re-fetch — e.g. calendar / pictures
* where [LargeSoftCache]'s WeakReference store may have evicted the previously-loaded notes
* while the user was viewing a different list.
*/
fun clear(
user: User,
listCode: U,
) {
users[user]?.clear(listCode)
}
fun since(
key: User,
listCode: U,

View File

@@ -122,7 +122,7 @@ object ScheduledPostNotifier {
}
}
private fun ensureChannel(context: Context) {
fun ensureChannel(context: Context) {
if (channel != null) return
channel =
NotificationChannel(

View File

@@ -49,6 +49,8 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.UriParser
import kotlinx.coroutines.CancellationException
@@ -192,7 +194,7 @@ fun uriToRoute(
routeFor(
note = LocalCache.getOrCreateAddressableNote(nip19.address()),
loggedIn = account,
) ?: Route.EventRedirect(nip19.aTag())
) ?: calendarDirectRoute(nip19) ?: Route.EventRedirect(nip19.aTag())
}
is NEmbed -> {
@@ -254,3 +256,19 @@ fun uriToRoute(
return null
}
/**
* Direct route for an `naddr` whose event hasn't arrived in [LocalCache] yet. When a notification
* is tapped (or a `nostr:naddr…` deep link arrives) for a calendar appointment we know is kind
* 31922/31923, route straight to the dedicated detail screen instead of bouncing through
* [Route.EventRedirect]. The detail screen issues its own per-event subscription, so the user
* sees the calendar-specific loading placeholder while the event arrives, not the generic
* redirect screen.
*/
private fun calendarDirectRoute(nip19: NAddress): Route? =
when (nip19.kind) {
CalendarTimeSlotEvent.KIND,
CalendarDateSlotEvent.KIND,
-> Route.CalendarEventDetail(nip19.kind, nip19.author, nip19.dTag)
else -> null
}

View File

@@ -36,12 +36,12 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
@@ -63,7 +63,6 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
@@ -81,6 +80,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.components.BechLink
import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview
import com.vitorpamplona.amethyst.ui.components.OutlinedThinPaddingTextField
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
import com.vitorpamplona.amethyst.ui.note.NoteCompose
@@ -300,8 +300,9 @@ fun EditPostView(
stringRes(id = R.string.lightning_invoice),
stringRes(id = R.string.lightning_create_and_add_invoice),
onNewInvoice = {
postViewModel.message =
TextFieldValue(postViewModel.message.text + "\n\n" + it)
postViewModel.message.setTextAndPlaceCursorAtEnd(
postViewModel.message.text.toString() + "\n\n" + it,
)
postViewModel.wantsInvoice = false
},
onError = { title, message -> accountViewModel.toastManager.toast(title, message) },
@@ -309,43 +310,6 @@ fun EditPostView(
}
}
}
/*
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(vertical = Size5dp, horizontal = Size10dp),
) {
Column {
Text(
text = stringRes(R.string.message_to_author),
fontSize = 18.sp,
fontWeight = FontWeight.W500,
)
HorizontalDivider(thickness = DividerThickness)
MyTextField(
value = postViewModel.subject,
onValueChange = { postViewModel.updateSubject(it) },
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
text = stringRes(R.string.message_to_author_placeholder),
color = MaterialTheme.colorScheme.placeholderText,
)
},
visualTransformation =
UrlUserTagTransformation(
MaterialTheme.colorScheme.primary,
),
colors =
OutlinedTextFieldDefaults.colors(
unfocusedBorderColor = Color.Transparent,
focusedBorderColor = Color.Transparent,
),
)
}
}*/
}
}
@@ -416,9 +380,11 @@ private fun MessageField(postViewModel: EditPostViewModel) {
}
}
OutlinedTextField(
value = postViewModel.message,
onValueChange = { postViewModel.updateMessage(it) },
OutlinedThinPaddingTextField(
state = postViewModel.message,
onTextChanged = { postViewModel.onMessageChanged() },
inputTransformation = MentionPreservingInputTransformation,
outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary),
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
@@ -447,7 +413,6 @@ private fun MessageField(postViewModel: EditPostViewModel) {
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
),
visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
)
}

View File

@@ -21,13 +21,14 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.compose.currentWord
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
@@ -64,8 +65,6 @@ import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent
import com.vitorpamplona.quartz.nip94FileMetadata.size
import com.vitorpamplona.quartz.nip94FileMetadata.thumbhash
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Stable
open class EditPostViewModel : ViewModel() {
@@ -80,7 +79,7 @@ open class EditPostViewModel : ViewModel() {
var nip95attachments by
mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(emptyList())
var message by mutableStateOf(TextFieldValue(""))
val message = TextFieldState()
var urlPreview by mutableStateOf<String?>(null)
val mediaUploadTracker = MediaUploadTracker()
val isUploadingImage: Boolean get() = mediaUploadTracker.isUploadingImage
@@ -114,7 +113,7 @@ open class EditPostViewModel : ViewModel() {
canAddInvoice = accountViewModel.userProfile().lnAddress() != null
multiOrchestrator = null
message = TextFieldValue(versionLookingAt?.event?.content ?: edit.event?.content ?: "")
message.setTextAndPlaceCursorAtEnd(versionLookingAt?.event?.content ?: edit.event?.content ?: "")
urlPreview = findUrlInMessage()
this.editedFromNote = edit
@@ -144,7 +143,7 @@ open class EditPostViewModel : ViewModel() {
}
account.sendEdit(
message = message.text,
message = message.text.toString(),
originalNote = editedFromNote!!,
notify = notify,
summary = subject.text.ifBlank { null },
@@ -186,86 +185,88 @@ open class EditPostViewModel : ViewModel() {
context: Context,
stripMetadata: Boolean = true,
) {
viewModelScope.launch(Dispatchers.IO) {
accountViewModel.launchSigner {
val myAccount = account
val myMultiOrchestrator = multiOrchestrator ?: return@launch
val myMultiOrchestrator = multiOrchestrator ?: return@launchSigner
mediaUploadTracker.startUpload(myMultiOrchestrator.hasNonMedia())
val results =
myMultiOrchestrator.upload(
alt,
if (sensitiveContent) "" else null,
MediaCompressor.intToCompressorQuality(mediaQuality),
server,
myAccount,
context,
useH265Codec,
stripMetadata,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
)
try {
val results =
myMultiOrchestrator.upload(
alt,
if (sensitiveContent) "" else null,
MediaCompressor.intToCompressorQuality(mediaQuality),
server,
myAccount,
context,
useH265Codec,
stripMetadata,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
)
if (results.allGood) {
val urls =
results.successful.mapNotNull { state ->
if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
val nip95 =
myAccount.createNip95(
byteArray = state.result.bytes,
headerInfo = state.result.fileHeader,
alt = alt,
contentWarningReason = if (sensitiveContent) "" else null,
)
nip95attachments = nip95attachments + nip95
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
if (results.allGood) {
val urls =
results.successful.mapNotNull { state ->
if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
val nip95 =
myAccount.createNip95(
byteArray = state.result.bytes,
headerInfo = state.result.fileHeader,
alt = alt,
contentWarningReason = if (sensitiveContent) "" else null,
)
nip95attachments = nip95attachments + nip95
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
note?.let {
"nostr:" + it.toNEvent()
note?.let {
"nostr:" + it.toNEvent()
}
} else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
val iMeta =
IMetaTagBuilder(state.result.url)
.apply {
hash(state.result.fileHeader.hash)
size(state.result.fileHeader.size)
state.result.fileHeader.mimeType
?.let { mimeType(it) }
state.result.fileHeader.dim
?.let { dims(it) }
state.result.fileHeader.blurHash
?.let { blurhash(it.blurhash) }
state.result.fileHeader.thumbHash
?.let { thumbhash(it.thumbhash) }
state.result.magnet?.let { magnet(it) }
state.result.uploadedHash?.let { originalHash(it) }
alt?.let { alt(it) }
if (sensitiveContent) sensitiveContent("")
}.build()
iMetaAttachments = iMetaAttachments.filter { it.url != iMeta.url } + iMeta
state.result.url
} else {
null
}
} else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
val iMeta =
IMetaTagBuilder(state.result.url)
.apply {
hash(state.result.fileHeader.hash)
size(state.result.fileHeader.size)
state.result.fileHeader.mimeType
?.let { mimeType(it) }
state.result.fileHeader.dim
?.let { dims(it) }
state.result.fileHeader.blurHash
?.let { blurhash(it.blurhash) }
state.result.fileHeader.thumbHash
?.let { thumbhash(it.thumbhash) }
state.result.magnet?.let { magnet(it) }
state.result.uploadedHash?.let { originalHash(it) }
alt?.let { alt(it) }
if (sensitiveContent) sensitiveContent("")
}.build()
iMetaAttachments = iMetaAttachments.filter { it.url != iMeta.url } + iMeta
state.result.url
} else {
null
}
}
message = message.insertUrlAtCursor(urls.joinToString(" "))
urlPreview = findUrlInMessage()
message.insertUrlAtCursor(urls.joinToString(" "))
urlPreview = findUrlInMessage()
this@EditPostViewModel.multiOrchestrator = null
} else {
val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct()
this@EditPostViewModel.multiOrchestrator = null
} else {
val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct()
onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n"))
onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n"))
}
} finally {
mediaUploadTracker.finishUpload()
}
mediaUploadTracker.finishUpload()
}
}
open fun cancel() {
message = TextFieldValue("")
message.setTextAndPlaceCursorAtEnd("")
subject = TextFieldValue("")
editedFromNote = null
@@ -281,17 +282,16 @@ open class EditPostViewModel : ViewModel() {
}
open fun findUrlInMessage(): String? =
message.text.split('\n').firstNotNullOfOrNull { paragraph ->
message.text.toString().split('\n').firstNotNullOfOrNull { paragraph ->
paragraph.split(' ').firstOrNull { word: String ->
RichTextParser.isValidURL(word) || RichTextParser.isUrlWithoutScheme(word)
}
}
open fun updateMessage(it: TextFieldValue) {
message = it
open fun onMessageChanged() {
urlPreview = findUrlInMessage()
if (it.selection.collapsed) {
if (message.selection.collapsed) {
val lastWord = message.currentWord()
if (lastWord.startsWith("@")) {
userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE
@@ -306,7 +306,7 @@ open class EditPostViewModel : ViewModel() {
open fun autocompleteWithUser(item: User) {
userSuggestions?.let { userSuggestions ->
val lastWord = message.currentWord()
message = userSuggestions.replaceCurrentWord(message, lastWord, item)
userSuggestions.replaceCurrentWord(message, lastWord, item)
userSuggestionsMainMessage = null
userSuggestions.reset()

View File

@@ -205,7 +205,7 @@ object MediaSaverToDisk {
contentSource: BufferedSource,
contentResolver: ContentResolver,
) {
val cleanMimeType = contentType.substringBefore(";").trim()
val cleanMimeType = normalizeMimeTypeForMediaStore(contentType.substringBefore(";").trim())
val (masterUri, baseDir) =
when {
@@ -271,6 +271,15 @@ object MediaSaverToDisk {
private fun trimInlineMetaData(url: String): String = url.substringBefore("#")
// Android's MediaStore only accepts a fixed allow-list of MIME types.
// MimeTypeMap returns variants like video/x-m4v that MediaProvider rejects,
// so map them to the closest supported equivalent.
internal fun normalizeMimeTypeForMediaStore(mimeType: String): String =
when (mimeType.lowercase()) {
"video/x-m4v" -> "video/mp4"
else -> mimeType
}
private const val AMETHYST_SUBDIRECTORY = "Amethyst"
private const val PDF_MIME_TYPE = "application/pdf"
}

View File

@@ -39,6 +39,7 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import kotlinx.collections.immutable.ImmutableList
@@ -90,10 +91,11 @@ open class NewMediaModel : ViewModel() {
fun upload(
context: Context,
accountViewModel: AccountViewModel,
onSucess: () -> Unit,
onError: (String, String) -> Unit,
) = try {
uploadUnsafe(context, onSucess, onError)
uploadUnsafe(context, accountViewModel, onSucess, onError)
} catch (e: SignerExceptions.ReadOnlyException) {
onError(
stringRes(context, R.string.read_only_user),
@@ -103,6 +105,7 @@ open class NewMediaModel : ViewModel() {
fun uploadUnsafe(
context: Context,
accountViewModel: AccountViewModel,
onSucess: () -> Unit,
onError: (String, String) -> Unit,
) {
@@ -155,10 +158,13 @@ open class NewMediaModel : ViewModel() {
}
}.toMap()
// Sign + publish via launchSigner so SignerExceptions surface
// through the standard toastManager pipeline (and timed-out /
// rejected prompts get logged instead of crashing the process).
val nip95jobs =
nip95s.map {
// upload each file as an individual nip95 event.
viewModelScope.launch(Dispatchers.IO) {
accountViewModel.launchSigner {
val nip95 = myAccount.createNip95(it.bytes, headerInfo = it.fileHeader, caption, if (sensitiveContent) "" else null)
myAccount.consumeAndSendNip95(nip95.first, nip95.second)
}
@@ -166,9 +172,8 @@ open class NewMediaModel : ViewModel() {
val videoJobs =
videosAndOthers.map {
// upload each file as an individual nip95 event.
viewModelScope.launch(Dispatchers.IO) {
account?.sendHeader(
accountViewModel.launchSigner {
myAccount.sendHeader(
url = it.url,
magnetUri = it.magnet,
headerInfo = it.fileHeader,
@@ -182,8 +187,8 @@ open class NewMediaModel : ViewModel() {
val imageJobs =
if (imageUrls.isNotEmpty()) {
listOf(
viewModelScope.launch(Dispatchers.IO) {
account?.sendAllAsOnePictureEvent(
accountViewModel.launchSigner {
myAccount.sendAllAsOnePictureEvent(
urlHeaderInfo = imageUrls,
caption = caption,
contentWarningReason = if (sensitiveContent) "" else null,

View File

@@ -110,7 +110,7 @@ fun NewMediaView(
onClose()
},
onPost = {
postViewModel.upload(context, onClose, accountViewModel.toastManager::toast)
postViewModel.upload(context, accountViewModel, onClose, accountViewModel.toastManager::toast)
postViewModel.selectedServer?.let {
account.settings.changeDefaultFileServer(it)
}

View File

@@ -271,6 +271,38 @@ fun NewUserMetadataScreen(
singleLine = true,
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = stringRes(R.string.lightning_address)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.lnAddress.value,
onValueChange = { postViewModel.lnAddress.value = it },
placeholder = {
Text(
text = "me@mylightningnode.com",
color = MaterialTheme.colorScheme.placeholderText,
)
},
singleLine = true,
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = stringRes(R.string.lnurl)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.lnURL.value,
onValueChange = { postViewModel.lnURL.value = it },
placeholder = {
Text(
text = "LNURL1…",
color = MaterialTheme.colorScheme.placeholderText,
)
},
singleLine = true,
)
// -- Social Proofs --
ExpandableSection(
title = stringRes(R.string.social_proof),

View File

@@ -57,6 +57,8 @@ class NewUserMetadataViewModel : ViewModel() {
val website = mutableStateOf("")
val pronouns = mutableStateOf("")
val nip05 = mutableStateOf("")
val lnAddress = mutableStateOf("")
val lnURL = mutableStateOf("")
val twitter = mutableStateOf("")
val github = mutableStateOf("")
@@ -80,6 +82,8 @@ class NewUserMetadataViewModel : ViewModel() {
website.value = it.info.website ?: ""
pronouns.value = it.info.pronouns ?: ""
nip05.value = it.info.nip05 ?: ""
lnAddress.value = it.info.lud16 ?: ""
lnURL.value = it.info.lud06 ?: ""
}
twitter.value = ""
@@ -117,6 +121,8 @@ class NewUserMetadataViewModel : ViewModel() {
pronouns = pronouns.value,
about = about.value,
nip05 = nip05.value,
lnAddress = lnAddress.value,
lnURL = lnURL.value,
)
val identities =
@@ -140,6 +146,8 @@ class NewUserMetadataViewModel : ViewModel() {
banner.value = ""
website.value = ""
nip05.value = ""
lnAddress.value = ""
lnURL.value = ""
twitter.value = ""
github.value = ""
mastodon.value = ""

View File

@@ -1,229 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions
import android.util.Patterns
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextDecoration
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import kotlin.coroutines.cancellation.CancellationException
data class RangesChanges(
val original: TextRange,
val modified: TextRange,
)
class UrlUserTagTransformation(
val color: Color,
) : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText = buildAnnotatedStringWithUrlHighlighting(text, color)
}
fun buildAnnotatedStringWithUrlHighlighting(
text: AnnotatedString,
color: Color,
): TransformedText {
val substitutions = mutableListOf<RangesChanges>()
val newText =
buildAnnotatedString {
val builderBefore = StringBuilder() // important to correctly measure Tag start and end
val builderAfter = StringBuilder() // important to correctly measure Tag start and end
append(
text.text.split('\n').joinToString("\n") { paragraph: String ->
paragraph.split(' ').joinToString(" ") { word: String ->
try {
val mention = parseUserMention(word)
if (mention != null) {
val (keyPortion, restOfWord, hexKey) = mention
val startIndex = builderBefore.toString().length
builderBefore.append(
"$keyPortion$restOfWord ",
) // accounts for the \n at the end of each paragraph
val endIndex = startIndex + keyPortion.length
val user = LocalCache.getOrCreateUser(hexKey)
val newWord = "@${user.toBestDisplayName()}"
val startNew = builderAfter.toString().length
builderAfter.append(
"$newWord$restOfWord ",
) // accounts for the \n at the end of each paragraph
substitutions.add(
RangesChanges(
TextRange(startIndex, endIndex),
TextRange(startNew, startNew + newWord.length),
),
)
newWord + restOfWord
} else if (Patterns.WEB_URL.matcher(word).matches()) {
val startIndex = builderBefore.toString().length
val endIndex = startIndex + word.length
val startNew = builderAfter.toString().length
val endNew = startNew + word.length
substitutions.add(
RangesChanges(
TextRange(startIndex, endIndex),
TextRange(startNew, endNew),
),
)
builderBefore.append("$word ")
builderAfter.append("$word ")
word
} else {
builderBefore.append("$word ")
builderAfter.append("$word ")
word
}
} catch (e: Exception) {
if (e is CancellationException) throw e
// if it can't parse the key, don't try to change.
builderBefore.append("$word ")
builderAfter.append("$word ")
word
}
}
},
)
substitutions.forEach {
addStyle(
style =
SpanStyle(
color = color,
textDecoration = TextDecoration.None,
),
start = it.modified.start,
end = it.modified.end,
)
}
}
val numberOffsetTranslator =
object : OffsetMapping {
// Treat each substitution as an atomic wedge: any cursor position that falls
// strictly inside a substituted range snaps to the wedge's trailing edge.
// Without this, an IME (e.g. SwiftKey in extracted-text mode) can place the
// cursor in the middle of an "@npub1..." mention, and a subsequent backspace
// deletes a char from inside the bech32, breaking the npub and "expanding"
// the collapsed mention.
override fun originalToTransformed(offset: Int): Int {
val inInsideRange =
substitutions.firstOrNull { offset > it.original.start && offset < it.original.end }
if (inInsideRange != null) {
return inInsideRange.modified.end
}
val lastRangeThrough = substitutions.lastOrNull { offset >= it.original.end }
return if (lastRangeThrough != null) {
lastRangeThrough.modified.end + (offset - lastRangeThrough.original.end)
} else {
offset
}
}
override fun transformedToOriginal(offset: Int): Int {
val inInsideRange =
substitutions.firstOrNull { offset > it.modified.start && offset < it.modified.end }
if (inInsideRange != null) {
return inInsideRange.original.end
}
val lastRangeThrough = substitutions.lastOrNull { offset >= it.modified.end }
return if (lastRangeThrough != null) {
lastRangeThrough.original.end + (offset - lastRangeThrough.modified.end)
} else {
offset
}
}
}
return TransformedText(
newText,
numberOffsetTranslator,
)
}
private data class UserMention(
val keyPortion: String,
val restOfWord: String,
val hexKey: String,
)
private fun parseUserMention(word: String): UserMention? {
var key = word
val prefix: String
if (key.startsWith("nostr:", true)) {
prefix = key.substring(0, 6)
key = key.substring(6)
} else if (key.startsWith("@")) {
prefix = "@"
key = key.substring(1)
} else {
return null
}
if (key.startsWith("npub1", true) && key.length >= 63) {
val keyB32 = key.substring(0, 63)
val restOfWord = key.substring(63)
val hex = decodePublicKeyAsHexOrNull(keyB32) ?: return null
return UserMention("$prefix$keyB32", restOfWord, hex)
} else if (key.startsWith("nprofile1", true)) {
val parsed = Nip19Parser.uriToRoute(key) ?: return null
val entity = parsed.entity
if (entity !is NProfile && entity !is NPub) return null
val hex =
when (entity) {
is NProfile -> entity.hex
is NPub -> entity.hex
else -> return null
}
val bech32Len = parsed.nip19raw.length
val restOfWord = key.substring(bech32Len)
return UserMention("$prefix${parsed.nip19raw}", restOfWord, hex)
}
return null
}

View File

@@ -38,6 +38,7 @@ enum class ServerType {
val DEFAULT_MEDIA_SERVERS: List<ServerName> =
listOf(
ServerName("Nostr.Build", "https://blossom.band/", ServerType.Blossom),
ServerName("Nostrcheck.me", "https://cdn.nostrcheck.me", ServerType.Blossom),
ServerName("24242.io", "https://24242.io/", ServerType.Blossom),
ServerName("Azzamo", "https://blossom.azzamo.media", ServerType.Blossom),
ServerName("YakiHonne", "https://blossom.yakihonne.com/", ServerType.Blossom),

View File

@@ -22,13 +22,24 @@ package com.vitorpamplona.amethyst.ui.call
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
fun hasPermission(
context: Context,
@@ -55,6 +66,17 @@ fun buildCallPermissions(isVideo: Boolean): Array<String> {
return permissions.toTypedArray()
}
fun openAppSettings(context: Context) {
runCatching {
context.startActivity(
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", context.packageName, null),
).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) },
)
}
}
@Composable
fun rememberCallWithPermission(
context: Context,
@@ -62,23 +84,50 @@ fun rememberCallWithPermission(
onCall: () -> Unit,
): () -> Unit {
val permissions = remember(isVideo) { buildCallPermissions(isVideo) }
var showDeniedDialog by remember { mutableStateOf(false) }
val launcher =
rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions(),
) { _ ->
// Bluetooth is optional — proceed if core permissions are granted
if (hasCallPermissions(context, isVideo)) onCall()
// Bluetooth is optional — proceed if core permissions are granted.
// If core permissions are still denied (including the silent
// permanently-denied case where Android skips the dialog), surface
// the deep-link dialog instead of failing silently.
if (hasCallPermissions(context, isVideo)) {
onCall()
} else {
showDeniedDialog = true
}
}
val bluetoothLauncher =
rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions(),
) { _ ->
// BT result is best-effort and never blocks the call.
}
if (showDeniedDialog) {
CallPermissionDeniedDialog(
isVideo = isVideo,
onDismiss = { showDeniedDialog = false },
onOpenSettings = {
showDeniedDialog = false
openAppSettings(context)
},
)
}
return remember(onCall, isVideo) {
{
if (hasCallPermissions(context, isVideo)) {
// Core permissions granted; still request BT if missing
// Core permissions granted; request BT separately so the
// result callback doesn't double-fire onCall().
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
!hasPermission(context, Manifest.permission.BLUETOOTH_CONNECT)
) {
launcher.launch(arrayOf(Manifest.permission.BLUETOOTH_CONNECT))
bluetoothLauncher.launch(arrayOf(Manifest.permission.BLUETOOTH_CONNECT))
}
onCall()
} else {
@@ -87,3 +136,36 @@ fun rememberCallWithPermission(
}
}
}
@Composable
private fun CallPermissionDeniedDialog(
isVideo: Boolean,
onDismiss: () -> Unit,
onOpenSettings: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringRes(R.string.call_permission_denied_title)) },
text = {
Text(
stringRes(
if (isVideo) {
R.string.call_permission_denied_video
} else {
R.string.call_permission_denied_voice
},
),
)
},
confirmButton = {
TextButton(onClick = onOpenSettings) {
Text(stringRes(R.string.call_permission_denied_open_settings))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringRes(R.string.call_permission_denied_cancel))
}
},
)
}

View File

@@ -0,0 +1,88 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.richtext.NowhereLinkSegment
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
// Maps the first path segment of a nowhere URL to a localized label. The nowhere project ships
// eight tools (event, fundraiser, store, petition, message, drop, art, forum) and the URL path
// uses the first letter as a discriminator (e.g. nowhr.xyz/s#... is a store). Unknown codes
// fall through to the generic "Nowhere site" label.
private val nowhereToolLabels =
mapOf(
"e" to R.string.nowhere_link_card_event,
"f" to R.string.nowhere_link_card_fundraiser,
"s" to R.string.nowhere_link_card_store,
"p" to R.string.nowhere_link_card_petition,
"m" to R.string.nowhere_link_card_message,
"d" to R.string.nowhere_link_card_drop,
"a" to R.string.nowhere_link_card_art,
"fo" to R.string.nowhere_link_card_forum,
)
@Composable
fun NowhereLinkCard(segment: NowhereLinkSegment) {
val uri = LocalUriHandler.current
val titleRes = segment.tool?.lowercase()?.let { nowhereToolLabels[it] } ?: R.string.nowhere_link_card_generic
Column(
modifier =
MaterialTheme.colorScheme.innerPostModifier
.clickable {
runCatching {
val target = if (segment.segmentText.contains("://")) segment.segmentText else "https://${segment.segmentText}"
uri.openUri(target)
}
}.padding(12.dp),
) {
Text(
text = stringRes(titleRes),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
)
Spacer(modifier = Modifier.padding(top = 2.dp))
Text(
text = segment.host,
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}

View File

@@ -0,0 +1,177 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.InputTransformation
import androidx.compose.foundation.text.input.OutputTransformation
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.ui.theme.placeholderText
/**
* Outlined sibling of [ThinPaddingTextField]. Wraps the new [TextFieldState] API
* with [OutlinedTextFieldDefaults.DecorationBox] so callers that want the
* Material3 outlined look (visible rounded border, transparent inside, notched
* label) can use the same `outputTransformation` / `inputTransformation`
* mention pipeline as the filled `ThinPaddingTextField`.
*/
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun OutlinedThinPaddingTextField(
state: TextFieldState,
modifier: Modifier = Modifier,
onTextChanged: (() -> Unit)? = null,
enabled: Boolean = true,
readOnly: Boolean = false,
textStyle: TextStyle = LocalTextStyle.current,
inputTransformation: InputTransformation? = null,
outputTransformation: OutputTransformation? = null,
label: @Composable (() -> Unit)? = null,
placeholder: @Composable (() -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
prefix: @Composable (() -> Unit)? = null,
suffix: @Composable (() -> Unit)? = null,
supportingText: @Composable (() -> Unit)? = null,
isError: Boolean = false,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
singleLine: Boolean = false,
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
minLines: Int = 1,
interactionSource: MutableInteractionSource? = null,
shape: Shape = OutlinedTextFieldDefaults.shape,
colors: TextFieldColors = OutlinedTextFieldDefaults.colors(),
contentPadding: PaddingValues =
OutlinedTextFieldDefaults.contentPadding(
start = 10.dp,
top = 12.dp,
end = 10.dp,
bottom = 12.dp,
),
) {
@Suppress("NAME_SHADOWING")
val interactionSource = interactionSource ?: remember { MutableInteractionSource() }
if (onTextChanged != null) {
val callback by rememberUpdatedState(onTextChanged)
LaunchedEffect(state) {
snapshotFlow { state.text }
.collect { callback() }
}
}
val textColor =
textStyle.color.takeOrElse {
val focused by interactionSource.collectIsFocusedAsState()
when {
!enabled -> MaterialTheme.colorScheme.placeholderText
isError -> MaterialTheme.colorScheme.onSurface
focused -> MaterialTheme.colorScheme.onSurface
else -> MaterialTheme.colorScheme.onSurface
}
}
val mergedTextStyle = textStyle.merge(TextStyle(color = textColor))
val lineLimits =
if (singleLine) {
TextFieldLineLimits.SingleLine
} else {
TextFieldLineLimits.MultiLine(minLines, maxLines)
}
CompositionLocalProvider(LocalTextSelectionColors provides colors.textSelectionColors) {
BasicTextField(
state = state,
modifier =
modifier
.defaultMinSize(
minWidth = TextFieldDefaults.MinWidth,
minHeight = 36.dp,
),
enabled = enabled,
readOnly = readOnly,
textStyle = mergedTextStyle,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
keyboardOptions = keyboardOptions,
lineLimits = lineLimits,
interactionSource = interactionSource,
inputTransformation = inputTransformation,
outputTransformation = outputTransformation,
decorator = { innerTextField ->
OutlinedTextFieldDefaults.DecorationBox(
value = state.text.toString(),
visualTransformation = VisualTransformation.None,
innerTextField = innerTextField,
placeholder = placeholder,
label = label,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
prefix = prefix,
suffix = suffix,
supportingText = supportingText,
singleLine = singleLine,
enabled = enabled,
isError = isError,
interactionSource = interactionSource,
colors = colors,
contentPadding = contentPadding,
container = {
OutlinedTextFieldDefaults.Container(
enabled = enabled,
isError = isError,
interactionSource = interactionSource,
colors = colors,
shape = shape,
)
},
)
},
)
}
}

View File

@@ -44,6 +44,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@@ -59,11 +60,13 @@ import com.vitorpamplona.amethyst.ui.note.ZapIcon
import com.vitorpamplona.amethyst.ui.note.ZappedIcon
import com.vitorpamplona.amethyst.ui.note.payViaIntent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.OnchainZapSendDialog
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ModifierWidth3dp
import com.vitorpamplona.amethyst.ui.theme.Size14Modifier
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -104,6 +107,12 @@ fun ReusableZapButton(
callbacks: ZapButtonCallbacks = ZapButtonCallbacks(),
) {
var wantsToZap by remember { mutableStateOf<ImmutableList<Long>?>(null) }
var onchainZapAmount by remember { mutableStateOf<Long?>(null) }
var showOnchainDialog by remember { mutableStateOf(false) }
val onchainZapAmountChoices by
accountViewModel.account.settings.syncedSettings.zaps.onchainZapAmountChoices
.collectAsStateWithLifecycle()
// Makes sure the user is loaded to get his ln address ahead of time (for DVM buttons)
if (config.showUserFinderSubscription) {
@@ -192,6 +201,26 @@ fun ReusableZapButton(
nav.nav(Route.ManualZapSplitPayment(uid))
}
},
onchainZapAmountChoices = onchainZapAmountChoices,
onOnchainAmount = { amount ->
wantsToZap = null
onchainZapAmount = amount
showOnchainDialog = true
},
)
}
if (showOnchainDialog) {
val zappedEventHint = remember(baseNote) { baseNote.toEventHint<Event>() }
OnchainZapSendDialog(
accountViewModel = accountViewModel,
onDismiss = {
showOnchainDialog = false
onchainZapAmount = null
},
recipientPubKey = baseNote.author?.pubkeyHex,
zappedEvent = zappedEventHint,
prefillAmountSats = onchainZapAmount,
)
}

View File

@@ -80,6 +80,7 @@ import com.vitorpamplona.amethyst.commons.richtext.ImageGalleryParagraph
import com.vitorpamplona.amethyst.commons.richtext.ImageSegment
import com.vitorpamplona.amethyst.commons.richtext.InvoiceSegment
import com.vitorpamplona.amethyst.commons.richtext.LinkSegment
import com.vitorpamplona.amethyst.commons.richtext.NowhereLinkSegment
import com.vitorpamplona.amethyst.commons.richtext.ParagraphState
import com.vitorpamplona.amethyst.commons.richtext.PdfSegment
import com.vitorpamplona.amethyst.commons.richtext.PhoneSegment
@@ -481,6 +482,8 @@ private fun RenderWordWithoutPreview(
is LinkSegment -> ClickableUrl(word.segmentText, word.segmentText)
is NowhereLinkSegment -> ClickableUrl(word.segmentText, word.segmentText)
is EmojiSegment -> RenderCustomEmoji(word.segmentText, state)
// Don't offer to pay invoices
@@ -530,6 +533,7 @@ private fun RenderWordWithPreview(
is VideoSegment -> ZoomableContentView(word.segmentText, state, accountViewModel)
is PdfSegment -> ZoomableContentView(word.segmentText, state, accountViewModel)
is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, callbackUri, accountViewModel)
is NowhereLinkSegment -> NowhereLinkCard(word)
is EmojiSegment -> RenderCustomEmoji(word.segmentText, state)
is InvoiceSegment -> MayBeInvoicePreview(word.segmentText, accountViewModel)
is WithdrawSegment -> MayBeWithdrawal(word.segmentText, accountViewModel)

View File

@@ -109,6 +109,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import net.engawapg.lib.zoomable.ZoomState
import net.engawapg.lib.zoomable.rememberZoomState
import net.engawapg.lib.zoomable.zoomable
@@ -129,6 +130,11 @@ fun ZoomableImageDialog(
// viewport — aligns with the tapped thumbnail at progress = 0.
var imageBounds by remember { mutableStateOf<Rect?>(null) }
// ZoomState of the currently-visible image, hoisted from RenderImageOrVideo so the
// grow/shrink animation can read live scale/offset and start the exit from the
// image's actual on-screen bounds when the user has zoomed in.
var currentZoomState by remember { mutableStateOf<ZoomState?>(null) }
// Start the enter animation as soon as valid image bounds are available. Without
// this gate, the animation can begin before onGloballyPositioned has reported real
// bounds and the graphicsLayer falls back to its alpha-only branch.
@@ -209,6 +215,8 @@ fun ZoomableImageDialog(
sourceBounds = sourceBounds,
imageBounds = imageBounds,
onImageBoundsChanged = updateImageBounds,
currentZoomState = currentZoomState,
onZoomStateChanged = { currentZoomState = it },
progress = progressProvider,
onDismiss = dismissWithAnimation,
accountViewModel = accountViewModel,
@@ -225,6 +233,8 @@ private fun DialogContent(
sourceBounds: Rect?,
imageBounds: Rect?,
onImageBoundsChanged: (Rect) -> Unit,
currentZoomState: ZoomState?,
onZoomStateChanged: (ZoomState) -> Unit,
progress: () -> Float,
onDismiss: () -> Unit,
accountViewModel: AccountViewModel,
@@ -281,18 +291,32 @@ private fun DialogContent(
src.width > 0f && src.height > 0f &&
img.width > 0f && img.height > 0f
) {
// Account for user-applied zoom: the image is rendered at
// imageBounds transformed by the inner zoomable (uniform scale
// around the layout center, then offset). The exit animation
// must start from those visible bounds, not the unzoomed layout
// bounds — otherwise dismissing a zoomed-in image jumps.
val zoom = currentZoomState
val zScale = zoom?.scale ?: 1f
val zOffX = zoom?.offsetX ?: 0f
val zOffY = zoom?.offsetY ?: 0f
val imgCenter = img.center
val zoomedWidth = img.width * zScale
val zoomedHeight = img.height * zScale
val zoomedCenterX = imgCenter.x + zOffX
val zoomedCenterY = imgCenter.y + zOffY
transformOrigin = TransformOrigin(0f, 0f)
// Uniform scale so non-square images keep their aspect ratio during
// the grow animation. max() so the image covers the source rect in
// at least one dimension; the other overflows centered on the tap.
val startScale = maxOf(src.width / img.width, src.height / img.height)
val startScale = maxOf(src.width / zoomedWidth, src.height / zoomedHeight)
val srcCenter = src.center
val imgCenter = img.center
val p = progress()
scaleX = lerp(startScale, 1f, p)
scaleY = lerp(startScale, 1f, p)
translationX = lerp(srcCenter.x - startScale * imgCenter.x, 0f, p)
translationY = lerp(srcCenter.y - startScale * imgCenter.y, 0f, p)
translationX = lerp(srcCenter.x - startScale * zoomedCenterX, 0f, p)
translationY = lerp(srcCenter.y - startScale * zoomedCenterY, 0f, p)
} else {
// No source bounds: fall back to a plain fade.
alpha = progress()
@@ -305,6 +329,7 @@ private fun DialogContent(
) { index ->
allImages.getOrNull(index)?.let { pageContent ->
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
val isCurrent = index == pagerState.currentPage
RenderImageOrVideo(
content = pageContent,
roundedCorner = false,
@@ -312,11 +337,9 @@ private fun DialogContent(
controllerVisible = controllerVisible,
accountViewModel = accountViewModel,
onContentBoundsChanged =
if (index == pagerState.currentPage) {
onImageBoundsChanged
} else {
null
},
if (isCurrent) onImageBoundsChanged else null,
onZoomStateChanged =
if (isCurrent) onZoomStateChanged else null,
)
}
}
@@ -330,6 +353,7 @@ private fun DialogContent(
controllerVisible = controllerVisible,
accountViewModel = accountViewModel,
onContentBoundsChanged = onImageBoundsChanged,
onZoomStateChanged = onZoomStateChanged,
)
}
}
@@ -517,6 +541,7 @@ private fun RenderImageOrVideo(
controllerVisible: MutableState<Boolean>,
accountViewModel: AccountViewModel,
onContentBoundsChanged: ((Rect) -> Unit)? = null,
onZoomStateChanged: ((ZoomState) -> Unit)? = null,
) {
val contentScale =
if (isFiniteHeight) {
@@ -534,6 +559,13 @@ private fun RenderImageOrVideo(
Modifier.fillMaxWidth()
}
// Hoist ZoomState so the parent dialog can read live scale/offset and start the
// exit animation from the image's actual on-screen bounds when zoomed in.
val zoomState = rememberZoomState()
LaunchedEffect(zoomState, onZoomStateChanged) {
onZoomStateChanged?.invoke(zoomState)
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, modifier = rowModifier) {
when (content) {
is MediaUrlImage -> {
@@ -541,7 +573,7 @@ private fun RenderImageOrVideo(
Modifier
.fillMaxWidth()
.zoomable(
rememberZoomState(),
zoomState,
onTap = {
controllerVisible.value = !controllerVisible.value
},
@@ -606,7 +638,7 @@ private fun RenderImageOrVideo(
Modifier
.fillMaxWidth()
.zoomable(
rememberZoomState(),
zoomState,
onTap = {
controllerVisible.value = !controllerVisible.value
},

View File

@@ -0,0 +1,328 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.components.namecoin
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin.NamecoinResolveState
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinResolveOutcome
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
/**
* Translate a [NamecoinResolveOutcome] from the quartz resolver into the
* shared [NamecoinResolveState] used elsewhere in the app (e.g. the
* desktop `SearchScreen`'s inline Namecoin lookup row).
*
* Mirrors the wording desktop already ships so the same identifier
* produces the same diagnostic string regardless of which surface
* triggered the lookup. Callers are expected to handle
* [NamecoinResolveOutcome.Success] separately (it needs a [User]
* lookup through [com.vitorpamplona.amethyst.model.LocalCache], which
* this helper has no access to).
*/
fun mapOutcomeToResolveState(outcome: NamecoinResolveOutcome): NamecoinResolveState =
when (outcome) {
is NamecoinResolveOutcome.Success ->
// Success is intentionally NOT handled here — callers must
// resolve the pubkey through LocalCache first.
error("mapOutcomeToResolveState called with Success outcome; resolve via LocalCache instead")
is NamecoinResolveOutcome.NameNotFound -> NamecoinResolveState.NotFound
is NamecoinResolveOutcome.NoNostrField ->
NamecoinResolveState.Error("${outcome.name} is registered but has no Nostr pubkey")
is NamecoinResolveOutcome.MalformedRecord ->
// Surface the parser detail verbatim so the publisher of the
// broken record can locate the bad byte
// (kotlinx.serialization includes a column number).
NamecoinResolveState.Error("${outcome.name} record is malformed: ${outcome.error}")
is NamecoinResolveOutcome.ServersUnreachable ->
NamecoinResolveState.Error("ElectrumX servers unreachable — check your connection or try again")
is NamecoinResolveOutcome.InvalidIdentifier ->
NamecoinResolveState.Error("Invalid Namecoin identifier")
NamecoinResolveOutcome.Timeout ->
NamecoinResolveState.Error("Resolution timed out — servers may be slow, try again")
}
/**
* Lightweight syntactic check: does this look like something we should
* route to Namecoin? Mirrors [com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver.isNamecoinIdentifier]
* but tolerates a leading `@` (matches the dropdown's [com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState.userSearchTermOrNull]).
*/
fun looksLikeNamecoinIdentifier(raw: String): Boolean {
val trimmed = raw.trim().removePrefix("@").lowercase()
if (trimmed.length < 5) return false
return trimmed.endsWith(".bit") ||
trimmed.contains("@") && trimmed.substringAfter('@').endsWith(".bit")
}
/**
* Inline Namecoin resolution indicator + result row. Designed to be
* mounted alongside any text input whose local-cache prefix search can
* race ahead of an on-chain `.bit` lookup (the onchain-zap recipient
* field and the global search bar both have this race).
*
* Behaviour:
* - Renders nothing when [searchInput] is not a `.bit` identifier.
* - Shows a small spinner row ("Resolving on Namecoin…") while the
* ElectrumX lookup is in flight (after a 300 ms debounce to match
* typical input-field debounce intervals).
* - On success, shows the resolved user as a tappable row with a
* `MaterialSymbols.Link` badge labelled "Namecoin"; tapping calls
* [onUserResolved].
* - On failure, shows a single explanatory line in the error colour.
*
* State is held in [NamecoinResolveState] (the same sealed class the
* desktop `SearchScreen` and `NamecoinNameService` already use) so this
* row stays in lockstep with the rest of the app's Namecoin UI.
*
* The composable is intentionally self-contained: it owns its own
* [LaunchedEffect] keyed on [searchInput], so it cancels in-flight
* lookups whenever the user keeps typing.
*
* @param modifier applied to the outer `Column` so callers can position
* or pad the row (e.g. the search bar pads horizontally).
*/
@Composable
fun NamecoinResolutionRow(
searchInput: String,
accountViewModel: AccountViewModel,
onUserResolved: (User) -> Unit,
modifier: Modifier = Modifier,
) {
val trimmed = remember(searchInput) { searchInput.trim().removePrefix("@") }
if (!looksLikeNamecoinIdentifier(trimmed)) return
var state by remember { mutableStateOf<NamecoinResolveState?>(null) }
LaunchedEffect(trimmed) {
// Match UserSuggestionState's 300 ms debounce so we don't fire a
// lookup on every keystroke.
delay(300)
state = NamecoinResolveState.Loading
val outcome =
withContext(Dispatchers.IO) {
runCatching {
Amethyst.instance.namecoinResolver.resolveDetailed(trimmed)
}.getOrElse {
NamecoinResolveOutcome.ServersUnreachable(
it.message ?: it::class.simpleName ?: "Lookup error",
)
}
}
state =
when (outcome) {
is NamecoinResolveOutcome.Success -> NamecoinResolveState.Resolved(outcome.result)
else -> mapOutcomeToResolveState(outcome)
}
}
Column(modifier = modifier) {
Spacer(Modifier.size(8.dp))
when (val s = state) {
null, NamecoinResolveState.Loading -> ResolvingChip(trimmed)
is NamecoinResolveState.Resolved -> ResolvedRow(trimmed, s, accountViewModel, onUserResolved)
NamecoinResolveState.NotFound -> FailedRow("No record for $trimmed on Namecoin.")
is NamecoinResolveState.Error -> FailedRow(s.message)
}
}
}
@Composable
private fun ResolvingChip(query: String) {
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = "Resolving $query on Namecoin…",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun ResolvedRow(
query: String,
state: NamecoinResolveState.Resolved,
accountViewModel: AccountViewModel,
onUserResolved: (User) -> Unit,
) {
// Look up the User in the same cache the rest of the app uses, exactly
// the way desktop's SearchScreen does. Falls back to a malformed-record
// error row if the pubkey somehow fails the hex shape check.
val user =
remember(state.result.pubkey) {
accountViewModel.account.cache.checkGetOrCreateUser(state.result.pubkey)
}
if (user == null) {
FailedRow(
"${state.result.namecoinName} record is malformed: " +
"pubkey ${state.result.pubkey} is not a valid hex key",
)
return
}
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
modifier =
Modifier
.fillMaxWidth()
.clickable { onUserResolved(user) },
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
UserPicture(
userHex = user.pubkeyHex,
size = 32.dp,
accountViewModel = accountViewModel,
nav = EmptyNav(),
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = user.toBestDisplayName(),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = query,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
NamecoinBadge()
}
}
}
@Composable
private fun NamecoinBadge() {
Surface(
shape = RoundedCornerShape(6.dp),
color = MaterialTheme.colorScheme.primaryContainer,
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(
symbol = MaterialSymbols.Link,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(14.dp),
)
Text(
text = "Namecoin",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
fontWeight = FontWeight.SemiBold,
)
}
}
}
@Composable
private fun FailedRow(message: String) {
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.errorContainer,
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon(
symbol = MaterialSymbols.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.size(18.dp),
)
Text(
text = message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.background(MaterialTheme.colorScheme.errorContainer),
)
}
}
}

View File

@@ -0,0 +1,100 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.components.util
import android.widget.Toast
import androidx.compose.foundation.combinedClickable
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.launch
/**
* Text composable that opens [onClick] on a tap and copies [copyValue] to the
* system clipboard on a long-press (with a Toast confirmation).
*
* This is the long-press-to-copy counterpart of [com.vitorpamplona.amethyst.ui.components.ClickableTextPrimary].
* It deliberately uses a plain [Text] + [combinedClickable] outer modifier
* rather than an inline `LinkAnnotation.Clickable` inside an `AnnotatedString`,
* because annotation-level clicks can't see a parent [combinedClickable]'s
* long-press: the parent's tap area sits above the annotation's hit-test
* region and would consume the tap before the annotation could fire it.
*
* @param displayText text shown to the user (may be a stripped form, e.g.
* "example.com" for a website value "https://example.com").
* @param copyValue raw value placed on the clipboard on long-press
* (typically the full, unmodified profile field).
* @param onClick tap action — usually "open the link" or "expand zap UI".
* @param toastResId string resource shown via [Toast.LENGTH_SHORT] after the
* value is placed on the clipboard. Defaults to a generic
* "Copied to clipboard" message.
* @param onLongClickLabelResId accessibility label exposed to TalkBack for
* the long-press action. Defaults to "Copy to clipboard".
*/
@Composable
fun LongPressCopyText(
displayText: String,
copyValue: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
color: Color = MaterialTheme.colorScheme.primary,
style: TextStyle = LocalTextStyle.current,
softWrap: Boolean = true,
overflow: TextOverflow = TextOverflow.Ellipsis,
maxLines: Int = Int.MAX_VALUE,
toastResId: Int = R.string.copied_to_clipboard,
onLongClickLabelResId: Int = R.string.copy_to_clipboard,
) {
val context = LocalContext.current
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
val longClickLabel = stringRes(onLongClickLabelResId)
Text(
text = displayText,
color = color,
style = style,
softWrap = softWrap,
overflow = overflow,
maxLines = maxLines,
modifier =
modifier.combinedClickable(
onClick = onClick,
onLongClick = {
scope.launch {
clipboard.setText(copyValue)
Toast.makeText(context, stringRes(context, toastResId), Toast.LENGTH_SHORT).show()
}
},
onLongClickLabel = longClickLabel,
),
)
}

View File

@@ -32,3 +32,12 @@ val DefaultFeedOrderEvent: Comparator<Event> =
val DefaultFeedOrderCard: Comparator<Card> =
compareByDescending<Card> { it.createdAt() }.thenBy { it.id() }
// Snapshots createdAt once per note so the comparator stays consistent even if
// another thread swaps a Note's event mid-sort (e.g. a newer AddressableEvent
// arriving from a relay). Avoids TimSort's "Comparison method violates its
// general contract!" IllegalArgumentException.
fun Iterable<Note>.sortedByDefaultFeedOrder(): List<Note> =
map { it to (it.createdAt() ?: 0L) }
.sortedWith(compareByDescending<Pair<Note, Long>> { it.second }.thenBy { it.first.idHex })
.map { it.first }

View File

@@ -65,6 +65,8 @@ object ScrollStateKeys {
const val BROWSE_EMOJI_SETS_SCREEN = "BrowseEmojiSetsFeed"
const val COMMUNITIES_LIST = "CommunitiesListFeed"
const val PICTURES_SCREEN = "PicturesFeed"
const val CALENDARS_SCREEN = "CalendarsFeed"
const val CALENDAR_COLLECTIONS_SCREEN = "CalendarCollectionsFeed"
const val PRODUCTS_SCREEN = "ProductsFeed"
const val SHORTS_SCREEN = "ShortsFeed"
const val PUBLIC_CHATS_SCREEN = "PublicChatsFeed"

View File

@@ -73,6 +73,12 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.metadat
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.ArticleBookmarkListManagementScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.PostBookmarkListManagementScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.old.OldBookmarkListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarCollectionsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarReminderSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.create.NewCalendarCollectionScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.create.NewCalendarEventScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.detail.CalendarEventDetailScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.CreateGroupScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.EditGroupInfoScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.MarmotGroupChatScreen
@@ -93,6 +99,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.CommunityScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.CommunitiesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.newCommunity.EditCommunityScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.newCommunity.NewCommunityScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.contactList.ContactListUsersScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.DiscoverScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.LongFormPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.NewProductScreen
@@ -164,6 +171,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.HomeTabsSettingsSc
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.MutedThreadsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NIP47SetupScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NamecoinSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NotificationSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.OtsSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ProfileUiSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ReactionsSettingsScreen
@@ -178,6 +186,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.ThreadScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.VideoScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.hls.NewHlsVideoScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.AddWalletScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.OnchainTransactionsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletDetailScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletReceiveScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletScreen
@@ -250,6 +259,17 @@ fun BuildNavigation(
composableFromEnd<Route.ProfileBadges> { ProfileBadgesScreen(accountViewModel, nav) }
composableFromBottomArgs<Route.AwardBadge> { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) }
composableFromEnd<Route.Pictures> { PicturesScreen(accountViewModel, nav) }
composableFromEnd<Route.Calendars> { CalendarsScreen(accountViewModel, nav) }
composableFromEnd<Route.CalendarCollections> { CalendarCollectionsScreen(accountViewModel, nav) }
composableFromEnd<Route.CalendarReminderSettings> { CalendarReminderSettingsScreen(nav) }
composableFromEndArgs<Route.CalendarEventDetail> {
CalendarEventDetailScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav)
}
composableFromBottomArgs<Route.NewCalendarEvent> { NewCalendarEventScreen(nav, accountViewModel) }
composableFromBottomArgs<Route.EditCalendarEvent> {
NewCalendarEventScreen(nav, accountViewModel, editKind = it.kind, editPubKeyHex = it.pubKeyHex, editDTag = it.dTag)
}
composableFromBottomArgs<Route.NewCalendarCollection> { NewCalendarCollectionScreen(nav, accountViewModel, it.dTag) }
composableFromEnd<Route.Products> { ProductsScreen(accountViewModel, nav) }
composableFromEnd<Route.Shorts> { ShortsScreen(accountViewModel, nav) }
composableFromEnd<Route.PublicChats> { PublicChatsScreen(accountViewModel, nav) }
@@ -266,6 +286,7 @@ fun BuildNavigation(
composableFromEnd<Route.WalletSend> { WalletSendScreen(accountViewModel, nav) }
composableFromEnd<Route.WalletReceive> { WalletReceiveScreen(accountViewModel, nav) }
composableFromEnd<Route.WalletTransactions> { WalletTransactionsScreen(accountViewModel, nav) }
composableFromEnd<Route.OnchainTransactions> { OnchainTransactionsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.WalletDetail> { WalletDetailScreen(it.walletId, accountViewModel, nav) }
composableFromEnd<Route.WalletAdd> { AddWalletScreen(accountViewModel, nav) }
@@ -326,6 +347,7 @@ fun BuildNavigation(
composableFromEnd<Route.ProfileUiSettings> { ProfileUiSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.VideoPlayerSettings> { VideoPlayerSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.CallSettings> { CallSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.NotificationSettings> { NotificationSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.ImportFollowsSelectUser> { ImportFollowListSelectUserScreen(accountViewModel, nav) }
composableFromEndArgs<Route.ImportFollowsPickFollows> {
ImportFollowListPickFollowsScreen(it.userHex, accountViewModel, nav)
@@ -349,6 +371,7 @@ fun BuildNavigation(
composableFromEndArgs<Route.ContentDiscovery> { DvmContentDiscoveryScreen(it.id, accountViewModel, nav) }
composableFromEndArgs<Route.Profile> { ProfileScreen(it.id, accountViewModel, nav) }
composableFromEndArgs<Route.Note> { ThreadScreen(it.id, accountViewModel, nav) }
composableFromEndArgs<Route.ContactListUsers> { ContactListUsersScreen(it.noteId, accountViewModel, nav) }
composableFromEndArgs<Route.Hashtag> { HashtagScreen(it, accountViewModel, nav) }
composableFromEndArgs<Route.Geohash> { GeoHashScreen(it, accountViewModel, nav) }
composableFromEndArgs<Route.RelayFeed> { RelayFeedScreen(it, accountViewModel, nav) }
@@ -364,7 +387,15 @@ fun BuildNavigation(
composableFromEndArgs<Route.RoomByAuthor> { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) }
composableFromEnd<Route.MarmotGroupList> { MarmotGroupListScreen(accountViewModel, nav) }
composableFromEndArgs<Route.MarmotGroupChat> { MarmotGroupChatScreen(it.nostrGroupId, accountViewModel, nav) }
composableFromEndArgs<Route.MarmotGroupChat> {
MarmotGroupChatScreen(
nostrGroupId = it.nostrGroupId,
draftMessage = it.message,
replyToInnerNote = it.replyId,
accountViewModel = accountViewModel,
nav = nav,
)
}
composableFromEndArgs<Route.MarmotGroupInfo> { MarmotGroupInfoScreen(it.nostrGroupId, accountViewModel, nav) }
composableFromBottom<Route.CreateMarmotGroup> { CreateGroupScreen(accountViewModel, nav) }

View File

@@ -50,6 +50,8 @@ enum class NavBarItem {
COMMUNITIES,
ARTICLES,
PICTURES,
CALENDARS,
CALENDAR_COLLECTIONS,
SHORTS,
PUBLIC_CHATS,
FOLLOW_PACKS,
@@ -199,6 +201,20 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
icon = MaterialSymbols.Photo,
resolveRoute = { Route.Pictures },
),
NavBarItem.CALENDARS to
NavBarItemDef(
id = NavBarItem.CALENDARS,
labelRes = R.string.route_calendars,
icon = MaterialSymbols.CalendarMonth,
resolveRoute = { Route.Calendars },
),
NavBarItem.CALENDAR_COLLECTIONS to
NavBarItemDef(
id = NavBarItem.CALENDAR_COLLECTIONS,
labelRes = R.string.route_calendar_collections,
icon = MaterialSymbols.AutoMirrored.FormatListBulleted,
resolveRoute = { Route.CalendarCollections },
),
NavBarItem.SHORTS to
NavBarItemDef(
id = NavBarItem.SHORTS,
@@ -318,6 +334,8 @@ val DrawerFeedsItems: List<NavBarItem> =
NavBarItem.COMMUNITIES,
NavBarItem.ARTICLES,
NavBarItem.PICTURES,
NavBarItem.CALENDARS,
NavBarItem.CALENDAR_COLLECTIONS,
NavBarItem.SHORTS,
NavBarItem.PUBLIC_CHATS,
NavBarItem.FOLLOW_PACKS,

View File

@@ -149,6 +149,18 @@ fun routeForInner(
Route.GitRepository(noteEvent.kind, noteEvent.pubKey, noteEvent.dTag())
}
// Calendar appointments route to their dedicated detail screen rather than the generic
// Route.Note that AddressableEvent would fall through to — without this the notification
// tap and `nostr:naddr…` deep links land on the bare note view instead of the calendar
// detail with RSVPs, participants, and the "in calendars" list.
is com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent -> {
Route.CalendarEventDetail(noteEvent.kind, noteEvent.pubKey, noteEvent.dTag())
}
is com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent -> {
Route.CalendarEventDetail(noteEvent.kind, noteEvent.pubKey, noteEvent.dTag())
}
is GiftWrapEvent -> {
noteEvent.innerEventId?.let {
routeFor(LocalCache.getOrCreateNote(it), loggedIn)
@@ -252,6 +264,15 @@ fun routeReplyTo(
note: Note,
account: Account,
): Route? {
// Marmot group messages must reply inside the encrypted group, not as a
// public kind:1111 comment. The inner kind:9 event has no group hint of
// its own — we detect the group via the gathering MarmotGroupChatroom,
// mirroring routeFor() above.
val marmotGroup = note.inGatherers?.firstNotNullOfOrNull { it as? MarmotGroupChatroom }
if (marmotGroup != null) {
return Route.MarmotGroupChat(marmotGroup.nostrGroupId, replyId = note.idHex)
}
val noteEvent = note.event
return when (noteEvent) {
is ChannelMessageEvent -> {

View File

@@ -81,6 +81,45 @@ sealed class Route {
@Serializable object Pictures : Route()
@Serializable object Calendars : Route()
@Serializable object CalendarCollections : Route()
@Serializable
data class NewCalendarEvent(
val draft: String? = null,
) : Route()
@Serializable
data class EditCalendarEvent(
val kind: Int,
val pubKeyHex: HexKey,
val dTag: String,
) : Route() {
constructor(address: Address) : this(
kind = address.kind,
pubKeyHex = address.pubKeyHex,
dTag = address.dTag,
)
}
@Serializable
data class NewCalendarCollection(
val dTag: String? = null,
) : Route()
@Serializable data class CalendarEventDetail(
val kind: Int,
val pubKeyHex: HexKey,
val dTag: String,
) : Route() {
constructor(address: Address) : this(
kind = address.kind,
pubKeyHex = address.pubKeyHex,
dTag = address.dTag,
)
}
@Serializable object Products : Route()
@Serializable object Shorts : Route()
@@ -111,6 +150,8 @@ sealed class Route {
@Serializable object WalletTransactions : Route()
@Serializable object OnchainTransactions : Route()
@Serializable
data class WalletDetail(
val walletId: String,
@@ -239,6 +280,10 @@ sealed class Route {
@Serializable object CallSettings : Route()
@Serializable object NotificationSettings : Route()
@Serializable object CalendarReminderSettings : Route()
@Serializable object Lists : Route()
@Serializable data class MyPeopleListView(
@@ -305,6 +350,10 @@ sealed class Route {
val id: String,
) : Route()
@Serializable data class ContactListUsers(
val noteId: String,
) : Route()
@Serializable data class Hashtag(
val hashtag: String,
) : Route()
@@ -400,6 +449,8 @@ sealed class Route {
@Serializable data class MarmotGroupChat(
val nostrGroupId: String,
val message: String? = null,
val replyId: HexKey? = null,
) : Route()
@Serializable data class MarmotGroupInfo(

View File

@@ -334,13 +334,13 @@ private enum class FeedGroup(
@param:androidx.annotation.StringRes val labelRes: Int,
) {
FEEDS(R.string.feed_group_feeds),
RELAYS(R.string.feed_group_relays),
HASHTAGS(R.string.feed_group_hashtags),
INTEREST_SETS(R.string.feed_group_interest_sets),
COMMUNITIES(R.string.feed_group_communities),
LOCATIONS(R.string.feed_group_locations),
LISTS(R.string.feed_group_lists),
DVMS(R.string.feed_group_dvms),
RELAYS(R.string.feed_group_relays),
COMMUNITIES(R.string.feed_group_communities),
LISTS(R.string.feed_group_lists),
}
private fun FeedDefinition.group(): FeedGroup =

View File

@@ -32,6 +32,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -107,6 +108,23 @@ fun HiddenNotePreview() {
)
}
@Composable
@Preview
fun HiddenNoteExcessiveHashtagsPreview() {
ThemeComparisonColumn(
toPreview = {
HiddenNote(
reports = persistentSetOf(),
isHiddenAuthor = false,
hasExcessiveHashtags = true,
hashtagLimit = 8,
accountViewModel = mockAccountViewModel(),
nav = EmptyNav(),
) {}
},
)
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun HiddenNote(
@@ -115,8 +133,11 @@ fun HiddenNote(
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier,
nav: INav,
hasExcessiveHashtags: Boolean = false,
hashtagLimit: Int = 0,
onClick: () -> Unit,
) {
val hasReporters = isHiddenAuthor || reports.isNotEmpty()
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
Row(
modifier = Modifier.padding(horizontal = 20.dp),
@@ -127,26 +148,42 @@ fun HiddenNote(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(30.dp),
) {
Text(
text = stringRes(R.string.post_was_flagged_as_inappropriate_by),
color = Color.Gray,
)
FlowRow(modifier = Modifier.padding(top = 10.dp)) {
if (isHiddenAuthor) {
UserPicture(
user = accountViewModel.userProfile(),
size = Size35dp,
nav = nav,
accountViewModel = accountViewModel,
)
}
reports.forEach {
NoteAuthorPicture(
baseNote = it,
size = Size35dp,
accountViewModel = accountViewModel,
nav = nav,
)
if (hasExcessiveHashtags) {
Text(
text = pluralStringResource(R.plurals.post_was_hidden_due_to_too_many_hashtags, hashtagLimit, hashtagLimit),
color = Color.Gray,
textAlign = TextAlign.Center,
)
}
if (hasReporters || !hasExcessiveHashtags) {
Text(
text = stringRes(R.string.post_was_flagged_as_inappropriate_by),
color = Color.Gray,
modifier =
if (hasExcessiveHashtags) {
Modifier.padding(top = 10.dp)
} else {
Modifier
},
)
FlowRow(modifier = Modifier.padding(top = 10.dp)) {
if (isHiddenAuthor) {
UserPicture(
user = accountViewModel.userProfile(),
size = Size35dp,
nav = nav,
accountViewModel = accountViewModel,
)
}
reports.forEach {
NoteAuthorPicture(
baseNote = it,
size = Size35dp,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}

View File

@@ -77,11 +77,13 @@ fun WatchBlockAndReport(
normalNote(isHidden.canPreview)
} else {
HiddenNote(
isHidden.relevantReports,
isHidden.isHiddenAuthor,
accountViewModel,
modifier,
nav,
reports = isHidden.relevantReports,
isHiddenAuthor = isHidden.isHiddenAuthor,
hasExcessiveHashtags = isHidden.hasExcessiveHashtags,
hashtagLimit = isHidden.hashtagLimit,
accountViewModel = accountViewModel,
modifier = modifier,
nav = nav,
onClick = { showAnyway.value = true },
)
}

View File

@@ -420,3 +420,23 @@ fun VerticalDotsIcon() {
tint = MaterialTheme.colorScheme.placeholderText,
)
}
@Composable
fun OnchainZappedIcon(modifier: Modifier) {
Icon(
symbol = MaterialSymbols.CurrencyBitcoin,
contentDescription = stringRes(R.string.onchain_zap_description),
tint = BitcoinOrange,
modifier = modifier,
)
}
@Composable
fun PendingClockBadge(modifier: Modifier) {
Icon(
symbol = MaterialSymbols.Schedule,
contentDescription = stringRes(R.string.onchain_zap_pending),
tint = BitcoinOrange,
modifier = modifier,
)
}

View File

@@ -93,6 +93,7 @@ import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.ui.note.types.BadgeDisplay
import com.vitorpamplona.amethyst.ui.note.types.DisplayBlockedRelayList
import com.vitorpamplona.amethyst.ui.note.types.DisplayBroadcastRelayList
import com.vitorpamplona.amethyst.ui.note.types.DisplayContactList
import com.vitorpamplona.amethyst.ui.note.types.DisplayDMRelayList
import com.vitorpamplona.amethyst.ui.note.types.DisplayFollowList
import com.vitorpamplona.amethyst.ui.note.types.DisplayIndexerRelayList
@@ -116,7 +117,9 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderAttestorRecommendation
import com.vitorpamplona.amethyst.ui.note.types.RenderAudioHeader
import com.vitorpamplona.amethyst.ui.note.types.RenderAudioTrack
import com.vitorpamplona.amethyst.ui.note.types.RenderBadgeAward
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarCollectionEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarDateSlotEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarRSVPEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarTimeSlotEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderCashuMint
import com.vitorpamplona.amethyst.ui.note.types.RenderChannelMessage
@@ -149,6 +152,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90ContentDiscoveryRespo
import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90Status
import com.vitorpamplona.amethyst.ui.note.types.RenderNamedSiteEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderNipContent
import com.vitorpamplona.amethyst.ui.note.types.RenderOnchainZap
import com.vitorpamplona.amethyst.ui.note.types.RenderPinListEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderPoll
import com.vitorpamplona.amethyst.ui.note.types.RenderPostApproval
@@ -212,6 +216,7 @@ import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geoHashOrScope
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
@@ -253,6 +258,8 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent
@@ -294,6 +301,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent
import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import kotlinx.coroutines.Dispatchers
@@ -490,9 +498,12 @@ fun calculateBackgroundColor(
val defaultBackgroundColor = MaterialTheme.colorScheme.background
val newItemColor = MaterialTheme.colorScheme.newItemBackgroundColor
// Only fade in/out the "new item" highlight for items that track read state.
// Inner notes (reposts/quotes) pass routeForLastRead = null and reuse the parent color directly,
// so the LaunchedEffect would just park a coroutine for 5s per item during scroll.
// Inner notes (reposts) pass routeForLastRead = null with the parent's bgColor state;
// share it directly so the inner highlight fades in lockstep with the outer.
if (routeForLastRead == null && parentBackgroundColor != null) {
return parentBackgroundColor
}
val isNew =
remember(createdAt, routeForLastRead) {
routeForLastRead != null && accountViewModel.loadAndMarkAsRead(routeForLastRead, createdAt)
@@ -945,6 +956,10 @@ private fun RenderNoteRow(
RenderLnZap(baseNote, backgroundColor, accountViewModel, nav)
}
is OnchainZapEvent -> {
RenderOnchainZap(baseNote, backgroundColor, accountViewModel, nav)
}
is LiveActivitiesClipEvent -> {
RenderChatClip(baseNote, accountViewModel, nav)
}
@@ -961,6 +976,10 @@ private fun RenderNoteRow(
DisplayFollowList(baseNote, true, accountViewModel, nav)
}
is ContactListEvent -> {
DisplayContactList(baseNote, accountViewModel, nav)
}
is RelaySetEvent -> {
DisplayRelaySet(baseNote, backgroundColor, accountViewModel, nav)
}
@@ -1180,6 +1199,14 @@ private fun RenderNoteRow(
RenderCalendarDateSlotEvent(baseNote, accountViewModel, nav)
}
is CalendarEvent -> {
RenderCalendarCollectionEvent(baseNote, accountViewModel, nav)
}
is CalendarRSVPEvent -> {
RenderCalendarRSVPEvent(baseNote, accountViewModel, nav)
}
is GoalEvent -> {
RenderGoal(baseNote, accountViewModel, nav)
}
@@ -1481,14 +1508,22 @@ fun RenderRepost(
}
}
fun getGradient(backgroundColor: MutableState<Color>): Brush =
Brush.verticalGradient(
@Composable
fun getGradient(backgroundColor: MutableState<Color>): Brush {
val solid =
if (backgroundColor.value.alpha == 0f) {
MaterialTheme.colorScheme.background
} else {
backgroundColor.value.copy(alpha = 1f)
}
return Brush.verticalGradient(
colors =
listOf(
backgroundColor.value.copy(alpha = 0f),
backgroundColor.value.copy(alpha = 1f),
solid.copy(alpha = 0f),
solid,
),
)
}
@Composable
fun ReplyNoteComposition(

View File

@@ -24,6 +24,7 @@ import android.content.Intent
import android.widget.Toast
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
@@ -136,15 +137,19 @@ fun LongPressToQuickAction(
) {
val popupExpanded = remember { mutableStateOf(false) }
content { popupExpanded.value = true }
// Box anchors the Popup to the note card; otherwise its parent resolves to the
// enclosing LazyColumn and `Alignment.Center` centers on the whole list.
Box {
content { popupExpanded.value = true }
if (popupExpanded.value) {
NoteQuickActionMenu(
note = baseNote,
onDismiss = { popupExpanded.value = false },
accountViewModel = accountViewModel,
nav = nav,
)
if (popupExpanded.value) {
NoteQuickActionMenu(
note = baseNote,
onDismiss = { popupExpanded.value = false },
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}

View File

@@ -0,0 +1,155 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.OnchainZapEntry
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteZaps
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.Size35Modifier
import com.vitorpamplona.amethyst.ui.theme.StdStartPadding
import com.vitorpamplona.amethyst.ui.theme.WidthAuthorPictureModifier
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import java.math.BigDecimal
private fun onOnchainZapEntryClick(
entry: OnchainZapEntry,
nav: INav,
) {
entry.source.author?.let { nav.nav(routeFor(it)) }
}
@Composable
internal fun WatchOnchainZapsAndRenderGallery(
baseNote: Note,
nav: INav,
accountViewModel: AccountViewModel,
) {
// Reuse the same flow the lightning gallery subscribes to. Note.addOnchainZap
// invalidates flowSet.zaps, so this composable refreshes when on-chain zaps
// arrive or upgrade pending → confirmed. The flow also fires for lightning
// zap arrivals on the same note, so memoize the list snapshot.
val zapsState by observeNoteZaps(baseNote, accountViewModel)
val entries =
remember(zapsState) {
zapsState
?.note
?.onchainZaps
?.values
?.toImmutableList() ?: persistentListOf()
}
if (entries.isNotEmpty()) {
RenderOnchainZapGallery(entries, nav, accountViewModel)
}
}
@Composable
private fun RenderOnchainZapGallery(
entries: ImmutableList<OnchainZapEntry>,
nav: INav,
accountViewModel: AccountViewModel,
) {
Row(Modifier.fillMaxWidth()) {
Box(modifier = WidthAuthorPictureModifier) {
OnchainZappedIcon(
modifier = Modifier.size(Size25dp).align(Alignment.TopEnd),
)
}
OnchainZapAuthorGallery(entries, nav, accountViewModel)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun OnchainZapAuthorGallery(
entries: ImmutableList<OnchainZapEntry>,
nav: INav,
accountViewModel: AccountViewModel,
) {
Column(modifier = StdStartPadding) {
FlowRow {
entries.forEach { entry ->
OnchainZapEntryRow(entry, nav, accountViewModel)
}
}
}
}
@Composable
private fun OnchainZapEntryRow(
entry: OnchainZapEntry,
nav: INav,
accountViewModel: AccountViewModel,
) {
val user = entry.source.author
val amountText =
remember(entry.verifiedSats) {
showAmount(BigDecimal.valueOf(entry.verifiedSats))
}
val avatarAlpha = if (entry.confirmed) 1f else 0.6f
Box(
modifier = Size35Modifier.clickable { onOnchainZapEntryClick(entry, nav) },
contentAlignment = Alignment.BottomCenter,
) {
// Only the avatar dims for pending entries. The amount overlay and clock
// badge stay at full opacity so they remain readable.
Box(modifier = Modifier.alpha(avatarAlpha)) {
WatchUserMetadataAndFollowsAndRenderUserProfilePictureOrDefaultAuthor(
user,
accountViewModel,
)
}
CrossfadeToDisplayAmount(amountText)
if (!entry.confirmed) {
// TopStart so the badge doesn't collide with the FollowingIcon
// that WatchUserMetadataAndFollowsAndRenderUserProfilePicture
// paints at TopEnd for followed users.
Box(modifier = Modifier.align(Alignment.TopStart)) {
PendingClockBadge(modifier = Modifier.size(14.dp))
}
}
}
}

View File

@@ -51,6 +51,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CardDefaults
@@ -59,6 +60,7 @@ import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
@@ -83,11 +85,11 @@ import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
@@ -97,7 +99,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.vitorpamplona.amethyst.R
@@ -109,6 +110,8 @@ import com.vitorpamplona.amethyst.model.ReactionRowAction
import com.vitorpamplona.amethyst.model.ReactionRowItem
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReactionCount
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReactions
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReferences
@@ -126,17 +129,16 @@ import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius
import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.ui.components.toasts.multiline.UserBasedErrorMessage
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeReplyTo
import com.vitorpamplona.amethyst.ui.note.types.EditState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.PaymentTargetsDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.OnchainZapSendDialog
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.HalfDoubleVertSpacer
@@ -171,6 +173,7 @@ import com.vitorpamplona.amethyst.ui.theme.reactionBox
import com.vitorpamplona.amethyst.ui.theme.ripple24dp
import com.vitorpamplona.amethyst.ui.theme.selectedReactionBoxModifier
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
@@ -348,15 +351,9 @@ fun PayReaction(
) {
val authorPubkey = baseNote.author?.pubkeyHex ?: return
val address = remember(authorPubkey) { PaymentTargetsEvent.createAddress(authorPubkey) }
val context = LocalContext.current
val clipboardManager = LocalClipboard.current
val scope = rememberCoroutineScope()
LoadAddressableNote(address, accountViewModel) { note ->
val targets = remember(note) { (note?.event as? PaymentTargetsEvent)?.paymentTargets() ?: emptyList() }
var expanded by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf<String?>(null) }
ClickableBox(
modifier = iconSizeModifier,
@@ -370,58 +367,12 @@ fun PayReaction(
)
}
if (expanded) {
M3ActionDialog(
title = stringRes(R.string.payment_targets),
onDismiss = { expanded = false },
) {
M3ActionSection {
if (targets.isEmpty()) {
M3ActionRow(
icon = MaterialSymbols.AccountBalanceWallet,
text = stringRes(R.string.no_payment_targets_message),
enabled = false,
onClick = {},
)
} else {
targets.forEach { target ->
M3ActionRow(
icon = MaterialSymbols.AccountBalanceWallet,
text = "${target.type.replaceFirstChar(Char::titlecase)}: ${target.authority}",
onClick = {
expanded = false
try {
val intent = Intent(Intent.ACTION_VIEW, "payto://${target.type}/${target.authority}".toUri())
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
context.startActivity(intent)
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e
errorMessage = stringRes(context, R.string.no_payment_app_found)
}
},
)
M3ActionRow(
icon = MaterialSymbols.ContentCopy,
text = stringRes(R.string.copy_to_clipboard),
onClick = {
expanded = false
scope.launch {
clipboardManager.setText(target.authority)
}
},
)
}
}
}
}
}
if (expanded && note != null) {
EventFinderFilterAssemblerSubscription(note, accountViewModel)
val event by observeNoteEvent<PaymentTargetsEvent>(note, accountViewModel)
val targets = remember(event) { event?.paymentTargets() ?: emptyList() }
errorMessage?.let { msg ->
ErrorMessageDialog(
title = stringRes(R.string.error_dialog_payment_error),
textContent = msg,
onDismiss = { errorMessage = null },
)
PaymentTargetsDialog(targets = targets, onDismiss = { expanded = false })
}
}
}
@@ -597,6 +548,7 @@ private fun ReactionDetailGallery(
) {
Column {
WatchZapAndRenderGallery(baseNote, backgroundColor, nav, accountViewModel)
WatchOnchainZapsAndRenderGallery(baseNote, nav, accountViewModel)
WatchBoostsAndRenderGallery(baseNote, nav, accountViewModel)
WatchReactionsAndRenderGallery(baseNote, nav, accountViewModel)
}
@@ -1171,6 +1123,11 @@ private fun likeClick(
}
}
@Immutable
private data class OnchainZapRequest(
val amountSats: Long?,
)
@Composable
@OptIn(ExperimentalFoundationApi::class, ExperimentalUuidApi::class)
fun ZapReaction(
@@ -1185,6 +1142,9 @@ fun ZapReaction(
) {
var wantsToZap by remember { mutableStateOf(false) }
var wantsToSetCustomZap by remember { mutableStateOf(false) }
// null = closed; OnchainZapRequest(amount=null) = open with no prefill;
// OnchainZapRequest(amount=N) = open prefilled to N sats.
var onchainZapRequest by remember { mutableStateOf<OnchainZapRequest?>(null) }
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -1257,6 +1217,10 @@ fun ZapReaction(
nav.nav(Route.UpdateZapAmount())
}
},
onOnchainAmount = { amount ->
wantsToZap = false
onchainZapRequest = OnchainZapRequest(amount)
},
onError = { _, message, user ->
scope.launch {
zappingProgress = 0f
@@ -1280,6 +1244,17 @@ fun ZapReaction(
)
}
onchainZapRequest?.let { request ->
val zappedEventHint = remember(baseNote) { baseNote.toEventHint<Event>() }
OnchainZapSendDialog(
accountViewModel = accountViewModel,
onDismiss = { onchainZapRequest = null },
recipientPubKey = baseNote.author?.pubkeyHex,
zappedEvent = zappedEventHint,
prefillAmountSats = request.amountSats,
)
}
if (wantsToSetCustomZap) {
ZapCustomDialog(
onZapStarts = { zapStartingTime = TimeUtils.now() },
@@ -1884,12 +1859,29 @@ fun ZapAmountChoicePopup(
onError: (title: String, text: String, user: User?) -> Unit,
onProgress: (percent: Float) -> Unit,
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit,
onOnchainAmount: ((Long?) -> Unit)? = null,
) {
val zapAmountChoices by
accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices
.collectAsStateWithLifecycle()
val onchainZapAmountChoices by
accountViewModel.account.settings.syncedSettings.zaps.onchainZapAmountChoices
.collectAsStateWithLifecycle()
ZapAmountChoicePopup(baseNote, zapAmountChoices, accountViewModel, popupYOffset, onZapStarts, onDismiss, onChangeAmount, onError, onProgress, onPayViaIntent)
ZapAmountChoicePopup(
baseNote = baseNote,
zapAmountChoices = zapAmountChoices,
accountViewModel = accountViewModel,
popupYOffset = popupYOffset,
onZapStarts = onZapStarts,
onDismiss = onDismiss,
onChangeAmount = onChangeAmount,
onError = onError,
onProgress = onProgress,
onPayViaIntent = onPayViaIntent,
onchainZapAmountChoices = if (onOnchainAmount != null) onchainZapAmountChoices else persistentListOf(),
onOnchainAmount = onOnchainAmount ?: {},
)
}
@Composable
@@ -1904,9 +1896,11 @@ fun ZapAmountChoicePopup(
onError: (title: String, text: String, user: User?) -> Unit,
onProgress: (percent: Float) -> Unit,
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit,
onchainZapAmountChoices: ImmutableList<Long> = persistentListOf(),
onOnchainAmount: (Long?) -> Unit = {},
) {
val visibilityState = rememberVisibilityState(onDismiss)
ZapAmountChoicePopup(baseNote, zapAmountChoices, accountViewModel, popupYOffset, visibilityState, onZapStarts, onChangeAmount, onError, onProgress, onPayViaIntent)
ZapAmountChoicePopup(baseNote, zapAmountChoices, onchainZapAmountChoices, accountViewModel, popupYOffset, visibilityState, onZapStarts, onChangeAmount, onOnchainAmount, onError, onProgress, onPayViaIntent)
}
@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
@@ -1914,18 +1908,18 @@ fun ZapAmountChoicePopup(
fun ZapAmountChoicePopup(
baseNote: Note,
zapAmountChoices: ImmutableList<Long>,
onchainZapAmountChoices: ImmutableList<Long>,
accountViewModel: AccountViewModel,
popupYOffset: Dp,
visibilityState: MutableTransitionState<Boolean>,
onZapStarts: () -> Unit,
onChangeAmount: () -> Unit,
onOnchainAmount: (Long?) -> Unit,
onError: (title: String, text: String, user: User?) -> Unit,
onProgress: (percent: Float) -> Unit,
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit,
) {
val context = LocalContext.current
val zapMessage = ""
val yOffset = with(LocalDensity.current) { -popupYOffset.toPx().toInt() }
Popup(
@@ -1939,62 +1933,175 @@ fun ZapAmountChoicePopup(
enter = popupAnimationEnter,
exit = popupAnimationExit,
) {
FlowRow(horizontalArrangement = Arrangement.Center) {
ZapAmountChoicePopupContent(
zapAmountChoices = zapAmountChoices,
onchainZapAmountChoices = onchainZapAmountChoices,
onZap = { amountInSats ->
onZapStarts()
accountViewModel.zap(
baseNote,
amountInSats * 1000,
null,
"",
context,
true,
onError,
onProgress,
onPayViaIntent,
)
visibilityState.targetState = false
},
onChangeAmount = onChangeAmount,
onOnchainAmount = { amount ->
onOnchainAmount(amount)
visibilityState.targetState = false
},
)
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ZapAmountChoicePopupContent(
zapAmountChoices: ImmutableList<Long>,
onZap: (Long) -> Unit,
onChangeAmount: () -> Unit,
onchainZapAmountChoices: ImmutableList<Long> = persistentListOf(),
onOnchainAmount: (Long?) -> Unit = {},
) {
Box(HalfPadding, contentAlignment = Center) {
ElevatedCard(
shape = SmallBorder,
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
FlowRow(
modifier = Modifier.padding(horizontal = 5.dp, vertical = 5.dp),
horizontalArrangement = Arrangement.Center,
verticalArrangement = Arrangement.Center,
itemVerticalAlignment = CenterVertically,
) {
zapAmountChoices.forEach { amountInSats ->
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = {
onZapStarts()
accountViewModel.zap(
baseNote,
amountInSats * 1000,
null,
zapMessage,
context,
true,
onError,
onProgress,
onPayViaIntent,
)
visibilityState.targetState = false
},
shape = ButtonBorder,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
) {
Text(
"${showAmount(amountInSats.toBigDecimal().setScale(1))}",
color = Color.White,
textAlign = TextAlign.Center,
modifier =
Modifier.combinedClickable(
onClick = {
onZapStarts()
accountViewModel.zap(
baseNote,
amountInSats * 1000,
null,
zapMessage,
context,
true,
onError,
onProgress,
onPayViaIntent,
)
visibilityState.targetState = false
},
onLongClick = { onChangeAmount() },
),
)
}
ZapAmountChip(
amountInSats = amountInSats,
onClick = { onZap(amountInSats) },
onLongClick = onChangeAmount,
)
}
onchainZapAmountChoices.forEach { amountInSats ->
OnchainZapAmountChip(
amountInSats = amountInSats,
onClick = { onOnchainAmount(amountInSats) },
onLongClick = onChangeAmount,
)
}
ClickableBox(
modifier =
Modifier
.padding(horizontal = 4.dp, vertical = 6.dp)
.size(32.dp)
.padding(7.dp),
onClick = onChangeAmount,
) {
Icon(
symbol = MaterialSymbols.Tune,
contentDescription = stringRes(R.string.quick_zap_amounts),
modifier = Size18Modifier,
tint = MaterialTheme.colorScheme.placeholderText,
)
}
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ZapAmountChip(
amountInSats: Long,
onClick: () -> Unit,
onLongClick: () -> Unit,
) {
Surface(
shape = ButtonBorder,
color = BitcoinOrange,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 6.dp),
) {
Row(
modifier =
Modifier
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = CenterVertically,
) {
Icon(
symbol = MaterialSymbols.Bolt,
contentDescription = null,
modifier = Size18Modifier,
tint = Color.White,
)
Spacer(Modifier.width(2.dp))
Text(
text = showAmount(amountInSats.toBigDecimal().setScale(1)),
color = Color.White,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun OnchainZapAmountChip(
amountInSats: Long,
onClick: () -> Unit,
onLongClick: () -> Unit,
) {
Surface(
shape = ButtonBorder,
color = BitcoinOrange,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 6.dp),
) {
Row(
modifier =
Modifier
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = CenterVertically,
) {
Icon(
symbol = MaterialSymbols.CurrencyBitcoin,
contentDescription = null,
modifier = Size18Modifier,
tint = Color.White,
)
Spacer(Modifier.width(2.dp))
Text(
text = showAmount(amountInSats.toBigDecimal().setScale(1)),
color = Color.White,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
)
}
}
}
@Preview
@Composable
fun ZapAmountChoicePopupPreview() {
ThemeComparisonColumn {
ZapAmountChoicePopupContent(
zapAmountChoices = persistentListOf(50L, 100L, 500L, 1_000L, 5_000L, 10_000L, 100_000L),
onchainZapAmountChoices = persistentListOf(10_000L, 50_000L, 250_000L),
onZap = {},
onChangeAmount = {},
onOnchainAmount = {},
)
}
}
fun showCount(count: Int?): String {
if (count == null) return ""
if (count == 0) return ""

View File

@@ -339,7 +339,96 @@ fun UpdateZapAmountContent(
)
}
// ── Section 3: Nostr Wallet Connect ───────────────────────────────────
// ── Section 3: Quick On-chain Zap Amounts ─────────────────────────────
Text(
text = stringRes(R.string.quick_zap_amounts_onchain),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleSmall,
modifier = SettingsCategorySpacingModifier,
)
Text(
text = stringRes(R.string.quick_zap_amounts_onchain_explainer),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.padding(bottom = 6.dp),
)
FlowRow(
modifier =
Modifier
.fillMaxWidth()
.animateContentSize(animationSpec = spring(stiffness = Spring.StiffnessMediumLow)),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
postViewModel.onchainAmountSet.forEach { amountInSats ->
InputChip(
selected = false,
onClick = { postViewModel.removeOnchainAmount(amountInSats) },
label = {
Text(
text = "${showAmount(amountInSats.toBigDecimal().setScale(1))}",
)
},
trailingIcon = {
Icon(
symbol = MaterialSymbols.Close,
contentDescription = stringRes(R.string.remove),
modifier = Modifier.size(InputChipDefaults.AvatarSize),
)
},
colors =
InputChipDefaults.inputChipColors(
containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f),
labelColor = MaterialTheme.colorScheme.primary,
trailingIconColor = MaterialTheme.colorScheme.primary,
),
)
}
}
Spacer(modifier = Modifier.height(6.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
label = { Text(text = stringRes(R.string.new_amount_in_sats_onchain)) },
value = postViewModel.nextOnchainAmount,
onValueChange = { postViewModel.nextOnchainAmount = it },
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.None,
keyboardType = KeyboardType.Number,
),
placeholder = {
Text(
text = "10000, 50000, 250000",
color = MaterialTheme.colorScheme.placeholderText,
)
},
trailingIcon = {
IconButton(
onClick = postViewModel::addOnchainAmount,
shape = ButtonBorder,
enabled = postViewModel.nextOnchainAmount.text.isNotBlank(),
) {
Icon(
symbol = MaterialSymbols.AddCircle,
contentDescription = stringRes(R.string.add),
modifier = Size20Modifier,
)
}
},
singleLine = true,
modifier = Modifier.weight(1f),
)
}
// ── Section 4: Nostr Wallet Connect ───────────────────────────────────
HorizontalDivider(
modifier = Modifier.padding(vertical = 16.dp),

View File

@@ -41,6 +41,8 @@ class UpdateZapAmountViewModel : ViewModel() {
var nextAmount by mutableStateOf(TextFieldValue(""))
var amountSet by mutableStateOf(listOf<Long>())
var nextOnchainAmount by mutableStateOf(TextFieldValue(""))
var onchainAmountSet by mutableStateOf(listOf<Long>())
var walletConnectRelay by mutableStateOf(TextFieldValue(""))
var walletConnectPubkey by mutableStateOf(TextFieldValue(""))
var walletConnectSecret by mutableStateOf(TextFieldValue(""))
@@ -59,6 +61,7 @@ class UpdateZapAmountViewModel : ViewModel() {
fun load() {
this.amountSet = accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices.value
this.onchainAmountSet = accountViewModel.account.settings.syncedSettings.zaps.onchainZapAmountChoices.value
this.selectedZapType = accountViewModel.account.settings.syncedSettings.zaps.defaultZapType.value
val nip47 = accountViewModel.account.settings.defaultZapPaymentRequest()
@@ -83,6 +86,19 @@ class UpdateZapAmountViewModel : ViewModel() {
amountSet = amountSet - amount
}
fun addOnchainAmount() {
val newValue = nextOnchainAmount.text.trim().toLongOrNull()
if (newValue != null) {
onchainAmountSet = onchainAmountSet + newValue
}
nextOnchainAmount = TextFieldValue("")
}
fun removeOnchainAmount(amount: Long) {
onchainAmountSet = onchainAmountSet - amount
}
fun sendPost() {
accountViewModel.launchSigner {
sendPostSuspend()
@@ -116,13 +132,15 @@ class UpdateZapAmountViewModel : ViewModel() {
null
}
accountViewModel.account.updateZapAmounts(amountSet, selectedZapType, nip47Update)
accountViewModel.account.updateZapAmounts(amountSet, onchainAmountSet, selectedZapType, nip47Update)
nextAmount = TextFieldValue("")
nextOnchainAmount = TextFieldValue("")
}
fun cancel() {
nextAmount = TextFieldValue("")
nextOnchainAmount = TextFieldValue("")
}
fun hasChanged(): Boolean {
@@ -130,6 +148,7 @@ class UpdateZapAmountViewModel : ViewModel() {
return (
selectedZapType != accountViewModel.account.settings.syncedSettings.zaps.defaultZapType.value ||
amountSet != accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices.value ||
onchainAmountSet != accountViewModel.account.settings.syncedSettings.zaps.onchainZapAmountChoices.value ||
walletConnectPubkey.text != (defaultUri?.pubKeyHex ?: "") ||
walletConnectRelay.text != (defaultUri?.relayUri?.url ?: "") ||
walletConnectSecret.text != (defaultUri?.secret ?: "")

View File

@@ -48,6 +48,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -81,6 +82,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.OnchainZapSendDialog
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
@@ -89,6 +91,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.ZeroPadding
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CancellationException
@@ -157,6 +160,11 @@ fun ZapCustomDialog(
val presetAmounts = remember(accountViewModel) { accountViewModel.zapAmountChoices() }
// True while the on-chain hand-off sheet is open. Dismissing the on-chain
// sheet closes the whole zap flow — the user has already committed to a
// payment method, no "go back to Lightning" is offered here.
var sendOnchain by remember { mutableStateOf(false) }
Dialog(
onDismissRequest = { onClose() },
properties =
@@ -326,9 +334,39 @@ fun ZapCustomDialog(
)
onClose()
}
// Hand off to the on-chain dialog with the entered amount +
// message prefilled. Disabled while the amount is empty so the
// user can't open a sheet that immediately gates on its own
// empty field.
TextButton(
onClick = { sendOnchain = true },
enabled = postViewModel.canSend() && !baseNote.isDraft(),
modifier =
Modifier
.fillMaxWidth()
.padding(top = 4.dp),
) {
Text(text = stringRes(id = R.string.send_onchain_instead))
}
}
}
}
if (sendOnchain) {
val zappedEventHint = remember(baseNote) { baseNote.toEventHint<Event>() }
OnchainZapSendDialog(
accountViewModel = accountViewModel,
onDismiss = {
sendOnchain = false
onClose()
},
recipientPubKey = baseNote.author?.pubkeyHex,
zappedEvent = zappedEventHint,
prefillAmountSats = postViewModel.value(),
prefillComment = postViewModel.customMessage.text,
)
}
}
@Composable

View File

@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.ui.note.creators.location
import android.content.Context
import android.location.Location
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -30,7 +32,10 @@ import androidx.compose.ui.platform.LocalContext
import com.vitorpamplona.amethyst.service.location.CachedReversedGeoLocations
import com.vitorpamplona.amethyst.service.location.toGeoHash
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
private const val MAX_GEOLOCATE_RETRIES = 5
@Composable
fun LoadCityName(
@@ -40,34 +45,50 @@ fun LoadCityName(
) {
var cityName by remember(geohashStr) { mutableStateOf(CachedReversedGeoLocations.cached(geohashStr)) }
if (cityName == null) {
if (onLoading != null) {
onLoading()
}
val resolved = cityName
if (resolved != null) {
content(resolved)
} else if (!CachedReversedGeoLocations.isGeocoderAvailable()) {
// Devices without a system Geocoder backend (e.g. AOSP / GrapheneOS
// without Google Play Services or microG) cannot reverse-geocode at
// all. Show the geohash directly instead of spinning forever.
content(geohashStr)
} else {
onLoading?.invoke()
val context = LocalContext.current
LaunchedEffect(key1 = geohashStr, context) {
LaunchedEffect(geohashStr, context) {
val location = runCatching { geohashStr.toGeoHash() }.getOrNull()?.toLocation()
if (location != null) {
launch {
var notReady = true
var myStep = 1000L
while (notReady) {
// Retries while the Reverse Geolocation service is offline.
CachedReversedGeoLocations.geoLocate(geohashStr, location, context) { newCityName ->
if (newCityName != cityName) {
notReady = false
cityName = newCityName
}
}
myStep = myStep * 2L
delay(myStep)
}
}
if (location == null) {
cityName = geohashStr
return@LaunchedEffect
}
var delayMs = 1000L
repeat(MAX_GEOLOCATE_RETRIES) {
val result = geoLocateOnce(geohashStr, location, context)
if (result != null) {
cityName = result
return@LaunchedEffect
}
delay(delayMs)
delayMs *= 2L
}
// Geocoder kept returning errors — give up and render the geohash
// so the loading indicator stops.
cityName = geohashStr
}
} else {
cityName?.let { content(it) }
}
}
private suspend fun geoLocateOnce(
geohashStr: String,
location: Location,
context: Context,
): String? =
suspendCancellableCoroutine { cont ->
CachedReversedGeoLocations.geoLocate(geohashStr, location, context) { result ->
if (cont.isActive) cont.resume(result)
}
}

View File

@@ -29,7 +29,6 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -41,7 +40,9 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.ui.components.OutlinedThinPaddingTextField
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -123,9 +124,11 @@ fun ForwardZapTo(
}
}
OutlinedTextField(
value = postViewModel.forwardZapToEditting.value,
onValueChange = { postViewModel.updateZapForwardTo(it) },
OutlinedThinPaddingTextField(
state = postViewModel.forwardZapToEditting,
onTextChanged = postViewModel::onForwardZapTextChanged,
inputTransformation = MentionPreservingInputTransformation,
outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary),
label = { Text(text = stringRes(R.string.zap_split_search_and_add_user)) },
modifier = Modifier.fillMaxWidth(),
placeholder = {
@@ -135,10 +138,6 @@ fun ForwardZapTo(
)
},
singleLine = true,
visualTransformation =
UrlUserTagTransformation(
MaterialTheme.colorScheme.primary,
),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
)
}

View File

@@ -20,15 +20,15 @@
*/
package com.vitorpamplona.amethyst.ui.note.creators.zapsplits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.Stable
import androidx.compose.ui.text.input.TextFieldValue
import com.vitorpamplona.amethyst.model.User
@Stable
interface IZapField {
val forwardZapTo: MutableState<SplitBuilder<User>>
val forwardZapToEditting: MutableState<TextFieldValue>
val forwardZapToEditting: TextFieldState
fun updateZapFromText()
@@ -37,5 +37,5 @@ interface IZapField {
sliderValue: Float,
)
fun updateZapForwardTo(newZapForwardTo: TextFieldValue)
fun onForwardZapTextChanged()
}

Some files were not shown because too many files have changed in this diff Show More