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)