Merge pull request #3790 from vitorpamplona/claude/home-screen-event-toggles-q75xwc

Add per-content-type toggles for Home feed event kinds
This commit is contained in:
Vitor Pamplona
2026-07-28 20:55:56 -04:00
committed by GitHub
10 changed files with 480 additions and 85 deletions
@@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntr
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.HomeFeedType
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.UiSettings
import com.vitorpamplona.amethyst.service.checkNotInMainThread
@@ -188,6 +189,10 @@ private object PrefKeys {
// Stores the DISABLED chat feed types (comma-joined codes) so absence = all-on and any newly
// added type defaults enabled for accounts that customized before it existed.
const val DISABLED_CHAT_FEEDS = "disabled_chat_feeds"
// Same convention as DISABLED_CHAT_FEEDS but for the Home feed's event-kind groups: stores the
// DISABLED codes so absence = all-on and any newly added group defaults enabled.
const val DISABLED_HOME_FEED_TYPES = "disabled_home_feed_types"
const val RELAY_AUTH_TRUST_MY_RELAYS = "relay_auth_trust_my_relays_and_venues"
const val RELAY_AUTH_TRUST_READ_FOLLOWS = "relay_auth_trust_read_follows"
const val RELAY_AUTH_TRUST_MESSAGE_FOLLOWS = "relay_auth_trust_message_follows"
@@ -604,6 +609,7 @@ object LocalPreferences {
putString(PrefKeys.RELAY_GROUP_VIEW_MODE, settings.relayGroupViewMode.value.name)
putString(PrefKeys.CONCORD_VIEW_MODE, settings.concordViewMode.value.name)
putString(PrefKeys.DISABLED_CHAT_FEEDS, ChatFeedType.encode(ChatFeedType.ALL - settings.enabledChatFeeds.value))
putString(PrefKeys.DISABLED_HOME_FEED_TYPES, HomeFeedType.encode(HomeFeedType.ALL - settings.enabledHomeFeedTypes.value))
putBoolean(PrefKeys.RELAY_AUTH_TRUST_MY_RELAYS, settings.relayAuthTrustMyRelaysAndVenues.value)
putBoolean(PrefKeys.RELAY_AUTH_TRUST_READ_FOLLOWS, settings.relayAuthTrustReadFollows.value)
putBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_FOLLOWS, settings.relayAuthTrustMessageFollows.value)
@@ -731,17 +737,10 @@ object LocalPreferences {
val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false)
val callsEnabled = getBoolean(PrefKeys.CALLS_ENABLED, true)
val alwaysOnNotificationService = getBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, false)
val defaultRelayAuthPolicy =
getString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, null)
?.let { runCatching { RelayAuthPolicy.valueOf(it) }.getOrNull() }
?: RelayAuthPolicy.CUSTOM
val relayGroupViewMode = RelayGroupViewMode.fromName(getString(PrefKeys.RELAY_GROUP_VIEW_MODE, null))
val concordViewMode = ConcordViewMode.fromName(getString(PrefKeys.CONCORD_VIEW_MODE, null))
val enabledChatFeeds = ChatFeedType.ALL - ChatFeedType.decode(getString(PrefKeys.DISABLED_CHAT_FEEDS, null))
val relayAuthTrustMyRelays = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MY_RELAYS, true)
val relayAuthTrustReadFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_READ_FOLLOWS, true)
val relayAuthTrustMessageFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_FOLLOWS, true)
val relayAuthTrustMessageStrangers = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_STRANGERS, false)
// Read as a group via a helper: this load lambda sits right at the JVM's
// per-method bytecode limit (see the note above the awaits below), so keeping
// these heavy string/enum decodes out of it preserves headroom.
val inboxPrefs = readInboxPrefs()
val splitNotificationsEnabled = getBoolean(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED, false)
val showMessagesInNotifications = getBoolean(PrefKeys.SHOW_MESSAGES_IN_NOTIFICATIONS, true)
val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf()
@@ -968,14 +967,15 @@ object LocalPreferences {
hideBlockAlertDialog = hideBlockAlertDialog,
hideNIP17WarningDialog = hideNIP17WarningDialog,
alwaysOnNotificationService = MutableStateFlow(alwaysOnNotificationService),
defaultRelayAuthPolicy = MutableStateFlow(defaultRelayAuthPolicy),
relayGroupViewMode = MutableStateFlow(relayGroupViewMode),
concordViewMode = MutableStateFlow(concordViewMode),
enabledChatFeeds = MutableStateFlow(enabledChatFeeds),
relayAuthTrustMyRelaysAndVenues = MutableStateFlow(relayAuthTrustMyRelays),
relayAuthTrustReadFollows = MutableStateFlow(relayAuthTrustReadFollows),
relayAuthTrustMessageFollows = MutableStateFlow(relayAuthTrustMessageFollows),
relayAuthTrustMessageStrangers = MutableStateFlow(relayAuthTrustMessageStrangers),
defaultRelayAuthPolicy = MutableStateFlow(inboxPrefs.defaultRelayAuthPolicy),
relayGroupViewMode = MutableStateFlow(inboxPrefs.relayGroupViewMode),
concordViewMode = MutableStateFlow(inboxPrefs.concordViewMode),
enabledChatFeeds = MutableStateFlow(inboxPrefs.enabledChatFeeds),
enabledHomeFeedTypes = MutableStateFlow(inboxPrefs.enabledHomeFeedTypes),
relayAuthTrustMyRelaysAndVenues = MutableStateFlow(inboxPrefs.relayAuthTrustMyRelays),
relayAuthTrustReadFollows = MutableStateFlow(inboxPrefs.relayAuthTrustReadFollows),
relayAuthTrustMessageFollows = MutableStateFlow(inboxPrefs.relayAuthTrustMessageFollows),
relayAuthTrustMessageStrangers = MutableStateFlow(inboxPrefs.relayAuthTrustMessageStrangers),
splitNotificationsEnabled = MutableStateFlow(splitNotificationsEnabled),
showMessagesInNotifications = MutableStateFlow(showMessagesInNotifications),
backupUserMetadata = latestUserMetadataResolved,
@@ -1184,3 +1184,36 @@ object LocalPreferences {
}
}
}
/**
* The inbox / relay-auth / feed-type preferences, read as one group. Extracted out of
* [LocalPreferences]' account-load lambda (which is right at the JVM's per-method bytecode limit)
* so these enum/set decodes don't count against that method's budget.
*/
private class InboxPrefs(
val defaultRelayAuthPolicy: RelayAuthPolicy,
val relayGroupViewMode: RelayGroupViewMode,
val concordViewMode: ConcordViewMode,
val enabledChatFeeds: Set<ChatFeedType>,
val enabledHomeFeedTypes: Set<HomeFeedType>,
val relayAuthTrustMyRelays: Boolean,
val relayAuthTrustReadFollows: Boolean,
val relayAuthTrustMessageFollows: Boolean,
val relayAuthTrustMessageStrangers: Boolean,
)
private fun SharedPreferences.readInboxPrefs() =
InboxPrefs(
defaultRelayAuthPolicy =
getString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, null)
?.let { runCatching { RelayAuthPolicy.valueOf(it) }.getOrNull() }
?: RelayAuthPolicy.CUSTOM,
relayGroupViewMode = RelayGroupViewMode.fromName(getString(PrefKeys.RELAY_GROUP_VIEW_MODE, null)),
concordViewMode = ConcordViewMode.fromName(getString(PrefKeys.CONCORD_VIEW_MODE, null)),
enabledChatFeeds = ChatFeedType.ALL - ChatFeedType.decode(getString(PrefKeys.DISABLED_CHAT_FEEDS, null)),
enabledHomeFeedTypes = HomeFeedType.ALL - HomeFeedType.decode(getString(PrefKeys.DISABLED_HOME_FEED_TYPES, null)),
relayAuthTrustMyRelays = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MY_RELAYS, true),
relayAuthTrustReadFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_READ_FOLLOWS, true),
relayAuthTrustMessageFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_FOLLOWS, true),
relayAuthTrustMessageStrangers = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_STRANGERS, false),
)
@@ -341,6 +341,9 @@ class AccountSettings(
// Which conversation protocols the Messages inbox loads and shows. A disabled type is both hidden
// from the inbox and dropped from the always-on downloading routes. Defaults to everything on.
val enabledChatFeeds: MutableStateFlow<Set<ChatFeedType>> = MutableStateFlow(ChatFeedType.ALL),
// Which event-kind groups the Home feed downloads (assembler) and renders (DAL). A disabled group
// is both dropped from the always-on home relay filters and hidden from the tabs. Everything on by default.
val enabledHomeFeedTypes: MutableStateFlow<Set<HomeFeedType>> = MutableStateFlow(HomeFeedType.ALL),
// The per-situation toggles applied under RelayAuthPolicy.CUSTOM.
val relayAuthTrustMyRelaysAndVenues: MutableStateFlow<Boolean> = MutableStateFlow(true),
val relayAuthTrustReadFollows: MutableStateFlow<Boolean> = MutableStateFlow(true),
@@ -391,6 +394,20 @@ class AccountSettings(
}
}
fun isHomeFeedTypeEnabled(type: HomeFeedType): Boolean = type in enabledHomeFeedTypes.value
fun setHomeFeedTypeEnabled(
type: HomeFeedType,
enabled: Boolean,
) {
val current = enabledHomeFeedTypes.value
val next = if (enabled) current + type else current - type
if (next != current) {
enabledHomeFeedTypes.tryEmit(next)
saveAccountSettings()
}
}
// ---
// Always-on Notification Service
// ---
@@ -0,0 +1,130 @@
/*
* 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.model
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip64Chess.challenge.offer.LiveChessGameChallengeEvent
import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent
import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
/**
* The distinct event-kind groups the Home feed downloads (in the relay assembler) and renders (in
* the DAL). Each is independently toggleable in Settings Home: turning one off both drops its
* kinds from the always-on home relay filters AND hides them from the New Threads / Conversations /
* Everything tabs.
*
* [code] is the stable on-disk identifier (do NOT rename — it is what [encode]/[decode] persist);
* the enum ordinal is never stored, so entries may be reordered freely. [kinds] are the Nostr event
* kinds this group governs; they must stay disjoint across entries so a single toggle owns each kind.
*/
enum class HomeFeedType(
val code: String,
val kinds: List<Int>,
) {
TEXT_NOTES("text_notes", listOf(TextNoteEvent.KIND)),
REPOSTS("reposts", listOf(RepostEvent.KIND, GenericRepostEvent.KIND)),
COMMENTS("comments", listOf(CommentEvent.KIND)),
ARTICLES("articles", listOf(LongTextNoteEvent.KIND)),
WIKI("wiki", listOf(WikiNoteEvent.KIND)),
HIGHLIGHTS("highlights", listOf(HighlightEvent.KIND)),
POLLS("polls", listOf(PollEvent.KIND, ZapPollEvent.KIND, PollResponseEvent.KIND)),
CLASSIFIEDS("classifieds", listOf(ClassifiedsEvent.KIND)),
VOICE("voice", listOf(VoiceEvent.KIND, VoiceReplyEvent.KIND)),
LIVE_ACTIVITIES("live_activities", listOf(LiveActivitiesEvent.KIND, LiveActivitiesChatMessageEvent.KIND)),
EPHEMERAL_CHAT("ephemeral_chat", listOf(EphemeralChatEvent.KIND)),
INTERACTIVE_STORIES("interactive_stories", listOf(InteractiveStoryPrologueEvent.KIND)),
CHESS("chess", listOf(ChessGameEvent.KIND, LiveChessGameChallengeEvent.KIND, LiveChessGameEndEvent.KIND)),
BIRDS("birds", listOf(BirdDetectionEvent.KIND, BirdexEvent.KIND)),
ATTESTATIONS(
"attestations",
listOf(
AttestationEvent.KIND,
AttestationRequestEvent.KIND,
AttestorRecommendationEvent.KIND,
AttestorProficiencyEvent.KIND,
),
),
NIPS("nips", listOf(NipTextEvent.KIND)),
MUSIC("music", listOf(AudioTrackEvent.KIND, MusicTrackEvent.KIND, MusicPlaylistEvent.KIND, AudioHeaderEvent.KIND)),
PODCASTS("podcasts", listOf(PodcastEpisodeEvent.KIND, PodcastMetadataEvent.KIND)),
FUNDRAISERS("fundraisers", listOf(FundraiserEvent.KIND)),
;
companion object {
/** Every group, enabled by default so a fresh (or never-customized) account loads everything. */
val ALL: Set<HomeFeedType> = entries.toSet()
fun fromCode(code: String?): HomeFeedType? = entries.firstOrNull { it.code == code }
/** Serializes a set of groups as their comma-joined [code]s, for SharedPreferences. */
fun encode(types: Set<HomeFeedType>): String = types.joinToString(",") { it.code }
/** Parses a comma-joined [code] list back to a set, dropping any unknown codes. */
fun decode(joined: String?): Set<HomeFeedType> =
joined
?.split(",")
?.mapNotNull { fromCode(it.trim()) }
?.toSet()
?: emptySet()
/**
* The event kinds to drop from the home relay filters and the home DAL, given the currently
* [enabled] set. A kind stays live if ANY enabled group still owns it (guards against a
* future overlap between two groups), so disabling one group never silently hides a kind a
* still-enabled group also wants.
*/
fun disabledKinds(enabled: Set<HomeFeedType>): Set<Int> {
if (enabled.size == ALL.size) return emptySet()
val enabledKinds = enabled.flatMapTo(HashSet()) { it.kinds }
return (ALL - enabled).flatMapTo(HashSet()) { it.kinds }.apply { removeAll(enabledKinds) }
}
}
}
@@ -248,6 +248,19 @@ class AccountFeedContentStates(
}
}
// Toggling a Home content type on/off in Settings Home changes which event kinds the tabs
// render, but no event flows through LocalCache — force a rebuild of all three home feeds so
// hidden kinds disappear (and re-enabled ones reappear from cache) immediately.
scope.launch(Dispatchers.IO) {
account.settings.enabledHomeFeedTypes
.drop(1)
.collect {
homeNewThreads.invalidateData()
homeReplies.invalidateData()
homeEverything.invalidateData()
}
}
// Pinning/unpinning a room only changes sort order, not membership, so no
// chat event flows through LocalCache. Force a rebuild to re-sort. This
// also fires when pins arrive via the synced AppSpecificData event.
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.HomeFeedType
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter
@@ -50,10 +51,11 @@ class HomeConversationsFeedFilter(
override fun feed(): List<Note> {
val filterParams = buildFilterParams(account)
val disabledKinds = HomeFeedType.disabledKinds(account.settings.enabledHomeFeedTypes.value)
return sort(
LocalCache.notes.filterIntoSet { _, it ->
acceptableEvent(it, filterParams)
acceptableEvent(it, filterParams, disabledKinds)
},
)
}
@@ -68,9 +70,10 @@ class HomeConversationsFeedFilter(
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val filterParams = buildFilterParams(account)
val disabledKinds = HomeFeedType.disabledKinds(account.settings.enabledHomeFeedTypes.value)
return collection.filterTo(HashSet()) {
acceptableEvent(it, filterParams)
acceptableEvent(it, filterParams, disabledKinds)
}
}
@@ -78,23 +81,27 @@ class HomeConversationsFeedFilter(
event: Event?,
relays: List<NormalizedRelayUrl>,
filterParams: FilterByListParams,
disabledKinds: Set<Int>,
): Boolean =
(
event is TextNoteEvent ||
event is ZapPollEvent ||
event is PollResponseEvent ||
event is ChannelMessageEvent ||
event is CommentEvent ||
event is VoiceReplyEvent ||
event is PublicMessageEvent ||
event is LiveActivitiesChatMessageEvent
) &&
event != null &&
event.kind !in disabledKinds &&
(
event is TextNoteEvent ||
event is ZapPollEvent ||
event is PollResponseEvent ||
event is ChannelMessageEvent ||
event is CommentEvent ||
event is VoiceReplyEvent ||
event is PublicMessageEvent ||
event is LiveActivitiesChatMessageEvent
) &&
filterParams.match(event, relays)
fun acceptableEvent(
note: Note,
filterParams: FilterByListParams,
): Boolean = acceptableEvent(note.event, note.relays, filterParams) && !note.isNewThread()
disabledKinds: Set<Int>,
): Boolean = acceptableEvent(note.event, note.relays, filterParams, disabledKinds) && !note.isNewThread()
override fun sort(items: Set<Note>): List<Note> = items.sortedByDefaultFeedOrder()
}
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal
import com.vitorpamplona.amethyst.commons.ui.feeds.isRenderableRepost
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.HomeFeedType
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.filterIntoSet
@@ -93,18 +94,19 @@ class HomeNewThreadFeedFilter(
override fun feed(): List<Note> {
val filterParams = buildFilterParams(account)
val disabledKinds = HomeFeedType.disabledKinds(account.settings.enabledHomeFeedTypes.value)
val notes =
LocalCache.notes.filterIntoSet { _, note ->
// Avoids processing addressables twice.
(note.event?.kind ?: 99999) < 10000 && acceptableEvent(note, filterParams)
(note.event?.kind ?: 99999) < 10000 && acceptableEvent(note, filterParams, disabledKinds)
}
val longFormNotes =
LocalCache.addressables.filterIntoSet(
kinds = ADDRESSABLE_KINDS,
) { _, note ->
acceptableEvent(note, filterParams)
acceptableEvent(note, filterParams, disabledKinds)
}
return sort(notes + longFormNotes)
@@ -114,17 +116,20 @@ class HomeNewThreadFeedFilter(
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val filterParams = buildFilterParams(account)
val disabledKinds = HomeFeedType.disabledKinds(account.settings.enabledHomeFeedTypes.value)
return collection.filterTo(HashSet()) {
acceptableEvent(it, filterParams)
acceptableEvent(it, filterParams, disabledKinds)
}
}
private fun acceptableEvent(
it: Note,
filterParams: FilterByListParams,
disabledKinds: Set<Int>,
): Boolean {
val noteEvent = it.event
val noteEvent = it.event ?: return false
if (noteEvent.kind in disabledKinds) return false
return (
noteEvent is TextNoteEvent ||
noteEvent is ClassifiedsEvent ||
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows
import com.vitorpamplona.amethyst.model.HomeFeedType
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
@@ -49,6 +50,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
@@ -63,19 +65,24 @@ class HomeOutboxEventsEoseManager(
val feedSettings = key.followsPerRelay()
val newThreadSince = key.feedState.homeNewThreads.lastNoteCreatedAtIfFilled()
val repliesSince = key.feedState.homeReplies.lastNoteCreatedAtIfFilled()
return when (feedSettings) {
is AllCommunitiesTopNavPerRelayFilterSet -> filterHomePostsByAllCommunities(feedSettings, since, newThreadSince)
is AllFollowsTopNavPerRelayFilterSet -> filterHomePostsByAllFollows(feedSettings, since, newThreadSince, repliesSince)
is AuthorsTopNavPerRelayFilterSet -> filterHomePostsByAuthors(feedSettings, since, newThreadSince, repliesSince)
is GlobalTopNavPerRelayFilterSet -> filterHomePostsByGlobal(feedSettings, since, newThreadSince, repliesSince)
is HashtagTopNavPerRelayFilterSet -> filterHomePostsByHashtags(feedSettings, since, newThreadSince)
is LocationTopNavPerRelayFilterSet -> filterHomePostsByGeohashes(feedSettings, since, newThreadSince)
is MutedAuthorsTopNavPerRelayFilterSet -> filterHomePostsByAuthors(feedSettings, since, newThreadSince, repliesSince)
is RelayTopNavPerRelayFilterSet -> filterHomePostsByRelay(feedSettings, since, newThreadSince, repliesSince)
is SingleCommunityTopNavPerRelayFilterSet -> filterHomePostsByCommunity(feedSettings, since, newThreadSince)
is FavoriteAlgoFeedTopNavPerRelayFilterSet -> filterHomePostsByAlgoFeedIds(feedSettings, since, newThreadSince)
else -> emptyList()
}
val base =
when (feedSettings) {
is AllCommunitiesTopNavPerRelayFilterSet -> filterHomePostsByAllCommunities(feedSettings, since, newThreadSince)
is AllFollowsTopNavPerRelayFilterSet -> filterHomePostsByAllFollows(feedSettings, since, newThreadSince, repliesSince)
is AuthorsTopNavPerRelayFilterSet -> filterHomePostsByAuthors(feedSettings, since, newThreadSince, repliesSince)
is GlobalTopNavPerRelayFilterSet -> filterHomePostsByGlobal(feedSettings, since, newThreadSince, repliesSince)
is HashtagTopNavPerRelayFilterSet -> filterHomePostsByHashtags(feedSettings, since, newThreadSince)
is LocationTopNavPerRelayFilterSet -> filterHomePostsByGeohashes(feedSettings, since, newThreadSince)
is MutedAuthorsTopNavPerRelayFilterSet -> filterHomePostsByAuthors(feedSettings, since, newThreadSince, repliesSince)
is RelayTopNavPerRelayFilterSet -> filterHomePostsByRelay(feedSettings, since, newThreadSince, repliesSince)
is SingleCommunityTopNavPerRelayFilterSet -> filterHomePostsByCommunity(feedSettings, since, newThreadSince)
is FavoriteAlgoFeedTopNavPerRelayFilterSet -> filterHomePostsByAlgoFeedIds(feedSettings, since, newThreadSince)
else -> emptyList()
}
// 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.
return base.removeDisabledHomeKinds(HomeFeedType.disabledKinds(key.account.settings.enabledHomeFeedTypes.value))
}
override fun user(key: HomeQueryState) = key.account.userProfile()
@@ -108,6 +115,13 @@ class HomeOutboxEventsEoseManager(
invalidateFilters()
}
},
key.scope.launch(Dispatchers.IO) {
// Re-arm the home subscriptions when a content-type toggle flips, so a disabled
// group leaves the live REQ and a re-enabled one comes back without a restart.
key.account.settings.enabledHomeFeedTypes
.drop(1)
.collectLatest { invalidateFilters() }
},
key.account.scope.launch(Dispatchers.IO) {
key.feedState.homeNewThreads.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest {
invalidateFilters()
@@ -131,3 +145,21 @@ class HomeOutboxEventsEoseManager(
userJobMap[key]?.forEach { it.cancel() }
}
}
/**
* Removes the [disabled] kinds from each home filter. A filter with no `kinds` (e.g. an
* algo-feed id/address fetch) is left untouched; a filter whose kinds all become disabled is
* dropped entirely, since sending it with an empty `kinds` would wrongly match every kind.
*/
private fun List<RelayBasedFilter>.removeDisabledHomeKinds(disabled: Set<Int>): List<RelayBasedFilter> {
if (disabled.isEmpty()) return this
return mapNotNull { relayFilter ->
val kinds = relayFilter.filter.kinds ?: return@mapNotNull relayFilter
val kept = kinds.filterNot { it in disabled }
when {
kept.size == kinds.size -> relayFilter
kept.isEmpty() -> null
else -> RelayBasedFilter(relayFilter.relay, relayFilter.filter.copy(kinds = kept))
}
}
}
@@ -34,7 +34,9 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.HomeFeedType
import com.vitorpamplona.amethyst.model.UiSettingsFlow
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -65,53 +67,106 @@ fun HomeTabsSettingsScreen(
TopBarWithBackButton(stringRes(id = R.string.home_tabs_settings), nav)
},
) { padding ->
HomeTabsSettingsContent(accountViewModel.settings.uiSettingsFlow, Modifier.padding(padding))
Column(
modifier =
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(padding)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
HomeTabsSection(accountViewModel.settings.uiSettingsFlow)
HomeContentTypesSection(accountViewModel)
}
}
}
@Composable
fun HomeTabsSettingsContent(
ui: UiSettingsFlow,
modifier: Modifier = Modifier,
) {
private fun HomeTabsSection(ui: UiSettingsFlow) {
val showNewThreads by ui.showHomeNewThreadsTab.collectAsStateWithLifecycle()
val showConversations by ui.showHomeConversationsTab.collectAsStateWithLifecycle()
val showEverything by ui.showHomeEverythingTab.collectAsStateWithLifecycle()
val activeCount = listOf(showNewThreads, showConversations, showEverything).count { it }
Column(
modifier =
modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
SettingsSection(R.string.settings_section_home_tabs) {
SettingsSection(R.string.settings_section_home_tabs) {
SettingsSwitchTile(
icon = MaterialSymbols.Forum,
title = R.string.new_threads,
checked = showNewThreads,
// Don't allow disabling the last remaining tab.
enabled = !(showNewThreads && activeCount == 1),
onCheckedChange = { ui.showHomeNewThreadsTab.tryEmit(it) },
)
SettingsDivider()
SettingsSwitchTile(
icon = MaterialSymbols.Chat,
title = R.string.conversations,
checked = showConversations,
enabled = !(showConversations && activeCount == 1),
onCheckedChange = { ui.showHomeConversationsTab.tryEmit(it) },
)
SettingsDivider()
SettingsSwitchTile(
icon = MaterialSymbols.Public,
title = R.string.home_tab_everything,
checked = showEverything,
enabled = !(showEverything && activeCount == 1),
onCheckedChange = { ui.showHomeEverythingTab.tryEmit(it) },
)
}
}
/** One toggleable Home content group, mapping a [HomeFeedType] to its display title + icon. */
private data class HomeFeedTypeUi(
val type: HomeFeedType,
val titleRes: Int,
val icon: MaterialSymbol,
)
// Ordered by how common each group is on a typical home feed (everyday posts first, niche last).
private val HOME_FEED_TYPES =
listOf(
HomeFeedTypeUi(HomeFeedType.TEXT_NOTES, R.string.home_content_type_text_notes, MaterialSymbols.EditNote),
HomeFeedTypeUi(HomeFeedType.REPOSTS, R.string.home_content_type_reposts, MaterialSymbols.Forward),
HomeFeedTypeUi(HomeFeedType.COMMENTS, R.string.home_content_type_comments, MaterialSymbols.Chat),
HomeFeedTypeUi(HomeFeedType.ARTICLES, R.string.home_content_type_articles, MaterialSymbols.AutoMirrored.Article),
HomeFeedTypeUi(HomeFeedType.WIKI, R.string.home_content_type_wiki, MaterialSymbols.MenuBook),
HomeFeedTypeUi(HomeFeedType.HIGHLIGHTS, R.string.home_content_type_highlights, MaterialSymbols.FormatQuote),
HomeFeedTypeUi(HomeFeedType.POLLS, R.string.home_content_type_polls, MaterialSymbols.Poll),
HomeFeedTypeUi(HomeFeedType.CLASSIFIEDS, R.string.home_content_type_classifieds, MaterialSymbols.Storefront),
HomeFeedTypeUi(HomeFeedType.VOICE, R.string.home_content_type_voice, MaterialSymbols.Mic),
HomeFeedTypeUi(HomeFeedType.LIVE_ACTIVITIES, R.string.home_content_type_live_activities, MaterialSymbols.Sensors),
HomeFeedTypeUi(HomeFeedType.EPHEMERAL_CHAT, R.string.home_content_type_ephemeral_chat, MaterialSymbols.Forum),
HomeFeedTypeUi(HomeFeedType.INTERACTIVE_STORIES, R.string.home_content_type_interactive_stories, MaterialSymbols.AutoAwesome),
HomeFeedTypeUi(HomeFeedType.CHESS, R.string.home_content_type_chess, MaterialSymbols.ChessKnight),
HomeFeedTypeUi(HomeFeedType.BIRDS, R.string.home_content_type_birds, MaterialSymbols.TravelExplore),
HomeFeedTypeUi(HomeFeedType.ATTESTATIONS, R.string.home_content_type_attestations, MaterialSymbols.Shield),
HomeFeedTypeUi(HomeFeedType.NIPS, R.string.home_content_type_nips, MaterialSymbols.Code),
HomeFeedTypeUi(HomeFeedType.MUSIC, R.string.home_content_type_music, MaterialSymbols.MusicNote),
HomeFeedTypeUi(HomeFeedType.PODCASTS, R.string.home_content_type_podcasts, MaterialSymbols.Podcasts),
HomeFeedTypeUi(HomeFeedType.FUNDRAISERS, R.string.home_content_type_fundraisers, MaterialSymbols.Paid),
)
/**
* Per-content-type load toggles for the Home feed. Turning one off both drops its event kinds from
* the always-on home relay filters AND hides them from the New Threads / Conversations / Everything
* tabs. Everything is on by default.
*/
@Composable
private fun HomeContentTypesSection(accountViewModel: AccountViewModel) {
val enabled by accountViewModel.account.settings.enabledHomeFeedTypes
.collectAsStateWithLifecycle()
SettingsSection(R.string.settings_section_home_content_types) {
HOME_FEED_TYPES.forEachIndexed { index, item ->
if (index > 0) SettingsDivider()
SettingsSwitchTile(
icon = MaterialSymbols.Forum,
title = R.string.new_threads,
checked = showNewThreads,
// Don't allow disabling the last remaining tab.
enabled = !(showNewThreads && activeCount == 1),
onCheckedChange = { ui.showHomeNewThreadsTab.tryEmit(it) },
)
SettingsDivider()
SettingsSwitchTile(
icon = MaterialSymbols.Chat,
title = R.string.conversations,
checked = showConversations,
enabled = !(showConversations && activeCount == 1),
onCheckedChange = { ui.showHomeConversationsTab.tryEmit(it) },
)
SettingsDivider()
SettingsSwitchTile(
icon = MaterialSymbols.Public,
title = R.string.home_tab_everything,
checked = showEverything,
enabled = !(showEverything && activeCount == 1),
onCheckedChange = { ui.showHomeEverythingTab.tryEmit(it) },
icon = item.icon,
title = item.titleRes,
checked = item.type in enabled,
onCheckedChange = { accountViewModel.account.settings.setHomeFeedTypeEnabled(item.type, it) },
)
}
}
+20
View File
@@ -2330,6 +2330,26 @@
<string name="settings_section_tor_routing">Route through Tor</string>
<string name="settings_section_profile_sections">Sections &amp; feeds</string>
<string name="settings_section_home_tabs">Visible tabs</string>
<string name="settings_section_home_content_types">Content in the feed</string>
<string name="home_content_type_text_notes">Text notes</string>
<string name="home_content_type_reposts">Reposts</string>
<string name="home_content_type_comments">Comments &amp; replies</string>
<string name="home_content_type_articles">Articles</string>
<string name="home_content_type_wiki">Wiki pages</string>
<string name="home_content_type_highlights">Highlights</string>
<string name="home_content_type_polls">Polls</string>
<string name="home_content_type_classifieds">Classifieds</string>
<string name="home_content_type_voice">Voice messages</string>
<string name="home_content_type_live_activities">Live activities</string>
<string name="home_content_type_ephemeral_chat">Ephemeral chats</string>
<string name="home_content_type_interactive_stories">Interactive stories</string>
<string name="home_content_type_chess">Chess games</string>
<string name="home_content_type_birds">Bird sightings</string>
<string name="home_content_type_attestations">Attestations</string>
<string name="home_content_type_nips">NIP drafts</string>
<string name="home_content_type_music">Music &amp; audio</string>
<string name="home_content_type_podcasts">Podcasts</string>
<string name="home_content_type_fundraisers">Fundraisers</string>
<string name="settings_section_reminders">Reminders</string>
<string name="wallet_connect">Wallet Connect</string>
<string name="language">Language</string>
@@ -0,0 +1,83 @@
/*
* 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.model
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class HomeFeedTypeTest {
@Test
fun allContainsEveryEntry() {
assertEquals(HomeFeedType.entries.toSet(), HomeFeedType.ALL)
}
@Test
fun kindsAreDisjointAcrossTypes() {
val seen = mutableSetOf<Int>()
HomeFeedType.entries.forEach { type ->
type.kinds.forEach { kind ->
assertTrue("kind $kind is owned by more than one HomeFeedType", seen.add(kind))
}
}
}
@Test
fun encodeThenDecodeRoundTrips() {
val disabled = setOf(HomeFeedType.CHESS, HomeFeedType.BIRDS)
val enabled = HomeFeedType.ALL - disabled
val stored = HomeFeedType.encode(HomeFeedType.ALL - enabled)
assertEquals(enabled, HomeFeedType.ALL - HomeFeedType.decode(stored))
}
@Test
fun decodeNullOrBlankIsEmpty() {
assertEquals(emptySet<HomeFeedType>(), HomeFeedType.decode(null))
// Absence of a stored value means "nothing disabled" -> everything enabled.
assertEquals(HomeFeedType.ALL, HomeFeedType.ALL - HomeFeedType.decode(null))
}
@Test
fun decodeDropsUnknownCodes() {
val decoded = HomeFeedType.decode("chess,future-kind")
assertEquals(setOf(HomeFeedType.CHESS), decoded)
assertNull(HomeFeedType.fromCode("future-kind"))
}
@Test
fun disabledKindsEmptyWhenEverythingEnabled() {
assertTrue(HomeFeedType.disabledKinds(HomeFeedType.ALL).isEmpty())
}
@Test
fun disabledKindsAreExactlyTheDisabledGroupsKinds() {
val enabled = HomeFeedType.ALL - HomeFeedType.TEXT_NOTES - HomeFeedType.REPOSTS
val disabled = HomeFeedType.disabledKinds(enabled)
assertTrue(TextNoteEvent.KIND in disabled)
HomeFeedType.REPOSTS.kinds.forEach { assertTrue(it in disabled) }
// A still-enabled group's kinds must not leak into the disabled set.
HomeFeedType.POLLS.kinds.forEach { assertFalse(it in disabled) }
}
}