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
Measured: relays deliver their events in ~0.6s then sit ~4.6s (86% of drain wall)
before sending EOSE — mostly relay-side (our pipeline adds only ~200ms). So instead
of waiting the full 10s fast window then parking, close a drain that has delivered
>=1 event and then gone silent for eoseIdleMs, treating it as complete ("eose-idle").
awaitTerminalOrQuiescent: the idle timer arms only AFTER the first event, so a relay
merely slow to answer still gets the full timeoutMs and is never cut prematurely; a
still-streaming relay keeps resetting the window. eose-idle paginates if the page was
capped and clears timeout strikes (it delivered), but joins notAnswered (no clean
EOSE, so its missing authors are retried elsewhere). Off by default (eoseIdleMs=0),
CLI --eose-idle-ms, so it can be A/B'd against the plain fast window.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
The dedicated frame-dispatch pool (ea1093ad) made dispatch lag WORSE, not better:
mean 200ms→460ms, max 3.5s→5.5s, frames>1s 43k→76k. The pool was sized cores*2
(=8 here) vs Dispatchers.IO's 64 threads, so it cut frame-processing parallelism
~8x. Lesson: the our-side lag is dominated by per-connection serial decode
throughput / thread count, NOT cross-contention with the store's IO writes — the
experiment ruled that hypothesis out. Reverting to shared IO; keep FrameDispatchStats.
EOSE-wait is confirmed dominantly relay-side (200ms our-mean vs ~5s eose-wait).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
Measured on a GrapeRank crawl, frame decode/dispatch (per-connection consumer
coroutines) ran on the shared Dispatchers.IO — the same pool that runs the store's
blocking SQLite inserts. During event floods, frame coroutines queued behind those
inserts: mean 200ms and up to 3.5s of dispatch lag, with 43k frames waiting >1s in
our pipeline. That lag also skews the relay-idle/EOSE timing the crawler reads.
Give frame processing its own daemon thread pool (sized to a small multiple of
cores; decode is light + CPU-bound), shared across all connections. Frame delivery
stays prompt regardless of what the IO pool is doing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
The browse-a-relay field was a plain text box. Wire it to the same relay
autocomplete every other relay field uses (RelaySuggestionState +
ShowRelaySuggestionList over LocalCache.relayHints): as you type, a popup lists
matching known relays, and tapping one opens that relay's group directory
directly. The manual paste-and-Go path and the your-relays / popular sections
are unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
Adds FrameDispatchStats: the lag between a relay frame arriving on the OkHttp
reader thread and our per-connection consumer coroutine (on shared Dispatchers.IO)
pulling it off the channel — pure our-side pipeline delay, relay send-timing
excluded. BasicOkHttpWebSocket stamps arrival before enqueue and records the lag
on dequeue; the crawler resets it at start and dumps it in the --diagnose summary.
Answers whether a drain's 5s gap between the relay's last event and its EOSE is
the relay being slow to SEND eose (low dispatch-lag) or our IO pipeline backing up
so the already-arrived eose frame sits queued (high dispatch-lag).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
A Nostr pubkey is x-only, exactly 32 bytes, but NPub.parse/NProfile.parse never
checked the length — they hex-encoded whatever bytes the bech32/TLV carried. A
malformed npub/nprofile that some clients encode with the full 33-byte COMPRESSED
secp256k1 key (0x02/0x03 prefix) therefore round-tripped its 66-char hex straight
into a `p`/`q` tag via the quote/mention path, and a strict relay (relay29 /
pyramid.fiatjaf.com) rejected the whole group message:
blocked: schema validation failed: tag[..]: invalid pubkey value
'02977dcf…c3402' ... pubkey should be 64-char hex
We never generate compressed keys ourselves (Nip01Crypto.pubKeyCreate strips the
prefix byte); this is purely inbound malformed input. Enforce the 32-byte length
at the decode boundary so the bad entity never becomes a mention/quote tag.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
Answers "are we resource-bound or waiting on relays" without a profiler:
- progress ticker gains "Nw/CAPw" (drain workers busy vs drainConcurrency) and
"N rl" (rate-limit responses so far) — a rarely-full pool means the producer or
the relays are the limit, not concurrency; a climbing rl count is the external
ceiling that made concurrency 60 backfire.
- crawl-end "latency breakdown": splits each drain's wall into time-to-first-event
vs EOSE-wait-AFTER-the-relay's-last-event, and reports the % of drain wall spent
waiting for EOSE after the relay was already done, how many drains blew the fast
window and parked, and total rate-limit hits. A high EOSE-wait % is the direct
case for a shorter/adaptive fast window over more concurrency.
All gated on config.diagnose; zero cost on a normal run.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
The standalone star icon sat right next to the group's Join button, so it read
as a second way to act on the group when it actually favorites the host RELAY.
Pull the relay out of the member-count line into its own tappable chip with the
star inside it — favorited relays fill primary, unfavorited show a tonal outline
— so the two scopes are visually distinct: the chip favorites the relay (and
surfaces its groups under the relay filter), the button joins the group.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
Two changes:
1. The reachability flush re-wrote the SEEDED known-dead relays with a fresh
created_at every run, refreshing their TTL without a re-probe — so a relay
marked dead once (and thereafter skipped, never re-dialed) would stay
blacklisted forever as long as crawls kept running, defeating the TTL's
re-probe. Stats.deadRelays now reports only relays actually dialed this run
(deadRelays - knownDeadRelays); seeded records keep their original timestamp
and age out on schedule so the next run re-probes them.
2. Rename GrapeRankDataCrawler -> GrapeRankCrawler (file + all references).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
Flipping the top-nav filter A->B showed A's groups until a manual pull-to-
refresh. The selected list flips synchronously, but the per-relay set the feed
filters on (liveRelayGroupsDiscoveryFollowListsPerRelay) resolves a frame later
via the async outbox loader. feedKey() keyed only on the list code, so the first
refresh ran against the stale (A) set and the catch-up emission — same list code
— was swallowed by checkKeysInvalidateDataAndSendToTop's key-unchanged guard,
freezing the feed on A.
Fold the resolved discriminator into feedKey(): the joined ids for "My Groups",
the per-relay constraints otherwise (both content-hashed via data classes). The
key now moves when the resolution lands, so the refresh fires and the feed
follows the selection without a manual pull.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
Wire RelayReachabilityStore into the crawler and the WoT updater so liveness is
shared across procedures and runs instead of each rediscovering dead relays.
- OperatorKeys.monitorKey(): a dedicated machine monitor identity derived from the
operator master (domain "relay-monitor:"), independent of any account — the
30166 records are published under this, not the observer key.
- Context.reachability: a RelayReachabilityStore over the shared store, signed by
the monitor key.
- Crawler: Config.knownDeadRelays seeds deadRelays before the run; Stats now
returns the final dead/live sets. GrapeRankCommand seeds from snapshot().dead
and flushes the crawl's verdicts back via reachability.record().
- Updater: Config.knownDead skips proven-dead relays from the reconcile plan — a
dead relay cannot serve its authors, so reconciling it only burns a timeout.
Live author-advertised relays are always synced.
All behind --no-reachability-cache. TTL'd (24h), so a recovered relay is retried
once its record ages out — a "skip for now", never a permanent ignore, keeping
the outbox rule that every live advertised relay is tried.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
A durable, shareable relay-reachability cache backed by the EventStore as NIP-66
kind:30166 Relay Discovery events, so the crawler, the WoT updater, and future
runs share liveness knowledge instead of each rediscovering dead relays from an
in-memory set wiped at process exit.
- 30166 is addressable by its d-tag (relay URL) → one replaceable status slot per
(monitor, relay), with created_at giving a free TTL.
- Reachable → 30166 with rtt-open; dead → 30166 without (NIP-66 has no explicit
offline field; liveness is inferred from a fresh successful open). Live wins
over dead within the TTL, so third-party monitors' 30166 can be ingested.
- snapshot() loads the fresh set once (not a per-request hot-path query); record()
flushes a run's findings. A relay is only skipped for the TTL, never permanently
— consistent with the outbox rule that every advertised write relay is tried.
Reuses the existing RelayDiscoveryEvent. jvmTest covers record/reload,
live-overrides-dead, TTL expiry, and .onion→Tor network tagging.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
The discovery filter spinner offers a favorite-relay chip (TopFilter.Relay),
but makeRelayGroupsDiscoveryFilter had no branch for RelayTopNavPerRelayFilterSet
— it fell through to `else -> emptyList()`, so selecting a relay sent no REQ and
the feed stayed empty even though the dal's toGroupConstraints() already mapped
that filter to AllGroups-on-that-relay. Add filterRelayGroupsByRelay (the same
whole-directory pull as Global, scoped to the one relay) and wire the dispatch
branch, restoring parity between the two per-type tables.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
Only 39000/39001/39002 (metadata + rosters) and the group-scoped content
kinds were consumed; every other registered NIP-29 kind deserialized fine but
fell through to the "Event Not Supported" else branch and was dropped.
Add explicit branches for the whole family:
- 39003 SupportedRoles and 39004 GroupParticipants are relay-signed
addressables — durable group state alongside 39000/1/2 — so they're stored
replaceably (consumeBaseReplaceable).
- the 9xxx moderation actions (put/remove user, edit/delete metadata, create/
delete group, create invite) and 9021/9022 join/leave requests are regular
one-shot events the relay is authoritative for; store them via
consumeRegularEvent so they're queryable and no longer warn, without acting
on them client-side.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
The sealed GroupDiscoveryConstraint matcher (AllGroups/ByPeople/ByHashtags/
ByGeohashes/AnyOf) is pure over RelayGroupChannel + HexKey with no LocalCache,
eose-manager, or topNavFeeds dependency, so it belongs in :commons alongside
RelayGroupChannel where Desktop/CLI can reuse it. The amethyst dal keeps only
the platform-specific toGroupConstraints() mapping from the Android top-nav
filter set onto the shared matcher.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
The synchronized intrinsic is only available on JVM/Android, breaking the
iOS (compileKotlinIosSimulatorArm64) build in commonMain. Replace the Any()
lock with a kotlinx.coroutines Mutex + withLock, which is KMP-common and
safe here since all three store methods are already suspend functions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A918G2Ks9i9LXRuVoNgm7R
"Mine" only listed groups where the relay-signed roster (39001/39002) already
had me as an admin/member — so a group I just joined (or an open group I post
in without being rostered) wouldn't appear until the relay caught up. It now
shows the UNION of:
- my kind-10009 joined list (authoritative, immediate), and
- groups whose roster lists me as an admin/member.
The screen re-scans when the joined list changes (join/leave), so a newly
joined group appears right away.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
The joined-groups ("Mine") entry was placed first in the discovery filter
dropdown; every other feed lists it last in the base group (after Global).
Match that ordering so the top-nav popup is consistent across screens.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
- RobohashAssembler read up to hash[10] but only required the input to be
>10 chars; a 16-char NIP-29 group id is valid hex that decodes to 8 bytes,
so hash[8] threw ArrayIndexOutOfBoundsException and crashed the discovery
feed. Require >=22 hex chars (>=11 bytes) before decoding; anything shorter
falls back to sha256 (32 bytes). Latent crash for any short hex seed.
- The discovery screen used a hand-rolled ShorterTopAppBar (dropping the
standard search icon + memory chip) to fit a browse action. Switched to the
shared UserDrawerSearchTopBar like every other top-level feed; the
browse-a-relay action moved to the FAB (DisappearingScaffold.floatingButton).
- Removed the extra Column(Modifier.padding(padding)) wrapper that double-
applied the top-bar inset (rememberFeedContentPadding already accounts for
it) — that was the large blank block above the first card.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
The top-level "Relay Groups" tab was a thin server-list home screen while a
separate "Find Groups" discovery feed did the real work. They're now one screen:
the discovery feed IS the Relay Groups tab, defaulting to a "My Groups" filter.
- Route.RelayGroups now renders the discovery feed (top-level DisappearingScaffold
+ AppBottomBar + drawer top bar with the filter spinner and browse action).
- "My Groups" (TopFilter.Mine) lists the groups you've joined. These live on their
host relays (kind 10009), not your outbox, so the filter scans the cache for
groups where you're the relay-key / an admin / a member; the joined rosters are
kept live by RelayGroupRosterSubscription mounted on the screen.
- Per-relay "server" browsing is still available via the relay chips in the filter;
the grouped server rail still shows in the Messages tab (GROUPED mode).
- Default discovery filter is now Mine (was Global); the "Mine" chip is back in the
route list.
- Deleted RelayGroupsHomeScreen and the redundant Route.RelayGroupDiscovery; the
Messages "Find groups" row and everything else point at Route.RelayGroups.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
- Correct the AUTH-approval persistence check: this JVM uses
MacOSXPreferences (~/Library/Preferences/com.vitorpamplona.amethyst.plist),
not ~/.java/.userPrefs. Updated T3.b/c/d to read it via plutil.
- Record session results (T1,T2,T3a,T3c,T6,T6b,T8,T12 pass) and the three
bugs found+fixed during the run.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The relay-hint plumbing existed end to end in quartz (NIP17Factory ->
GiftWrapEvent.create(recipientRelayHint), with GiftWrapRelayHintTest), but
DesktopIAccount called createMessageNIP17/createEncryptedFileNIP17 without
passing recipientRelayHints. The default {null} lambda meant every outgoing
gift wrap shipped a 2-element ["p", pubkey] tag — the hint feature was dead
in production. Manual testing (T8) caught this: wraps on the recipient's
inbox relay had no third element.
Pre-resolve each recipient's primary DM inbox relay (first entry of their
kind:10050, order-preserving) and pass it as the hint, yielding
["p", pubkey, "wss://primary-relay/"]. Recipients with no resolvable
kind:10050 map to null and keep the 2-element shape.
Adds resolveDmInboxRelaysStrictOrdered (order-preserving) as the basis for
both the target-relay set and the primary-relay hint.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The new-conversation picker rendered a pasted npub as a non-clickable
Surface whenever getUserIfExists returned null — i.e. for any recipient
whose kind:0 metadata the local cache hadn't seen. The npub showed in the
results list but couldn't be selected, so you couldn't start a DM to
anyone new by npub.
A DM recipient is identified purely by pubkey; metadata is not required to
open a conversation. Use getOrCreateUser so a valid npub always resolves to
a selectable UserSearchCard, keeping the non-clickable fallback only for
keys that can't be resolved at all.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Round-8 profiling showed the crawl re-querying the same never-had-a-10002
users' outboxes every round they recirculated — ~144k slow kind:10002 drains
(p50 17.4s) against a static discovery set, dragging the round to ~18 users/s.
1. ensureRelayLists guards with `relayListDiscoverySwept`: each user's outbox
discovery runs once. The discovery relay set is static, so a second sweep of
a user still lacking a 10002 cannot find one the first missed.
2. The discovery REQ to the bounded INDEXER set co-fetches [10002, 3]: the
outbox lookup already pays the round-trip and an indexer holding a user's
10002 often holds their kind:3, so we harvest the contact list as a cheap
byproduct. The wide "every live relay" completeness sweep stays 10002-ONLY —
co-fetching kind:3 across thousands of relays downloaded the same big contact
lists repeatedly and inflated the fire-and-forget bgScope sweep the finishing
drain waits on (measured +260s at hop-3; the indexer-only co-fetch keeps
coverage flat at baseline speed).
3. harvestFromStore folds any already-stored kind:3 into the graph at Phase-A
time so Phase B never re-drains a list we hold (also speeds re-runs).
Verified same-session hop-3: pre-fix 685s / narrowed 690s / wide-co-fetch 945s,
coverage 91.74% across all.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
Covers the review fix — recipient with NIP-65 read relays but no
kind:10050 must be treated as unreachable, not routed to the read relays.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The indexer fan-out already used lists.dmInbox (strict), but the
LocalCache fast-path in DmInboxRelayResolver.resolve() — and the
no-resolver fallback in DesktopIAccount — went through the lenient
User.dmInboxRelays(), which falls back to NIP-65 read relays (kind:10002)
when the recipient has no kind:10050.
Because that fast-path returns first and short-circuits the strict
indexer lookup, a recipient with NIP-65 read relays but no published
DM-inbox would get gift wraps published to relays they never designated
for DMs — re-introducing the metadata leak (recipient pubkey + send
timing) the P0 fix was meant to close. LocalCache commonly holds
kind:10002 but not kind:10050, so this path fired often.
Switch both LocalCache lookups to dmInboxRelaysStrict().
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The per-relay pending maps in RelayLatencyTracker are
Collections.synchronizedMap(LinkedHashMap): individual read/write
ops are thread-safe, but per the synchronizedMap javadoc iteration
is NOT — callers MUST hold the map's monitor while walking its
views. sweep() was iterating directly, so any network-dispatcher
mutation (adding a pending REQ, receiving an OK) during a sweep
would throw ConcurrentModificationException on AWT-EventQueue-0,
killing the Compose renderer while coroutine work kept running.
Pre-existing bug, documented in memory
desktop_relay_health_cme_crash. Ordinarily "not our problem", but
it's actively blocking manual T3 testing of this branch's AUTH
approval banner: adding any new relay triggers a
RelayHealthStore.reclassify sweep, so testers can't get a banner
render in without hitting the crash. Fix it here so the branch is
actually testable end-to-end.
Wrap both iteration loops in synchronized(pending) blocks. Sweep is
O(pending) with typically single-digit entries per relay, so the
hold time is negligible and the network dispatcher just briefly
waits.
Reproduced during manual T3 testing 2026-07-06 when adding
wss://pyramid.fiatjaf.com. Stack: RelayLatencyTracker.sweep:182 →
RelayHealthStore$reclassify$flagged$1.invokeSuspend:268.
DesktopAuthCoordinator.selfApprovedRelaysFor was calling the lenient
User.dmInboxRelays() helper, whose NIP-65-read fallback silently
expanded the tier-1 auto-allow set to include every relay in the
user's read markers. That defeated the tier-2 prompt for any AUTH-
required relay the user happened to have in NIP-65 — such as
wss://pyramid.fiatjaf.com, which never surfaced a banner during
manual testing because the coordinator was auto-signing it as
tier-1.
Switch to dmInboxRelaysStrict() (added in the earlier pre-send
alignment fix) so tier-1 is truly kind:10050 only. The KDoc already
promised strictness ("write/read relays are NOT included") — this
just makes the code match.
Surfaced during manual T3 testing 2026-07-06 with an account whose
NIP-65 outbox included pyramid.fiatjaf.com.
Rewrites the manual testing sheet so every step is an executable action
or a specific observation:
- Numbered steps within each test — no jumping between reference
sections. "Click X", "run Y in a terminal", "watch for Z".
- Each observation records YES/NO/SKIPPED for the sign-off matrix.
- Setup section spells out the wipe-preferences command and the
post-restart smoke check.
- T3 broken into T3.a/b/c/d for each button and its persistence
check separately (previously bundled T3.1–T3.10 was too dense).
- T6/T7 include exact tcpdump/tshark commands for the security
observations that require packet capture.
- T13 shortened to a sanity re-check (already verified).
- Sign-off table lists every test with a checkbox.
Purpose: give the tester a self-contained document they can follow
top to bottom in ~40 min without cross-referencing other sheets.