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
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
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
- `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
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
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
- Composer chip: replaced the text button with a bolt icon carrying the
difficulty as a small badge (primary-tinted when active, dimmed when
off), matching the layered-icon language of the rest of the options
row; the current choice is bolded in the override menu.
- Mining banner: pulsing bolt while the nonce search runs, per-job
elapsed time driven by a 1 Hz clock ("Note • mining at 20 bits • 12s"),
and proper close IconButtons instead of a text "×". PoWJobState now
carries miningStartedAt for the elapsed display.
- Settings: a live "≈ 45s per post on this device" estimate under the
difficulty picker, from a one-shot cached sha256 benchmark
(PoWEstimator in commons) and the 2^d expected-attempts mean.
- Received notes: DisplayPoW's plain "PoW-24" text is now a compact
secondaryContainer pill (bolt + bits) used by NoteCompose, thread view
and chat messages.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
Backgrounding the app no longer pauses or loses mining:
- commons: PoWPublishQueue checkpoints template jobs through a
PoWJobPersistence hook (saved on enqueue, removed on finish/cancel,
deduped by id so restore is idempotent) and fires onQueueActive on
every enqueue. PoWReplay flattens the live continuation into a
PersistedPoWJob record (broadcast / to-relays / schedule) that can be
replayed headlessly. Opaque enqueueWork jobs (reactions, reposts, gift
wraps) and anonymous posts stay in-memory on purpose — a throwaway key
and its content must never touch disk.
- amethyst: PowJobStore (atomic-rename JSON file, single-lane writer,
3-day staleness purge) and PowJobRestorer, which re-enqueues an
account's checkpointed jobs on login and finishes them via the replay
descriptor. Composers now pass the matching PoWReplay for the public,
group-thread and scheduled paths.
- PowMiningForegroundService: a shortService-type FGS started on every
enqueue (~3 min guaranteed budget, no special permission) that keeps
the process out of the cached-apps freezer while mining. Stops itself
when the queue drains; on onTimeout it exits cleanly since the jobs
are checkpointed. The notification is a live progress card
(NotificationCompat.ProgressStyle): one track segment per post filling
as jobs finish, indeterminate for a single post, per-kind text, a
cancel-all action, and Live Updates rendering on Android 16+.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
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
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
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
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
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
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
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
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
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
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
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
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
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
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
Opening a group from the relay's channel list used to start its chat from a cold
load. Mount the existing RelayGroupWarmupSubscription on every visible card
(content-only — the directory subscription already streams metadata), so a tap
lands on already-cached messages.
Add a contentLimit to the warmup (default 50, unchanged for discovery's "50+"
signal); the channel list passes ~10 — a first screen's worth. Bounded to
visible rows by the LazyColumn and released as they scroll off.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
The group "new thread" FAB opened a cramped title+body screen. Point it at the
rich ShortNotePostScreen instead (attachments, emoji, previews, markdown), and
teach that composer to emit a kind-11 group thread when opened for a group.
- Route.NewShortNote gains groupThreadId + groupThreadRelayUrl; the group Threads
FAB navigates there.
- ShortNotePostViewModel.setGroupThread arms a group-thread mode: createTemplate
builds a kind-11 ThreadEvent (title = first line, body = the rest, `h` scope),
and sendPostSync publishes it ONLY to the group's host relay via
signAndSendPrivatelyOrBroadcast — bypassing the outbox/private/scheduled paths.
- The poll, private-note and scheduling toggles are hidden in group-thread mode
(they don't apply / would break the host-relay pin).
- Delete the now-unused RelayGroupNewThreadScreen and its route/nav dispatch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
Move the NIP-29 inline/by-relay toggle off the top of the Messages tab (where it
sat clipped behind the tab row) into a new Settings › Messages screen, and make
"by relay" actually mean something in the feed.
- New MessagesSettingsScreen (Route + SettingsCatalog entry + nav) with a
radio choice: show each joined group inline, or collapse each relay's groups
into one row. Removes the pinned SegmentedButton + above-pager server list
from both the single- and two-pane layouts; deletes the now-dead
RelayGroupViewModeToggle and RelayGroupServerList composables.
- GROUPED mode now weaves one row PER HOST RELAY (never duplicated) into the
Messages feed, positioned at that relay's newest group message so it
interleaves with DMs by recency and shows the last message. Backed by a
synthetic RelayGroupServerRoomNote whose createdAt mirrors the newest message;
ChatroomListKnownFeedFilter builds/updates it in feed(), applyFilter and
updateListWith, keyed by relay url. A view-mode change forces a feed rebuild.
- Revert the moot kind-7 render guard: the data-layer content filter already
keeps reactions out of the row, so the ChatroomEntry fallback stays simple
(the shared Event.isGroupChatContent helper remains, used by the feed filter).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
The NIP-29 group composer always built replies with ChatEvent.build and ignored
replyTo entirely, so a reply to a kind-9 message quoted its parent in the UI but
the signed event carried no NIP-18 `q` tag (and no `p` notify to the author) —
unlike the public-chat and live-activity paths, which use `.reply(...)`.
Route group replies through ChatEvent.reply when replyTo is set: it emits the
`q` quote tag, and we add a `p` tag to the parent's author.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
The Messages-list feed already filtered kind-9/1068/11/1111 content when
selecting a group's representative note, but the ChatroomEntry render fallback
still rendered ANY group-scoped note — including a kind-7 reaction lingering in
the in-memory list — as the group row, using the reaction's content and time.
Add a shared quartz helper `Event.isGroupChatContent()` and use it in both
places: the feed filter and the render fallback. A non-content group-scoped note
now falls back to the channel placeholder ("No messages yet") instead of showing
the reaction as the room's last message.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
Notification events were only fetched from my inbox relays (#p=me), but NIP-29
group activity — a reaction or reply to my message — lives on the group's HOST
relay, so it never surfaced in Notifications until I opened the group (which
subscribes to the group's content directly).
Extend the notifications subscription to also poll each joined group's host relay
for events that tag me, scoped by `#h` to my joined groups (new
filterGroupNotificationsToPubkey over reaction/reply/repost/zap/report kinds).
Re-subscribe when the joined-group list changes so a newly-joined group's relay
is added.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
filterRelevantRelayGroupMessages picked ANY group-scoped note (isGroupScoped()
= carries the group's `h` tag), so a reaction (kind 7) to my message became the
group's "last message" on the Messages tab — a wrong row that the chat renderer
can't display. Whitelist actual chat content (kind 9 chat / 1068 poll / 11
thread / 1111 comment) and reject reactions, deletions, labels, etc. Apply the
same guard to the feed()'s channel-notes scan as defense in depth.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
Relay targeting is fully distributed: every feed, loader, finder and
broadcast path builds its own relay set and hands it to the shared
INostrClient. Only the follow-outbox flows and the top-nav feed filters
subtracted the NIP-51 kind:10006 blocked list, so blocked relays still
leaked in through the event/thread loaders (FilterMissingEvents /
FilterMissingAddressables), the user-metadata finder
(pickRelaysToLoadUsers), channel finder, DM targeting, the one-shot
fetch helpers, and the publish path (Account.computeRelayListToBroadcast)
— none of which consulted the blocked set.
Add BlockedRelayFilteringClient, a thin INostrClient decorator that
strips the active account's blocked relays from subscribe, count and
publish right before they reach the pool. Because the one-shot fetch
helpers route through subscribe/count, wrapping the client covers them
too. The blocked set is read per-call so account switches and list
edits apply with nothing to invalidate.
Wire it around the shared app client (blocked set from the logged-in
account) and around the per-account crawl client used by Event Sync and
Cashu discovery. Add commonTest coverage for the filtering, pass-through,
fully-blocked, and per-call-read behaviors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JNMPdC2eGUwTrt3XkQefuf
A NIP-29 group message whose note isn't attached to its RelayGroupChannel (its
gatherer never registered — loaded before the channel existed, or via a path
that skips attach) hit ChatroomEntry's when(event) with no matching case and fell
to `else -> BlankNote()`: a white, non-clickable row where the group should be.
The inGatherers check only covers attached notes.
Handle group-scoped events explicitly before the when: resolve the group from the
event's `h` tag + the note's provenance relay and render RelayGroupRoomCompose,
so the row shows the group and taps through to the chat.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
Add a regression test proving RelayGroupChannel.placeholderNote() carries the
channel as a gatherer and is cached/stable — this is what lets the Messages row
renderer resolve the event-less placeholder back to the group (the prior fix).
Also give the empty-group placeholder row a visible "No messages yet" second
line instead of blank content, matching the Marmot-group row, so a just-joined
group with no messages reads clearly rather than looking like an empty item.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
Activity.dispatchKeyEvent is a public framework hook; lint flags the
override only because androidx.core's intermediate override carries a
library-group @RestrictTo. Scoped to the method so the check stays live
for genuine restricted-API use. Makes :nappletHost:lintDebug pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HLfwhTdf72qFPnmPRYnzqu
The Messages placeholder guard in ChatroomHeaderCompose only recognized Marmot
placeholders, so a just-joined NIP-29 relay group (an event-less placeholder note
carrying a RelayGroupChannel gatherer) fell through to the wait-for-event branch
and rendered BlankNote() — a white gap where the group row should be. Recognize
a RelayGroupChannel gatherer too so it routes to the group row renderer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
Addresses correctness/perf issues found in the crawler + reachability audit:
- deadHosts permanent eviction (#1): an authority that accrued timeoutEvictStrikes
before its first EOSE was evicted forever — clearTimeoutStrikes only zeroed the
counter and could not un-evict, contradicting the "a host that ever produces is
never evicted" invariant. Add a producedHosts set that isDead() consults, so a
proven-productive authority is never treated as dead even if a concurrent strike
from the 24-worker fan-out raced it into deadHosts.
- Parking-disabled event loss (#2): when parking is off (no bgScope, or
parkTimeoutMs <= timeoutMs), a relay that streamed events but didn't EOSE in the
fast window had its buffer dropped without persist() and reported count 0. Drain,
persist, and return those events like the other two branches; strike only when
nothing was delivered.
- Wide-sweep over-narrowing (#4): relayListDiscoverySwept excluded an already-swept
straggler from the wide pass even though the wide net grows each round, so a 10002
hosted only on a later-learned relay was never fetched. Gate the wide pass on the
asked-relay set (wideRelaysSwept) instead: new users get the full net, older
stragglers get only newly-appeared relays, no (user, relay) pair asked twice.
- Onion detection (#10): replace loose relay.url.contains(".onion") with
RelayUrlNormalizer.isOnion() in isDead() and networkTypeOf(), fixing the
foo.onionfake.com false positive and the store/crawler disagreement.
- rtt-open=0 semantics (#9): document that the crawler's reachable records use
rtt-open purely as a liveness flag (0 = latency not probed), not a real 0 ms
measurement, and must not be published as authoritative latency data.
deadHosts is deliberately still NOT persisted to the 24h reachability cache (#8):
a timeout eviction means "too slow under our fan-out this run", not "proven
unreachable", so persisting it would blacklist slow-but-live hubs across runs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
Tapping a NIP-29 message in Notifications (or the feed) fell through routeFor's
`else -> Route.Note`, opening the generic thread view instead of the group chat —
unlike every other chat NIP. Mirror the Marmot-group path: when a note is
attached to a RelayGroupChannel gatherer, route to Route.RelayGroup (the channel
carries the host relay). Add an `h`-tag + provenance-relay fallback for a
group-scoped note that isn't attached to a channel yet, so it still opens the
chat rather than a thread.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
The Messages tab (INLINE mode) mapped each joined group to its newest cached
kind-9 message and dropped it when none existed. Since the joined-groups
subscription only fetches roster kinds (39000/1/2), not chat, a group you just
joined stayed invisible until you opened it (loading messages) or posted — unlike
Marmot groups, which already fall back to a placeholder row.
Mirror the Marmot pattern for relay groups:
- RelayGroupChannel.placeholderNote(): a cached synthetic note that adds the
channel as a gatherer, so the existing Messages row renderer resolves it back
to the group (RelayGroupRoomCompose already handles a null-event note).
- ChatroomListKnownFeedFilter.feed(): fall back to placeholderNote() when the
group has no loaded message.
- AccountFeedContentStates: rebuild dmKnown when relayGroupList (kind 10009)
changes — join/leave doesn't flow through newEventBundles, so without this the
placeholder wouldn't appear until a later event.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
The assembler names didn't say when each is active — most confusingly, a
"Threads" assembler with no matching "chat" one, because group chat (kind-9) is
served by the shared `channel` assembler, not a group-specific one. Rename the
four group-specific families for their surface, and document that chat has no
dedicated assembler:
relayGroupDirectory -> relayGroupsOnRelay (browsing one relay's channels)
relayGroupRoster -> relayGroupMyJoinedGroups (metadata+rosters of joined groups)
relayGroupThreads -> relayGroupThreadFeed (a group's forum-threads tab)
relayGroupPreview -> relayGroupWarmup (prefetch before a group opens)
Each family's FilterAssembler / QueryState / SubAssembler / Subscription + file
renamed to match. relayGroupsDiscovery is left as-is: it already names the
Discover feed and shares its token namespace with the screen/DAL/settings, so a
rename would either collide with RelayGroupDiscoveryFeedFilter or corrupt those.
Pure rename; no behavior change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
FrameDispatchStats stamped a ValueTimeMark on every relay frame and recorded a
contended atomic per frame in BasicOkHttpWebSocket — the WebSocket layer used by
the whole app, unconditionally, forever — to answer a one-time question that only
graperank --diagnose read. It served its purpose (proved the our-side dispatch
lag is ~200ms mean and the EOSE-wait is dominantly relay-side, so the crawler is
network-bound), but the ongoing per-frame Pair allocation + atomic contention on
every client's relay traffic isn't worth carrying. Revert the channel back to
Channel<String> and delete the stats holder. The diagnose-gated saturation ticker
and per-drain latency breakdown stay — they're crawler-local, off the hot path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
NIP-29 relays reject kind-9 writes from non-members, so typing in a group you
haven't joined only earns a silent relay rejection. Show the composer only when
the relay-signed roster (39001/39002) lists me as a member/mod/admin — the same
boundary the threads FAB already uses — collecting the channel metadata flow so
it appears the instant my join is accepted. Otherwise replace it with a notice:
"Join this group to send messages" (open) or an invite-only explanation (closed).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
The "groups on this relay" and group-threads screens used a bare
FloatingActionButton, which renders as Material 3's default rounded-square shape.
Every other new-post FAB in the app is circular (shape = CircleShape); match it
so the group FABs read the same as the rest of the app.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
The user's NIP-51 "simple groups" list (kind 10009 — joined NIP-29 groups +
servers) was neither consumed nor requested at login:
- LocalCache had no dispatch branch for SimpleGroupListEvent, so an arriving
10009 fell through to the "Event Not Supported" else and was dropped —
RelayGroupListState, which reads it from the addressable cache, could never
populate from the network (only from the on-device offline backup).
- The account-info assemblers (filterAccountInfoAndListsFromKey /
filterBasicAccountInfoFromKeys) never REQd kind 10009 alongside the sibling
NIP-51 lists, so a fresh sign-in never fetched it.
Add the consume branch (consumeBaseReplaceable, like every sibling list) and
include SimpleGroupListEvent.KIND in both assemblers, so "My Groups" and group
memberships resolve from the start of login without opening the groups screen.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
Three group-discovery card changes:
- Reactive loaded-message count. The preview subscription streams the group's
recent kind-9 chats into the channel note cache; the card now shows that count
on the stats line ("12 members · 50+ messages"), updating live as messages
arrive. Bumped the preview page 15 -> 50 so an active chat reads as "50+"
(also a better warm-up); the display caps at DISCOVERY_MESSAGE_CAP.
- People-you-follow social proof. RelayGroupChannel.participatingFollows()
intersects the relay-signed roster with the kind-3 follow set; the card shows
an overlapping face pile + "%d people you follow" caption when non-empty.
- Split the relay chip's tap targets. Tapping the chip body now opens that
relay's full group list (Route.RelayGroupServer); only the star toggles the
relay favorite. Previously the whole chip favorited, which was easy to hit by
accident.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj