docs: fix stale claims in Claude skills against current code

Audit pass that verified every concrete claim in .claude/ against the
repository:

- account-state: Account.kt no longer exposes followListFlow-style
  StateFlows; document the state-object pattern (kind3FollowList,
  muteList, bookmarkState, ... each exposing .flow) and rewrite the
  catalog reference from the real Account.kt
- feed-patterns: filter bases (FeedFilter, AdditiveFeedFilter,
  ChangesFlowFilter, FeedContentState) moved to commons/ui/feeds;
  ui/dal keeps AdditiveComplexFeedFilter/FilterByListParams plus
  back-compat typealiases; fix recipe example signatures
- relay-client: add nip17Dm/, eoseManagers and subscriptions entries
  to the layout tree
- gradle-expert: 4-module claim -> 10 modules; refresh compose/kotlin/
  BOM versions; rewrite dependency graph with verified edges for cli,
  geode, quic, nestsClient, quic-interop, benchmark
- desktop-expert: drop drifted Main.kt line numbers; sidebar is the
  custom MainSidebar in DeckSidebar.kt, not a NavigationRail in
  SinglePaneLayout.kt
- android-expert: compileSdk/targetSdk 36 -> 37, versionName via
  generateVersionName()
- kotlin-expert: remove reference to nonexistent commit 258c4e011
- CLAUDE.md: add missing geode/benchmark/quic-interop modules
- desktop-run: packageRpm + correct binaries output path; extract.md:
  drop duplicated find clause
- session-start.sh: /home/user/Amber fallback was a copy-paste from
  another repo; fall back to CLAUDE_PROJECT_DIR

