Merge pull request #3196 from vitorpamplona/claude/nice-ptolemy-9153uk

Add Selected filter mode for curated notifications
This commit is contained in:
Vitor Pamplona
2026-06-12 14:07:38 -04:00
committed by GitHub
9 changed files with 74 additions and 10 deletions
@@ -54,9 +54,10 @@ import org.junit.runner.RunWith
* Asserts the three contracts the feature relies on:
* 1. `feedKey` is mode-discriminated so each pinned tab caches independently.
* 2. `followList()` honors `modeOverride` when set; falls back to the spinner setting otherwise.
* 3. `buildFilterParams()` returns a GlobalTopNavFilter-backed FilterByListParams for
* `TopFilter.Global` (so `isGlobal()` is true, allowing non-follower notifications through),
* and a non-Global filter for `TopFilter.AllFollows` (forcing the follow-membership gate).
* 3. `buildFilterParams()` returns a GlobalTopNavFilter-backed FilterByListParams for both
* `TopFilter.Global` and `TopFilter.Selected` (so `isGlobal()` is true, allowing
* non-follower notifications through), and a non-Global filter for `TopFilter.AllFollows`
* (forcing the follow-membership gate).
*/
@RunWith(AndroidJUnit4::class)
class NotificationFeedFilterModeOverrideTest {
@@ -130,8 +131,8 @@ class NotificationFeedFilterModeOverrideTest {
fun followListFallsBackToSpinnerWhenOverrideNull() {
val spinner = NotificationFeedFilter(account)
account.settings.defaultNotificationFollowList.value = TopFilter.Global
assertEquals(TopFilter.Global, spinner.followList())
account.settings.defaultNotificationFollowList.value = TopFilter.Selected
assertEquals(TopFilter.Selected, spinner.followList())
account.settings.defaultNotificationFollowList.value = TopFilter.AllFollows
assertEquals(TopFilter.AllFollows, spinner.followList())
@@ -151,6 +152,22 @@ class NotificationFeedFilterModeOverrideTest {
)
}
@Test
fun buildFilterParamsForSelectedOverrideReportsGlobal() {
val selected = NotificationFeedFilter(account, TopFilter.Selected)
val params = selected.buildFilterParams(account)
// Selected rides the same GlobalFeedFlow relay set as Global, so it must
// also report isGlobal and let non-followers through; the difference is
// that acceptableEvent applies the per-kind relevance heuristics, which
// Global skips.
assertTrue(
"Selected mode's FilterByListParams must report isGlobal so non-followers pass the gate",
params.isGlobal(),
)
}
@Test
fun buildFilterParamsForAllFollowsOverrideIsNotGlobal() {
val following = NotificationFeedFilter(account, TopFilter.AllFollows)
@@ -812,7 +812,7 @@ object LocalPreferences {
FollowListPrefs(
home = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null), TopFilter.AllFollows),
stories = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null), TopFilter.Global),
notification = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null), TopFilter.Global),
notification = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null), TopFilter.Selected),
discovery = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null), TopFilter.Global),
polls = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_POLLS_FOLLOW_LIST, null), TopFilter.Global),
pictures = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PICTURES_FOLLOW_LIST, null), TopFilter.Global),
@@ -96,6 +96,16 @@ sealed class TopFilter(
@Serializable
object Global : TopFilter(" Global ")
/**
* Notifications-only curated mode: like [Global] it admits authors the
* user doesn't follow, but it also applies per-kind relevance heuristics
* to remove less interesting notes (reactions/reposts that don't target
* the user's own notes, unrelated thread replies, etc.). In Notifications,
* [Global] shows every event that p-tags the user instead.
*/
@Serializable
object Selected : TopFilter(" Selected ")
@Serializable
object AllFollows : TopFilter(" All Follows ")
@@ -175,7 +185,7 @@ class AccountSettings(
val hideCommunityRulesViolations: MutableStateFlow<Boolean> = MutableStateFlow(false),
val defaultHomeFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
val defaultStoriesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultNotificationFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultNotificationFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Selected),
val defaultDiscoveryFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultPollsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultPicturesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
@@ -72,7 +72,7 @@ class FeedTopNavFilterState(
) {
fun loadFlowsFor(listName: TopFilter): IFeedFlowsType =
when (listName) {
TopFilter.Global -> {
TopFilter.Global, TopFilter.Selected -> {
GlobalFeedFlow(followsRelays, proxyRelays, relayFeeds)
}
@@ -367,6 +367,7 @@ private fun FeedDefinition.group(): FeedGroup =
when (code) {
is TopFilter.AroundMe -> FeedGroup.LOCATIONS
is TopFilter.Global -> FeedGroup.RELAYS
is TopFilter.Selected -> FeedGroup.RELAYS
is TopFilter.AllFavoriteAlgoFeeds -> FeedGroup.DVMS
else -> FeedGroup.FEEDS
}
@@ -507,6 +508,10 @@ private fun FeedIcon(
MaterialSymbols.Public
}
is TopFilter.Selected -> {
MaterialSymbols.FilterAlt
}
is TopFilter.AroundMe -> {
MaterialSymbols.LocationOn
}
@@ -82,6 +82,14 @@ class TopNavFilterState(
name = ResourceName(R.string.follow_list_global),
)
// Notifications-only curated mode; in Notifications, Global itself shows
// every event that p-tags the user.
val selectedFollow =
FeedDefinition(
code = TopFilter.Selected,
name = ResourceName(R.string.follow_list_curated),
)
val aroundMe =
FeedDefinition(
code = TopFilter.AroundMe,
@@ -108,6 +116,8 @@ class TopNavFilterState(
val defaultLists = persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, muteListFollow)
val defaultNotificationLists = persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, selectedFollow, globalFollow, muteListFollow)
fun mergePeopleLists(
peopleLists: List<AddressableNote>,
followLists: List<AddressableNote>,
@@ -294,6 +304,18 @@ class TopNavFilterState(
)
}
private val _notificationLists =
livePeopleListsFlow.transform { peopleLists ->
checkNotInMainThread()
emit(
listOf(
listOf(allFollows, userFollows, kind3Follows, aroundMe, selectedFollow, globalFollow),
peopleLists,
listOf(muteListFollow),
).flatten().toImmutableList(),
)
}
val kind3GlobalPeopleRoutes =
_kind3GlobalPeopleRoutes
.flowOn(Dispatchers.IO)
@@ -304,6 +326,11 @@ class TopNavFilterState(
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, defaultLists)
val notificationLists =
_notificationLists
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, defaultNotificationLists)
val badgeRoutes =
_badgeRoutes
.flowOn(Dispatchers.IO)
@@ -64,7 +64,7 @@ private fun TopNavFilterBar(
accountViewModel: AccountViewModel,
onChange: (FeedDefinition) -> Unit,
) {
val allLists by followListsModel.kind3GlobalPeople.collectAsStateWithLifecycle()
val allLists by followListsModel.notificationLists.collectAsStateWithLifecycle()
FeedFilterSpinner(
placeholderCode = listName,
@@ -328,13 +328,17 @@ class NotificationFeedFilter(
// Chess events bypass the follow filter — opponents may not be followed
val isChessEvent = noteEvent is LiveChessGameAcceptEvent || noteEvent is LiveChessMoveEvent
// Global keeps every event that p-tags the user; Selected (and the
// follow/list modes) also applies the per-kind relevance heuristics.
val isRawGlobal = followList() is TopFilter.Global
return noteEvent?.kind in NOTIFICATION_KINDS &&
(noteEvent is LnZapEvent || notifAuthor != loggedInUserHex) &&
(isChessEvent || filterParams.isGlobal() || notifAuthor == null || filterParams.isAuthorInFollows(notifAuthor)) &&
noteEvent?.isTaggedUser(loggedInUserHex) ?: false &&
(filterParams.isHiddenList || notifAuthor == null || !account.isHidden(notifAuthor)) &&
(noteEvent !is PrivateDmEvent || !account.isDecryptedContentHidden(noteEvent)) &&
tagsAnEventByUser(it, loggedInUserHex)
(isRawGlobal || tagsAnEventByUser(it, loggedInUserHex))
}
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
+1
View File
@@ -1180,6 +1180,7 @@
<string name="follow_list_kind3follows_proxy">Follows via Proxy</string>
<string name="follow_list_aroundme">Around Me</string>
<string name="follow_list_global">Global</string>
<string name="follow_list_curated">Curated</string>
<string name="follow_list_chess">Chess</string>
<string name="follow_list_mine">Mine</string>
<string name="follow_list_mute_list">Mute List</string>