Commit Graph
1091 Commits
Author SHA1 Message Date
Claude b76b37744e feat: nickname card on the user profile, above the real display name
The profile header no longer replaces the big display name with the petname.
Instead, when the account nicknamed the user (or kept a private note about
them), an outlined card renders above it — petname, divider, private summary —
with the standard Lock private marker in its top-right corner, since both
fields live NIP-44 encrypted in the account's contact card. Tapping the card
opens the shared nickname editor. The profile's own display name stays fully
visible underneath. Feeds, chats and mentions keep rendering the petname
instead of the display name.

To carry the summary into the UI, the commons PetName holder generalizes to
Nickname(petName?, summary?, tags), built when either field exists — so a
note-only card (no petname) now shows on the profile too, while the name
override everywhere else keys strictly off petName.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY
2026-07-13 23:33:05 +00:00
Claude a6da2ee69d fix: record contact-card EOSEs from the d-tag so card syncs are incremental
UserCardsSubAssembler.newEose read the filter's p-tags to stamp each target
user's card EOSE, but kind:30382 filters address targets in the d-tag — so
no per-user EOSE was ever recorded, groupByRelayPresence always classified
users as never-checked, and every filter update re-downloaded the visible
users' cards with since = null. Read the d-tag instead, and use DTag.TAG_NAME
on both the filter builder and the EOSE reader so the two keys cannot drift
apart again. Pre-existing on main; surfaced while verifying that card syncs
are incremental from the account's outbox relays.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY
2026-07-13 21:52:29 +00:00
Claude e8ecf429a2 refactor: move nickname policy, emoji autocomplete, filters and dialog to commons
Generalizes the remaining platform-bound pieces of the nickname feature so
the desktop app can adopt it:

- ContactCardsState.displayNameFlow/cachedDisplayName own the NIP-81 render
  policy (petname over profile name over npub); the Android observeUserName
  is now a thin wrapper around it
- EmojiSuggestionState.autocompleteInto completes the word under the cursor
  and closes the list — replaces the insert+reset pair copy-pasted in the 8
  composer ViewModels and the nickname dialog
- the kind:30382 filter builders move to commons relayClient/assemblers
  (cards about targets from trusted accounts, and the account's own cards by
  author), shared by the user watcher and the login subscription
- ShowEmojiSuggestionList moves to commons nip30CustomEmojis/ui using coil
  and commons string resources
- EditNicknameDialog moves to commons nip85TrustedAssertions/ui: it takes the
  ContactCardsState and an onSave callback, so each front end only wires its
  own publish path and menu entry; dialog strings move to commons resources

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY
2026-07-13 21:20:36 +00:00
Claude ab4b0b984d refactor: audit nickname code — dedupe emoji helpers into commons
Review pass over the nickname/contact-card feature for reuse, simplicity and
performance:

- EmojiSuggestionState moves to commons next to EmojiPackState and now takes
  the EmojiPackState directly instead of the whole Android Account, so the
  desktop app can reuse the :shortcode: autocomplete
- the findEmoji helper that was copy-pasted in 8 composer ViewModels is gone;
  everyone calls the shared EmojiPackState.findEmojiTags, which now resolves
  codes through a map lookup instead of a linear scan per code
- ContactCardsState owns the emoji resolution for nickname saves (Account
  just publishes), takes EmojiPackState, and drops its unused scope param
- new synchronous cachedPetName path (decryption-cache read, no crypto) seeds
  observeUserName/observeUserPetName initial values, removing the flash of
  the real name before the flow's first emission
- nickname dialog prefill no longer clobbers text typed while a slow external
  signer decrypts the existing card

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY
2026-07-13 12:40:38 +00:00
Claude 184f0bfde7 refactor: align ContactCardEvent with the nip88Polls class structure
Replicates the PollEvent layout: all tag parsing moves to TagArray extensions
in TagArrayExt.kt with the event accessors delegating to them, builder
extensions use addUnique for single-instance tags, and construction goes
through template-returning builders instead of methods that sign internally.

- build() returns an EventTemplate<ContactCardEvent> (the signer is only used
  to NIP-44 encrypt the private tags, matching TrustProviderListEvent);
  create() remains as the signer.sign(build(...)) convenience and now takes
  the emoji list directly
- updatePetNameAndSummary() returns an unsigned EventTemplate; callers sign
  it (ContactCardsState and tests updated)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY
2026-07-13 12:28:23 +00:00
Claude ebeecd1ee7 feat: custom emojis in contact card petnames
Nicknames can now use NIP-30 custom emojis: typing : in the nickname dialog
autocompletes from the account's emoji packs, and the emoji mappings for any
shortcode used are embedded in the card's NIP-44 encrypted content next to the
petname — so even the emoji set stays private. Renderers resolve the petname's
shortcodes against the card's decrypted tags instead of the profile's metadata
tags.

- quartz: updatePetNameAndSummary replaces the private emoji tag set wholesale
  and keeps it out of the public tags; round-trip test added
- commons: PetName(name, tags) holder with content equality, decryption cache
  returns the merged decrypted tag list, EmojiPackState.findEmojiTags resolves
  :codes: against the selected packs
- amethyst: Account embeds resolved emoji tags on save; all petname render
  sites pass the card tags to the WithEmoji composables; nickname dialog gets
  the : emoji autocomplete via EmojiSuggestionState

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY
2026-07-13 01:39:59 +00:00
Claude 8ee90907ca feat: nickname users via NIP-85 contact cards signed by the account key
Adds petnames/nicknames per https://github.com/nostr-protocol/nips/pull/761,
reusing the kind:30382 contact card already used for WoT scores — a card only
acts as a nickname when signed by the account's main key. Petname and summary
are always stored in the card's NIP-44 encrypted content.

quartz:
- ContactCardEvent.updatePetNameAndSummary edits both fields in the encrypted
  private tags, strips stray public copies, preserves every other tag
