Commit Graph
100 Commits
Author SHA1 Message Date
Claude 3feb0049e6 perf: reduce background and mobile-data battery usage
Five targeted fixes for the biggest idle drains:

- NostrClient keep-alive sweep: the 60s reconnect loop kept ticking for
  the whole life of the process even after disconnect() (i.e. the entire
  time the app sat in the background). isActive is now a StateFlow and
  the loop suspends on it, so an inactive client schedules zero timers.

- Relay WebSocket pings on cellular: every ping on an idle connection
  promotes the radio out of its low-power state, per connection. The
  mobile-data client now pings every 240s instead of 120s (wifi keeps
  120s), staying under common carrier NAT idle timeouts.

- Always-on service watchdog: the 5-min health-check alarm no longer
  uses the _WAKEUP variant. Pulling the CPU out of sleep to restart a
  service that can't do useful network work on a sleeping device was
  pure cost; the alarm now fires as soon as the device is next awake,
  and the WorkManager + FCM/UnifiedPush layers still cover doze.

- ScheduledPostWorker: the 15-min periodic worker was enqueued forever
  for every user, waking (often cold-starting) the process with nothing
  to publish. It is now enqueued exactly while the store holds a PENDING
  post, driven by a store-flow observer in AppModules.

- CalendarReminderWorker: same unconditional 15-min periodic schedule,
  but worse — LocalCache is memory-only, so a WorkManager wake of a dead
  process can never find an RSVP to remind about. The worker is now
  scheduled when an ACCEPTED RSVP lands in LocalCache and cancels its
  own chain when nothing upcoming remains (or the feature is disabled).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P
2026-07-11 21:29:33 +00:00
Claude 0e1a14a62d fix: don't drop whole kind-0 profiles over one mistyped field
Clients in the wild publish structurally wrong values in profile
metadata — e.g. "nip05":{} — and the strict field serializers made
JsonMapper throw, so contactMetaData() returned null and the entire
profile (name, picture, about…) was discarded.

Generalize the BirthdayTolerantSerializer precedent: string fields now
use TolerantStringSerializer (accepts any JSON primitive, ignores
objects/arrays/null) and the bot flag uses TolerantBooleanSerializer,
so a single malformed field is skipped instead of being fatal.

Non-JSON content (e.g. "Relay initialized") remains unrecoverable and
still parses to null; a test pins that it does so without throwing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSCGp6KgcnaepEMbxNroYe
2026-07-11 14:34:50 +00:00
Claude 5365311e7d fix: describe relay auth read permission as profile, posts and engagement
The READ_OUTBOX relay-auth prompt and the settings toggle described the
permission as only loading posts, but authenticating to a follow's relay
also downloads their profile information and post engagement. Update the
prompt title, consequence line, reason label and toggle description to
say so.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VmzSAtkFv29KWDzPVBbxS
2026-07-11 14:22:09 +00: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
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
Claude f3084fcf79 docs(graperank): record communal-sweep hop-8 validation (160.7 min, +15.6% lists, +47% users)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zEYRGKF943RgLaHTViJaB
2026-07-11 03:14:15 +00:00
Claude 9801f03ce4 fix: make the PoW pill in note headers a bit smaller
Shrinks the DisplayPoW icon and text from 12 to 10 (dp/sp) and trims
horizontal padding so the pill takes less space inline in NoteCompose
headers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PbpEL8rRBwLd2mgbAqBPvX
2026-07-11 03:06:24 +00:00
Claude a3cdb06404 fix: resolve bad merge of broadcastPrivately overloads and DM read marker
The PoW publishing-queue branch split broadcastPrivately into a
NIP17Factory.Result overload delegating to a List<GiftWrapEvent> one,
while the DM-unread branch appended markDmRoomAsRead(signedEvents.msg)
to the old single body. The merge left that call inside the wraps
overload, where signedEvents does not exist, breaking compilation.

Move the read-marker call to the Result overload, and mark the room as
read on the PoW early-return path too (the rumor is signed inline there
and the wraps only publish after mining), so PoW-enabled accounts keep
the unread-clearing behavior from #1286/#1287.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1hu9zpyiuJo6yyoJs1vdw
2026-07-11 03:03:30 +00:00
Claude c84cee8107 feat(pow): predictable mining progress — estimated-time bars and time-left labels
The mining banner's indeterminate bar becomes determinate: each mining job
gets its own bar that fills over the device's estimated duration for its
difficulty (elapsed / (2^bits / benchmarked hash rate)), with an
"≈ 10 minutes left" label next to the elapsed time. The nonce search is
memoryless, so past the mean the bar honestly falls back to the
indeterminate sweep with "any moment now" instead of parking at 100%.
Queued-only banners keep the shared activity sweep.

The mining notification mirrors this: single-job progress fills toward the
estimate with the time-left text, refreshed every 30 s while mining (the
queue only emits on state changes, not clock ticks).

The composer difficulty menu now prices each option on this device —
"24 bits · ≈ 45 seconds" — using the same cached benchmark, and the duration
formatting is shared (formatApproxDuration/formatTimeLeft) with the settings
estimate instead of being private to the settings screen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
2026-07-11 01:43:06 +00:00
Claude 195538e25f style: reduce relay header button row padding to 20dp
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012w9KridAbRpesbX2ytdjn4
2026-07-11 01:40:41 +00: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 f17d3e942a feat: add Groups button to relay info screen for NIP-29 relays
Shows a button in the relay information header that opens the relay's
NIP-29 group list (RelayGroupServer route) when the relay's NIP-11
document advertises support for NIP-29. Also converts the header button
row to a FlowRow and marks button labels single-line so labels no
longer break mid-word (e.g. "Member/s") when the row runs out of
width.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012w9KridAbRpesbX2ytdjn4
2026-07-11 01:24:31 +00:00
Claude 80ece7c40f refactor(nip59): replace wrap PoW params with a template-conversion hook
GiftWrapEvent.create no longer takes powDifficulty/powIsActive — it takes a
single templateConversion hook ((template, ephemeralPubKey) -> template,
default identity) that runs on the finished wrap template right before the
ephemeral key signs it. The hook receives the ephemeral pubkey because the
NIP-01 id a nonce commits to includes it and the key never leaves create().

NIP17Factory forwards the same hook through wrapSeal/createWraps and the
create*NIP17 entry points, so quartz's NIP-59/NIP-17 code no longer imports
the NIP-13 miner at all; Account builds the mining closure at the call site.
Any future pre-sign wrap adjustment flows through the same seam.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
2026-07-11 01:17:18 +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
Claude 139659bbda Merge remote-tracking branch 'origin/main' into claude/dm-unread-own-messages-jl18pt
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
2026-07-11 00:17:37 +00:00
Claude 507de32575 fix: harden own-message-counts-as-read against audit findings
Addresses the issues found reviewing the #1286/#1287 fix:

- Centralize the room read-marker route in privateChatLastReadRoute()
  and the authorship rule in chatMessageMarksRoomAsRead(); all writers
  and readers now share one implementation.
- Exempt notes-to-self rooms from the authorship rule — there the
  user's own messages are the content still to be seen.
- Mark own chat messages as read at the ingestion choke point
  (EventProcessor), covering every send path (including the play-flavor
  App Functions assistant) and own messages arriving from other devices
  at the persisted-marker level, not just visually.
- Advance the send-path marker to the newest known room message, so a
  quick-reply to a skew-ahead peer also clears the indicators.
- Revert the authorship guard on public-chat/ephemeral/marmot rows:
  in multi-party channels 'I posted last' does not imply the earlier
  backlog was seen, and no marker advances there to back the guard.
