Skip to content
Mobile
Skill

/relay-client

Compose-scoped relay subscriptions in commons/relayClient: observeUser*/EventFinder, FilterAssemblers, EOSE managers, ComposeSubscriptionManager. Use when a screen must load events from relays while visible.

From plugin
amethyst
1.6k12 skills

Claude Code

$ npx -y skills add vitorpamplona/amethyst --skill relay-client --agent claude-code

SKILL.md

relay-client.SKILL.md
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

// 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/ChessGameFilterAssembler.kt`):

class ChessGameFilterAssembler(cache: ICacheProvider, client: INostrClient) : ComposeSubscriptionManager<ChessGameQueryState>() {
    val group = listOf(ChessGameSubAssembler(cache, client, ::allKeys))
    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

// 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 he

Read more
@vitorpamplona
Ships withamethyst

Nostr client for Android

Stars on GitHub

Momentum

stars in total
1.6k
Star history of vitorpamplona/amethyst on GitHub, a chart by star-history.com

Other skills on amethyst.

amy-expert

amy-expert

amy CLI in cli/: adding an `amy <verb>` command, Main.kt dispatch, Output.emit/--json contract, exit codes 0/1/2/124, ~/.amy…

android-expert

android-expert

Android shim in `amethyst/`: MainActivity, AndroidManifest/intent filters, :napplet process, play/fdroid flavors, R8 +…

auth-signers

auth-signers

NostrSigner in Amethyst: NostrSignerInternal (local key), NostrSignerRemote (NIP-46 bunker), NostrSignerExternal (NIP-55 Android…

compose-expert

compose-expert

Shared Compose UI in `commonsUI/`: screens, components, the nav shell (`ScreenLayoutSpec`, `AppShellLayout`, Navigation 3 back…

desktop-expert

desktop-expert

Desktop JVM shim in desktopApp/: AmethystDesktop window, DesktopMenuBar, platform ports, OS keyring; :desktopApp:run, packaging.…

More skills from other plugins.

deep-code-review

deep-code-review

In-depth design-focused code review - understands codebase context before evaluating PR changes, posts structured feedback to…

expo4d53k
@expo@expoInstall Skill
modlens

modlens

Plug-in vision for text-only models. Hard rule: when a file path or URL with an image extension (.png, .jpg, .jpeg, .webp, .gif,…

modlens3w4.6k
@liustack@liustackInstall Skill
appllama-app-design-skill

appllama-app-design-sk…

Build native-feeling, benchmark-quality mobile app screens (Expo / React Native). Use when designing or implementing any mobile…

appllama2mo3.9k
@appllama@appllamaInstall Skill

Questions about relay-client.

Which coding agents can use relay-client?
relay-client installs in Claude Code, Codex, Cursor, Gemini CLI, GitHub Copilot, OpenCode and Windsurf with the skills CLI, using each agent's flag shown on this page. Flowy's auto-invocation, which fires a skill when your prompt matches, runs in Claude Code only.