- TagArray.petName()/summary() parsers for decrypted tag lists + tests

commons:
- ContactCardDecryptionCache: LRU NIP-44 decrypt cache for own cards
- ContactCardsState: account-scoped access to the account's own cards,
  petname flow per target user, create/update entry point

amethyst:
- Account.updateContactCardPetName publishes through the extended outbox
  relays (NIP-65 write + private outbox + local + broadcast) and the login
  subscription now downloads the account's own kind:30382s from its relays
- petname renders instead of the display name in usernames, profile header,
  chats and @mentions (observeUserPetName)
- Edit-nickname action + dialog on the profile actions menu

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY
2026-07-13 01:26:27 +00:00
davotoulaandClaude Opus 4.8 d7e163111a build: remove Kover coverage plugin
Reverts the Kover coverage aggregation (introduced in dca345212b) from every
module, the root aggregation block, the Sonar coverage import, the version
catalog, and BUILDING.md. With the plugin present the 16 GB CI runner OOM-killed
during the Android test+build job even with Kover opt-in disabled; removing it
entirely clears the OOM. No other CI changes — the diff versus main is exactly
the inverse of dca345212b.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 16:52:29 +01:00
davotoula 0053edf2dd refactor: adopt androidx.core KTX helpers (Bitmap/Uri/SharedPreferences) 2026-07-11 17:00:40 +01:00
Vitor PamplonaandGitHub 00e02048ea Merge pull request #3536 from davotoula/feat/kover-coverage
Aggregate Kover coverage (for SonarQube import)
2026-07-11 11:02:23 -04:00
davotoula dca345212b build: aggregate Kover coverage for SonarQube import
- Apply Kover (Apache-2.0, build-time only) to the KMP/JVM modules and aggregate at the root
- include amethyst playDebug in the aggregated Kover coverage
2026-07-11 15:37:40 +01:00
Vitor PamplonaandGitHub 8e38ea6607 Merge pull request #3533 from vitorpamplona/claude/amethyst-miner-performance-0rw1dn
Parallelize PoW mining with multi-worker nonce search
2026-07-11 09:54:01 -04:00
Claude 5683c74629 perf(amethyst): mine on half the device's cores in the Android app
Each PoW job now races PoWPolicy.minerWorkers(cores) = cores/2 parallel
searches (at least 1) instead of a single thread, so a post on an 8-core
phone mines ~4x faster while the other half of the cores stays free for
the UI, and the total CPU budget matches the old 2-job x 1-thread pool.

- PoWPolicy.minerWorkers(availableProcessors) is the single definition of
  the per-job worker budget.
- PoWPublishQueue gains minerThreads (used by its built-in miner and
  exposed for custom mine lambdas); AppModules wires maxConcurrent = 1 +
  minerThreads = half the cores.
- PoWNostrSigner gains a workers param (default 1), covering reactions,
  reposts and reports via Account.miningSigner; the anonymous-post paths
  in the short-note and comment composers pass the same budget.
- Gift-wrap envelope mining stays single-threaded: the template
  conversion is a non-suspend hook inside the synchronous NIP-59 build.
- UI time estimates (settings picker, composer chip, broadcast banner)
  now benchmark at the queue's worker count via deviceHashesPerSecond(),
  and PoWEstimator caches one rate per worker count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhaF5scnAvhP9wr9R7bTWM
2026-07-11 12:13:32 +00:00
davotoula cf95b07855 fix: document intentionally empty default no-op bodies 2026-07-11 11:12:20 +01:00
Claude ac98036a8e perf(pow): mine NIP-13 proof of work on all cores and drop per-hash allocations
The miner enumerates the nonce space deterministically (the random base is
overwritten before the first hash), so naively racing N copies of the search
duplicates the exact same candidate sequence N times. PoWMiner.mine() now
races workers over disjoint slices instead: each worker's nonce carries a
distinct fixed prefix while only the bytes after it are enumerated, making the
aggregate hash rate scale with cores (~3.8x on a 4-core box).

The hot loop also switches from sha256() to sha256Into() with a reused
32-byte buffer, so hashing no longer allocates per attempt.

amy wiring:
- `pow mine` and `post --pow` mine on all cores by default; `pow mine
  --threads N` overrides.
- `pow bench` measures the all-cores rate (what mining now uses, also the
  basis for expected_seconds) alongside a new hashes_per_second_single_core.
- PoWEstimator benchmarks with sha256Into to match the miner, and gains a
  workers overload that prices in cross-core contention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhaF5scnAvhP9wr9R7bTWM
2026-07-11 04:04:28 +00:00
Vitor PamplonaandGitHub e60003063b Merge pull request #3527 from vitorpamplona/claude/nip13-pow-publishing-queue-1takp1
Add NIP-13 Proof-of-Work mining queue with persistence and UI
2026-07-10 21:48:03 -04:00
Claude 39ffb0ed4d feat(pow): move composer PoW button before Subject; switch icon to Manufacturing
The per-post PoW button on the New Post screen now sits before the
Subject/Title toggle instead of last in the options row.

Every PoW surface (composer button, note chip, mining banner, settings tile,
relay-info minimum-PoW row) switches from the Bolt icon to Manufacturing
(gear, U+E726): Bolt is the app's zap/lightning glyph everywhere else, so a
Bolt PoW badge next to the zap-split/zapraiser/invoice buttons read as a
payment feature. Subset font regenerated for the new codepoint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
2026-07-11 01:33:54 +00:00
Claude 6dd1e4d31d fix(pow): resolve audit findings — durability, coverage, perf and l10n
Durability & correctness:
- Keep the job entry + disk checkpoint alive until the publish continuation
  completes (was: dropped at mining-complete); failed publishes keep their
  checkpoint for restart retry and surface through a failures SharedFlow