- Restore the new-items bubble for rooms whose newest item is an
  unsent draft — a draft still needs the user's attention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqN8cfqNdo1MAvLN4C9krm
2026-07-10 23:51:55 +00:00
Claude f0f57a93ff fix: record relay attribution for repost and community-approval notes
Non-chat audit follow-up: RepostEvent, GenericRepostEvent, and
CommunityPostApprovalEvent consumes never called note.addRelay, so those
notes carried an empty relay list forever.

- CommunityPostApprovalEvent is badge-rendered directly in community feeds
  (BadgeBox has no repost-style indirection for it), so its relay icon row
  was always empty.
- Repost badges borrow the boosted note's relays in BadgeBox, but the
  repost note's own list feeds relayHintUrl()/relayUrls() for nevent hints
  when citing or sharing the repost — those fell back to author outbox
  guesses.

Add the standard attribution block (author.addRelayBeingUsed +
note.addRelay) before the duplicate check, matching consumeRegularEvent,
so both first arrivals and re-deliveries from other relays are recorded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YEiq1NMK3q12KGQ2yYhPEp
2026-07-10 23:10:03 +00:00
Claude f151cb74fc fix: drill relay attribution into Marmot kind-445 inner notes
Audit follow-up to the gift-wrap relay-icon fix: Marmot group chat rows
render the decrypted inner note (kind 9/7) via the standard chat feed with
RelayBadgesHorizontal, but nothing ever populated that note's relay list —
OK acceptances and relay deliveries all landed on the kind-445 envelope,
which has no link to its inner event.

- GroupEvent now implements HasInnerEvent (same @Transient @Volatile
  innerEventId pattern as GiftWrapEvent/SealedRumorEvent), so
  LocalCache.addRelayToNoteAndInners drills 445 -> inner for both OK
  confirmations and duplicate EVENT deliveries. RouteMaker is unaffected:
  it gates on the concrete wrap types before casting to HasInnerEvent.

- GroupEventHandler sets innerEventId at decrypt time and copies the
  envelope's accumulated relays down to the inner note (looked up by
  event.id, not the eventNote/publicNote params, which belong to the
  triggering event when replayed from retryPendingFor).

- sendMarmotGroupMessage sets innerEventId on the freshly built envelope
  before consuming/publishing, so acceptances for sent group messages
  reach the rendered note as soon as the inner note is indexed.

Also audited the remaining chat-rendered types: NIP-04 PrivateDmEvent,
ChatMessageEvent rumors, NIP-C7 ChatEvent, ChannelMessage/Ephemeral/
LiveActivities messages all route through consumeRegularEvent (duplicates
covered by the shared helper), and NIP-37 drafts render the wrap note
itself with markAsSeen covering the version/addressable pair.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YEiq1NMK3q12KGQ2yYhPEp
2026-07-10 23:01:45 +00:00
Claude 962146bd60 perf(graperank): round-wide communal sweep replaces per-batch backbone re-fan
Retry rounds were the dominant tail cost of deep crawls (rounds 8-10 of a
hop-8 run burned ~80 min for +6.6k lists): every 256-user batch with
attempt>0 or no-outbox users fanned the SAME ~40 backbone+fallback relays,
queueing thousands of small REQs against their 16-permit gates — pure
hot-relay head-of-line blocking.

Restructured:
- routeByOutbox now routes ONLY own-signal relays (write relays; hints when
  no outbox) and flags no-outbox/retry users into a communal set.
- New Phase C communalSweep asks the shared relays ONCE per round for all
  flagged users in author-chunked units — same (relay, user) coverage,
  ~8x fewer REQs — and records answered-empty pairs so each round shrinks.
- Attempt accounting moves to Phase C, after the round's full coverage
  (own relays + shared set) has run.
- shardedSweep now also records answered-empty (user, relay) pairs, so the
  communal sweep never re-asks a pair the shard pass already proved empty.
- Fixpoint recovery dedup: recoverStragglersFromAggregators skips users a
  prior pass already asked the aggregators for (each repeat pass cost ~4 min
  of store scans + REQs at 470k stragglers for zero new answers).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zEYRGKF943RgLaHTViJaB
2026-07-10 22:50:08 +00:00
Claude 81dc4449bd feat(cli): NIP-13 for amy — post --pow and the pow verb group
- `amy notes post TEXT --pow BITS [--pow-timeout SECS]` mines the note
  pre-signature via quartz's PoWMiner (blocking — the CLI process is the
  job), exits 124 on timeout with nothing published, and adds additive
  --json keys: pow, pow_target, pow_millis.
- `amy pow check EVENT-JSON|-` reports actual_bits, committed_target,
  has_commitment and effective_pow (capped at the commitment per
  NIP-13's anti-lucky-spam rule) plus id+sig validity.
- `amy pow mine --target N [--pubkey HEX] [--timeout SECS] TEMPLATE|-`
  mines an unsigned template for any pubkey — NIP-13's delegated PoW:
  ids don't commit to signatures, so a headless box can mine for a
  phone and hand the template back for signing.
- `amy pow bench` prints the machine's hash rate and expected seconds
  at 16/20/24/28 bits (commons PoWEstimator).
- cli/tests/pow/pow-headless.sh: relay-free harness covering bench,
  mine (commitment shape + 124 timeout), and the delegated round trip
  (mine → sign via `amy event` → pow check ≥ target). 6/6 passing.

No logic added to cli/ — thin assembly over quartz nip13Pow + commons
PoWEstimator per the CLI architecture rules.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
2026-07-10 22:47:05 +00:00
Claude 8d528ea01c revert(relayauth): drop the "couldn't deliver your event" toast
The give-up toast fired per-relay, but Nostr publishes each event to several
relays (NIP-65 outbox), so one relay rejecting an event that reached the others
produced a misleading "couldn't deliver" popup. It also named no event and fired
mid-scroll on reconnect-driven re-pumps, so it read as random noise. Remove the
toast and its subscription/strings.

The low-level quartz onEventGaveUp signal stays (tested, no UI consumer) as a
primitive for a future per-message send-status indicator, which is the right
surface for delivery failures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:45:47 +00:00
Claude d1ba04ad52 fix(relayauth): pre-merge audit — auth retry budget + lost-prompt window
Two bugs found in a pre-merge audit:

- quartz PoolEventOutboxState: auth-required NAKs only spared the `responses`
  budget, but `tries` (grown by every send/re-pump and NOT auth-aware) still
  accumulated across reconnects, so a slow/flapping AUTH handshake could exhaust
  Tries.isDone() and drop the event — with a spurious onEventGaveUp — before AUTH
  landed. Now an auth-required NAK resets the relay's retry budget (it responded,
  so it's up and just wants auth). Regression test added.

- RelayAuthPromptBus used a replay=0 SharedFlow, so a challenge that resolved to
  ASK before RelayAuthPromptHost subscribed (cold start / account switch) was
  dropped and the auth coroutine stalled the full timeout then DISMISSed. Add
  replay so late subscribers recover pending prompts (the host already filters
  resolved ones). Regression test added.

Also record the as-built design (Always/Never/Custom + toggles, venues, give-up
toast, known deny-relay-outbox limitation) in the plan doc.

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 50d942cfce style(relayauth): use the settings design system for the toggle and relay blocks
Adopt the same componentized UI as the Security Filters screen for blocks 2 and
3 (block 1, the Always/Never/Custom mode selector, stays as the policy cards):

- The Custom toggles are now a SettingsSection card of SettingsSwitchTiles
  (leading colored icon box, title + description, inset dividers) — reusing the
  shared settings components instead of bespoke rows.
- The per-relay list is now a grouped rounded card (surfaceContainerLow) with
  the same header style. Kept lazy: each row is its own LazyColumn item that
  clips the card's top/bottom corners on the first/last row so contiguous rows
  read as one card, preserving the earlier perf fix.
- Section headers unified to the SettingsSection primary-colored style.

Toggle icons: Dns (my relays), Download (read follows), Mail (message follows),
Public (message strangers).

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 0f8b276af7 perf(relayauth): lazily render the per-relay list; fix trailing divider
The per-relay list was a non-lazy Column + forEach, so every relay row composed
on first frame — each building a NIP-11 relay icon (robohash generation is
CPU-heavy) plus up to three avatars. With many authenticated relays that is a
lot of synchronous main-thread work, making the screen slow to open. Move the
whole screen to a LazyColumn so only the visible rows (and their icon/avatar
loads) compose.

Also render the per-relay divider only between rows (keyed itemsIndexed, skip
index 0) so there's no full-width divider trailing after the last row.

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 a16a9f4522 style(relayauth): match the settings-screen house layout
Drop the ad-hoc cards/thin-divider layout and adopt the same structure as the
other settings screens: small primary-colored SectionHeaders, 4dp dividers
between sections, and plain padded rows instead of surfaceVariant blocks.

- The Custom toggles are now standard settings switch rows (24dp inset, 16sp
  title / 13sp description) under a "What to log in to" header, not a card.
- The per-relay list is now icon-led rows separated by dividers (like the app's
  other relay lists) instead of chunky cards; tapping a row still opens the
  relay's NIP-11 info.
- Section labels reworded: "When to authenticate" and "What to log in to".

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 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 3e6cda8407 feat(relayauth): modernize the per-relay list to match the app's relay rows
Restore the "Per-relay overrides" heading and rebuild each relay card using the
same conventions as the other relay screens: the relay's NIP-11 icon (robohash
fallback), the shortened displayUrl instead of the raw URL, last-used as a
subtitle under the name, and the whole card tappable to open the relay's
NIP-11 info screen (Route.RelayInfo). The Allow/Deny chip and Forget stay on
the trailing edge; the served-people facepile sits on its own row below.

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 7c58ea31fd feat(relayauth): merge override + rationale into one per-relay list
Fold the separate "Why you're logged into these relays" section into the
per-relay list so each relay is one card: URL, an Allow/Deny chip, a facepile
of the people it serves (3 avatars + "+N"), and when it was last used.

