The 33 descriptions (~16k chars, ~19k with paths) overran Codex's skill-list budget (2% of the context window, or 8,000 chars when unknown), and a review found most bodies stale: paths from before the commons/commonsUI moves, the deleted legacy desktop app, a callback signer API that no longer exists, and example APIs that were never in the tree. Kept, rewritten and checked against the code (every path, class and command): account-state (+feeds), amy-expert, android-expert, auth-signers, compose-expert (+API style, Navigation 3 back stacks), desktop-expert (+run/package), event-store-semantics (+NIP-50 search), find-missing- translations, kotlin-coroutines (+structured concurrency, flow modeling), kotlin-multiplatform (+Gradle, composable extraction), ngit-pr, nip85-trusted-assertions, nostr-expert (+NIP lookup, generated NIP catalog), relay-client. Dropped: textbook Compose/Kotlin technique skills, kotlin-expert, kotlin-types-value-class, gradle-expert, quartz-integration (external-facing; see the Quartz docs commit), find-non-lambda-logs (audit finished; the rule is now in AGENTS.md Kotlin Style), and the /nip, /desktop-run, /extract commands. The skill list is now ~4k chars. Also drops .claude/core-skills-plan.md (stale history) and repoints SearchableEvent's KDoc to event-store-semantics. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7.0 KiB
Feeds
Every scrollable list of notes (home, profile, hashtag, bookmarks, notifications, DMs, discovery,
communities) is a filter that decides membership over LocalCache, plus a state holder that
keeps the result current as events arrive. All of it is headless and shared by Android and Desktop.
Paths are relative to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/.
Where things live
| Piece | Location |
|---|---|
Filter bases: IFeedFilter, FeedFilter<T>, IAdditiveFeedFilter, AdditiveFeedFilter<T>, AdditiveComplexFeedFilter<T, U>, ChangesFlowFilter<T> |
feeds/ |
FilterByListParams (top-nav list, hidden users, muted threads, future dates, hashtag spam) |
feeds/FilterByListParams.kt |
Orders: DefaultFeedOrder, sortedByDefaultFeedOrder(), DefaultFeedOrderEvent, DefaultFeedOrderCard, NotificationFeedOrderCard |
feeds/DefaultFeedOrder.kt, feeds/FeedOrders.kt |
State: FeedContentState, FeedState (Loading / Loaded / Empty / FeedError), LoadedFeedState, plus ChannelFeedContentState, notifications/CardFeedContentState.kt |
feeds/, notifications/ |
ViewModels: FeedViewModel, AndroidFeedViewModel (binds LocalCache), ListChangeFeedViewModel, ChatroomFeedViewModel, MarmotGroupFeedViewModel |
viewmodels/ |
| Account-lifetime feeds (home tabs, DMs, discovery, notifications, …) | viewmodels/AccountFeedContentStates.kt, exposed as BaseAccountViewModel.feedStates |
| Concrete filters + per-screen ViewModels | <feature>/dal/ — home/dal/, hashtag/dal/, notifications/dal/, music/dal/, … (~55 folders) |
| Rendering | commonsUI/…/commons/ui/screen/FeedView.kt (RefresheableFeedView, RenderFeedState), commonsUI/…/commons/ui/feeds/ (FeedLoaded, WatchLifecycleAndUpdateModel) |
There is no Android-only or Desktop-only feed layer any more: write a new filter in
commons/…/<feature>/dal/ and both apps get it.
Choosing a base
| Situation | Base |
|---|---|
| Recompute on change is fine | FeedFilter<Note> |
| Notes arrive one by one and should merge in | AdditiveFeedFilter<Note> (applyFilter + sort) |
| Rows differ in type from incoming notes | AdditiveComplexFeedFilter<T, U> — e.g. home/dal/HomeLiveFilter.kt (<Channel, Note>) |
| Membership is pushed by a list, not discovered in the cache | a ChangesFlowFilter<Note> + ListChangeFeedViewModel (feeds/ChatroomFeedFilter.kt, feeds/MarmotGroupFeedFilter.kt) |
Recipe
Model it on hashtag/dal/ (filter + ViewModel + factory) and
commonsUI/…/commons/ui/screen/loggedIn/hashtag/HashtagScreen.kt (screen).
-
Filter (
hashtag/dal/HashtagFeedFilter.kt, abridged):class HashtagFeedFilter( val tag: String, val relays: Set<NormalizedRelayUrl>, val account: Account, val cache: LocalCache, ) : AdditiveFeedFilter<Note>() { override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + tag override fun feed(): List<Note> { val follows = account.followingKeySet() val notes = cache.notes.filterIntoSet { _, it -> acceptableEvent(it, tag, follows) } return sort(notes) } override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems) override fun sort(items: Set<Note>): List<Note> = items.sortedByDefaultFeedOrder() }acceptableEventends with!it.isHiddenFor(account.hiddenUsers.flow.value) && account.isAcceptable(it). Feeds driven by a top-nav list buildFilterByListParams.create(followLists, account.hiddenUsers.flow.value)and callmatch(...)— seehome/dal/HomeNewThreadFeedFilter.kt(buildFilterParams). -
State holder. A per-screen feed subclasses
AndroidFeedViewModel(filter)and ships aViewModelProvider.Factory(hashtag/dal/HashtagFeedViewModel.kt). A feed that lives as long as the account is aFeedContentState(filter, scope, LocalCache)field inAccountFeedContentStates— also add it to that class's update/delete/trim fan-out (updateFeedsWith,deleteNotes,trimFeedsToSize), or it never sees new events. -
Screen.
rememberViewModel(key = …, factory = HashtagFeedViewModel.Factory(…)), thenWatchLifecycleAndUpdateModel(viewModel)(refresh on first composition and on resume), the relay subscription composable (HashtagFilterAssemblerSubscription, seerelay-client), andRefresheableFeedView(viewModel, …), which collectsfeedState.feedContentand renders byFeedState. -
Invalidation.
FeedViewModelalready collectscacheProvider.getEventStream()newEventBundles→feedState.updateFeedWith(...)(additive merge when the filter is additive and the feed is loaded, full refresh otherwise) anddeletedEventBundles→deleteFromFeed(...). When an account input of the filter changes (top-nav list, mutes), callcheckKeysInvalidateDataAndSendToTop()— it re-runs only iffeedKey()changed. Real use:WatchAccountForHomeScreenincommonsUI/…/ui/screen/loggedIn/home/HomeScreen.ktkeys aLaunchedEffectonaccount.liveHomeFollowLists.invalidateData()forces a full re-run. -
Tests. Existing filter tests are JVM unit tests in
amethyst/src/test/java/…/ui/dal/and…/ui/screen/loggedIn/notifications/dal/(e.g.NotificationTagsAnEventByUserTest.kt), which exercise filter predicates directly.
Traps
feedKey()is the invalidation key. Everything the result depends on — account pubkey, selected list, the resolved constraint set — must be in it, orcheckKeysInvalidateDataAndSendToTop()swallows the change.RelayGroupDiscoveryFeedFilter(chats/publicChannels/relayGroup/dal/) documents a frozen-feed bug from keying only on the list name while the per-relay set resolved a frame later.- Additive merges only add.
AdditiveFeedFilter.updateListWithunions accepted new items into the old list; it never re-checks old rows. If a new event can invalidate an existing row, overrideupdateListWith(HomeNewThreadFeedFilterdrops superseded RSVPs first).applyFiltermust accept exactly whatfeed()would, or the list drifts until the next full refresh. - Sort with
sortedByDefaultFeedOrder(), notsortedWith(DefaultFeedOrder), forNotelists that may change under you. It snapshotscreatedAtonce per note; the comparator alone can throw TimSort's "Comparison method violates its general contract!" when a newer addressable swaps a note's event mid-sort. showHiddenKey()returns true for the "muted / blocked" views (FilterByListParams.showHiddenKey,settings/dal/HiddenAccountsFeedFilter.kt); hidden content is otherwise dropped silently.limit()defaults to 500 andAccountFeedContentStatestrims to 200 under memory pressure; don't build feeds that need the full history in memory.- Never filter
LocalCachein a composable.feed()runs onDispatchers.IOinsideFeedContentStatewith 250 ms bundling; a composable scan reruns every recomposition and never invalidates.