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
This commit is contained in:
Claude
2026-04-05 21:09:25 +00:00
parent 7ed036bbaa
commit ddca55fae6
7 changed files with 374 additions and 1 deletions
@@ -1804,6 +1804,21 @@ class Account(
manager.createGroup(nostrGroupId) manager.createGroup(nostrGroupId)
} }
/**
* Leave a Marmot MLS group.
* Publishes the SelfRemove proposal and removes local state.
*/
suspend fun leaveMarmotGroup(
nostrGroupId: HexKey,
groupRelays: Set<NormalizedRelayUrl>,
) {
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 createStatus(newStatus: String) = sendMyPublicAndPrivateOutbox(UserStatusAction.create(newStatus, signer))
suspend fun publishCallSignaling(wrap: EphemeralGiftWrapEvent) { suspend fun publishCallSignaling(wrap: EphemeralGiftWrapEvent) {
@@ -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.membershipManagement.PostBookmarkListManagementScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.old.OldBookmarkListScreen 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.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.marmotGroup.MarmotGroupListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomByAuthorScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomByAuthorScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomScreen
@@ -286,6 +287,7 @@ fun BuildNavigation(
composableFromEnd<Route.MarmotGroupList> { MarmotGroupListScreen(accountViewModel, nav) } composableFromEnd<Route.MarmotGroupList> { MarmotGroupListScreen(accountViewModel, nav) }
composableFromEndArgs<Route.MarmotGroupChat> { MarmotGroupChatScreen(it.nostrGroupId, accountViewModel, nav) } composableFromEndArgs<Route.MarmotGroupChat> { MarmotGroupChatScreen(it.nostrGroupId, accountViewModel, nav) }
composableFromEndArgs<Route.MarmotGroupInfo> { MarmotGroupInfoScreen(it.nostrGroupId, accountViewModel, nav) }
composableFromEndArgs<Route.PublicChatChannel> { composableFromEndArgs<Route.PublicChatChannel> {
PublicChatChannelScreen(it.id, it.draftId, it.replyTo, accountViewModel, nav) PublicChatChannelScreen(it.id, it.draftId, it.replyTo, accountViewModel, nav)
@@ -269,6 +269,10 @@ sealed class Route {
val nostrGroupId: String, val nostrGroupId: String,
) : Route() ) : Route()
@Serializable data class MarmotGroupInfo(
val nostrGroupId: String,
) : Route()
@Serializable data class NewGroupDM( @Serializable data class NewGroupDM(
val message: String? = null, val message: String? = null,
val attachment: String? = null, val attachment: String? = null,
@@ -1464,6 +1464,13 @@ class AccountViewModel(
account.publishMarmotKeyPackage() account.publishMarmotKeyPackage()
} }
suspend fun leaveMarmotGroup(nostrGroupId: String) {
val relays = account.outboxRelays.flow.value
account.leaveMarmotGroup(nostrGroupId, relays)
}
fun marmotGroupMembers(nostrGroupId: String): List<com.vitorpamplona.amethyst.commons.marmot.GroupMemberInfo> = account.marmotManager?.memberPubkeys(nostrGroupId) ?: emptyList()
override fun onCleared() { override fun onCleared() {
Log.d("AccountViewModel", "onCleared") Log.d("AccountViewModel", "onCleared")
callController?.cleanup() callController?.cleanup()
@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup 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.Column
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -41,6 +42,7 @@ import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.navs.INav 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.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -72,7 +74,12 @@ fun MarmotGroupChatScreen(
} }
}, },
title = { title = {
Column { Column(
modifier =
Modifier.clickable {
nav.nav(Route.MarmotGroupInfo(nostrGroupId))
},
) {
Text(displayName ?: "Marmot Group") Text(displayName ?: "Marmot Group")
if (memberCount > 0) { if (memberCount > 0) {
Text( Text(
@@ -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<GroupMemberInfo>()) }
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")
}
},
)
}
@@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore 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.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
@@ -254,4 +255,44 @@ class MarmotManager(
* Get all active group IDs. * Get all active group IDs.
*/ */
fun activeGroupIds(): Set<HexKey> = groupManager.activeGroupIds() fun activeGroupIds(): Set<HexKey> = 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<GroupMemberInfo> {
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,
)