Refreshes .claude/ skill library: fixes stale refs, adds 4 new skills
- Updates CLAUDE.md tech stack to current versions (Compose 1.10.3, Kotlin 2.3.20). - Reframes kotlin-multiplatform iOS as mature; adds secp256k1-kmp 0.23.0 references. - Updates desktop-expert Main.kt references (code grew from ~270 to 1341 lines and NavigationRail moved to ui/deck/SinglePaneLayout.kt); replaces obsolete "hardcoded ctrl = true" anti-pattern note with accurate isMacOS branching. - Removes compose-desktop.md (superseded by desktop-expert/). - Adds nostr-expert references: nip19-bech32, event-factory, crypto-and-encryption, large-cache. Adds kotlin-expert/common-utilities, compose-expert/rich-text-parsing, android-expert/image-loading. - New skills: account-state (Account + LocalCache), relay-client (subscriptions, filter assemblers, preloaders), feed-patterns (FeedFilter + FeedViewModel family), auth-signers (NostrSigner across internal / NIP-46 / NIP-55).
This commit is contained in:
@@ -0,0 +1,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.
|
||||
Reference in New Issue
Block a user