The relay-group section header and joinRelayGroup KDoc were left
dangling at the end of AccountConcordActions when the clusters were
split into separate files; reattach them to the function they describe.
Found by the post-refactor audit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA
The account-qualification regex in the AccountMarmotActions extraction
also rewrote 'marmotManager is NULL' to 'account.marmotManager is NULL'
inside two log string literals, changing log output text. Restore the
original wording. Found by the post-refactor equivalence audit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA
Moves the ~270-line zap/payment orchestration (NIP-57 zap requests,
NWC wallet requests with spoof tracking, NIP-B1 BOLT12 zaps, NIP-BC
onchain zaps/sends/splits) into AccountZapActions, exposed as
account.zaps. The onchain backend-not-configured constant moves with
it. External callers (ZapPaymentHandler, V4VPaymentHandler, wallet
viewmodels, blossom payments, app functions) now call account.zaps.*
directly. Moved code is unchanged except for account. qualification.
Completes the Account decoupling series: Account.kt went from 6228 to
3618 lines across EventBroadcaster, AccountConcordActions,
AccountMarmotActions, AccountRelayGroupActions, and AccountZapActions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA
Moves the ~460-line NIP-29 relay-group + Buzz workspace orchestration
(join/leave/create/delete/archive, threads, invites, pins, member/role
management, metadata edits, Buzz DMs/jobs/workflows/typing,
community member add/remove) into AccountRelayGroupActions, exposed as
account.relayGroups. External callers now use account.relayGroups.*
directly. Moved code is unchanged except for account. qualification.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA
Moves the ~540-line Marmot/MLS orchestration cluster (group create/
leave/reset, member add/remove via key-package fetch, admin grant/
revoke, metadata updates, group messaging, key-package publishing and
relay resolution) into AccountMarmotActions, exposed as account.marmot.
External callers (marmot group screens, AccountViewModel forwarders,
NotificationReplyReceiver, DecryptAndIndexProcessor) now call
account.marmot.* directly. Moved code is unchanged except for
account. qualification.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA
Moves the ~1,000-line Concord orchestration cluster (join/create/invite
flows, channel messages/reactions/edits/typing, roles and moderation,
refound/rekey/stranded-recovery, metadata + channel management,
control-plane sync) into AccountConcordActions, exposed as
account.concord. The two Concord file-level constants move with it.
Rumor ingestion (consumeConcordRumorGated, refreshConcordChannelIndex)
stays on Account since ConcordSessionManager is constructed with it,
as do the cross-feature sendMinichatReply and the read-path
isConcordBanned policy. External callers (Concord screens,
AccountViewModel forwarders, note action menus) now call
account.concord.* directly - no delegating shims.
Moved code is unchanged except for account. qualification.
Account.kt: 6228 -> 4935 lines so far in this series.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA
Moves the sign-and-publish choke point out of Account into an
EventBroadcaster class: relay-set computation (outbox model, hints,
channel home relays, broadcast lists, DM inboxes, the recursive
linked-event descent) plus every publish path (sendAutomatic,
sendMyPublicAndPrivateOutbox, sendLiterallyEverywhere, broadcast,
signAndSendPrivately*, signAndComputeBroadcast,
signAnonymouslyAndBroadcast, republishEventsTo).
Account keeps one-line delegates so its 85+ internal call sites and all
external callers are unchanged; upcoming Account*Actions extractions
will call the broadcaster directly. Moved code is unchanged except for
account. qualification.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA
Two read/reclaim policy clusters leave the LocalCache god object into
sibling classes in the same package, each taking the cache as its only
constructor dependency so the policies are testable in isolation:
- CachePruner: cleanMemory/cleanObservers, the six prune passes
(hidden/old/expired/superseded/replies+reactions), and the shared
unlinkAndRemove removal primitive (with removeIfWrap and
editedTargetIdOf). LocalCache.deleteNote and
DecryptAndIndexProcessor now call pruner.unlinkAndRemove;
MemoryTrimmingService drives cache.pruner.*.
refreshDeletedNoteObservers becomes internal so the pruner can
notify observers.
- CacheSearch: findUsersStartingWith(username, account),
findNotesStartingWith, and the three channel prefix searches, plus
their private exclusion rules. Callers (SearchBarViewModel,
AgentAttestationScreen, UserSuggestionState, BuzzNewDmViewModel) use
cache.search.* directly - no delegating shims left behind.
Also moves the Dao interface out of ui/actions/NewMessageTagger.kt into
the model package where its implementor (LocalCache) and its types
live, removing a model-layer interface defined in a UI file.
All moved code is unchanged except for cache. qualification; behavior
is identical. LocalCache.kt: 4554 -> 3921 lines.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA
justConsumeInnerInner was one when(event) with ~290 branches, of which
172 were identical single-call bodies routing to consumeBaseReplaceable
or consumeRegularEvent, and ~55 more were single-line Buzz consumer
calls. Since all four shared consumers take a plain Event, the
boilerplate branches are now comma-grouped into one branch per
consumer (replaceable/addressable, regular, Buzz timeline, Buzz
store-only), keeping every branch with per-kind logic exactly as it
was.
Dispatch is provably unchanged: none of the 289 event classes has a
supertype among the classes in any other branch group, so reordering
cannot shadow a branch, and the old and new type-to-consumer mappings
were compared exhaustively and are identical. The else branch still
rejects unlisted kinds, preserving the supported-kinds allowlist.
LocalCache.kt: 5155 -> 4554 lines.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA
EOSERelayList backs SingleSubEoseManager with a plain mutableMapOf, and
addOrUpdate is called from SubscriptionListener callbacks — i.e. from each
relay's own socket-reader thread. A client holding a few hundred relays therefore
had that many concurrent writers to one unsynchronized map. EOSEAccountFast wraps
its lists in a lock for exactly this reason; a bare list handed to
SingleSubEoseManager had nothing.
The race predates this branch but the branch made it load-bearing: both merged
managers (notifications and account metadata, across every logged-in account and
every relay they read) now run through SingleSubEoseManager, and the EOSE refetch
fix added a second writer in remove(). Writes are serialized with KmpLock, the
same primitive ComposeSubscriptionManager uses.
Reads still go through the live map from since(), deliberately — but the two
merged managers no longer depend on that. They were reading `since` immediately
after clearing a relay and relying on the mutation being visible through it, so
hardening since() into a snapshot later would have silently disabled the refetch
with no test to notice. Growth now zeroes the cursor for that pass explicitly, in
addition to clearing it.
Verified: both iOS targets, JVM, Android, desktop and the full suite build and
pass; a cold start on emulator-5554 shows no fatal exceptions, no
ConcurrentModificationException, and 0 nos.lol refusals.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The service layers were being re-enabled level-triggered. Before this branch the
inner flow was a Boolean behind distinctUntilChanged, so enableServiceLayers()
ran once per real transition. It now carries (all accounts, participating
accounts), and Account has no equals — so the flow re-emits whenever the account
map changes identity, and the outer collector restarts on every
foreground/background crossing, which includes every screen-off.
That matters because ServiceWatchdogManager.schedule() calls setInexactRepeating
with FLAG_UPDATE_CURRENT and a first fire of `elapsedRealtime() + 5min`. Every
call replaces the alarm and pushes that first fire out again. Screen-on happens
far more often than every five minutes, so L5 — the layer whose entire job is
noticing a dead service and restarting it — would effectively never have fired.
NotificationCatchUpWorker was unaffected: it enqueues with
ExistingPeriodicWorkPolicy.KEEP, so repeat calls leave the existing period alone.
Enable/disable is edge-triggered again, explicitly this time rather than as a
side effect of what the upstream flow happens to emit.
Also drops ActiveSubscriptionsState.busiestPurposeFilters, which has no readers —
the cards draw their share against attributedFilters.
Verified on emulator-5554: after a cold start, HOME, and return, the foreground
service is still running and the watchdog alarm is still registered.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The wallet was the only account-level subscription whose lifetime was decided by
the model. CashuWalletState collected relay sets on the Account's own scope and
called subscribe/unsubscribe itself, so it ran for every Account object that
happened to be resident — including accounts loaded purely so pushed gift wraps
could be decrypted by their owner, which have no wallet anyone is looking at.
Gating that with a subscribedAccounts flow made it worse: a model object read the
relay layer's bookkeeping ("is this pubkey REQ-ing anywhere?") to decide whether
to talk to relays, and encoded a proxy for the rule rather than the rule. It
happened to work only because the registry mounts every account in the
foreground.
CashuWalletEoseManager is a PerUserEoseManager in the account group, exactly like
NwcNotificationsEoseManager already was. Start and stop now come from the same
mounts as notifications, DMs and gift wraps — the screen's for the account on
show, the registry's for the rest — and will follow whatever the
foreground/background rule becomes without knowing about it. Per user, never
merged: each wallet reads its own outbox for its own events and its own inbox for
nutzaps addressed to it.
commons keeps the query shape as a plain cashuWalletFilters() function and loses
the CashuWalletFilterAssembler wrapper. subscribedAccounts disappears entirely —
from Account, AccountCacheState, AppModules, both AccountViewModel previews, both
androidTests, and AccountFilterAssembler itself.
Verified on the wire, not just on the screen: with REQ logging temporarily on,
134 REQ frames carried kind 17375 across 9 relays naming all 4 logged-in accounts,
and 90 carried kind 9321. The subscriptions screen agrees — Wallet and Nutzap
Inbox under each of the four.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A merged filter keeps one EOSE cursor per relay. That is right while the set of
accounts it covers is stable and silently wrong the moment it grows: the account
that joins inherits a cursor it never earned, so `since` skips everything older
and its history is never requested. The subscription looks healthy, reports EOSE
and delivers new events forever after — it just never backfills.
This is the normal startup path, not an edge case. The account on screen mounts
from Compose and EOSEs within a second or two; the registry brings the rest in a
moment later, onto relays that have already reported EOSE. Account switching and
mid-session login hit it the same way.
It bites metadata harder than notifications. Notifications have a backward pager
that is deliberately still per-account and can recover the gap. Metadata has
none, so an account that joined mid-session would go without its own profile,
follows and lists until the next launch cleared the in-memory cursor.
MergedAuthorTracker records which accounts each relay's filter last covered and
reports growth; both merged managers drop that relay's cursor when it grows, so
the next filter asks from scratch. Growth only: an account leaving takes nothing
with it, and the accounts that remain already have their events.
Verified: MergedAuthorTrackerTest covers first-sight, join, no-op, leave, swap,
per-relay independence and clear — mutation-checked by making the predicate
return false, which fails three of the seven. On emulator-5554 all four accounts
keep their Notifications, DM Inbox and Account's data, and nos.lol refusals over
three clean cold starts were 0/0/7 against 8 before, so the extra refetch costs
nothing measurable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three findings from reading the branch end to end:
- AccountNotificationsEoseFromInboxRelaysManager carried a `pubkeyOf` helper with
no callers and a comment inventing a reason for it. Removed.
- `limit = 1 * pubkeys.size` said the multiplication out loud for no reason.
- The two merged managers scale their limits differently and nothing said why.
Metadata multiplies by account count, notifications does not, and that is
deliberate: metadata is replaceable so its limit is a safety bound, while
notifications are a stream whose limit is a page size — scaling would ask 4x
the data on every cold start and relays clamp it anyway. Documented, along with
the consequence (a busy account can crowd a quiet one out of the shared
newest-N) and what recovers it (the history pager, left unmerged).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fills the on-disk gaps in the four target locales: channel archive/unarchive,
the new-forum title, the External Content screen title, the archived channel
section header, the three share targets (picture / short / video) and the URL
preview's open-in-browser action.
Wording follows each locale's existing siblings rather than being translated
fresh -- url_preview_open_in_browser reuses the string already in
git_repo_open_in_browser, share_target_as_picture follows new_picture, and
share_target_as_short_video follows home_content_type_shorts.
relay_group_section_archived heads a channel list, so cs/sv/pt take the plural
adjective.
Keys whose translation would be byte-identical to the English source are left
out on purpose: Crowdin strips source-identical entries on export and Android
already falls back to values/strings.xml at runtime. That covers the "workflow"
loanword in cs/de, and Highlights / Podcasts / Reposts / Torrents / Videos /
Name / Backlog in de, plus Podcasts / Torrents / Backlog in pt-BR.
The commons composeResources tree was already complete for all four locales.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WopdqNoZ9tYoMNppJG17BL
The row sits under an account header that already names whose it is, and with
several accounts on screen "Your Account's data" repeated under someone else's
name reads as a contradiction.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Your Account's data" fell into "Not attributed" one commit ago. None of the
metadata builders ever named their own account — they relied on
PerUserEoseManager stamping `attributedTo(pk)` over whatever they returned. Moving
the manager to SingleSubEoseManager took that away, because that base class
cannot attribute: one of its filters may serve several accounts, so there is no
single pubkey to stamp.
So the builders name themselves now, which is where the knowledge actually is.
Every filter in this package fetches an account's OWN profile, lists, bookmarks
and recent posts, so `authors` and the accounts asking are the same list.
The one exception is filterBasicAccountInfoFromKeys, which fetches OTHER people's
profiles for the account-switcher avatars: there the authors are precisely not
the requester, so it takes `requestedBy` and is attributed to that instead. Its
parameter carries the reason, since it reads like an inconsistency otherwise.
Notifications were unaffected — those builders already set accountPubKeys
themselves, which is why only this purpose lost its account.
Verified on emulator-5554: "Your Account's data" appears under all four accounts
again, and the unattributed count is back to 8 filters.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Account metadata was the largest single contributor to blowing a relay's
subscription cap: seven filters in a subscription, repeated once per logged-in
account. Every one of them is `authors`-keyed, so a relay that several accounts
read from can be asked about all of them by widening `authors` — the same shape
of merge the notification tail just took, and for the same reason.
The per-account `limit`s are summed rather than shared. These are mostly
replaceable events, so the limit is a safety bound rather than a page size, and
scaling it by the number of accounts leaves each one exactly the headroom it had
alone. That is the difference from gift wraps, which stay per-account because
their `limit` IS a page size over unsolicited content.
Measured on emulator-5554, four accounts, cold start, counting nos.lol's
`ERROR: too many concurrent REQs`: 13 before either merge, 11 after
notifications, 8 after this. The subscription count on that relay went from
24-26 to 23, against its cap of 20.
So the per-account multiplication is no longer the driver, and the remaining 23
are not account-level at all — they are the feed, channel and finder assemblers.
Cutting further means looking there, not at more merging.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every account-level loader is a PerUserEoseManager, which opens one subscription
per user. With four accounts open that multiplies, and shared relays run out of
room: nos.lol (strfry, `max_subscriptions: 20`) answered `ERROR: too many
concurrent REQs` — 0 times before this branch, 7 with three accounts subscribed,
13 with four. The refusal arrives as a NOTICE, which carries no subscription id
and never reaches RelayReqRefusals (wired only to CLOSED), and "too many
concurrent REQs" matches none of its markers. So the relay drops those REQs while
we still believe they are live: they never EOSE and never deliver.
Notifications are `#p`-scoped, so a relay serving several accounts can be asked
about all of them in one filter naming every pubkey — same query, wider tag. The
manager moves to SingleSubEoseManager: one REQ per relay instead of one per
(account, relay), with the `since` floor taken per relay as the OLDEST of the
participating accounts' floors so widening for one can never cut another short.
Gift wraps deliberately do NOT merge. They are unsolicited and opaque to the
relay, so it cannot rate-limit them per recipient; a merged query would let one
spammed account eat the shared `limit` and starve every other account's DMs.
Each account keeps its own budget there.
A merged filter serves several accounts, so accountPubKey becomes accountPubKeys.
The subscriptions screen shows such a filter under each account it serves —
"why is this relay busy for me" has to be answerable per account — which makes
the per-account counts a breakdown of a shared filter rather than a partition of
the total. attributedFilters is that sum, and is what a card's share is drawn
against now; dividing by the wire total would read as 100% for each of two
accounts sharing one filter.
Measured, and it is only part of the answer: nos.lol carries 24-26 subscriptions
against its cap of 20, and this removes 3 of them. The rest are the other
per-account managers — account metadata alone is 7 filters in a subscription per
account, and gift wraps another. Merging metadata (it is `authors`-keyed and
merges the same way) is the next lever; this commit does not get us under the cap
on its own.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Keep this account active in the background" gated subscriptions everywhere,
not just in the background. An account you had not opted in for showed no
notifications and no DMs even with the app open in front of you — you had to
switch to it and wait for its subscriptions to mount from scratch. The setting's
name only ever promised something about being away.
So the rule now matches the name. While any activity is STARTED, every loaded
account pulls its own notifications, DMs and gift wraps: the user can switch
accounts at any moment and expects the one they land on to be current, and this
costs nothing once the app is away because it ends with the screen. When the app
goes away, the set narrows to the accounts that opted in — which is the only
thing the flag decides now.
The service layers stay where they were, gated on the master switch AND somebody
having opted in. A foreground-only account must never start a foreground service
that outlives the screen that wanted it, so those two questions are answered from
one snapshot of accounts + flags rather than two.
The registry loses "Background" from its name along with the assumption: it
mounts exactly the set it is handed and decides nothing, so the rule lives in one
place.
Verified on emulator-5554 with 4 loaded accounts, 1 opted in. Foreground: 4
mounted, and the account that had no section on the subscriptions screen at all
now shows Notifications 8 filters / 2 relays plus DM Inbox. On HOME: 3 mounted
(-1), releasing the one that never opted in.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Active Subscriptions screen reported two different things as "filters".
The header counted filters; a purpose card summed its per-entity rows. Those
only agree when every filter names exactly one entity — and the busiest ones
name many, because batching is the whole point of them: one `#e` filter per
relay carrying every followed chat, one `#d` filter per host relay carrying
every joined group.
So six chats on six relays read as 144 filters where 24 were on the wire, and
"8% of all" divided the inflated number by the real one. The screen exists to
answer "why do I have this many subscriptions", and it was overstating its
loudest purposes by exactly their batching factor — the opposite of the job.
A purpose now tallies each filter once as it is scanned, before the fan-out.
The per-entity rows stay, because "which chats is this relay serving" is worth
seeing, but they are a breakdown rather than a total: the field is renamed to
namedInFilters and says in its docs that it is not summable.
The aggregation moves out of the ViewModel into a pure aggregateSubscriptions()
so the invariant is testable without a relay pool. AggregateSubscriptionsTest
pins it on the reported shape, and was mutation-checked: restoring the old
`entityRows.sumOf { … }` fails three of its four cases, the fourth being the
relay count, which never depended on it.
On device, Public Chat goes from 144 filters over 6 relays to 18 over the same
6, and Relay Groups from 116 to 96. No filter changed; only the arithmetic.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- refactor(location): dedupe test fixtures and simplify the gate internals
- test(location): assert the release, not just its ledger side-effect
- test(location): cover the LocationState -> LocationFlow -> RefCountedSession seam
Commit 4f1bd6e0c2 left the six public-chat discovery producers untagged and
justified it as "they search for chats rather than serving known ones, so there
is no entity to name". The first half is right and the conclusion does not
follow: having no entity is not the same as having no explanation. Every one of
those filters is built from a top-nav selection — Global, your follows, a
hashtag, a geohash, a community — which was known where the filter was built and
simply had nowhere to travel. The screen could only render them as "All", which
is the one thing they are not.
3f4723437e already moved the 25 top-nav value types into commons for exactly
this, so ExplainedFilter now carries the scope. It carries the per-relay value
rather than the whole set: the filter is already scoped to one relay, so it
holds only the slice that applies to it and no reference to the other relays'
authors. It stays a typed value rather than a formatted string because
purposeDetail already taught that lesson — text built in commons can never be
translated, so the UI matches on the type and picks its own wording.
scopedTo() stamps it at each feed's make…Filter dispatch, the last place that
still knows the selection; below it the builders have flattened it into
authors/#t/#g and it is unrecoverable. IFeedTopNavPerRelayFilterSet grew
scopeFor(relay) so that stamping is compiler-enforced across all 11 sets rather
than a type-switch that silently misses the next one added.
The screen groups these rows by scope *type*, not contents: an author-based
selection sends a different slice of the follow list to every relay, so keying
on contents would shatter "People you follow" into one row per relay — the
opposite of what the screen is for.
ExplainedFilterTest had not compiled since 4d53bbea9e renamed entityId to
entityIds, because `./gradlew test` does not run :commons:jvmTest. Repaired, and
extended to pin the new field: the scope is a slice of the user's follow list or
their chosen hashtag, and handing a relay the selection rather than the authors
it already sees would tell it which of its neighbours' filters belong together.
Verified on emulator-5554: Home Feed's row now reads "People you follow" with
its 176 relays, as one row rather than 176. The public-chat discovery producers
take the identical path but only mount while the Discover→Chats screen is open,
which this device's bottom nav has no tab for, so that specific row is unproven
on device.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An account that opted into "keep this account active in the background" got
nothing from that setting but a running service. Subscriptions were only ever
created from a composable holding an AccountViewModel, so the account on screen
was the only one talking to relays; the rest were loaded purely so pushed gift
wraps could be decrypted by their owner. On a device with no push — no Play
Services, no UnifiedPush distributor, no Pokey — those accounts pulled nothing
at all, and the setting quietly meant less than its name.
BackgroundAccountSubscriptionRegistry mounts the always-on loaders for each
participating account directly, with no view model behind them.
AlwaysOnNotificationServiceManager already watched the two flags that define
participation (the account's own toggle, or its NIP-46 signer), so it now keeps
the set instead of collapsing it to "is anyone participating" and hands it to
the registry on every change.
Running headless meant the key had to stop assuming a screen. AccountQueryState
drops to what an Account alone can supply, and AccountUiQueryState adds the feed
states for screens — the only always-on reader is the notifications cold-start
floor, which reads a feed nothing fills without UI and so could only ever be
null for these accounts. An account can now be mounted twice, on screen and in
the background; the managers dedup by user but keep whichever key arrived first,
so preferredKeys picks the screen's key and the account being looked at keeps
its floor rather than losing it to a race.
The Cashu wallet had the mirror-image bug: it subscribed from every Account's
own scope, ungated, which put a Wallet and a Nutzap Inbox on the wire for every
saved account — including accounts that never opted in and have no wallet. It
now follows AccountFilterAssembler.subscribedAccounts, the same truth both mount
paths write to.
Verified on emulator-5554 with 4 loaded accounts, 3 participating: each of the
3 gets its own Notifications and DM Inbox (12 and 3 filters for a background-only
account), filters scale per account while relays overlap, and the 4th account —
loaded but not participating — now holds no subscriptions at all where it
previously held Wallet and Nutzap Inbox.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The comment called it a backward-paging boundary that asks for everything
older than the feed's oldest card. It is neither: backward paging lives in
AccountNotificationsHistoryEoseManager, and `since` means newer-than, so
it floors the query at the depth the feed already reaches instead of
asking all-time again. It is also read only until a relay has an EOSE.
Records that it is always null for an account with no UI, since nothing
fills that feed — which is what makes the field inert on a headless path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The topNavFeeds package holds two layers. The value layer — the per-relay
filter sets, plain `Map<relay, filter>` of authors, hashtags, geohashes —
is pure data. The resolution layer around it (TopNavFilter, FeedFlow, the
loaders and decryption caches) evaluates feeds against the live event
graph and needs LocalCache, NoteState and the outbox loaders, so it is
app-coupled by nature and stays put.
Moving the 25 value types lets commons describe a feed selection without
depending on the app, which is what an ExplainedFilter needs if it is to
carry the top-nav scope instead of a flattened List<HexKey>. Desktop gets
them too.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Public Chat collapsed into a single unnamed row because none of its
producers carried an entity id, so the screen could say how many filters
were running but not which chats caused them.
The six channel-scoped producers now tag their channel ids — including
the batched ones, which carry every channel a relay serves so the screen
fans them into a row each.
The six discovery producers (global, by author, by community, by hashtag,
by geohash) stay untagged on purpose: they search for chats rather than
serving known ones, so there is no entity to name.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tapping the always-on notification landed on whatever tab was last open,
which does not answer the question that notification raises. It now
deep-links to Active Relay Subscriptions via an `activesubs` route, and
the screen gets its own entry in All Settings.
The expanded breakdown also held its last value across reconnects. It is
derived from the *connected* relays, so a drop to zero — the "connecting"
state — emptied it and collapsed the expanded view to a single line
exactly when someone was most likely reading it. What each connection is
for does not change while it re-establishes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entity rows resolved to a short hex whenever LocalCache could not place
the id, which covered Marmot groups, Concord communities and geohash
cells — the three that most needed naming.
Each now resolves through the path its own subsystem uses: Marmot reads
the per-account chatroom StateFlows and its encrypted Blossom avatar,
Concord reads the folded Control Plane keyed on the session revision, and
geohash cells go through the app's reverse-geocode cache so a cell reads
as a place. All three route to their screen on tap.
Relay groups pivot on the host relay, expandable to the groups it carries:
a group is keyed by (id, host relay) and one community relay routinely
hosts many, so the flat list repeated one hostname seven times.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
- Correct the KDoc on the Shorts and Longs composers. They claimed
everything posted from them lands in that feed; VideoPostKind only
governs videos, and the gallery picker takes images with no mime
filter, so a picked JPEG still posts as a kind-20 picture that
neither feed reads. Say so instead of asserting a false invariant.
- Forward the shared text as the composer's caption. The media targets
dropped EXTRA_TEXT entirely, so sharing a photo with a caption lost
it — while the DM target kept it. The routes now carry the message
and NewMediaModel.load() seeds the caption field from it.
- Accept SEND_MULTIPLE on the three media targets. Sharing several
files at once previously did not offer Amethyst at all, even though
the picture composer publishes N images as one kind-20 event. Routes
carry a URI list; other targets take the first.
- Replace, rather than stack, a feed entry when a second share arrives
while the first is still open. Only entries that carry attachments
are replaced, so a feed reached from the bottom bar keeps its
tab-root marker underneath.
- Rename NewImageButton to NewVideoFeedButton: it is the Video feed's
composer and handles pictures and video alike.
Adds ShareTargetManifestTest, which pins the activity-alias names in
AndroidManifest.xml to the constants ShareIntentRouting matches them
by — in both directions, plus the SEND_MULTIPLE filters. That link is
invisible to the compiler and fails silently at runtime by routing a
share to the wrong composer. Verified the guard bites by renaming an
alias in the manifest alone: three of its four tests go red.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R4WsYWaNMPD34SBc6Ej6hx
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 three share targets next to "New Post", "Send as DM" and
"New Highlight":
- New Picture (image/*) -> the picture feed's composer, publishing a
NIP-68 kind 20 picture post
- New Short (video/*) -> the Shorts feed's composer, publishing a
NIP-71 kind 22 short
- New Video (video/*) -> the Video feed's composer, publishing a
NIP-71 video event
Until now every SEND intent landed in the kind-1 composer, so a shared
picture became a text note with a link instead of a post in the feed
the user was aiming for.
Each alias resolves to MainActivity like the existing ones, so
ShareIntentRouting now maps the launching component class to a
ShareTarget enum instead of a growing chain of isShareAsX() booleans.
The media targets navigate to the destination feed carrying the shared
content URI, and the feed's existing composer button opens on it.
Video kind is no longer guessed from orientation alone in feeds that
only read one of the two kinds: the Shorts composer always publishes
kind 22 and the Longs composer always publishes kind 21, so a post
lands in the feed it was composed from whatever the footage's shape.
The mixed Video feed and the picture feed keep the automatic choice.
Also fixes the New Post launch path passing the literal string "null"
as the attachment when a share carried no media.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R4WsYWaNMPD34SBc6Ej6hx
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>