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:
@@ -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.
|
||||
@@ -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`).
|
||||
@@ -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) | 100ms–seconds | No (needs bunker reachable) | Key never touches Amethyst | Occasional approval prompts |
|
||||
| External (NIP-55) | 100–500ms | 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.
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.)
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user