mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-30 07:37:38 +00:00
The 33 descriptions (~16k chars, ~19k with paths) overran Codex's skill-list budget (2% of the context window, or 8,000 chars when unknown), and a review found most bodies stale: paths from before the commons/commonsUI moves, the deleted legacy desktop app, a callback signer API that no longer exists, and example APIs that were never in the tree. Kept, rewritten and checked against the code (every path, class and command): account-state (+feeds), amy-expert, android-expert, auth-signers, compose-expert (+API style, Navigation 3 back stacks), desktop-expert (+run/package), event-store-semantics (+NIP-50 search), find-missing- translations, kotlin-coroutines (+structured concurrency, flow modeling), kotlin-multiplatform (+Gradle, composable extraction), ngit-pr, nip85-trusted-assertions, nostr-expert (+NIP lookup, generated NIP catalog), relay-client. Dropped: textbook Compose/Kotlin technique skills, kotlin-expert, kotlin-types-value-class, gradle-expert, quartz-integration (external-facing; see the Quartz docs commit), find-non-lambda-logs (audit finished; the rule is now in AGENTS.md Kotlin Style), and the /nip, /desktop-run, /extract commands. The skill list is now ~4k chars. Also drops .claude/core-skills-plan.md (stale history) and repoints SearchableEvent's KDoc to event-store-semantics. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
126 lines
6.8 KiB
Markdown
126 lines
6.8 KiB
Markdown
---
|
||
name: relay-client
|
||
description: Compose-scoped relay subscriptions in commons/relayClient: observeUser*/EventFinder, FilterAssemblers, EOSE managers, ComposeSubscriptionManager. Use when a screen must load events from relays while visible.
|
||
---
|
||
|
||
# Relay client: screen-scoped subscriptions
|
||
|
||
How a composable gets events from relays only while it is on screen. Headless code (amy,
|
||
geode, tests, one-shot fetches) does not use this layer: it uses the `INostrClient`
|
||
extensions — see AGENTS.md › Feature Workflow ("Relay client ops already exist").
|
||
|
||
## Where it lives
|
||
|
||
- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/` — the
|
||
headless half: one package per feature (`chess/`, `communities/`, `home/`, `thread/`,
|
||
`user/`, `event/`, …) holding query states, filter assemblers and sub-assemblers;
|
||
`composeSubscriptionManagers/`, `eoseManagers/`, `subscriptions/` (`ExplainedFilter`,
|
||
`SubPurpose`), `assemblers/` (shared ones: `MetadataFilterAssembler`,
|
||
`ReactionsFilterAssembler`, `ContactCardFilters.kt`), `oneshot/`, `paging/`, `preload/`.
|
||
- `reqCommand/RelaySubscriptionsCoordinator.kt` — owns one instance of every assembler
|
||
(`account`, `home`, `userFinder`, `eventFinder`, `chess`, `community`, …). Screens reach it
|
||
as `accountViewModel.dataSources()` (`BaseAccountViewModel.dataSources`).
|
||
- `commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/` — the
|
||
`@Composable` entry points, same package names: `observeUser*`,
|
||
`EventFinderFilterAssemblerSubscription`, `KeyDataSourceSubscription`,
|
||
`LifecycleAwareKeyDataSourceSubscription`, and per-feature `*FilterAssemblerSubscription`s.
|
||
|
||
## First choice: `observeUser*` and `EventFinder`
|
||
|
||
Before writing any assembler, check whether these already load what you need:
|
||
|
||
- **Users** (`commonsUI/.../relayClient/user/UserMetadataObservers.kt`):
|
||
`observeUserInfo`, `observeUserPicture`, `observeUserBanner`, `observeUserAboutMe`,
|
||
`observeUserName(user)` each open `UserFinderFilterAssemblerSubscription(user)` and return
|
||
a `State`. Metadata, relay lists, reports and contact cards load for on-screen users only,
|
||
batched into one REQ per relay for the whole visible set. A `LazyColumn` composes only the
|
||
visible window, so this is "load what's on screen" for free.
|
||
- **Notes** (`commonsUI/.../relayClient/event/EventFinderFilterAssemblerSubscription.kt`):
|
||
`EventFinderFilterAssemblerSubscription(note)` loads the note's reactions, zaps, reposts and
|
||
replies while composed; the observers in `event/EventObservers.kt` /
|
||
`AccountEventObservers.kt` sit on top of it.
|
||
- Both read `LocalUserFinder`, `LocalUserFinderAccount` and `LocalEventFinder`, provided
|
||
once in `commonsUI/.../ui/app/AmethystApp.kt` from `dataSources()`. Their defaults are
|
||
`error(...)`, so they must never be reached from a composition with no relay client (e.g.
|
||
the Android `:napplet` process).
|
||
|
||
Don't hand-roll "load metadata for this list". `FeedMetadataCoordinator` and
|
||
`MetadataPreloader` (`assemblers/`, `preload/`) still exist but have no production caller;
|
||
prefer the observers.
|
||
|
||
## The building blocks
|
||
|
||
```kotlin
|
||
// composeSubscriptionManagers/ComposeSubscriptionManager.kt
|
||
interface Subscribable<T> {
|
||
fun subscribe(query: T?) // called on main: keep it fast
|
||
fun unsubscribe(query: T?)
|
||
fun subscribe(query: List<T>)
|
||
fun unsubscribe(query: List<T>)
|
||
}
|
||
abstract class ComposeSubscriptionManager<T> : ComposeSubscriptionManagerControls, Subscribable<T>
|
||
// keeps the live query states; allKeys() snapshots them; each change calls invalidateKeys()
|
||
interface ComposeSubscriptionManagerControls { fun invalidateKeys(); fun invalidateFilters(); fun destroy() }
|
||
```
|
||
|
||
`MutableComposeSubscriptionManager<T : MutableQueryState>` is the variant for query states
|
||
that change over time (it collects their flow).
|
||
|
||
A **filter assembler** is a `ComposeSubscriptionManager` that groups **sub-assemblers**; each
|
||
sub-assembler is an EOSE manager that turns the current keys into relay filters. They are
|
||
not pure: they own subscriptions on the `INostrClient` and per-relay EOSE cursors. Real shape
|
||
(`chess/ChessFilterAssembler.kt`):
|
||
|
||
```kotlin
|
||
class ChessFilterAssembler(client: INostrClient) : ComposeSubscriptionManager<ChessQueryState>() {
|
||
private val subAssembler = ChessFeedFilterSubAssembler(client, ::allKeys)
|
||
val group = listOf(subAssembler)
|
||
override fun invalidateKeys() = invalidateFilters()
|
||
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
|
||
override fun destroy() = group.forEach { it.destroy() }
|
||
}
|
||
```
|
||
|
||
EOSE managers (`eoseManagers/`, all on `BaseEoseManager(client, allKeys, sampleTime = 500)`,
|
||
which batches invalidations): `SingleSubEoseManager` (one REQ for all keys, one shared EOSE
|
||
cursor per relay — `MetadataFilterAssembler` uses it), `PerUserEoseManager`,
|
||
`PerKeyEoseManager`, `PerUniqueIdEoseManager`, `PerUserAndFollowListEoseManager`,
|
||
`SingleSubNoEoseCacheEoseManager`, `AccountScopedSingleSubNoEoseCacheEoseManager`. Pick by how
|
||
the EOSE cursor must be shared. Details and the new-screen recipe:
|
||
`references/filter-assemblers.md`.
|
||
|
||
## Wiring a composable
|
||
|
||
```kotlin
|
||
// commonsUI/.../relayClient/communities/CommunityFilterAssemblerSubscription.kt
|
||
@Composable
|
||
fun CommunityFilterAssemblerSubscription(channel: AddressableNote, filterAssembler: CommunityFilterAssembler) {
|
||
val state = remember(channel) { CommunityQueryState(channel) } // different screens get different states
|
||
LifecycleAwareKeyDataSourceSubscription(state, filterAssembler)
|
||
}
|
||
// call site: CommunityFilterAssemblerSubscription(note, accountViewModel.dataSources().community)
|
||
```
|
||
|
||
- `KeyDataSourceSubscription(state, dataSource)` — `DisposableEffect`: subscribe on enter,
|
||
unsubscribe on dispose. For light, always-needed subscriptions.
|
||
- `LifecycleAwareKeyDataSourceSubscription(state, dataSource)` — also unsubscribes 30 s after
|
||
the lifecycle stops (cancelled if it restarts), and immediately on dispose. Use it for feeds
|
||
and anything heavy.
|
||
|
||
Events arrive in `LocalCache`; the screen renders from `LocalCache`/`Account` flows (see
|
||
`account-state`), not from the subscription.
|
||
|
||
## Rules
|
||
|
||
- Build filters as `ExplainedFilter(..., purpose = SubPurpose.X)`. The purpose never reaches the
|
||
relay but drives the relay screens; Android debug builds wrap the client in
|
||
`UntaggedFilterWarningClient`, which logs any untagged REQ.
|
||
- No raw `client.subscribe` or `Filter` in a composable — put it in a sub-assembler.
|
||
- A one-shot read (fetch once, page back, count) is not a compose subscription: use the
|
||
`INostrClient` accessories or `relayClient/oneshot/` (`ProfileLoader`, `ThreadLoader`, …).
|
||
- New kinds a screen subscribes to also need `LocalCache` dispatch (see `nostr-expert`
|
||
› `references/event-factory.md`), or they arrive and are dropped.
|
||
|
||
Related skills: `account-state` (`LocalCache`, feeds), `nostr-expert` (filters, kinds),
|
||
`kotlin-coroutines` (flows, dispatchers).
|