- Move draft deletion into the publish continuations in every composer so a
  cancelled or process-killed mining job can't destroy the only copy of a post
- Persist gift-wrap mining: split NIP17Factory into createSeals (signer
  interaction, runs inline) + wrapSeal (pure CPU, runs on the queue), new
  REPLAY_WRAPS records restore pending DM wraps after process death
- Clamp synced NIP-78 difficulty (PoWPolicy.MAX_DIFFICULTY), require a sane
  target in PoWMiner, bound PoWRankEvaluator against short ids
- Reaction double-tap while mining now toggles (dedupeKey + cancelByKey)
  instead of publishing duplicates
- Restore checkpoints for every loaded account, not just the active one
- logOff purges the account's checkpoints and cancels its queued jobs
- Private notes: composer chip now gates on the gift-wrap kind and the
  per-post override reaches sendPrivateNote

Coverage:
- Public/live chat (kinds 42 + 1311) and voice replies (1244 + kind-1 audio
  replies) now route through the mining gate

Perf:
- FGS start() dedupes with a running flag; PendingIntents built once
- Banner 1 Hz clock only ticks while a job shows elapsed time
- PoWEstimator benchmark is single-flight behind a Mutex

UX / l10n:
- Post-mining failures toast with retry information
- Count strings converted to <plurals>; elapsed time via DateUtils; settings
  estimate uses localized units; shared powKindLabelRes replaces three
  duplicated kind→label maps; dead pow_chip_* strings removed
- CLI: pow mine lowercases the pubkey before mining (uppercase hex mined an
  id that never matches the signed event) and validates via quartz Hex

New queue tests: checkpoint lifetime, failure reporting, cancelByKey toggle,
per-owner cancellation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
2026-07-11 00:31:05 +00:00
Vitor PamplonaandGitHub 57aaf69073 Merge pull request #3524 from vitorpamplona/claude/relay-icons-display-timing-7c3n7c
Fix relay attribution in gift-wrap chains and Marmot messages
2026-07-10 19:11:47 -04:00
Claude 3a96554c8d feat(relayauth): replace the policy list with Always / Never / Custom + toggles
Restructure the global auth control into a top-level mode — Always authenticate,
Never authenticate, or Custom — where Custom reveals independent per-situation
toggles instead of the confusing single sub-toggle:

