feat(messages): per-type load toggles for the Messages inbox

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RbzL9cZk1h88kE7ErgjG5
This commit is contained in:
Claude
2026-07-17 16:22:20 +00:00
parent 7b5ddd3637
commit ae6e1e9c2d
20 changed files with 729 additions and 141 deletions
@@ -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),
@@ -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<RelayAuthPolicy> = MutableStateFlow(RelayAuthPolicy.CUSTOM),
val relayGroupViewMode: MutableStateFlow<RelayGroupViewMode> = MutableStateFlow(RelayGroupViewMode.DEFAULT),
val concordViewMode: MutableStateFlow<ConcordViewMode> = 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<Set<ChatFeedType>> = MutableStateFlow(ChatFeedType.ALL),
// The per-situation toggles applied under RelayAuthPolicy.CUSTOM.
val relayAuthTrustMyRelaysAndVenues: MutableStateFlow<Boolean> = MutableStateFlow(true),
val relayAuthTrustReadFollows: MutableStateFlow<Boolean> = 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
// ---
@@ -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() }
}
@@ -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<RelayBasedFilter> {
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<RelayBasedFilter>()
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)
@@ -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<RelayBasedFilter> {
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(
@@ -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<RelayBasedFilter> {
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.
@@ -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.
@@ -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<RelayBasedFilter>? {
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<Account, Job>()
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()
}
}
@@ -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<RelayBasedFilter>? {
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<Account, Job>()
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()
}
}
@@ -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<Note>() {
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<Note> {
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<Note>()
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<Note>()
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<Note>,
account: Account,
): MutableMap<String, Note> {
if (!isEnabled(ChatFeedType.GEOHASH)) return mutableMapOf()
val joined = account.geohashList.flow.value
val newRelevant = mutableMapOf<String, Note>()
newItems.forEach { newNote ->
@@ -385,6 +420,7 @@ class ChatroomListKnownFeedFilter(
newItems: Set<Note>,
account: Account,
): MutableMap<String, Note> {
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<String, Note>()
@@ -406,6 +442,7 @@ class ChatroomListKnownFeedFilter(
newItems: Set<Note>,
account: Account,
): MutableMap<String, Note> {
if (!isEnabled(ChatFeedType.NIP28)) return mutableMapOf()
val followingChannels = account.publicChatList.flowSet.value
val newRelevantPublicMessages = mutableMapOf<String, Note>()
newItems
@@ -429,6 +466,7 @@ class ChatroomListKnownFeedFilter(
newItems: Set<Note>,
account: Account,
): MutableMap<RoomId, Note> {
if (!isEnabled(ChatFeedType.EPHEMERAL)) return mutableMapOf()
val followingEphemeralChats = account.ephemeralChatList.liveEphemeralChatList.value
val newRelevantEphemeralChats = mutableMapOf<RoomId, Note>()
newItems
@@ -493,6 +531,7 @@ class ChatroomListKnownFeedFilter(
newItems: Set<Note>,
account: Account,
): MutableMap<String, Note> {
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<ChatroomKey, Note>()
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)
@@ -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<Note>() {
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<Note> {
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<ChatroomKey, Note>()
newItems.forEach { newNote ->
if (!isDmEnabled(newNote)) return@forEach
val noteEvent = newNote.event
if (noteEvent is ChatroomKeyable) {
val roomKey = noteEvent.chatroomKey(me.pubkeyHex)
@@ -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<RelayBasedFilter>? {
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())
@@ -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<RelayBasedFilter>? =
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(
@@ -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<RelayBasedFilter> =
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)
@@ -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<RelayBasedFilter> =
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)
@@ -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<RelayBasedFilter> =
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)
@@ -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,
+19 -1
View File
@@ -2246,7 +2246,25 @@
<string name="relay_group_server_label">Relay groups</string>
<string name="relay_groups_button">Groups</string>
<string name="messages_settings">Messages</string>
<string name="messages_settings_search_keywords" translatable="false">messages, chats, groups, nip-29, relay, inline, dm</string>
<string name="messages_settings_search_keywords" translatable="false">messages, chats, groups, nip-29, relay, inline, dm, nip-04, nip-17, nip-28, concord, marmot, geohash, ephemeral, load</string>
<string name="messages_load_types_title">Conversations to load</string>
<string name="messages_load_types_desc">Pick which kinds of chats appear in your Messages inbox. Turning one off hides it here and stops downloading it from your relays.</string>
<string name="chat_type_nip17_title">Private messages</string>
<string name="chat_type_nip17_desc">End-to-end encrypted, gift-wrapped direct messages (NIP-17).</string>
<string name="chat_type_nip04_title">Legacy DMs</string>
<string name="chat_type_nip04_desc">Older, less-private encrypted direct messages (NIP-04).</string>
<string name="chat_type_nip28_title">Public channels</string>
<string name="chat_type_nip28_desc">Open, public chat rooms anyone can read and join (NIP-28).</string>
<string name="chat_type_nip29_title">Relay groups</string>
<string name="chat_type_nip29_desc">Relay-managed group chats with rosters and moderation (NIP-29).</string>
<string name="chat_type_marmot_title">Encrypted groups</string>
<string name="chat_type_marmot_desc">MLS end-to-end encrypted group chats (Marmot).</string>
<string name="chat_type_concord_title">Concord communities</string>
<string name="chat_type_concord_desc">Encrypted communities with multiple channels (Concord).</string>
<string name="chat_type_geohash_title">Location chats</string>
<string name="chat_type_geohash_desc">Public geohash-scoped chat for a place (Geolocation).</string>
<string name="chat_type_ephemeral_title">Ephemeral chats</string>
<string name="chat_type_ephemeral_desc">Lightweight, relay-scoped rooms that don\'t persist history.</string>
<string name="relay_group_create_title">Create a group</string>
<string name="relay_group_relay_no_nip29">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.</string>
<string name="relay_group_create_name">Group name</string>
@@ -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<ChatFeedType> = 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<ChatFeedType>): String = types.joinToString(",") { it.code }
/** Parses a comma-joined [code] list back to a set, dropping any unknown codes. */
fun decode(joined: String?): Set<ChatFeedType> =
joined
?.split(",")
?.mapNotNull { fromCode(it.trim()) }
?.toSet()
?: emptySet()
}
}
@@ -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>(), 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))
}
}