https://claude.ai/code/session_01EC7LdXjatFTh1CJSP4qKRn
This commit is contained in:
Claude
2026-06-10 22:01:29 +00:00
parent f725b2ee39
commit 8209f4416a
15 changed files with 252 additions and 196 deletions
+5 -2
View File
@@ -12,8 +12,10 @@ architecture while sharing the back end components with the android counterpart.
a non-interactive JVM command-line client that drives the same `quartz` + `commons` code — used by
humans, agents, and interop tests. `quic` is a from-scratch pure-Kotlin QUIC v1 + HTTP/3 +
WebTransport client (no JNI, no BouncyCastle), built because no Android-compatible Java QUIC library
exists. `nestsClient` runs the audio-room protocol on top of `:quic` for the NIP-53
audio-rooms feature. It implements both IETF `draft-ietf-moq-transport-17` (under
exists. `geode` is a standalone JVM Nostr relay (Ktor) built on quartz's
relay-server code; smaller modules are `benchmark` (Android macrobenchmarks) and
`quic-interop` (QUIC interop runner, lives at `quic/interop`). `nestsClient` runs
the audio-room protocol on top of `:quic` for the NIP-53 audio-rooms feature. It implements both IETF `draft-ietf-moq-transport-17` (under
`moq/`) and **moq-lite Lite-03** (kixelated's variant, under `moq/lite/`); the
production listener AND speaker paths both run on moq-lite to interop with the
nostrnests reference relay. The IETF code is kept as a reference + unit-test
@@ -55,6 +57,7 @@ amethyst/
│ └── src/
│ ├── commonMain/ # MoQ session, NestsListener, audio glue
│ └── jvmAndroid/ # Opus encode/decode, AudioRecord/AudioTrack
├── geode/ # Standalone JVM Nostr relay (Ktor) on quartz's relay-server code
├── desktopApp/ # Desktop JVM application (layouts, navigation)
├── amethyst/ # Android app (layouts, navigation)
└── cli/ # Amy — non-interactive CLI (JVM only, no Compose)
+3 -3
View File
@@ -12,7 +12,7 @@ Build and run the Amethyst Desktop application:
If the build fails, check:
1. **JDK Version**: Requires JDK 17+
1. **JDK Version**: Requires JDK 21+ (`jvmToolchain(21)` in `desktopApp/build.gradle.kts`)
```bash
java -version
```
@@ -39,7 +39,7 @@ If the build fails, check:
./gradlew :desktopApp:packageMsi
# Linux
./gradlew :desktopApp:packageDeb
./gradlew :desktopApp:packageDeb # or :desktopApp:packageRpm
```
Outputs will be in `desktopApp/build/compose/binaries/`
Outputs will be in `desktopApp/build/compose/binaries/main/`
+1 -1
View File
@@ -8,7 +8,7 @@ Extract the component `$ARGUMENTS` from the Android app to shared KMP code:
1. **Locate the component** in the amethyst module:
```bash
find amethyst/src -name "*$ARGUMENTS*" -o -name "*$ARGUMENTS*"
find amethyst/src -name "*$ARGUMENTS*"
grep -r "fun $ARGUMENTS\|class $ARGUMENTS" amethyst/src/
```
+1 -1
View File
@@ -160,7 +160,7 @@ echo -e "\n504667f4c0de7af1a06de9f4b1727b84351f2910" >> "$ANDROID_SDK_DIR/licens
echo -e "\nd975f751698a77b662f1254ddbeed3901e976f5a" > "$ANDROID_SDK_DIR/licenses/intel-android-extra-license"
# Create local.properties if missing
REPO_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel 2>/dev/null || echo "${CLAUDE_PROJECT_DIR:-/home/user/Amber}")"
REPO_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel 2>/dev/null || echo "${CLAUDE_PROJECT_DIR:-$PWD}")"
LOCAL_PROPS="$REPO_ROOT/local.properties"
if [ ! -f "$LOCAL_PROPS" ]; then
echo "sdk.dir=$ANDROID_SDK_DIR" > "$LOCAL_PROPS"
+23 -25
View File
@@ -1,6 +1,6 @@
---
name: account-state
description: Account state and in-memory event store patterns in Amethyst. Use when working with `Account.kt` (per-user StateFlow properties — follow list, relays, settings, mutes, bookmarks), `LocalCache` (the object-level event store backed by `LargeCache`), `User`/`Note` model classes, or any ViewModel that reads user-specific state. Covers how account events cascade from relay arrival to UI state, how to add a new account-scoped setting, and when to read from `LocalCache` vs subscribe to a StateFlow.
description: Account state and in-memory event store patterns in Amethyst. Use when working with `Account.kt` (per-user state objects — `kind3FollowList`, `nip65RelayList`, `muteList`, `bookmarkState`, each exposing a `.flow` StateFlow), `LocalCache` (the object-level event store backed by `LargeCache`), `User`/`Note` model classes, or any ViewModel that reads user-specific state. Covers how account events cascade from relay arrival to UI state, how to add a new account-scoped setting, and when to read from `LocalCache` vs subscribe to a StateFlow.
---
# Account & Local Cache State
@@ -21,10 +21,10 @@ The backbone of Amethyst's client state: one `Account` per signed-in user, plus
Relay frame ──► LocalCache.insertOrUpdateNote() ──► LocalCacheFlow emits change
Account observes relevant kinds (3, 10002, 10000, …)
Account state objects pin the relevant addressable notes
Account StateFlow updates (followList, relays, mutes, …)
State-object `.flow` updates (kind3FollowList, nip65RelayList, muteList, …)
ViewModels collect
@@ -33,22 +33,22 @@ Relay frame ──► LocalCache.insertOrUpdateNote() ──► LocalCacheFlow e
Composables render
```
`LocalCache` is the event store. `Account` is the *derived* per-user view (follow list, relays, mutes, emojis, bookmarks, etc.). UI listens to `Account`'s StateFlows, not directly to `LocalCache`, except for note-level rendering.
`LocalCache` is the event store. `Account` is the *derived* per-user view (follow list, relays, mutes, emojis, bookmarks, etc.). UI listens to the `.flow` of `Account`'s state objects, not directly to `LocalCache`, except for note-level rendering.
## Key Files
### `Account.kt` (singleton-per-session)
- `class Account(...)` — holds 50+ StateFlow properties, each wired to a specific Nostr kind:
- `followListFlow` ← NIP-02 ContactList (kind 3)
- `relayListFlow` ← NIP-65 RelayList (kind 10002)
- `muteListFlow` ← NIP-51 Lists (kind 10000)
- `bookmarkListFlow` ← NIP-51 Lists (kind 10003)
- `topNavFeedsFlow`, `marmotGroupsFlow`, `customEmojisFlow`, `privateBookmarksFlow`, etc.
- Settings: `defaultZapAmountsFlow`, `theme`, `language`, `proxyFlow`, `showSensitiveContentFlow`, …
- Each flow has a private `MutableStateFlow` and a public read-only `StateFlow` view. Mutation goes through specific methods (`sendPost`, `follow(pubKey)`, `addBookmark(...)`) that both update the flow and publish the signed replaceable event.
- Uses `CoroutinesExt.launchIO` for network / crypto; UI reads via `collectAsStateWithLifecycle` on Android and `collectAsState` on Desktop.
- Sibling files per feature live alongside: `AccountSettings.kt`, `AccountSyncedSettings.kt`, plus per-NIP state objects under `model/nip02FollowLists/`, `model/nip51Lists/`, `model/nip65RelayList/`, etc.
- `class Account(...)` — holds 50+ **state objects**, one per feature, each wired to a specific Nostr kind:
- `kind3FollowList = Kind3FollowListState(...)` ← NIP-02 ContactList (kind 3)
- `nip65RelayList = Nip65RelayListState(...)` ← NIP-65 RelayList (kind 10002), plus siblings `dmRelayList`, `searchRelayList`, `blockedRelayList`, `trustedRelayList`, `proxyRelayList`, `broadcastRelayList`, `indexerRelayList`, …
- `muteList = MuteListState(...)` ← NIP-51 MuteList (kind 10000)
- `bookmarkState = BookmarkListState(...)` ← NIP-51 Bookmarks (kind 10003), plus `labeledBookmarkLists`, `pinState`, `interestSets`, `peopleLists`, `followLists`, `hashtagList`, `geohashList`, `communityList`, `emoji`, `blossomServers`, …
- Derived/merged views: `hiddenUsers`, `allFollows`, `homeRelays`, `outboxRelays`, `dmRelays`, `notificationRelays`, `trustedRelays`, and the `live*FollowListsPerRelay` outbox loaders.
- **The pattern:** each `XState` class pins its addressable note via `cache.getOrCreateAddressableNote(address)` (a long-term reference so GC/eviction can't drop it), exposes `val flow: StateFlow<…>` derived from the note's metadata flow (decrypted through a per-feature `DecryptionCache`, with backup fallback from `AccountSettings`, `stateIn(scope, Eagerly, …)`), and offers suspend mutation helpers (e.g. `MuteListState.hideUser(pubkey)`) that build the updated signed event. Consumers read `account.muteList.flow`, never a raw `MutableStateFlow` on `Account`.
- Encrypted lists pair the state object with a `DecryptionCache` sibling (`muteListDecryptionCache`, `peopleListDecryptionCache`, …) so NIP-44 decryption results are cached per event.
- UI reads via `collectAsStateWithLifecycle` on Android and `collectAsState` on Desktop.
- Sibling files per feature live alongside: `AccountSettings.kt`, `AccountSyncedSettings.kt`, plus per-NIP state classes under `model/nip02FollowLists/`, `model/nip51Lists/`, `model/nip65RelayList/`, etc.
### `LocalCache.kt`
@@ -72,31 +72,29 @@ Relay frame ──► LocalCache.insertOrUpdateNote() ──► LocalCacheFlow e
Typical recipe:
1. If the setting is persisted as a Nostr event, pick the right kind (e.g. NIP-51 list, NIP-78 app-specific data, NIP-65 relay list).
2. Add a model folder under `amethyst/.../model/nipXX…/` with an `ExtState`/builder class if needed.
3. In `Account.kt`:
- Add a private `MutableStateFlow<T>`.
- Expose a `StateFlow<T>` read view.
- Subscribe to the relay (via the relayClient subscription pattern see `relay-client` skill).
- On event arrival, parse with the quartz event class and update the flow.
- Write a mutation method (`updateX(...)`) that builds a new event via the corresponding `TagArrayBuilder`, signs through `NostrSigner`, and publishes.
4. Add UI that `collect`s the flow. Settings screens live in `amethyst/.../ui/screen/loggedIn/settings/`.
2. Add a model folder under `amethyst/.../model/nipXX…/` with an `XState` class modeled on an existing one (`MuteListState` for an encrypted list, `BookmarkListState` for a plain one):
- Pin the addressable note: `val xNote = cache.getOrCreateAddressableNote(XEvent.createAddress(signer.pubKey))`.
- Expose `val flow: StateFlow<…>` mapped from `xNote.flow().metadata.stateFlow`, decrypting through a per-feature `DecryptionCache` if the list is private, with backup fallback from `AccountSettings`, then `stateIn(scope, Eagerly, default)`.
- Add suspend mutation helpers that build the updated event via the quartz event class (`XEvent.add/remove/create`) and return it signed.
3. In `Account.kt`, instantiate the state object (and its `DecryptionCache` sibling if encrypted) as a `val`. Publishing the returned event goes through `Account`'s send path; the relay subscription side is the relayClient pattern (see `relay-client` skill).
4. Add UI that `collect`s `account.x.flow`. Settings screens live in `amethyst/.../ui/screen/loggedIn/settings/`.
## `LocalCache` vs `Account` Flow — Which to Read?
- **Are you rendering a specific note / user you hold an id for?** → `LocalCache.getOrCreateNote(id)` + collect `note.flowSet.metadata`.
- **Are you rendering "my follows", "my mutes", "my relays"?** → `Account.<featureFlow>`.
- **Are you rendering "my follows", "my mutes", "my relays"?** → `account.<feature>.flow` (e.g. `account.kind3FollowList.flow`, `account.muteList.flow`, `account.nip65RelayList.flow`).
- **Are you rendering a feed?** → Use a `FeedFilter` + `FeedViewModel` (see `feed-patterns` skill). Don't scan `LocalCache` in a composable.
## Gotchas
- **`LocalCache` is a singleton across accounts.** Switching accounts doesn't wipe it — `Account` re-derives its flows from the same cache.
- **Don't store Flows inside `Note` / `User`** expecting them to survive eviction. Eviction drops the whole object.
- **Mutations to `Account` flows must also publish the signing event.** A flow update without a publish means other clients won't see it.
- **State-object mutation helpers return a signed event — publishing it is the caller's job.** A locally updated list without a publish means other clients won't see it.
- **`Note` is mutable** — treat instances as identity-based (same id → same Note). Use `.flowSet` when you need reactive state.
- **`MemoryTrimmingService` can evict aggressively** on Android under pressure. Don't assume a previously-seen note is still resident.
## References
- `references/account-state-flow.md` — catalog of major `Account` StateFlow properties and their source kinds.
- `references/account-state-flow.md` — catalog of major `Account` state objects and their source kinds.
- `references/local-cache.md``LocalCache` internals, insertion path, indexes.
- Complements: `nostr-expert` (event parsing), `relay-client` (subscription wiring), `feed-patterns` (how feeds consume this state), `auth-signers` (how mutation signs events).
@@ -1,93 +1,94 @@
# Account StateFlow Catalog
# Account State-Object Catalog
`Account.kt` exposes dozens of `StateFlow` properties that mirror different facets of the current user. This is a map from flow → Nostr kind → model package.
`Account.kt` composes ~50 **feature state objects** (not raw StateFlow
properties). Each object pins its backing addressable note in `LocalCache`,
exposes `val flow: StateFlow<…>` (decrypted + backup-merged + `stateIn`), and
offers suspend mutation helpers that return signed events. Consumers read
`account.<property>.flow`.
(Flow names are exact as of the current `Account.kt`; if a flow has been renamed, grep `Account.kt` for the old name.)
(Property and class names are exact as of the current `Account.kt`; if one has
been renamed, grep `Account.kt` for the class name.)
## Identity & Contacts
| Flow | Kind(s) | Source | Model package |
|------|---------|--------|---------------|
| `userProfile().liveMetadata` | 0 MetadataEvent | relay | `model/nip01UserMetadata/` |
| `followListFlow` | 3 ContactListEvent | relay | `model/nip02FollowLists/` |
| `followersFlow` | derived | LocalCache scan | — |
| `muteListFlow` | 10000 NIP-51 | relay | `model/nip51Lists/` |
| `blockListFlow` | 10000 list variant | relay | `model/nip51Lists/` |
| Account property | State class | Kind | Package |
|------------------|-------------|------|---------|
| `userMetadata` | `UserMetadataState` | 0 | `amethyst/.../model/nip01UserMetadata/` |
| `kind3FollowList` | `Kind3FollowListState` | 3 | `model/nip02FollowLists/` |
| `muteList` (+ `muteListDecryptionCache`) | `MuteListState` | 10000 | `model/nip51Lists/muteList/` |
| `blockPeopleList`, `peopleLists` | `BlockPeopleListState`, `PeopleListsState` | NIP-51 people sets | `model/nip51Lists/peopleList/` |
| `followLists` | `FollowListsState` | NIP-51 follow sets | `model/nip51Lists/peopleList/` |
| `hiddenUsers` | `HiddenUsersState` — derived from `muteList.flow` + `blockPeopleList.flow` | — | `model/nip51Lists/` |
| `allFollows` | `MergedFollowListsState` — merges kind3 + people/follow/hashtag/geohash/community lists | — | `model/serverList/` |
## Relays & Connectivity
## Relay Lists
| Flow | Kind | Package |
|------|------|---------|
| `relayListFlow` | 10002 RelayList (NIP-65) | `model/nip65RelayList/` |
| `dmRelayListFlow` | 10050 | `model/nip65RelayList/` |
| `searchRelayListFlow` | 10007 | `model/nip65RelayList/` |
| `nip86RelayListFlow` | NIP-86 relay management | `model/nip86RelayManagement/` |
| `proxyFlow`, `torStateFlow` | local preferences | `model/torState/`, `AccountSyncedSettings` |
| Account property | State class | Kind | Package |
|------------------|-------------|------|---------|
| `nip65RelayList` | `Nip65RelayListState` | 10002 | `model/nip65RelayList/` |
| `dmRelayList` | `DmRelayListState` | 10050 | `model/nip17Dms/` |
| `searchRelayList` | `SearchRelayListState` | 10007 | `model/nip51Lists/searchRelays/` |
| `blockedRelayList` | `BlockedRelayListState` | 10006 | `model/nip51Lists/blockedRelays/` |
| `localRelayList` | `LocalRelayListState` | local | `model/localRelays/` |
| `privateStorageRelayList` | `PrivateStorageRelayListState` | private storage | `model/edits/` |
| `keyPackageRelayList`, `trustedRelayList`, `proxyRelayList`, `broadcastRelayList`, `indexerRelayList`, `relayFeedsList` | per-feature `…RelayListState` classes, each with a `DecryptionCache` sibling | custom relay sets | `model/nip51Lists/…` |
Derived relay views (merge several of the above): `homeRelays`
(`AccountHomeRelayState`), `outboxRelays`, `dmRelays`, `notificationRelays`,
`trustedRelays`, `followPlusAllMineWithIndex`, `followPlusAllMineWithSearch`,
`defaultGlobalRelays`.
## Content Lists
| Flow | Kind | Package |
|------|------|---------|
| `bookmarkListFlow` | 10003 | `model/nip51Lists/` |
| `privateBookmarksFlow` | encrypted list | `model/nip51Lists/` |
| `topNavFeedsFlow` | custom | `model/topNavFeeds/` |
| `customEmojisFlow` | 10030 NIP-30 | `model/nip30CustomEmojis/` |
| `marmotGroupsFlow` | NIP-29 (marmot variant) | `model/marmot/` |
| `nip72CommunitiesFlow` | 34550 (NIP-72) | `model/nip72Communities/` |
| `nip64ChessFlow` | NIP-64 chess games | `model/nip64Chess/` |
| Account property | State class | Kind | Package |
|------------------|-------------|------|---------|
| `bookmarkState` (and legacy `oldBookmarkState`) | `BookmarkListState` | 10003 | `model/nip51Lists/` |
| `labeledBookmarkLists` | `LabeledBookmarkListsState` | NIP-51 bookmark sets | `model/nip51Lists/labeledBookmarkLists/` |
| `pinState` | `PinListState` | NIP-51 | `model/nip51Lists/` |
| `interestSets` | `InterestSetsState` | NIP-51 interest sets | `model/nip51Lists/interestSets/` |
| `hashtagList` / `geohashList` | `HashtagListState` / `GeohashListState` | NIP-51 | `model/nip51Lists/hashtagLists/`, `…/geohashLists/` |
| `communityList` | `CommunityListState` | NIP-72 communities | `model/nip72Communities/` |
| `favoriteAlgoFeedsList` | `FavoriteAlgoFeedsListState` | NIP-51 | `model/nip51Lists/` |
| `emoji`, `ownedEmojiPacks` | `EmojiPackState`, `OwnedEmojiPacksState` | 10030 | `commons/.../commons/model/nip30CustomEmojis/` |
| `publicChatList` | `PublicChatListState` | NIP-28 | `commons/.../commons/model/nip28PublicChats/` |
| `ephemeralChatList` | `EphemeralChatListState` | ephemeral chats | `commons/.../commons/model/emphChat/` |
| `blossomServers` | `BlossomServerListState` | Blossom (BUD) | `model/nipB7Blossom/` |
## Messaging
## Other Feature State
| Flow | Kind | Package |
|------|------|---------|
| `dmInboxFlow` | 14 / 1059 (NIP-17 / gift-wrap) | `model/nip17Dms/` |
| `nwcSettingsFlow` | NIP-47 wallet connect | `model/nip47WalletConnect/` |
| `paymentTargetsFlow` | NIP-A3 | `model/nipA3PaymentTargets/` |
| `blossomServersFlow` | NIP-B7 blossom | `model/nipB7Blossom/` |
| Account property | State class | Purpose | Package |
|------------------|-------------|---------|---------|
| `vanish` | `VanishRequestsState` | NIP-62 vanish requests | `model/nip62Vanish/` |
| `appSpecific` | `AppSpecificState` | NIP-78 app data | `model/nip78AppSpecific/` |
| `otsState` | `OtsState` | NIP-03 OpenTimestamps | `model/nip03Timestamp/` |
| `live*FollowListsPerRelay` | `OutboxLoaderState(...).flow` — already a flow | per-feed outbox routing | `model/topNavFeeds/` |
| `privateDMDecryptionCache`, `draftsDecryptionCache` | `PrivateDMCache`, `DraftEventCache` | NIP-44 decryption caches | — |
## Settings & UI
| Flow | Source | Package |
|------|--------|---------|
| `uiSettingsFlow` | local | `model/UiSettings.kt`, `UiSettingsFlow.kt` |
| `antiSpamFilter` | local | `model/AntiSpamFilter.kt` |
| `privacyOptionsFlow` | local | `model/privacyOptions/` |
| `trustedAssertionsFlow` | derived | `model/trustedAssertions/` |
| `defaultZapAmountsFlow`, `theme`, `language` | local preferences | `AccountSettings.kt`, `AccountSyncedSettings.kt` |
## Advanced / Derived
| Flow | Purpose | Package |
|------|---------|---------|
| `accountsCacheFlow` | multi-account switcher | `model/accountsCache/` |
| `algoFeedsFlow` | custom algorithmic feeds | `model/algoFeeds/` |
| `vanishFlow` | NIP-62 account vanish requests | `model/nip62Vanish/` |
| `nip78AppSpecificFlow` | NIP-78 app-specific data | `model/nip78AppSpecific/` |
| `serverListFlow` | media/upload servers | `model/serverList/` |
Note the migration direction: newer/extracted state classes live in
`commons/src/commonMain/.../commons/model/`, the rest still in
`amethyst/src/main/java/.../model/`. Check both when looking for one.
## Publishing Mutations
Every flow has a corresponding mutation method on `Account` that:
State objects' mutation helpers (e.g. `MuteListState.hideUser(pubkey)`,
`BookmarkListState` add/remove) **build and sign** the updated replaceable
event via the quartz event class (`XEvent.add / remove / create`) and return
it. The caller (usually a method on `Account`) is responsible for sending it
through the client. Decryption results are cached in the paired
`…DecryptionCache` so re-renders don't re-decrypt.
1. Constructs the updated event using a `TagArrayBuilder`.
2. Signs through the injected `NostrSigner` (see `auth-signers` skill).
3. Publishes to the appropriate relay set.
4. Updates the local StateFlow *before* relay round-trip (optimistic).
5. Rolls back / reconciles on failure.
## When a State Object Doesn't Exist Yet
Examples of mutation methods (names may vary slightly in current code):
- `follow(pubKey)` / `unfollow(pubKey)`
- `addBookmark(noteId)` / `removeBookmark(noteId)`
- `mute(pubKey)` / `unmute(pubKey)`
- `updateRelayList(...)`, `updateDmRelayList(...)`
- `sendPost(...)`, `sendReaction(...)`, `sendZap(...)`
If you're adding a new NIP that's user-scoped, follow the pattern (full recipe
in `SKILL.md`):
## When a Flow Doesn't Exist Yet
If you're adding a new NIP that's user-scoped, follow the pattern:
1. Create `model/nipXX…/` with an optional `ExtState`/builder class.
2. Add `private val _xFlow = MutableStateFlow(initial)` + `val xFlow: StateFlow<T> = _xFlow.asStateFlow()` to `Account`.
3. Wire the relay subscription (see `relay-client` skill).
4. Add the mutation method that builds, signs, and publishes.
5. Update persistence if the setting is local-only (`AccountSettings.kt`).
1. Create `model/nipXX…/XState.kt` modeled on `MuteListState` (encrypted) or
`BookmarkListState` (plain).
2. Pin the note with `cache.getOrCreateAddressableNote(...)`, expose
`val flow: StateFlow<…>` via `stateIn(scope, Eagerly, default)`.
3. Instantiate it in `Account.kt` (plus a `DecryptionCache` sibling if
private), and wire the relay subscription (see `relay-client` skill).
4. Add mutation helpers that build, sign, and return the event; publish from
the calling site.
5. Use `AccountSettings` for the local backup copy if the list must survive
relay loss.
+3 -3
View File
@@ -744,14 +744,14 @@ fun SignerIntegration(accountViewModel: AccountViewModel) {
```gradle
android {
namespace = 'com.vitorpamplona.amethyst'
compileSdk = 36
compileSdk = 37 // from libs.versions.toml android-compileSdk — check there, it drifts
defaultConfig {
applicationId = "com.vitorpamplona.amethyst"
minSdk = 26 // Android 8.0 (Oreo)
targetSdk = 36 // Android 15
targetSdk = 37 // android-targetSdk in libs.versions.toml
versionCode = 447
versionName = "1.11.0"
versionName = generateVersionName(libs.versions.app.get(), rootDir)
vectorDrawables {
useSupportLibrary = true
+4 -4
View File
@@ -74,7 +74,7 @@ fun main() = application {
- `rememberWindowState()` manages size/position
- `onCloseRequest` handles window close
**See:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt``fun main()` at L172, `application {` at L186, top-level `Window` at L229, `MenuBar` at L234.
**See:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt` grep for `fun main()`, `application {`, the top-level `Window`, and `MenuBar {` (the file is large and line numbers drift; navigate by symbol).
---
@@ -280,9 +280,9 @@ Row(Modifier.fillMaxSize()) {
}
```
**See:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt` (NavigationRail at L97, items at L103+). `DeckLayout` alongside it handles multi-pane workspaces.
**In Amethyst Desktop:** the sidebar is the custom `MainSidebar` composable in `desktopApp/.../ui/deck/DeckSidebar.kt`, instantiated from `Main.kt` and shared by both layout modes (`SinglePaneLayout` and the multi-pane `DeckLayout` alongside it). It is hand-rolled, not Material's `NavigationRail` — use `NavigationRail` only for new, simpler cases.
**Why NavigationRail?**
**Why a left sidebar?**
- Desktop has horizontal space (1200+ dp width)
- Vertical sidebar is standard desktop pattern
- Always visible (no tabs hidden)
@@ -290,7 +290,7 @@ Row(Modifier.fillMaxSize()) {
**Android comparison:**
- Android: `BottomNavigationBar` (horizontal, bottom)
- Desktop: `NavigationRail` (vertical, left)
- Desktop: left vertical sidebar (`MainSidebar`)
### Multi-Pane Layouts
@@ -11,11 +11,11 @@ Comparison of mobile vs desktop navigation patterns in AmethystMultiplatform.
---
## Desktop: NavigationRail
## Desktop: Left Sidebar
### Current Implementation
**File:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt` (NavigationRail begins at L97; `NavigationRailItem`s at L103 and L127+).
**File:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt` — the custom `MainSidebar` composable, instantiated from `Main.kt` and shared by both `SinglePaneLayout` and the multi-pane `DeckLayout`. Amethyst Desktop does **not** use Material's `NavigationRail`; the snippet below shows the generic Compose pattern for reference, useful for simpler new surfaces.
```kotlin
@Composable
+36 -22
View File
@@ -1,6 +1,6 @@
---
name: feed-patterns
description: Feed composition and data-access layer patterns in Amethyst. Use when adding or modifying a feed (home, profile, hashtag, bookmarks, notifications, DMs, communities), working with `FeedFilter` / `AdditiveComplexFeedFilter` / `ChangesFlowFilter` / `FilterByListParams` in `amethyst/.../ui/dal/`, or extending the `FeedViewModel` family in `commons/.../viewmodels/`. Covers how feeds scan `LocalCache`, react to changes, apply ordering, and render through Compose.
description: Feed composition and data-access layer patterns in Amethyst. Use when adding or modifying a feed (home, profile, hashtag, bookmarks, notifications, DMs, communities), working with the shared `FeedFilter` / `AdditiveFeedFilter` / `ChangesFlowFilter` / `FeedContentState` in `commons/.../ui/feeds/`, the Android-only `AdditiveComplexFeedFilter` / `FilterByListParams` in `amethyst/.../ui/dal/`, or extending the `FeedViewModel` family in `commons/.../viewmodels/`. Covers how feeds scan `LocalCache`, react to changes, apply ordering, and render through Compose.
---
# Feed Patterns
@@ -24,27 +24,33 @@ Amethyst's "feed" abstraction is: a `FeedFilter` that decides which notes belong
│ ◄── ChatroomFeedViewModel │
│ ◄── MarmotGroupFeedViewModel │
│ │
FeedContentState — the flow the UI collects
│ commons/.../ui/feeds/ (shared, KMP) │
│ IFeedFilter / FeedFilter<T> (abstract base) │
│ IAdditiveFeedFilter / AdditiveFeedFilter<T> │
│ ChangesFlowFilter │
│ FeedContentState, FeedState — the flow the UI collects │
└─────────────────────────────────────────────────────────────┘
│ uses
┌─────────────────────────────────────────────────────────────┐
│ amethyst/.../ui/dal/ (Android; feeds defined per screen)
│ FeedFilter<T> (abstract) │
│ amethyst/.../ui/dal/ (Android-only additions)
│ AdditiveComplexFeedFilter<T, U> │
│ ChangesFlowFilter │
│ FilterByListParams │
│ DefaultFeedOrder
│ DefaultFeedOrder (Note/Event/Card comparators)
│ (FeedFilters.kt & ChangesFlowFilter.kt here are just │
│ back-compat typealiases re-exporting commons) │
│ │
Plus concrete feeds: HomeFeedFilter, HashtagFeedFilter,
BookmarkListFeedFilter, NotificationFeedFilter, …
Concrete feeds: HomeNewThreadFeedFilter,
HashtagFeedFilter, NotificationFeedFilter, … live in
│ feature folders under ui/screen/loggedIn/*/dal/ │
└─────────────────────────────────────────────────────────────┘
│ reads
┌─────────────────────────────────────────────────────────────┐
│ model/LocalCache.kt + Account.<featureFlow>
│ model/LocalCache.kt + account.<feature>.flow │
└─────────────────────────────────────────────────────────────┘
```
@@ -60,25 +66,33 @@ Amethyst's "feed" abstraction is: a `FeedFilter` that decides which notes belong
- **`MarmotGroupFeedViewModel.kt`** — NIP-29 / marmot group feed.
- **`LiveStreamTopZappersViewModel.kt`, `SearchBarState.kt`, `ChatNewMessageState.kt`** — narrower, non-feed states that share the plumbing.
### Android DAL (the filters)
### Shared filter bases (commons)
`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/`:
- **`FeedFilter.kt`** — `abstract class FeedFilter<T> : IFeedFilter<T>`. Has `feed(): List<T>` (the sync query against the cache), `feedKey(): String` (identity used to cache), `limit()`, and `loadTop()`.
- **`AdditiveFeedFilter.kt`** — `abstract class AdditiveFeedFilter<T> : FeedFilter<T>(), IAdditiveFeedFilter<T>`. Adds incremental updates (the "additive" part): `updateListWith(oldList, newItems)` runs `applyFilter(newItems)` and grafts accepted items onto the existing list (re-`sort` + `take(limit())`) without recomputing everything.
- **`ChangesFlowFilter.kt`** — wraps a filter with a coarse "state changed" signal so the ViewModel knows to re-query.
- **`FeedContentState.kt` / `FeedState.kt`** — the reactive state the UI collects.
### Android DAL (additions on top)
`amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/`:
- **`FeedFilters.kt`** — `abstract class FeedFilter<T>`. Has `feed(): List<T>` (the sync query against `LocalCache`) and `feedKey(): String` (identity used to cache).
- **`AdditiveComplexFeedFilter.kt`** — `abstract class AdditiveComplexFeedFilter<T, U> : FeedFilter<T>()`. Adds incremental updates (the "additive" part): when a single new event arrives, the filter can decide whether to graft it onto the existing list without recomputing everything.
- **`ChangesFlowFilter.kt`** — wraps a filter with a coarse "Account state changed" signal so the ViewModel knows to re-query.
- **`FilterByListParams.kt`** — common parameters (author set, exclude muted, limit, since/until) shared across many filters.
- **`DefaultFeedOrder.kt`** — standard sort (by `createdAt` desc, plus tiebreakers for stable paging).
- **`AdditiveComplexFeedFilter.kt`** — `abstract class AdditiveComplexFeedFilter<T, U> : FeedFilter<T>()`: like `AdditiveFeedFilter` but the incoming items (`Set<U>`) are a different type than the list rows (`T`).
- **`FilterByListParams.kt`** — common parameters (top-nav filter, exclude muted, since/until) shared across many filters.
- **`DefaultFeedOrder.kt`** — standard comparators (`createdAt` desc + id tiebreaker for stable paging) for `Note`, `Event`, and `Card`.
- **`FeedFilters.kt` / `ChangesFlowFilter.kt`** — back-compat typealiases re-exporting the commons classes; don't add logic here.
Concrete filters (Home, Hashtag, Profile, Bookmark, Notifications, Communities, etc.) live in feature subfolders under `amethyst/.../ui/screen/loggedIn/*/` — each extends `FeedFilter` or `AdditiveComplexFeedFilter`.
Concrete filters (Home, Hashtag, Profile, Bookmark, Notifications, Communities, etc.) live in feature `dal/` subfolders under `amethyst/.../ui/screen/loggedIn/*/` — each extends `FeedFilter`, `AdditiveFeedFilter`, or `AdditiveComplexFeedFilter`. Desktop has its own in `desktopApp/.../feeds/DesktopFeedFilters.kt`.
## Adding a New Feed
1. **Define the filter.** Extend `AdditiveComplexFeedFilter<Note, Set<HexKey>>` (or plain `FeedFilter<Note>` if additivity doesn't matter). Implement:
1. **Define the filter.** Extend `AdditiveFeedFilter<Note>` (or plain `FeedFilter<Note>` if additivity doesn't matter; `AdditiveComplexFeedFilter<T, U>` if incoming items differ in type from list rows). Implement:
- `feedKey()` — stable identity (e.g. hashtag name, account pubkey).
- `feed()` — synchronous scan over `LocalCache` / `Account` state producing an ordered list.
- `limit()` — pagination hint.
- If using `AdditiveComplexFeedFilter`: `applyFilter(collection: Set<Note>): Set<Note>` and `sort(collection: Set<Note>): List<Note>`.
- If additive: `applyFilter(collection: Set<Note>): Set<Note>` and `sort(collection: Set<Note>): List<Note>`.
2. **Pick or write a ViewModel.** If the feed's membership shifts often (bookmarks, notifications), extend `ListChangeFeedViewModel`. Otherwise `FeedViewModel`.
3. **Wire invalidation.** The ViewModel must observe the right `Account` flows + `LocalCacheFlow` so it re-queries when state changes.
4. **Render.** In the composable, collect `viewModel.feedState.feedContent` and render with a `LazyColumn { items(..., key = { it.id }) { NoteCompose(it) } }`.
@@ -86,9 +100,9 @@ Concrete filters (Home, Hashtag, Profile, Bookmark, Notifications, Communities,
## Filter Sharing (Android vs Desktop)
- `FeedFilter` and the concrete filters currently live in `amethyst/.../ui/dal/`**Android-only**. Desktop has parallel filters in `desktopApp/.../feeds/`.
- ViewModels are in `commons/commonMain/`**shared**. That's the boundary: filter is Android (could be extracted), ViewModel is shared.
- When porting a new feed, extract the filter to a KMP-friendly location only if both platforms need it.
- The filter **base classes** (`FeedFilter`, `AdditiveFeedFilter`, `ChangesFlowFilter`) and feed state (`FeedContentState`) are in `commons/.../ui/feeds/`**shared**. ViewModels are in `commons/.../viewmodels/`**shared**.
- The **concrete** filters are platform-local: Android's in `amethyst/.../ui/screen/loggedIn/*/dal/`, Desktop's in `desktopApp/.../feeds/`. `amethyst/.../ui/dal/` keeps Android-only helpers (`AdditiveComplexFeedFilter`, `FilterByListParams`, `DefaultFeedOrder`) plus back-compat typealiases.
- When porting a feed, share the concrete filter only if both platforms need identical inclusion rules.
## Gotchas
@@ -96,7 +110,7 @@ Concrete filters (Home, Hashtag, Profile, Bookmark, Notifications, Communities,
- **`feedKey()` is used as a cache key.** Two different semantic feeds must produce different keys, otherwise their state cross-contaminates.
- **Additive updates must stay consistent with the full recompute.** If `applyFilter` accepts a note that `feed()` wouldn't include, UX drifts.
- **Paging isn't free** — use `limit()` and `since/until` in `FilterByListParams` rather than trimming a giant scan.
- **Notifications feed is special** — it inspects `Account.followListFlow` and `LocalCache` deletions to hide muted/deleted content; always run through `FilterByListParams.exclude*` paths rather than filtering post-hoc.
- **Notifications feed is special** — it inspects the follow/mute state (`account.kind3FollowList.flow`, `account.hiddenUsers`) and `LocalCache` deletions to hide muted/deleted content; always run through the `FilterByListParams` exclusion paths rather than filtering post-hoc.
## References
@@ -7,11 +7,12 @@ Step-by-step recipe for composing a new feed. Assume the feed shows `Note`s filt
| If… | Use |
|-----|-----|
| Membership is stable (e.g. "my follows") and you re-compute on change | `FeedFilter<Note>` |
| New notes arrive one at a time and should slot into the list incrementally | `AdditiveComplexFeedFilter<Note, Set<Note>>` |
| New notes arrive one at a time and should slot into the list incrementally | `AdditiveFeedFilter<Note>` |
| Incoming items are a different type than the list rows | `AdditiveComplexFeedFilter<T, U>` (Android-only) |
| The feed is a simple list that changes frequently (e.g. bookmarks, lists) | `FeedFilter<Note>` + `ListChangeFeedViewModel` |
| The feed is a DM thread | `ChatroomFeedViewModel` (already provides filter machinery) |
All live in `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/`.
The bases live in `commons/src/commonMain/.../commons/ui/feeds/`; `AdditiveComplexFeedFilter` and the `FilterByListParams` / `DefaultFeedOrder` helpers in `amethyst/src/main/java/.../ui/dal/`.
## 2. Write the Filter
@@ -19,7 +20,7 @@ All live in `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/`.
class HashtagFeedFilter(
private val accountViewModel: AccountViewModel,
private val hashtag: String,
) : AdditiveComplexFeedFilter<Note, Set<Note>>() {
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = "Hashtag-$hashtag"
@@ -28,7 +29,7 @@ class HashtagFeedFilter(
override fun feed(): List<Note> {
val params = FilterByListParams.create(
excludeMuted = true,
hiddenUsers = accountViewModel.hiddenUsersFlow.value,
hiddenUsers = account.hiddenUsers.flow.value,
)
return LocalCache.hashtagIndex[hashtag]
.orEmpty()
@@ -69,7 +70,7 @@ class HashtagFeedViewModel(
)
```
If membership changes aggressively (e.g. the user toggles a mute), use `ListChangeFeedViewModel` instead and hook into `Account.muteListFlow`.
If membership changes aggressively (e.g. the user toggles a mute), use `ListChangeFeedViewModel` instead and hook into `account.muteList.flow`.
## 4. Wire Invalidation
@@ -78,7 +79,7 @@ If membership changes aggressively (e.g. the user toggles a mute), use `ListChan
```kotlin
init {
viewModelScope.launch {
accountViewModel.muteListFlow.collect { invalidateAll() }
account.muteList.flow.collect { invalidateAll() }
}
}
```
+6 -6
View File
@@ -5,11 +5,11 @@ description: Build optimization, dependency resolution, and multi-module KMP tro
# Gradle Expert
Build system expertise for AmethystMultiplatform's 4-module KMP architecture. Focus: practical troubleshooting, dependency resolution, and project-specific optimizations.
Build system expertise for AmethystMultiplatform's 10-module KMP architecture (`amethyst`, `benchmark`, `quartz`, `geode`, `commons`, `quic`, `nestsClient`, `desktopApp`, `cli`, `quic-interop` — see `settings.gradle.kts`). Focus: practical troubleshooting, dependency resolution, and project-specific optimizations.
## Build Architecture Mental Model
Think of this project as **4 layers**:
The core app stack is **4 layers** (the other modules hang off it: `cli` and `geode` are JVM apps over `commons`/`quartz`, `nestsClient` sits on `quic`, `benchmark` and `quic-interop` are test harnesses):
```
┌─────────────┬─────────────┐
@@ -165,11 +165,11 @@ implementation(libs.jna)
**The problem:** Two Compose ecosystems (Multiplatform + AndroidX) must align, or duplicate classes.
**Current project config:**
**Current project config** (always re-check `gradle/libs.versions.toml` — these drift):
```toml
composeMultiplatform = "1.9.3" # Plugin + runtime
composeBom = "2025.12.01" # AndroidX Compose BOM
kotlin = "2.3.0"
composeMultiplatform = "1.11.0" # Plugin + runtime
composeBom = "2026.05.01" # AndroidX Compose BOM
kotlin = "2.3.21"
```
**Rule:** Compose Multiplatform version must be compatible with Kotlin version. Check: https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-compatibility-and-versioning.html
@@ -3,46 +3,50 @@
## Visual Hierarchy
```
┌─────────────────────────────────────────────────────────┐
│ Root Project │
(Amethyst)
└─────────────────────────────────────────────────────────┘
┌────────────────┼────────────────┐
│ │
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ :amethyst :desktopApp │ │ :benchmark
(Android) │ (JVM) │ │ (Android)
└─────────────┘ └─────────────┘ └─────────────┘
│ │
│ │
────────────────┼────────────────┘
─────────────┐
:commons
(KMP UI)
│ │
jvmAndroid
/ \
│ jvm android
└─────────────┘
┌─────────────┐
│ :quartz
│(KMP Library)│
│ │
│ commonMain │
│ │ │
│ jvmAndroid │
│ / | \ │
│jvm and ios │
└─────────────┘
Apps / harnesses Libraries
┌─────────────┐ ┌─────────────┐ ┌────────────┐
:amethyst │ │ :desktopApp │ │ :benchmark
│ (Android) │ │ (JVM) │ │ (Android) │
└──┬───┬───┬──┘ └──┬───────┬──┘ └─┬───────┬──┘
───────┼────┐ │ │ │
│ │
│ ┌────────────────┐ │ │ (androidTest │
│ │ :commons ◄┼──┼──only)
│ │ (KMP UI) │
└───────┬────────┘ │ │ │
──────────────┐ │ ┌─┴──┴─┐ ┌───────┐ │
│ :nestsClient │ │ :cli │ │:geode │
│ (KMP, MoQ) │ │ │(JVM) │ │(JVM │ │
────────────┘ │ └──┬───┘ │relay) │ │
│ │ └───┬───┘
│ │ │
┌────────┐
│ :quic
│ (KMP)
└───┬────┘ │ │ │ │
│ ▲ │ │ │ │ │
│ └── :quic-interop │
▼ ▼ ▼ ▼ ▼ ▼
┌──────────────────────────────┐
│ :quartz │
(KMP Library)
└──────────────────────────────┘
```
Verified edges (from each module's `build.gradle.kts`):
- `:amethyst``:quartz`, `:commons`, `:nestsClient`
- `:desktopApp``:quartz`, `:commons`
- `:benchmark``:quartz`, `:commons` (androidTest only)
- `:cli``:quartz`, `:commons`
- `:geode``:quartz` (api + testFixtures)
- `:nestsClient``:quartz` (api), `:quic`
- `:quic``:quartz` (api)
- `:quic-interop``:quic` (project dir: `quic/interop`)
## Module Details
### :quartz (KMP Nostr Library)
@@ -86,11 +90,41 @@
**Type:** Android Library
**Targets:** Android
**Dependencies:**
- Modules: `:commons`, `:quartz`
- Modules: `:commons`, `:quartz` (androidTest only)
- External: AndroidX Benchmark
**Role:** Performance benchmarking for Android builds
### :cli (Amy CLI)
**Type:** JVM Application (no Compose)
**Dependencies:** `:quartz`, `:commons`
**Role:** `amy`, the non-interactive command-line client; thin assembly layer, no new logic (see `amy-expert` skill)
### :geode (Relay Server)
**Type:** JVM Application (Ktor)
**Dependencies:** `:quartz` (api + testFixtures)
**Role:** Standalone Nostr relay built on quartz's relay-server code
### :quic (QUIC Transport)
**Type:** Kotlin Multiplatform Library
**Dependencies:** `:quartz` (api)
**Role:** Pure-Kotlin QUIC v1 + HTTP/3 + WebTransport client (no JNI); transport for MoQ
### :nestsClient (Audio Rooms)
**Type:** Kotlin Multiplatform Library
**Dependencies:** `:quartz` (api), `:quic`
**Role:** MoQ / moq-lite audio-room client for the NIP-53 nests feature
### :quic-interop (Interop Harness)
**Type:** JVM Application (project dir `quic/interop`)
**Dependencies:** `:quic`
**Role:** QUIC interop-runner test client
## Dependency Flow Patterns
### Desktop Build Chain
@@ -177,9 +211,9 @@ implementation(libs.jna) // JAR variant
implementation(compose.ui) // Compose Multiplatform BOM
implementation(compose.material3)
// Version catalog alignment
composeMultiplatform = "1.9.3"
composeBom = "2025.12.01" // AndroidX Compose
// Version catalog alignment (re-check libs.versions.toml — these drift)
composeMultiplatform = "1.11.0"
composeBom = "2026.05.01" // AndroidX Compose
```
**Why:** Two Compose ecosystems (Multiplatform + AndroidX) must align
+2 -3
View File
@@ -807,6 +807,5 @@ Passing lambda to function?
---
**Version:** 1.0.0
**Last Updated:** 2025-12-30
**Codebase Reference:** AmethystMultiplatform commit 258c4e011
**Version:** 1.0.1
**Last Updated:** 2026-06-10
+8 -2
View File
@@ -24,17 +24,23 @@ 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
── 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"
── KeyDataSourceSubscription.kt # "this set of keys drives this filter"
├── LifecycleAwareKeyDataSourceSubscription.kt
└── PrioritizedSubscriptionQueue.kt / SubscriptionPriority.kt
```
## Core Concept: `Subscribable<T>`