- My relays and venues (own relays + joined/subscribed/favorited venues) — on
- Read posts from people I follow — on
- Message people I follow (DMs, replies, notifications) — on
- Message anyone / strangers — off by default (you're asked each time instead)

RelayAuthPolicy is now {ALWAYS, NEVER, CUSTOM}. The resolver takes a
RelayAuthCustomToggles plus split serves-facts (followed-read, followed-write,
stranger-write, own-relay, venue) and, under CUSTOM, allows if any enabled
category matches — else falls through to a prompt. There is deliberately no
"read strangers' posts" category, so that always prompts.

Account settings replace the single delivery flag with four persisted booleans
(default policy CUSTOM; no migration, unreleased). The contextual DM/notification
prompt button now switches to CUSTOM and enables both message toggles. Resolver
tests rewritten per-toggle, including that reading a stranger is never
auto-allowed. Old IF_IN_MY_LIST / TRUSTED_FOLLOWS policies and their strings are
removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:13 +00:00
Claude 9ca1ba2f5c feat(relayauth): reframe follows trust as relationship vs. message delivery
Restructure the two TRUSTED_FOLLOWS controls so they read as independent ideas
instead of a confusing read/write sub-toggle:

- The "My relays and people I follow" policy now trusts a followed user as any
  counterparty — reading their posts AND reaching them (DM/notification) — plus
  your own relays and joined venues. Reading your follows is no longer gated.
- The sub-toggle is repurposed to "Also log in to deliver my messages": trust a
  relay to send DMs, replies or notifications to anyone you're talking to, even
  people you don't follow.

Resolver: replace servesFollowed{Write,Read}Counterparty with a single
servesFollowedCounterparty, add servesWriteCounterparty (an inbox of anyone
you're messaging), and gate the latter behind the new
messageDeliveryTrustEnabled input. Rename the account setting
relayAuthTrustFollowsForReads -> relayAuthTrustMessageDelivery (+ pref key;
no migration needed, unreleased). The contextual prompt button moves from
read-post prompts to DM/notification prompts and now enables message delivery.
Resolver tests updated for the new inputs, including that the delivery toggle
is write-only and never auto-allows reading a stranger.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:13 +00:00
Claude 3526a14ebe feat(relayauth): venue-aware auth for public chats, communities & live streams
The purpose model was person-centric, so an auth-required NIP-28 channel,
NIP-72 community, or NIP-53 live stream broke: reads filter by #e/#a with
no authors, so nothing was attributable -> silent DENY (chat wouldn't
load); top-level posts (no p tags) also silently failed; replies were
mis-attributed as "notify" and trusted on the wrong signal.

Add venue purposes (POST_VENUE / READ_VENUE) carrying the venue id
(channel event-id, or community/live `kind:pubkey:dTag` address). The
deriver recognizes kind-42 channel posts (root `e`), community/live posts
and reads (`#a` 34550/30311), and channel reads (`#e`). Venues are trusted
under TRUSTED_FOLLOWS when you've joined them (publicChatList /
communityList) or their owner — the pubkey in the address, e.g. a live
stream's host — is someone you follow; trusted venues auto-auth for both
reading and posting.

Safety net: any active use we still can't attribute yields an OTHER
purpose so the relay is prompted about instead of failing silently.
Prompt copy gains venue-aware titles/consequences ("Post to this room?",
"If you don't, your message won't be posted."). Default policy already
TRUSTED_FOLLOWS, so joined venues just work. Unit-tested across resolver
and deriver.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:11 +00:00
Claude c29374f089 feat(relayauth): surface failed sends + per-relay forget/last-used
Consume the new give-up signal: RelayPublishFailureToastSubscription
(hosted in LoggedInPage) listens for onEventGaveUp and toasts "couldn't
deliver to <relay>", so a dropped send is visible instead of silent.

Rationale polish in the auth settings screen: each relay card now shows
"Last used N ago" and a Forget button that clears both the ALLOW/DENY
override and the accumulated rationale for that relay. The store gains
clearRationale + allLastUsed (default-implemented on the interface) and
records a last-used timestamp on each grant; clearDecision/clearRationale
now prune the shared url key only when a relay has neither an override
nor rationale left, so a partial clear never orphans the reverse-lookup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:10 +00:00
Claude 70b23b6999 feat(relayauth): remember and show why each relay was granted
Extend RelayAuthPermissionStore with grant rationale (purpose ->
counterparty pubkeys), default no-op so other implementers are
unaffected; DataStoreRelayAuthPermissionStore persists it per relay,
merging counterparties across grants. AuthCoordinator records the
rationale via ledger.recordGrant whenever it authenticates (auto-allow,
override, or approved prompt). The relay-auth settings screen adds a
"Why you're logged in to these relays" section: per relay, purpose-
grouped rows ("Send your private message to:", "Download posts from:")
with each counterparty's avatar + name. Unit-tested: grouping, cross-
grant merge, and that counterparty-less purposes aren't recorded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:10 +00:00
Claude 4bd73596e3 feat(relayauth): add purpose-aware AUTH policy core + TRUSTED_FOLLOWS
Introduce the platform-neutral decision core for contextual NIP-42 auth
in commons/relayauth:
- AuthPurpose / AuthPurposeKind / RelayAuthContext describe *why* a relay
  wants auth (send DM, deliver notification, read outbox, own relay).
- RelayAuthVerdict adds an ASK outcome (runtime-only; never persisted,
  unlike the two-value RelayAuthDecision override).
- RelayAuthResolver is a pure, unit-tested precedence ladder: blocked
  list > per-relay override > policy > ASK-if-attributable-else-DENY.
- New TRUSTED_FOLLOWS policy: auto-auth for relays serving a followed
  counterparty on a write purpose (DMs/notifications), and — behind a
  read sub-toggle — read purposes; strangers fall through to ASK.

Wires the new enum value through the existing settings screen (new
option + strings, reusing the Group symbol) and the URL-only ledger path
(degrades to the my-list check until challenge context is plumbed).
Live prompt UI, grant-rationale persistence, and quartz challenge-context
plumbing are follow-up steps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:09 +00:00
Claude 1cffd8db18 fix: propagate relay acceptances into gift-wrap chains for chat relay icons
Chat rows render the inner rumor note of a NIP-17 message, but relay
attribution only landed there through narrow windows, so accepted relays
often never showed as icons:

- Duplicate deliveries were stranded on the wrap: a gift wrap re-delivered
  by a second relay hit the duplicate branch of consumeRegularEvent, which
  tagged the outer wrap note only and never re-processed the event. Extract
  the OK-path drilling (wrap -> seal -> rumor) into
  LocalCache.addRelayToNoteAndInners and call it from both the OK
  confirmation path (markAsSeen) and the duplicate EVENT path, replacing
  CacheClientConnector's private copy.

- Cross-thread visibility: Note.event, Note.relays, Note.flowSet and the
  innerEventId of GiftWrapEvent/SealedRumorEvent are written by decrypt/index
  coroutines and read lock-free on relay socket threads; a stale read parks
  an acceptance on the outer envelope permanently. Mark them @Volatile.

- Orphaned UI flows: RenderClosedRelayList/RenderAllRelayList and
  createMustShowExpandButtonFlows captured note.flow().relays.stateFlow once
  in remember/stateIn; MemoryTrimmingService.cleanObservers destroys the
  unobserved NoteFlowSet while the lifecycle is stopped, so resumed rows
  never saw another relay update. Wrap in a cold flow that re-resolves
  flow() on every collection start.

- Indexing latency: sent DMs waited for the ~1s newEventBundles batcher
  before the self-wrap was unwrapped and the message reached the chatroom,
  parking early OKs on the wrap. broadcastPrivately and
  sendNip04PrivateMessage now run the EventProcessor on the freshly
  consumed note immediately; the batched re-delivery is idempotent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YEiq1NMK3q12KGQ2yYhPEp
2026-07-10 22:29:41 +00:00
Claude 97fc801294 fix: NIP-13 compliance — refresh created_at at mining start, clean nonce tag
Findings from a full NIP-13 review:

- The spec recommends updating created_at while mining. Queue jobs now
  re-stamp the template to "now" when a worker picks them up (a post can
  wait behind other jobs, and a job restored after process death could be
  hours old); the restorer does the same. Scheduled posts are exempt —
  their future created_at is intentional. Anonymous posts re-stamp before
  mining against the throwaway key.
- PoWTag.assemble(nonce, null) serialized the literal string "null" as
  the third tag entry; a missing commitment now omits the entry entirely.
- New tests: PoWTagTest pins the NIP-13 example tag shape and the
  no-commitment round trip; a queue test asserts the created_at
  re-stamp at mining start.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
2026-07-10 22:22:04 +00:00
Vitor PamplonaandGitHub 4d16d6e38f Merge pull request #3522 from vitorpamplona/claude/richtext-trailing-whitespace-85eudl
Strip trailing whitespace from rich text parser output
2026-07-10 18:11:21 -04:00
Claude 24ea909221 fix: remove trailing whitespace and newlines when rendering RichText
Trailing spaces and newlines in a note's content produced empty trailing
paragraphs after the line split, each rendered as a blank FlowRow line
between the last visible word and the end of the component. Trim the
content's tail before segmenting, and return no paragraphs for
whitespace-only content.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZET7ig8MvR5vSCSxvcURi
2026-07-10 22:09:11 +00:00
Claude 96965305cf fix: use Brainstorm's real apple-touch-icon in browser suggestions
https://brainstorm.world/brainstorm.svg 404s (the SPA serves its HTML
not-found page with a 200), so the Discover row fell back to the globe
glyph. Point at the site's declared 180x180 apple-touch-icon.png instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EzQhmuFNULgiP31XqMRpJP
2026-07-10 21:53:49 +00:00
Claude 6ef1caef77 feat: polish the PoW surfaces — icon chip, animated banner, time estimate, note pill
- Composer chip: replaced the text button with a bolt icon carrying the
  difficulty as a small badge (primary-tinted when active, dimmed when
  off), matching the layered-icon language of the rest of the options
  row; the current choice is bolded in the override menu.
