Compare commits

...

1 Commits

Author SHA1 Message Date
Claude
03fe87c376 feat(feeds): migrate Home feed off the global fan-out onto LocalCache observers
Start the dal/FeedFilter -> LocalCache.observer migration with the Home feed
as the pilot. Today every open feed re-runs its filter over every new-event
bundle (AccountFeedContentStates.updateFeedsWith), an O(feeds x events)
fan-out. LocalCache already has an inverted index (FilterIndex<Observable>)
that wakes only observers whose Nostr Filter narrows on a field the event
carries.

This moves homeNewThreads / homeReplies / homeEverything onto that index
while leaving FeedContentState byte-for-byte unchanged: a stateless observer
forwards index-matched inserts/removes into the existing
updateFeedWith/deleteFromFeed additive path, so inclusion logic
(FilterByListParams, isNewThread, sort, limit) and the
lastNoteCreatedAtWhenFullyLoaded relay-pagination cursor are untouched. Only
the trigger narrows from "every event" to "events whose kind this feed cares
about".

- commons: add IndexableFeedFilter (opt-in marker yielding the coarse index
  Filter(s) — a superset of feed()).
- amethyst: add LocalCache.observeFeedDeltas + FeedNoteDelta.
- amethyst: implement indexFilters()/INDEX_KINDS on the three Home filters
  (full acceptableEvent kind superset, co-located with the type switch).
- amethyst: connectObservedFeed in AccountFeedContentStates; drop the three
  Home feeds from updateFeedsWith/deleteNotes (homeLive stays — not migrated).
- amethyst: HomeFeedIndexKindsTest asserts INDEX_KINDS covers every rendered
  relay-subscription kind (it already caught two relay-only kinds).
- plan: amethyst/plans/2026-06-18-dal-filter-to-localcache-observer.md.

On-device Home smoke test (live posts, mute/follow-switch refresh,
scroll-to-top, relay pagination) is the remaining gate before merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMHPCdmcjnmC3ZGfTMsWsa
2026-06-18 04:23:34 +00:00
8 changed files with 502 additions and 12 deletions

View File