Collapse the two removal actions (the override "X" and the rationale "Forget")
into a single Forget that clears both the stored decision and the recorded
reason, so the relay drops off the list — this removes the earlier ambiguity
about what each button did. The Allow/Deny chip now also shows for relays
allowed by policy (no explicit override), so they can be blocked from here too.

Also clarify the confusing read-trust sub-toggle copy so it reads as the
read-side extension of the write-only follows policy ("On its own, the option
above only logs in to send messages or notifications… turn this on to also log
in when downloading their posts"). Drop the three now-unused strings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:12 +00:00
Claude 44462ecce1 fix(relayauth): reword follow-trust button to "Always allow my follows' relays"
Shorter and clearer than "Always allow relays from my follows".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:12 +00:00
Claude e9dad44131 fix(relayauth): shorten the follow-trust button label
"Always allow relays of people I follow" overflowed the button; shorten to
"Always allow relays from my follows".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:12 +00:00
Claude 620eeadc2b feat(relayauth): one-tap "trust my follows' relays" on read-post prompts
On a "Load posts from <user> and others" (READ_OUTBOX) auth prompt, add a
button that turns on the broad rule instead of allowing one relay at a time:
it sets the policy to TRUSTED_FOLLOWS and enables the read-trust sub-toggle,
so every relay serving people the user follows auto-authenticates for reads
and these prompts stop appearing. Read-trust only applies under
TRUSTED_FOLLOWS, so the button sets both to keep its promise on any prior
policy, then authenticates the current connection once.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:12 +00:00
Claude 672fb931c8 test: integration-test NIP-17 DM delivery through an auth-required relay
Drive a real NostrClient against an in-process geode relay running
FullAuthPolicy, publishing a NIP-17 gift wrap through the PoolEventOutbox
retry queue. The first EVENT races ahead of AUTH and is rejected
`auth-required`; a RelayAuthenticator answers the challenge and the
still-pending wrap is resent on the post-AUTH resync and stored. This is
the integration counterpart to PoolEventOutboxAuthTest and exercises the
"auth-required must not burn the retry budget" fix end-to-end. A control
test (no authenticator) proves the relay genuinely gates, so the delivery
assertion isn't vacuous.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:12 +00:00
Claude 893b3c7140 test: cover DataStoreRelayAuthPermissionStore round-trips and pruning
Add JVM unit tests (real PreferenceDataStore on a per-test temp dir, no
Robolectric) covering: decision round-trip and hashed-key reverse lookup;
recordUse counterparty merge by kind, the 64-counterparty cap, idempotence,
and that a new counterparty always persists despite the write throttle; and
the reverse-lookup pruning contract — clearRationale/clearDecision prune the
shared url key only when nothing else references the relay, keeping it when a
decision or rationale still remains.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:12 +00:00
Claude e959acb215 refactor: extract testable AuthDecisionResolver from AuthCoordinator
The verdict-folding policy (no-ledgers auto-allow, any-ALLOW short-circuit,
ASK→prompt→remember Allow/Block) was inlined in AuthCoordinator's signing
lambda, untestable without the relay client and signers. Move it to a pure
suspend AuthDecisionResolver.resolve(verdicts, prompt) returning an Outcome
(shouldAuth + optional remembered override), and cover every branch with
unit tests, including that ASK never prompts when an account already allows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:12 +00:00
Claude 72f1f72ea7 refactor: share user-load and reason-string helpers across relay-auth UI
The auth prompt dialog (RelayAuthPromptHost) and the relay-auth settings
screen each carried an identical copy of a pubkey→User loader and the
AuthPurposeKind→reason-string mapping. Extract both into
RelayAuthComposeHelpers (LoadRelayAuthUser, relayAuthReasonRes) and point
both call sites at the shared versions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:12 +00:00
Claude a8fe2eee92 refactor: use quartz typed tag parsers in auth purpose derivation
Replace hand-rolled tag-index parsing in RelayAuthPurposeDeriver with
quartz's typed parsers: PTag.parseKey, ATag.parseAddress (filtered by
community/live-activity venue kinds), MarkedETag.parseRoot / ETag.parseId
for channel roots, and Address.parse for filter `a`-tags. Derive the
venue owner pubkey via Address.parse(...).pubKeyHex instead of splitting
the address string by hand.

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 95e3a8a54d fix(relayauth): load and observe venue names in the auth prompt
rememberVenueLabel was a non-loading cache snapshot, so a venue not yet
in LocalCache showed its id and never fetched or updated. Bring it to
parity with the person path (checkGetOrCreateUser + observeUserInfo):
get-or-create the public chat / live activity channel and observeChannel
it, which subscribes to relays for the metadata and recomposes when the
title arrives. Communities keep the NIP-72 d-identifier (no fetch needed).

Audit of the rest confirmed correct: person names (rememberDisplayName /
LoadUser / UsernameDisplay) already load + observe; the trust predicates
read .value of Eagerly-shared StateFlows (publicChatList/communityList/
allFollows), which is the right decision-time snapshot.

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 ffe9637c8d feat(relayauth): resolve real venue names in the auth prompt
The venue prompt said generic "this room"; now it names the actual place.
rememberVenueLabel resolves a public chat channel's title (by event id),
a live activity's title (by address), or a community's d-identifier (its
NIP-72 name), falling back to the address d-tag / short id when the venue
isn't cached. So the prompt reads "Post to nostr-dev?" / "Open <stream
title>?" with a matching consequence ("…your message to nostr-dev won't
be posted."), reusing the same who-slot as the person purposes.

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 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 eea072ec42 feat(relayauth): follow-trust default + action-aware prompt copy
User-perspective fixes so the auth prompt stops feeling like "why does
this app keep asking me to log into things":

