From 6292ceb8036c33b2dfb59dda1f88fa88da8630d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 23:17:19 +0000 Subject: [PATCH] feat: grouped-by-relay Messages view + a Messages settings screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the NIP-29 inline/by-relay toggle off the top of the Messages tab (where it sat clipped behind the tab row) into a new Settings › Messages screen, and make "by relay" actually mean something in the feed. - New MessagesSettingsScreen (Route + SettingsCatalog entry + nav) with a radio choice: show each joined group inline, or collapse each relay's groups into one row. Removes the pinned SegmentedButton + above-pager server list from both the single- and two-pane layouts; deletes the now-dead RelayGroupViewModeToggle and RelayGroupServerList composables. - GROUPED mode now weaves one row PER HOST RELAY (never duplicated) into the Messages feed, positioned at that relay's newest group message so it interleaves with DMs by recency and shows the last message. Backed by a synthetic RelayGroupServerRoomNote whose createdAt mirrors the newest message; ChatroomListKnownFeedFilter builds/updates it in feed(), applyFilter and updateListWith, keyed by relay url. A view-mode change forces a feed rebuild. - Revert the moot kind-7 render guard: the data-layer content filter already keeps reactions out of the row, so the ChatroomEntry fallback stays simple (the shared Event.isGroupChatContent helper remains, used by the feed filter). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../loggedIn/AccountFeedContentStates.kt | 11 ++ .../relayGroup/RelayGroupBrowseScreen.kt | 2 +- .../relayGroup/RelayGroupServerList.kt | 61 +------- .../relayGroup/RelayGroupViewModeToggle.kt | 77 ---------- .../chats/rooms/ChatroomHeaderCompose.kt | 75 ++++++++-- .../rooms/dal/ChatroomListKnownFeedFilter.kt | 131 +++++++++++++----- .../rooms/dal/RelayGroupServerRoomNote.kt | 47 +++++++ .../rooms/singlepane/MessagesSinglePane.kt | 30 ++-- .../chats/rooms/twopane/MessagesTwoPane.kt | 32 ++--- .../settings/MessagesSettingsScreen.kt | 131 ++++++++++++++++++ .../settings/SettingsCatalogBuilder.kt | 1 + amethyst/src/main/res/values/strings.xml | 6 + 14 files changed, 375 insertions(+), 233 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupViewModeToggle.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/RelayGroupServerRoomNote.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MessagesSettingsScreen.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 75e89017b0..85f86e9c1e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -221,6 +221,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.CallSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ComposeSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.HiddenWordsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.HomeTabsSettingsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.MessagesSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.MutedThreadsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NIP47SetupScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NamecoinSettingsScreen @@ -473,6 +474,7 @@ fun BuildNavigation( composableFromEnd { ComposeSettingsScreen(accountViewModel, nav) } composableFromEnd { UserSettingsScreen(accountViewModel, nav) } composableFromEnd { ReactionsSettingsScreen(accountViewModel, nav) } + composableFromEnd { MessagesSettingsScreen(accountViewModel, nav) } composableFromEnd { AudioVisualizerSettingsScreen(accountViewModel, nav) } composableFromEnd { BottomBarSettingsScreen(accountViewModel, nav) } composableFromEnd { HomeTabsSettingsScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index b6301cffc7..7962a3d383 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -404,6 +404,8 @@ sealed class Route { @Serializable object ReactionsSettings : Route() + @Serializable object MessagesSettings : Route() + @Serializable object AudioVisualizerSettings : Route() @Serializable object BottomBarSettings : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index 86c9897bfd..a3a3ec542a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -179,6 +179,17 @@ class AccountFeedContentStates( } } + // Flipping the NIP-29 view mode (inline groups vs one row per relay) changes what the + // Messages feed emits for joined groups, but no event flows through LocalCache — force a + // full rebuild so the list switches shape immediately. + scope.launch(Dispatchers.IO) { + account.settings.relayGroupViewMode + .drop(1) + .collect { + dmKnown.invalidateData() + } + } + // Pinning/unpinning a room only changes sort order, not membership, so no // chat event flows through LocalCache. Force a rebuild to re-sort. This // also fires when pins arrive via the synced AppSpecificData event. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupBrowseScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupBrowseScreen.kt index fee8bfbe3e..fbbb7cbe18 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupBrowseScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupBrowseScreen.kt @@ -75,7 +75,7 @@ private val POPULAR_RELAYS = * Discovery entry point for NIP-29 groups: paste any relay URL to browse the * channels it hosts (opening [Route.RelayGroupServer]), pick from relays you're * already on, or try a popular public relay. This is the "find new groups" flow - * that the joined-only [RelayGroupServerList] can't offer. + * that the joined-only Messages list can't offer. */ @Composable fun RelayGroupBrowseScreen( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupServerList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupServerList.kt index 7270680b71..e3068e3e06 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupServerList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupServerList.kt @@ -27,82 +27,23 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme 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.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo -import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl -/** - * The "grouped" Messages view: one row per host relay of the user's joined NIP-29 - * groups, each with the relay's NIP-11 avatar and name. Tapping a relay opens its - * channel list. Rendered above the DM feed only in - * [com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode.GROUPED]. - */ -@Composable -fun RelayGroupServerList( - accountViewModel: AccountViewModel, - nav: INav, -) { - val servers by accountViewModel.account.relayGroupList.liveRelayGroupServers - .collectAsStateWithLifecycle() - - Column(Modifier.fillMaxWidth()) { - servers.sorted().forEach { server -> - RelayGroupServerRow(server, accountViewModel) { nav.nav(Route.RelayGroupServer(server)) } - HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) - } - - // Always offer a discovery entry, so a user with no groups yet still has - // somewhere to start — the Relay Groups tab is the discovery feed. - FindChannelsRow { nav.nav(Route.RelayGroups) } - HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) - } -} - -@Composable -private fun FindChannelsRow(onClick: () -> Unit) { - Row( - modifier = - Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Icon( - symbol = MaterialSymbols.Add, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(24.dp), - ) - Text( - text = stringRes(R.string.relay_group_browse_title), - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.primary, - ) - } -} - -/** One relay row: NIP-11 avatar + name, used by the grouped list and the browse screen. */ +/** One relay row: NIP-11 avatar + name, used by the browse screen. */ @Composable fun RelayGroupServerRow( relayUrl: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupViewModeToggle.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupViewModeToggle.kt deleted file mode 100644 index b45d706b66..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupViewModeToggle.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * 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.ui.screen.loggedIn.chats.publicChannels.relayGroup - -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.SegmentedButton -import androidx.compose.material3.SegmentedButtonDefaults -import androidx.compose.material3.SingleChoiceSegmentedButtonRow -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.stringRes - -/** - * A compact toggle to switch how the user's NIP-29 groups appear in the Messages - * tab — [RelayGroupViewMode.INLINE] (channels as rows with a relay chip) vs - * [RelayGroupViewMode.GROUPED] (a row per relay, drill in for its channels). - * Only shown once the user has joined at least one group. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun RelayGroupViewModeToggle(accountViewModel: AccountViewModel) { - val groups by accountViewModel.account.relayGroupList.liveRelayGroupList - .collectAsStateWithLifecycle() - if (groups.isEmpty()) return - - val mode by accountViewModel.account.settings.relayGroupViewMode - .collectAsStateWithLifecycle() - - SingleChoiceSegmentedButtonRow( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 6.dp), - ) { - SegmentedButton( - selected = mode == RelayGroupViewMode.INLINE, - onClick = { accountViewModel.account.settings.updateRelayGroupViewMode(RelayGroupViewMode.INLINE) }, - shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2), - ) { - Text(stringRes(R.string.relay_group_view_inline)) - } - SegmentedButton( - selected = mode == RelayGroupViewMode.GROUPED, - onClick = { accountViewModel.account.settings.updateRelayGroupViewMode(RelayGroupViewMode.GROUPED) }, - shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2), - ) { - Text(stringRes(R.string.relay_group_view_grouped)) - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index 9d255ad439..e54a7e3732 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -82,6 +82,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.marmotGroupLastReadRoute import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.LoadEphemeralChatChannel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.RelayGroupServerRoomNote import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.AccountPictureModifier import com.vitorpamplona.amethyst.ui.theme.Height4dpModifier @@ -100,7 +101,6 @@ import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import com.vitorpamplona.quartz.nip29RelayGroups.groupId -import com.vitorpamplona.quartz.nip29RelayGroups.isGroupChatContent import com.vitorpamplona.quartz.nip29RelayGroups.isGroupScoped import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent @@ -110,14 +110,18 @@ fun ChatroomHeaderCompose( accountViewModel: AccountViewModel, nav: INav, ) { - // A joined Marmot or NIP-29 relay group with no messages yet is represented by an event-less - // placeholder note carrying its channel as a gatherer. Render it as the group row (via the - // gatherer) instead of waiting for an event that never arrives, which would blank the row. - val isEmptyGroupPlaceholder = - baseNote.event == null && - baseNote.inGatherers?.any { it is MarmotGroupChatroom || it is RelayGroupChannel } == true + // Some Messages rows have no event: a per-relay grouped row (RelayGroupServerRoomNote), or a + // joined Marmot/NIP-29 group with no messages yet (an event-less placeholder carrying its channel + // as a gatherer). Render these directly instead of waiting for an event that never arrives, which + // would blank the row. + val rendersWithoutEvent = + baseNote is RelayGroupServerRoomNote || + ( + baseNote.event == null && + baseNote.inGatherers?.any { it is MarmotGroupChatroom || it is RelayGroupChannel } == true + ) - if (baseNote.event != null || isEmptyGroupPlaceholder) { + if (baseNote.event != null || rendersWithoutEvent) { ChatroomComposeChannelOrUser(baseNote, accountViewModel, nav) } else { val hasEvent by observeNoteHasEvent(baseNote, accountViewModel) @@ -152,6 +156,11 @@ private fun ChatroomEntry( accountViewModel: AccountViewModel, nav: INav, ) { + if (lastMessage is RelayGroupServerRoomNote) { + RelayGroupServerRoomCompose(lastMessage, accountViewModel, nav) + return + } + val marmotGroup = lastMessage.inGatherers?.firstNotNullOfOrNull { it as? MarmotGroupChatroom } if (marmotGroup != null) { MarmotGroupRoomCompose(lastMessage, marmotGroup, accountViewModel, nav) @@ -172,12 +181,12 @@ private fun ChatroomEntry( val gid = groupScopedEvent.groupId() val hostRelay = lastMessage.relays.firstOrNull() if (gid != null && hostRelay != null) { - val channel = LocalCache.getOrCreateRelayGroupChannel(GroupId(gid, hostRelay)) - // Only actual chat content is the room's "last message". A group-scoped reaction/ - // deletion/label lingering in the list must not render as the row (its content/time - // aren't a message) — fall back to the channel placeholder so the group still shows. - val rowNote = if (groupScopedEvent.isGroupChatContent()) lastMessage else channel.placeholderNote() - RelayGroupRoomCompose(rowNote, channel, accountViewModel, nav) + RelayGroupRoomCompose( + lastMessage, + LocalCache.getOrCreateRelayGroupChannel(GroupId(gid, hostRelay)), + accountViewModel, + nav, + ) return } } @@ -397,6 +406,44 @@ private fun RelayGroupRoomCompose( ) } +@Composable +private fun RelayGroupServerRoomCompose( + row: RelayGroupServerRoomNote, + accountViewModel: AccountViewModel, + nav: INav, +) { + val relay = row.relay + val relayInfo by loadRelayInfo(relay) + val host = relay.displayUrl() + val name = relayInfo.name?.takeIf { it.isNotBlank() } ?: host + + val author = row.newestMessage?.author + val noteEvent = row.newestMessage?.event + val lastContent = + if (author != null && noteEvent != null) { + val authorName by observeUserName(author, accountViewModel) + "$authorName: ${noteEvent.content.take(200)}" + } else { + stringRes(R.string.relay_group_no_messages_yet) + } + + ChannelName( + channelIdHex = relay.url, + channelPicture = relayInfo.icon, + channelTitle = { modifier -> ChannelTitleWithLabelInfo(name, R.string.relay_group_server_label, modifier) }, + channelLastTime = row.newestMessage?.createdAt(), + channelLastContent = lastContent, + hasNewMessages = false, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = + accountViewModel.settings.autoPlayVideosFlow + .collectAsStateWithLifecycle() + .value, + onClick = { nav.nav(Route.RelayGroupServer(relay.url)) }, + ) +} + /** A small tappable chip naming the relay a channel is hosted on. */ @Composable private fun RelayNameChip( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt index 0dfeb9a12d..613bb9917f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode import com.vitorpamplona.amethyst.commons.util.replace import com.vitorpamplona.amethyst.model.Account @@ -30,6 +31,7 @@ import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent 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.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable @@ -95,26 +97,35 @@ class ChatroomListKnownFeedFilter( } } - // NIP-29 relay groups the user joined (kind 10009) appear inline in the - // Messages list (the "inline" view mode), each row tagged with its host - // relay. In the "grouped" mode they're reached via relay rows instead, so - // they're excluded from this flat feed. + // NIP-29 relay groups the user joined (kind 10009). In INLINE view mode each group is its + // own row tagged with its host relay; in GROUPED mode all the groups on one relay collapse + // 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 = - if (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.notes - .filter { _, it -> account.isAcceptable(it) && it.event?.isGroupChatContent() == true } - .sortedByDefaultFeedOrder() - .firstOrNull() - ?: channel.placeholderNote() - } - } else { - emptyList() + 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) + } } return sort((privateMessages + publicChannels + ephemeralChats + marmotGroups + relayGroups).toSet()) @@ -196,8 +207,7 @@ class ChatroomListKnownFeedFilter( newRelevantRelayGroups.forEach { newNotePair -> var hasUpdated = false oldList.forEach { oldNote -> - val oldGroupId = oldNote.event?.takeIf { it.isGroupScoped() }?.groupId() - if (newNotePair.key == oldGroupId) { + if (newNotePair.key == oldNote.relayGroupRowKey()) { hasUpdated = true if ((newNotePair.value.createdAt() ?: 0L) > (oldNote.createdAt() ?: 0L)) { myNewList = myNewList.replace(oldNote, newNotePair.value) @@ -286,29 +296,76 @@ class ChatroomListKnownFeedFilter( return newRelevantEphemeralChats } - /** Latest message per joined NIP-29 group (inline view mode), keyed by group id. */ + /** The newest actual chat message loaded in this group's channel, or null if none yet. */ + private fun RelayGroupChannel.newestChatNote(account: Account): Note? = + notes + .filter { _, it -> account.isAcceptable(it) && it.event?.isGroupChatContent() == true } + .sortedByDefaultFeedOrder() + .firstOrNull() + + /** + * The row a relay-group note belongs to in the feed, so [updateListWith] can find and replace it: + * a per-relay [RelayGroupServerRoomNote] (GROUPED), a joined group's chat note keyed by group id + * (INLINE), or an empty-group placeholder resolved back to its group id via its channel gatherer. + */ + private fun Note.relayGroupRowKey(): String? = + when (this) { + is RelayGroupServerRoomNote -> relay.url + else -> + event?.takeIf { it.isGroupScoped() }?.groupId() + ?: inGatherers?.firstNotNullOfOrNull { (it as? RelayGroupChannel)?.groupId?.id } + } + + /** + * Latest relay-group rows from the new items, keyed the same way as [relayGroupRowKey]: by group + * id in INLINE mode (one row per group) and by host relay url in GROUPED mode (one row per relay). + * Only actual group content counts — a reaction (kind 7)/deletion/label carries the group's `h` + * tag too, so [Event.isGroupChatContent] gates them out of ever becoming a room's "last message". + */ private fun filterRelevantRelayGroupMessages( newItems: Set, account: Account, ): MutableMap { - if (account.settings.relayGroupViewMode.value != RelayGroupViewMode.INLINE) return mutableMapOf() - val joinedGroupIds = - account.relayGroupList.liveRelayGroupList.value - .mapTo(HashSet()) { it.groupId } - val result = mutableMapOf() - newItems.forEach { newNote -> - // Only actual group content represents a room. A reaction (kind 7), deletion, label, - // etc. carries the group's `h` tag too, so isGroupScoped() alone would let a reaction to - // my message become the group's "last message" — a wrong, unrenderable row. - val gid = newNote.event?.takeIf { it.isGroupChatContent() }?.groupId() - if (gid != null && gid in joinedGroupIds && account.isAcceptable(newNote)) { - val lastNote = result[gid] - if (lastNote == null || (newNote.createdAt() ?: 0L) > (lastNote.createdAt() ?: 0L)) { - result[gid] = newNote + val joined = account.relayGroupList.liveRelayGroupList.value + if (joined.isEmpty()) return mutableMapOf() + + return when (account.settings.relayGroupViewMode.value) { + RelayGroupViewMode.INLINE -> { + val joinedGroupIds = joined.mapTo(HashSet()) { it.groupId } + val result = mutableMapOf() + newItems.forEach { newNote -> + val gid = newNote.event?.takeIf { it.isGroupChatContent() }?.groupId() + if (gid != null && gid in joinedGroupIds && account.isAcceptable(newNote)) { + val lastNote = result[gid] + if (lastNote == null || (newNote.createdAt() ?: 0L) > (lastNote.createdAt() ?: 0L)) { + result[gid] = newNote + } + } } + result + } + + RelayGroupViewMode.GROUPED -> { + val groupToRelay = HashMap() + joined.forEach { tag -> + RelayUrlNormalizer.normalizeOrNull(tag.relayUrl)?.let { groupToRelay[tag.groupId] = it } + } + // Newest new message per host relay, collapsed into one per-relay row. + val newestPerRelay = HashMap() + newItems.forEach { newNote -> + val gid = newNote.event?.takeIf { it.isGroupChatContent() }?.groupId() ?: return@forEach + val relay = groupToRelay[gid] ?: return@forEach + if (!account.isAcceptable(newNote)) return@forEach + val lastNote = newestPerRelay[relay] + if (lastNote == null || (newNote.createdAt() ?: 0L) > (lastNote.createdAt() ?: 0L)) { + newestPerRelay[relay] = newNote + } + } + val result = mutableMapOf() + newestPerRelay.forEach { (relay, note) -> result[relay.url] = RelayGroupServerRoomNote(relay, note) } + result } } - return result } private fun filterRelevantPrivateMessages( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/RelayGroupServerRoomNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/RelayGroupServerRoomNote.kt new file mode 100644 index 0000000000..f0b8e8a26e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/RelayGroupServerRoomNote.kt @@ -0,0 +1,47 @@ +/* + * 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.ui.screen.loggedIn.chats.rooms.dal + +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * A synthetic Messages-list row that collapses ALL of a user's joined NIP-29 groups on one host + * [relay] into a single entry — the "grouped by relay" view mode + * ([com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode.GROUPED]). + * + * It is not a real event: [event] stays null and [createdAt] mirrors [newestMessage] (the newest + * chat across that relay's groups) so the row interleaves with DMs and other chats by recency and + * lands in "the newest message spot". Tapping it opens the relay's group list. Exactly one instance + * exists per relay — never duplicated — keyed by a stable [idHex] so feed diffing and the + * LazyColumn treat it as the same row across refreshes. + */ +class RelayGroupServerRoomNote( + val relay: NormalizedRelayUrl, + val newestMessage: Note?, +) : Note(idFor(relay)) { + override fun createdAt(): Long? = newestMessage?.createdAt() + + companion object { + fun idFor(relay: NormalizedRelayUrl): HexKey = "relaygroupserver-${relay.url}" + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/singlepane/MessagesSinglePane.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/singlepane/MessagesSinglePane.kt index 32e37e43a4..1f0bc4aef4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/singlepane/MessagesSinglePane.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/singlepane/MessagesSinglePane.kt @@ -21,15 +21,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.singlepane import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel @@ -41,8 +40,6 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.AmethystClickableIcon import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupServerList -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupViewModeToggle import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupMyJoinedGroupsSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChannelFabColumn import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssemblerSubscription @@ -104,25 +101,18 @@ fun MessagesSinglePane( }, accountViewModel = accountViewModel, ) { - val viewMode by accountViewModel.account.settings.relayGroupViewMode - .collectAsStateWithLifecycle() - // Keep joined groups' rosters live while the messages list is on top, so // membership/pending state is accurate inline without opening each chat. RelayGroupMyJoinedGroupsSubscription(accountViewModel.dataSources().relayGroupMyJoinedGroups, accountViewModel) - Column { - RelayGroupViewModeToggle(accountViewModel) - if (viewMode == RelayGroupViewMode.GROUPED) { - RelayGroupServerList(accountViewModel, nav) - } - MessagesPager( - pagerState, - tabs, - accountViewModel, - nav, - modifier = Modifier.weight(1f), - ) - } + // The inline-vs-grouped NIP-29 display preference lives in Settings › Messages; joined groups + // (or per-relay rows in grouped mode) are woven directly into the feed below. + MessagesPager( + pagerState, + tabs, + accountViewModel, + nav, + modifier = Modifier.fillMaxSize(), + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt index b8a05e2537..7f0a3c60a1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt @@ -21,26 +21,21 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.twopane import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.accompanist.adaptive.FoldAwareConfiguration import com.google.accompanist.adaptive.HorizontalTwoPaneStrategy import com.google.accompanist.adaptive.TwoPane import com.google.accompanist.adaptive.calculateDisplayFeatures -import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar @@ -51,8 +46,6 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomView import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.PublicChatChannelView -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupServerList -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupViewModeToggle import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupMyJoinedGroupsSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChannelFabColumn import com.vitorpamplona.amethyst.ui.theme.Size20dp @@ -100,25 +93,16 @@ fun MessagesTwoPane( TwoPane( first = { Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.BottomEnd) { - val viewMode by accountViewModel.account.settings.relayGroupViewMode - .collectAsStateWithLifecycle() - RelayGroupMyJoinedGroupsSubscription(accountViewModel.dataSources().relayGroupMyJoinedGroups, accountViewModel) - Column(Modifier.fillMaxSize()) { - RelayGroupViewModeToggle(accountViewModel) - if (viewMode == RelayGroupViewMode.GROUPED) { - RelayGroupServerList(accountViewModel, nav) - } - Box(Modifier.weight(1f).fillMaxWidth()) { - ChatroomList( - knownFeedContentState, - newFeedContentState, - accountViewModel, - twoPaneNav, - ) - } - } + // The inline-vs-grouped NIP-29 preference lives in Settings › Messages; joined + // groups (or per-relay rows in grouped mode) are woven into the list itself. + ChatroomList( + knownFeedContentState, + newFeedContentState, + accountViewModel, + twoPaneNav, + ) Box(Modifier.padding(Size20dp), contentAlignment = Alignment.Center) { ChannelFabColumn(nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MessagesSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MessagesSettingsScreen.kt new file mode 100644 index 0000000000..edf13e06fe --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MessagesSettingsScreen.kt @@ -0,0 +1,131 @@ +/* + * 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.ui.screen.loggedIn.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +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.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.nip29RelayGroups.RelayGroupViewMode +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow + +@Composable +@Preview(device = "spec:width=2100px,height=2340px,dpi=440") +fun MessagesSettingsScreenPreview() { + ThemeComparisonRow { + MessagesSettingsScreen(mockAccountViewModel(), EmptyNav()) + } +} + +/** + * 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. + */ +@Composable +fun MessagesSettingsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val mode by accountViewModel.account.settings.relayGroupViewMode + .collectAsStateWithLifecycle() + + Scaffold( + topBar = { + TopBarWithBackButton(stringRes(R.string.messages_settings), nav) + }, + ) { padding -> + Column( + Modifier + .padding(padding) + .verticalScroll(rememberScrollState()), + ) { + 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), + ) + + RelayGroupViewModeOption( + 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) }, + ) + RelayGroupViewModeOption( + 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) }, + ) + } + } +} + +@Composable +private fun RelayGroupViewModeOption( + title: String, + description: String, + selected: Boolean, + onSelect: () -> Unit, +) { + androidx.compose.foundation.layout.Row( + modifier = + Modifier + .fillMaxWidth() + .selectable(selected = selected, onClick = onSelect) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = selected, onClick = onSelect) + Column(Modifier.padding(start = 12.dp)) { + Text(text = title, fontWeight = FontWeight.SemiBold) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt index b6aab37179..3ace415fd7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt @@ -97,6 +97,7 @@ fun buildSettingsCatalog( symEntry(R.string.notification_settings, MaterialSymbols.Notifications, R.string.notification_settings_search_keywords, Route.NotificationSettings), symEntry(R.string.home_tabs_settings, MaterialSymbols.Home, R.string.home_tabs_search_keywords, Route.HomeTabsSettings), symEntry(R.string.reactions_settings, MaterialSymbols.ThumbUp, R.string.reactions_settings_search_keywords, Route.ReactionsSettings), + symEntry(R.string.messages_settings, MaterialSymbols.Forum, R.string.messages_settings_search_keywords, Route.MessagesSettings), symEntry(R.string.bottom_bar_settings, MaterialSymbols.Dashboard, R.string.bottom_bar_search_keywords, Route.BottomBarSettings), symEntry(R.string.profile_ui_settings, MaterialSymbols.AccountCircle, R.string.profile_ui_search_keywords, Route.ProfileUiSettings), symEntry(R.string.calendar_reminder_settings_title, MaterialSymbols.CalendarMonth, R.string.calendar_reminder_search_keywords, Route.CalendarReminderSettings), diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index c34220af72..f70da7d59f 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1932,6 +1932,12 @@ Relay Groups Inline By relay + Show each group as its own conversation, mixed in with your chats. + Collapse each relay\'s groups into a single row, placed at its newest message. + NIP-29 group display + Relay groups + Messages + messages, chats, groups, nip-29, relay, inline, dm Create a group Group name Topic (optional)