Refreshes .claude/ skill library: fixes stale refs, adds 4 new skills

- Updates CLAUDE.md tech stack to current versions (Compose 1.10.3, Kotlin 2.3.20).
- Reframes kotlin-multiplatform iOS as mature; adds secp256k1-kmp 0.23.0 references.
- Updates desktop-expert Main.kt references (code grew from ~270 to 1341 lines and
  NavigationRail moved to ui/deck/SinglePaneLayout.kt); replaces obsolete
  "hardcoded ctrl = true" anti-pattern note with accurate isMacOS branching.
- Removes compose-desktop.md (superseded by desktop-expert/).
- Adds nostr-expert references: nip19-bech32, event-factory, crypto-and-encryption,
  large-cache. Adds kotlin-expert/common-utilities, compose-expert/rich-text-parsing,
  android-expert/image-loading.
- New skills: account-state (Account + LocalCache), relay-client (subscriptions,
  filter assemblers, preloaders), feed-patterns (FeedFilter + FeedViewModel family),
  auth-signers (NostrSigner across internal / NIP-46 / NIP-55).
This commit is contained in:
Claude
2026-04-21 21:00:45 +00:00
parent 9147f1b08b
commit 60edd473c7
31 changed files with 1743 additions and 373 deletions
+102
View File
@@ -0,0 +1,102 @@
---
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.
---
# Account & Local Cache State
The backbone of Amethyst's client state: one `Account` per signed-in user, plus the singleton `LocalCache` that holds every `Note` and `User` the client has seen.
## When to Use This Skill
- Working on `amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt`
- Working on `amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt`
- Adding a new account-scoped setting (mutes, bookmarks, custom relay lists, private lists)
- Reading/writing user metadata (`User`) or note state (`Note`)
- Deciding whether to query `LocalCache` vs subscribe to an `Account` StateFlow
## Mental Model
```
Relay frame ──► LocalCache.insertOrUpdateNote() ──► LocalCacheFlow emits change
Account observes relevant kinds (3, 10002, 10000, …)
Account StateFlow updates (followList, relays, mutes, …)
ViewModels collect
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.
## 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.
### `LocalCache.kt`
- `object LocalCache : ILocalCache, ICacheProvider` — the singleton event store.
- Primary structures (all `LargeCache` — see `nostr-expert/references/large-cache.md`):
- `notes: LargeCache<HexKey, Note>` — every seen event (regular + addressable + replaceable) keyed by id or d-address.
- `users: LargeCache<HexKey, User>` — every seen pubkey, lazily populated.
- `addressables: LargeCache<Address, Note>` — secondary index for `kind:pubkey:d-tag` lookups.
- `channels`, `deletionIndex`, `hashtagIndex`, …
- `LocalCacheFlow` emits coarse-grained "something changed, recheck" signals. Fine-grained reactivity lives in `Account`'s per-kind StateFlows.
- Eviction is driven by `MemoryTrimmingService` (android service) under pressure.
### Model classes
- `User.kt` — mutable profile holder. Contains metadata, follow/follower counts, relay lists, liveset of notes authored.
- `Note.kt` — mutable note holder. Contains the underlying `Event`, replies, reactions, zaps. Mutation via `addReply`, `addReaction`, `addZap`, emitted on `Note.flowSet` flows.
- `Constants.kt` — DEFAULT_RELAYS, magic kinds/limits not covered by quartz.
## Adding a New Account-Scoped Setting
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/`.
## `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 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.
- **`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/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).
@@ -0,0 +1,93 @@
# Account StateFlow 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.
(Flow names are exact as of the current `Account.kt`; if a flow has been renamed, grep `Account.kt` for the old 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/` |
## Relays & Connectivity
| 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` |
## 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/` |
## Messaging
| 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/` |
## 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/` |
## Publishing Mutations
Every flow has a corresponding mutation method on `Account` that:
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.
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(...)`
## 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`).
@@ -0,0 +1,101 @@
# LocalCache: The Singleton Event Store
`amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt` is the singleton (`object LocalCache`) that holds every event the client has received during the session. All rendering, all feed building, all search goes through it.
## Shape
```kotlin
object LocalCache : ILocalCache, ICacheProvider {
val notes: LargeCache<HexKey, Note>
val users: LargeCache<HexKey, User>
val addressables: LargeCache<Address, Note>
val channels, deletionIndex, hashtagIndex,
}
```
All `LargeCache<K,V>` — see `nostr-expert/references/large-cache.md`. Thread-safe `getOrCreate`, functional scan with `forEach` / `filter` / `map`.
## Insertion Path
```
Relay frame (EVENT "sub-id" {...})
RelayPool / subscription manager calls parseNostrEvent(json)
EventFactory.create(kind, ...) → typed Event subclass
LocalCache.consume(event) / insertOrUpdateNote(event)
├─ notes.getOrCreate(id) { Note(id) } — finds/creates the Note wrapper
├─ updates note.event if this is a newer replaceable / first time for regular
├─ reindex: hashtag tags → hashtagIndex, addressable → addressables, deletions → deletionIndex
├─ for metadata: user.latestMetadata = event; user.liveMetadata.tryEmit(user)
└─ LocalCacheFlow signals listeners that something changed
```
`Note` and `User` are mutable wrappers — `getOrCreate` returns the same object across subsequent inserts for the same id/pubkey, which is why other code can `remember(noteId)` a `Note` reference and have it stay fresh.
## Lookup
```kotlin
// By id (regular or replaceable)
val note: Note = LocalCache.getOrCreateNote(id)
// By `kind:pubkey:d-tag`
val addressable: Note? = LocalCache.getAddressableNoteIfExists(address)
// By pubkey
val user: User = LocalCache.getOrCreateUser(pubKey)
// By hashtag
LocalCache.hashtagIndex.filter { _, notes -> ... }
```
All `getOrCreate*` functions are safe to call from any thread. They return immediately; they do NOT trigger network I/O.
## Eviction
Android-only. `amethyst/.../service/eventCache/MemoryTrimmingService.kt` listens for `ComponentCallbacks2.onTrimMemory` levels and drops least-recently-used entries from `notes` and `users`. On aggressive eviction, previously-returned `Note` / `User` references remain usable (they're just detached from the cache) but any new ids will produce new objects.
## Reactive Consumption
### Note-level
```kotlin
val note = LocalCache.getOrCreateNote(id)
val metadata by note.flowSet.metadata.collectAsState()
// `flowSet` has flows for: metadata, replies, reactions, zaps, reports, …
```
### Global
```kotlin
LocalCacheFlow.live.collectLatest {
// coarse "something changed" ping — used by feeds to re-run filters
}
```
For per-feature reactivity (follow list changed, relays changed), prefer `Account.<featureFlow>` over `LocalCacheFlow`.
## Deletion / Replacement
- **Regular events**: once inserted, the first event wins unless explicitly deleted via a kind-5 deletion. `deletionIndex` tracks ids to hide.
- **Replaceable** (kinds 0, 3, 10000-19999): a newer `created_at` replaces the older event in-place on the same `Note` wrapper.
- **Addressable** (kinds 30000-39999): same as replaceable but keyed by `kind:pubkey:d-tag` in the `addressables` index.
## Gotchas
- **Don't hold a direct `Event` reference** — hold the `Note` wrapper. The `Note.event` field can be replaced by newer replaceable/addressable events behind your back.
- **`LocalCache` is process-global**. Tests must either use a dedicated test fixture or reset it between cases.
- **No TTL beyond memory pressure.** A long-running session accumulates. If you need bounded retention, do it at the feed / filter layer.
- **Scanning the full cache is expensive** in hot paths. Always prefer an index (hashtag, addressable) or a pre-built feed filter.
- **Eviction is not atomic with in-flight coroutines**. If you `forEach` during low-memory, you may see concurrent removals — that's fine, the snapshot semantics in `LargeCache` keep it safe, but your result set shrinks.
## Related
- `nostr-expert/references/large-cache.md` — the underlying cache primitive.
- `nostr-expert/references/event-factory.md` — how raw JSON becomes the typed `Event` that `LocalCache` stores.
- `feed-patterns` skill — how feeds scan and observe `LocalCache` efficiently.
+1
View File
@@ -1055,6 +1055,7 @@ fun testPermissionRequest() {
- `references/android-navigation.md` - Complete navigation patterns and examples
- `references/android-permissions.md` - Permission handling patterns
- `references/proguard-rules.md` - Proguard configuration
- `references/image-loading.md` - Coil 3.x setup, custom fetchers (Blossom/Base64/BlurHash/ThumbHash), `MyAsyncImage`, `RobohashAsyncImage`
- `scripts/analyze-apk-size.sh` - APK size optimization script
## When NOT to Use
@@ -0,0 +1,60 @@
# Image Loading
Amethyst uses **Coil 3.x (KMP)** for async image loading on both Android and Desktop. Coil is configured once at app startup with custom fetchers, decoders, and an OkHttp/Ktor network layer wired to Tor/proxy settings.
## Android Setup
Under `amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/`:
- **`ImageLoaderSetup.kt`** — `class ImageLoaderSetup` is the entry point. Called from `Amethyst.onCreate()`. Installs a global Coil `ImageLoader` with custom fetchers/decoders and a shared `OkHttpClient` (via `OkHttpFactory`).
- **`ImageCacheFactory.kt`** — builds the disk + memory cache backing the loader. Size-bounded; cleared on memory pressure.
- **`ThumbnailDiskCache.kt`** — separate on-disk cache for generated thumbnails.
- **`Base64Fetcher.kt`** — resolves `data:image/...;base64,...` URLs into bitmaps inline.
- **`BlossomFetcher.kt`** — fetches blossom-hosted media using authenticated requests (NIP-96 / blossom auth event).
- **`BlurHashFetcher.kt`** / **`ThumbHashFetcher.kt`** — synthesize placeholders from `blurhash` / `thumbhash` in NIP-92 `imeta` tags while the real image loads.
- **`ProfilePictureFetcher.kt`** — special-case fetcher that falls back to a robohash when a profile has no `picture`.
- **`MyDebugLogger`** (inside `ImageLoaderSetup.kt`) — surfaces loader errors to logcat in debug builds.
## Desktop Setup
Mirror under `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/`:
- **`DesktopImageLoaderSetup.kt`** — same shape as Android, but uses Skia decoders and a JVM-native OkHttp client.
- **`DesktopBase64Fetcher.kt`**, **`DesktopBlurHashFetcher.kt`**, **`DesktopThumbHashFetcher.kt`**, **`SkiaGifDecoder.kt`** — Skia / JVM equivalents of the Android fetchers.
- Called from `desktopApp/.../desktop/Main.kt` (`fun main()` at L172) via `DesktopImageLoaderSetup.setup()` before `application { }`.
## Composable Entry Points (Android)
- **`amethyst/.../ui/components/MyAsyncImage.kt`** — `@Composable fun MyAsyncImage(...)`. Wraps Coil's `AsyncImage` with the project's error fallbacks, blurhash placeholders, and content-description defaults.
- **`amethyst/.../ui/components/RobohashAsyncImage.kt`** — auto-generates a deterministic robohash avatar from a pubkey when no profile picture is set.
- **`amethyst/.../ui/components/ImageGallery.kt`** — paged/zoomable gallery for notes with multiple images.
Desktop has parallel composables under `desktopApp/.../ui/` — they consume the shared Coil `ImageLoader` configured by `DesktopImageLoaderSetup`.
## Typical Reuse
```kotlin
MyAsyncImage(
model = url,
contentDescription = description,
modifier = Modifier.size(48.dp).clip(CircleShape),
placeholderBlurHash = imeta?.blurhash, // NIP-92 placeholder
loading = { /* shimmer */ },
error = { /* broken image */ },
)
```
For profile pictures, prefer `RobohashAsyncImage` so users without a `picture` field still get a stable avatar.
## Gotchas
- **Never call `ImageLoader.Builder` in a composable** — build once in `ImageLoaderSetup` and rely on `SingletonImageLoader`. Otherwise you shred the cache and blow up memory.
- **Blossom/encrypted media must go through `BlossomFetcher`** — using the default HTTP fetcher returns ciphertext that decoders reject.
- **Debug logcat noise**: `MyDebugLogger` is on in debug builds only; do not enable in release.
- **`OkHttpFactory` feeds the loader** — if you replace the HTTP client (e.g. to route through Tor), update the factory, not the loader setup, or Tor routing silently bypasses images.
- **NIP-92 `imeta` metadata flows in from the event, not the URL.** See `compose-expert/references/rich-text-parsing.md` for how `MediaUrlImage`/`MediaUrlVideo` carry the blurhash and dimensions into the composable.
## Related
- `compose-expert/references/rich-text-parsing.md` — how `MediaContentModels` feed composables.
- `gradle-expert/references/version-catalog-guide.md` — Coil version alignment (`coil = 3.4.0` in `libs.versions.toml`).
+104
View File
@@ -0,0 +1,104 @@
---
name: auth-signers
description: Signer abstraction patterns in Amethyst. Use when working with event signing, choosing between a local keypair (`NostrSignerInternal`), a remote NIP-46 bunker signer (`NostrSignerRemote`), or a NIP-55 Android external-app signer (`NostrSignerExternal`). Covers the abstract `NostrSigner` base class, `SignerResult` contract, how to wire a new flow that needs to sign events, and the security/UX trade-offs between signer kinds.
---
# Auth & Signers
Any time Amethyst produces a signed Nostr event, it goes through a `NostrSigner`. There are three kinds; all three implement the same abstract contract so feature code doesn't care which one the user has configured.
## When to Use This Skill
- Adding a new flow that publishes an event (follow, post, react, zap, profile edit).
- Reviewing whether a feature works when the user has a remote bunker signer or an external Android signer.
- Debugging "Sign request approved but nothing happens" / timeouts on sign operations.
- Onboarding a new signer kind (hardware signer, browser extension, etc.).
- Understanding the NIP-46 bunker request/response taxonomy.
## The Abstract Contract
`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSigner.kt`:
```kotlin
abstract class NostrSigner(val pubKey: HexKey) {
abstract fun <T : Event> sign(
template: EventTemplate<T>,
onReady: (T) -> Unit,
)
abstract fun nip04Encrypt(plaintext: String, toPubKey: HexKey, onReady: (String) -> Unit)
abstract fun nip04Decrypt(ciphertext: String, fromPubKey: HexKey, onReady: (String) -> Unit)
abstract fun nip44Encrypt(...)
abstract fun nip44Decrypt(...)
abstract fun decryptZapEvent(event: LnZapRequestEvent, onReady: (LnZapRequestEvent) -> Unit)
}
```
Sibling files in the same folder:
- **`NostrSignerInternal.kt`** — in-process signer with the user's seckey in memory. Fastest; used for locally-stored accounts.
- **`NostrSignerSync.kt`** — blocking wrapper for scripts / migrations / tests where callbacks are inconvenient.
- **`EventTemplate.kt`** — the unsigned holder passed to `sign()`.
- **`SignerExceptions.kt`** — the error taxonomy (user denied, timeout, unsupported method, etc.).
- **`caches/`** — request cache so duplicate sign/encrypt requests coalesce.
### Concrete implementations
- **Local (in-process)**: `NostrSignerInternal` — direct `Secp256k1Instance.signSchnorr` + NIP-44 inline. Used by accounts created/imported into Amethyst.
- **Remote (NIP-46 bunker)**: `quartz/.../nip46RemoteSigner/signer/NostrSignerRemote.kt`. Talks to a bunker service over Nostr DMs using the `BunkerRequest*` / `BunkerResponse*` event taxonomy (`BunkerRequestConnect`, `BunkerRequestSign`, `BunkerRequestNip44Encrypt`, …).
- **Android external (NIP-55)**: `quartz/src/androidMain/.../nip55AndroidSigner/client/NostrSignerExternal.kt`. Uses Android intents + content provider to delegate to another app on the same device. Launcher: `ExternalSignerLogin.kt`, `IActivityLauncher.kt`. Install-check: `IsExternalSignerInstalled.kt`.
## The `SignerResult` Contract
Signers return via callback (and internally track via `SignerResult` sealed types in `nip46RemoteSigner/signer/SignerResult.kt` and `nip55AndroidSigner/api/SignerResult.kt`). Result variants cover success, user-denied, timeout, remote-disconnected, unsupported. Feature code should:
1. Pass a callback that handles success.
2. Trust the cache/timeout behavior — don't roll your own retry.
3. Surface `SignerExceptions` to the user with actionable messaging (e.g. "Bunker disconnected — reconnect?").
## Typical Flow (Feature Code)
```kotlin
// High-level: Account methods already do this internally.
val signer: NostrSigner = account.signer // whichever kind the user configured
val template = reactionEventTemplate(noteId, authorPubKey, "+")
signer.sign(template) { signed ->
account.sendToRelays(signed) // or similar pipeline
}
```
Most feature code should go through `Account`'s mutation methods (`account.sendReaction`, `account.follow`) rather than touching the signer directly — the account layer handles signing + publishing + local state update atomically. Reach for the signer directly only when `Account` doesn't have a helper.
## Choosing a Signer at Sign-Up
Entry points:
- **Existing private key** (`nsec`, 32-byte hex, file) → `NostrSignerInternal`.
- **Bunker URL** (`bunker://...`) → `RemoteSignerManager.connect(url)` in `nip46RemoteSigner/signer/RemoteSignerManager.kt` returns a `NostrSignerRemote`.
- **Installed external signer app** (Amber, nos2x, etc. on Android) → `ExternalSignerLogin.launch(...)` opens the signer app; approval yields a `NostrSignerExternal`.
The UI hosts both flows via `amethyst/.../ui/screen/loggedOff/login/` — look there for `ExternalSignerButton.kt` and the bunker-URL paste screen.
## Trade-offs
| Signer | Latency | Offline OK? | Security | UX |
|--------|---------|-------------|----------|-----|
| Internal | µs | Yes | Key in app memory | No confirmation prompts |
| Remote (NIP-46) | 100msseconds | No (needs bunker reachable) | Key never touches Amethyst | Occasional approval prompts |
| External (NIP-55) | 100500ms | Yes | Key in separate app | Prompt on every sign by default (configurable) |
## Gotchas
- **Callbacks may never fire.** External signers can be dismissed without result; remote signers can time out. Use `SignerExceptions` / timeout handling at every call site or rely on the `Account` layer's wrapping.
- **`nip04Encrypt` is legacy** for NIP-04 DMs. New DM code should use NIP-17 gift-wrap → `nip44Encrypt` path.
- **Don't cache signer output** beyond the `caches/` that quartz already maintains. Stale cache entries lead to duplicate publishes.
- **Remote signer disconnects** need explicit reconnection UX — `RemoteSignerManager` exposes state; hook into it for an account-switching warning.
- **External signer launch requires an Activity context** — it can't happen from a background service. Structure flows so signing is on the main dispatcher through an activity-scoped launcher.
- **`NostrSignerSync`** is rare. If you reach for it, you're probably in a test or migration — production code uses the async API.
## References
- `references/nip46-remote-signer.md` — the NIP-46 bunker message taxonomy and connection lifecycle.
- `references/nip55-android-signer.md` — Android intent-based external signer flow.
- Complements: `nostr-expert/references/crypto-and-encryption.md` (the crypto under all signers), `account-state` (which wraps signer calls), `android-expert` (intent launcher patterns).
@@ -0,0 +1,109 @@
# NIP-46 Remote Signer (Bunker)
Remote signers are a different Nostr client (the "bunker") that holds the private key and signs on request over Nostr DMs. Amethyst connects to a bunker via a `bunker://` URL and proxies every signing / encryption operation as a request/response over kind 24133 events.
## Layout
`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/`:
```
nip46RemoteSigner/
├── BunkerMessage.kt ── common wrapper
├── BunkerRequest.kt ── sealed base for requests
├── BunkerRequestConnect.kt
├── BunkerRequestGetPublicKey.kt
├── BunkerRequestGetRelays.kt
├── BunkerRequestNip04Decrypt.kt
├── BunkerRequestNip04Encrypt.kt
├── BunkerRequestNip44Decrypt.kt
├── BunkerRequestNip44Encrypt.kt
├── BunkerRequestPing.kt
├── BunkerRequestSign.kt
├── BunkerResponse.kt ── sealed base for responses
├── BunkerResponseAck.kt
├── BunkerResponseDecrypt.kt
├── BunkerResponseEncrypt.kt
├── BunkerResponseError.kt
├── BunkerResponseEvent.kt
├── BunkerResponseGetRelays.kt
├── BunkerResponsePong.kt
├── BunkerResponsePublicKey.kt
├── NostrConnectEvent.kt ── kind 24133 payload
├── kotlinSerialization/ ── JSON codecs for each request/response
└── signer/
├── NostrSignerRemote.kt ── the NostrSigner impl
├── RemoteSignerManager.kt ── connection lifecycle
├── ConnectResponse.kt
├── Nip04DecryptResponse.kt
├── Nip04EncryptResponse.kt
├── Nip44DecryptResponse.kt
├── Nip44EncryptResponse.kt
├── PingResponse.kt
├── PubKeyResponse.kt
├── SignerResult.kt
└── SignResponse.kt
```
## Connection Flow
```
User pastes bunker://<bunker-pubkey>?relay=wss://…&secret=…
RemoteSignerManager.connect(url) generates a client keypair
Publish BunkerRequestConnect (kind 24133, encrypted) to relay
Wait for BunkerResponseAck or BunkerResponseError
▼ on ack
Return a NostrSignerRemote(clientKeys, bunkerPubKey, relays)
```
The client keypair is **not** the user's Nostr identity — it's a session key used to correspond with the bunker. The bunker controls the real signing key. `RemoteSignerManager` persists session state so reconnecting skips the handshake.
## Request / Response Pattern
Every signing or encryption call is an async round-trip:
```
Amethyst Bunker
│ │
│── BunkerRequestSign(template) ──►│
│ │ user approves (sometimes)
│◄── BunkerResponseEvent(signed) ──│
│ │
```
`NostrSignerRemote.sign(template, onReady)` serializes the `BunkerRequestSign`, encrypts it to the bunker's pubkey, publishes it, and waits (with a timeout) for a `BunkerResponseEvent` carrying the signed event. The response goes through a request-id correlation map so concurrent signs don't interleave.
## Supported Requests
- `BunkerRequestConnect` / `BunkerRequestPing` — lifecycle.
- `BunkerRequestGetPublicKey` — verify what pubkey this session controls.
- `BunkerRequestGetRelays` — discover the bunker's preferred inbox relays.
- `BunkerRequestSign` — the main path.
- `BunkerRequestNip04Encrypt`/`Decrypt` — legacy DMs.
- `BunkerRequestNip44Encrypt`/`Decrypt` — NIP-44 payloads (gift-wrap, modern DMs).
Each has a matching response with the same correlation id.
## Timeouts & Disconnects
- **Timeout**: default in `NostrSignerRemote`. Expired requests surface as `SignerExceptions.TimedOut` (or equivalent). Treat as "maybe the user will approve later but UI has given up" — don't auto-retry.
- **Relay disconnect**: `RemoteSignerManager` reconnects transparently when the bunker's relay reappears; in-flight requests may still time out.
- **Bunker revoke**: if the bunker closes the session, next sign attempt returns `BunkerResponseError`; prompt the user to reconnect.
## Gotchas
- **The session key is not the user key.** Logs and UI should never surface the session pubkey as "your pubkey".
- **Don't assume a sign is fast** — UX should show a spinner and allow cancellation for 10+ seconds.
- **Relay hints from `BunkerRequestGetRelays`** can change the relay list mid-session; the session manager handles it, but re-reads of `relays` in feature code may be stale.
- **NIP-46 over Nostr is the only transport here** — there's no HTTP/WS shortcut. Any feature gating on "can this sign" must check relay reachability.
## Related
- `nip55-android-signer.md` — the other remote-ish signer (but local to the device).
- `nostr-expert/references/crypto-and-encryption.md` — NIP-44 details (used by request/response encryption).
@@ -0,0 +1,87 @@
# NIP-55 Android External Signer
NIP-55 lets the user delegate signing to another Android app (Amber, nos2x-fox, etc.) that holds the private key. Communication is via `Intent`s and a `ContentProvider`, not Nostr itself.
## Layout
Android-only, under `quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/`:
```
nip55AndroidSigner/
├── JsonMapperNip55.kt ── JSON layer for intent extras
├── SignString.kt ── canonical string to sign for login challenges
├── api/
│ ├── CommandType.kt ── sign_event / nip04_encrypt / nip44_encrypt / …
│ ├── SignerResult.kt ── sealed result sent back by launcher callback
│ ├── background/ ── "background" signer path via ContentProvider (no UI)
│ ├── foreground/ ── "foreground" signer path via Activity + Intent
│ └── permission/ ── permission grant / revoke helpers
└── client/
├── ExternalSignerLogin.kt ── one-shot login / bootstrap intent
├── IActivityLauncher.kt ── abstraction over Activity + ActivityResultLauncher
├── IsExternalSignerInstalled.kt ── query PM for compatible signers
├── NostrSignerExternal.kt ── the NostrSigner impl
└── handlers/ ── per-command result handlers
```
Amethyst's Android app uses `ExternalSignerButton` (`amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/ExternalSignerButton.kt`) as the sign-up entry point.
## Two Transport Modes
### Foreground (Activity + Intent)
- Launches an `Intent` with `ACTION_VIEW` and data URI `nostrsigner:<payload>`.
- The signer app opens, shows a UI prompt, returns via `onActivityResult`.
- **Always works**, but requires user interaction each call unless the user has "always allow" granted.
- Lives in `api/foreground/` and `client/handlers/`.
### Background (ContentProvider)
- Queries the signer's `ContentProvider` with `content://<signer-auth>/sign_event?...`.
- No UI interaction — signer either silently approves (if pre-authorized) or denies.
- Requires the user to have granted "always allow" permission beforehand via the foreground flow.
- Lives in `api/background/` and `api/permission/`.
- Falls back to foreground when background denies.
`NostrSignerExternal` picks the path automatically: try background if pre-authorized, else foreground. See `client/handlers/` for the per-command dispatch.
## Command Types
`api/CommandType.kt` enumerates what the external signer supports:
- `sign_event` — sign a Nostr event.
- `nip04_encrypt` / `nip04_decrypt` — legacy DMs.
- `nip44_encrypt` / `nip44_decrypt` — NIP-44 payloads (gift-wrap).
- `get_public_key` — identity check.
- `decrypt_zap_event` — LN zap request decoding.
- `connect` — bootstrap / permissions.
Commands map 1:1 to `NostrSigner` abstract methods.
## Installation Check
Before showing the "Use external signer" button, `IsExternalSignerInstalled.kt` queries the Android PackageManager for intent filters matching `nostrsigner:` URIs. If no compatible app is installed, hide the button (the UI already does this).
## Permission Flow
1. User taps "Use external signer" → `ExternalSignerLogin.launch(activityLauncher)`.
2. Amethyst fires an intent asking the signer for the user's pubkey.
3. Signer app opens, user approves, returns via `onActivityResult`.
4. `NostrSignerExternal` is created with that pubkey and the package name of the approved signer.
5. On subsequent sign requests, Amethyst tries background (via `ContentProvider`); if not granted, falls back to foreground intent.
`api/permission/` has helpers to pre-grant / revoke permissions through the signer's dedicated permission URI.
## Gotchas
- **Activity context required.** All launch paths need an `Activity`, not just a `Context`. Design flows so the launcher is available when sign is called — if a background service needs to sign, it must defer until the app is foregrounded, or show a notification.
- **Foreground loop.** Without "always allow", every single event sign = one Activity round-trip. That's bad UX for reactions / zaps. Push users toward granting always-allow.
- **Multiple signer apps installed.** `IActivityLauncher` honors the one the user first approved, persisted in `AccountSyncedSettings`. Changing signers requires explicit re-login.
- **KMP boundary.** `NostrSignerExternal` is strictly Android; Desktop uses `NostrSignerInternal` or `NostrSignerRemote` (NIP-46 bunker). Don't pretend there's a portable external-signer layer.
- **Test coverage.** Signer Android flows have instrumented tests in `quartz/src/androidDeviceTest/kotlin/.../nip55AndroidSigner/` — device or emulator only.
## Related
- `nip46-remote-signer.md` — the other delegated-signing path (works on all platforms).
- `android-expert/references/android-permissions.md` — Android permission mechanics.
- `android-expert/SKILL.md` — intent / activity-result launcher patterns.
-339
View File
@@ -1,339 +0,0 @@
# Compose Desktop Skill
## Desktop Application Entry Point
```kotlin
// desktopApp/src/jvmMain/kotlin/Main.kt
package com.vitorpamplona.amethyst.desktop
import androidx.compose.ui.Alignment
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.*
fun main() = application {
val windowState = rememberWindowState(
width = 1200.dp,
height = 800.dp,
position = WindowPosition.Aligned(Alignment.Center)
)
// System tray
Tray(
icon = painterResource("icon.png"),
tooltip = "Amethyst",
menu = {
Item("Show", onClick = { windowState.isMinimized = false })
Separator()
Item("Exit", onClick = ::exitApplication)
}
)
Window(
onCloseRequest = ::exitApplication,
state = windowState,
title = "Amethyst",
icon = painterResource("icon.png")
) {
MenuBar {
Menu("File") {
Item("New Note", shortcut = KeyShortcut(Key.N, ctrl = true)) { }
Separator()
Item("Settings", shortcut = KeyShortcut(Key.Comma, ctrl = true)) { }
Separator()
Item("Quit", shortcut = KeyShortcut(Key.Q, ctrl = true), onClick = ::exitApplication)
}
Menu("Edit") {
Item("Copy", shortcut = KeyShortcut(Key.C, ctrl = true)) { }
Item("Paste", shortcut = KeyShortcut(Key.V, ctrl = true)) { }
}
Menu("View") {
Item("Feed") { }
Item("Messages") { }
Item("Notifications") { }
}
Menu("Help") {
Item("About Amethyst") { }
}
}
App()
}
}
```
## Desktop-Specific Components
### File Dialog
```kotlin
@Composable
fun rememberFileDialog(): FileDialogState {
return remember { FileDialogState() }
}
class FileDialogState {
var isOpen by mutableStateOf(false)
var result by mutableStateOf<File?>(null)
fun open() { isOpen = true }
@Composable
fun Dialog(
title: String = "Select File",
allowedExtensions: List<String> = emptyList()
) {
if (isOpen) {
DisposableEffect(Unit) {
val dialog = java.awt.FileDialog(null as java.awt.Frame?, title)
if (allowedExtensions.isNotEmpty()) {
dialog.setFilenameFilter { _, name ->
allowedExtensions.any { name.endsWith(it) }
}
}
dialog.isVisible = true
result = dialog.file?.let { File(dialog.directory, it) }
isOpen = false
onDispose { }
}
}
}
}
```
### Scroll Behavior
```kotlin
@Composable
fun DesktopScrollableColumn(
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit
) {
val scrollState = rememberScrollState()
Box(modifier) {
Column(
modifier = Modifier
.verticalScroll(scrollState)
.fillMaxSize()
) {
content()
}
VerticalScrollbar(
modifier = Modifier.align(Alignment.CenterEnd),
adapter = rememberScrollbarAdapter(scrollState)
)
}
}
```
### Keyboard Navigation
```kotlin
@Composable
fun KeyboardNavigableList(
items: List<Note>,
selectedIndex: Int,
onSelect: (Int) -> Unit,
onActivate: (Note) -> Unit
) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
LazyColumn(
modifier = Modifier
.focusRequester(focusRequester)
.focusable()
.onKeyEvent { event ->
when {
event.key == Key.DirectionDown && event.type == KeyEventType.KeyDown -> {
onSelect((selectedIndex + 1).coerceAtMost(items.lastIndex))
true
}
event.key == Key.DirectionUp && event.type == KeyEventType.KeyDown -> {
onSelect((selectedIndex - 1).coerceAtLeast(0))
true
}
event.key == Key.Enter && event.type == KeyEventType.KeyDown -> {
items.getOrNull(selectedIndex)?.let { onActivate(it) }
true
}
else -> false
}
}
) {
itemsIndexed(items) { index, note ->
NoteCard(
note = note,
isSelected = index == selectedIndex,
modifier = Modifier.clickable { onSelect(index) }
)
}
}
}
```
### Multi-Window Support
```kotlin
@Composable
fun ApplicationScope.NoteDetailWindow(
note: Note,
onClose: () -> Unit
) {
Window(
onCloseRequest = onClose,
title = "Note by ${note.author.name}",
state = rememberWindowState(width = 600.dp, height = 400.dp)
) {
NoteDetailScreen(note)
}
}
// Usage in main application
var openNotes by remember { mutableStateOf<List<Note>>(emptyList()) }
openNotes.forEach { note ->
key(note.id) {
NoteDetailWindow(
note = note,
onClose = { openNotes = openNotes - note }
)
}
}
```
### Tooltips
```kotlin
@Composable
fun TooltipButton(
tooltip: String,
onClick: () -> Unit,
content: @Composable () -> Unit
) {
TooltipArea(
tooltip = {
Surface(
shape = RoundedCornerShape(4.dp),
color = MaterialTheme.colorScheme.inverseSurface
) {
Text(
text = tooltip,
modifier = Modifier.padding(8.dp),
color = MaterialTheme.colorScheme.inverseOnSurface
)
}
}
) {
IconButton(onClick = onClick) {
content()
}
}
}
```
## Desktop Layout Pattern
```kotlin
@Composable
fun DesktopAppLayout(
currentScreen: Screen,
onNavigate: (Screen) -> Unit,
content: @Composable () -> Unit
) {
Row(Modifier.fillMaxSize()) {
// Sidebar navigation
NavigationRail(
modifier = Modifier.width(72.dp),
containerColor = MaterialTheme.colorScheme.surfaceVariant
) {
Spacer(Modifier.height(16.dp))
NavigationRailItem(
icon = { Icon(Icons.Default.Home, "Feed") },
label = { Text("Feed") },
selected = currentScreen == Screen.Feed,
onClick = { onNavigate(Screen.Feed) }
)
NavigationRailItem(
icon = { Icon(Icons.Default.Email, "Messages") },
label = { Text("DMs") },
selected = currentScreen == Screen.Messages,
onClick = { onNavigate(Screen.Messages) }
)
NavigationRailItem(
icon = { Icon(Icons.Default.Notifications, "Notifications") },
label = { Text("Alerts") },
selected = currentScreen == Screen.Notifications,
onClick = { onNavigate(Screen.Notifications) }
)
Spacer(Modifier.weight(1f))
NavigationRailItem(
icon = { Icon(Icons.Default.Settings, "Settings") },
label = { Text("Settings") },
selected = currentScreen == Screen.Settings,
onClick = { onNavigate(Screen.Settings) }
)
}
// Divider
VerticalDivider()
// Main content
Box(Modifier.weight(1f).fillMaxHeight()) {
content()
}
}
}
```
## Build Configuration
```kotlin
// desktopApp/build.gradle.kts
plugins {
kotlin("jvm")
id("org.jetbrains.compose")
}
dependencies {
implementation(compose.desktop.currentOs)
implementation(compose.material3)
implementation(project(":quartz"))
}
compose.desktop {
application {
mainClass = "com.vitorpamplona.amethyst.desktop.MainKt"
nativeDistributions {
targetFormats(
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Dmg,
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Msi,
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Deb
)
packageName = "Amethyst"
packageVersion = "1.0.0"
macOS {
bundleID = "com.vitorpamplona.amethyst.desktop"
iconFile.set(project.file("icons/icon.icns"))
}
windows {
iconFile.set(project.file("icons/icon.ico"))
menuGroup = "Amethyst"
}
linux {
iconFile.set(project.file("icons/icon.png"))
}
}
}
}
```
+1
View File
@@ -520,6 +520,7 @@ fun FeedList(items: List<Item>) {
- **references/shared-composables-catalog.md** - Complete catalog of shared UI components
- **references/state-patterns.md** - State management patterns with visual examples
- **references/icon-assets.md** - Custom ImageVector icon patterns
- **references/rich-text-parsing.md** - `RichTextParser`, `UrlParser`, `GalleryParser`, `Patterns`, `MediaContentModels`; NIP-92 imeta enrichment
- **scripts/find-composables.sh** - Find all @Composable functions in codebase
## Quick Reference
@@ -0,0 +1,64 @@
# Rich Text Parsing
Amethyst converts raw event content (plain text with URLs, mentions, hashtags, media links, nostr references, markdown) into structured segments that Compose can render. Everything lives under `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/`.
## Files
- **`RichTextParser.kt`** — the main entry point. A `class RichTextParser` that takes a note's content, the URL preview cache, and NIP-92 `imeta` tags and returns a `RichTextViewState`.
- **`RichTextParserSegments.kt`** — segment data classes (hashtag, url, mention, invoice, etc.) that the parser emits.
- **`Patterns.kt`** — the regex bank. Single source of truth for URL, hashtag, mention, email, invoice, cashu, nostr-URI patterns. Prefer adding a case here to writing a one-off regex at a call site.
- **`UrlParser.kt`** — URL extraction + validation; used to pull URLs out of free-form text before the parser classifies them.
- **`GalleryParser.kt`** — builds `MediaGallery` groupings from consecutive media URLs in a note.
- **`MediaContentModels.kt`** — the rendering contracts:
- `MediaUrlImage`, `MediaUrlVideo` — plain HTTP(S) media with optional NIP-92 metadata.
- `EncryptedMediaUrlImage`, `EncryptedMediaUrlVideo` — for encrypted/blossom-gated media.
- `MediaLocalImage`, `MediaLocalVideo` — for drafts / not-yet-uploaded media.
- **`Base64Image.kt`** — inline base64 data URI support.
- **`ExpandableTextCutOffCalculator.kt`** — decides where to truncate long content for "Show more" fold points.
## How a Note Becomes Rendered UI
1. Raw `content: String` arrives (from an `Event`).
2. `RichTextParser` scans with patterns, extracts URLs, nostr IDs, hashtags, mentions, invoices, cashu tokens.
3. URLs are classified against `imeta` tags (NIP-92) so media gets correct dimensions, mime type, blurhash.
4. `GalleryParser` groups adjacent media into a single `MediaGallery` segment.
5. The composable layer (elsewhere in `commons/compose/` and amethyst/desktop UI) walks the segment list and renders each with the appropriate composable (`RenderMarkdown`, `NoteQuoteBody`, `ClickableUrl`, etc.).
## Typical Reuse
```kotlin
// inside a composable
val state = remember(note, imetaTags) {
CachedRichTextParser.parseReturningNullable(content, imetaTags, callbackUri)
}
state?.paragraphs?.forEach { paragraph ->
paragraph.words.forEach { segment ->
when (segment) {
is UrlSegment -> ClickableUrl(segment)
is HashtagSegment -> HashtagChip(segment)
is NostrRefSegment -> NoteCompose(segment.entity)
is ImageSegment -> ZoomableMedia(segment.media)
// ...
}
}
}
```
On Android there's `amethyst/.../service/CachedRichTextParser.kt` which caches parser output per content — re-parsing the same note on every recomposition is expensive, so **always parse behind a cache**.
## NIP-92 imeta Enrichment
`imeta` tags attached to an event carry structured metadata for each media URL: `url`, `m` (mime), `dim`, `blurhash`, `x` (sha256), `size`. `RichTextParser` maps these into `MediaUrlImage` / `MediaUrlVideo` so the renderer can reserve correct aspect ratio and show a blurhash placeholder before the image loads. Reference: `nip-catalog.md`.
## Gotchas
- **Don't parse on every recomposition.** Use `CachedRichTextParser` (Android) or `remember(content, imeta) { … }` for commonMain.
- **Regexes live in `Patterns.kt`.** If you're writing a new regex for URLs/mentions/hashtags in a UI file, move it to `Patterns.kt` instead.
- **Segments are `@Immutable`** data classes — safe to pass to Compose without triggering recomposition spam.
- **Encrypted media is a separate class** (`EncryptedMediaUrl*`). If you handle `MediaUrlImage` but not its encrypted sibling, blossom/NIP-17 gated media silently falls through.
- **`GalleryParser` groups** across whitespace-only-lines between URLs. Changing its grouping rules breaks layout in many note screens.
## Related
- `nostr-expert/references/nip-catalog.md` — NIP-92 (imeta) spec location
- `compose-expert/references/shared-composables-catalog.md` — which composables consume which segment types
+20 -14
View File
@@ -69,7 +69,7 @@ fun main() = application {
- `rememberWindowState()` manages size/position
- `onCloseRequest` handles window close
**See:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt:87-138`
**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.
---
@@ -144,7 +144,7 @@ Window(onCloseRequest = ::exitApplication, title = "App") {
### Keyboard Shortcuts (OS-Aware)
**Current issue:** Main.kt hardcodes `ctrl = true` (Main.kt:105, 111, 117, 122, 123).
**Current state:** `Main.kt` already branches on `isMacOS` (declared at L120) for every menu shortcut — `if (isMacOS) { KeyShortcut(..., meta = true) } else { KeyShortcut(..., ctrl = true) }` (see L239, L249, L286, L313, L325, L335, L347, L358, L374, L384, L400, L416, L449). When adding a new shortcut, follow the same branching pattern rather than hardcoding `ctrl = true`.
**OS-specific shortcuts:**
@@ -275,7 +275,7 @@ Row(Modifier.fillMaxSize()) {
}
```
**See:** Main.kt:191-264
**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.
**Why NavigationRail?**
- Desktop has horizontal space (1200+ dp width)
@@ -504,9 +504,10 @@ desktopApp/
```
**Key files:**
- `Main.kt:87-138` - `application {}`, `Window`, `MenuBar`
- `Main.kt:183-264` - NavigationRail pattern
- `build.gradle.kts:45-73` - Desktop packaging config
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt``fun main()` L172, `application {` L186, `Window` L229, `MenuBar` L234 (OS-aware shortcuts begin at L239)
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt` NavigationRail at L97
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/` DeckLayout, WorkspaceManager, DeckState (multi-pane)
- `desktopApp/build.gradle.kts` — desktop packaging config (DMG/MSI/DEB)
---
@@ -650,10 +651,11 @@ fun FeedScreen() {
- `references/os-detection.md` - Platform detection patterns
### Codebase Examples
- Main.kt:87-138 - Window, MenuBar entry point
- Main.kt:183-264 - NavigationRail pattern
- FeedScreen.kt:49-136 - Desktop screen layout
- LoginScreen.kt:44-97 - Centered desktop login
- `Main.kt` Window + MenuBar entry point (`application` L186, `Window` L229, `MenuBar` L234)
- `ui/deck/SinglePaneLayout.kt` NavigationRail at L97
- `ui/deck/DeckLayout.kt` / `WorkspaceManager.kt` — multi-pane workspace
- `ui/feed/` — Desktop feed screens
- `ui/login/` — Centered desktop login
---
@@ -685,13 +687,17 @@ When working on desktop features:
**Hardcoding Ctrl everywhere**
```kotlin
// Main.kt:105 - Current issue
// Do NOT do this in a new shortcut:
shortcut = KeyShortcut(Key.N, ctrl = true) // Wrong on macOS
```
**OS-aware shortcuts**
**OS-aware shortcuts (the pattern `Main.kt` already uses)**
```kotlin
shortcut = DesktopShortcuts.primary(Key.N)
shortcut = if (isMacOS) {
KeyShortcut(Key.N, meta = true) // Cmd+N on macOS
} else {
KeyShortcut(Key.N, ctrl = true) // Ctrl+N on Win/Linux
}
```
---
@@ -736,7 +742,7 @@ When implementing desktop features:
1. **Read** `references/desktop-compose-apis.md` for API catalog
2. **Check** `references/keyboard-shortcuts.md` for standard shortcuts
3. **Reference** Main.kt:87-264 for current patterns
3. **Reference** `Main.kt` (entry point L172-L450+) and `ui/deck/SinglePaneLayout.kt` (NavigationRail) for current patterns
4. **Test** on all 3 platforms (macOS, Windows, Linux) if possible
5. **Delegate** build issues to gradle-expert
6. **Share** UI components via compose-expert, not desktop-expert
@@ -15,7 +15,7 @@ Comparison of mobile vs desktop navigation patterns in AmethystMultiplatform.
### Current Implementation
**File:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt:191-264`
**File:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt` (NavigationRail begins at L97; `NavigationRailItem`s at L103 and L127+).
```kotlin
@Composable
@@ -459,6 +459,6 @@ fun FeedScreen() {
## References
- **Current Desktop:** Main.kt:191-264
- **Current Desktop:** `ui/deck/SinglePaneLayout.kt` (NavigationRail L97) and `ui/deck/DeckLayout.kt` (multi-pane)
- **Material3 NavigationRail:** [Material Design Docs](https://m3.material.io/components/navigation-rail)
- **Material3 NavigationBar:** [Material Design Docs](https://m3.material.io/components/navigation-bar)
@@ -365,9 +365,9 @@ fun testOsDetection() {
---
## Current Issues in Amethyst
## Current Pattern in Amethyst
**Main.kt:105-123** hardcodes `ctrl = true`:
`Main.kt` (MenuBar starting at L234) already branches on `isMacOS` (L120) for every shortcut. When adding a new menu item, follow the same pattern — **do not** hardcode `ctrl = true`:
```kotlin
// ❌ WRONG: Hardcoded Ctrl (doesn't work on macOS)
@@ -378,7 +378,7 @@ Item(
)
```
**Fix:**
**Current pattern (what Main.kt does):**
```kotlin
// ✅ CORRECT: OS-aware
+105
View File
@@ -0,0 +1,105 @@
---
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.
---
# Feed Patterns
Amethyst's "feed" abstraction is: a `FeedFilter` that decides which notes belong in a list, plus a `FeedViewModel` that exposes the current state reactively to the UI. Every scrollable list — home, profile, hashtag, bookmarks, notifications, DMs — is a variant of this.
## When to Use This Skill
- Adding a new screen that shows a list of notes.
- Modifying an existing feed's filtering / ordering / inclusion rules.
- Investigating why a feed doesn't update after a mute/follow/bookmark change.
- Deciding whether to extend a ViewModel or write a new filter.
- Understanding the Android ⇄ Desktop sharing boundary for feeds.
## Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ commons/.../viewmodels/ (shared, KMP) │
│ FeedViewModel ◄── ListChangeFeedViewModel │
│ ◄── ChatroomFeedViewModel │
│ ◄── MarmotGroupFeedViewModel │
│ │
│ FeedContentState — the flow the UI collects │
└─────────────────────────────────────────────────────────────┘
│ uses
┌─────────────────────────────────────────────────────────────┐
│ amethyst/.../ui/dal/ (Android; feeds defined per screen) │
│ FeedFilter<T> (abstract) │
│ AdditiveComplexFeedFilter<T, U> │
│ ChangesFlowFilter │
│ FilterByListParams │
│ DefaultFeedOrder │
│ │
│ Plus concrete feeds: HomeFeedFilter, HashtagFeedFilter, │
│ BookmarkListFeedFilter, NotificationFeedFilter, … │
└─────────────────────────────────────────────────────────────┘
│ reads
┌─────────────────────────────────────────────────────────────┐
│ model/LocalCache.kt + Account.<featureFlow> │
└─────────────────────────────────────────────────────────────┘
```
## Key Files
### Shared (commons)
`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/`:
- **`FeedViewModel.kt`** — `abstract class FeedViewModel(localFilter, cacheProvider)`. Holds a `FeedContentState`, subscribes to invalidation signals (from `Account` flows and `LocalCacheFlow`), re-runs the filter, and emits a new `FeedState` for the UI.
- **`ListChangeFeedViewModel.kt`** — specialization for feeds whose membership changes frequently (e.g. bookmarks).
- **`ChatroomFeedViewModel.kt`** — DM thread feed.
- **`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)
`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).
Concrete filters (Home, Hashtag, Profile, Bookmark, Notifications, Communities, etc.) live in feature subfolders under `amethyst/.../ui/screen/loggedIn/*/` — each extends `FeedFilter` or `AdditiveComplexFeedFilter`.
## Adding a New Feed
1. **Define the filter.** Extend `AdditiveComplexFeedFilter<Note, Set<HexKey>>` (or plain `FeedFilter<Note>` if additivity doesn't matter). 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>`.
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) } }`.
5. **Subscribe to relays.** Most feeds also need a `Subscribable` to fetch historical events. See the `relay-client` skill.
## 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.
## Gotchas
- **Never scan `LocalCache` from a composable.** Always go through a `FeedFilter` + `FeedViewModel`, which does it on a background dispatcher and debounces invalidation.
- **`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.
## References
- `references/feed-filter-composition.md` — step-by-step for adding a feed.
- `references/viewmodel-base-classes.md` — inheritance graph for the `FeedViewModel` family.
- Complements: `account-state` (where the data lives), `relay-client` (how to subscribe), `compose-expert` (how to render).
@@ -0,0 +1,128 @@
# Adding a New Feed
Step-by-step recipe for composing a new feed. Assume the feed shows `Note`s filtered by some criterion and should update reactively when the underlying state changes.
## 1. Choose a Filter Base
| 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>>` |
| 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/`.
## 2. Write the Filter
```kotlin
class HashtagFeedFilter(
private val accountViewModel: AccountViewModel,
private val hashtag: String,
) : AdditiveComplexFeedFilter<Note, Set<Note>>() {
override fun feedKey(): String = "Hashtag-$hashtag"
override fun showHiddenKey(): Boolean = false
override fun feed(): List<Note> {
val params = FilterByListParams.create(
excludeMuted = true,
hiddenUsers = accountViewModel.hiddenUsersFlow.value,
)
return LocalCache.hashtagIndex[hashtag]
.orEmpty()
.filter { params.match(it) }
.sortedWith(DefaultFeedOrder)
.take(limit())
}
override fun applyFilter(collection: Set<Note>): Set<Note> =
collection.filter { it.event?.isTaggedHash(hashtag) == true }.toSet()
override fun sort(collection: Set<Note>): List<Note> =
collection.sortedWith(DefaultFeedOrder)
override fun limit(): Int = 1000
}
```
Key points:
- `feedKey()` must uniquely identify this filter *instance*. The parameter (hashtag in this case) is part of the key so two hashtag feeds don't share state.
- `feed()` is the full recompute — synchronous, runs on a background dispatcher.
- `applyFilter()` is the per-event membership check used by the additive path.
- Always use `FilterByListParams` rather than re-implementing mute / hidden-user logic.
- `DefaultFeedOrder` is the canonical sort; deviating breaks paging assumptions.
## 3. Pick a ViewModel
If an existing ViewModel already matches the flow pattern, reuse it with your new filter:
```kotlin
class HashtagFeedViewModel(
val hashtag: String,
accountViewModel: AccountViewModel,
) : FeedViewModel(
localFilter = HashtagFeedFilter(accountViewModel, hashtag),
cacheProvider = LocalCache,
)
```
If membership changes aggressively (e.g. the user toggles a mute), use `ListChangeFeedViewModel` instead and hook into `Account.muteListFlow`.
## 4. Wire Invalidation
`FeedViewModel` already re-queries on `LocalCacheFlow` ticks. For changes that come from `Account` state (mutes, follows, bookmarks, relay list updates) add them in the ViewModel:
```kotlin
init {
viewModelScope.launch {
accountViewModel.muteListFlow.collect { invalidateAll() }
}
}
```
`invalidateAll()` triggers a full `feed()` re-run; `invalidateInsertData(addedNotes)` is the additive path.
## 5. Subscribe to Relays
Unless the feed only shows already-cached data, write a `Subscribable` that fetches history. See `relay-client` skill. Typically:
```kotlin
val subscribable = rememberSubscribable(hashtag) {
HashtagFilterAssembler(hashtag).toSubscribable()
}
DisposableEffect(hashtag) {
subscribable.subscribe()
onDispose { subscribable.unsubscribe() }
}
```
## 6. Render
```kotlin
val feedState by viewModel.feedState.feedContent.collectAsStateWithLifecycle()
LazyColumn {
items(
items = feedState.feed.value,
key = { it.idHex },
) { note ->
NoteCompose(note)
}
}
```
Use `key = { it.idHex }` so Compose can diff efficiently across additive updates.
## 7. Test
Unit-test the filter in isolation: feed it a known `LocalCache` snapshot and assert the output order. Filters are side-effect-free once `LocalCache` is fixed, so they're straightforward to pin.
## Common Mistakes
- **Inline filtering in composables.** If you call `LocalCache.notes.filter { … }` in a composable, the filter recomputes every recomposition and never invalidates correctly. Always go through a `FeedFilter`.
- **Forgetting `showHiddenKey()`.** If you want a "show hidden" toggle, override it; otherwise hidden content is silently dropped.
- **Non-stable `feedKey()`.** Using a hash that depends on current time or mutable state causes the ViewModel to lose its cached state on every invalidation.
- **Skipping `FilterByListParams`.** Muted users, reported users, spam filter — all of it lives there. Reimplementing is a source of bugs.
@@ -0,0 +1,90 @@
# ViewModel Base Classes
Inheritance tree for the shared feed ViewModels in `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/`.
## Tree
```
androidx.lifecycle.ViewModel
InvalidatableContent (interface)
FeedViewModel(localFilter: FeedFilter<Note>, cacheProvider: ICacheProvider)
├── ListChangeFeedViewModel (list membership changes often)
│ │
│ └── (concrete bookmark / list feeds)
├── ChatroomFeedViewModel (DM thread)
└── MarmotGroupFeedViewModel (NIP-29 group chat)
```
Tangentially related (same folder, not in the tree):
- `LiveStreamTopZappersViewModel.kt` — sidebar state for live streams.
- `SearchBarState.kt` — search input + suggestions.
- `ChatNewMessageState.kt` — composer state for a new DM.
- `thread/*` — thread ViewModels (not technically feeds but share wiring).
## `FeedViewModel`
```kotlin
abstract class FeedViewModel(
localFilter: FeedFilter<Note>,
val cacheProvider: ICacheProvider,
) : ViewModel(), InvalidatableContent {
val feedState = FeedContentState(localFilter, viewModelScope, cacheProvider)
fun invalidateAll() // full recompute
fun invalidateInsertData(newNotes: Set<Note>) // additive path
fun invalidateReplace(replaced: Set<Note>) // replaceable/addressable update
}
```
`FeedContentState` is the thing the UI collects:
- `feedContent: StateFlow<FeedState>` — the actual list, loading flag, paging state.
- Runs the `localFilter.feed()` on a background dispatcher.
- Debounces consecutive invalidations so bursts of relay frames don't thrash the filter.
## `ListChangeFeedViewModel`
Extends `FeedViewModel`. Override point:
```kotlin
abstract class ListChangeFeedViewModel(...) : FeedViewModel(...) {
// Automatically re-invalidates on Account list-flow changes
abstract fun dependencyList(): List<Flow<*>>
}
```
Used for bookmarks, mutes, and custom `NIP-51` lists — anything whose membership is decided by an `Account` StateFlow.
## `ChatroomFeedViewModel`
Wraps filter + relay subscription + typing-indicator state for a single DM thread. Use directly for chat screens; don't reimplement per-thread.
## `MarmotGroupFeedViewModel`
NIP-29 (marmot variant) group feed. Adds group membership / moderator state on top of the base feed.
## When to Extend vs Reuse
- **Just a new filter** → instantiate `FeedViewModel` with your filter; no new class needed.
- **New invalidation signal** → subclass and override `init` to add collectors.
- **Entirely new paging model** (infinite scroll, server-assisted paging) → subclass with a custom `FeedContentState`.
- **Non-feed state** (search, composer) → don't use `FeedViewModel` at all; see `SearchBarState.kt` / `ChatNewMessageState.kt` for narrow-state patterns.
## Platform Wrapping
On Android, feed ViewModels are created via `viewModel { HashtagFeedViewModel(...) }` in the composable. On Desktop, they're instantiated directly and stored in a `WorkspaceManager` column (see `desktopApp/.../ui/deck/WorkspaceManager.kt`). The ViewModel class itself is KMP-friendly.
## Gotchas
- **Multiple subscribers to `feedContent`** are fine — it's a `StateFlow`.
- **ViewModels survive configuration changes on Android** but not on Desktop `key {}` rebuilds — re-instantiate in Desktop's workspace lifecycle.
- **`cacheProvider` is almost always `LocalCache`** but the parameter exists so tests can inject a fixture.
- **Don't call `invalidateAll()` from UI** — it's triggered by the ViewModel's own collectors. Calling it from the composable just causes extra filter runs.
+1
View File
@@ -795,6 +795,7 @@ Passing lambda to function?
- `references/sealed-class-catalog.md` - All sealed types in quartz
- `references/dsl-builder-examples.md` - TagArrayBuilder, other DSL patterns
- `references/immutability-patterns.md` - @Immutable usage, data classes, collections
- `references/common-utilities.md` - Canonical helpers: `NumberFormatters`, `TimeUtils`, `Hex`, `PubKeyFormatter`, `CoroutinesExt.launchIO`, `OptimizedJsonMapper`, etc.
### Codebase Examples
- AccountManager.kt:36-50 - sealed class AccountState, StateFlow pattern
@@ -0,0 +1,69 @@
# Common Utility Functions
Canonical helpers that repeatedly come up when working in Amethyst. Prefer these to hand-rolling equivalents.
## Formatting (commons)
All under `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/`:
- **`NumberFormatters.kt`**
- `countToHumanReadable(counter: Int, noun: String): String``1500 → "1K items"`, `2_500_000 → "2M items"`. Suffixes `K`, `M`, `G`.
- `countToHumanReadableBytes(bytes: Int): String``1024 → "1 KB"`, scales through KB/MB/GB/TB.
- **`PubKeyFormatter.kt`** — condense an npub to `npub1abc…xyz` with a symmetric prefix/suffix truncation. Use in chips and small UI that show an author.
- **`EmojiUtils.kt`** — parse custom emoji (`:name:`), render bridging to NIP-30 `emoji` tags.
- **`IterableUtils.kt`** — small shortcuts like `firstNotNullOf` variants, chunking helpers.
- **`PlatformNumberFormatter.kt`** — expect/actual locale-aware number formatting (delegates to `NumberFormat` on JVM/Android).
## Time (quartz)
`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/TimeUtils.kt` — the source of truth for "now in Nostr seconds" and common offsets:
```kotlin
TimeUtils.now() // Long seconds since epoch (Nostr `created_at`)
TimeUtils.oneMinuteAgo()
TimeUtils.fiveMinutesAgo()
TimeUtils.fifteenMinutesAgo()
TimeUtils.oneHourAgo()
TimeUtils.oneDayAgo()
TimeUtils.oneWeekAgo()
TimeUtils.withinTenMinutes(other) // |now - other| < 10m
```
Constants (`TEN_SECONDS`, `ONE_MINUTE`, `FIVE_MINUTES`, `TEN_MINUTES`, `FIFTEEN_MINUTES`, `ONE_HOUR`, `EIGHT_HOURS`, `ONE_DAY`, `ONE_WEEK`) are in seconds and are what every subscription filter and staleness check uses — keep using them instead of magic numbers.
## Hex / bytes / strings (quartz)
Under `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/`:
- **`Hex.kt`** — `ByteArray.toHex()`, `String.hexToByteArray()`. MPP-friendly, no java.util.
- **`StringUtils.kt`** — generic helpers (normalization, truncation).
- **`StringExt.kt`** — small extensions (e.g. safe substring).
- **`UriParser.kt`** — NIP-19 / nostr URI friendly URL parsing without java.net.
- **`UrlEncoder.kt`** / **`Rfc3986.kt`** — percent-encoding / decoding for URL-safe content.
- **`UnicodeNormalizer.kt`** — NFC normalization for search/matching.
## Threading & coroutines
- **`commons/src/commonMain/.../threading/Threading.kt`** — shared dispatchers and `CoroutineScope` helpers for commonMain code.
- **`amethyst/src/main/java/.../service/CoroutinesExt.kt`** — Android-only helpers: `launchIO(block)`, `launchMain(block)` built on top of `Dispatchers.IO` / `Dispatchers.Main`. Use these in ViewModels and services to stop re-spelling the dispatcher every time.
- **`amethyst/src/main/java/.../service/MainThreadChecker.kt`** — debug assertion helper for catching main-thread misuse during dev.
## Quartz iterables & JSON
- **`quartz/.../utils/IterableExt.kt`** — mutation-free filter/map/group helpers used by the cache layer.
- **`quartz/.../utils/JsonElementExt.kt`** — safe navigation for Jackson nodes when parsing unknown-shape JSON.
- **`quartz/.../kotlinSerialization/OptimizedJsonMapper.kt`** — shared Jackson mapper with reified `fromJson<T>(...)` / `toJson(...)`; prefer this to spinning up a local `ObjectMapper`.
## Number formatting for Android display
`amethyst/src/main/java/.../service/CountFormatter.kt` and `ByteFormatter.kt` wrap the commons formatters with Android-specific pluralization / locale. Use them from Android UI; use the commons functions directly from commonMain.
## When to Add vs Reuse
Before introducing a new helper:
1. `grep -r "fun count\|fun format\|fun toHuman" commons/ quartz/` — there is almost certainly something already.
2. If you're about to write `System.currentTimeMillis() / 1000` — use `TimeUtils.now()`.
3. If you're about to write `String.format("%.1f K", n / 1000.0)` — use `countToHumanReadable`.
4. If you need locale-specific rendering on both Android and Desktop, reach for `PlatformNumberFormatter` (expect/actual) rather than hard-coding.
+5 -5
View File
@@ -3,7 +3,7 @@ name: kotlin-multiplatform
description: |
Platform abstraction decision-making for Amethyst KMP project. Guides when to abstract vs keep platform-specific,
source set placement (commonMain, jvmAndroid, platform-specific), expect/actual patterns. Covers primary targets
(Android, JVM/Desktop, iOS) with web/wasm future considerations. Integrates with gradle-expert for dependency issues.
(Android, JVM/Desktop, iOS — all mature) with web/wasm as possible future targets. Integrates with gradle-expert for dependency issues.
Triggers on: abstraction decisions ("should I share this?"), source set placement questions, expect/actual creation,
build.gradle.kts work, incorrect placement detection, KMP dependency suggestions.
---
@@ -242,17 +242,17 @@ expect fun currentTimeSeconds(): Long
**Android (androidMain):**
- Uses Android framework (Activity, Context, etc.)
- secp256k1-kmp-jni-android for crypto
- secp256k1-kmp-jni-android (`0.23.0` in `libs.versions.toml`) for crypto
- AndroidX libraries
**Desktop JVM (jvmMain):**
- Uses Compose Desktop (Window, MenuBar, etc.)
- secp256k1-kmp-jni-jvm for crypto
- secp256k1-kmp-jni-jvm (same `0.23.0` line) for crypto
- Pure JVM libraries
**iOS (iosMain):**
- Active development, framework configured
- Architecture targets: macosArm64Main, iosArm64Main, iosSimulatorArm64Main
- Mature target — actively built and tested
- Architecture targets: iosArm64, iosSimulatorArm64, iosX64 (plus macosArm64 for host tooling)
- Platform APIs via platform.posix, Security framework
### Web, wasm - Future Targets
@@ -14,7 +14,7 @@ Current targets (Android, JVM/Desktop, iOS) and future targets (web, wasm) with
- Android framework (Activity, Context, Intent, etc.)
- AndroidX libraries (ViewModel, Navigation, etc.)
- JVM libraries via jvmAndroid (Jackson, OkHttp)
- Platform-specific crypto: secp256k1-kmp-jni-android
- Platform-specific crypto: `secp256k1-kmp-jni-android` (0.23.0 in `libs.versions.toml`)
**Constraints:**
- Mobile UX paradigms (bottom navigation, vertical scroll)
@@ -44,7 +44,7 @@ class MainActivity : AppCompatActivity() {
- Pure JVM libraries
- JVM libraries via jvmAndroid (Jackson, OkHttp)
- Compose Desktop (Window, MenuBar, etc.)
- Platform-specific crypto: secp256k1-kmp-jni-jvm
- Platform-specific crypto: `secp256k1-kmp-jni-jvm` (same 0.23.0 line)
**Constraints:**
- Desktop UX paradigms (sidebar, menus, keyboard shortcuts)
@@ -70,7 +70,7 @@ fun main() = application {
### iOS (iosMain + architecture targets)
**Status:** ⚠️ In development, framework configured
**Status:** ✅ Mature — actively built and tested
**Runtime:** Native iOS
@@ -78,6 +78,8 @@ fun main() = application {
- iosMain (common iOS code)
- iosArm64Main (device - iPhone/iPad)
- iosSimulatorArm64Main (Apple Silicon simulator)
- iosX64Main (Intel simulator)
- macosArm64Main (host tooling / XCFramework build)
**Available:**
- iOS platform APIs (platform.posix, Foundation, etc.)
+4
View File
@@ -514,6 +514,10 @@ Or see `references/nip-catalog.md` for complete catalog.
- **references/nip-catalog.md** - All 57 NIPs with package locations and key files
- **references/event-hierarchy.md** - Event class hierarchy, kind classifications, common types
- **references/tag-patterns.md** - Tag structure, TagArrayBuilder DSL, common tag types, parsing patterns
- **references/nip19-bech32.md** - `Nip19Parser`, `Bech32Util`, `TlvBuilder`, entity types (NPub, NSec, NEvent, NAddress, NProfile, NRelay, NEmbed)
- **references/event-factory.md** - `EventFactory` dispatch pattern and how to register a new kind
- **references/crypto-and-encryption.md** - Event signing/verification, secp256k1 abstraction, NIP-44 encryption, `SharedKeyCache`
- **references/large-cache.md** - `LargeCache<K,V>` expect/actual + `ICacheOperations` functional API
- **scripts/nip-lookup.sh** - Find NIP implementations by number or search term
## Quick Reference
@@ -0,0 +1,80 @@
# Crypto & Encryption in Quartz
Event signing, hashing, and NIP-44 payload encryption.
## Layout
### Core crypto (`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/`)
- `EventHasher.kt` — canonical JSON serialization + SHA-256 → event id. NIP-01 §1.
- `EventHasherSerializer.kt` — Jackson serializer that emits the exact byte layout NIP-01 hashing requires.
- `KeyPair.kt` — holder for `privateKey: ByteArray` + derived `pubKey: ByteArray`. Generates fresh key pairs via `secureRandom`.
- `Nip01Crypto.kt` — one-stop helper: sign an event, verify a signature, derive pubkey from seckey.
- `EventAssembler.kt` — takes an unsigned template + signer and produces a fully populated `Event`.
- `EventExt.kt``Event.verify()` / `Event.hasValidSignature()` extensions.
### secp256k1 abstraction (`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/`)
- `Secp256k1Instance.kt``expect object` with `signSchnorr`, `verifySchnorr`, `pubKey(seckey)`, `sharedSecret`.
- `Secp256k1InstanceC.kt` — C-based actual using secp256k1 JNI (Android/JVM).
- `Secp256k1InstanceKotlin.kt` — pure-Kotlin actual (iOS via native, etc.).
- Android actual: `secp256k1-kmp-jni-android` (0.23.0). JVM actual: `secp256k1-kmp-jni-jvm`.
### NIP-44 encryption (`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/`)
- `Nip44.kt` — dispatcher that handles both v1 (ChaCha20 w/o Poly1305, legacy) and v2 (XChaCha20-Poly1305).
- `Nip44v2.kt` — current spec: HKDF key derivation → XChaCha20-Poly1305 → padded plaintext → Base64.
- `Nip44v1.kt` — legacy path (decrypt-only for backward compat; do not encrypt with v1).
- `crypto/``ChaCha20Poly1305`, `HKDF`, `Hmac`, etc. (pure Kotlin, MPP-friendly).
- `SharedKeyCache.kt` — in-process LRU for ECDH shared secrets. Critical for performance in chat/list screens that decrypt many messages with the same counterparty.
- `EncryptedInfoString.kt` — versioned payload envelope that the parser reads to pick v1 vs v2.
## Typical Flows
### Sign an event
```kotlin
// Direct (when you have the privkey in memory)
val signed = Nip01Crypto.sign(unsignedEvent, keyPair.privateKey)
// Via signer (preferred — honors external/remote signers)
val signer: NostrSigner = ... // NostrSignerInternal, Nip46RemoteSigner, NostrSignerExternal
signer.sign(template) { signed -> /* emit signed event */ }
```
Use `NostrSigner` whenever the key might not live in the current process (NIP-46 bunker, NIP-55 Android external signer). See the `auth-signers` skill.
### Verify an event
```kotlin
event.verify() // throws on failure
event.hasValidSignature() // returns Boolean
```
Both recompute `sha256(canonicalJson(event))` and call Schnorr `verifySchnorr(sig, hash, pubKey)`.
### NIP-44 encrypt / decrypt
```kotlin
// Always compute shared secret through the cache — direct ECDH is expensive
val sharedSecret = SharedKeyCache.getOrComputeShared(mySeckey, theirPubkey)
val cipherText = Nip44.encrypt(plaintext, sharedSecret) // v2 by default
val plain = Nip44.decrypt(cipherText, sharedSecret) // dispatches on version byte
```
Callers rarely touch `Nip44v2` directly; go through `Nip44`.
## Gotchas
- **Never log private keys, shared secrets, or raw plaintext.** `KeyPair.privateKey` is a `ByteArray` on purpose so it doesn't get interned as a String.
- **Don't recompute ECDH per message.** `SharedKeyCache` exists because the same counterparty appears in many messages; bypassing the cache produces noticeable UI lag.
- **`EventHasher` ordering is canonical.** Serialize tags / content exactly as `EventHasherSerializer` emits, or ids won't match relays.
- **secp256k1 JNI is platform-specific**: if you add crypto that must run in `commonTest`, wrap it in `expect/actual` or you'll get `UnsatisfiedLinkError` in JVM unit tests.
- **NIP-44 pads messages**. Don't assert exact ciphertext length; assert decrypt round-trips.
## Tests
- `quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/` — sign/verify/hash round-trips.
- `quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/` — NIP-44 vectors (encryption parity with reference vectors).
- JNI crypto is exercised in `androidUnitTest` / JVM integration tests.
@@ -0,0 +1,68 @@
# EventFactory: Parsing JSON into Typed Events
`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt` is the single dispatch point that turns a parsed `(id, pubKey, createdAt, kind, tags, content, sig)` tuple into the correct `Event` subclass.
## What It Does
EventFactory is a giant `when` over `kind` that maps integer kind values to concrete event classes. If a kind isn't recognized, it falls back to the generic base `Event` (so unknown kinds still round-trip). Every NIP that defines a new kind registers its class here.
Typical shape:
```kotlin
object EventFactory {
fun create(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
kind: Int,
tags: TagArray,
content: String,
sig: HexKey,
): Event = when (kind) {
MetadataEvent.KIND -> MetadataEvent(id, pubKey, createdAt, tags, content, sig)
TextNoteEvent.KIND -> TextNoteEvent(id, pubKey, createdAt, tags, content, sig)
ContactListEvent.KIND -> ContactListEvent(id, pubKey, createdAt, tags, content, sig)
ReactionEvent.KIND -> ReactionEvent(id, pubKey, createdAt, tags, content, sig)
// …hundreds more…
else -> Event(id, pubKey, createdAt, kind, tags, content, sig)
}
}
```
Callers are normally upstream of this: `Event.fromJson(...)` / `EventMapper.fromJson(...)` / the relay client's message parser. You rarely call EventFactory directly — you consume typed events it produces.
## Registering a New Event Kind
Adding a NIP is roughly:
1. Create the event class under `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXX…/` alongside the NIP package.
2. Subclass the right base:
- `Event` — regular events (stored forever).
- `BaseReplaceableEvent` — kinds `10000-19999`, `0`, `3`.
- `BaseAddressableEvent` — kinds `30000-39999` (identified by `kind:pubkey:d-tag`).
- Ephemeral events extend `Event` but have kind `20000-29999`.
3. Define `companion object { const val KIND = <n> }`.
4. If the event has tag builders, define a `TagArrayBuilder<YourEvent>` DSL in a `TagArrayBuilder` extension — see `nostr-expert/references/tag-patterns.md`.
5. Add a branch to `EventFactory.create(...)` so JSON parsing produces your typed class.
6. If the event is addressable, ensure it exposes a stable `dTag()` and `address()`.
7. Add tests under `quartz/src/commonTest/...`.
## Why a Monolithic when?
- **Zero overhead**: compiled to a dense lookup. No reflection, no registry map.
- **Exhaustive browsing**: every known kind lives at one search location. `grep KIND = 1234 quartz/...` finds everything.
- **Obvious migration path**: adding a kind means adding a case; removing a kind is a grep-and-delete.
The tradeoff is the file is large and every new kind edits the same file — expect merge conflicts in PRs that touch it, and resolve by keeping both branches.
## Supporting Utilities
- `EventAssembler.kt` (crypto/) — higher-level helper that takes a signer and a `kind + tags + content` and produces a fully signed event (id + sig populated).
- `EventTemplate.kt` (signers/) — unsigned-event holder, useful in signer flows.
- `Event.fromJson(...)` / `Event.toJson()` — JSON round-trip using `OptimizedJsonMapper` (Jackson on jvmAndroid).
## Related References
- `event-hierarchy.md` — class hierarchy, Kind ranges
- `nip-catalog.md` — which kind maps to which NIP
- `tag-patterns.md``TagArrayBuilder` DSL for writing tags cleanly
@@ -0,0 +1,63 @@
# LargeCache: Platform-Aware In-Memory Store
`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/cache/LargeCache.kt` provides a thread-safe key-value cache with a functional iteration API. Used everywhere Amethyst needs to hold many events, users, or derived state in memory.
## Files
- `LargeCache.kt``expect class LargeCache<K, V>` and its factory `createLargeCache()`.
- `ICacheOperations.kt` — interface the cache exposes: `forEach`, `filter`, `map`, `mapNotNull`, `groupBy`, `maxOrNullOf`, `sumOf`, `count`, `any`, `firstOrNull`, etc.
- `CacheCollectors.kt` — functional collector helpers used by the cache API.
### Actual implementations
- **Android** (`androidMain`) — backed by a `ConcurrentHashMap` (and optionally `androidx.collection.LruCache` variants for size-bounded caches).
- **JVM/Desktop** (`jvmMain`) — `ConcurrentHashMap` directly.
- **iOS** (`iosMain`) — `NSMapTable`/Kotlin concurrent map wrapper.
## Core API
```kotlin
val cache: LargeCache<HexKey, Note> = LargeCache()
cache.put(id, note)
cache.get(id) // V?
cache.getOrCreate(id) { Note(id) } // atomic compute-if-absent
cache.containsKey(id)
cache.remove(id)
cache.size()
// Functional iteration — thread-safe snapshot semantics
cache.forEach { key, value -> ... }
cache.filter { key, value -> value.kind == 1 }
cache.map { key, value -> value.pubKey }
cache.count { _, v -> v.isUnread }
cache.maxOrNullOf { _, v -> v.createdAt }
cache.groupBy { _, v -> v.kind }
```
The important contract: **functional operations iterate a consistent snapshot**, so you can `filter` inside a coroutine without racing concurrent writers. This is why `LocalCache` (the Amethyst event store) can be scanned to build a feed while relays are still inserting.
## When to Use
- **Event / note stores**`LocalCache.notes: LargeCache<HexKey, Note>`.
- **User profiles**`LocalCache.users: LargeCache<HexKey, User>`.
- **Address → event** lookups for addressable (parameterized replaceable) events.
- **Shared-secret caches** (see `SharedKeyCache.kt` — a similar pattern at smaller scale).
## When Not to Use
- Small maps (<100 entries) — regular `mutableMapOf` is fine.
- Off-process state (DB, disk) — use the `store/` event DB, not LargeCache.
- Hot one-shot lookups — if you're already inside a Flow pipeline, chain operators rather than maintaining a parallel cache.
## Gotchas
- **`getOrCreate` vs `put`** — `getOrCreate` is atomic and safe under contention; `get` then `put` is a race.
- **Iteration during mutation is safe** but the snapshot may include or exclude a concurrent write. Don't rely on a just-put value being visible inside a currently-running `forEach`.
- **Don't store `Flow`s inside LargeCache.** Cache values should be immutable / thread-safe objects. For reactive state, keep a `StateFlow` next to the cache and emit on writes.
- **No TTL / eviction by default.** If you need bounded size, wrap with `LruCache` or build an explicit eviction loop keyed off a secondary structure.
## Related
- `amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt` — the canonical user of `LargeCache<HexKey, Note>` and `LargeCache<HexKey, User>`.
- `nip44Encryption/SharedKeyCache.kt` — smaller domain-specific cache using the same pattern.
@@ -0,0 +1,83 @@
# NIP-19: Bech32 Encoding & Parsing
Quartz implementation for `npub`, `nsec`, `note`, `nevent`, `nprofile`, `naddr`, `nrelay`, `nembed` — the user-facing encoded forms of Nostr identifiers.
## Layout
All under `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/`:
- `Nip19Parser.kt` — the entry point. Parses any Bech32 or `nostr:` URI into a typed `Entity`.
- `bech32/Bech32Util.kt` — raw Bech32 encode/decode (bits ↔ 5-bit groups).
- `tlv/Tlv.kt` / `tlv/TlvBuilder.kt` — Type-Length-Value codec for composite entities (`nevent`, `nprofile`, `naddr`).
- `TlvTypes.kt` — TLV type constants (0 = special payload, 1 = relay, 2 = author, 3 = kind).
- `entities/` — one class per entity type (see below).
- `ATagExt.kt`, `ByteArrayExt.kt`, `EventExt.kt`, `ListEntityExt.kt`, `TlvBuilderExt.kt` — convenience extensions for encoding domain objects directly.
## Entity Types
Each is a `sealed class Entity` subclass under `entities/`:
| Class | Prefix | Payload | Purpose |
|-------------|------------|---------------------------------------------------|---------|
| `NPub` | `npub1...` | 32-byte pubkey | Public key |
| `NSec` | `nsec1...` | 32-byte private key | Private key (never log/share) |
| `NNote` | `note1...` | 32-byte event id | Bare note reference (no hints) |
| `NEvent` | `nevent1…` | TLV: event id + relays + author + kind | Rich note reference |
| `NProfile` | `nprofile…`| TLV: pubkey + relays | User reference with relay hints |
| `NAddress` | `naddr1…` | TLV: d-tag + relays + author + kind (addressable) | Parameterized replaceable event |
| `NRelay` | `nrelay1…` | TLV: relay URL | Relay pointer |
| `NEmbed` | `nembed1…` | Compressed event JSON | Full event embedded inline |
## Parsing
```kotlin
// From anywhere (URI, Bech32, nostr: prefix, "nostr:" + data):
val entity: Entity? = Nip19Parser.uriToRoute(input)?.entity
// More forgiving — strips scheme, whitespace, surrounding chars:
val parsed = Nip19Parser.tryParseAndClean(dirtyInput)
when (entity) {
is NPub -> entity.hex // 32-byte pubkey hex
is NEvent -> entity.hex + entity.relay + entity.author + entity.kind
is NAddress -> entity.atag // kind:pubkey:d-tag
is NProfile -> entity.hex + entity.relay
// …
}
```
## Encoding
The cleanest path is the entity's `toNostrUri()` / `toBech32()` methods (each entity class defines them). For composite entities (NEvent, NProfile, NAddress), internally the code builds a TLV buffer via `TlvBuilder`:
```kotlin
// TlvBuilder DSL (tlv/TlvBuilder.kt)
val bytes = TlvBuilder().apply {
addHex(TlvTypes.SPECIAL, eventIdHex)
addString(TlvTypes.RELAY, relayUrl)
addHex(TlvTypes.AUTHOR, authorHex)
addInt(TlvTypes.KIND, kind)
}.build()
Bech32Util.encode("nevent", bytes)
```
Kotlin-idiomatic extension helpers live in `TlvBuilderExt.kt`, `EventExt.kt`, and `ATagExt.kt` — prefer those over hand-building TLV.
## When to Use
- **Pasted input from users**`Nip19Parser.tryParseAndClean` (handles prefixes, whitespace, leftover `nostr:`)
- **Internal routing / deep links**`Nip19Parser.uriToRoute`
- **Outbound share links** → call the entity's `toNostrUri()` / `toBech32()` directly
- **Building a custom TLV entity**`TlvBuilder` DSL + `Bech32Util.encode`
## Gotchas
- `NSec` should never be logged or propagated. Parse and discard the string buffer.
- Relay hints in `NEvent`/`NProfile`/`NAddress` are hints, not guarantees. The Outbox model (NIP-65) overrides them.
- TLV types are fixed (see `TlvTypes.kt`); do not reorder or invent new types without NIP-19 support.
- `NEmbed` is an Amethyst-specific compressed-event extension, not part of NIP-19 proper.
## Tests
See `quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/` for round-trip tests covering every entity and `Nip19Parser` input cleaning.
+123
View File
@@ -0,0 +1,123 @@
---
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/`:
```
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
├── composeSubscriptionManagers/
│ ├── ComposeSubscriptionManager.kt # interface Subscribable<T>
│ ├── MutableComposeSubscriptionManager.kt # reference impl
│ └── ComposeSubscriptionManagerControls.kt # DisposableEffect-style controls
├── eoseManagers/ # EOSE tracking per subscription
├── 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"
```
## 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.
## 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
- `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.
@@ -0,0 +1,56 @@
# Filter Assemblers
An assembler takes a plain input (a set of pubkeys, a set of note ids, a hashtag, a time range) and produces a relay `Filter`. Assemblers are pure, test-friendly, and composable.
## Concrete Assemblers
Under `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/`:
- **`MetadataFilterAssembler.kt`** — `class MetadataFilterAssembler(pubKeys: Set<HexKey>)`. Emits a `Filter` for kind 0 over those authors, respecting relay `limit` conventions.
- **`ReactionsFilterAssembler.kt`** — builds a kind 7 filter with `#e` tag set to the note ids you want reactions for.
- **`FeedMetadataCoordinator.kt`** — higher-order coordinator: given a list of currently-visible notes, figure out which pubkeys and note ids still need metadata and reactions, and produce one (or two) consolidated filters.
## Subscription Helper
`commons/.../relayClient/subscriptions/KeyDataSourceSubscription.kt` — wraps an assembler plus a "data source" that keeps the assembler's input set up to date. E.g. "the set of pubkeys visible in the current feed" is a data source; when the feed scrolls, the set changes, and the subscription re-emits a new filter.
## RelayFilter DSL (quartz)
The `Filter` type the assemblers produce is defined in `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/RelayFilters.kt`. The DSL roughly looks like:
```kotlin
filter {
kinds(MetadataEvent.KIND)
authors(pubKeys)
since(TimeUtils.oneHourAgo())
until(TimeUtils.now())
limit(200)
// tag filters
tag("e", noteIds)
tag("p", pubKeys)
tag("t", hashtags)
}
```
Builders for individual tag types match the `TagArrayBuilder` conventions (see `nostr-expert/references/tag-patterns.md`).
## Writing a New Assembler
Recipe for `FooFilterAssembler`:
1. Create the file under `assemblers/`.
2. Define a small immutable `data class FooQuery(...)` holding the inputs — or take constructor parameters directly if simple.
3. Expose one method: `fun toFilter(): Filter` (or `toFilters()` if you need multiple).
4. Keep the class deterministic and side-effect-free. No cache reads, no coroutines.
5. Add a unit test that asserts the `Filter`'s JSON serialization — `quartz`'s filter serialization is stable, so golden tests work.
## Coordinator Pattern
When a feature needs several related filters (content + reactions + zaps + metadata), write a **coordinator** (see `FeedMetadataCoordinator.kt`) that takes higher-level inputs and fans out to several single-purpose assemblers. Coordinators compose; they don't talk to relays themselves.
## Gotchas
- **`limit` matters** on large author sets. Without it, relays may rate-limit or truncate.
- **Use `since` liberally** to avoid pulling years of history; a feed usually only needs `TimeUtils.oneHourAgo()` or similar.
- **Don't put filter logic inline in composables** — it ends up duplicated and desynced across screens.
- **`toFilter()` should be cheap** — assemblers may be called on every recomposition until you wrap them in a subscription.
@@ -0,0 +1,46 @@
# Preloaders & Rate Limiting
Bulk-fetch patterns for data that needs to come in over relays but isn't directly user-requested (e.g. "hydrate metadata for 200 pubkeys the user might scroll past", "prefetch images before a gallery opens").
## Files
Under `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/preload/`:
- **`MetadataPreloader.kt`** — `class MetadataPreloader(rateLimiter: MetadataRateLimiter, ...)`. Accepts "I need metadata for pubkeys X, Y, Z" requests and batches them into relay-friendly subscriptions.
- **`MetadataRateLimiter.kt`** — token-bucket-style limiter. Caps how many metadata fetches are in-flight simultaneously so relays don't rate-limit the client.
Additional preloader seams worth knowing:
- `MetadataPreloader` is itself called by `FeedMetadataCoordinator` (`assemblers/FeedMetadataCoordinator.kt`) — feeds generate the bulk request set.
- Image prefetching uses an `ImagePrefetcher` interface (see `preload/MetadataPreloader.kt` for the contract). Implementations live in platform code: Android uses Coil's prefetch API, Desktop uses the Skia loader.
## When to Use a Preloader
- You have a **set** of pubkeys/note ids and you want them in `LocalCache` "soon" but the user isn't waiting on any single one.
- You need to **coalesce** many small requests from different composables into one relay subscription.
- You care about **backpressure** — a normal `Subscribable` fires immediately; a preloader defers and batches.
If the user is looking at something right now, use a `Subscribable` instead (`MetadataFilterAssembler`). Preloaders are for "might need this".
## Typical Flow
```kotlin
// Inside a coordinator, e.g. when a feed list emits the set of visible pubkeys
metadataPreloader.request(pubKeys)
// MetadataPreloader deduplicates against what it has already scheduled, clips
// with the rate limiter, and eventually opens a relay sub through the normal
// ComposeSubscriptionManager plumbing.
```
The preloader is process-wide (one instance, injected where needed). Don't instantiate per-screen — that defeats the deduplication.
## Gotchas
- **Rate limits are cooperative.** The limiter throttles the client; relays enforce their own limits. If you see `NOTICE: rate-limited` frames, the preloader's budget is too generous — lower the bucket size.
- **Don't preload sensitive kinds** (encrypted DMs, gift-wrapped events) — they aren't speculatively useful and waste bandwidth.
- **Preload scope should match visibility scope.** When a screen unmounts, cancel its preload requests, otherwise you keep fetching for off-screen data.
- **`MetadataRateLimiter` is not a general rate limiter.** Reuse it only for metadata-like patterns. For publishing rate limits use relay-specific logic in the signer path.
## Related
- `filter-assemblers.md` — the atomic unit the preloader assembles.
- `kotlin-coroutines/references/relay-patterns.md` — how the underlying relay pool handles concurrent subscriptions.