- Mining banner: pulsing bolt while the nonce search runs, per-job
  elapsed time driven by a 1 Hz clock ("Note • mining at 20 bits • 12s"),
  and proper close IconButtons instead of a text "×". PoWJobState now
  carries miningStartedAt for the elapsed display.
- Settings: a live "≈ 45s per post on this device" estimate under the
  difficulty picker, from a one-shot cached sha256 benchmark
  (PoWEstimator in commons) and the 2^d expected-attempts mean.
- Received notes: DisplayPoW's plain "PoW-24" text is now a compact
  secondaryContainer pill (bolt + bits) used by NoteCompose, thread view
  and chat messages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
2026-07-10 21:22:27 +00:00
Claude 35647d0a5c feat: persist the PoW queue and shield it with a shortService foreground service
Backgrounding the app no longer pauses or loses mining:

- commons: PoWPublishQueue checkpoints template jobs through a
  PoWJobPersistence hook (saved on enqueue, removed on finish/cancel,
  deduped by id so restore is idempotent) and fires onQueueActive on
  every enqueue. PoWReplay flattens the live continuation into a
  PersistedPoWJob record (broadcast / to-relays / schedule) that can be
  replayed headlessly. Opaque enqueueWork jobs (reactions, reposts, gift
  wraps) and anonymous posts stay in-memory on purpose — a throwaway key
  and its content must never touch disk.
- amethyst: PowJobStore (atomic-rename JSON file, single-lane writer,
  3-day staleness purge) and PowJobRestorer, which re-enqueues an
  account's checkpointed jobs on login and finishes them via the replay
  descriptor. Composers now pass the matching PoWReplay for the public,
  group-thread and scheduled paths.
- PowMiningForegroundService: a shortService-type FGS started on every
  enqueue (~3 min guaranteed budget, no special permission) that keeps
  the process out of the cached-apps freezer while mining. Stops itself
  when the queue drains; on onTimeout it exits cleanly since the jobs
  are checkpointed. The notification is a live progress card
  (NotificationCompat.ProgressStyle): one track segment per post filling
  as jobs finish, indeterminate for a single post, per-kind text, a
  cancel-all action, and Live Updates rendering on Android 16+.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
