From ddca55fae6cd6227ee0cf5d5881a671f8c74795d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 21:09:25 +0000 Subject: [PATCH] feat: add Marmot group info screen, member list, and leave group Based on MIP spec review, add missing UI screens: MarmotManager additions (commons): - memberPubkeys(): extract Nostr pubkeys from MLS ratchet tree leaf nodes via Credential.Basic identity - memberCount(): get group member count - groupEpoch(): expose current MLS epoch - GroupMemberInfo data class for leaf index + pubkey pairs Account & AccountViewModel: - leaveMarmotGroup(): publish SelfRemove proposal and clean up - marmotGroupMembers(): expose member list to UI MarmotGroupInfoScreen (new): - Group metadata header (name, ID, epoch, member count) - Member list with leaf indices, clickable to view profiles - "Leave Group" with confirmation dialog - "Add Member" access from group info Navigation: - Route.MarmotGroupInfo added and wired into nav graph - Chat screen title now clickable to navigate to group info https://claude.ai/code/session_0194SxKfAU61PY92eqP4cCXM --- .../vitorpamplona/amethyst/model/Account.kt | 15 + .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../amethyst/ui/navigation/routes/Routes.kt | 4 + .../ui/screen/loggedIn/AccountViewModel.kt | 7 + .../marmotGroup/MarmotGroupChatScreen.kt | 9 +- .../marmotGroup/MarmotGroupInfoScreen.kt | 297 ++++++++++++++++++ .../amethyst/commons/marmot/MarmotManager.kt | 41 +++ 7 files changed, 374 insertions(+), 1 deletion(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 57b7a4ed7..7600664a2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1804,6 +1804,21 @@ class Account( manager.createGroup(nostrGroupId) } + /** + * Leave a Marmot MLS group. + * Publishes the SelfRemove proposal and removes local state. + */ + suspend fun leaveMarmotGroup( + nostrGroupId: HexKey, + groupRelays: Set, + ) { + val manager = marmotManager ?: return + if (!isWriteable()) return + + val outbound = manager.leaveGroup(nostrGroupId) + client.publish(outbound.signedEvent, groupRelays) + } + suspend fun createStatus(newStatus: String) = sendMyPublicAndPrivateOutbox(UserStatusAction.create(newStatus, signer)) suspend fun publishCallSignaling(wrap: EphemeralGiftWrapEvent) { 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 dc2c0d4cf..d56828777 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 @@ -72,6 +72,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipMa import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.PostBookmarkListManagementScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.old.OldBookmarkListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.MarmotGroupChatScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.MarmotGroupInfoScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.MarmotGroupListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomByAuthorScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomScreen @@ -286,6 +287,7 @@ fun BuildNavigation( composableFromEnd { MarmotGroupListScreen(accountViewModel, nav) } composableFromEndArgs { MarmotGroupChatScreen(it.nostrGroupId, accountViewModel, nav) } + composableFromEndArgs { MarmotGroupInfoScreen(it.nostrGroupId, accountViewModel, nav) } composableFromEndArgs { PublicChatChannelScreen(it.id, it.draftId, it.replyTo, 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 3d5f7712a..a9d4a56b8 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 @@ -269,6 +269,10 @@ sealed class Route { val nostrGroupId: String, ) : Route() + @Serializable data class MarmotGroupInfo( + val nostrGroupId: String, + ) : Route() + @Serializable data class NewGroupDM( val message: String? = null, val attachment: String? = null, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 6ddb07484..791a9fa4a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1464,6 +1464,13 @@ class AccountViewModel( account.publishMarmotKeyPackage() } + suspend fun leaveMarmotGroup(nostrGroupId: String) { + val relays = account.outboxRelays.flow.value + account.leaveMarmotGroup(nostrGroupId, relays) + } + + fun marmotGroupMembers(nostrGroupId: String): List = account.marmotManager?.memberPubkeys(nostrGroupId) ?: emptyList() + override fun onCleared() { Log.d("AccountViewModel", "onCleared") callController?.cleanup() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatScreen.kt index 22606699a..2af3843cd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.padding @@ -41,6 +42,7 @@ import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -72,7 +74,12 @@ fun MarmotGroupChatScreen( } }, title = { - Column { + Column( + modifier = + Modifier.clickable { + nav.nav(Route.MarmotGroupInfo(nostrGroupId)) + }, + ) { Text(displayName ?: "Marmot Group") if (memberCount > 0) { Text( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt new file mode 100644 index 000000000..b5764c16c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt @@ -0,0 +1,297 @@ +/* + * 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.marmotGroup + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ExitToApp +import androidx.compose.material.icons.filled.GroupAdd +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +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.commons.marmot.GroupMemberInfo +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MarmotGroupInfoScreen( + nostrGroupId: HexKey, + accountViewModel: AccountViewModel, + nav: INav, +) { + val chatroom = + remember(nostrGroupId) { + accountViewModel.account.marmotGroupList.getOrCreateGroup(nostrGroupId) + } + val displayName by chatroom.displayName.collectAsStateWithLifecycle() + var members by remember { mutableStateOf(emptyList()) } + var showLeaveDialog by remember { mutableStateOf(false) } + var showAddMemberDialog by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val myPubkey = accountViewModel.account.signer.pubKey + + LaunchedEffect(nostrGroupId) { + members = accountViewModel.marmotGroupMembers(nostrGroupId) + chatroom.memberCount.value = members.size + } + + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + ) + } + }, + title = { Text("Group Info") }, + actions = { + IconButton(onClick = { showAddMemberDialog = true }) { + Icon( + imageVector = Icons.Default.GroupAdd, + contentDescription = "Add Member", + ) + } + }, + ) + }, + ) { padding -> + LazyColumn( + modifier = + Modifier + .fillMaxSize() + .padding(padding), + ) { + // Group header section + item { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Text( + text = displayName ?: "Group ${nostrGroupId.take(8)}...", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + Text( + text = "${members.size} members", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + Text( + text = "Group ID: ${nostrGroupId.take(16)}...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp), + ) + val epoch = accountViewModel.account.marmotManager?.groupEpoch(nostrGroupId) + if (epoch != null) { + Text( + text = "Epoch: $epoch", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp), + ) + } + } + HorizontalDivider() + } + + // Members section header + item { + Text( + text = "Members", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + } + + // Member list + items(members, key = { it.leafIndex }) { member -> + MemberRow( + member = member, + isMe = member.pubkey == myPubkey, + onClick = { + nav.nav(Route.Profile(member.pubkey)) + }, + ) + HorizontalDivider() + } + + // Leave group section + item { + HorizontalDivider(modifier = Modifier.padding(top = 8.dp)) + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { showLeaveDialog = true } + .padding(horizontal = 16.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ExitToApp, + contentDescription = "Leave Group", + tint = MaterialTheme.colorScheme.error, + ) + Text( + text = "Leave Group", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + } + + if (showLeaveDialog) { + LeaveGroupDialog( + groupName = displayName ?: "this group", + onConfirm = { + showLeaveDialog = false + scope.launch(Dispatchers.IO) { + accountViewModel.leaveMarmotGroup(nostrGroupId) + } + nav.nav(Route.MarmotGroupList) + }, + onDismiss = { showLeaveDialog = false }, + ) + } + + if (showAddMemberDialog) { + AddMemberDialog( + nostrGroupId = nostrGroupId, + accountViewModel = accountViewModel, + onDismiss = { showAddMemberDialog = false }, + ) + } +} + +@Composable +fun MemberRow( + member: GroupMemberInfo, + isMe: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + modifier = Modifier.size(36.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = + if (isMe) { + "${member.pubkey.take(16)}... (you)" + } else { + "${member.pubkey.take(16)}..." + }, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = if (isMe) FontWeight.Bold else FontWeight.Normal, + ) + Text( + text = "Leaf #${member.leafIndex}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +fun LeaveGroupDialog( + groupName: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Leave Group") }, + text = { + Text("Are you sure you want to leave \"$groupName\"? You will no longer receive messages from this group.") + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text("Leave", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + }, + ) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt index 9cc905b5d..746cb1386 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore +import com.vitorpamplona.quartz.marmot.mls.tree.Credential import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray @@ -254,4 +255,44 @@ class MarmotManager( * Get all active group IDs. */ fun activeGroupIds(): Set = groupManager.activeGroupIds() + + // --- Group Info --- + + /** + * Get the member count for a group. + */ + fun memberCount(nostrGroupId: HexKey): Int = groupManager.getGroup(nostrGroupId)?.memberCount ?: 0 + + /** + * Get the member list for a group. + * Returns leaf index and Nostr pubkey (extracted from BasicCredential) for each member. + */ + fun memberPubkeys(nostrGroupId: HexKey): List { + val group = groupManager.getGroup(nostrGroupId) ?: return emptyList() + return group.members().mapNotNull { (leafIndex, leafNode) -> + val pubkey = + when (val cred = leafNode.credential) { + is Credential.Basic -> cred.identity.toHexKey() + else -> null + } + if (pubkey != null) { + GroupMemberInfo(leafIndex = leafIndex, pubkey = pubkey) + } else { + null + } + } + } + + /** + * Get the current epoch for a group. + */ + fun groupEpoch(nostrGroupId: HexKey): Long? = groupManager.getGroup(nostrGroupId)?.epoch } + +/** + * Information about a member in a Marmot MLS group. + */ +data class GroupMemberInfo( + val leafIndex: Int, + val pubkey: HexKey, +)