@@ -0,0 +1,189 @@
# Migrating feeds from the `dal` filter scan to `LocalCache` observers
Date: 2026-06-18
Branch: `claude/dal-filter-localcache-migration-65r8aj`
Status: in progress — infrastructure + Home pilot
## Problem
Every scrollable list in Amethyst (home, profile, hashtag, discover, …) is a
`FeedFilter`/`AdditiveFeedFilter` subclass (≈70 of them, mostly under
`amethyst/.../ui/screen/loggedIn/*/dal/`) driven by `FeedContentState` /
`FeedViewModel`.
The data-sourcing model is a **global fan-out**:
- `AccountViewModel` collects one global event stream
(`LocalCache.getEventStream().newEventBundles`) and calls
`AccountFeedContentStates.updateFeedsWith(newNotes)`.
- `updateFeedsWith` pushes the *same* new-note bundle into **every** live
`FeedContentState`, each of which runs its filter's `applyFilter` over the
bundle. Cost is O(open feeds × events), even for feeds the event can't
possibly belong to.
- A cold `feed()` is a full linear scan of `LocalCache.notes` /
`LocalCache.addressables`.
`LocalCache` already grew the replacement: an inverted index
(`observables: FilterIndex<Observable>`). On insertion, an event fans out only
to observers whose Nostr `Filter` narrows on a field the event actually carries
(`candidatesFor(event)`), then each candidate runs `filter.match` to enforce
negative constraints. Cost is O(matching observers). The public surface is
`LocalCache.observeNotes(filter)` / `observeEvents(filter)` /
`observeNewEvents(...)` returning cold `Flow`s, and several newer call sites
already use it (`UserProfileFollowersUserFeedViewModel`,
`UserProfileZapsViewModel`, the `Room*State` holders in commons, notifications
dispatcher, …).
**Goal:** move the `dal` feeds off the global fan-out onto the indexed observer
registry, then retire the global `updateFeedsWith`/`deleteNotes` plumbing and
(eventually) the `AdditiveComplexFeedFilter`/`FilterByListParams` scan helpers
once nothing depends on them.
## The gap that makes this non-trivial
`observeNotes(filter)` applies **only** the Nostr `Filter`. Most feeds layer on
predicates the Filter grammar can't express:
- `FilterByListParams.match()` — top-nav follow/relay/global/community
membership, hidden users, muted threads, future-dating, excessive-hashtag
guard. Driven by **reactive** state (`account.liveHomeFollowLists`,
`account.hiddenUsers.flow`).
- `Note.isNewThread()` / `!isNewThread()`, NIP-32 follow-label cross-refs
(hashtag feed), repost de-duplication, etc.
Two consequences:
1. We can't just hand a `Filter` to `observeNotes` and be done — the observer
must still run the filter's own `applyFilter`/`feed` predicates. So we need a
richer observer that **reuses the existing `FeedFilter` verbatim** and is
merely *triggered* by the index instead of by the global bundle.
2. When the reactive inputs change (follow list switched, user muted), the
membership predicate changes for events already in the list. The index only
helps with *new* events; membership-change re-evaluation stays an explicit
`invalidateData()` (full `feed()` re-scan), exactly as today. We keep the
existing Account-flow → invalidate wiring for that.
## Design: stateless delta forwarding into the existing additive path
The lowest-risk shape — and the one implemented — leaves `FeedContentState`
**byte-for-byte unchanged** and changes only the *trigger*. Because
`FeedContentState` already has a robust additive engine
(`updateFeedWith``invalidateInsertData``refreshFromOldState`
`AdditiveFeedFilter.updateListWith``applyFilter`) that reconciles against its
own displayed list, dedups, handles `feedKey` mismatch (full re-scan) and
deletions, the migrated feed just needs its candidate notes delivered there from
the index instead of from the global bundle. Making the observer **stateless**
(no list of its own) avoids a second source of truth that could desync from the
`FeedContentState` list.
Two pieces:
1. **`IndexableFeedFilter` (commons)** — opt-in marker giving the coarse index
`Filter`(s): the narrowest Nostr filter that is a *superset* of what `feed()`
returns, so the index never drops a real match.
```
interface IndexableFeedFilter { fun indexFilters(): List<Filter> }
```
For the Home filters that is `Filter(kinds = <every kind acceptableEvent
admits>)` — the full superset, co-located as `INDEX_KINDS` next to
`acceptableEvent`. The index buckets by kind; `FilterByListParams.match` +
`isNewThread()` still run inside `applyFilter`, unchanged. (Authors are
intentionally *not* in the coarse filter even when the top-nav is an author
list — the author set is large and reactive; kind-bucketing is selective
enough and keeps re-subscription off the follow-list-change path.)
2. **`LocalCache.observeFeedDeltas(filters): Flow<FeedNoteDelta>` (amethyst)** —
modelled on `observeNotes`: registers a stateless `Observable` whose `new` /
`remove` emit `FeedNoteDelta.Added` / `Removed`; `awaitClose { unregister }`;
`UNLIMITED` buffer (deltas must not be conflated — a dropped `Added` is a
missing post).
### Wiring (`AccountFeedContentStates`)
- **Today:** `updateFeedsWith(bundle)` → `feedState.updateFeedWith(bundle)` for
every feed (global O(feeds × events) fan-out).
- **After:** `connectObservedFeed(state, filter)` collects
`observeFeedDeltas(filter.indexFilters())` and routes
`Added → state.updateFeedWith(setOf(note))`,
`Removed → state.deleteFromFeed(setOf(note))`. The three Home feeds are
removed from `updateFeedsWith` / `deleteNotes` (only `homeLive`, a
`ChannelFeedContentState`, stays on the fan-out — not migrated in step 1).
Per-event `updateFeedWith(setOf(note))` re-bundles inside
`BasicBundledInsert.invalidateList` (it queues items and drains them as a batch
every 250 ms), so this is functionally equivalent to today's per-bundle call.
`lastNoteCreatedAtWhenFullyLoaded` is still computed by `updateFeed` from
`size >= limit()`, so relay pagination via `HomeOutboxEventsEoseManager` is
unaffected. Initial load and membership-change refreshes keep flowing through
the existing `FeedContentState` scan path (`checkKeysInvalidateDataAndSendToTop`
in `WatchAccountForHomeScreen`, pull-refresh `invalidateData`) — untouched.
## Pilot: the Home feed
Home is the hardest feed and the one chosen to prove the pattern end-to-end. It
has the deepest integration surface, so if the bridge holds here it holds
everywhere:
- Filters: `HomeNewThreadFeedFilter`, `HomeConversationsFeedFilter`
(`HomeEverythingFeedFilter` composes the two; `HomeLiveFilter` is a separate
`ChannelFeedContentState` — out of scope for step 1).
- State holders: `AccountFeedContentStates.homeNewThreads` / `homeReplies` /
`homeEverything`.
- Consumers that must keep working unchanged:
- `HomeScreen` (renders the three `FeedContentState`s).
- `AccountViewModel` tab badges (`tabHasNewItems(..., homeNewThreads.feedContent)`).
- `HomeOutboxEventsEoseManager` (relay pagination via
`homeNewThreads.lastNoteCreatedAtWhenFullyLoaded` / `lastNoteCreatedAtIfFilled`).
### Steps
1. **commons:** add `IndexableFeedFilter`. ✅
2. **amethyst:** add `LocalCache.observeFeedDeltas(...)` + `FeedNoteDelta`. ✅
3. **amethyst:** implement `indexFilters()` / `INDEX_KINDS` on the three home
filters (`HomeNewThreadFeedFilter`, `HomeConversationsFeedFilter`,
`HomeEverythingFeedFilter`). ✅
4. **amethyst:** `connectObservedFeed` in `AccountFeedContentStates`; drop the
three home feeds from `updateFeedsWith` / `deleteNotes`; keep their
membership-change invalidation. ✅
5. **amethyst test:** `HomeFeedIndexKindsTest` asserts `INDEX_KINDS` covers
every rendered relay-subscription kind. ✅
6. Build `:amethyst`; run unit tests. **On-device smoke test of the Home screen
(new posts appear live, mute/follow-switch refresh, scroll-to-top, relay
pagination) is the remaining manual gate before merge** — it can't be
verified headless.
## Rollout order after the pilot
Migrate in increasing order of predicate complexity, one PR per cluster, each
behind the same `IndexableFeedFilter` opt-in so un-migrated feeds keep using the
global fan-out until converted:
1. Single-author / single-tag feeds (profile sub-feeds, hashtag, geohash,
git-repo, community) — small coarse filters, easy verification.
2. Kind-scoped discover/list feeds (articles, longs, video, pictures, polls,
marketplace, …).
3. Chat/DM list feeds (`ChatroomList*`) — already have bespoke invalidation.
4. Notifications (`CardFeedContentState`) — special mute/delete handling.
5. Home everything + live, then retire `updateFeedsWith`/`deleteNotes` and the
global event-stream collection in `AccountViewModel`.
Once every feed is migrated, retire `FilterByListParams`'s use inside scan-based
`feed()` only where it's been fully replaced, and the `AdditiveComplexFeedFilter`
scan helpers if unused. (`FeedFilter`/`AdditiveFeedFilter` stay — the observer
reuses them.)
## Risks / open questions
- **Coarse-filter completeness.** `indexFilters()` must be a superset of
`feed()`; a missing kind silently drops posts. Mitigation: derive the kind
list from the same `acceptableEvent` switch and assert in tests.
- **Re-subscription churn** if `indexFilters()` ever depends on reactive author
sets. Avoided in the pilot by indexing on kind only.
- **Double counting** while a feed is half-migrated: a feed must be either on
the observer *or* in `updateFeedsWith`, never both. The opt-in check
(`is IndexableFeedFilter`) enforces this per feed.
- **Headless verification limit.** Unit tests cover the observer/bridge logic;
the Home screen behaviour itself needs a device/emulator pass.
</invoke>