1. Default policy is now TRUSTED_FOLLOWS (new installs only; a persisted
   choice is untouched). Messaging/notifying people you follow just works;
   only strangers prompt.
2/3. The prompt is reframed around what the user was doing and its
   consequence, not "log in":
   - Title is action-aware: "Send your message to Alice?" / "Notify …?" /
     "Load posts from …?", resolving the counterparty's display name.
   - The message states the real tradeoff (the relay confirms it's you;
     its operator sees which account you are) instead of "log in".
   - A purpose-specific, error-tinted consequence line — "If you don't,
     your message to Alice won't be delivered." — so Block/Dismiss no
     longer silently break the exact thing the user was trying to do.
   Per-purpose reason labels now show only when a challenge spans multiple
   purposes (the title carries the single-purpose case).

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 af0274bb24 style(relayauth): modernize the NIP-42 auth prompt dialog
Light design pass on the ASK dialog (the weakest surface):
- Add the M3 AlertDialog icon slot with a primary-tinted shield, so it
  reads as a security decision.
- Give the actions a real hierarchy: filled "Allow once", tonal "Always
  allow this relay", and an error-tinted text "Block this relay" (it was
  three same-emphasis buttons before).
- Show the relay in a rounded surfaceVariant chip instead of raw bold text.
- Keep named avatar rows for ≤2 counterparties (the common DM case) and
  collapse larger sets into an overlapping avatar facepile with a "+N"
  overflow badge.

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 cface58e15 perf(relayauth): lazy auth context + prune stale auth prompts
Audit follow-ups #3 and #5:
- AuthCoordinator builds the RelayAuthContext lazily, so the no-ledgers
  auto-allow path no longer pays for activeOutboxEvents/activeRequests +
  purpose derivation on every challenge.
- RelayAuthPromptBus now completes the deferred with DISMISS on timeout
  (it previously returned DISMISS without resolving it), and RelayAuthPrompt
  exposes isResolved/onResolved. RelayAuthPromptHost drops a prompt as soon
  as it resolves by any path (answered, answered elsewhere, or timed out)
  and skips any already-resolved prompt, so a stale dialog is never shown
  for a relay whose auth already gave up.

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 fb30982f92 perf(relayauth): bound grant rationale and throttle its writes
Audit fixes for the grant-rationale path:
- Bound counterparties per relay+purpose in the store (cap 64). An outbox
  relay can serve a large slice of the follow list; recording every author
  bloated the DataStore value and, since recordGrant stores every purpose
  that has counterparties, this happened even when the grant was for a DM.
- Throttle recordUse: auth is re-granted on every reconnect, so skip the
  DataStore write unless a new counterparty appeared or the last-used
  timestamp is stale (>5 min), instead of writing on every grant.
- Cap the settings rationale rows at 8 (+ "…and others"); they render in a
  plain non-lazy scroll column, so an unbounded set could compose hundreds
  of user rows.

The live follow-trust decision still sees the full counterparty set (the
cap is storage/display only), so trust verdicts are unaffected.

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 9179bd9d8d feat(quartz): signal when an outgoing event exhausts its retry budget
Instead of silently dropping an event once its per-relay retry budget is
spent, PoolEventOutbox now reports it: PoolEventOutboxState.newTry returns
whether the attempt gave up on the relay, PoolEventOutbox.onSent surfaces
the dropped event, and NostrClient notifies a new (default no-op, so
non-breaking) RelayConnectionListener.onEventGaveUp(relay, event). Lets a
host surface a failed delivery rather than lose it silently; the event may
still be pending on other relays. Unit-tested via the outbox try budget.

Note: timed retry backoff (the other half of this item) is intentionally
deferred — applied in the shared syncState path it would also delay the
post-auth resend and regress the auth-required fix, so it needs
trigger-aware handling designed separately.

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 b1b7a4e7c3 fix(relayauth): exclude event author from NOTIFY_INBOX counterparties
A pending non-gift-wrap event's `p` tags are the people it notifies, but
some events self-p-tag the author. Drop the author's own key so the auth
reason and follow-trust check reflect who is actually being notified, not
the sender. (Own-relay over-attribution is already handled upstream by the
isInMyRelayList allow.) Adds a regression test.

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 39dc053bf7 feat(relayauth): user toggle for trusting follows' outboxes on reads
Add relayAuthTrustFollowsForReads (local AccountSettings flag, persisted
via LocalPreferences, default off) and wire it into the ledger's
readTrustEnabled input. The relay-auth settings screen shows a switch —
"Also trust when reading their posts" — under the Trusted-follows policy,
so downloading a followed author's outbox auto-authenticates only when
the user opts in; writes (DMs/notifications) remain auto-trusted.

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 e128a7834b feat(relayauth): interactive "log in to this relay?" prompt dialog
RelayAuthPromptHost collects RelayAuthPromptBus.prompts app-wide (hosted
in LoggedInPage next to RelayAuthSubscription) and shows one dialog at a
time when a NIP-42 challenge resolves to ASK. The dialog explains why the
relay wants auth — grouped by purpose (send DM / notify / download posts)
with each counterparty's avatar + name — and offers Allow once / Always
allow this relay / Block this relay. Dismissing answers DISMISS, matching
the bus timeout fallback so a connection never blocks on the UI.

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 deb0abb982 docs: state the auth-required outbox fix narrowly
Clarify that resend-after-auth already worked for the common single-round
case; the fix only prevents auth-required NAKs from consuming the per-relay
retry budget, which could evict the saved event across repeated rounds
(slow signer / reconnect churn / re-challenge) at the moment syncFilters
tries to redeliver it post-auth.

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 e857e1c8e8 feat(relayauth): suspendable ASK prompt mechanism for NIP-42 auth
Add RelayAuthPromptBus: when a challenge resolves to ASK, the auth
coroutine calls requestDecision() and suspends until the UI answers via
a SharedFlow<RelayAuthPrompt> + CompletableDeferred. Concurrent
challenges for the same relay share one prompt (no double-asking) and an
unanswered prompt times out to DISMISS so a connection never hangs.

AuthCoordinator now consults the prompt on ASK and acts on the choice:
ALLOW_ONCE signs once; ALWAYS_ALLOW / BLOCK persist a per-relay override
via the ledger and then sign / skip; DISMISS skips. Exposes promptBus so
a Composable can host the dialog (the visual layer is the next step).
Unit-tested: choice delivery, same-relay dedup, and timeout-to-DISMISS.

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 c036069439 feat(relayauth): wire purpose-aware resolver into the live auth path
AuthCoordinator now reconstructs the challenge purpose from what the
client is doing with the relay — pending outgoing events + active
subscription filters — via the new pure RelayAuthPurposeDeriver
(gift wrap => SEND_DM, p-tagged event => NOTIFY_INBOX, filter authors
=> READ_OUTBOX), and asks each account's ledger for a verdict through
RelayAuthResolver instead of the old URL-only ALLOW/DENY.

