- 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
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
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
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
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
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
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
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
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
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
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
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
- 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
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
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
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
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
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
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
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
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
- 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
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.
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.
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.
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.
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.
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.
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.
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).
The current Android-only AuthCoordinator signs every NIP-42 AUTH
challenge from every relay unconditionally (and across every logged-in
account). For desktop there is no AUTH wiring at all — challenges are
ignored, so AUTH-walled relays silently drop DMs.
Both behaviours fail the security review: unconditional signing lets
any relay the user reads (or any malicious relay they touch) extract an
identity-key signature with timestamp, and signing across all accounts
links them under one relay observer.
This commit adds the substrate for a tiered classifier — wire-up will
follow with the desktop AuthCoordinator (P2.5) and SQLite-backed
persistence (P2.4). The policy itself is platform-agnostic and lives in
commons so Android can adopt the same design later.
Two tiers, no third silent-drop path:
- auto-allow when the relay is in the user's own outbox/DM-inbox set,
or has a persisted ALWAYS grant (subject to BLOCKED override)
- prompt-and-suspend via CompletableDeferred for everything else, with
the user's `[Once] [Always] [Never]` choice driving the deferred
Includes InMemoryAuthApprovalStore for tests + the ONCE session cache;
SqliteAuthApprovalStore lands in P2.4 with the sibling outbox.db.
Eight unit tests cover tier-1, persisted ALWAYS, persisted BLOCKED
(including BLOCKED overriding tier-1), unknown-prompt-then-cache,
re-eval of selfApprovedRelays on Account changes, and store.clear().
Correctness:
- Discovery sort no longer reads createdAt live in the comparator (TimSort
"contract violated" crash risk); orders by member count then the shared
sortedByDefaultFeedOrder snapshot.
- One shared resolver (relayGroupDiscoveryChannelFor) is used by the feed
match, sort AND the row, so a 39000 seen on >1 relay always binds one
channel — no more "sorted by relay B, rendered with relay A's empty roster".
- Roster (39001/39002) arrivals now re-inject the group's 39000 into the feed
and re-invalidate the datasource, so a group where a follow is an admin/
member surfaces instead of staying hidden / frozen at 0 members.
- Group replies route to the group's host (resolved from the channel), never
falling through to signAndComputeBroadcast — fixes the outbox leak when the
parent note had no relay provenance.
- Messages list (inline mode) updates group rows incrementally: the additive
path now handles group-scoped messages, not just public/ephemeral/DM.
- Optimistic null-relay group sends attach only to an unambiguous single
channel, so a message to the "_" group on relay A no longer bleeds into "_"
on relay B.
Consistency:
- AllFollows discovery also REQs #t/#g (not just authors), matching the local
AnyOf constraint; muted-authors maps to ByPeople; community stays AllGroups
— fetch and display now agree.
- Global drops the 1-week `since` floor so long-lived group metadata is fetched.
Performance:
- #d metadata backfill scans the group cache once per filter assembly (grouped
by relay), not once per relay.
- Discovery rows warm content only; the directory subscription already streams
metadata/rosters for the relay.
- memberCount is memoized (members ∪ admins recomputed on roster change, not per
read); thread re-sort extracted.
- Discovery list gets rememberFeedContentPadding, contentType and animateItem.
CLI:
- `relaygroup edit` preserves current name/about/tags when only a flag changes.
Cleanup: dropped dead relayKeys() branches and the redundant AnyOf single-lens
collapse in the discovery constraint.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
Reject a single-character group id (except the default `_`) so a possessive
glued to a bare relay URL — `wss://relay.damus.io's uptime` — no longer
linkifies group "s". Real ids (relay29/Wisp/0xchat) are all longer.
Adds coverage proving only genuine ws/wss relay URLs are peeked: apostrophes
after http, nostr:, blossom:, email and bech32 tokens never become group links;
plus ws:// (insecure), second-apostrophe boundary, and multi-link cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
Recognise the de-facto `<relay>'<groupId>[?code=<code>]` NIP-29 group invite
link format used by Wisp and 0xchat, both inside rendered note content and as
an external deep link, so tapping one opens the group.
The URL detector correctly stops a host at the apostrophe (host names can't
contain `'`), so the group id is torn off before classification. Rather than
loosen the shared URL grammar — which would swallow prose possessives like
`example.com's` — group links are recovered by peeking just past each relay
URL the detector already found. This is cache-miss-only and costs nothing on
notes without a `wss://` link.
- quartz: GroupInviteLink.parse / suffixLength (+ tests)
- commons: Urls.groupLinks, UrlParser peek, RichTextParser plumbing
(atomic span through fixMissingSpaces, new RelayGroupLinkSegment) (+ tests)
- amethyst: ClickableRelayGroupLink renderer + uriToRoute deep-link branch
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
Two compatibility gaps found analyzing nostrord (a NIP-29 client):
- Thread titles: NIP-7D (and Amethyst) use a `title` tag, but nostrord
writes/reads `subject`, so neither showed the other's thread titles.
ThreadEvent.title() now reads `title` OR `subject`; we still emit only the
spec-correct `title`.
- Joined-groups list (kind 10009): Amethyst wrote memberships as NIP-44
private items, but both reference clients (Flotilla, nostrord) store — and
nostrord only READS — public `["group", id, relay]` tags, so an Amethyst
user's groups were invisible to them. follow() (and the amy CLI) now write
public tags. NIP-29 membership is already public via the relay's kind-39002
list, so this loses no real privacy; reads still merge any legacy private
items so existing lists keep working.
Tests: read title from title/subject (title wins; we emit title only); public
group is a plain tag and still read through the cache; a mixed public+private
list reads as both.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
Closes the Flotilla interop gap where NIP-29 groups also carry kind-11
"threads" (forum-style posts) that Amethyst's chat-only room view dropped.
Threads are a secondary surface, kept out of the kind-9 chat feed — a
Threads button in the group top bar, mirroring Discord/Slack.
- RelayGroupChannel: a separate `threads` collection (kind-11 notes) with a
reactive StateFlow, distinct from the chat timeline.
- LocalCache: attach kind-11 to channel.threads (same host-pinned + own-send
null-relay routing as chat messages).
- Host-pinned RelayGroupThreadsFilterAssembler (kinds 11 + 1111 scoped by
`#h`), active only while a group's Threads screen is open; fetching the
1111 comments too means opening a thread has its replies already cached.
- RelayGroupThreadsScreen lists a group's threads (title, author, preview,
reply count) and opens each in the existing thread view (Route.Note) for
the full comment tree — no bespoke detail screen needed. Members start a
thread via NewRelayGroupThreadDialog → Account.postRelayGroupThread
(ThreadEvent.build(body, title){ hTag }).
- Route.RelayGroupThreads + a Forum icon in the group top bar.
Verified: kind-11 with h + title round-trips and is queryable by #h against
an embedded relay (the exact filter Flotilla uses); commons threads-collection
test (dedup/flow/remove) passes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
`@Volatile` without `import kotlin.concurrent.Volatile` resolves to the
JVM-only `kotlin.jvm.Volatile`, which breaks `commonMain` on iOS/Native
targets. CI catches this on `:commons:compileKotlinIosSimulatorArm64`.
Two call sites needed the import:
- OutboxDispatcher.kt:468 (`@Volatile private var lastCount`)
- FeedMetadataCoordinator.kt:433 (`@Volatile private var lastCount`)
Verified: `./gradlew :commons:compileKotlinIosSimulatorArm64` now green.
Closes CI break on PR #3483.
Fixes found in a full audit of the NIP-29 relay-groups feature across
quartz/commons/amethyst/cli.
Correctness (app):
- Own group messages never appeared in the timeline until an app restart:
the optimistic send is consumed with a null relay, so attachToRelayGroup
bailed on the relay==null guard, and the host relay's echo (new==false)
was skipped by the "only attach when newly consumed" gate. Attach now runs
on every arrival, gated on the note being loaded, and the null-relay case
attaches to the already-open channel(s) for that group id. Also avoids the
wrong-relay phantom by only fabricating a channel from real provenance.
- Roster subscription was frozen after an in-place join/leave (state keyed
on the stable account, never re-derived); it now invalidates on every
liveRelayGroupList change, so a fresh join's 39002 admission is fetched.
- membershipOf demoted a 39001 admin with an empty/unknown role to MEMBER,
hiding moderation; presence in the admins list now means at least MODERATOR.
- Members roster showed permanent truncated-hex names (one-shot
getUserIfExists cached null); uses checkGetOrCreateUser so UsernameDisplay
fills in when kind:0 arrives.
Protocol / data:
- GroupTag had no value equality → joined-group Sets never deduped and the
StateFlow re-emitted on every identical re-arrival. Equality is now the
(id, relay) pair, excluding the cosmetic name.
- create/edit emitted non-canonical ["public"]/["open"] status tags; NIP-29
flags are presence-only, so only private/closed are emitted when set.
- Metadata/member/admin supersede guards use <= so an equal-createdAt
duplicate isn't reprocessed (first-arrival wins); updatedMetadataAt is now
private-set. Relay-group channels are now included in the prune loops.
CLI:
- join/leave/create updated the kind:10009 list from a network-only drain;
a slow/empty fetch could publish a fresh list containing ONLY the new
group, wiping the rest. Now reads the local store (source of truth) too.
- edit re-asserted both visibility axes from flag presence, so --closed on a
private group leaked it public. It now reads current 39000 and merges,
with --public/--open counter-flags; only the specified axis changes.
- create now tracks the new group in kind:10009 (parity with join/Android).
UI polish:
- Invite dialog no longer mints a 9009 for open groups and won't copy a code
it never displayed. Browse "popular" list normalizes URLs before filtering.
Tests: GroupTag identity, unknown-role-admin-moderates, equal-createdAt
no-resupersede added; all quartz+commons NIP-29 suites and the amy
relaygroup harness pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
Adds runnable jvmTest coverage for the commons logic behind relay groups,
which until now was only compile-verified:
- RelayGroupChannelTest: the roster fold in RelayGroupChannel — role
derivation (admin/moderator/plain member), an admin present only in the
39001 list still resolving as a member, member-count dedup across
admins+members, and the createdAt supersede guards dropping stale
out-of-order 39000/39002 events.
- RelayGroupListDecryptionTest: drives the exact create/add/remove calls
join/leave delegate to, then reads them back through the decryption
cache, proving a followed group survives the NIP-44 encrypt→sign→decrypt
round-trip and that unfollow removes only the intended group.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
Nostr-native invites/deep-links for relay groups (no proprietary URL scheme):
- RelayGroupChannel.toNAddr(): a NIP-19 `naddr` for the group's kind-39000
metadata (authored by the relay key, host relay as hint) — a cross-client
coordinate that opens the group.
- ClickableRoute: a clicked/rendered `naddr` for kind 39000 routes straight into
the group chat (Route.RelayGroup with the relay hint) instead of the generic
addressable-note view.
- InviteRelayGroupDialog now shares the group `nostr:naddr…` (for discovery) plus
the one-time code for closed groups; copy grabs both.
- JoinRelayGroupDialog: closed groups prompt for the invite code, which the join
request (9021) carries; open groups still join in one tap.
Follow-up: cold-start `nostr:naddr` deep links (from outside the app) still route
to the generic note view; only the in-app clicked/rendered path is wired here.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
Membership is now derived from the relay's own signed lists (kind 39001 admins /
39002 members) instead of the client's kind-10009 intent:
- RelayGroupChannel gains members/admins (from 39002/39001), a RelayGroupMembership
derivation (ADMIN/MODERATOR/MEMBER/NONE, + a client-side PENDING), and a member
count. LocalCache consumes 39001/39002 into the channel.
- RelayGroupTopBar shows the real state: member count and your role in the
subtitle; a Join button when you're not a member, an optimistic "Requested"
after you tap Join (until the relay's roster confirms), and Invite (mods only) +
Leave once you're in. Invite is gated on moderate rights.
Caveat: private groups may hide 39002 from non-members, so state resolves after
the roster is visible; a targeted 9000/9001 subscription could tighten that later.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj