From ae6e1e9c2d0f06bc46614b01922c46c2a52fddbf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 16:22:20 +0000 Subject: [PATCH] feat(messages): per-type load toggles for the Messages inbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "Conversations to load" section to Settings › Messages that lets users choose which chat protocols the inbox loads: NIP-04, NIP-17, NIP-28, NIP-29, Marmot (MLS), Concord, Geolocation (geohash) and Ephemeral chats. Disabling a type both hides its rows from the inbox and drops its kinds/ assemblers from the always-on downloading routes: - New ChatFeedType enum (commons) with stable persisted codes. - AccountSettings.enabledChatFeeds (defaults to all-on); persisted per-device in LocalPreferences as the disabled set, so absence = everything on and any future type defaults enabled. - ChatroomListKnown/NewFeedFilter gate each section (NIP-04 vs NIP-17 split by event type) in both the full build and the incremental update paths. - Each downloading route (rooms-list NIP-04/28/ephemeral/geohash, account gift wraps + Marmot, NIP-29 joined groups, Concord) returns no filters when its type is off and re-arms via a shared launchChatFeedToggleObserver. - AccountFeedContentStates rebuilds both tabs when a toggle flips. - Modernized MessagesSettingsScreen: colorful accent switch cards per type; the NIP-29/Concord display-mode options now only appear while their type is on. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016RbzL9cZk1h88kE7ErgjG5 --- .../amethyst/LocalPreferences.kt | 8 + .../amethyst/model/AccountSettings.kt | 18 ++ .../eoseManagers/ChatFeedToggle.kt | 52 ++++ .../marmot/MarmotGroupEventsEoseManager.kt | 4 + .../AccountGiftWrapsEoseManager.kt | 5 +- .../AccountGiftWrapsHistoryEoseManager.kt | 2 + .../loggedIn/AccountFeedContentStates.kt | 12 + .../ConcordChannelFilterAssembler.kt | 22 ++ ...RelayGroupMyJoinedGroupsFilterAssembler.kt | 22 ++ .../rooms/dal/ChatroomListKnownFeedFilter.kt | 188 +++++++----- .../rooms/dal/ChatroomListNewFeedFilter.kt | 32 +- .../ChatroomListNip04HistorySubAssembler.kt | 2 + .../ChatroomListNip04SubAssembler.kt | 5 +- .../FollowingEphemeralChatSubAssembler.kt | 13 +- .../FollowingGeohashChatSubAssembler.kt | 13 +- .../FollowingPublicChatSubAssembler.kt | 15 +- .../settings/MessagesSettingsScreen.kt | 281 +++++++++++++++--- amethyst/src/main/res/values/strings.xml | 20 +- .../commons/model/chats/ChatFeedType.kt | 76 +++++ .../commons/model/chats/ChatFeedTypeTest.kt | 80 +++++ 20 files changed, 729 insertions(+), 141 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/ChatFeedToggle.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/chats/ChatFeedType.kt create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/chats/ChatFeedTypeTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 1018dde865..001acbd3c4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -25,6 +25,7 @@ import android.content.Context import android.content.SharedPreferences import androidx.compose.runtime.Immutable import androidx.core.content.edit +import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntry import com.vitorpamplona.amethyst.commons.model.concord.ConcordViewMode import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode @@ -174,6 +175,10 @@ private object PrefKeys { const val DEFAULT_RELAY_AUTH_POLICY = "default_relay_auth_policy" const val RELAY_GROUP_VIEW_MODE = "relay_group_view_mode" const val CONCORD_VIEW_MODE = "concord_view_mode" + + // 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" 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" @@ -568,6 +573,7 @@ object LocalPreferences { putString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, settings.defaultRelayAuthPolicy.value.name) 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)) 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) @@ -698,6 +704,7 @@ object LocalPreferences { ?: 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) @@ -916,6 +923,7 @@ object LocalPreferences { defaultRelayAuthPolicy = MutableStateFlow(defaultRelayAuthPolicy), relayGroupViewMode = MutableStateFlow(relayGroupViewMode), concordViewMode = MutableStateFlow(concordViewMode), + enabledChatFeeds = MutableStateFlow(enabledChatFeeds), relayAuthTrustMyRelaysAndVenues = MutableStateFlow(relayAuthTrustMyRelays), relayAuthTrustReadFollows = MutableStateFlow(relayAuthTrustReadFollows), relayAuthTrustMessageFollows = MutableStateFlow(relayAuthTrustMessageFollows), 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 69fc7d61a5..02833dc6d0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle +import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntryNorm import com.vitorpamplona.amethyst.commons.model.concord.ConcordListRepository import com.vitorpamplona.amethyst.commons.model.concord.ConcordViewMode @@ -310,6 +311,9 @@ class AccountSettings( val defaultRelayAuthPolicy: MutableStateFlow = MutableStateFlow(RelayAuthPolicy.CUSTOM), val relayGroupViewMode: MutableStateFlow = MutableStateFlow(RelayGroupViewMode.DEFAULT), val concordViewMode: MutableStateFlow = MutableStateFlow(ConcordViewMode.DEFAULT), + // 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), // The per-situation toggles applied under RelayAuthPolicy.CUSTOM. val relayAuthTrustMyRelaysAndVenues: MutableStateFlow = MutableStateFlow(true), val relayAuthTrustReadFollows: MutableStateFlow = MutableStateFlow(true), @@ -346,6 +350,20 @@ class AccountSettings( } } + fun isChatFeedEnabled(type: ChatFeedType): Boolean = type in enabledChatFeeds.value + + fun setChatFeedEnabled( + type: ChatFeedType, + enabled: Boolean, + ) { + val current = enabledChatFeeds.value + val next = if (enabled) current + type else current - type + if (next != current) { + enabledChatFeeds.tryEmit(next) + saveAccountSettings() + } + } + // --- // Always-on Notification Service // --- diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/ChatFeedToggle.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/ChatFeedToggle.kt new file mode 100644 index 0000000000..0e2b0e7b7e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/ChatFeedToggle.kt @@ -0,0 +1,52 @@ +/* + * 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.service.relayClient.eoseManagers + +import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType +import com.vitorpamplona.amethyst.model.Account +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +/** + * Rebuilds a Messages-inbox subscription's filters whenever the user flips [type]'s + * load-toggle in Settings › Messages. A disabled type's `updateFilter` returns no filters, so a + * flip to off empties the live subscription and a flip back on re-arms it — no restart needed. + * + * Only the boolean for [type] is watched (via distinct + drop(1)), so unrelated toggle changes + * don't churn this assembler. + */ +fun CoroutineScope.launchChatFeedToggleObserver( + account: Account, + type: ChatFeedType, + onToggle: () -> Unit, +): Job = + launch(Dispatchers.IO) { + account.settings.enabledChatFeeds + .map { type in it } + .distinctUntilChanged() + .drop(1) + .collect { onToggle() } + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt index 4ad2b21530..4dc904d02e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt @@ -20,8 +20,10 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.marmot +import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.launchChatFeedToggleObserver import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient @@ -51,6 +53,7 @@ class MarmotGroupEventsEoseManager( ): List { val manager = key.account.marmotManager ?: return emptyList() if (!key.account.isWriteable()) return emptyList() + if (!key.account.settings.isChatFeedEnabled(ChatFeedType.MARMOT)) return emptyList() val result = mutableListOf() val fallbackRelays = key.account.homeRelays.flow.value @@ -130,6 +133,7 @@ class MarmotGroupEventsEoseManager( invalidateFilters() } }, + key.account.scope.launchChatFeedToggleObserver(key.account, ChatFeedType.MARMOT) { invalidateFilters() }, ) return super.newSub(key) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt index 1ddf8e8028..3c14d32848 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps +import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType import com.vitorpamplona.amethyst.commons.model.privateChats.DmHistoryTuning import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.filterGiftWrapsToPubkey import com.vitorpamplona.amethyst.commons.relayClient.paging.WindowLoadTracker @@ -27,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.paging.trackingListener import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.DmRelayLog import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.launchChatFeedToggleObserver import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient @@ -63,7 +65,7 @@ class AccountGiftWrapsEoseManager( key: AccountQueryState, since: SincePerRelayMap?, ): List { - if (!key.account.isWriteable()) { + if (!key.account.isWriteable() || !key.account.settings.isChatFeedEnabled(ChatFeedType.NIP17)) { windowLoad.setExpectedRelays(emptySet()) return emptyList() } @@ -90,6 +92,7 @@ class AccountGiftWrapsEoseManager( key.account.dmRelays.flow .collectLatest { invalidateFilters() } }, + key.account.scope.launchChatFeedToggleObserver(key.account, ChatFeedType.NIP17) { invalidateFilters() }, ) return requestNewSubscription( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsHistoryEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsHistoryEoseManager.kt index 93f94d2af0..02d351be88 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsHistoryEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsHistoryEoseManager.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps +import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.filterGiftWrapsToPubkey import com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus @@ -73,6 +74,7 @@ class AccountGiftWrapsHistoryEoseManager( since: SincePerRelayMap?, ): List { if (!key.account.isWriteable()) return emptyList() + if (!key.account.settings.isChatFeedEnabled(ChatFeedType.NIP17)) return emptyList() // Only relays that have been advanced (armed) and aren't done carry a REQ. A relay that finished a // page keeps the same `until` here, so re-assembly (triggered when ANOTHER relay advances) doesn't // re-REQ it — it stays parked until the UI advances it again. 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 b128268db9..e1808649a8 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 @@ -210,6 +210,18 @@ class AccountFeedContentStates( } } + // Toggling a chat type on/off in Settings › Messages changes which sections the inbox shows, + // but no event flows through LocalCache — force a full rebuild of both tabs so hidden types + // disappear (and re-enabled ones reappear from cache) immediately. + scope.launch(Dispatchers.IO) { + account.settings.enabledChatFeeds + .drop(1) + .collect { + dmKnown.invalidateData() + dmNew.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/chats/publicChannels/concord/datasource/ConcordChannelFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelFilterAssembler.kt index 74c1219214..e90d18bc91 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelFilterAssembler.kt @@ -22,12 +22,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.conco import com.vitorpamplona.amethyst.commons.actions.ConcordPlaneSub import com.vitorpamplona.amethyst.commons.actions.ConcordSubscriptionPlanner +import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.launchChatFeedToggleObserver import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import kotlinx.coroutines.Job /** One screen's request to keep the user's joined Concord Channels live. */ class ConcordChannelQueryState( @@ -69,6 +73,7 @@ class ConcordChannelSubAssembler( since: SincePerRelayMap?, ): List? { val account = key.account + if (!account.settings.isChatFeedEnabled(ChatFeedType.CONCORD)) return null val entries = account.concordChannelList.liveCommunities.value if (entries.isEmpty()) return null @@ -96,4 +101,21 @@ class ConcordChannelSubAssembler( } override fun id(key: ConcordChannelQueryState) = key.account + + private val toggleJobs = mutableMapOf() + + override fun newSub(key: ConcordChannelQueryState): Subscription { + toggleJobs.remove(key.account)?.cancel() + toggleJobs[key.account] = + key.account.scope.launchChatFeedToggleObserver(key.account, ChatFeedType.CONCORD) { invalidateFilters() } + return super.newSub(key) + } + + override fun endSub( + key: Account, + subId: String, + ) { + super.endSub(key, subId) + toggleJobs.remove(key)?.cancel() + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupMyJoinedGroupsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupMyJoinedGroupsFilterAssembler.kt index 44b88c0451..c94fd56930 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupMyJoinedGroupsFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupMyJoinedGroupsFilterAssembler.kt @@ -20,12 +20,15 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource +import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.launchChatFeedToggleObserver import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent @@ -34,6 +37,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import kotlinx.coroutines.Job /** One screen's request to keep the roster of the user's joined groups fresh. */ class RelayGroupMyJoinedGroupsQueryState( @@ -106,6 +110,7 @@ class RelayGroupMyJoinedGroupsSubAssembler( key: RelayGroupMyJoinedGroupsQueryState, since: SincePerRelayMap?, ): List? { + if (!key.account.settings.isChatFeedEnabled(ChatFeedType.NIP29)) return null val joined = key.account.relayGroupList.liveRelayGroupList.value if (joined.isEmpty()) return null @@ -149,4 +154,21 @@ class RelayGroupMyJoinedGroupsSubAssembler( } override fun id(key: RelayGroupMyJoinedGroupsQueryState) = key.account + + private val toggleJobs = mutableMapOf() + + override fun newSub(key: RelayGroupMyJoinedGroupsQueryState): Subscription { + toggleJobs.remove(key.account)?.cancel() + toggleJobs[key.account] = + key.account.scope.launchChatFeedToggleObserver(key.account, ChatFeedType.NIP29) { invalidateFilters() } + return super.newSub(key) + } + + override fun endSub( + key: Account, + subId: String, + ) { + super.endSub(key, subId) + toggleJobs.remove(key)?.cancel() + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt index 135b0a304c..5ec4afb1a3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal +import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel import com.vitorpamplona.amethyst.commons.model.concord.ConcordViewMode import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel @@ -39,6 +40,7 @@ import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent @@ -54,6 +56,11 @@ class ChatroomListKnownFeedFilter( ) : AdditiveFeedFilter() { override fun feedKey(): String = account.userProfile().pubkeyHex + private fun isEnabled(type: ChatFeedType): Boolean = type in account.settings.enabledChatFeeds.value + + /** A room note is NIP-04 when its event is a [PrivateDmEvent], otherwise it is a NIP-17 message. */ + private fun isDmEnabled(note: Note): Boolean = isEnabled(if (note.event is PrivateDmEvent) ChatFeedType.NIP04 else ChatFeedType.NIP17) + // returns the last Note of each user. override fun feed(): List { val chatList = account.chatroomList @@ -61,57 +68,76 @@ class ChatroomListKnownFeedFilter( val privateMessages = chatList.rooms.mapNotNull { key, chatroom -> - if ((chatroom.senderIntersects(followingKeySet) || chatList.hasSentMessagesTo(key)) && + val newest = chatroom.newestMessage + if (newest != null && + isDmEnabled(newest) && + (chatroom.senderIntersects(followingKeySet) || chatList.hasSentMessagesTo(key)) && !account.isAllHidden(key.users) ) { - chatroom.newestMessage + newest } else { null } } val publicChannels = - account - .publicChatList.flowSet.value - .mapNotNull { channelId -> - LocalCache - .getOrCreatePublicChatChannel(channelId) - .notes - .filter { _, it -> account.isAcceptable(it) && it.event != null } - .sortedByDefaultFeedOrder() - .firstOrNull() - } + if (!isEnabled(ChatFeedType.NIP28)) { + emptyList() + } else { + account + .publicChatList.flowSet.value + .mapNotNull { channelId -> + LocalCache + .getOrCreatePublicChatChannel(channelId) + .notes + .filter { _, it -> account.isAcceptable(it) && it.event != null } + .sortedByDefaultFeedOrder() + .firstOrNull() + } + } val ephemeralChats = - account - .ephemeralChatList.liveEphemeralChatList.value - .mapNotNull { it -> - LocalCache - .getOrCreateEphemeralChannel(it) - .notes - .filter { _, it -> account.isAcceptable(it) && it.event != null } - .sortedByDefaultFeedOrder() - .firstOrNull() - } + if (!isEnabled(ChatFeedType.EPHEMERAL)) { + emptyList() + } else { + account + .ephemeralChatList.liveEphemeralChatList.value + .mapNotNull { it -> + LocalCache + .getOrCreateEphemeralChannel(it) + .notes + .filter { _, it -> account.isAcceptable(it) && it.event != null } + .sortedByDefaultFeedOrder() + .firstOrNull() + } + } // Joined geohash location channels (kind 10081 list). Ephemeral, so a quiet // cell has no stored message — show a placeholder row until one arrives, the // same way just-joined NIP-29/Marmot groups do. val geohashChannels = - account.geohashList.flow.value.map { geohash -> - val channel = LocalCache.getOrCreateGeohashChannel(geohash) - channel.notes - .filter { _, it -> account.isAcceptable(it) && it.event != null } - .sortedByDefaultFeedOrder() - .firstOrNull() ?: channel.placeholderNote() + if (!isEnabled(ChatFeedType.GEOHASH)) { + emptyList() + } else { + account.geohashList.flow.value.map { geohash -> + val channel = LocalCache.getOrCreateGeohashChannel(geohash) + channel.notes + .filter { _, it -> account.isAcceptable(it) && it.event != null } + .sortedByDefaultFeedOrder() + .firstOrNull() ?: channel.placeholderNote() + } } val marmotGroups = - account.marmotGroupList.rooms.mapNotNull { _, chatroom -> - if (chatroom.isKnown(followingKeySet)) { - chatroom.newestMessage ?: chatroom.placeholderNote() - } else { - null + if (!isEnabled(ChatFeedType.MARMOT)) { + emptyList() + } else { + account.marmotGroupList.rooms.mapNotNull { _, chatroom -> + if (chatroom.isKnown(followingKeySet)) { + chatroom.newestMessage ?: chatroom.placeholderNote() + } else { + null + } } } @@ -120,30 +146,34 @@ class ChatroomListKnownFeedFilter( // to a single relay row positioned by that relay's newest message. Both interleave with the // rest of the Messages list by recency. val relayGroups = - when (account.settings.relayGroupViewMode.value) { - RelayGroupViewMode.INLINE -> - account.relayGroupList.liveRelayGroupList.value.mapNotNull { groupTag -> - val relay = RelayUrlNormalizer.normalizeOrNull(groupTag.relayUrl) ?: return@mapNotNull null - val channel = LocalCache.getOrCreateRelayGroupChannel(GroupId(groupTag.groupId, relay)) - // Newest loaded chat message, or a placeholder row so a just-joined group shows - // up on Messages before its first kind-9 arrives (mirrors the Marmot-group path - // above). Content kinds only — never a reaction/deletion as the "last message". - channel.newestChatNote(account) ?: channel.placeholderNote() - } - - RelayGroupViewMode.GROUPED -> - // One row per host relay (never duplicated), carrying the newest chat across ALL of - // that relay's joined groups so it lands in the newest-message spot among the DMs. - account.relayGroupList.liveRelayGroupList.value - .groupBy { it.relayUrl } - .mapNotNull { (relayUrl, tags) -> - val relay = RelayUrlNormalizer.normalizeOrNull(relayUrl) ?: return@mapNotNull null - val newest = - tags - .mapNotNull { LocalCache.getOrCreateRelayGroupChannel(GroupId(it.groupId, relay)).newestChatNote(account) } - .maxByOrNull { it.createdAt() ?: 0L } - RelayGroupServerRoomNote(relay, newest) + if (!isEnabled(ChatFeedType.NIP29)) { + emptyList() + } else { + when (account.settings.relayGroupViewMode.value) { + RelayGroupViewMode.INLINE -> + account.relayGroupList.liveRelayGroupList.value.mapNotNull { groupTag -> + val relay = RelayUrlNormalizer.normalizeOrNull(groupTag.relayUrl) ?: return@mapNotNull null + val channel = LocalCache.getOrCreateRelayGroupChannel(GroupId(groupTag.groupId, relay)) + // Newest loaded chat message, or a placeholder row so a just-joined group shows + // up on Messages before its first kind-9 arrives (mirrors the Marmot-group path + // above). Content kinds only — never a reaction/deletion as the "last message". + channel.newestChatNote(account) ?: channel.placeholderNote() } + + RelayGroupViewMode.GROUPED -> + // One row per host relay (never duplicated), carrying the newest chat across ALL of + // that relay's joined groups so it lands in the newest-message spot among the DMs. + account.relayGroupList.liveRelayGroupList.value + .groupBy { it.relayUrl } + .mapNotNull { (relayUrl, tags) -> + val relay = RelayUrlNormalizer.normalizeOrNull(relayUrl) ?: return@mapNotNull null + val newest = + tags + .mapNotNull { LocalCache.getOrCreateRelayGroupChannel(GroupId(it.groupId, relay)).newestChatNote(account) } + .maxByOrNull { it.createdAt() ?: 0L } + RelayGroupServerRoomNote(relay, newest) + } + } } // Concord Channels the user joined (kind 13302 list → folded Control Plane). In INLINE view @@ -153,26 +183,30 @@ class ChatroomListKnownFeedFilter( // positioned by that community's newest message. Concord groups by community exactly as // NIP-29 groups by host relay above; both interleave with the rest of Messages by recency. val concordChannels = - when (account.settings.concordViewMode.value) { - ConcordViewMode.INLINE -> - account.concordSessions.sessions().flatMap { session -> - val state = session.state.value ?: return@flatMap emptyList() - state.channels.keys.map { channelIdHex -> - val channel = LocalCache.getOrCreateConcordChannel(ConcordChannelId(session.entry.id, channelIdHex)) - channel.newestConcordNote(account) ?: channel.placeholderNote() + if (!isEnabled(ChatFeedType.CONCORD)) { + emptyList() + } else { + when (account.settings.concordViewMode.value) { + ConcordViewMode.INLINE -> + account.concordSessions.sessions().flatMap { session -> + val state = session.state.value ?: return@flatMap emptyList() + state.channels.keys.map { channelIdHex -> + val channel = LocalCache.getOrCreateConcordChannel(ConcordChannelId(session.entry.id, channelIdHex)) + channel.newestConcordNote(account) ?: channel.placeholderNote() + } } - } - ConcordViewMode.GROUPED -> - // One row per joined community, carrying the newest message across ALL its channels. - account.concordSessions.sessions().mapNotNull { session -> - val state = session.state.value ?: return@mapNotNull null - val newest = - state.channels.keys - .mapNotNull { LocalCache.getOrCreateConcordChannel(ConcordChannelId(session.entry.id, it)).newestConcordNote(account) } - .maxByOrNull { it.createdAt() ?: 0L } - ConcordServerRoomNote(session.entry.id, newest) - } + ConcordViewMode.GROUPED -> + // One row per joined community, carrying the newest message across ALL its channels. + account.concordSessions.sessions().mapNotNull { session -> + val state = session.state.value ?: return@mapNotNull null + val newest = + state.channels.keys + .mapNotNull { LocalCache.getOrCreateConcordChannel(ConcordChannelId(session.entry.id, it)).newestConcordNote(account) } + .maxByOrNull { it.createdAt() ?: 0L } + ConcordServerRoomNote(session.entry.id, newest) + } + } } return sort((privateMessages + publicChannels + ephemeralChats + geohashChannels + marmotGroups + relayGroups + concordChannels).toSet()) @@ -343,6 +377,7 @@ class ChatroomListKnownFeedFilter( newItems: Set, account: Account, ): MutableMap { + if (!isEnabled(ChatFeedType.GEOHASH)) return mutableMapOf() val joined = account.geohashList.flow.value val newRelevant = mutableMapOf() newItems.forEach { newNote -> @@ -385,6 +420,7 @@ class ChatroomListKnownFeedFilter( newItems: Set, account: Account, ): MutableMap { + if (!isEnabled(ChatFeedType.CONCORD)) return mutableMapOf() // Newest new message per channel (INLINE) or per community (GROUPED). val grouped = account.settings.concordViewMode.value == ConcordViewMode.GROUPED val newestPerKey = mutableMapOf() @@ -406,6 +442,7 @@ class ChatroomListKnownFeedFilter( newItems: Set, account: Account, ): MutableMap { + if (!isEnabled(ChatFeedType.NIP28)) return mutableMapOf() val followingChannels = account.publicChatList.flowSet.value val newRelevantPublicMessages = mutableMapOf() newItems @@ -429,6 +466,7 @@ class ChatroomListKnownFeedFilter( newItems: Set, account: Account, ): MutableMap { + if (!isEnabled(ChatFeedType.EPHEMERAL)) return mutableMapOf() val followingEphemeralChats = account.ephemeralChatList.liveEphemeralChatList.value val newRelevantEphemeralChats = mutableMapOf() newItems @@ -493,6 +531,7 @@ class ChatroomListKnownFeedFilter( newItems: Set, account: Account, ): MutableMap { + if (!isEnabled(ChatFeedType.NIP29)) return mutableMapOf() val joined = account.relayGroupList.liveRelayGroupList.value if (joined.isEmpty()) return mutableMapOf() @@ -545,6 +584,7 @@ class ChatroomListKnownFeedFilter( val newRelevantPrivateMessages = mutableMapOf() newItems .forEach { newNote -> + if (!isDmEnabled(newNote)) return@forEach val roomKey = (newNote.event as? ChatroomKeyable)?.chatroomKey(me.pubkeyHex) if (roomKey != null) { val room = account.chatroomList.rooms.get(roomKey) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt index 3b26f37d54..c9be15794f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt @@ -20,11 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal +import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType import com.vitorpamplona.amethyst.commons.util.replace import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable @@ -33,6 +35,11 @@ class ChatroomListNewFeedFilter( ) : AdditiveFeedFilter() { override fun feedKey(): String = account.userProfile().pubkeyHex + private fun isEnabled(type: ChatFeedType): Boolean = type in account.settings.enabledChatFeeds.value + + /** A room note is NIP-04 when its event is a [PrivateDmEvent], otherwise it is a NIP-17 message. */ + private fun isDmEnabled(note: Note): Boolean = isEnabled(if (note.event is PrivateDmEvent) ChatFeedType.NIP04 else ChatFeedType.NIP17) + // returns the last Note of each user. override fun feed(): List { val chatList = account.chatroomList @@ -40,19 +47,29 @@ class ChatroomListNewFeedFilter( val privateMessages = chatList.rooms.mapNotNull { key, chatroom -> - if (!chatroom.senderIntersects(followingKeySet) && !chatList.hasSentMessagesTo(key) && !account.isAllHidden(key.users)) { - chatroom.newestMessage + val newest = chatroom.newestMessage + if (newest != null && + isDmEnabled(newest) && + !chatroom.senderIntersects(followingKeySet) && + !chatList.hasSentMessagesTo(key) && + !account.isAllHidden(key.users) + ) { + newest } else { null } } val marmotGroups = - account.marmotGroupList.rooms.mapNotNull { _, chatroom -> - if (!chatroom.isKnown(followingKeySet)) { - chatroom.newestMessage ?: chatroom.placeholderNote() - } else { - null + if (!isEnabled(ChatFeedType.MARMOT)) { + emptyList() + } else { + account.marmotGroupList.rooms.mapNotNull { _, chatroom -> + if (!chatroom.isKnown(followingKeySet)) { + chatroom.newestMessage ?: chatroom.placeholderNote() + } else { + null + } } } @@ -114,6 +131,7 @@ class ChatroomListNewFeedFilter( val newRelevantPrivateMessages = mutableMapOf() newItems.forEach { newNote -> + if (!isDmEnabled(newNote)) return@forEach val noteEvent = newNote.event if (noteEvent is ChatroomKeyable) { val roomKey = noteEvent.chatroomKey(me.pubkeyHex) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04HistorySubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04HistorySubAssembler.kt index 73a31dab16..bf7f2fee05 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04HistorySubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04HistorySubAssembler.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource +import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType import com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus import com.vitorpamplona.amethyst.model.Account @@ -66,6 +67,7 @@ class ChatroomListNip04HistorySubAssembler( ): List? { val user = user(key) if (!key.account.isWriteable()) return emptyList() + if (!key.account.settings.isChatFeedEnabled(ChatFeedType.NIP04)) return emptyList() val homeRelays = key.account.homeRelays.flow.value val dmRelays = key.account.dmRelays.flow.value val armed = pager.armedRelays((homeRelays + dmRelays).toSet()) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04SubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04SubAssembler.kt index cd712abcaa..2c951d7823 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04SubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04SubAssembler.kt @@ -20,12 +20,14 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource +import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType import com.vitorpamplona.amethyst.commons.model.privateChats.DmHistoryTuning import com.vitorpamplona.amethyst.commons.relayClient.paging.WindowLoadTracker import com.vitorpamplona.amethyst.commons.relayClient.paging.trackingListener import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.DmRelayLog import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.launchChatFeedToggleObserver import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter @@ -55,7 +57,7 @@ class ChatroomListNip04SubAssembler( key: ChatroomListState, since: SincePerRelayMap?, ): List? = - if (key.account.isWriteable()) { + if (key.account.isWriteable() && key.account.settings.isChatFeedEnabled(ChatFeedType.NIP04)) { val homeRelays = key.account.homeRelays.flow.value val dmRelays = key.account.dmRelays.flow.value windowLoad.setExpectedRelays((homeRelays + dmRelays).toSet()) @@ -88,6 +90,7 @@ class ChatroomListNip04SubAssembler( key.account.dmRelays.flow .collectLatest { invalidateFilters() } }, + key.account.scope.launchChatFeedToggleObserver(key.account, ChatFeedType.NIP04) { invalidateFilters() }, ) return requestNewSubscription( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingEphemeralChatSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingEphemeralChatSubAssembler.kt index ad6ca0ddb7..a457643694 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingEphemeralChatSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingEphemeralChatSubAssembler.kt @@ -20,8 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource +import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.launchChatFeedToggleObserver import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter @@ -41,9 +43,13 @@ class FollowingEphemeralChatSubAssembler( key: ChatroomListState, since: SincePerRelayMap?, ): List = - listOfNotNull( - filterFollowingEphemeralChats(key.account.ephemeralChatList.liveEphemeralChatList.value, since), - ).flatten() + if (!key.account.settings.isChatFeedEnabled(ChatFeedType.EPHEMERAL)) { + emptyList() + } else { + listOfNotNull( + filterFollowingEphemeralChats(key.account.ephemeralChatList.liveEphemeralChatList.value, since), + ).flatten() + } override fun user(key: ChatroomListState) = key.account.userProfile() @@ -59,6 +65,7 @@ class FollowingEphemeralChatSubAssembler( invalidateFilters() } }, + key.account.scope.launchChatFeedToggleObserver(key.account, ChatFeedType.EPHEMERAL) { invalidateFilters() }, ) return super.newSub(key) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingGeohashChatSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingGeohashChatSubAssembler.kt index 7987140998..e74e82581b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingGeohashChatSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingGeohashChatSubAssembler.kt @@ -20,9 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource +import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.geohash.GeohashRelays import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.launchChatFeedToggleObserver import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter @@ -50,9 +52,13 @@ class FollowingGeohashChatSubAssembler( key: ChatroomListState, since: SincePerRelayMap?, ): List = - listOfNotNull( - filterFollowingGeohashChats(key.account.geohashList.flow.value, since), - ).flatten() + if (!key.account.settings.isChatFeedEnabled(ChatFeedType.GEOHASH)) { + emptyList() + } else { + listOfNotNull( + filterFollowingGeohashChats(key.account.geohashList.flow.value, since), + ).flatten() + } override fun user(key: ChatroomListState) = key.account.userProfile() @@ -74,6 +80,7 @@ class FollowingGeohashChatSubAssembler( key.account.scope.launch(Dispatchers.IO) { if (GeohashRelays.ensureLoaded()) invalidateFilters() }, + key.account.scope.launchChatFeedToggleObserver(key.account, ChatFeedType.GEOHASH) { invalidateFilters() }, ) return super.newSub(key) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingPublicChatSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingPublicChatSubAssembler.kt index 98c66b737e..07973513ed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingPublicChatSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingPublicChatSubAssembler.kt @@ -20,8 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource +import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.launchChatFeedToggleObserver import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter @@ -41,10 +43,14 @@ class FollowingPublicChatSubAssembler( key: ChatroomListState, since: SincePerRelayMap?, ): List = - listOfNotNull( - filterLastMessageFollowingPublicChats(key.account.publicChatList.flowSet.value, since), - filterFollowingPublicChatsCreationEvent(key.account.publicChatList.flowSet.value, since), - ).flatten() + if (!key.account.settings.isChatFeedEnabled(ChatFeedType.NIP28)) { + emptyList() + } else { + listOfNotNull( + filterLastMessageFollowingPublicChats(key.account.publicChatList.flowSet.value, since), + filterFollowingPublicChatsCreationEvent(key.account.publicChatList.flowSet.value, since), + ).flatten() + } override fun user(key: ChatroomListState) = key.account.userProfile() @@ -60,6 +66,7 @@ class FollowingPublicChatSubAssembler( invalidateFilters() } }, + key.account.scope.launchChatFeedToggleObserver(key.account, ChatFeedType.NIP28) { invalidateFilters() }, ) return super.newSub(key) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MessagesSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MessagesSettingsScreen.kt index 0f0a0a2d67..6e8f71c51c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MessagesSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MessagesSettingsScreen.kt @@ -20,26 +20,43 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.selection.selectable -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight 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.model.chats.ChatFeedType import com.vitorpamplona.amethyst.commons.model.concord.ConcordViewMode import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav @@ -59,16 +76,42 @@ fun MessagesSettingsScreenPreview() { } /** - * User preferences for the Messages tab. Currently the NIP-29 relay-group display mode: show each - * joined group inline as its own conversation, or collapse each host relay's groups into a single - * row placed at its newest message. Lives here (rather than pinned above the feed) so it doesn't - * crowd the list. + * A single toggleable Messages conversation type, with a distinct accent color that carries over onto + * its Switch so the (long) list reads as a colorful palette instead of a wall of grey rows. + */ +private data class ChatFeedTypeUi( + val type: ChatFeedType, + val titleRes: Int, + val descRes: Int, + val accent: Color, +) + +// Ordered by how central each type is to the inbox (private first, exotic last). +private val CHAT_FEED_TYPES = + listOf( + ChatFeedTypeUi(ChatFeedType.NIP17, R.string.chat_type_nip17_title, R.string.chat_type_nip17_desc, Color(0xFF2EBD85)), + ChatFeedTypeUi(ChatFeedType.NIP04, R.string.chat_type_nip04_title, R.string.chat_type_nip04_desc, Color(0xFFF6A609)), + ChatFeedTypeUi(ChatFeedType.NIP28, R.string.chat_type_nip28_title, R.string.chat_type_nip28_desc, Color(0xFF2E90FA)), + ChatFeedTypeUi(ChatFeedType.NIP29, R.string.chat_type_nip29_title, R.string.chat_type_nip29_desc, Color(0xFF9E77ED)), + ChatFeedTypeUi(ChatFeedType.MARMOT, R.string.chat_type_marmot_title, R.string.chat_type_marmot_desc, Color(0xFF5B6AD0)), + ChatFeedTypeUi(ChatFeedType.CONCORD, R.string.chat_type_concord_title, R.string.chat_type_concord_desc, Color(0xFFEC4899)), + ChatFeedTypeUi(ChatFeedType.GEOHASH, R.string.chat_type_geohash_title, R.string.chat_type_geohash_desc, Color(0xFFEF4444)), + ChatFeedTypeUi(ChatFeedType.EPHEMERAL, R.string.chat_type_ephemeral_title, R.string.chat_type_ephemeral_desc, Color(0xFF06B6D4)), + ) + +/** + * User preferences for the Messages tab. The main control is a set of per-conversation-type load + * toggles: each turns a chat kind (NIP-04/17/28/29, Marmot, Concord, geohash, ephemeral) both off the + * inbox AND off the always-on downloading routes. Below that, the NIP-29 and Concord display modes + * only surface while their type is enabled, so the screen stays focused. */ @Composable fun MessagesSettingsScreen( accountViewModel: AccountViewModel, nav: INav, ) { + val enabled by accountViewModel.account.settings.enabledChatFeeds + .collectAsStateWithLifecycle() val mode by accountViewModel.account.settings.relayGroupViewMode .collectAsStateWithLifecycle() val concordMode by accountViewModel.account.settings.concordViewMode @@ -79,52 +122,195 @@ fun MessagesSettingsScreen( TopBarWithBackButton(stringRes(R.string.messages_settings), nav) }, ) { padding -> - Column( - Modifier - .padding(padding) - .verticalScroll(rememberScrollState()), + LazyColumn( + modifier = Modifier.fillMaxWidth(), + contentPadding = + PaddingValues( + start = 16.dp, + end = 16.dp, + top = padding.calculateTopPadding() + 12.dp, + bottom = padding.calculateBottomPadding() + 24.dp, + ), ) { + item { + SectionHeader( + title = stringRes(R.string.messages_load_types_title), + description = stringRes(R.string.messages_load_types_desc), + ) + } + + item { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)), + ) { + Column { + CHAT_FEED_TYPES.forEachIndexed { index, ui -> + if (index > 0) { + HorizontalDivider( + modifier = Modifier.padding(start = 68.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), + ) + } + ChatTypeToggleRow( + ui = ui, + checked = ui.type in enabled, + onCheckedChange = { accountViewModel.account.settings.setChatFeedEnabled(ui.type, it) }, + ) + } + } + } + } + + if (ChatFeedType.NIP29 in enabled) { + item { + SectionHeader(title = stringRes(R.string.relay_group_view_mode_title)) + } + item { + ViewModeCard { + ViewModeOption( + title = stringRes(R.string.relay_group_view_inline), + description = stringRes(R.string.relay_group_view_inline_desc), + selected = mode == RelayGroupViewMode.INLINE, + onSelect = { accountViewModel.account.settings.updateRelayGroupViewMode(RelayGroupViewMode.INLINE) }, + ) + ViewModeOption( + title = stringRes(R.string.relay_group_view_grouped), + description = stringRes(R.string.relay_group_view_grouped_desc), + selected = mode == RelayGroupViewMode.GROUPED, + onSelect = { accountViewModel.account.settings.updateRelayGroupViewMode(RelayGroupViewMode.GROUPED) }, + ) + } + } + } + + if (ChatFeedType.CONCORD in enabled) { + item { + SectionHeader(title = stringRes(R.string.concord_view_mode_title)) + } + item { + ViewModeCard { + ViewModeOption( + title = stringRes(R.string.concord_view_inline), + description = stringRes(R.string.concord_view_inline_desc), + selected = concordMode == ConcordViewMode.INLINE, + onSelect = { accountViewModel.account.settings.updateConcordViewMode(ConcordViewMode.INLINE) }, + ) + ViewModeOption( + title = stringRes(R.string.concord_view_grouped), + description = stringRes(R.string.concord_view_grouped_desc), + selected = concordMode == ConcordViewMode.GROUPED, + onSelect = { accountViewModel.account.settings.updateConcordViewMode(ConcordViewMode.GROUPED) }, + ) + } + } + } + } + } +} + +@Composable +private fun SectionHeader( + title: String, + description: String? = null, +) { + Column(Modifier.padding(start = 4.dp, end = 4.dp, top = 20.dp, bottom = 10.dp)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + if (description != null) { Text( - text = stringRes(R.string.relay_group_view_mode_title), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp), - ) - - ViewModeOption( - title = stringRes(R.string.relay_group_view_inline), - description = stringRes(R.string.relay_group_view_inline_desc), - selected = mode == RelayGroupViewMode.INLINE, - onSelect = { accountViewModel.account.settings.updateRelayGroupViewMode(RelayGroupViewMode.INLINE) }, - ) - ViewModeOption( - title = stringRes(R.string.relay_group_view_grouped), - description = stringRes(R.string.relay_group_view_grouped_desc), - selected = mode == RelayGroupViewMode.GROUPED, - onSelect = { accountViewModel.account.settings.updateRelayGroupViewMode(RelayGroupViewMode.GROUPED) }, - ) - - Text( - text = stringRes(R.string.concord_view_mode_title), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp), - ) - - ViewModeOption( - title = stringRes(R.string.concord_view_inline), - description = stringRes(R.string.concord_view_inline_desc), - selected = concordMode == ConcordViewMode.INLINE, - onSelect = { accountViewModel.account.settings.updateConcordViewMode(ConcordViewMode.INLINE) }, - ) - ViewModeOption( - title = stringRes(R.string.concord_view_grouped), - description = stringRes(R.string.concord_view_grouped_desc), - selected = concordMode == ConcordViewMode.GROUPED, - onSelect = { accountViewModel.account.settings.updateConcordViewMode(ConcordViewMode.GROUPED) }, + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), ) } } } +@Composable +private fun ChatTypeToggleRow( + ui: ChatFeedTypeUi, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + // Fade the accent badge out when off, so the palette itself signals what's active. + val badgeColor by animateColorAsState( + targetValue = if (checked) ui.accent else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.25f), + label = "badgeColor", + ) + + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { onCheckedChange(!checked) } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = + Modifier + .size(36.dp) + .clip(RoundedCornerShape(12.dp)) + .background(badgeColor.copy(alpha = 0.16f)), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = + Modifier + .size(14.dp) + .clip(CircleShape) + .background(badgeColor), + ) + } + + Column( + Modifier + .weight(1f) + .padding(start = 16.dp, end = 12.dp), + ) { + Text( + text = stringRes(ui.titleRes), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = stringRes(ui.descRes), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp), + ) + } + + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + colors = + SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = ui.accent, + checkedBorderColor = ui.accent, + ), + ) + } +} + +@Composable +private fun ViewModeCard(content: @Composable () -> Unit) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)), + ) { + Column(Modifier.padding(vertical = 4.dp)) { content() } + } +} + @Composable private fun ViewModeOption( title: String, @@ -132,7 +318,7 @@ private fun ViewModeOption( selected: Boolean, onSelect: () -> Unit, ) { - androidx.compose.foundation.layout.Row( + Row( modifier = Modifier .fillMaxWidth() @@ -141,7 +327,8 @@ private fun ViewModeOption( verticalAlignment = Alignment.CenterVertically, ) { RadioButton(selected = selected, onClick = onSelect) - Column(Modifier.padding(start = 12.dp)) { + Spacer(Modifier.width(12.dp)) + Column { Text(text = title, fontWeight = FontWeight.SemiBold) Text( text = description, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 348b08fecc..7b28acf2fe 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2246,7 +2246,25 @@ Relay groups Groups Messages - messages, chats, groups, nip-29, relay, inline, dm + messages, chats, groups, nip-29, relay, inline, dm, nip-04, nip-17, nip-28, concord, marmot, geohash, ephemeral, load + Conversations to load + Pick which kinds of chats appear in your Messages inbox. Turning one off hides it here and stops downloading it from your relays. + Private messages + End-to-end encrypted, gift-wrapped direct messages (NIP-17). + Legacy DMs + Older, less-private encrypted direct messages (NIP-04). + Public channels + Open, public chat rooms anyone can read and join (NIP-28). + Relay groups + Relay-managed group chats with rosters and moderation (NIP-29). + Encrypted groups + MLS end-to-end encrypted group chats (Marmot). + Concord communities + Encrypted communities with multiple channels (Concord). + Location chats + Public geohash-scoped chat for a place (Geolocation). + Ephemeral chats + Lightweight, relay-scoped rooms that don\'t persist history. Create a group This relay doesn\'t advertise support for NIP-29 relay groups. A group created here won\'t work — its name, members and messages won\'t be managed by the relay. Pick a relay that supports relay-based groups. Group name diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/chats/ChatFeedType.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/chats/ChatFeedType.kt new file mode 100644 index 0000000000..3e5a581a72 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/chats/ChatFeedType.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.chats + +/** + * The distinct conversation protocols that the Messages inbox weaves into a single list. Each is + * independently toggleable in Settings › Messages: turning one off both hides its rows from the + * inbox and drops its kinds/assemblers from the always-on downloading routes. + * + * [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. + */ +enum class ChatFeedType( + val code: String, +) { + /** NIP-17 private messages (gift-wrapped kind 14/15). */ + NIP17("nip17"), + + /** NIP-04 legacy encrypted direct messages (kind 4). */ + NIP04("nip04"), + + /** NIP-28 public chat channels (kind 40/42). */ + NIP28("nip28"), + + /** NIP-29 relay-based groups (kind 9 and friends). */ + NIP29("nip29"), + + /** Marmot / MLS end-to-end encrypted group chats (kind 445). */ + MARMOT("marmot"), + + /** Concord encrypted communities (gift-wrapped plane streams). */ + CONCORD("concord"), + + /** Geohash location channels (kind 20000). */ + GEOHASH("geohash"), + + /** Ephemeral relay-scoped chat rooms (kind 23333). */ + EPHEMERAL("ephemeral"), + ; + + companion object { + /** Every type, enabled by default so a fresh (or never-customized) account loads everything. */ + val ALL: Set = entries.toTypedArray().toSet() + + fun fromCode(code: String?): ChatFeedType? = entries.firstOrNull { it.code == code } + + /** Serializes a set of types 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() + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/chats/ChatFeedTypeTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/chats/ChatFeedTypeTest.kt new file mode 100644 index 0000000000..0a3f5f4459 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/chats/ChatFeedTypeTest.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.chats + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatFeedTypeTest { + // The Messages settings persist the DISABLED set as `ALL - enabled`; these tests pin the + // round-trip so a stored preferences string keeps meaning the same thing across releases. + + @Test + fun allContainsEveryType() { + assertEquals(ChatFeedType.entries.toSet(), ChatFeedType.ALL) + } + + @Test + fun emptyEncodeMeansAllOn() { + // Nothing disabled -> empty string -> decode back to nothing disabled -> everything enabled. + val encoded = ChatFeedType.encode(ChatFeedType.ALL - ChatFeedType.ALL) + assertEquals("", encoded) + assertEquals(ChatFeedType.ALL, ChatFeedType.ALL - ChatFeedType.decode(encoded)) + } + + @Test + fun nullDecodesToNothingDisabled() { + assertEquals(emptySet(), ChatFeedType.decode(null)) + assertEquals(ChatFeedType.ALL, ChatFeedType.ALL - ChatFeedType.decode(null)) + } + + @Test + fun roundTripsAPartiallyDisabledSet() { + val enabled = ChatFeedType.ALL - ChatFeedType.NIP04 - ChatFeedType.GEOHASH + val storedDisabled = ChatFeedType.encode(ChatFeedType.ALL - enabled) + val restored = ChatFeedType.ALL - ChatFeedType.decode(storedDisabled) + assertEquals(enabled, restored) + } + + @Test + fun stableCodesForEveryType() { + // Codes are the on-disk identity; renaming one silently disables it for upgraders. + assertEquals("nip17", ChatFeedType.NIP17.code) + assertEquals("nip04", ChatFeedType.NIP04.code) + assertEquals("nip28", ChatFeedType.NIP28.code) + assertEquals("nip29", ChatFeedType.NIP29.code) + assertEquals("marmot", ChatFeedType.MARMOT.code) + assertEquals("concord", ChatFeedType.CONCORD.code) + assertEquals("geohash", ChatFeedType.GEOHASH.code) + assertEquals("ephemeral", ChatFeedType.EPHEMERAL.code) + } + + @Test + fun unknownCodesAreDropped() { + assertNull(ChatFeedType.fromCode("does-not-exist")) + // A future/unknown code in stored prefs must not disable a known type. + val decoded = ChatFeedType.decode("nip04,future-kind") + assertEquals(setOf(ChatFeedType.NIP04), decoded) + assertTrue(ChatFeedType.NIP17 in (ChatFeedType.ALL - decoded)) + } +}