RelayAuthPermissionLedger gains context-aware decide(RelayAuthContext)
plus isBlocked / isFollowed / readTrustEnabled inputs; the live wiring in
AccountDataSourceSubscription splits the old combined check into separate
blocked-list and trusted-list predicates and feeds follow membership from
allFollows (any follow list). TRUSTED_FOLLOWS now auto-auths for relays
serving a followed DM/notification counterparty. ASK is treated as
"don't auth yet" pending the interactive prompt (next step).

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 38b2e65362 feat(quartz): expose pending outbox events per relay for auth context
Add INostrClient.activeOutboxEvents(url) (backed by
PoolEventOutbox.activeOutboxEventsFor) returning the full events still
pending delivery to a relay, not just their ids like activeOutboxCache.
This lets a host explain *why* a relay is being authenticated with —
e.g. a pending kind-1059 gift wrap means we're sending a DM to its
recipient — by inspecting kind/tags. Combined with the existing
activeRequests(url) filters, it is the generic challenge context the
NIP-42 decision hook needs. Updates the INostrClient test fakes.

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 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 9c618f9ba1 fix(quartz): don't drop outgoing events on NIP-42 auth-required NAKs
PoolEventOutboxState treated an `auth-required` OK-false the same as any
transient failure, recording it against the per-relay Tries budget
(responses > 2 drops the event on the next send attempt). Relays that
NAK every unauthenticated EVENT could therefore exhaust the budget and
drop the message before the AUTH handshake completed — the event was
gone by the time syncFilters re-sent it after the auth OK.