View File

@@ -314,6 +314,22 @@ interface ILocalCache {
}
}
/**
* An incremental change emitted by [LocalCache.observeFeedDeltas]: a candidate
* note was inserted ([Added]) or removed ([Removed]) from the cache.
*/
sealed interface FeedNoteDelta {
val note: Note
data class Added(
override val note: Note,
) : FeedNoteDelta
data class Removed(
override val note: Note,
) : FeedNoteDelta
}
object LocalCache : ILocalCache, ICacheProvider {
val antiSpam = AntiSpamFilter()
@@ -547,6 +563,51 @@ object LocalCache : ILocalCache, ICacheProvider {
fun observeLatestNote(filter: Filter) = observeNotes(filter).map { it.firstOrNull() }
/**
* Emits an incremental [FeedNoteDelta] for each note inserted/removed that
* the inverted index considers a candidate for [filters] (i.e. its kind /
* author / tag narrows on one of the filters). The index is a *superset*:
* the consumer (a `FeedFilter.applyFilter`, via
* `FeedContentState.updateFeedWith`) still decides final inclusion, exactly
* as it does today for the global new-event fan-out.
*
* This replaces, per migrated feed, that feed's participation in
* `AccountFeedContentStates.updateFeedsWith` — the feed is now woken only
* for matching kinds instead of for every event. Full (re)loads and
* membership-change refreshes still go through the existing
* `FeedContentState` scan path.
*
* Removals are broadcast to every observer (deletions have no filterable
* shape), so a [FeedNoteDelta.Removed] may reference a note this feed never
* held; the downstream `deleteFromFeed` no-ops in that case.
*
* See `amethyst/plans/2026-06-18-dal-filter-to-localcache-observer.md`.
*/
fun observeFeedDeltas(filters: List<Filter>): Flow<FeedNoteDelta> =
callbackFlow {
val observer =
object : Observable {
override fun new(
event: Event,
note: Note,
) {
trySend(FeedNoteDelta.Added(note))
}
override fun remove(note: Note) {
trySend(FeedNoteDelta.Removed(note))
}
}
observables.register(filters, observer)
awaitClose {
observables.unregister(observer)
}
// UNLIMITED (not CONFLATED): deltas must not be collapsed — a dropped
// Added is a post missing from the feed until the next full refresh.
}.buffer(kotlinx.coroutines.channels.Channel.UNLIMITED)
fun checkGetOrCreateUser(key: String): User? = runCatching { getOrCreateUser(key) }.getOrNull()
fun load(keys: List<String>): List<User> = keys.mapNotNull(::checkGetOrCreateUser)

View File

@@ -21,7 +21,9 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.IndexableFeedFilter
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.FeedNoteDelta
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.TopFilter
@@ -81,9 +83,16 @@ class AccountFeedContentStates(
val scope: CoroutineScope,
) {
val homeLive = ChannelFeedContentState(HomeLiveFilter(account), scope)
val homeNewThreads = FeedContentState(HomeNewThreadFeedFilter(account), scope, LocalCache)
val homeReplies = FeedContentState(HomeConversationsFeedFilter(account), scope, LocalCache)
val homeEverything = FeedContentState(HomeEverythingFeedFilter(account), scope, LocalCache)
// Migrated to the LocalCache observer registry: these three feeds are woken
// by observeFeedDeltas (indexed by kind) in init{} rather than by the global
// updateFeedsWith fan-out below.
private val homeNewThreadsFilter = HomeNewThreadFeedFilter(account)
private val homeRepliesFilter = HomeConversationsFeedFilter(account)
private val homeEverythingFilter = HomeEverythingFeedFilter(account)
val homeNewThreads = FeedContentState(homeNewThreadsFilter, scope, LocalCache)
val homeReplies = FeedContentState(homeRepliesFilter, scope, LocalCache)
val homeEverything = FeedContentState(homeEverythingFilter, scope, LocalCache)
val dmKnown = FeedContentState(ChatroomListKnownFeedFilter(account), scope, LocalCache)
val dmNew = FeedContentState(ChatroomListNewFeedFilter(account), scope, LocalCache)
@@ -138,7 +147,33 @@ class AccountFeedContentStates(
val webBookmarks = FeedContentState(WebBookmarkFeedFilter(account), scope, LocalCache)
/**
* Wires a migrated feed to the [LocalCache] observer registry: incremental
* inserts/removes for the feed's indexed kinds are routed into the same
* `FeedContentState` additive path the global fan-out used, so behaviour is
* unchanged — only the trigger narrows from "every event" to "events whose
* kind this feed cares about". Initial load and membership-change refreshes
* still flow through the existing `FeedContentState` scan path.
*/
private fun connectObservedFeed(
state: FeedContentState,
filter: IndexableFeedFilter,
) {
scope.launch(Dispatchers.IO) {
LocalCache.observeFeedDeltas(filter.indexFilters()).collect { delta ->
when (delta) {
is FeedNoteDelta.Added -> state.updateFeedWith(setOf(delta.note))
is FeedNoteDelta.Removed -> state.deleteFromFeed(setOf(delta.note))
}
}
}
}
init {
connectObservedFeed(homeNewThreads, homeNewThreadsFilter)
connectObservedFeed(homeReplies, homeRepliesFilter)
connectObservedFeed(homeEverything, homeEverythingFilter)
// Marmot group list changes (new group, group marked known, group
// metadata synced) don't flow through LocalCache.newEventBundles, so
// the additive update path can't see them. Force a full feed rebuild
@@ -187,10 +222,9 @@ class AccountFeedContentStates(
fun updateFeedsWith(newNotes: Set<Note>) {
checkNotInMainThread()
// homeNewThreads / homeReplies / homeEverything are driven by the
// LocalCache observer registry (see connectObservedFeed), not this fan-out.
homeLive.updateFeedWith(newNotes)
homeNewThreads.updateFeedWith(newNotes)
homeReplies.updateFeedWith(newNotes)
homeEverything.updateFeedWith(newNotes)
dmKnown.updateFeedWith(newNotes)
dmNew.updateFeedWith(newNotes)
@@ -248,10 +282,9 @@ class AccountFeedContentStates(
fun deleteNotes(newNotes: Set<Note>) {
checkNotInMainThread()
// homeNewThreads / homeReplies / homeEverything handle removals via their
// observer subscription (FeedNoteDelta.Removed), not this fan-out.
homeLive.deleteFromFeed(newNotes)
homeNewThreads.deleteFromFeed(newNotes)
homeReplies.deleteFromFeed(newNotes)
homeEverything.deleteFromFeed(newNotes)
dmKnown.updateFeedWith(newNotes)
dmNew.updateFeedWith(newNotes)

View File

@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal
import com.vitorpamplona.amethyst.commons.ui.feeds.IndexableFeedFilter
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
@@ -30,6 +31,7 @@ import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
@@ -41,7 +43,30 @@ import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent
class HomeConversationsFeedFilter(
val account: Account,
) : AdditiveFeedFilter<Note>() {
) : AdditiveFeedFilter<Note>(),
IndexableFeedFilter {
companion object {
/**
* Every event kind [acceptableEvent] can admit, for registering this feed's
* observer in the `LocalCache` inverted index. MUST stay a superset of the
* type switch in [acceptableEvent] and of the relay-subscription kinds in
* `FilterHomePostsByAuthors` (asserted by `HomeFeedIndexKindsTest`).
*/
val INDEX_KINDS =
listOf(
TextNoteEvent.KIND,
ZapPollEvent.KIND,
PollResponseEvent.KIND,
ChannelMessageEvent.KIND,
CommentEvent.KIND,
VoiceReplyEvent.KIND,
PublicMessageEvent.KIND,
LiveActivitiesChatMessageEvent.KIND,
)
}
override fun indexFilters(): List<Filter> = listOf(Filter(kinds = INDEX_KINDS))
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultHomeFollowList.value
override fun showHiddenKey(): Boolean =

View File

@@ -20,12 +20,14 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal
import com.vitorpamplona.amethyst.commons.ui.feeds.IndexableFeedFilter
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
@@ -35,10 +37,13 @@ import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
*/
class HomeEverythingFeedFilter(
val account: Account,
) : AdditiveFeedFilter<Note>() {
) : AdditiveFeedFilter<Note>(),
IndexableFeedFilter {
private val newThreads = HomeNewThreadFeedFilter(account)
private val conversations = HomeConversationsFeedFilter(account)
override fun indexFilters(): List<Filter> = newThreads.indexFilters() + conversations.indexFilters()
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultHomeFollowList.value
override fun showHiddenKey(): Boolean =

View File

@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal
import com.vitorpamplona.amethyst.commons.ui.feeds.IndexableFeedFilter
import com.vitorpamplona.amethyst.commons.ui.feeds.isRenderableRepost
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
@@ -42,6 +43,7 @@ import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStory
import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
@@ -59,7 +61,8 @@ import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
class HomeNewThreadFeedFilter(
val account: Account,
) : AdditiveFeedFilter<Note>() {
) : AdditiveFeedFilter<Note>(),
IndexableFeedFilter {
companion object {
val ADDRESSABLE_KINDS =
listOf(
@@ -76,8 +79,49 @@ class HomeNewThreadFeedFilter(
LiveChessGameEndEvent.KIND,
AttestationEvent.KIND,
)
/**
* Every event kind that [acceptableEvent] can admit, used only to register
* this feed's observer in the `LocalCache` inverted index (so the feed is
* woken for these kinds instead of every event). This MUST stay a superset
* of the type switch in [acceptableEvent]: a kind accepted there but missing
* here would only enter the feed on a full refresh, not live. It is a
* superset of the relay-subscription kinds in `FilterHomePostsByAuthors`
* (asserted by `HomeFeedIndexKindsTest`).
*/
val INDEX_KINDS =
listOf(
TextNoteEvent.KIND,
RepostEvent.KIND,
GenericRepostEvent.KIND,
ClassifiedsEvent.KIND,
FundraiserEvent.KIND,
BirdexEvent.KIND,
LongTextNoteEvent.KIND,
WikiNoteEvent.KIND,
ZapPollEvent.KIND,
PollEvent.KIND,
HighlightEvent.KIND,
InteractiveStoryPrologueEvent.KIND,
CommentEvent.KIND,
AudioTrackEvent.KIND,
MusicTrackEvent.KIND,
MusicPlaylistEvent.KIND,
PodcastEpisodeEvent.KIND,
PodcastMetadataEvent.KIND,
VoiceEvent.KIND,
AudioHeaderEvent.KIND,
ChessGameEvent.KIND,
LiveChessGameEndEvent.KIND,
AttestationEvent.KIND,
AttestationRequestEvent.KIND,
AttestorRecommendationEvent.KIND,
AttestorProficiencyEvent.KIND,
)
}
override fun indexFilters(): List<Filter> = listOf(Filter(kinds = INDEX_KINDS))
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultHomeFollowList.value
override fun showHiddenKey(): Boolean =

View File

@@ -0,0 +1,90 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows.HomePostsConversationKinds
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows.HomePostsNewThreadKinds1
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows.HomePostsNewThreadKinds2
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip64Chess.challenge.offer.LiveChessGameChallengeEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Guards the coarse index-kind lists that wake the migrated Home feeds via the
* `LocalCache` observer registry (`observeFeedDeltas`). The danger is a kind
* that arrives **live** from the home relay subscription but is missing from a
* feed's `INDEX_KINDS`: it would only appear after a full refresh, not live.
*
* So every relay-subscription kind that the feed actually renders must be in
* `INDEX_KINDS`. A few kinds are fetched from relays for other purposes but are
* NOT rendered in the Home feed (so they are legitimately absent) — those are
* the documented exclusions below; if one starts being rendered, drop it from
* the exclusion set and add it to `INDEX_KINDS`.
*/
class HomeFeedIndexKindsTest {
// Fetched by the home subscription but NOT rendered (acceptableEvent rejects
// them), so legitimately absent from INDEX_KINDS.
private val newThreadRelayOnly = setOf(NipTextEvent.KIND, LiveChessGameChallengeEvent.KIND)
private val conversationRelayOnly = setOf(LiveActivitiesEvent.KIND, EphemeralChatEvent.KIND, VoiceEvent.KIND)
@Test
fun newThreadIndexKindsCoverEveryRenderedRelayKind() {
val renderedRelayKinds = (HomePostsNewThreadKinds1 + HomePostsNewThreadKinds2).toSet() - newThreadRelayOnly
val indexed = HomeNewThreadFeedFilter.INDEX_KINDS.toSet()
val missing = renderedRelayKinds - indexed
assertTrue(
"Home New Threads INDEX_KINDS is missing rendered relay kinds $missing — they would not appear live",
missing.isEmpty(),
)
}
@Test
fun conversationIndexKindsCoverEveryRenderedRelayKind() {
val renderedRelayKinds = HomePostsConversationKinds.toSet() - conversationRelayOnly
val indexed = HomeConversationsFeedFilter.INDEX_KINDS.toSet()
val missing = renderedRelayKinds - indexed
assertTrue(
"Home Conversations INDEX_KINDS is missing rendered relay kinds $missing — they would not appear live",
missing.isEmpty(),
)
}
@Test
fun indexKindListsHaveNoDuplicates() {
assertEquals(
HomeNewThreadFeedFilter.INDEX_KINDS.size,
HomeNewThreadFeedFilter.INDEX_KINDS.toSet().size,
)
assertEquals(
HomeConversationsFeedFilter.INDEX_KINDS.size,
HomeConversationsFeedFilter.INDEX_KINDS.toSet().size,
)
}
}

View File

@@ -0,0 +1,43 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.feeds
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
/**
* Opt-in marker for a [FeedFilter] that can source its incremental updates from
* the `LocalCache` observer registry (the inverted [com.vitorpamplona.quartz.nip01Core.relay.filters.FilterIndex])
* instead of the global new-event fan-out.
*
* [indexFilters] returns the **coarse** Nostr [Filter](s) used only to register
* the observer in the index. They must be a *superset* of what [FeedFilter.feed]
* returns — the index uses them solely to decide which observers to wake for a
* newly inserted event; the filter's own `applyFilter`/`feed` predicates still
* run and remain the source of truth for inclusion. A filter that narrows too
* tightly here (e.g. a `since`/author constraint the real feed doesn't enforce)
* would silently drop posts, so prefer the broadest reliable narrowing field
* (usually `kinds`).
*
* See `amethyst/plans/2026-06-18-dal-filter-to-localcache-observer.md`.
*/
interface IndexableFeedFilter {
fun indexFilters(): List<Filter>
}