feat(relays): explain discovery filters by the feed selection behind them

Commit 4f1bd6e0c2 left the six public-chat discovery producers untagged and
justified it as "they search for chats rather than serving known ones, so there
is no entity to name". The first half is right and the conclusion does not
follow: having no entity is not the same as having no explanation. Every one of
those filters is built from a top-nav selection — Global, your follows, a
hashtag, a geohash, a community — which was known where the filter was built and
simply had nowhere to travel. The screen could only render them as "All", which
is the one thing they are not.

3f4723437e already moved the 25 top-nav value types into commons for exactly
this, so ExplainedFilter now carries the scope. It carries the per-relay value
rather than the whole set: the filter is already scoped to one relay, so it
holds only the slice that applies to it and no reference to the other relays'
authors. It stays a typed value rather than a formatted string because
purposeDetail already taught that lesson — text built in commons can never be
translated, so the UI matches on the type and picks its own wording.

scopedTo() stamps it at each feed's make…Filter dispatch, the last place that
still knows the selection; below it the builders have flattened it into
authors/#t/#g and it is unrecoverable. IFeedTopNavPerRelayFilterSet grew
scopeFor(relay) so that stamping is compiler-enforced across all 11 sets rather
than a type-switch that silently misses the next one added.

The screen groups these rows by scope *type*, not contents: an author-based
selection sends a different slice of the follow list to every relay, so keying
on contents would shatter "People you follow" into one row per relay — the
opposite of what the screen is for.

ExplainedFilterTest had not compiled since 4d53bbea9e renamed entityId to
entityIds, because `./gradlew test` does not run :commons:jvmTest. Repaired, and
extended to pin the new field: the scope is a slice of the user's follow list or
their chosen hashtag, and handing a relay the selection rather than the authors
it already sees would tell it which of its neighbours' filters belong together.