Treat `auth-required` as a deferred state instead: keep the relay in
relaysRemaining and record no failure, so the existing
syncFilters-after-auth path redelivers it. Mirrors the behavior already
present in StandaloneRelayClient. Terminal rejections (invalid/pow/
replaced/deleted) and ordinary transient errors are unchanged.

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 6c9259fea0 docs: split AUTH permission plan into quartz mechanism vs amethyst policy
Account for the existing (rudimentary) outgoing-event retry in
PoolEventOutbox: syncFilters already re-sends EVENTs after an auth OK,
but auth-required is wrongly treated as a burned retry (hard >2/>3 cap,
no backoff, silent drop) — a race that can drop a message before AUTH
completes. Recommend fixing the resend/retry mechanism generically in
quartz (port StandaloneRelayClient's auth-required handling, add backoff
+ terminal give-up signal, enrich the injected auth-decision hook with
pending-event/active-filter context) and keeping only policy, purpose
derivation, ASK prompt, and grant-rationale UI in amethyst.

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 3570ebb014 docs: plan for contextual NIP-42 AUTH permissions
Design doc for asking the user *why* an auth is requested (send DM,
deliver notification, read outbox), a follow-based auto-trust policy
mode, an ASK decision state, and per-relay grant rationale shown in the
auth settings screen. Reuses the existing RelayAuthenticator /
AuthCoordinator / RelayAuthPermissionLedger stack.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:08 +00:00
Claude 6d4262bc2a feat: route [text](#hashtag) markdown links to the hashtag feed (#1564)
Markdown links whose target is a bare hashtag fragment, e.g.
[Nostr Multiplayer Games](#NostrMultiplayerGames), did nothing when
tapped: uriToRoute had no branch for them, so the tap fell through to
the system uri handler, which silently fails on a bare fragment.

- uriToRoute now resolves a bare #tag target to Route.Hashtag, reusing
  RichTextParser.hashTagsPattern so tag validity matches how #hashtags
  linkify in plain text (including unicode tags). Full URLs with
  anchors are unaffected.
- MarkdownMediaRenderer rewrites the link destination to
  nostr:hashtag?id=<tag> (mirroring renderHashtag) while keeping the
  custom link text, so the tap flows through the same route string as
  a plain #hashtag.

Read side only: the composer still never emits this syntax.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D382kMNdcmMLZrwKeZAPTY
2026-07-10 22:36:52 +00:00
Claude 906744d32a fix: treat DM rooms whose newest message is my own as read (#1286, #1287)
Sending a reply — from this device or from another one via the
self-addressed gift wrap — now counts as having read the conversation:

- Account.broadcastPrivately / sendNip04PrivateMessage advance the
  room's local read marker to the sent message, so the Messages tab dot
  and the room's new-items bubble clear immediately on send.
- unreadPrivateChatRoute and the room/channel/marmot rows in
  ChatroomHeaderCompose additionally ignore newest messages authored by
  the logged-in user, covering own messages that arrive from other
  devices before any local marker exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqN8cfqNdo1MAvLN4C9krm
2026-07-10 22:34:13 +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
Claude f56edf7986 docs(graperank): record fixpoint validation (hop-8: 796k users, deepest run)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zEYRGKF943RgLaHTViJaB
2026-07-10 22:20:37 +00: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 ba98f8c232 fix: never mine the inner kind-7 of gift-wrapped reactions
Reactions to NIP-17 groups and unsealed rumors are gift-wrapped: the
inner reaction only travels as ciphertext, so relays can never PoW-filter
it and mining it was pure battery waste. Account.reactTo now detects
private targets up front and routes them straight to the plain signer,
skipping the mining queue. DM/private-note/file wraps were already
correct (only the kind-1059 envelope is mined, never the seal or rumor).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
2026-07-10 21:43:00 +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 ac5223b956 feat: mine scheduled posts through the PoW queue
The scheduled branch re-stamps the template with created_at = the future
publish time and the worker publishes the stored signed JSON verbatim,
so a nonce mined at compose time commits to that future created_at and
remains valid when the post goes out. The sign+store step moves into the
mining continuation (storeScheduledPost), keeping the composer
fire-and-forget for scheduled posts too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
2026-07-10 20:31:41 +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
Claude a509d41358 fix: keep login/signup page choice across uiMode recreation (#1097)
Toggling the system dark/light theme recreates MainActivity (uiMode is
not in configChanges), and the login/signup page choice was held in a
plain remember, so the UI reset from Sign-Up back to Login. Switch it to
rememberSaveable so the choice survives recreation. Same fix for the
password-visibility toggle in LoginScreen.

https://github.com/vitorpamplona/amethyst/issues/1097

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J74bRJ9H1KFx4vTo9wpez3
2026-07-10 20:11:47 +00:00
Claude 03cb5e4123 fix: trim leading/trailing whitespace from composed posts (#349)
Trim the composer text once, where it is handed to NewMessageTagger, so
every event kind built from tagger.message (short note, poll, zap poll,
NIP-29 thread, NIP-22 comment) publishes without stray leading/trailing
whitespace and mention rewriting operates on the exact string that gets
published. The now-redundant per-call trim on the NIP-29 thread build is
removed. Whitespace-only messages are still rejected by the existing
isNotBlank gates before a template is ever built.

https://github.com/vitorpamplona/amethyst/issues/349

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1V6v7aYBE8B5PhNZMNs3o
2026-07-10 20:11:08 +00:00
Claude 412ec16905 docs: publish APK signing certificate SHA-256 for verification (#972)
Adds a 'Verifying the APK signature' section to the README so users
sideloading via Obtainium/AppVerifier/GitHub Releases can check the
signing certificate of their download.

The fingerprint was COMPUTED (not transcribed) from official release
artifacts of the latest release, v1.12.6:

- amethyst-fdroid-arm64-v8a-v1.12.6.apk
  https://github.com/vitorpamplona/amethyst/releases/download/v1.12.6/amethyst-fdroid-arm64-v8a-v1.12.6.apk
  (file SHA-256 ed95fdb39d668ad2100ca6ff90f21617537790c8db47850f36ca36f75bc7aeea,
  matching the GitHub release asset digest)
- amethyst-googleplay-arm64-v8a-v1.12.6.apk
  https://github.com/vitorpamplona/amethyst/releases/download/v1.12.6/amethyst-googleplay-arm64-v8a-v1.12.6.apk
  (file SHA-256 dacc6db1ba14c914504c6629f8fc039de0918a2ea9d35483915b4721391c3b19,
  matching the GitHub release asset digest)

Extracted with 'apksigner verify --print-certs' (build-tools 37.0.0)
and cross-checked with 'keytool -printcert -jarfile'. Both flavors are
signed with the identical certificate (CN=Vitor Pamplona, O=Amethyst
Labs), consistent with create-release.yml signing both flavors with
the same SIGNING_KEY secret:

  SHA-256: c2d0aa86bcb6b62090561a41bbe336e98b78c2d0210a498dc885f28e1348cf17

Fixes #972

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0148shLViyE1KjFc69J6xYux
2026-07-10 20:02:35 +00:00
Claude 0dcfe9cfb5 fix(graperank): crawl what the terminal recovery discovers (fixpoint loop)
An A/B rerun of the full-depth crawl exposed a completeness cliff: deep-hop
coverage was hostage to WHEN a contact list arrived. Lists fetched in-round
expand the frontier and get their follows crawled next round; the same lists
recovered by the terminal aggregator pass were counted but never crawled -
the rounds were already over. One weak backbone hour at round 5 shifted ~30k
lists from in-round to the terminal pass and silently amputated ~220k
hop-6/7 users (621k discovered vs 394k on otherwise-identical runs, while
total lists differed only 7.5%).

Extract the round loop into runRounds() and iterate rounds + aggregator
recovery to a fixpoint: while a recovery pass reveals new in-budget pending
users, resume rounds and recover again. Converges because each straggler
folds at most once and the hop budget bounds depth; maxRounds still
backstops. The warm pool stays up through recovery so resumed rounds start
on warm sockets, and a report-deletions top-up runs only when extra rounds
actually happened.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zEYRGKF943RgLaHTViJaB
2026-07-10 18:20:40 +00:00
Claude 743658433f Merge remote-tracking branch 'origin/main' into claude/amy-graperank-crawl-perf-jhmx0x
# Conflicts:
#	cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt
2026-07-10 17:12:10 +00:00
Claude f946e1aaf4 fix: use the Topic icon for the composer's subject toggle
The subject/title toggle used the Article (document-lines) glyph, which reads as
"body text". Switch to Topic — a clearer signifier for a subject/title line.
Codepoint already present in MaterialSymbols, so no font-subset regen needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-10 16:50:23 +00:00
Claude 003142122b fix: don't drop NIP-11 waiters on a stuck Loading marker
The relay-signed NIP-29 gate reads each relay's NIP-11 `self` key from the
cache. Nip11CachedRetriever.loadRelayInfo treated a valid `Loading` marker as
"a fetch is already in flight, just wait" — but it dropped the caller's
callback entirely and had no way to notify it when the fetch finished.

That is a lost-wakeup: if the coroutine that started the fetch is cancelled
mid-flight (e.g. the discovery screen that launched the warm-up leaves
composition when you tap a relay chip), the `Loading` marker is left behind,
valid for a full hour. The relay's on-group-list screen then mounts, sees the
stuck `Loading`, waits forever, and never receives the doc — so its NIP-11
looks empty (no `self`), the self-key gate rejects every 39000, and a group
you can plainly see under the "Mine" filter (which bypasses the gate) vanishes
on the relay page.

Re-fetch on `Loading` instead of silently waiting: the fetch is cheap, dedups
at the HTTP layer, and guarantees this caller is notified. Error caching is
unchanged (still avoids hammering a genuinely broken relay).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-10 16:27:02 +00:00
Claude af0e9c512e fix: warm host-relay NIP-11 in discovery so All-Follows shows their groups
Companion to the All-Follows roster fix: the discovery screen warmed NIP-11
only for the outbox `candidateRelays`, but a group's relay-signed 39000 lives
on its HOST relay. In All-Follows the subassembler now probes those host
relays (joined kind-10009 + favorited kind-10012) for follow rosters, so their
39000s land in the cache — but the self-key gate (isRelaySignedRelayGroup)
then read an unwarmed, empty NIP-11 (self=null) and dropped every one of them.

That is why the groups only appeared after bouncing through Global (whose
relay set happens to include the host relay, warming it as a side effect).
Warm the joined + favorited host relays alongside the outbox candidates so
the gate is a cache hit with the relay's `self` key present.

Verified against wss://basspistol.org: NIP-11 advertises nip-29 with
self=afd7da3f…, all six 39000s are signed by that self key, and
`amy relaygroup browse` returns all six with unverified_dropped=0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-10 16:15:28 +00:00
Claude 4d81d298ec fix: load follow-participated relay groups in All-Follows without Global
The All-Follows / Authors discovery filter sets resolve their relays via
the outbox model (each follow's own publish relays), but a NIP-29 roster
(kind-39001 admins / 39002 members) lives ONLY on the group's host relay.
So a follow who is an admin or member of a group never surfaced in
All-Follows until the user bounced through Global — which pulls the whole
directory — and back.

Additionally query the follows as `#p` against the group-host relays we
already know about (joined via kind-10009 + favorited via kind-10012),
minus the relays the outbox filter already covers, and re-assemble when
either list changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-10 16:08:21 +00:00
Claude babdea67ab feat: list joined groups' relays as filter chips in Relay Groups discovery
You had to Favorite a relay (kind-10012) before it appeared as a chip in the
Relay Groups top-nav filter — even for relays you already have groups on. Now the
host relay of every group in your joined list (kind-10009) also shows up as a
relay chip, so you can browse the other groups on those relays without favoriting
them first.

Added only to the relay-groups discovery catalog (not git/podcasts/etc.), deduped
against relays already present as favorites. Selecting one resolves to that
relay's groups (filtered by the relay-signed self-key check as usual).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-10 15:51:51 +00:00
Claude 1614a01ae4 refactor(dns): one resolver everywhere — promote SurgeDns to quartz, fix its blind spots, drop CachingDns
CachingDns duplicated 20% of what the app's SurgeDns already did better
(stale-while-revalidate, single-flight, jittered 24-48h positive TTL,
persistence, poison filtering). Consolidate on SurgeDns and fix what the
verification pass found along the way:

- move SurgeDns + SurgeDnsStore (+ their 41 tests) to quartz jvmAndroid so
  the CLI can share them; delete CachingDns
- negative TTL 10s -> 10min (class default): a dead domain burns 10-30s of
  getaddrinfo per re-dial and both the relay pool and a crawl re-dial dead
  hosts continuously; the relay pool's own backoff already reaches 5min
- stop the call-failure listeners from erasing negative entries they were
  just written from: callFailed caused by UnknownHostException must not
  invalidate (it deleted every negative entry milliseconds after creation,
  silently defeating the negative TTL entirely). The invalidation remains
  for its real purpose - stale positives with rotated IPs
- new SurgeDns.staleAll(): soft-expire everything WITHOUT discarding -
  positives serve stale and revalidate in the background on next use,
  negatives re-try on first touch. Wired to network-identity changes in
  AppModules (same trigger as Tor's onNetworkChange), replacing nothing:
  the full-clear invalidate() was never actually called on network change
- amy: SurgeDns wired into the CLI OkHttp client, snapshot persisted at
  ~/.amy/shared/dns-cache.bin across runs (best-effort load/save)

Verified: all SurgeDns/SurgeDnsStore tests pass in the new location incl.
two new staleAll tests; :amethyst compiles (main + unit tests); cli suite
green. Caveat recorded in the plan doc: behind an HTTP CONNECT proxy OkHttp
never consults the client resolver (hostname goes to the proxy), so this
layer pays off on direct-connect deployments only - which also means the
branch's A/B numbers never depended on it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zEYRGKF943RgLaHTViJaB
2026-07-10 15:51:32 +00:00
Claude fa0883f4a0 perf: warm NIP-29 relays' NIP-11 in parallel + pre-warm joined groups
The relay-signed group check reads each host relay's NIP-11 `self` from cache,
but the discovery screen was warming those docs SERIALLY — Nip11Retriever awaits
each HTTP fetch, so N relays meant N sequential round-trips and one slow or
unreachable relay stalled every group behind it until its socket timeout. That's
why groups trickled in.

- Add WarmNip11(relays): fans the fetches out, one coroutine each, so the wait is
  the slowest single fetch instead of the sum. Discovery now uses it and
  re-invalidates the feed as each doc lands (via a version counter).
- Pre-warm NIP-11 for joined groups' host relays from the Messages tab
  (WarmJoinedRelayGroupNip11), so by the time one surfaces in discovery the
  answer is a cache hit. NIP-11 stays cached for an hour.

Note: the Messages tab itself never needed this — joined ("My Groups") rows are
authoritative from the kind-10009 list and were never gated on NIP-11.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-10 15:43:20 +00:00
Claude 0f01dc0c12 feat: verify NIP-29 group metadata is signed by the relay's own key
NIP-29 metadata/roster events (39000-39003) "are addressable events signed by
the relay keypair directly ... as stated by the NIP-11 `self` pubkey", and
"relays shouldn't accept these events if they're signed by anyone else". So the
authoritative test for a genuine group is `39000.author == relay.self` — which
also rejects a stray user-published 39000 even on a real NIP-29 relay, something
the earlier supported_nips heuristic could not.

Add `isRelaySignedRelayGroup(channel)`: strict `author == self` when the relay
publishes `self`, falling back to `supported_nips ∋ 29` when it omits `self`, and
false when it has neither. Apply it at the surfaces that show unsolicited groups:

- Discovery feed: replace the relay-level supported_nips filter with the
  per-channel self-key check in matches(); the screen now warms each candidate
  relay's NIP-11 and re-invalidates the feed as each doc resolves.
- On-relay group list: filter to relay-signed groups, warming that relay's
  NIP-11 so genuine groups fill in and fakes stay hidden.
- CLI `relaygroup browse`/`info`: fetch the relay's NIP-11 (new
  Context.relayInfo) and drop 39xxx not signed by `self`; browse reports the
  dropped count.

Explicit user actions (a received invite link, opening an naddr) are left
untouched — hiding those would be user-hostile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-10 15:18:13 +00:00
Claude 85ad70521b feat(graperank): rebuild --diagnose around where the wall time actually hides
Every hard diagnosis this branch needed lived in blind spots of the old
output — waits that happen before its timers start and stages it never
delimited. Close them:

- permit/rate-gate wait ledger per relay (queueing for a limiter slot
  happens BEFORE the drain timer starts; total + worst offender always,
  top-10 queue table under --diagnose)
- [batch-walls] per round: p50/p90/max of Phase-B batch walls vs the fast
  window, naming the last-resolving unit of the slowest batches - the
  direct measure of pre-drain queueing
- store path split: insert wall now labeled as mutex-wait-inclusive with
  an uncontended-writes estimate (solo batches), plus a read counter
  (351 queries/724ms at hop-2) that makes point-query pathologies visible
- ticker gains visited/s + batches/s so retry rounds over list-less users
  read as "working, zero yield" instead of "stalled" (the done-rate hid
  this for hours)
- per-round limiter demotion counts; stage durations for aggregator
  recovery and report deletions
- [slow-relay] lines deduped to 3 samples per authority with 3-author
  samples (a hop-8 log was ~1MB of repeats; telemetry keeps full counts)
  and the redundant per-drain parked line removed

Smoke-tested live on a hop-2 --diagnose crawl; all suites green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zEYRGKF943RgLaHTViJaB
2026-07-10 14:43:35 +00:00
Claude 188ba3c609 feat: discovery only shows groups from relays that advertise NIP-29
Stray kind-39000 events published by ordinary users to general relays (e.g.
nostr.wine) were surfacing in the Relay Groups discovery feed as joinable groups
that have no roster and no chat and can't actually be joined — because those
relays don't run NIP-29, they just store the fake metadata like any addressable
event.

A relay that truly runs NIP-29 rejects user-authored 39xxx, so on such a relay
every 39000 is relay-signed and genuine. Gate discovery on that: restrict the
per-relay constraint set to relays whose NIP-11 `supported_nips` advertises 29.
This is the single point both the match test and the REQ-driven feed read, so
non-advertising relays drop out wholesale. The discovery screen warms each
candidate relay's NIP-11 and re-invalidates the feed as support resolves (a
relay whose NIP-11 lands after its 39000s would otherwise stay hidden until a
manual refresh). "My Groups" (the kind-10009 joined list) is unaffected.

Trade-off: a relay that runs NIP-29 but doesn't publish NIP-11 (or omits 29 from
its list) is hidden from discovery until it advertises.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-10 14:32:30 +00:00
Claude 9a5a278b01 feat: block NIP-29 group creation on relays that don't advertise it
The reported "title/image didn't save, group shows as a bare hex, and I'm asked
to join my own group" all come from creating a group on a relay that isn't
running NIP-29: it stores the 9007/9002 as ordinary events but never creates the
group, emits 39000/39001/39002 metadata, or makes the creator an admin.

Gate the create screen on the relay advertising NIP-29 in its NIP-11
`supported_nips`: a tri-state check (checking / unsupported / supported) disables
the Create button until support is confirmed and shows an explanatory warning
banner when the relay is confirmed to lack it. Editing is unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-10 13:13:30 +00:00
Claude a2fdca08cf docs(graperank): record sub-cap rejection + cold hop-5 validation in plan
Per-relay sub cap 32 made the hop-3 crawl 52% slower with slightly fewer
lists (27 relays demoted us vs 5 at cap 16) - the Phase-B plateau is
relay-side service rate, so 16 stays the default and AMY_RELAY_SUB_CAP
remains an experiment hatch. Final validation: cold hop-5 completed in
85.4 min with 203,903 contact lists / 391,549 users / 626,599 events -
the workload that never finished before this branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zEYRGKF943RgLaHTViJaB
2026-07-10 06:19:18 +00:00
Claude 2d58afefe6 feat(amy): AMY_RELAY_SUB_CAP env override for the per-relay sub cap
Phase-B throughput plateaus at ~100-150 users/s at every crawl scale while
CPU/FDs/network sit idle, pointing at the per-relay 16-permit gates on the
hot backbone/fallback relays every batch touches. The 16-vs-100 benchmark
that chose the default predates the multithreaded-crawl fix, so expose the
starting cap for A/B runs; the NOTICE/CLOSED demotion ladder still protects
relays that push back.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zEYRGKF943RgLaHTViJaB
2026-07-10 05:13:56 +00:00
Claude 3a4b4d5c27 perf(graperank): default drainConcurrency 24 -> 48; record A/B results
Cold hop-3 A/B (fresh store per leg, same observer): 48 workers beat 24
twice at identical contact-list counts (579s->559s and 474s->396s). The
old result that made 64 look 2x slower predated the multithreaded
dispatcher fix - more coroutines on one starved event-loop thread. Plan
doc gains the full A/B table (kept: background-park degating, sweep
dedup, tail overlap, dc48; rejected: 5s fast window).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zEYRGKF943RgLaHTViJaB
2026-07-10 04:41:57 +00:00
Claude e2afe23e52 perf(graperank): overlap the finishing tail instead of serializing it
Timestamped hop-3 logs split the 373s tail three ways: 222s blocking
convergence on parkedInFlight (whose late lists provably cannot fold once
pending is empty - parked filters only ever asked for in-budget users and
they are all done; the events still stream into the store), 14s of
aggregator recovery, and 137s of report-deletion fetching serialized
behind it. Convergence now breaks as soon as the frontier is empty; the
aggregator pass and report deletions run concurrently; and the aggregator
folds the lateHarvest trickle until one full park window of silence
instead of inheriting every background park's lifetime.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zEYRGKF943RgLaHTViJaB
2026-07-10 04:24:26 +00:00
Claude 297244813a feat(amy): stamp graperank crawl log lines with elapsed seconds
A saved crawl log couldn't attribute wall time to the finishing tail
(aggregator recovery vs report deletions vs parked drains) without
external timestamps. Prefix every crawl progress line with [t+SSSs].

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zEYRGKF943RgLaHTViJaB
2026-07-10 03:55:48 +00:00
Claude f334402034 perf(graperank): don't re-shard-sweep the same stragglers every round
Straggler rounds re-offered the identical missing set to the identical
top-relay backbone each round, re-paying the rotation barriers (~20s of
Phase A per round) for ~zero new lists. Track swept users and only sweep
newcomers - but only once the backbone is at full shard width, so users
swept against round 2's proto-backbone still get a real sweep when the
top-10 exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zEYRGKF943RgLaHTViJaB
2026-07-10 03:48:20 +00:00
Claude 91835993f6 perf(graperank): stop background sweeps' parks from gating convergence
A hop-3 crawl spent ~500s of its 594s total in the finishing tail waiting
for parkedInFlight to hit zero - and the spike (1791 parked units) was the
fire-and-forget Tier-2 relay-list sweeps, whose whole point was not to
block the crawl. They only gated it because a parked unit persisted its
events at park END, so cancelling early would have lost them.

Fix in two parts: the park loop now persists incrementally (drains + stores
the queued chunk on every activity ping, plus a NonCancellable final drain),
so cancelling a park loses nothing already delivered; and drainGated grew a
background flag - Tier-2 sweep parks (and their capped-page paginations)
no longer count toward parkedInFlight and are simply cancelled at crawl
end. Round-critical parks (outbox drains, sharded sweep, aggregator
recovery) still gate convergence exactly as before. paginateIfCapped takes
(pageSize, oldest) since the incremental path no longer retains the page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zEYRGKF943RgLaHTViJaB
2026-07-10 03:31:11 +00:00
Claude 720f76ed97 perf(graperank): batch store contact-list reads instead of per-user point queries
Once the crawl went multithreaded, the store became the visible Phase-A
bottleneck: harvestFromStore, shardedSweep's missing-check, the Phase-B
consumer, and the aggregator straggler scan each issued one contactsOf
point query per user (~8ms each on a multi-GB store under concurrent
writers) - 100k-user folds burned minutes serially, and the aggregator
pass over 300k discovered users would have burned ~40 on its own. Add
latestContactsFor (one chunked author query per 300 users, newest
created_at per author wins) and route all four paths through it;
harvestFromStore folds chunk-by-chunk so peak memory stays one chunk.
Phase A now folds the store first and shardedSweep trusts `done` instead
of re-checking per author.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zEYRGKF943RgLaHTViJaB
2026-07-10 03:14:12 +00:00
Claude e595d42c49 perf(graperank): run the crawl on Dispatchers.IO, not the caller's loop
The CLI calls crawl() from runBlocking's single-threaded event loop, so
Phase B's thousands of concurrent drain-unit coroutines (timeout timers,
channels, REQ JSON encoding, signature verifies, SQLite writes) all queued
on ONE thread: timers fired late, batch walls inflated ~5x, and the
--diagnose ticker showed 24/24 workers pinned while completing ~1 user/s
with one core pegged and three idle. It also explains why the old
64-worker A/B ran slower - more coroutines on the same thread. Hop the
whole run onto Dispatchers.IO inside the crawler so every front end gets
real parallelism; IO (not Default) because the store's blocking SQLite
calls must not starve the cores-sized pool.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zEYRGKF943RgLaHTViJaB
2026-07-10 03:02:51 +00:00
Claude f912aea004 fix(amy): reserve 'operator' so machine keys don't break account auto-select
Any graperank crawl/probe now creates ~/.amy/operator/ (the reachability
cache signs with the operator monitor key), and listAccounts counted it as
an account - so a single-account user's very first crawl left every later
command failing with "multiple accounts (operator, <name>)". Add it to
RESERVED_NAMES alongside shared/current. Hit for real while network-testing
the connect-storm changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zEYRGKF943RgLaHTViJaB
2026-07-10 01:45:17 +00:00
Claude 2bad6779ce perf(graperank): mass pre-connect, cached DNS, and an amy relay census probe
Most of a from-scratch crawl's wall clock was connection setup, re-paid
serially: the relay pool tears down a relay's socket ~300ms after its last
drain unsubscribes, OkHttp allowed only 256 concurrent WS handshakes, and
every dial re-ran an uncached blocking getaddrinfo (10-30s per dead domain,
once per per-user path URL of the same host). Open the connections once, in
parallel, and keep them:

- GrapeRankCrawler: the warm-pool trick (never-matching REQ that only holds
  the socket) now covers the whole candidate universe instead of the top 20 -
  seeded at crawl start from the reachability cache's live set (one parallel
  connection storm, Config.knownLiveRelays) and refreshed each round with
  newly learned outbox relays, capped by Config.preconnectCap (FD-budget
  aware, --preconnect-cap / --no-preconnect).
- CachingDns (quartz jvmAndroid): 10-min positive + negative DNS cache with
  in-flight per-host dedup; dead domains fail in microseconds instead of
  re-burning resolver timeouts, path URLs of one host resolve once.
- cli Context: dispatcher and pre-connect caps derived from the process's
  open-files limit (UnixOperatingSystemMXBean), warning when ulimit is low.
- amy graperank probe: relay census - mass-connects every relay the store
  knows (kind:10002 universe deduped per authority + cached verdicts) in
  waves, records live/dead with real measured rtt-open into the NIP-66
  reachability cache (RelayProber + RelayReachabilityStore.recordProbed),
  so the next crawl skips dead relays and waits once for the slow-but-alive.

Single-server limits (FDs, ephemeral ports, DNS, threads, conntrack) and the
design are documented in quartz/plans/2026-07-10-graperank-connect-storm.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zEYRGKF943RgLaHTViJaB
2026-07-10 01:13:23 +00:00
Claude 567cc3f416 style: match the composer subject/title field to the new-DM To field
Restyle the ShortNotePostScreen subject/title input to the inline-label +
borderless ThinPaddingTextField + hairline-divider look used by the new-DM
composer's "To"/"Subject" rows, instead of a boxed OutlinedTextField. The field
now backs onto a TextFieldState (like the DM composer) rather than a String.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-10 00:29:41 +00:00
Claude d650c184de feat: require the title for NIP-29 group threads (no text fallback)
The group-thread composer now treats the subject/title field as mandatory: the
field is always shown (no toggle to hide it), canPost() blocks until it's filled,
and createTemplate uses it verbatim as the kind-11 title — dropping the previous
first-line-of-the-body fallback. Plain kind-1 notes keep the optional subject.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-10 00:21:25 +00:00
Claude bf288711a2 feat: optional subject/title field in the ShortNotePostScreen composer
Add a subject line toggled from the composer's bottom row. On a kind-1 note it
becomes a NIP-14 `subject` tag; on a NIP-29 kind-11 group thread it becomes the
`title` (superseding the first-line-as-title heuristic — that stays as the
fallback when the field is left empty). The field is auto-shown for group
threads and labelled "Title" there, "Subject" otherwise.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-10 00:14:05 +00:00