diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 8c87760cd3..c255ce1805 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -40,12 +40,12 @@ amethyst/ | Layer | Technology | |-------|------------| | **Core** | Quartz (Nostr KMP) | -| **UI** | Compose Multiplatform 1.7.x | +| **UI** | Compose Multiplatform 1.10.3 | | **Async** | kotlinx.coroutines + Flow | | **Network** | OkHttp (JVM) | | **Serialization** | Jackson | | **DI** | Manual / Koin | -| **Build** | Gradle 8.x, Kotlin 2.1.0 | +| **Build** | Gradle 8.x, Kotlin 2.3.20 | ## Skills @@ -53,14 +53,21 @@ Specialized skills provide domain expertise with bundled resources and patterns: | Skill | Expertise | When to Use | |-------|-----------|-------------| -| `nostr-expert` | Nostr protocol (Quartz library) | Event types, NIPs, tags, signing, Bech32 | -| `kotlin-expert` | Advanced Kotlin patterns | StateFlow, sealed classes, @Immutable, DSLs | +| `nostr-expert` | Nostr protocol (Quartz library) | Event types, NIPs, tags, signing, Bech32, NIP-44, LargeCache | +| `kotlin-expert` | Advanced Kotlin patterns | StateFlow, sealed classes, @Immutable, DSLs, common utilities | | `kotlin-coroutines` | Advanced async patterns | supervisorScope, callbackFlow, relay pools, testing | | `kotlin-multiplatform` | Platform abstraction | expect/actual, source sets, sharing decisions | -| `compose-expert` | Shared UI components | Material3, state hoisting, recomposition | -| `android-expert` | Android platform | Navigation, permissions, lifecycle, Material3 | -| `desktop-expert` | Desktop platform | Window, MenuBar, Tray, keyboard shortcuts | +| `compose-expert` | Shared UI components | Material3, state hoisting, recomposition, rich-text parsing | +| `android-expert` | Android platform | Navigation, permissions, lifecycle, Material3, Coil image loading | +| `desktop-expert` | Desktop platform | Window, MenuBar, keyboard shortcuts, DeckLayout | | `gradle-expert` | Build system | Dependencies, versioning, packaging, optimization | +| `account-state` | `Account` + `LocalCache` | Per-user StateFlows, event store, adding account-scoped settings | +| `relay-client` | Subscriptions & filter assembly | `ComposeSubscriptionManager`, assemblers, preloaders, EOSE | +| `feed-patterns` | Feeds & DAL | `FeedFilter`, `AdditiveComplexFeedFilter`, `FeedViewModel` family | +| `auth-signers` | `NostrSigner` implementations | Local, NIP-46 bunker, NIP-55 Android external signer | +| `quartz-integration` | Quartz as an external library | Gradle setup, `NostrClient`, `KeyPair`, for external projects | +| `find-missing-translations` | Utility | Extract untranslated Android strings | +| `find-non-lambda-logs` | Utility | Audit Log calls for lambda overloads | ## Workflow diff --git a/.claude/core-skills-plan.md b/.claude/core-skills-plan.md index f85c0d9a18..16056cd4c4 100644 --- a/.claude/core-skills-plan.md +++ b/.claude/core-skills-plan.md @@ -320,3 +320,59 @@ Should show: - kotlin-expert/ - kotlin-multiplatform/ - nostr-expert/ + +--- + +## Phase 2 (2026-04): Audit & Expansion + +After a full audit of the skill library, the following changes were made: + +### Stale references fixed +- `CLAUDE.md` tech-stack versions updated to Compose 1.10.3 / Kotlin 2.3.20. +- `kotlin-multiplatform` reframed iOS as a mature target (not future) and added secp256k1-kmp 0.23.0 version notes. +- `desktop-expert` Main.kt line references rewritten to match current layout (Main.kt grew from ~270 to ~1341 lines; NavigationRail moved to `ui/deck/SinglePaneLayout.kt:97`); the obsolete "hardcoded ctrl = true anti-pattern" section replaced with a note that `isMacOS` branching is now applied throughout. + +### Redundant files removed +- `.claude/skills/compose-desktop.md` deleted (superseded by `desktop-expert/`). `quartz-kmp.md` kept as a small breadcrumb pointer. + +### New references added to existing skills +- `nostr-expert/references/nip19-bech32.md` — `Nip19Parser`, `Bech32Util`, `TlvBuilder`, entities. +- `nostr-expert/references/event-factory.md` — `EventFactory` dispatch + registering a new kind. +- `nostr-expert/references/crypto-and-encryption.md` — `EventHasher`, `Secp256k1Instance`, NIP-44, `SharedKeyCache`. +- `nostr-expert/references/large-cache.md` — `LargeCache` + `ICacheOperations`. +- `kotlin-expert/references/common-utilities.md` — `NumberFormatters`, `TimeUtils`, `Hex`, `PubKeyFormatter`, `CoroutinesExt.launchIO`, etc. +- `compose-expert/references/rich-text-parsing.md` — `RichTextParser`, `UrlParser`, `GalleryParser`, NIP-92 imeta. +- `android-expert/references/image-loading.md` — Coil 3.x setup, custom fetchers, `MyAsyncImage`, `RobohashAsyncImage`. + +### New skills created +- **`account-state/`** — `Account.kt` (50+ StateFlow properties) and `LocalCache.kt` event store. + - `references/account-state-flow.md`, `references/local-cache.md` +- **`relay-client/`** — `ComposeSubscriptionManager`, filter assemblers, preloaders (`MetadataPreloader`, `MetadataRateLimiter`). + - `references/filter-assemblers.md`, `references/preloaders.md` +- **`feed-patterns/`** — `FeedFilter`, `AdditiveComplexFeedFilter`, `FeedViewModel` hierarchy in `commons/`. + - `references/feed-filter-composition.md`, `references/viewmodel-base-classes.md` +- **`auth-signers/`** — `NostrSigner` abstraction across `NostrSignerInternal`, `NostrSignerRemote` (NIP-46), `NostrSignerExternal` (NIP-55). + - `references/nip46-remote-signer.md`, `references/nip55-android-signer.md` + +### Updated skills directory (Phase 2) +``` +- android-expert/ +- auth-signers/ (new) +- account-state/ (new) +- compose-expert/ +- desktop-expert/ +- feed-patterns/ (new) +- find-missing-translations/ +- find-non-lambda-logs/ +- gradle-expert/ +- kotlin-coroutines/ +- kotlin-expert/ +- kotlin-multiplatform/ +- nostr-expert/ +- quartz-integration/ +- relay-client/ (new) +- quartz-kmp.md (breadcrumb pointer) +``` + +### Still deferred +- ⏸️ `ios-expert` — iOS targets are mature but iOS-specific UI work hasn't surfaced yet in this repo. diff --git a/.claude/skills/account-state/SKILL.md b/.claude/skills/account-state/SKILL.md new file mode 100644 index 0000000000..ec370630eb --- /dev/null +++ b/.claude/skills/account-state/SKILL.md @@ -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` — every seen event (regular + addressable + replaceable) keyed by id or d-address. + - `users: LargeCache` — every seen pubkey, lazily populated. + - `addressables: LargeCache` — 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`. + - Expose a `StateFlow` 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.`. +- **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). diff --git a/.claude/skills/account-state/references/account-state-flow.md b/.claude/skills/account-state/references/account-state-flow.md new file mode 100644 index 0000000000..e9bf1256f3 --- /dev/null +++ b/.claude/skills/account-state/references/account-state-flow.md @@ -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 = _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`). diff --git a/.claude/skills/account-state/references/local-cache.md b/.claude/skills/account-state/references/local-cache.md new file mode 100644 index 0000000000..8f6295d4ce --- /dev/null +++ b/.claude/skills/account-state/references/local-cache.md @@ -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 + val users: LargeCache + val addressables: LargeCache + val channels, deletionIndex, hashtagIndex, … +} +``` + +All `LargeCache` — 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.` 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. diff --git a/.claude/skills/android-expert/SKILL.md b/.claude/skills/android-expert/SKILL.md index b24c48fd28..9c63073e38 100644 --- a/.claude/skills/android-expert/SKILL.md +++ b/.claude/skills/android-expert/SKILL.md @@ -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 diff --git a/.claude/skills/android-expert/references/image-loading.md b/.claude/skills/android-expert/references/image-loading.md new file mode 100644 index 0000000000..ddbbe4dce2 --- /dev/null +++ b/.claude/skills/android-expert/references/image-loading.md @@ -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`). diff --git a/.claude/skills/auth-signers/SKILL.md b/.claude/skills/auth-signers/SKILL.md new file mode 100644 index 0000000000..26b72b1b79 --- /dev/null +++ b/.claude/skills/auth-signers/SKILL.md @@ -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 sign( + template: EventTemplate, + 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). diff --git a/.claude/skills/auth-signers/references/nip46-remote-signer.md b/.claude/skills/auth-signers/references/nip46-remote-signer.md new file mode 100644 index 0000000000..1c9e9b4bf6 --- /dev/null +++ b/.claude/skills/auth-signers/references/nip46-remote-signer.md @@ -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://?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). diff --git a/.claude/skills/auth-signers/references/nip55-android-signer.md b/.claude/skills/auth-signers/references/nip55-android-signer.md new file mode 100644 index 0000000000..4f09d613f4 --- /dev/null +++ b/.claude/skills/auth-signers/references/nip55-android-signer.md @@ -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:`. +- 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:///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. diff --git a/.claude/skills/compose-desktop.md b/.claude/skills/compose-desktop.md deleted file mode 100644 index 7f9d3b6c98..0000000000 --- a/.claude/skills/compose-desktop.md +++ /dev/null @@ -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(null) - - fun open() { isOpen = true } - - @Composable - fun Dialog( - title: String = "Select File", - allowedExtensions: List = 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, - 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>(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")) - } - } - } -} -``` diff --git a/.claude/skills/compose-expert/SKILL.md b/.claude/skills/compose-expert/SKILL.md index 0ba02c0135..6b85114ae8 100644 --- a/.claude/skills/compose-expert/SKILL.md +++ b/.claude/skills/compose-expert/SKILL.md @@ -520,6 +520,7 @@ fun FeedList(items: List) { - **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 diff --git a/.claude/skills/compose-expert/references/rich-text-parsing.md b/.claude/skills/compose-expert/references/rich-text-parsing.md new file mode 100644 index 0000000000..9c2c5cada9 --- /dev/null +++ b/.claude/skills/compose-expert/references/rich-text-parsing.md @@ -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 diff --git a/.claude/skills/desktop-expert/SKILL.md b/.claude/skills/desktop-expert/SKILL.md index 8cbcaaf8af..cbd3f5d48d 100644 --- a/.claude/skills/desktop-expert/SKILL.md +++ b/.claude/skills/desktop-expert/SKILL.md @@ -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 diff --git a/.claude/skills/desktop-expert/references/desktop-navigation.md b/.claude/skills/desktop-expert/references/desktop-navigation.md index 01d1927678..38011f82cb 100644 --- a/.claude/skills/desktop-expert/references/desktop-navigation.md +++ b/.claude/skills/desktop-expert/references/desktop-navigation.md @@ -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) diff --git a/.claude/skills/desktop-expert/references/keyboard-shortcuts.md b/.claude/skills/desktop-expert/references/keyboard-shortcuts.md index fd9257ef4f..a34c638bc2 100644 --- a/.claude/skills/desktop-expert/references/keyboard-shortcuts.md +++ b/.claude/skills/desktop-expert/references/keyboard-shortcuts.md @@ -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 diff --git a/.claude/skills/feed-patterns/SKILL.md b/.claude/skills/feed-patterns/SKILL.md new file mode 100644 index 0000000000..7526d5d34e --- /dev/null +++ b/.claude/skills/feed-patterns/SKILL.md @@ -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 (abstract) │ +│ AdditiveComplexFeedFilter │ +│ ChangesFlowFilter │ +│ FilterByListParams │ +│ DefaultFeedOrder │ +│ │ +│ Plus concrete feeds: HomeFeedFilter, HashtagFeedFilter, │ +│ BookmarkListFeedFilter, NotificationFeedFilter, … │ +└─────────────────────────────────────────────────────────────┘ + ▲ + │ reads + │ +┌─────────────────────────────────────────────────────────────┐ +│ model/LocalCache.kt + Account. │ +└─────────────────────────────────────────────────────────────┘ +``` + +## 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`. Has `feed(): List` (the sync query against `LocalCache`) and `feedKey(): String` (identity used to cache). +- **`AdditiveComplexFeedFilter.kt`** — `abstract class AdditiveComplexFeedFilter : FeedFilter()`. 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>` (or plain `FeedFilter` 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): Set` and `sort(collection: Set): List`. +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). diff --git a/.claude/skills/feed-patterns/references/feed-filter-composition.md b/.claude/skills/feed-patterns/references/feed-filter-composition.md new file mode 100644 index 0000000000..3f345b1b16 --- /dev/null +++ b/.claude/skills/feed-patterns/references/feed-filter-composition.md @@ -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` | +| New notes arrive one at a time and should slot into the list incrementally | `AdditiveComplexFeedFilter>` | +| The feed is a simple list that changes frequently (e.g. bookmarks, lists) | `FeedFilter` + `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>() { + + override fun feedKey(): String = "Hashtag-$hashtag" + + override fun showHiddenKey(): Boolean = false + + override fun feed(): List { + 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): Set = + collection.filter { it.event?.isTaggedHash(hashtag) == true }.toSet() + + override fun sort(collection: Set): List = + 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. diff --git a/.claude/skills/feed-patterns/references/viewmodel-base-classes.md b/.claude/skills/feed-patterns/references/viewmodel-base-classes.md new file mode 100644 index 0000000000..d1d72f43cd --- /dev/null +++ b/.claude/skills/feed-patterns/references/viewmodel-base-classes.md @@ -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, 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, + val cacheProvider: ICacheProvider, +) : ViewModel(), InvalidatableContent { + + val feedState = FeedContentState(localFilter, viewModelScope, cacheProvider) + + fun invalidateAll() // full recompute + fun invalidateInsertData(newNotes: Set) // additive path + fun invalidateReplace(replaced: Set) // replaceable/addressable update +} +``` + +`FeedContentState` is the thing the UI collects: + +- `feedContent: StateFlow` — 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> +} +``` + +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. diff --git a/.claude/skills/kotlin-expert/SKILL.md b/.claude/skills/kotlin-expert/SKILL.md index 6bd7eb097b..bc1d81bc06 100644 --- a/.claude/skills/kotlin-expert/SKILL.md +++ b/.claude/skills/kotlin-expert/SKILL.md @@ -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 diff --git a/.claude/skills/kotlin-expert/references/common-utilities.md b/.claude/skills/kotlin-expert/references/common-utilities.md new file mode 100644 index 0000000000..c2e0de633e --- /dev/null +++ b/.claude/skills/kotlin-expert/references/common-utilities.md @@ -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(...)` / `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. diff --git a/.claude/skills/kotlin-multiplatform/SKILL.md b/.claude/skills/kotlin-multiplatform/SKILL.md index 6f8e43f42d..c473298d07 100644 --- a/.claude/skills/kotlin-multiplatform/SKILL.md +++ b/.claude/skills/kotlin-multiplatform/SKILL.md @@ -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 diff --git a/.claude/skills/kotlin-multiplatform/references/target-compatibility.md b/.claude/skills/kotlin-multiplatform/references/target-compatibility.md index c2a4d71299..d1a371c898 100644 --- a/.claude/skills/kotlin-multiplatform/references/target-compatibility.md +++ b/.claude/skills/kotlin-multiplatform/references/target-compatibility.md @@ -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.) diff --git a/.claude/skills/nostr-expert/SKILL.md b/.claude/skills/nostr-expert/SKILL.md index 692f2fe767..373b14eb2b 100644 --- a/.claude/skills/nostr-expert/SKILL.md +++ b/.claude/skills/nostr-expert/SKILL.md @@ -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` expect/actual + `ICacheOperations` functional API - **scripts/nip-lookup.sh** - Find NIP implementations by number or search term ## Quick Reference diff --git a/.claude/skills/nostr-expert/references/crypto-and-encryption.md b/.claude/skills/nostr-expert/references/crypto-and-encryption.md new file mode 100644 index 0000000000..a17d4328cc --- /dev/null +++ b/.claude/skills/nostr-expert/references/crypto-and-encryption.md @@ -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. diff --git a/.claude/skills/nostr-expert/references/event-factory.md b/.claude/skills/nostr-expert/references/event-factory.md new file mode 100644 index 0000000000..19476b0940 --- /dev/null +++ b/.claude/skills/nostr-expert/references/event-factory.md @@ -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 = }`. +4. If the event has tag builders, define a `TagArrayBuilder` 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 diff --git a/.claude/skills/nostr-expert/references/large-cache.md b/.claude/skills/nostr-expert/references/large-cache.md new file mode 100644 index 0000000000..e68afc08ac --- /dev/null +++ b/.claude/skills/nostr-expert/references/large-cache.md @@ -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` 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 = 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`. +- **User profiles** — `LocalCache.users: LargeCache`. +- **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` and `LargeCache`. +- `nip44Encryption/SharedKeyCache.kt` — smaller domain-specific cache using the same pattern. diff --git a/.claude/skills/nostr-expert/references/nip19-bech32.md b/.claude/skills/nostr-expert/references/nip19-bech32.md new file mode 100644 index 0000000000..405a9f72ea --- /dev/null +++ b/.claude/skills/nostr-expert/references/nip19-bech32.md @@ -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. diff --git a/.claude/skills/relay-client/SKILL.md b/.claude/skills/relay-client/SKILL.md new file mode 100644 index 0000000000..8aaa258d05 --- /dev/null +++ b/.claude/skills/relay-client/SKILL.md @@ -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 +│ ├── 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` + +```kotlin +// composeSubscriptionManagers/ComposeSubscriptionManager.kt +interface Subscribable { + val state: StateFlow + 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, +) { + 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. diff --git a/.claude/skills/relay-client/references/filter-assemblers.md b/.claude/skills/relay-client/references/filter-assemblers.md new file mode 100644 index 0000000000..39e0d7f5ca --- /dev/null +++ b/.claude/skills/relay-client/references/filter-assemblers.md @@ -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)`. 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. diff --git a/.claude/skills/relay-client/references/preloaders.md b/.claude/skills/relay-client/references/preloaders.md new file mode 100644 index 0000000000..f9c3496707 --- /dev/null +++ b/.claude/skills/relay-client/references/preloaders.md @@ -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.