2026-07-10 21:08:51 +00:00
Claude 8988c98308 feat: add NIP-13 proof-of-work publishing with a fire-and-forget mining queue
Implements user-facing PoW publishing (#3317) on top of the existing
quartz miner:

- quartz: PoWMiner.run gains a cooperative isActive cancellation hook
  (checked every ~VALID_BYTES^2 hashes); PoWNostrSigner decorator mines
  kind-scoped templates pre-signature so it composes with every signer;
  GiftWrapEvent.create/NIP17Factory can mine the outer ephemeral-key
  wrap (never the seal/rumor); NostrSignerWithClientTag exposes
  prepareTags so mining runs over the final tag set.
- commons: PoWPublishQueue (FIFO, capped worker pool on
  Dispatchers.Default, per-job cancel, in-memory only — unmined posts
  are lost on process death, logged) and PoWPolicy (kind-group
  categories with a hardcoded NEVER list: auth, zap requests, NWC and
  bunker RPC, HTTP/Blossom auth, drafts, metadata and lists, OTS).
- amethyst: per-account synced settings (difficulty Off/16/20/24/28 or
  custom, per-category checklist) in Compose Settings; Post enqueues the
  template and returns immediately; reactions, reposts, reports, private
  notes, DMs and long-form route through the same shouldMine gate at
  their existing choke points; per-post PoW override chip in the
  composer options row; "Mining proof of work… (N in queue)" phase with
  per-job cancel in the broadcast banner.

Scheduled posts and anonymous posts mine against the correct key
(scheduled posts skip mining in v1); the client tag is applied to the
template before mining so signing never invalidates the nonce.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
2026-07-10 20:24:45 +00:00
Vitor PamplonaandGitHub 525d3ef189 Merge pull request #3514 from vitorpamplona/claude/armada-nip29-integration-lwqard
Relay Groups: NIP-29 relay-based group chat (Armada interop)
2026-07-10 13:03:48 -04:00
Claude ace7a7f476 fix(relay): enforce blocked relays centrally on every REQ/COUNT/publish
Relay targeting is fully distributed: every feed, loader, finder and
broadcast path builds its own relay set and hands it to the shared
INostrClient. Only the follow-outbox flows and the top-nav feed filters
subtracted the NIP-51 kind:10006 blocked list, so blocked relays still
leaked in through the event/thread loaders (FilterMissingEvents /
FilterMissingAddressables), the user-metadata finder
(pickRelaysToLoadUsers), channel finder, DM targeting, the one-shot
fetch helpers, and the publish path (Account.computeRelayListToBroadcast)
— none of which consulted the blocked set.

Add BlockedRelayFilteringClient, a thin INostrClient decorator that
strips the active account's blocked relays from subscribe, count and
publish right before they reach the pool. Because the one-shot fetch
helpers route through subscribe/count, wrapping the client covers them
too. The blocked set is read per-call so account switches and list
edits apply with nothing to invalidate.

Wire it around the shared app client (blocked set from the logged-in
account) and around the per-account crawl client used by Event Sync and
Cashu discovery. Add commonTest coverage for the filtering, pass-through,
fully-blocked, and per-call-read behaviors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JNMPdC2eGUwTrt3XkQefuf
2026-07-09 21:50:55 +00:00
Claude 2eb7ff2ef2 Merge remote-tracking branch 'origin/main' into claude/armada-nip29-integration-lwqard
# Conflicts:
#	cli/tests/.gitignore
2026-07-09 21:48:04 +00:00
Claude a645688baf test+polish: verify NIP-29 placeholder gatherer; show "No messages yet"
Add a regression test proving RelayGroupChannel.placeholderNote() carries the
channel as a gatherer and is cached/stable — this is what lets the Messages row
renderer resolve the event-less placeholder back to the group (the prior fix).

Also give the empty-group placeholder row a visible "No messages yet" second
line instead of blank content, matching the Marmot-group row, so a just-joined
group with no messages reads clearly rather than looking like an empty item.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 21:21:59 +00:00
Claude dae4ade55a fix: show a just-joined NIP-29 group on the Messages tab immediately
The Messages tab (INLINE mode) mapped each joined group to its newest cached
kind-9 message and dropped it when none existed. Since the joined-groups
subscription only fetches roster kinds (39000/1/2), not chat, a group you just
joined stayed invisible until you opened it (loading messages) or posted — unlike
Marmot groups, which already fall back to a placeholder row.

Mirror the Marmot pattern for relay groups:
 - RelayGroupChannel.placeholderNote(): a cached synthetic note that adds the
   channel as a gatherer, so the existing Messages row renderer resolves it back
   to the group (RelayGroupRoomCompose already handles a null-event note).
 - ChatroomListKnownFeedFilter.feed(): fall back to placeholderNote() when the
   group has no loaded message.
 - AccountFeedContentStates: rebuild dmKnown when relayGroupList (kind 10009)
   changes — join/leave doesn't flow through newEventBundles, so without this the
   placeholder wouldn't appear until a later event.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 20:22:00 +00:00
Claude 4fca9ae25e feat: message-count + follows-in-group on discovery cards; split relay chip
Three group-discovery card changes:

- Reactive loaded-message count. The preview subscription streams the group's
  recent kind-9 chats into the channel note cache; the card now shows that count
  on the stats line ("12 members · 50+ messages"), updating live as messages
  arrive. Bumped the preview page 15 -> 50 so an active chat reads as "50+"
  (also a better warm-up); the display caps at DISCOVERY_MESSAGE_CAP.
- People-you-follow social proof. RelayGroupChannel.participatingFollows()
  intersects the relay-signed roster with the kind-3 follow set; the card shows
  an overlapping face pile + "%d people you follow" caption when non-empty.
- Split the relay chip's tap targets. Tapping the chip body now opens that
  relay's full group list (Route.RelayGroupServer); only the star toggles the
  relay favorite. Previously the whole chip favorited, which was easy to hit by
  accident.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 18:24:36 +00:00
Claude a4679da0a9 refactor: move GroupDiscoveryConstraint matcher to commons
The sealed GroupDiscoveryConstraint matcher (AllGroups/ByPeople/ByHashtags/
ByGeohashes/AnyOf) is pure over RelayGroupChannel + HexKey with no LocalCache,
eose-manager, or topNavFeeds dependency, so it belongs in :commons alongside
RelayGroupChannel where Desktop/CLI can reuse it. The amethyst dal keeps only
the platform-specific toGroupConstraints() mapping from the Android top-nav
filter set onto the shared matcher.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 15:25:03 +00:00
Claude 69204f16cb fix: use Mutex instead of JVM-only synchronized in AuthApprovalPolicy
The synchronized intrinsic is only available on JVM/Android, breaking the
iOS (compileKotlinIosSimulatorArm64) build in commonMain. Replace the Any()
lock with a kotlinx.coroutines Mutex + withLock, which is KMP-common and
safe here since all three store methods are already suspend functions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A918G2Ks9i9LXRuVoNgm7R
2026-07-09 15:19:01 +00:00
Claude 34bbb7b727 fix: robohash crash on short ids + standard Relay Groups top bar/layout
- RobohashAssembler read up to hash[10] but only required the input to be
  >10 chars; a 16-char NIP-29 group id is valid hex that decodes to 8 bytes,
  so hash[8] threw ArrayIndexOutOfBoundsException and crashed the discovery
  feed. Require >=22 hex chars (>=11 bytes) before decoding; anything shorter
  falls back to sha256 (32 bytes). Latent crash for any short hex seed.
- The discovery screen used a hand-rolled ShorterTopAppBar (dropping the
  standard search icon + memory chip) to fit a browse action. Switched to the
  shared UserDrawerSearchTopBar like every other top-level feed; the
  browse-a-relay action moved to the FAB (DisappearingScaffold.floatingButton).
- Removed the extra Column(Modifier.padding(padding)) wrapper that double-
  applied the top-bar inset (rememberFeedContentPadding already accounts for
  it) — that was the large blank block above the first card.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 14:47:04 +00:00
nrobi144 3f0ad05b58 fix(commons): guard RelayLatencyTracker.sweep against ConcurrentModificationException
The per-relay pending maps in RelayLatencyTracker are
Collections.synchronizedMap(LinkedHashMap): individual read/write
ops are thread-safe, but per the synchronizedMap javadoc iteration
is NOT — callers MUST hold the map's monitor while walking its
views. sweep() was iterating directly, so any network-dispatcher
mutation (adding a pending REQ, receiving an OK) during a sweep
would throw ConcurrentModificationException on AWT-EventQueue-0,
killing the Compose renderer while coroutine work kept running.

Pre-existing bug, documented in memory
desktop_relay_health_cme_crash. Ordinarily "not our problem", but
it's actively blocking manual T3 testing of this branch's AUTH
approval banner: adding any new relay triggers a
RelayHealthStore.reclassify sweep, so testers can't get a banner
render in without hitting the crash. Fix it here so the branch is
actually testable end-to-end.

Wrap both iteration loops in synchronized(pending) blocks. Sweep is
O(pending) with typically single-digit entries per relay, so the
hold time is negligible and the network dispatcher just briefly
waits.

Reproduced during manual T3 testing 2026-07-06 when adding
wss://pyramid.fiatjaf.com. Stack: RelayLatencyTracker.sweep:182 →
RelayHealthStore$reclassify$flagged$1.invokeSuspend:268.
2026-07-09 07:33:43 +03:00
nrobi144 2061ef9e37 fix(desktop): align pre-send DM validation with strict NIP-17 semantics + wire resolver fan-out
Two related fixes that surface the same class of bug: the pre-send
"Recipient has no DM relay list" warning could disagree with the
actual send path, causing sends that either fail after the user
clicks Send, or block sends the user would have expected to work.

(a) STRICT ALIGNMENT — User.dmInboxRelays() (commons) is the lenient
    "give me a delivery target for a DM" helper: it returns kind:10050
    if present else the NIP-65 read marker (kind:10002). The send path
    (DesktopIAccount.resolveDmInboxRelaysStrict → DmInboxRelayResolver)
    uses the strict variant that returns kind:10050 only, because NIP-17
    §Publishing mandates delivery to the recipient's kind:10050
    exclusively — routing a wrap through a NIP-65 read relay leaks the
    conversation metadata to a relay the recipient did not designate
    for DMs.

    Add User.dmInboxRelaysStrict() as the kind:10050-only accessor and
    switch ChatNewMessageState.updateRecipientRelayStatus() to it so the
    UI's "can we deliver" check matches what the send path actually
    enforces.

(b) RESOLVER PROBE — pre-send validation was cache-only: if a peer's
    kind:10050 hadn't landed in LocalCache yet (e.g. the peer just
    published, or their event sits on an indexer relay we don't
    subscribe to), the UI reported them unreachable and blocked send
    even though the send path's DmInboxRelayResolver would have found
    them via indexer fan-out.

    Add an optional `dmInboxResolver: suspend (HexKey) -> List<...>?`
    callback to ChatNewMessageState. On a synchronous cache miss for any
    peer, the state optimistically blocks (preserving the "don't
    silent-fail" invariant) and launches a probe. If any peer's relays
    turn up, unblock immediately without requiring the user to reopen
    the conversation.

    Wired at both ChatNewMessageState construction sites in
    DesktopMessagesScreen to DesktopIAccount.dmInboxResolver (which
    Main.kt injected in the earlier P4 wire-up commit). Android's
    ChatNewMessageViewModel is a separate class and keeps
    cache-only behaviour — Android UI parity is deferred.

Surfaced during manual testing 2026-07-06 with two accounts where one
had a kind:10050 and one didn't: the UI correctly blocked send, but
the block persisted even after publishing kind:10050 for the missing
account until the conversation was reopened.
2026-07-09 07:33:43 +03:00
nrobi144 007217f407 fix(desktop): pad AuthApprovalBanner around macOS traffic lights
Amethyst Desktop uses apple.awt.fullWindowContent = true (see
applyNativeWindowChrome), which draws content edge-to-edge under
the title bar so the macOS traffic-light buttons overlap whatever
sits in the top-left of the App content column.

The AuthApprovalBanner mounts at (0, 0) of the content column,
which put its lock icon + "pyramid.fiatjaf.com" text directly
under the red/yellow/green window buttons. Screenshotted in
manual testing 2026-07-06.

Two-part fix:

1. Bump the row's own padding from horizontal 12dp / vertical 8dp
   to horizontal 16dp / vertical 10dp for better breathing room
   in general.

2. At the mount site in Main.kt, wrap the banner in a
   platform-aware Modifier: on macOS pad 80dp from start (clears
   3 traffic lights at 14pt each + spacing) plus 8dp top / bottom
   4dp; on other platforms just an 8dp horizontal / 4dp vertical
   margin. Non-mac users see the banner flush-ish since their
   window chrome doesn't overlap.

Padding lives at the mount site so the banner composable itself
remains reusable inside chat panes or other contexts where
traffic-light clearance isn't needed.
2026-07-09 07:33:42 +03:00
nrobi144 49d31ccb44 feat(commons): SigningOpState.Progress for per-step in-flight UI
Adds a Progress(current, total, label?) variant to SigningOpState so
multi-step signing operations (NIP-17 group sends via remote signer,
batched zaps) can show "Encrypting via remote signer (3 of 5)" rather
than an opaque indeterminate spinner.

Backwards compatible:
- Pending stays a data object — existing callers' `is Pending` checks
  unaffected.
- New helper `isPending()` returns true for both Pending and Progress;
  SigningState.execute uses it so a second execute() during Progress
  returns null (matching the old single-flight semantics).
- SigningAwareButton renders both Pending and Progress as a spinner;
  callers wanting the counter must read the state directly.
- SigningStatusBar adds a Progress branch that shows "<label>
  (<current> of <total>)" — uses "Signing" as default label.

New `SigningState.updateProgress(current, total, label?)` lets the
in-flight block emit progress updates between Pending start and
finish. No-op when state is Idle/Error so callers don't have to gate.

Wire-up for NIP-17 bunker sends (publishing per-recipient progress
during NIP17Factory.createWraps' mapNotNullAsync) is a follow-up
that depends on threading the SigningState reference into the
factory's signing lambda; the substrate is here.
2026-07-09 07:33:42 +03:00
nrobi144 4ce21e7034 test(commons): AUTH end-to-end exercising policy → signer round-trip
Four tests covering the lambda shape that DesktopAuthCoordinator's
signWithAllLoggedInUsers calls into for every NIP-42 challenge:

  build RelayAuthEvent template → classify via policy → sign or
  block → return List<RelayAuthEvent> for RelayAuthenticator

- tier-1 own-inbox auto-signs a valid kind:22242 event with the
  right challenge + relay tags
- tier-2 unknown surfaces a PendingAuthApproval; ONCE resolution
  produces a signed event (no persistence)
- tier-2 BLOCKED returns null AND persists the rejection
- tier-2 ALWAYS persists and skips the prompt on subsequent calls

Concurrency: the policy.classify call inside the lambda suspends on
the CompletableDeferred when prompting; tests use coroutineScope +
async + yieldUntilNotNull to model the banner-resolving-from-outside
pattern, mirroring how DesktopAuthCoordinator.resolve() drives the
deferred from a UI click.

Together with the existing PoolEventOutboxStateTest (auth-required
carve-out), AuthApprovalPolicyTest (classifier), and
GiftWrapRelayHintTest (NIP-17 hint placement), this completes
unit-level coverage of the AUTH pipeline. The websocket-level
round-trip stays covered by geode/.../KtorRelayTest.kt against a
real Ktor mock relay; that infra is reusable for a future
desktopApp integration test that combines mock relay + this stack.
2026-07-09 07:33:42 +03:00
nrobi144 2240d64ae8 test(commons): cover DmInboxRelayResolver three-layer lookup + cache
Eight tests covering the resolver contract:

- localLookup hit short-circuits indexer fan-out
- empty indexer set returns empty
- empty local + empty indexer (no events arrive) yields empty
- cache hit within TTL skips indexer
- cache expiry triggers fresh indexer call
- clear() wipes all entries
- invalidate(pubkey) removes only the named entry
- localLookup returning an EMPTY list falls through to cache/indexer
  (the takeIf { isNotEmpty() } guard — emptyList from localLookup
  means "I don't know", not "I know they have nothing")

Uses EmptyNostrClient so RecipientRelayFetcher.fetchRelayLists returns
no events — covers the canonical "indexer found nothing" path without
needing a real mock relay. Tests for the populated-indexer path will
land with the Phase 4 wire-up commit when a Ktor-based mock relay is
plumbed through.
2026-07-09 07:33:22 +03:00
nrobi144 e091f6d3d3 feat(commons): DmInboxRelayResolver with strict kind:10050-only fan-out
Three-layer resolver for "where do I publish this NIP-17 gift wrap":

1. LocalCache hit — if the caller already saw the user's kind:10050
   via the regular feed pipeline, skip I/O entirely.
2. In-memory LRU cache — TTL 1h, 100 entries; avoids re-querying
   indexers when opening several conversations in sequence.
3. Indexer fan-out — RecipientRelayFetcher against a curated set
   (DefaultDmIndexerRelays: relay.nos.social, relay.damus.io,
   nos.lol, relay.nostr.band, purplerelay.com — purplepag.es
   deliberately excluded for poor kind:10050 coverage).

Strictness vs. the existing User.dmInboxRelays():
  - filters to kind:10050 ONLY; NEVER falls back to NIP-65 read
    marker (kind:10002). User.dmInboxRelays() silently substitutes
    that, which is the same metadata-leak class fixed by 5293dae65.
  - empty list = canonical "unreachable" signal; caller refuses to
    publish (DesktopIAccount.resolveDmInboxRelaysStrict already
    does this).

Security: the NostrClient passed in MUST be a dedicated
unauthenticated instance — no RelayAuthenticator attached. An
authenticated indexer fan-out (the current state with the primary
client) would extract identity-key signatures during the kind:10050
probe, escalating "indexer learns we want to DM pubkey X" into
"indexer learns user U wants to DM pubkey X". KDoc warning is
explicit; Phase 4 follow-up creates the unauth client in Main.kt
and injects it.

LocalLookup callback is plugged via lambda so CLI / headless
callers (amy) can use this without a Compose LocalCache.

Not yet wired into DesktopIAccount.resolveDmInboxRelaysStrict —
that wire-up is the next commit and converts the sync helper to
suspend, threading through sendNip17* batch construction.
2026-07-09 07:33:22 +03:00
nrobi144 2f3805bbfa feat(commons,desktop): inline AUTH approval banner with [Once] [Always] [Never]
Adds AuthApprovalBanner in commons.relayClient.auth — a Compose-
Multiplatform composable that renders one row per pending tier-2
NIP-42 AUTH challenge with three actions matching the AuthApprovalScope:

  [Once]    — sign this challenge, don't persist
  [Always]  — sign + persist ALWAYS via the store
  [Never]   — drop + persist BLOCKED via the store

Wired into desktop Main.kt as a global top-of-content banner reading
authCoordinator.pendingApprovals and calling authCoordinator.resolve.
Now tier-2 challenges actually have a UI to resolve — desktop AUTH is
end-to-end usable.

Up to 3 rows stack inline; the rest collapse into a "+N more pending"
row (click-to-expand can come later). Each row shows the relay's
display URL plus message-count when multiple challenges from the same
relay have coalesced.

The composable itself is in commons so Android picks it up free when
its AccountAuthApprovals VM wire-up lands — only the Main.kt-level
wiring (where to mount the banner in the layout) is platform-specific.

Lifecycle:
- Banner subscribes to pendingApprovals via collectAsState; recomposes
  only when the PersistentMap identity changes (per the substrate
  built in earlier commits).
- onResolve calls authCoordinator.resolve(url, scope), which completes
  the underlying CompletableDeferred + removes the entry from the
  pending map; the suspended signer wakes up and signs (or doesn't).
2026-07-09 07:33:21 +03:00