Filters carry an accountPubKey so the relay screens can group by account,
but attribution only happened for keys implementing AccountScopedQuery —
and ~50 query states held an `account` without declaring it, so the cast
failed silently and their filters showed as unattributed.
Two of the gaps were real bugs rather than display issues:
- CashuWalletFilterAssembler took `keys.first().pubkey` while flat-mapping
every account's relays, so with two wallets logged in the second was
never subscribed and its inbox relays were queried for the first
account's nutzaps. Now built per account.
- UserReportsSubAssembler unioned every account's follow list into one
per-relay map, asking one account's follows of another's outbox relays.
Now one pass per account.
Where a subscription genuinely pools accounts (outbox discovery, on-screen
event watching, profile metadata), it is attributed only when a single
account is asking rather than inventing an owner.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PUBLIC_CHATS was a catch-all carrying NIP-28 chats, NIP-29 relay groups,
ephemeral chats, geohash chats and live-stream chat under one row labelled
with the NIP-29 name — so the subscription screen could not say where any
of them came from, and the label was wrong for four fifths of what it
counted.
Each is now its own purpose with its own label and explainer. Geohash
cells carry their id too, so location chats list one row per cell and
render as a place name instead of a single opaque group.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The probe that catches mentions delivered to the wrong relay was subscribing to
every relay the user's follows post to — ~330 relays, ~670 filters held
permanently, by a wide margin the largest thing the client ran. Despite the class
name nothing about it was random and nothing bounded it; `updateFilter` returned
`followsPerRelay.keys - notificationRelays` in full, and a grep for
take/shuffled/random/subList across the manager found nothing.
It now watches a rotating window of 5 relays that slides every 5 minutes, so the
whole space is still swept, just never all at once. That matches what the job
actually needs: a background sweep for misdelivered mentions, while the inbox
relays carry the real traffic.
The window is a contiguous slice of a URL-sorted list, not a fresh random draw.
Re-picking at random on each invalidation would re-REQ a different set every few
seconds — the manager already invalidates on follows changes (debounced 5s) and
notification-feed updates (sampled 5s) — which would cost more than the
subscriptions it replaced. Deterministic order means the window only moves when
the rotation timer says so. The rotation job is registered alongside the existing
two and cancelled with them in endSub.
Measured on device, cold start, same account:
before NOTIFICATIONS 670 filters 168 relays
after NOTIFICATIONS 58 filters 13 relays (8 inbox + 5 sampled)
The Active Subscriptions explainer was updated in the same commit: it described
the old behaviour, and an explainer that lies is worse than none.
HOME_FEED is now the largest at 2,485 filters across 355 relays. That is the
outbox model working as designed rather than an anomaly, but it is the next thing
worth looking at.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces the per-relay purpose chips with a screen whose only job is to answer
"why do I have this many subscriptions right now".
The chips were the wrong shape. Pivoting on relay hides the thing worth finding:
the notifications straggler probe holds ~670 filters across 168 relays, and on a
relay-shaped list that is one unremarkable chip repeated on 168 rows. Pivoted on
purpose it is a single line that dwarfs everything under it, which is exactly how
it was spotted in the first place.
Account is the outer grouping — several accounts are normally logged in, they do
not share relay sets, and a mixed total cannot be acted on. Purposes sort by
filter count, expand to per-entity rows, and carry an explainer describing the
actual strategy rather than the intent. Those explainers are written from the
code they describe: MODERATION says it asks each relay your follows publish to
because UserReportsSubAssembler walks declaredFollowsPerOutboxRelay, and
NOTIFICATIONS mentions the follows-wide probe because
AccountNotificationsEoseFromRandomRelaysManager subscribes to every follows relay
with no sampling.
Names resolve at render time from LocalCache and fall back to a short id — a name
captured when the filter was built would usually be missing (profiles arrive
later) and would go stale on rename.
Untagged filters are counted and shown rather than hidden. A total that claims to
be fully attributed when it is not would defeat the point of the screen.
Reached from the Connected Relays list, which is where the question occurs to
people. Notification filters now carry accountPubKey so the largest purpose
groups correctly; the remaining assemblers still report under "Not attributed to
an account" until they are threaded through.
Counts use two separate plurals composed at the call site rather than one string
with two %d, so "filter" and "relay" decline independently.
NOT visually verified: reaching the screen needs drawer navigation that was not
worth scripting. Compile-clean, tests green, and it reads the same
activeRequests data already verified on device.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds entityId and accountPubKey to ExplainedFilter so a chip can answer "which
community made me connect here, and for whose account" rather than a bare
"Communities". Both ride copy() and are covered by the existing
never-reaches-the-wire test.
Ids, not names: names change, are often not loaded when the filter is built, and
would pin a stale copy into a subscription that outlives them. The UI resolves
against LocalCache at render time.
A pubkey, not an Account reference: filters are held by the relay pool for the
session and outlive the objects that built them, so a reference would keep a
logged-out account alive. Several accounts are normally active and do not share
relay sets, so without this one account's communities read as another's.
purposeEntities() returns the (purpose, entity, account) rows a relay is serving,
which is what a per-relay or per-subscription screen needs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds the purpose chips to the Connected Relays rows, and retires the invented
vocabulary the notification was using.
"Relay lists" and "Moderation" read as fuzzy because they were words I made up
that match nothing the user can navigate to. Every label now points at a string
the app already shows somewhere else — nav routes (Home, Discover, Messages,
Notifications, Chess), event-kind names (Reports, Profile, Follow List, Outbox
Relays, Drafts, Reactions) and feature names (Communities, Nests, Marmot Group,
Wallet). Twelve invented strings deleted; nine remain, only for jobs the app had
never had to name (Media, Hashtags, Topics, Conversation, Search, Quoted posts,
Add-ons, Relay info, Other). Those twelve also needed translating and now do not.
Checking those two labels found three real mis-buckets from the sweep's rule
ordering, all now fixed:
FilterHomePostsByAuthors RELAY_LISTS -> HOME_FEED (nip65Follows in the
FilterPictureAndVideoByAuthors RELAY_LISTS -> MEDIA_FEED path matched first)
FilterDraftsAndReportsFromKey MODERATION -> ACCOUNT_DATA (kinds = DraftKinds;
"report" in the filename)
So "Relay lists · 173 relays" was mostly the home and media feed fan-out, and
"Moderation" was counting drafts. RELAY_LISTS now covers only the outbox finder
and MODERATION only reports-about-you, which is what the words claim.
SubPurposeLabels is the single place a purpose becomes words, shared by both
surfaces so they cannot drift. Which jobs earn a notification line is now derived
from the taxonomy — the ACCOUNT and MESSAGES groups — rather than a hand-kept
list that happened to contain the same twelve.
The two surfaces stay deliberately different: the notification names only the
dozen jobs that survive backgrounding, while the relay screen someone opened on
purpose shows every job, feeds and current-screen work included.
Verified on device, notification re-read from the system:
Notifications · 168 · Reports · 164 · Follow List · 134 · Relay Chats · 19
Profile · 14 · Wallet · 11 · Communities · 8 · Messages · 6
Marmot Group · 5 · Drafts · 3 · Browsing · 165
The chips themselves are compile-verified and read from the same
already-device-verified data, but the rendered row has NOT been seen — reaching
that screen needs drawer navigation that was not worth scripting. Worth a look
before merge.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Renames SubPurpose.isBackground to runsInBackground, and puts the per-job
breakdown behind the notification's expand affordance.
The collapsed line is byte-for-byte what it was — "Connected to 188 relays" —
because that is all most people want from an ongoing notification, and this one
sits in the shade permanently. The breakdown goes in a BigTextStyle, so it costs
nothing until someone deliberately expands it to ask why their phone is talking
to that many relays. It is skipped entirely when nothing is attributed yet, so an
empty section can never render.
Verified by reading the notification back out of the system rather than trusting
the code path (`dumpsys notification --noredact`):
android.text Connected to 188 relays <- unchanged
android.bigText Connected to 188 relays
Relay lists · 173 relays
Notifications · 169 relays
Moderation · 159 relays
Your follows · 154 relays
Public chats · 21 relays
Profiles · 12 relays
Wallet · 12 relays
Communities · 9 relays
Direct messages · 6 relays
Encrypted groups · 6 relays
Your account · 3 relays
Browsing · 173 relays
Only the twelve jobs worth naming to a user get a line. Feeds and whatever screen
is open have no label and collapse into "Browsing" — they tear themselves down
once the app is backgrounded, which is exactly when this notification matters, so
itemising them would add noise precisely when nobody is interested.
The counts overlap on purpose and sum well past the relay count: a typical relay
carries four jobs at once, so there is no partition to show. The copy answers "how
many relays carry my DMs", not "how is the pool split" — which is why the earlier
sketched wording ("4 for notifications, 3 for DMs") was dropped; it implied a
partition that does not exist.
Strings are one reused plural plus thirteen labels, so the count agrees with its
noun in languages that decline it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first pass put 208 of 343 filters into SCREEN_CONTENT — a bucket covering the
whole Discover tab, every media feed, search, threads, profiles, badges, chess
and a mis-filed wallet screen. Useless for explaining anything. MODERATION ended
up with zero filters despite kind-1984 subscriptions being live, and
ENCRYPTED_GROUPS with zero because Marmot builds its filters in quartz and wraps
them later, so the sweep's `filter = Filter(` pattern never saw them.
SubPurpose is now 27 specific jobs on two axes:
- `group` rolls them up (ACCOUNT / MESSAGES / FEEDS / CURRENT_SCREEN) so a
notification can say something short while a relay screen or bug report keeps
the fine value. Adding a fine value stays cheap.
- `isBackground` marks what may outlive the foreground.
SCREEN_CONTENT is gone, replaced by DISCOVER_FEED, MEDIA_FEED, TAG_FEED,
COMMUNITY_FEED, TOPIC_FEED, THREAD, USER_PROFILE, SEARCH, ENGAGEMENT,
REFERENCED_EVENTS, ADD_ONS, GAMES and RELAY_INFO. CHATS splits into PUBLIC_CHATS,
COMMUNITY_CHATS, ENCRYPTED_GROUPS and LIVE_ROOMS. Marmot is tagged through
ExplainedFilter.of() at the wrap site, since quartz cannot see commons.
Every one of the 27 values is applied by at least one filter — checked, not
assumed. Nothing in a relay-bound path is left untagged.
Measured on device, cold start then HOME:
foreground @45s 13 purposes HOME_FEED 337 relays · NOTIFICATIONS 329 ·
RELAY_LISTS 337 · MODERATION 326 · ENGAGEMENT 20
background @90s 9 purposes all isBackground=true
Both `isBackground = false` purposes in flight (HOME_FEED, ENGAGEMENT) were gone
after backgrounding, which is the invariant worth having: a CURRENT_SCREEN
purpose alive in the background is a leak, and that is now assertable.
The same run corrected the flag's documentation. RELAY_LISTS and FOLLOW_LISTS are
marked background-capable yet absent at @90s — because those loaders had nothing
to fetch, not because they were torn down. So `isBackground` is a ceiling, not a
promise: absence proves nothing, presence of a `false` one proves a bug. The KDoc
now says that instead of implying the stronger claim.
Worth a second look before this ships: WALLET holds 21 filters across 12 relays
while backgrounded — more relays than DIRECT_MESSAGES (5) or NOTIFICATIONS (8).
That may be correct for nutzap watching, or it may be more sockets than the
feature warrants. The tagging is what makes the question askable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Completes the sweep: all 343 filters that reach a relay now carry a SubPurpose,
so `client.activeRequests(relay)` can answer "what is this relay doing for me?"
for every connection, not just the three assemblers wired as proof.
Scope was bigger than the "12 assemblers" first estimated — that count was files
calling newSubId(). The real target is filters wrapped in RelayBasedFilter, since
those are the ones that reach the wire: 202 files. The other ~400 `Filter(` sites
in the app query LocalCache and never leave the device, so they are deliberately
untouched.
Purposes assigned by subsystem: DIRECT_MESSAGES for the DM/giftwrap paths,
NOTIFICATIONS for the inbox filters, FOLLOW_LISTS / PROFILE_METADATA /
RELAY_LISTS for the account loaders, CHATS for public channels, relay groups,
Concord, nests and Marmot, HOME_FEED for the follow feed, WALLET for Cashu and
nutzaps, and SCREEN_CONTENT for everything opened on demand (thread, profile,
hashtag, video, polls, music, podcasts, git repos, workouts, chess).
Verified on device rather than by inspection — 369 relays attributed through
client.activeRequests() after a cold start, with no untagged relay-bound filter
left (`0` by grep). Observed distribution across relays:
FOLLOW_LISTS 921 · HOME_FEED 895 · PROFILE_METADATA 881 · NOTIFICATIONS 690
CHATS 82 · SCREEN_CONTENT 81 · WALLET 38 · DIRECT_MESSAGES 17 · RELAY_LISTS 8
which reads correctly: the outbox fan-out puts follows/feed/metadata/notifications
on almost every relay, while DMs and relay-list discovery stay on the few relays
that actually serve them, and the account's own relay carries everything.
That measurement also corrects an assumption behind the planned notification
copy: relays are NOT partitioned by job. A typical relay serves four purposes at
once, so "4 for notifications, 3 for DMs" would be wrong — per-purpose counts
overlap and sum to more than the relay count. The popup wording needs to reflect
that, which is why it is still not written here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The always-on notification says "connected to N relays" and nothing more. That
count is emergent, not curated: NotificationRelayService owns no subscriptions,
and nothing closes a socket when it stops being useful, so a relay stays
connected exactly as long as some filter still references it. Neither a user nor
a developer can tell whether those N relays are carrying DMs or re-dialling a
stale outbox hint.
The client already tracks every in-flight REQ per relay with its filters
(INostrClient.activeRequests, which the Connected Relays screen reads). A filter
says *what* is matched — kinds, authors, tags — but not *who asked*, and subIds
are random. This adds the missing half.
`Filter` becomes `open`, with `copy()` open too, and commons gets
`ExplainedFilter` carrying a `SubPurpose`. The purpose rides on the filter, so it
arrives with the data the relay screens already read — no parallel registry to
keep in sync or leak on teardown.
**It never reaches a relay.** FilterSerializer is registered against Filter and
writes an explicit protocol field list, and Jackson applies a serializer
registered for a class to its subclasses — so an ExplainedFilter serializes to
byte-identical JSON. That is the point: telling relays what each REQ is *for*
would hand them a ready-made fingerprint of client intent and correlate
subscriptions that are deliberately kept apart.
`copy()` is overridden because filters are copied on the live path — assemblers
call copy(since = …) after every EOSE. Inheriting the base implementation would
downgrade to a plain Filter on the first window refresh, so the purpose would
survive the opening REQ and vanish seconds later.
Both invariants are pinned by tests, and both were mutation-checked rather than
assumed:
- removing the copy() override -> `copy preserves the purpose` fails
- unregistering FilterSerializer -> 3 tests fail, one reporting the literal
leak: `purpose leaked to the wire: {…}`
A test also asserts FiltersChanged does not see the new field, so tagging a
filter cannot trigger a re-REQ storm across ~400 relays.
Wired three assemblers as proof (metadata, reactions, outbox finder) and surfaced
the derived set on BasicRelaySetupInfo.purposes for the Connected Relays screen.
Verified on device: 8 relays attributed RELAY_LISTS through
client.activeRequests() — purpleplag.es, user.kindpag.es, indexer.coracle.social,
directory.yabu.me and friends, which is semantically right — across 2,709 sent
REQs containing zero occurrences of "purpose" or any SubPurpose name.
The remaining assemblers are untagged, which is why `purposes` is documented as
"not yet attributed" rather than "idle". Notification-popup copy is deliberately
not built yet: it should describe the measured background grouping, not the
intended one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four kind-0 events collected from relays exposed two content-parsing
problems. Both `MetadataEvent.contactMetaData()` and `contactMetadataJson()`
are shared code, so Jackson (JVM/Android) and kotlinx (native) were equally
affected — verified against both mappers.
Empty content is a valid, empty profile — someone wiping their metadata —
not a parse failure. It logged "Content Parse Error" and returned null,
which made `LocalCache.consume` drop the event, so the stale profile stayed
in place forever even though a newer replaceable event had arrived. Blank
content now decodes to a blank `UserMetadata` (and an empty `JsonObject`).
A string field wrapped in a one-element array (`"nip05":["a@b.com"]`) has
only one possible reading, so `TolerantStringSerializer` now unwraps it
rather than silently dropping the user's NIP-05 verification. Empty,
multi-element, and non-primitive arrays stay ignored.
The other two events already behaved correctly and are pinned as
regressions: `"nip05":{}` with foreign client keys, and Ditto's ambiguous
`"birthday":"10-24"` — both drop just the offending field.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XGYcqfBKsP9SCvRdPzdkSx
`loadMetadataBatched follows the same retry semantics` failed on the macOS
build-desktop runner (1431 tests in the module, 1 failed). The coordinator is
fine; the test was wall-clock coupled.
It fired call 1 with `timeoutMs = 200`, waited a flat `delay(350)`, then
asserted that call 2 re-subscribed. That leaves a 150ms budget for
`scope.launch` to be scheduled, subscribe, `awaitAll(200)`, unsubscribe and roll
the pubkeys out of `inFlightBatchedMetadata`. On a loaded runner the launch
itself can be queued past the margin, so the roll-back lands late, call 2
short-circuits by design, and the assertion fails on healthy code. Every test in
the file had the same shape.
"Has call 1 finished?" is a question about the job tree, not the clock, and the
test owns the scope — so it can just ask. `awaitCoordinatorIdle()` waits until
no child of the test scope is active; `waitUntil {}` polls the few preconditions
that aren't expressible that way (listener registered before EOSEs are fired).
Both carry a generous deadline and fail with a message rather than hanging. Safe
here because the coordinator launches nothing at construction and
`BatchEoseGate` closes and joins its consumer inside `awaitAll`, so the scope
does reach idle.
Also made the test fake thread-safe. `subscriptions` and `subscribeCalls` were a
plain map and list, written from the coordinator's `Dispatchers.Default`
coroutines (and `Dispatchers.IO` in the concurrent-EOSE test) and read from the
test thread: an unsynchronized `size` read can be stale, and `fireEose`
iterating `subscriptions.values` while a coordinator coroutine calls
`unsubscribe` can throw ConcurrentModificationException. Concurrency is the
thing under test, so the fake shouldn't be the weak link. Now
ConcurrentHashMap + synchronizedList + AtomicInteger, with a snapshot in
`fireEose`.
Verified deterministic rather than just green:
- 3/3 idle, 4/4 under 24 busy loops on 12 cores (the old shape needed the
margin; the new one is load-independent).
- Still catches its regression: reintroducing the original mark-on-send bug
(`eosedRelays > 0` -> `>= 0`) fails exactly this test. A deflaked test that no
longer detects the fault would be worse than the flake.
This is a pre-existing flake (test dates from 5217035f94, 2026-07-07) unrelated
to the rest of this branch, which touches no commons code — fixed here because
it blocks the branch's CI.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three findings from auditing the previous commit.
observeChannel registered a ChannelFinder query per invite row that could
never produce a filter: every assembler under ChannelFinderFilterAssemblyGroup
is gated on `is PublicChatChannel` / `is LiveActivitiesChannel`, so a
RelayGroupChannel contributes nothing and the registration only churned the
app-wide key set (an allKeys() Set copy per bundled invalidation) on every
mount and unmount. Collect the channel's metadata stateFlow directly instead —
same live name updates when the group's kind-39000 lands, none of the churn.
TimeAgo used the default Dotted style inside a row that already spaces its
children, so the variant's own leading " • " doubled the gap. DottedTight is
what the note header uses in exactly this position.
Key each row by channel id. The list is sorted newest-first, so an arriving
invite shifts every row below it; without a key Compose matches children by
call-site position and each shifted row recomposed against a different invite,
re-resolving the actor and reloading their avatar. Also remember the row
modifier rather than rebuilding the chain on every recomposition.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGB1z4VcNfDw5cMt9V5NCG
The INFO default landed a readable log but an incomplete one: only the boot
census came from our own code, so the default read as "warnings + census" with
nothing between process start and the 20s mark. The first four seconds — the
expensive part — were silent.
Adds the missing spine, ten INFO lines a boot:
- `AmethystApp`: version, process and effective log level at start. The
napplet-sandbox skip is promoted too — it is the one line that explains why
`instance` is unset and `LocalCache` empty in that process, which otherwise
reads as a broken app rather than a deliberately secret-free sandbox.
- `LocalPreferences`: how many accounts are saved (nearly every per-account
subsystem multiplies by this), then each account's load with its elapsed ms.
Decrypting one account's settings is among the most expensive things a cold
start does and "which account was slow" is the first question when a boot
drags. The six intermediate steps stay at DEBUG.
- `TorService`: every status transition, with time since bootstrap started. All
status writes now go through one `setStatus` helper instead of nine scattered
assignments, so a transition is logged exactly once; self-transitions are
skipped because the reset paths reassign `Off` defensively. A stuck
`Connecting`, an `Active` that took 40s and a silent drop to `Off` are three
different bugs that used to look identical.
- `BootRelayDiagnostics`: adds a census at 5s. The pool is assembled and
dialling well before 20s, and the early datapoint is what separates "slow to
connect" from "connected fine, slow to serve" — on device it reads pool=21
at 5s against pool=359 at 20s. Paid for by folding the `=====` banners down
to DEBUG, so a census is 3 INFO lines instead of 5.
Reads on device as:
I AmethystApp: Amethyst 1.13.1-DEBUG starting in main process (log level INFO)
I TorService: Off -> Connecting
I LocalPreferences: Found 4 saved account(s)
I LocalPreferences: Loaded account npub1gcx… in 394ms
I LocalPreferences: Loaded account npub142g… in 760ms
I BootRelayDiag: census @5s pool=21 opened=17 served_events=14 …
I TorService: Connecting -> Active after 9910ms
I BootRelayDiag: census @20s pool=359 opened=158 served_events=78 …
Verified by tag on a cold start: +10 INFO lines from our code, no other change
to volume. (Raw totals moved more, but that run happened to autoplay video, so
15 extra INFO lines came from Android's media/codec stack — not from this.)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Audit follow-ups on the previous commit's gating.
1. The `hasLoadedContent` gate over-suppressed. Cached channels in
LocalCache are proof the socket answered *once*, not that it answers
now — so a relay whose circuits died mid-session, leaving a full but
frozen screen, would never get the offer. Replace it (and the
one-shot grace timer) with a debounce on the connection itself: the
countdown restarts on every drop and clears the moment the socket
returns. That covers the cold-start case, the reconnect blip and the
mid-session death with one signal, and no longer depends on what
happens to be cached.
2. Don't offer what can't help. The action adds the relay to the
kind-10089 Trusted list, which only moves it to clearnet while
trusted relays are off Tor. Under the Small-Payloads and
Full-Privacy presets (trustedRelaysViaTor = true) it changed no
routing at all — the old `relay !in trustedRelays` condition merely
hid the banner afterwards, so the dead button looked like it worked.
Withhold the offer under those presets instead of silently flipping
a global privacy setting from a per-relay banner.
Tests cover both gates plus the preset routing they read.
The invite prompt was a floating Material card with a surfaceVariant fill,
sitting above the Notifications feed and Messages > New Requests as its own
visual language — nothing else on either surface looks like that.
Render it through NoteComposeLayout instead, the same layout every note and
notification card uses: the actor becomes the row's author (picture, name and
time in the usual header), "Added to <channel>" plus the host relay become the
row's content, and the three choices take the reactions slot. So the prompt now
reads like the reply and mention notifications it sits next to, and a divider
closes each row the way the feed does.
The channel name is now read through observeChannel, the metadata-only observer
the channel rows use, so a stranger's add resolves to a name instead of a raw
group id. It does not open the channel's message subscription — holding that
back until the viewer answers is the whole point of the prompt.
Also relabel "Show" to "Add to Messages". acceptChannelInvite is defined as
`= addRelayGroupToMessages(channel)`, literally the call behind the channel top
bar's "Add to Messages" item, so one action had two words for it. Reusing the
existing string drops channel_invite_accept and channel_invite_body (the actor
and the question both live in the row now).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGB1z4VcNfDw5cMt9V5NCG
A Buzz/NIP-29 group id is only unique within its host relay, so a message
notification titled just "#general" doesn't say which #general it came from.
The Notifications card already draws a RelayGroupChannelHeader naming the
channel; add the relay next to it, mirroring what a Concord message gets on
the same cards (ConcordCommunityPill naming its parent community) and what the
Messages row already does for these groups.
Extracts the relay chip out of ChatroomHeaderCompose into a shared
RelayNameChip so the Messages row and the Notifications feed render the same
chip, the way ConcordCommunityPill is already shared between them. The chip
taps through to the relay's channel list; the name/avatar still open the room.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DsoLSb42C3CdjMceJW2Pq
Closes#3817.
collectWarmTargets() harvested link URLs from note content but not from
NIP-73 `I`-tag scopes, so the external-content preview cards popped in
mid-scroll while every other link preview was warmed ahead of view.
Gated on `is CommentEvent` so only kind 1111 walks the tags, keeping the
other kinds off that path. Reuses scope() -- the same accessor the
renderer uses -- so a URL is only warmed when it will actually be shown.
Inherits the existing showUrlPreview() data-saver gate already applied to
targets.links.
An entity edited after a CORD-06 Refounding was frozen at its pre-Refounding
version and its newer state silently discarded on every refold. Observed on
device: entity e83ee182 of a real community held at v1 across 21 consecutive
refolds while the current epoch offered v2 citing v1's own hash.
`EditionFold.foldEntity` anchored the walk by requiring the offered set to
*contain* the floor edition. But a Refounding re-wraps ONE edition per entity
(CORD-06 §3, "the last Control Plane state is simply rewrapped"), so an entity
edited afterwards has only its successor on the new epoch while the floor
edition stays behind on the old one. `prev == floor.hash` is a stronger proof
the chain connects than mere presence, and it was being ignored. Worse, the
gap made `admissible` — the pre-filter every derived fold shares — keep only
`version < floor.version`, so the newer edition was dropped every refold: a
role edit, channel rename or banlist entry reverting itself for that user.
Checked against Armada (semantics only, AGPLv3) and the canonical spec before
changing consensus behaviour. Armada selects an arm per entity, and Amethyst
was missing both halves:
1. Its chain-walk arm has three anchor branches to our one; the missing
`versions[0] === floor + 1n -> bytesEq(lo.prevHash, floorHash)` is now
implemented.
2. Once an entity has been re-wrapped into the epoch being folded it does not
chain-walk at all — it anchors on version alone (`bootstrapHead`), because
behind a compaction dangling `prev`s are normal and, since seal signatures
survive re-wrap, any group-key holder can re-serve a genuine old edition
under the current group. A re-wrap cannot raise the version inside the
signed seal, so version is what bounds that. This is the half that fixes
the observed pin; branch 1 alone would still refuse a floor-v1 entity whose
only served edition is v3.
Implemented as an optional `snapshot` (the rumor ids of the epoch being
folded), defaulting to null = pure chain walk for every other caller.
`ConcordCommunityState.fold`/`authorizedHeads` capture it from their own
editions argument before `admissible` re-seats older-epoch heads — Amethyst
folds exactly one epoch per call, so the argument is the snapshot. `admissible`
and `candidates` now ask `foldEntity` whether it gapped rather than
re-deriving the test, so the three sites cannot drift apart again.
`LOG_GAP` also dedups on (entity, floor, offered): the same refusal was
re-reported on every refold, 22 byte-identical warnings a boot, which read as
22 attacks rather than one unchanged state. Deduping is what made the two
genuinely distinct refusals visible and led here. Fails open above 4096
distinct refusals — a flood is when the warnings matter most.
Anti-rollback is unchanged: all 12 pre-existing floor tests still pass, and
three new negative tests cover the fork, the below-floor re-serve, and an
entity absent from the snapshot. Device gaps for the pinned entity: 21 -> 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A cold start emitted 6,198 app lines in 80s (~77/sec) on a real device.
Six tags produced 83% of it, and the lines that are actually actionable —
relay protocol refusals like `auth-required`, `rate-limited`,
`unsupported: too many filters` — were buried among them.
`Log.minLevel` was `DEBUG` in debug and `ERROR` in release: two settings,
both wrong. Release dropped all 460 `Log.w` call sites, so the field never
saw a single relay refusal. Debug kept everything.
Now debug defaults to INFO and release to WARN, with `Amethyst.VERBOSE_LOGS`
to restore the firehose. 333 files already route through `quartz.utils.Log`
(only 13 use raw `android.util.Log`), so `minLevel` is a real global gate —
which is why three of the four loudest buckets needed no code change at all:
relay socket lifecycle (1,659 lines), `RelaySpeedLogger` (1,308) and Arti's
per-stream SOCKS errors (753) were already `Log.d`.
The rest:
- `RelayLogger.onCannotConnect` E -> D. Under the outbox model a boot dials
~400 relays and most fail identically; one line per dead socket is not
actionable, and its multi-line TLS certificate dumps were 3-4 logcat lines
each. This one mattered most: at `Log.e` it survived every level.
- `BootRelayDiagnostics` rollup -> INFO, per-relay WASTE/SERVE tables -> DEBUG.
The census line carries the aggregate the 655 per-socket errors spelled out.
- `Duplicated/SPAM` -> DEBUG: a detection is the filter working, and it is
already reported via `relayStats.newSpam()` and `flowSpam` to the UI.
- `MarmotDbg` "not a member of group" -> DEBUG: relays serve kind:445 for
every group they carry, so this is the steady state. It was burying the
real warning next to it ("Generation N already consumed").
Measured on emulator (4 accounts, Tor on): 6,198 -> 433 lines. Flipping
VERBOSE_LOGS gives 5,574 back, so nothing was deleted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The "Can't reach this relay over Tor" card on a community's screen was
driven by a bare 6s timer: `connectTimedOut` flipped true after the delay
and never flipped back, and the condition never consulted the relay at
all. On a Buzz relay the banner is an unconditional item in the sectioned
list (the empty-list guard only covers the vanilla NIP-29 branch), so it
appeared six seconds after opening any community and stayed up while the
relay was connected and delivering messages.
The Tor half was wrong too: `torType != OFF` says Tor is enabled somewhere,
not that this relay is routed through it — onion, localhost and the
per-role presets (trusted / DM / new) each decide independently.
Gate the offer on live signals instead, extracted into a pure predicate:
- routed over Tor — TorRelayEvaluation.useTor, the same predicate the
relay pool dials with
- Tor is up — TorManager.status is Active, so a bootstrapping Tor
isn't misreported as the relay blocking exits
- not connected — client.connectedRelaysFlow(); a relay that answers is
reachable by definition
- nothing loaded — no channels/DMs on screen, so a reconnect blip can't
flash the banner over a live list
- grace elapsed — unchanged, so a slow first connect isn't nagged
Adds unit tests for each gate, including the reported regression
(connected and delivering -> banner stays hidden).
Flipping either local-cache setting called probe.invalidate(), which only
clears the TTL timestamp — it never re-runs the probe. Nothing else does
either: isAvailable() is reached only from the one-shot startup warm and
from inside findServers().
That leaves the feature dormant when enabled mid-session. The feed image
path gates on `available.value` (AccountViewModel.useLocalBlossomBridge
and shouldBridgeBlossomCache), so while the probe reads false no request
ever routes through the resolver, and the resolver is the only thing that
would have refreshed the probe. The settings "detected" chip reads the
same flow, so it also stays stale.
Re-probe right after invalidating so enabling the toggle activates the
feature — and reports its true state — in the same session.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PCgrM83QetNPJTg3Z5VWnF
BlossomServerResolver.findServers and LocalBlossomCacheProbe.probe are
suspend functions that don't confine to Dispatchers.IO. They're reached
from Compose produceState/LaunchedEffect (RichTextViewer,
MarmotGroupIconDisplay, ZoomableContentView), which run on the main
dispatcher, so the pre-suspension work — BlossomUri regex parsing,
LruCache lookups, the probe's OkHttpClient build on TTL expiry, and the
server-list flow setup — executed on the UI thread on every uncached
resolution during feed scroll.
Wrap findServersInner and the probe's client-build + HEAD in
withContext(Dispatchers.IO) so that work stays off the main thread.
Behaviour is unchanged; callers already on IO (Coil BlossomFetcher,
PlaybackService) are unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PCgrM83QetNPJTg3Z5VWnF
Review feedback from PR #3810:
- Gate both new preview call sites behind settings.showUrlPreview(), so a
user on WIFI_ONLY over cellular or NEVER no longer triggers an outbound
OpenGraph request and image download per external-scoped comment. Falls
back to the plain link chip / URL header.
- Wrap the open-in-browser icon in an IconButton so it gets a real touch
target instead of a 14dp one nested inside the card's own clickable,
where a near-miss silently navigated instead.
- Only show that icon when onCardClick is set; without it the icon and the
card tap did the same thing, so existing note-body link cards stay as-is.
- staticCompositionLocalOf for LocalCurrentExternalScope (constant per
screen, avoids read tracking per feed row).
- Inset the loading/error URL header to match the card, and use the
hoisted Size14Modifier.