From cdfc275891f30991296ba0f508597c8c101678c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 23:22:50 +0000 Subject: [PATCH] feat(home): add per-event-kind toggles for the home feed Add a "Content in the feed" section to the Home settings screen with a switch per event-kind group (text notes, reposts, comments, articles, polls, voice, live activities, chess, ...). Disabling a group both drops its kinds from the always-on home relay filters (the assembler) and hides them from the New Threads / Conversations / Everything tabs (the DAL). - HomeFeedType: single source of truth mapping each toggleable group to its Nostr kinds, with stable codes + encode/decode for persistence (mirrors the existing ChatFeedType pattern for Messages). - AccountSettings.enabledHomeFeedTypes (+ setHomeFeedTypeEnabled), persisted per-account as the set of disabled codes so new groups default on. - Assembler: HomeOutboxEventsEoseManager strips disabled kinds from every home relay filter at the single choke point, and re-arms on toggle. - DAL: HomeNewThreadFeedFilter / HomeConversationsFeedFilter reject disabled kinds; AccountFeedContentStates rebuilds the home feeds when a toggle flips. Also extracts the inbox / relay-auth / feed-type preference reads out of LocalPreferences' account-load lambda into a helper: that lambda was already at the JVM per-method bytecode limit and the new field tipped it over ("Method too large"). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01L576BhgU3c638PkGHL8YUS --- .../amethyst/LocalPreferences.kt | 71 +++++++--- .../amethyst/model/AccountSettings.kt | 17 +++ .../amethyst/model/HomeFeedType.kt | 130 ++++++++++++++++++ .../loggedIn/AccountFeedContentStates.kt | 13 ++ .../home/dal/HomeConversationsFeedFilter.kt | 33 +++-- .../home/dal/HomeNewThreadFeedFilter.kt | 13 +- .../HomeOutboxEventsEoseManager.kt | 58 ++++++-- .../settings/HomeTabsSettingsScreen.kt | 127 ++++++++++++----- amethyst/src/main/res/values/strings.xml | 20 +++ .../amethyst/model/HomeFeedTypeTest.kt | 83 +++++++++++ 10 files changed, 480 insertions(+), 85 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/HomeFeedType.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/model/HomeFeedTypeTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 72512be6ae..d5e2530d15 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -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, + val enabledHomeFeedTypes: Set, + 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), + ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index c7c4956a72..1001b80f5e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -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> = 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> = MutableStateFlow(HomeFeedType.ALL), // The per-situation toggles applied under RelayAuthPolicy.CUSTOM. val relayAuthTrustMyRelaysAndVenues: MutableStateFlow = MutableStateFlow(true), val relayAuthTrustReadFollows: MutableStateFlow = 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 // --- diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HomeFeedType.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HomeFeedType.kt new file mode 100644 index 0000000000..d325b552e5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HomeFeedType.kt @@ -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, +) { + 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 = 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): String = types.joinToString(",") { it.code } + + /** Parses a comma-joined [code] list back to a set, dropping any unknown codes. */ + fun decode(joined: String?): Set = + 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): Set { + if (enabled.size == ALL.size) return emptySet() + val enabledKinds = enabled.flatMapTo(HashSet()) { it.kinds } + return (ALL - enabled).flatMapTo(HashSet()) { it.kinds }.apply { removeAll(enabledKinds) } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index 530792d193..f64b6aa565 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -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. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeConversationsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeConversationsFeedFilter.kt index d866826f53..ac42f6061c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeConversationsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeConversationsFeedFilter.kt @@ -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 { 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): Set { 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, filterParams: FilterByListParams, + disabledKinds: Set, ): 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, + ): Boolean = acceptableEvent(note.event, note.relays, filterParams, disabledKinds) && !note.isNewThread() override fun sort(items: Set): List = items.sortedByDefaultFeedOrder() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt index 5e3597e2cb..6a01d8dc07 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt @@ -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 { 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): Set { 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, ): 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 || diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt index ce93d4018a..d7b253d242 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt @@ -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.removeDisabledHomeKinds(disabled: Set): List { + 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)) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HomeTabsSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HomeTabsSettingsScreen.kt index 6c518bb796..ecdfded7d0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HomeTabsSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HomeTabsSettingsScreen.kt @@ -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) }, ) } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 25f314256d..81c43f1db4 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2330,6 +2330,26 @@ Route through Tor Sections & feeds Visible tabs + Content in the feed + Text notes + Reposts + Comments & replies + Articles + Wiki pages + Highlights + Polls + Classifieds + Voice messages + Live activities + Ephemeral chats + Interactive stories + Chess games + Bird sightings + Attestations + NIP drafts + Music & audio + Podcasts + Fundraisers Reminders Wallet Connect Language diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/HomeFeedTypeTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/HomeFeedTypeTest.kt new file mode 100644 index 0000000000..9d17589fe9 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/HomeFeedTypeTest.kt @@ -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() + 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.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) } + } +}