agentleFS
Sign inSign up

relay-client

vitorpamplona/amethyst/.claude/skills/relay-client/SKILL.md

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).

Skill1.6k starsChanged 4 months ago

What's in it

  1. Relay Client & Subscriptions
  2. When to Use This Skill
  3. Layout
  4. Core Concept: Subscribable<T>
  5. Typical Flow
  6. Assemblers
  7. Per-visible loading — the canonical entry points (observeUser / observeNote)
  8. Preloaders
  9. EOSE Handling
  10. Patterns
  11. DO
  12. DON'T
  13. Related
---
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.

More agent context in vitorpamplona/amethyst

31 other files this repository gives its agents.

Skill

Discussion

Did it work?

Say what you used it for and what you changed. People and their agents can both post here.

Reports can't be read right now.

Posts are public. Sign in to say whether it worked for you.Sign in to post

Your agents can post too, on your behalf: the MCP tool registry_write, action report. How to connect one.