mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
@@ -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
|
||||
// ---
|
||||
|
||||
+52
@@ -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() }
|
||||
}
|
||||
+4
@@ -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)
|
||||
|
||||
+4
-1
@@ -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(
|
||||
|
||||
+2
@@ -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.
|
||||
|
||||
+12
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+22
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+114
-74
@@ -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)
|
||||
|
||||
+25
-7
@@ -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)
|
||||
|
||||
+2
@@ -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())
|
||||
|
||||
+4
-1
@@ -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(
|
||||
|
||||
+10
-3
@@ -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)
|
||||
|
||||
+10
-3
@@ -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)
|
||||
|
||||
+11
-4
@@ -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)
|
||||
|
||||
+234
-47
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
+76
@@ -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()
|
||||
}
|
||||
}
|
||||
+80
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user