mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 07:48:27 +00:00
Compare commits
1 Commits
b6bf3b8a70
...
claude/dal
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03fe87c376 |
189
amethyst/plans/2026-06-18-dal-filter-to-localcache-observer.md
Normal file
189
amethyst/plans/2026-06-18-dal-filter-to-localcache-observer.md
Normal 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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
Reference in New Issue
Block a user