mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 11:18:24 +00:00
`:commons` is on the CLI classpath, yet it declared Compose UI, Coil, Compose resources, markdown and desktop Compose as dependencies, dragging ~40 MB of UI/Skiko jars into every `amy` distribution. This moves every Compose-dependent file into a new KMP module, `:commonsUI`, that `api`-depends on `:commons`; `:commons` keeps only the Compose runtime (stability annotations + snapshot state) and lifecycle-viewmodel. Files keep their `com.vitorpamplona.amethyst.commons.*` packages, so the split is a build-graph boundary and no consumer import changed. 236 files were `git mv`'d (composables, icons, robohash, theme, Coil fetchers, the `@Composable` relay-client entry points, `composeResources`, and the tests that exercise them). Two headless files needed surgery instead of a move: `GalleryParser` lost a vestigial foundation `@OptIn`, and the `LocalPrivacyLockState`/`lockStateFor` CompositionLocal accessor moved out of `PrivacyLockState` into its own commonsUI file. The feed DAL under `ui/feeds` and `ui/note/ParentNote`+`ReplyContext` stay in `commons` because ViewModels depend on them. `amethyst`, `desktopApp`, `nappletHost` (NappletWebContract serves the shell from composeResources) and `benchmark` now depend on `:commonsUI`; `cli`, `geode` and `marmotBench` do not. commons' androidMain gains an explicit androidx.core KTX dep it previously got transitively through Compose UI. CI, crowdin, the icon-font tools and the escaping hook point at the new composeResources location; CLAUDE.md, commons/ARCHITECTURE.md, a new commonsUI/ARCHITECTURE.md, CONTRIBUTING, BUILDING and the affected skills document the boundary. A plan doc under commons/plans records the classification method and follow-ups. Verified: JVM compiles for commons, commonsUI, cli, desktopApp; Android debug compiles for nappletHost and amethyst; commons/commonsUI/cli JVM test suites; both verifyKmpPurity gates; the cli runtime classpath no longer resolves Compose UI, material3, Skiko or Coil. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N56KzPSYiN5edMRamvKEgD
162 lines
8.8 KiB
Markdown
162 lines
8.8 KiB
Markdown
---
|
|
name: relay-client
|
|
description: Subscription and filter-assembly patterns for the Amethyst relay client layer in `commons/.../relayClient/`. Use when working with compose-scoped subscriptions (`ComposeSubscriptionManager`, `Subscribable`), filter assemblers (`MetadataFilterAssembler`, `ReactionsFilterAssembler`, `FeedMetadataCoordinator`), preloaders (`MetadataPreloader`, `MetadataRateLimiter`), EOSE managers, or any feature that needs to talk to relays lifecycle-aware from a composable. Complements `nostr-expert` (protocol filter syntax) and `kotlin-coroutines` (callbackFlow patterns).
|
|
---
|
|
|
|
# Relay Client & Subscriptions
|
|
|
|
The layer between `LocalCache`/`Account` and the raw relay connection. Ensures composables only subscribe to what is visible, deduplicates filters across screens, and rate-limits bulk queries like "fetch metadata for these 200 pubkeys".
|
|
|
|
## When to Use This Skill
|
|
|
|
- Adding a new screen that needs events it doesn't already have (write a `FilterAssembler`).
|
|
- Wiring a composable to subscribe on enter / unsubscribe on leave (`ComposeSubscriptionManager`).
|
|
- Preloading metadata / profile pictures for a set of pubkeys (`MetadataPreloader`).
|
|
- Deduplicating identical filters across concurrent screens.
|
|
- Handling EOSE → "we have historical data, stop showing loading" transitions.
|
|
|
|
## Layout
|
|
|
|
All under `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/` (the `@Composable` entry points — `observeUser*`, `*FilterAssemblerSubscription`, `KeyDataSourceSubscription` — sit in the same package but in `commonsUI/src/commonMain/…`, the Compose half of the shared layer):
|
|
|
|
```
|
|
relayClient/
|
|
├── assemblers/ # "Given these inputs, build this relay Filter"
|
|
│ ├── MetadataFilterAssembler.kt # kind 0 for N pubkeys
|
|
│ ├── ReactionsFilterAssembler.kt # kind 7 for N note ids
|
|
│ ├── FeedMetadataCoordinator.kt # coordinates metadata loads for a feed
|
|
│ └── CashuMintDirectoryFilterAssembler.kt / CashuWalletFilterAssembler.kt
|
|
├── composeSubscriptionManagers/
|
|
│ ├── ComposeSubscriptionManager.kt # interface Subscribable<T>
|
|
│ ├── MutableComposeSubscriptionManager.kt # reference impl
|
|
│ └── ComposeSubscriptionManagerControls.kt # DisposableEffect-style controls
|
|
├── eoseManagers/ # EOSE tracking per subscription
|
|
│ └── IEoseManager / BaseEoseManager / PerKeyEoseManager / SingleSubEoseManager
|
|
├── nip17Dm/ # gift-wrap DM plumbing
|
|
│ └── FilterGiftWrapsToPubkey.kt / GiftWrapDecryptor.kt
|
|
├── preload/
|
|
│ ├── MetadataPreloader.kt # bulk-fetch metadata with rate limiting
|
|
│ └── MetadataRateLimiter.kt # token-bucket-ish limiter
|
|
└── subscriptions/
|
|
├── KeyDataSourceSubscription.kt # "this set of keys drives this filter"
|
|
├── LifecycleAwareKeyDataSourceSubscription.kt
|
|
└── PrioritizedSubscriptionQueue.kt / SubscriptionPriority.kt
|
|
```
|
|
|
|
## Core Concept: `Subscribable<T>`
|
|
|
|
```kotlin
|
|
// composeSubscriptionManagers/ComposeSubscriptionManager.kt
|
|
interface Subscribable<T> {
|
|
val state: StateFlow<T>
|
|
fun subscribe()
|
|
fun unsubscribe()
|
|
}
|
|
```
|
|
|
|
Every feature-level manager implements or embeds a `Subscribable`. The `MutableComposeSubscriptionManager` reference implementation uses reference-counting so that two screens asking for the same feed share one subscription, and only the last leaver actually closes it.
|
|
|
|
`ComposeSubscriptionManagerControls.kt` provides `DisposableEffect`-style helpers so composables don't leak subscriptions when the user navigates away or the process backgrounds.
|
|
|
|
## Typical Flow
|
|
|
|
```kotlin
|
|
@Composable
|
|
fun ProfileHeader(pubKey: HexKey) {
|
|
val subscription = rememberSubscribable(pubKey) {
|
|
MetadataFilterAssembler(setOf(pubKey)).toSubscribable()
|
|
}
|
|
LaunchedEffect(pubKey) { subscription.subscribe() }
|
|
DisposableEffect(pubKey) { onDispose { subscription.unsubscribe() } }
|
|
|
|
val metadata by subscription.state.collectAsStateWithLifecycle()
|
|
// render metadata…
|
|
}
|
|
```
|
|
|
|
The assembler produces a `Filter` (see `quartz/.../nip01Core/relay/RelayFilters.kt` in the quartz module). The `RelayPool` below dedups, opens subs, emits events to `LocalCache.consume`, and emits EOSE through the eose manager.
|
|
|
|
## Assemblers
|
|
|
|
An assembler is a plain class:
|
|
|
|
```kotlin
|
|
class MetadataFilterAssembler(
|
|
private val pubKeys: Set<HexKey>,
|
|
) {
|
|
fun toFilter(): Filter = filter {
|
|
kinds(MetadataEvent.KIND)
|
|
authors(pubKeys)
|
|
limit(pubKeys.size)
|
|
}
|
|
}
|
|
```
|
|
|
|
Assemblers stay pure — no state, no I/O. They're the composition seam: `FeedMetadataCoordinator` takes a list of visible notes and assembles a single metadata filter covering every referenced pubkey.
|
|
|
|
## Per-visible loading — the canonical entry points (`observeUser*` / `observeNote*`)
|
|
|
|
Prefer these over hand-rolled "load metadata for this list" calls. They are the shared,
|
|
KMP way to load data **only for what's on screen** — a composable subscribes while it is in
|
|
composition and unsubscribes ~30s after it leaves (or the app backgrounds). Both live in
|
|
`commons/relayClient/`:
|
|
|
|
- **Per user** (`relayClient/user/`): `observeUserInfo/Picture/Banner/AboutMe/Name(user)`
|
|
each open a composition-scoped `UserFinderFilterAssemblerSubscription(user)` **and** return
|
|
reactive `State`. Metadata (kind 0 + relay lists) loads for on-screen users only, coalesced
|
|
into one batched REQ per relay for the whole visible set.
|
|
- **Per note** (`relayClient/event/`): `EventFinderFilterAssemblerSubscription(note)` loads a
|
|
note's interactions (reactions / zaps / reposts / replies) while it is composed. Android's
|
|
`observeNote*` display observers layer on top of the same subscription.
|
|
|
|
Both read front-end-provided CompositionLocals — `LocalUserFinder` / `LocalUserFinderAccount`
|
|
(reused by the event finder) / `LocalEventFinder` — provided once near the composition root
|
|
(Android `AppModules`, Desktop `Main.kt` via its subscriptions coordinator). The account seam
|
|
is the narrow `UserFinderAccount` (snapshot relay-hint getters), NOT the fat `IAccount`.
|
|
`error()` defaults mean these must never be reached from a composition without a relay client
|
|
(e.g. the Android `:napplet` sandbox).
|
|
|
|
The load-once, viewport-batch path (`FeedMetadataCoordinator.loadMetadataForNotes` /
|
|
`loadMetadataBatched`) is superseded for foreground loading; `MetadataPreloader` remains only
|
|
as an optional off-screen background warmer.
|
|
|
|
## Preloaders
|
|
|
|
`MetadataPreloader` is the "I need metadata for 200 pubkeys, but don't melt my CPU or the relay" path. It uses `MetadataRateLimiter` (token bucket) to throttle bulk fetches and group them into relay-friendly chunks.
|
|
|
|
Related: `amethyst/.../service/images/ImageLoaderSetup.kt` also uses preloaders for blurhash hydration — they're a general pattern, not metadata-specific.
|
|
|
|
## EOSE Handling
|
|
|
|
Each subscription tracks "End of Stored Events" per relay. The eose manager in `eoseManagers/` aggregates per-relay EOSE into a single "loading done" boolean that the UI uses to hide spinners. Without aggregation, composables would flicker as individual relays ack.
|
|
|
|
## Patterns
|
|
|
|
### DO
|
|
|
|
- Build one `Subscribable` per feature scope (screen / dialog / card).
|
|
- Dedupe via reference counting — multiple identical subscriptions should share.
|
|
- Use `DisposableEffect` / `LaunchedEffect` to tie sub/unsub to lifecycle.
|
|
- Put the relay `Filter` building in an assembler so the test is trivial.
|
|
- Route bulk metadata through `MetadataPreloader`; don't fire N subscriptions.
|
|
|
|
### DON'T
|
|
|
|
- Don't call `RelayPool` / `NostrClient` directly from composables — always through a `Subscribable`.
|
|
- Don't hold a subscription past the composable's lifetime — memory & socket leaks.
|
|
- Don't build ad-hoc filters inline in composables — assemblers only.
|
|
- Don't preload metadata for everything — it's a rate-limited resource and competes with user-visible loads.
|
|
|
|
## Related
|
|
|
|
- **Headless / one-shot client ops** (CLI, geode, tests, non-compose code): don't go
|
|
through `Subscribable` — use the `INostrClient` extension functions in
|
|
`quartz/…/nip01Core/relay/client/accessories/` (`fetchAll`, `fetchFirst`,
|
|
`fetchAllPages`, `publishAndConfirm`, `count`, `negentropyReconcile`/`negentropySync`,
|
|
…). They're extensions, so they don't show up under "usages of `NostrClient`" — see
|
|
that package's `README.md` for the catalog before writing a raw subscribe/collect loop.
|
|
- `nostr-expert/references/tag-patterns.md` — how tags inform what a filter needs to look for.
|
|
- `kotlin-coroutines/references/relay-patterns.md` — relay pool internals (sibling layer beneath assemblers).
|
|
- `feed-patterns` skill — feeds compose several Subscribables (content + metadata + reactions).
|
|
- `account-state` skill — `Account`'s per-kind flows are themselves consumers of the relay-client layer.
|