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

- Updates CLAUDE.md tech stack to current versions (Compose 1.10.3, Kotlin 2.3.20).
- Reframes kotlin-multiplatform iOS as mature; adds secp256k1-kmp 0.23.0 references.
- Updates desktop-expert Main.kt references (code grew from ~270 to 1341 lines and
  NavigationRail moved to ui/deck/SinglePaneLayout.kt); replaces obsolete
  "hardcoded ctrl = true" anti-pattern note with accurate isMacOS branching.
- Removes compose-desktop.md (superseded by desktop-expert/).
- Adds nostr-expert references: nip19-bech32, event-factory, crypto-and-encryption,
  large-cache. Adds kotlin-expert/common-utilities, compose-expert/rich-text-parsing,
  android-expert/image-loading.
- New skills: account-state (Account + LocalCache), relay-client (subscriptions,
  filter assemblers, preloaders), feed-patterns (FeedFilter + FeedViewModel family),
  auth-signers (NostrSigner across internal / NIP-46 / NIP-55).
This commit is contained in:
Claude
2026-04-21 21:00:45 +00:00
parent 9147f1b08b
commit 60edd473c7
31 changed files with 1743 additions and 373 deletions
@@ -0,0 +1,128 @@
# Adding a New Feed
Step-by-step recipe for composing a new feed. Assume the feed shows `Note`s filtered by some criterion and should update reactively when the underlying state changes.
## 1. Choose a Filter Base
| If… | Use |
|-----|-----|
| Membership is stable (e.g. "my follows") and you re-compute on change | `FeedFilter<Note>` |
| New notes arrive one at a time and should slot into the list incrementally | `AdditiveComplexFeedFilter<Note, Set<Note>>` |
| The feed is a simple list that changes frequently (e.g. bookmarks, lists) | `FeedFilter<Note>` + `ListChangeFeedViewModel` |
| The feed is a DM thread | `ChatroomFeedViewModel` (already provides filter machinery) |
All live in `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/`.
## 2. Write the Filter
```kotlin
class HashtagFeedFilter(
private val accountViewModel: AccountViewModel,
private val hashtag: String,
) : AdditiveComplexFeedFilter<Note, Set<Note>>() {
override fun feedKey(): String = "Hashtag-$hashtag"
override fun showHiddenKey(): Boolean = false
override fun feed(): List<Note> {
val params = FilterByListParams.create(
excludeMuted = true,
hiddenUsers = accountViewModel.hiddenUsersFlow.value,
)
return LocalCache.hashtagIndex[hashtag]
.orEmpty()
.filter { params.match(it) }
.sortedWith(DefaultFeedOrder)
.take(limit())
}
override fun applyFilter(collection: Set<Note>): Set<Note> =
collection.filter { it.event?.isTaggedHash(hashtag) == true }.toSet()
override fun sort(collection: Set<Note>): List<Note> =
collection.sortedWith(DefaultFeedOrder)
override fun limit(): Int = 1000
}
```
Key points:
- `feedKey()` must uniquely identify this filter *instance*. The parameter (hashtag in this case) is part of the key so two hashtag feeds don't share state.
- `feed()` is the full recompute — synchronous, runs on a background dispatcher.
- `applyFilter()` is the per-event membership check used by the additive path.
- Always use `FilterByListParams` rather than re-implementing mute / hidden-user logic.
- `DefaultFeedOrder` is the canonical sort; deviating breaks paging assumptions.
## 3. Pick a ViewModel
If an existing ViewModel already matches the flow pattern, reuse it with your new filter:
```kotlin
class HashtagFeedViewModel(
val hashtag: String,
accountViewModel: AccountViewModel,
) : FeedViewModel(
localFilter = HashtagFeedFilter(accountViewModel, hashtag),
cacheProvider = LocalCache,
)
```
If membership changes aggressively (e.g. the user toggles a mute), use `ListChangeFeedViewModel` instead and hook into `Account.muteListFlow`.
## 4. Wire Invalidation
`FeedViewModel` already re-queries on `LocalCacheFlow` ticks. For changes that come from `Account` state (mutes, follows, bookmarks, relay list updates) add them in the ViewModel:
```kotlin
init {
viewModelScope.launch {
accountViewModel.muteListFlow.collect { invalidateAll() }
}
}
```
`invalidateAll()` triggers a full `feed()` re-run; `invalidateInsertData(addedNotes)` is the additive path.
## 5. Subscribe to Relays
Unless the feed only shows already-cached data, write a `Subscribable` that fetches history. See `relay-client` skill. Typically:
```kotlin
val subscribable = rememberSubscribable(hashtag) {
HashtagFilterAssembler(hashtag).toSubscribable()
}
DisposableEffect(hashtag) {
subscribable.subscribe()
onDispose { subscribable.unsubscribe() }
}
```
## 6. Render
```kotlin
val feedState by viewModel.feedState.feedContent.collectAsStateWithLifecycle()
LazyColumn {
items(
items = feedState.feed.value,
key = { it.idHex },
) { note ->
NoteCompose(note)
}
}
```
Use `key = { it.idHex }` so Compose can diff efficiently across additive updates.
## 7. Test
Unit-test the filter in isolation: feed it a known `LocalCache` snapshot and assert the output order. Filters are side-effect-free once `LocalCache` is fixed, so they're straightforward to pin.
## Common Mistakes
- **Inline filtering in composables.** If you call `LocalCache.notes.filter { … }` in a composable, the filter recomputes every recomposition and never invalidates correctly. Always go through a `FeedFilter`.
- **Forgetting `showHiddenKey()`.** If you want a "show hidden" toggle, override it; otherwise hidden content is silently dropped.
- **Non-stable `feedKey()`.** Using a hash that depends on current time or mutable state causes the ViewModel to lose its cached state on every invalidation.
- **Skipping `FilterByListParams`.** Muted users, reported users, spam filter — all of it lives there. Reimplementing is a source of bugs.
@@ -0,0 +1,90 @@
# ViewModel Base Classes
Inheritance tree for the shared feed ViewModels in `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/`.
## Tree
```
androidx.lifecycle.ViewModel
InvalidatableContent (interface)
FeedViewModel(localFilter: FeedFilter<Note>, cacheProvider: ICacheProvider)
├── ListChangeFeedViewModel (list membership changes often)
│ │
│ └── (concrete bookmark / list feeds)
├── ChatroomFeedViewModel (DM thread)
└── MarmotGroupFeedViewModel (NIP-29 group chat)
```
Tangentially related (same folder, not in the tree):
- `LiveStreamTopZappersViewModel.kt` — sidebar state for live streams.
- `SearchBarState.kt` — search input + suggestions.
- `ChatNewMessageState.kt` — composer state for a new DM.
- `thread/*` — thread ViewModels (not technically feeds but share wiring).
## `FeedViewModel`
```kotlin
abstract class FeedViewModel(
localFilter: FeedFilter<Note>,
val cacheProvider: ICacheProvider,
) : ViewModel(), InvalidatableContent {
val feedState = FeedContentState(localFilter, viewModelScope, cacheProvider)
fun invalidateAll() // full recompute
fun invalidateInsertData(newNotes: Set<Note>) // additive path
fun invalidateReplace(replaced: Set<Note>) // replaceable/addressable update
}
```
`FeedContentState` is the thing the UI collects:
- `feedContent: StateFlow<FeedState>` — the actual list, loading flag, paging state.
- Runs the `localFilter.feed()` on a background dispatcher.
- Debounces consecutive invalidations so bursts of relay frames don't thrash the filter.
## `ListChangeFeedViewModel`
Extends `FeedViewModel`. Override point:
```kotlin
abstract class ListChangeFeedViewModel(...) : FeedViewModel(...) {
// Automatically re-invalidates on Account list-flow changes
abstract fun dependencyList(): List<Flow<*>>
}
```
Used for bookmarks, mutes, and custom `NIP-51` lists — anything whose membership is decided by an `Account` StateFlow.
## `ChatroomFeedViewModel`
Wraps filter + relay subscription + typing-indicator state for a single DM thread. Use directly for chat screens; don't reimplement per-thread.
## `MarmotGroupFeedViewModel`
NIP-29 (marmot variant) group feed. Adds group membership / moderator state on top of the base feed.
## When to Extend vs Reuse
- **Just a new filter** → instantiate `FeedViewModel` with your filter; no new class needed.
- **New invalidation signal** → subclass and override `init` to add collectors.
- **Entirely new paging model** (infinite scroll, server-assisted paging) → subclass with a custom `FeedContentState`.
- **Non-feed state** (search, composer) → don't use `FeedViewModel` at all; see `SearchBarState.kt` / `ChatNewMessageState.kt` for narrow-state patterns.
## Platform Wrapping
On Android, feed ViewModels are created via `viewModel { HashtagFeedViewModel(...) }` in the composable. On Desktop, they're instantiated directly and stored in a `WorkspaceManager` column (see `desktopApp/.../ui/deck/WorkspaceManager.kt`). The ViewModel class itself is KMP-friendly.
## Gotchas
- **Multiple subscribers to `feedContent`** are fine — it's a `StateFlow`.
- **ViewModels survive configuration changes on Android** but not on Desktop `key {}` rebuilds — re-instantiate in Desktop's workspace lifecycle.
- **`cacheProvider` is almost always `LocalCache`** but the parameter exists so tests can inject a fixture.
- **Don't call `invalidateAll()` from UI** — it's triggered by the ViewModel's own collectors. Calling it from the composable just causes extra filter runs.