Verified on emulator-5554: Home Feed's row now reads "People you follow" with
its 176 relays, as one row rather than 176. The public-chat discovery producers
take the identical path but only mount while the Discover→Chats screen is open,
which this device's bottom nav has no tab for, so that specific row is unproven
on device.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-07-31 00:20:41 -04:00
co-authored by Claude Opus 5
parent 548a5c979e
commit 8e019a2d59
43 changed files with 247 additions and 51 deletions
@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.Aut
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
@@ -47,7 +48,7 @@ fun makeBadgesFilter(
is MutedAuthorsTopNavPerRelayFilterSet -> filterBadgesByMutedAuthors(feedSettings, since, defaultSince)
is GlobalTopNavPerRelayFilterSet -> filterBadgesGlobal(feedSettings, since, defaultSince)
else -> emptyList()
}
}.scopedTo(feedSettings)
private fun filterBadgesByAuthorsOnRelay(
relay: NormalizedRelayUrl,
@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavP
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.subassemblies.filterCalendarsByAuthors
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.subassemblies.filterCalendarsByFollows
@@ -49,4 +50,4 @@ fun makeCalendarsFilter(
is LocationTopNavPerRelayFilterSet -> filterCalendarsByGeohashes(feedSettings, since, defaultSince)
is MutedAuthorsTopNavPerRelayFilterSet -> filterCalendarsByMutedAuthors(feedSettings, since, defaultSince)
else -> emptyList()
}
}.scopedTo(feedSettings)
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.Aut
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.relay.RelayTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies.filterRelayGroupsByAllCommunities
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies.filterRelayGroupsByAuthors
@@ -58,4 +59,4 @@ fun filterRelayGroupsDiscovery(
is RelayTopNavPerRelayFilterSet -> filterRelayGroupsByRelay(feedSettings, since, defaultSince)
is SingleCommunityTopNavPerRelayFilterSet -> filterRelayGroupsByCommunity(feedSettings, since, defaultSince)
else -> emptyList()
}
}.scopedTo(feedSettings)
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relay
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.User
@@ -88,7 +89,9 @@ class RelayGroupsDiscoverySubAssembler(
)
}
return base + extra
// `base` is scoped by its own builder; these host-relay rosters are built here, so they get
// the same selection stamped on them rather than showing up as an unexplained extra.
return base + extra.scopedTo(feedSettings)
}
/** Relays that host NIP-29 groups the user is connected to: joined (kind-10009) + favorited (kind-10012). */
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommuni
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies.filterLongFormByAllCommunities
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies.filterLongFormByAuthors
@@ -54,4 +55,4 @@ fun makeLongFormFilter(
is MutedAuthorsTopNavPerRelayFilterSet -> filterLongFormByAuthors(feedSettings, since, defaultSince)
is SingleCommunityTopNavPerRelayFilterSet -> filterLongFormByCommunity(feedSettings, since, defaultSince)
else -> emptyList()
}
}.scopedTo(feedSettings)
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommuni
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies.filterPublicChatsByAllCommunities
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies.filterPublicChatsByAuthors
@@ -54,4 +55,4 @@ fun makePublicChatsFilter(
is MutedAuthorsTopNavPerRelayFilterSet -> filterPublicChatsByAuthors(feedSettings, since, defaultSince)
is SingleCommunityTopNavPerRelayFilterSet -> filterPublicChatsByCommunity(feedSettings, since, defaultSince)
else -> emptyList()
}
}.scopedTo(feedSettings)
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommuni
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies.filterFollowSetsByAllCommunities
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies.filterFollowSetsByAuthors
@@ -54,4 +55,4 @@ fun makeFollowSetsFilter(
is MutedAuthorsTopNavPerRelayFilterSet -> filterFollowSetsByAuthors(feedSettings, since, defaultSince)
is SingleCommunityTopNavPerRelayFilterSet -> filterFollowSetsByCommunity(feedSettings, since, defaultSince)
else -> emptyList()
}
}.scopedTo(feedSettings)
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommuni
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies.filterLiveActivitiesByAllCommunities
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies.filterLiveActivitiesByAuthors
@@ -54,4 +55,4 @@ fun makeLiveActivitiesFilter(
is MutedAuthorsTopNavPerRelayFilterSet -> filterLiveActivitiesByAuthors(feedSettings, since, defaultSince)
is SingleCommunityTopNavPerRelayFilterSet -> filterLiveActivitiesByCommunity(feedSettings, since, defaultSince)
else -> emptyList()
}
}.scopedTo(feedSettings)
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommuni
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies.filterCommunitiesByAllCommunities
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies.filterCommunitiesByAuthors
@@ -54,4 +55,4 @@ fun makeCommunitiesFilter(
is MutedAuthorsTopNavPerRelayFilterSet -> filterCommunitiesByAuthors(feedSettings, since, defaultSince)
is SingleCommunityTopNavPerRelayFilterSet -> filterCommunitiesByCommunity(feedSettings, since, defaultSince)
else -> emptyList()
}
}.scopedTo(feedSettings)
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommuni
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsByAllCommunities
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsByAuthors
@@ -54,4 +55,4 @@ fun makeContentDVMsFilter(
is MutedAuthorsTopNavPerRelayFilterSet -> filterContentDVMsByAuthors(feedSettings, since, defaultSince)
is SingleCommunityTopNavPerRelayFilterSet -> filterContentDVMsByCommunity(feedSettings, since, defaultSince)
else -> emptyList()
}
}.scopedTo(feedSettings)
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommuni
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies.filterClassifiedsByAllCommunities
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies.filterClassifiedsByAuthors
@@ -54,4 +55,4 @@ fun makeClassifiedsFilter(
is MutedAuthorsTopNavPerRelayFilterSet -> filterClassifiedsByAuthors(feedSettings, since, defaultSince)
is SingleCommunityTopNavPerRelayFilterSet -> filterClassifiedsByCommunity(feedSettings, since, defaultSince)
else -> emptyList()
}
}.scopedTo(feedSettings)
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavP
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.datasource.subassemblies.filterBrowseEmojiSetsByAuthors
import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.datasource.subassemblies.filterBrowseEmojiSetsByFollows
@@ -46,4 +47,4 @@ fun makeBrowseEmojiSetsFilter(
is GlobalTopNavPerRelayFilterSet -> filterBrowseEmojiSetsGlobal(feedSettings, since, defaultSince)
is HashtagTopNavPerRelayFilterSet -> filterBrowseEmojiSetsByHashtag(feedSettings, since, defaultSince)
else -> emptyList()
}
}.scopedTo(feedSettings)
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommuni
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.datasource.subassemblies.filterGitRepositoriesByAllCommunities
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.datasource.subassemblies.filterGitRepositoriesByAuthors
@@ -55,4 +56,4 @@ fun makeGitRepositoriesFilter(
is MutedAuthorsTopNavPerRelayFilterSet -> filterGitRepositoriesByMutedAuthors(feedSettings, since, defaultSince)
is SingleCommunityTopNavPerRelayFilterSet -> filterGitRepositoriesByCommunity(feedSettings, since, defaultSince)
else -> emptyList()
}
}.scopedTo(feedSettings)
@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavP
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.subassemblies.filterHighlightsByAuthors
import com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.subassemblies.filterHighlightsByFollows
@@ -57,4 +58,4 @@ fun makeHighlightsFilter(
is LocationTopNavPerRelayFilterSet -> filterHighlightsByGeohashes(feedSettings, since, defaultSince)
is MutedAuthorsTopNavPerRelayFilterSet -> filterHighlightsByMutedAuthors(feedSettings, since, defaultSince)
else -> emptyList()
}
}.scopedTo(feedSettings)
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.Aut
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.relay.RelayTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo
import com.vitorpamplona.amethyst.model.HomeFeedType
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.User
@@ -78,7 +79,7 @@ class HomeOutboxEventsEoseManager(
is SingleCommunityTopNavPerRelayFilterSet -> filterHomePostsByCommunity(feedSettings, since, newThreadSince)
is FavoriteAlgoFeedTopNavPerRelayFilterSet -> filterHomePostsByAlgoFeedIds(feedSettings, since, newThreadSince)
else -> emptyList()
}
}.scopedTo(feedSettings)
// Drop the kinds the user turned off in Settings Home from every home relay filter, so a
// disabled group is never downloaded regardless of which top-nav strategy built the filters.
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommuni
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.subassemblies.filterLongsByAllCommunities
import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.subassemblies.filterLongsByAuthors
@@ -55,4 +56,4 @@ fun makeLongsFilter(
is MutedAuthorsTopNavPerRelayFilterSet -> filterLongsByMutedAuthors(feedSettings, since, defaultSince)
is SingleCommunityTopNavPerRelayFilterSet -> filterLongsByCommunity(feedSettings, since, defaultSince)
else -> emptyList()
}
}.scopedTo(feedSettings)
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommuni
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies.MUSIC_PLAYLIST_KINDS
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies.MUSIC_TRACK_KINDS
@@ -82,4 +83,4 @@ private fun makeMusicFilter(
is MutedAuthorsTopNavPerRelayFilterSet -> filterMusicEventsByMutedAuthors(feedSettings, kinds, since, defaultSince)
is SingleCommunityTopNavPerRelayFilterSet -> filterMusicEventsByCommunity(feedSettings, kinds, since, defaultSince)
else -> emptyList()
}
}.scopedTo(feedSettings)
@@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollow
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.subassemblies.filterNappletsByAuthors
import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.subassemblies.filterNappletsByFollows
@@ -51,4 +52,4 @@ fun makeNappletsFilter(
is GlobalTopNavPerRelayFilterSet -> filterNappletsGlobal(feedSettings, since, defaultSince)
is MutedAuthorsTopNavPerRelayFilterSet -> filterNappletsByMutedAuthors(feedSettings, since, defaultSince)
else -> emptyList()
}
}.scopedTo(feedSettings)
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommuni
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.subassemblies.filterNestsByAllCommunities
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.subassemblies.filterNestsByAuthors
@@ -56,7 +57,7 @@ fun makeNestsFilter(
is MutedAuthorsTopNavPerRelayFilterSet -> filterNestsByAuthors(feedSettings, since, defaultSince)
is SingleCommunityTopNavPerRelayFilterSet -> filterNestsByCommunity(feedSettings, since, defaultSince)
else -> return emptyList()
}
}.scopedTo(feedSettings)
if (rooms.isEmpty()) return rooms
// Add a single presence probe per relay we're already querying for
@@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollow
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nsites.datasource.subassemblies.filterNsitesByAuthors
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nsites.datasource.subassemblies.filterNsitesByFollows
@@ -51,4 +52,4 @@ fun makeNsitesFilter(
is GlobalTopNavPerRelayFilterSet -> filterNsitesGlobal(feedSettings, since, defaultSince)
is MutedAuthorsTopNavPerRelayFilterSet -> filterNsitesByMutedAuthors(feedSettings, since, defaultSince)
else -> emptyList()
}
}.scopedTo(feedSettings)
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommuni
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.subassemblies.filterPicturesByAllCommunities
import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.subassemblies.filterPicturesByAuthors
@@ -55,4 +56,4 @@ fun makePicturesFilter(
is MutedAuthorsTopNavPerRelayFilterSet -> filterPicturesByMutedAuthors(feedSettings, since, defaultSince)
is SingleCommunityTopNavPerRelayFilterSet -> filterPicturesByCommunity(feedSettings, since, defaultSince)
else -> emptyList()
}
}.scopedTo(feedSettings)
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommuni
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.PODCASTING20_METADATA_KINDS
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.PODCAST_EPISODE_KINDS
@@ -78,4 +79,4 @@ private fun makePodcastFilter(
is MutedAuthorsTopNavPerRelayFilterSet -> filterPodcastEventsByMutedAuthors(feedSettings, kinds, since, defaultSince, additionalTags)
is SingleCommunityTopNavPerRelayFilterSet -> filterPodcastEventsByCommunity(feedSettings, kinds, since, defaultSince, additionalTags)
else -> emptyList()
}
}.scopedTo(feedSettings)
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommuni
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.subassemblies.filterPollsByAllCommunities
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.subassemblies.filterPollsByAuthors
@@ -55,4 +56,4 @@ fun makePollsFilter(
is LocationTopNavPerRelayFilterSet -> filterPollsByGeohashes(feedSettings, since, defaultSince)
is SingleCommunityTopNavPerRelayFilterSet -> filterPollsByCommunity(feedSettings, since, defaultSince)
else -> emptyList()
}
}.scopedTo(feedSettings)
@@ -60,6 +60,15 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilter
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurposeGroup
import com.vitorpamplona.amethyst.model.LocalCache
@@ -284,6 +293,29 @@ private fun PurposeCard(
}
}
/**
* The feed selection a discovery filter is searching within, in the app's own words.
*
* Hashtags and geohashes read out their own values — they are short, and the whole point is *which*
* hashtag. The author-based selections do not: a follow list is thousands of keys, and per relay it
* is a different slice of them, so naming the kind is the honest summary.
*/
@Composable
private fun scopeLabel(scope: IFeedTopNavPerRelayFilter): String? =
when (scope) {
is GlobalTopNavPerRelayFilter -> stringRes(R.string.active_subs_scope_global)
is AllFollowsTopNavPerRelayFilter -> stringRes(R.string.active_subs_scope_follows)
is AuthorsTopNavPerRelayFilter -> stringRes(R.string.active_subs_scope_authors)
is MutedAuthorsTopNavPerRelayFilter -> stringRes(R.string.active_subs_scope_muted)
is AllCommunitiesTopNavPerRelayFilter -> stringRes(R.string.active_subs_scope_all_communities)
is FavoriteAlgoFeedTopNavPerRelayFilter -> stringRes(R.string.active_subs_scope_algo)
is HashtagTopNavPerRelayFilter -> scope.hashtags.sorted().joinToString(", ") { "#$it" }
is LocationTopNavPerRelayFilter -> scope.geotags.sorted().joinToString(", ")
// The community and the relay already name themselves — the community through its own
// entity row, the relay through the row's relay list — so repeating it here would be noise.
else -> null
}
/** The entity's name, coloured as a link only when tapping it actually goes somewhere. */
@Composable
private fun EntityLabelText(
@@ -400,7 +432,12 @@ private fun EntityBlock(
// Deliberately not falling back to `entity.detail`: that field is a developer breadcrumb set in
// `commons`, where Android string resources do not exist, so it is hardcoded English and could
// never be translated. A localized "no entity" line is better than an untranslatable one.
val label = resolved?.name ?: stringRes(R.string.active_subs_no_entity)
//
// A discovery filter has no entity by nature — it searches for chats and articles rather than
// serving ones already named — but it does carry the selection it searches within, which is a
// far better answer than "no entity". The scope arrives as a typed value for exactly this
// reason: the wording is chosen here, where translations exist.
val label = resolved?.name ?: entity.scope?.let { scopeLabel(it) } ?: stringRes(R.string.active_subs_no_entity)
Column {
Row(
@@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilter
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -56,11 +57,29 @@ import kotlinx.coroutines.withContext
data class SubscriptionEntityRow(
/** Null when the filter named no entity — "the rest of this purpose", not a real entity. */
val entityId: HexKey?,
/**
* The top-nav selection behind a filter that names no entity — what a discovery filter is
* searching *within*. Null for filters that name a real entity, which speaks for itself.
*/
val scope: IFeedTopNavPerRelayFilter?,
val detail: String?,
val relays: List<NormalizedRelayUrl>,
val filterCount: Int,
)
/**
* What makes two filters the same row.
*
* [scopeKey] is the scope's **type**, not its contents: an author-based selection carries a
* different slice of the follow list to every relay, so keying on contents would shatter "People you
* follow" into one row per relay — the opposite of what this screen is for. Selections whose
* contents are the same everywhere (a hashtag, a geohash) render theirs from the retained instance.
*/
private data class EntityKey(
val entityId: HexKey?,
val scopeKey: String?,
)
@Immutable
data class SubscriptionPurposeRow(
val purpose: SubPurpose,
@@ -109,8 +128,9 @@ class ActiveSubscriptionsViewModel : ViewModel() {
val client = Amethyst.instance.client
// account -> purpose -> entity -> relays / count
val byAccount = mutableMapOf<HexKey?, MutableMap<SubPurpose, MutableMap<HexKey?, MutableList<NormalizedRelayUrl>>>>()
val detailOf = mutableMapOf<Pair<SubPurpose, HexKey?>, String?>()
val byAccount = mutableMapOf<HexKey?, MutableMap<SubPurpose, MutableMap<EntityKey, MutableList<NormalizedRelayUrl>>>>()
val detailOf = mutableMapOf<Pair<SubPurpose, EntityKey>, String?>()
val scopeOf = mutableMapOf<Pair<SubPurpose, EntityKey>, IFeedTopNavPerRelayFilter>()
var total = 0
var untagged = 0
val allRelays = mutableSetOf<NormalizedRelayUrl>()
@@ -124,17 +144,23 @@ class ActiveSubscriptionsViewModel : ViewModel() {
return@forEach
}
allRelays.add(relay)
// Discovery filters name no entity — they go looking for things rather than
// serving known ones — but they do carry the selection they search within, which
// is what keeps them from collapsing into one nameless row per purpose.
val scopeKey = explained.scope?.let { it::class.simpleName }
// A batched filter serves several entities at once — relay-group state is one #d
// filter per host relay carrying every joined group on it — so it contributes a
// row to each of them rather than collapsing to "All".
val entities: List<HexKey?> = explained.entityIds?.takeIf { it.isNotEmpty() } ?: listOf(null)
entities.forEach { entityId ->
val key = EntityKey(entityId, scopeKey)
byAccount
.getOrPut(explained.accountPubKey) { mutableMapOf() }
.getOrPut(explained.purpose) { mutableMapOf() }
.getOrPut(entityId) { mutableListOf() }
.getOrPut(key) { mutableListOf() }
.add(relay)
detailOf[explained.purpose to entityId] = explained.purposeDetail
detailOf[explained.purpose to key] = explained.purposeDetail
explained.scope?.let { scopeOf.getOrPut(explained.purpose to key) { it } }
}
}
}
@@ -147,10 +173,11 @@ class ActiveSubscriptionsViewModel : ViewModel() {
.map { (purpose, entities) ->
val entityRows =
entities
.map { (entityId, relays) ->
.map { (key, relays) ->
SubscriptionEntityRow(
entityId = entityId,
detail = detailOf[purpose to entityId],
entityId = key.entityId,
scope = scopeOf[purpose to key],
detail = detailOf[purpose to key],
relays = relays.distinct().sortedBy { it.url },
filterCount = relays.size,
)
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommuni
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.datasource.subassemblies.filterShortsByAllCommunities
import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.datasource.subassemblies.filterShortsByAuthors
@@ -55,4 +56,4 @@ fun makeShortsFilter(
is MutedAuthorsTopNavPerRelayFilterSet -> filterShortsByMutedAuthors(feedSettings, since, defaultSince)
is SingleCommunityTopNavPerRelayFilterSet -> filterShortsByCommunity(feedSettings, since, defaultSince)
else -> emptyList()
}
}.scopedTo(feedSettings)
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavP
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.datasource.subassemblies.filterSoftwareAppsByAuthors
import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.datasource.subassemblies.filterSoftwareAppsByFollows
@@ -48,4 +49,4 @@ fun makeSoftwareAppsFilter(
is HashtagTopNavPerRelayFilterSet -> filterSoftwareAppsByHashtag(feedSettings, since, defaultSince)
is MutedAuthorsTopNavPerRelayFilterSet -> filterSoftwareAppsByMutedAuthors(feedSettings, since, defaultSince)
else -> emptyList()
}
}.scopedTo(feedSettings)
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommuni
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.datasource.subassemblies.filterWorkoutsByAllCommunities
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.datasource.subassemblies.filterWorkoutsByAuthors
@@ -55,4 +56,4 @@ fun makeWorkoutsFilter(
is MutedAuthorsTopNavPerRelayFilterSet -> filterWorkoutsByMutedAuthors(feedSettings, since, defaultSince)
is SingleCommunityTopNavPerRelayFilterSet -> filterWorkoutsByCommunity(feedSettings, since, defaultSince)
else -> emptyList()
}
}.scopedTo(feedSettings)
+6
View File
@@ -2151,6 +2151,12 @@
<string name="active_subs_pair">%1$s \u00b7 %2$s</string>
<string name="active_subs_unattributed">Not attributed to an account</string>
<string name="active_subs_no_entity">All</string>
<string name="active_subs_scope_global">Everyone</string>
<string name="active_subs_scope_follows">People you follow</string>
<string name="active_subs_scope_authors">A chosen list of people</string>
<string name="active_subs_scope_muted">Muted people</string>
<string name="active_subs_scope_all_communities">Your communities</string>
<string name="active_subs_scope_algo">A favorite algo feed</string>
<string name="active_subs_share">%1$d%% of all</string>
<string name="active_subs_search_keywords">subscriptions filters relays requests reqs connections why diagnostics</string>
<string name="relay_explain_home">Posts by people you follow, read from the relays each of them publishes to.</string>
@@ -20,4 +20,19 @@
*/
package com.vitorpamplona.amethyst.commons.model.topNavFeeds
interface IFeedTopNavPerRelayFilterSet
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
interface IFeedTopNavPerRelayFilterSet {
/**
* What this feed selection asks of one relay the follows, hashtags, geohashes or communities
* scoped to it or null when the set says nothing per relay.
*
* Declared here so an `ExplainedFilter` can carry the scope that produced it. A discovery filter
* names no entity (it searches for things rather than serving known ones), which used to leave
* the Active Subscriptions screen with a nameless row it could only label "no entity". The
* selection behind it was always known at build time; it just had nowhere to travel. Returning
* the per-relay value rather than the whole set keeps it exact: the filter is already per relay,
* so it carries only the slice that applies to it.
*/
fun scopeFor(relay: NormalizedRelayUrl): IFeedTopNavPerRelayFilter?
}
@@ -25,4 +25,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
class AllFollowsTopNavPerRelayFilterSet(
val set: Map<NormalizedRelayUrl, AllFollowsTopNavPerRelayFilter>,
) : IFeedTopNavPerRelayFilterSet
) : IFeedTopNavPerRelayFilterSet {
override fun scopeFor(relay: NormalizedRelayUrl) = set[relay]
}
@@ -25,4 +25,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
class LocationTopNavPerRelayFilterSet(
val set: Map<NormalizedRelayUrl, LocationTopNavPerRelayFilter>,
) : IFeedTopNavPerRelayFilterSet
) : IFeedTopNavPerRelayFilterSet {
override fun scopeFor(relay: NormalizedRelayUrl) = set[relay]
}
@@ -39,4 +39,8 @@ class FavoriteAlgoFeedTopNavPerRelayFilterSet(
val contentFetches: Map<NormalizedRelayUrl, FavoriteAlgoFeedTopNavPerRelayFilter>,
val listenRelays: Set<NormalizedRelayUrl>,
val requestIds: Set<HexKey>,
) : IFeedTopNavPerRelayFilterSet
) : IFeedTopNavPerRelayFilterSet {
// Only the content half is per-relay. [listenRelays] is where the DVMs answer, which is a
// delivery address rather than a scope, so a filter aimed there carries none.
override fun scopeFor(relay: NormalizedRelayUrl) = contentFetches[relay]
}
@@ -25,4 +25,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
class GlobalTopNavPerRelayFilterSet(
val set: Map<NormalizedRelayUrl, GlobalTopNavPerRelayFilter>,
) : IFeedTopNavPerRelayFilterSet
) : IFeedTopNavPerRelayFilterSet {
override fun scopeFor(relay: NormalizedRelayUrl) = set[relay]
}
@@ -25,4 +25,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
class HashtagTopNavPerRelayFilterSet(
val set: Map<NormalizedRelayUrl, HashtagTopNavPerRelayFilter>,
) : IFeedTopNavPerRelayFilterSet
) : IFeedTopNavPerRelayFilterSet {
override fun scopeFor(relay: NormalizedRelayUrl) = set[relay]
}
@@ -25,4 +25,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
class AllCommunitiesTopNavPerRelayFilterSet(
val set: Map<NormalizedRelayUrl, AllCommunitiesTopNavPerRelayFilter>,
) : IFeedTopNavPerRelayFilterSet
) : IFeedTopNavPerRelayFilterSet {
override fun scopeFor(relay: NormalizedRelayUrl) = set[relay]
}
@@ -25,4 +25,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
class AuthorsTopNavPerRelayFilterSet(
val set: Map<NormalizedRelayUrl, AuthorsTopNavPerRelayFilter>,
) : IFeedTopNavPerRelayFilterSet
) : IFeedTopNavPerRelayFilterSet {
override fun scopeFor(relay: NormalizedRelayUrl) = set[relay]
}
@@ -25,4 +25,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
class SingleCommunityTopNavPerRelayFilterSet(
val set: Map<NormalizedRelayUrl, SingleCommunityTopNavPerRelayFilter>,
) : IFeedTopNavPerRelayFilterSet
) : IFeedTopNavPerRelayFilterSet {
override fun scopeFor(relay: NormalizedRelayUrl) = set[relay]
}
@@ -25,4 +25,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
class MutedAuthorsTopNavPerRelayFilterSet(
val set: Map<NormalizedRelayUrl, MutedAuthorsTopNavPerRelayFilter>,
) : IFeedTopNavPerRelayFilterSet
) : IFeedTopNavPerRelayFilterSet {
override fun scopeFor(relay: NormalizedRelayUrl) = set[relay]
}
@@ -20,9 +20,14 @@
*/
package com.vitorpamplona.amethyst.commons.model.topNavFeeds.relay
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
class RelayTopNavPerRelayFilterSet(
val relayUrl: NormalizedRelayUrl,
) : IFeedTopNavPerRelayFilterSet
) : IFeedTopNavPerRelayFilterSet {
// The relay *is* the whole selection here, so there is nothing per-relay left to say — the
// filter's own relay already carries it.
override fun scopeFor(relay: NormalizedRelayUrl): IFeedTopNavPerRelayFilter? = null
}
@@ -20,6 +20,10 @@
*/
package com.vitorpamplona.amethyst.commons.model.topNavFeeds.unknown
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
object UnknownTopNavPerRelayFilterSet : IFeedTopNavPerRelayFilterSet
object UnknownTopNavPerRelayFilterSet : IFeedTopNavPerRelayFilterSet {
override fun scopeFor(relay: NormalizedRelayUrl): IFeedTopNavPerRelayFilter? = null
}
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.commons.relayClient.subscriptions
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
@@ -89,6 +91,24 @@ class ExplainedFilter(
* account alive.
*/
val accountPubKey: HexKey? = null,
/**
* The top-nav selection that produced this filter Global, the user's follows, a hashtag, a
* geohash, a community.
*
* [entityIds] answers "which known thing does this serve"; discovery filters have no such thing,
* because they go looking for chats/articles/streams rather than serving ones already named. That
* is not the same as having no explanation: the feed selection behind them was always known where
* the filter was built, it simply had nowhere to travel, so the screen could only render those
* rows as "no entity".
*
* The per-relay value, not the whole set: this filter is already scoped to one relay, so it
* carries only the slice that applies to it and holds no reference to the other relays' authors.
*
* A typed value rather than a formatted string, because [purposeDetail] taught the lesson
* text built in `commons` can never be translated. The UI matches on the type and picks its own
* localized wording.
*/
val scope: IFeedTopNavPerRelayFilter? = null,
) : Filter(ids, authors, kinds, tags, tagsAll, since, until, limit, search) {
override fun copy(
ids: List<String>?,
@@ -100,7 +120,7 @@ class ExplainedFilter(
until: Long?,
limit: Int?,
search: String?,
) = ExplainedFilter(ids, authors, kinds, tags, tagsAll, since, until, limit, search, purpose, purposeDetail, entityIds, accountPubKey)
) = ExplainedFilter(ids, authors, kinds, tags, tagsAll, since, until, limit, search, purpose, purposeDetail, entityIds, accountPubKey, scope)
companion object {
/** Tags [filter] with a [purpose], preserving every protocol field. */
@@ -110,6 +130,7 @@ class ExplainedFilter(
detail: String? = null,
entityIds: List<HexKey>? = null,
accountPubKey: HexKey? = null,
scope: IFeedTopNavPerRelayFilter? = null,
) = ExplainedFilter(
filter.ids,
filter.authors,
@@ -124,6 +145,7 @@ class ExplainedFilter(
detail,
entityIds,
accountPubKey,
scope,
)
}
}
@@ -173,7 +195,31 @@ fun List<RelayBasedFilter>.attributedTo(accountPubKey: HexKey): List<RelayBasedF
if (filter is ExplainedFilter && filter.accountPubKey == null) {
RelayBasedFilter(
relay = relayFilter.relay,
filter = ExplainedFilter.of(filter, filter.purpose, filter.purposeDetail, filter.entityIds, accountPubKey),
filter = ExplainedFilter.of(filter, filter.purpose, filter.purposeDetail, filter.entityIds, accountPubKey, filter.scope),
)
} else {
relayFilter
}
}
/**
* Stamps the top-nav selection onto every tagged filter that does not already name one.
*
* Applied once at each feed's `makeFilter` dispatch the single place that still knows which
* selection is being served, and the same place that already chooses a builder from it. Below that
* point the builders have flattened it into `authors`/`#t`/`#g` and the selection is unrecoverable.
*
* Each filter gets the slice for its own relay, so a filter aimed at a relay the selection says
* nothing about is simply left alone rather than labelled with someone else's scope.
*/
fun List<RelayBasedFilter>.scopedTo(feedSettings: IFeedTopNavPerRelayFilterSet): List<RelayBasedFilter> =
map { relayFilter ->
val filter = relayFilter.filter
val scope = if (filter is ExplainedFilter && filter.scope == null) feedSettings.scopeFor(relayFilter.relay) else null
if (filter is ExplainedFilter && scope != null) {
RelayBasedFilter(
relay = relayFilter.relay,
filter = ExplainedFilter.of(filter, filter.purpose, filter.purposeDetail, filter.entityIds, filter.accountPubKey, scope),
)
} else {
relayFilter
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.commons.relayClient.subscriptions
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.FiltersChanged
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
@@ -56,8 +57,9 @@ class ExplainedFilterTest {
limit = 20,
purpose = SubPurpose.NOTIFICATIONS,
purposeDetail = "inbox relays for the active account",
entityId = "cafe0000000000000000000000000000000000000000000000000000000000ff",
entityIds = listOf("cafe0000000000000000000000000000000000000000000000000000000000ff"),
accountPubKey = pubkey,
scope = HashtagTopNavPerRelayFilter(setOf("askednostr")),
)
// ---- the wire must not learn why we asked -------------------------------
@@ -81,6 +83,11 @@ class ExplainedFilterTest {
assertFalse("detail leaked to the wire: $json", json.contains("inbox relays", ignoreCase = true))
assertFalse("entityId leaked to the wire: $json", json.contains("cafe0000", ignoreCase = true))
assertFalse("accountPubKey leaked as a field: $json", json.contains("accountPubKey", ignoreCase = true))
// The scope is the feed selection behind the filter — a hashtag here, but for a follows feed
// it is a slice of the user's follow list. Handing a relay the *selection* rather than the
// authors it already sees would tell it which of its neighbours' filters belong together.
assertFalse("scope leaked to the wire: $json", json.contains("askednostr", ignoreCase = true))
assertFalse("scope leaked as a field: $json", json.contains("scope", ignoreCase = true))
}
/** The filter is serialized as part of a REQ, so check the real command too, not just the filter. */
@@ -106,9 +113,10 @@ class ExplainedFilterTest {
assertTrue("copy() must stay an ExplainedFilter", advanced is ExplainedFilter)
assertEquals(SubPurpose.NOTIFICATIONS, advanced.purposeOrNull())
assertEquals("inbox relays for the active account", (advanced as ExplainedFilter).purposeDetail)
// entityId/accountPubKey ride the same path and would vanish just as silently
assertEquals("cafe0000000000000000000000000000000000000000000000000000000000ff", advanced.entityId)
// entityIds/accountPubKey/scope ride the same path and would vanish just as silently
assertEquals(listOf("cafe0000000000000000000000000000000000000000000000000000000000ff"), advanced.entityIds)
assertEquals(pubkey, advanced.accountPubKey)
assertEquals(setOf("askednostr"), (advanced.scope as? HashtagTopNavPerRelayFilter)?.hashtags)
assertEquals(1_785_379_272L, advanced.since)
// and the protocol fields came along untouched
assertEquals(listOf(pubkey), advanced.authors)