From e6e3120cd23cb4ca7b8984b502cb03bb09d1a414 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Apr 2026 00:28:54 +0000 Subject: [PATCH] feat(marmot): let admins grant/revoke admin privileges from group info UI Adds a star toggle next to each member in MarmotGroupInfoScreen that admins can use to promote a non-admin to admin or demote a fellow admin. Both paths show a confirmation dialog and publish a GroupContextExtensions commit that rewrites admin_pubkeys. The toggle is hidden for non-admin viewers and for the signer's own row (self demotion must go through the leave flow). The demote button is also hidden when the member is the only remaining admin so we never hit the MIP-03 admin-depletion guard in MlsGroup at commit time. Account.grantMarmotGroupAdmin / revokeMarmotGroupAdmin reuse updateMarmotGroupMetadata so the new admin list ships alongside the signer's outbox relays, keeping the canonical relay set consistent with other metadata commits. --- .../vitorpamplona/amethyst/model/Account.kt | 57 +++++++ .../ui/screen/loggedIn/AccountViewModel.kt | 16 ++ .../marmotGroup/MarmotGroupInfoScreen.kt | 159 ++++++++++++++++++ 3 files changed, 232 insertions(+) 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 831057941..65d8bae05 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -2343,6 +2343,63 @@ class Account( client.publish(outbound.signedEvent, groupRelays) } + /** + * Grant admin privileges to [targetPubKey] in a Marmot MLS group by + * appending them to `admin_pubkeys` via a GroupContextExtensions commit. + * + * No-op if the group has no prior metadata (shouldn't happen outside the + * first bootstrap commit) or the target is already an admin. Callers + * must be an admin themselves — the MLS engine enforces this via the + * MIP-03 authorization gate in `enforceAuthorizedProposalSet`. + */ + suspend fun grantMarmotGroupAdmin( + nostrGroupId: HexKey, + targetPubKey: HexKey, + groupRelays: Set, + ) { + val manager = marmotManager ?: return + if (!isWriteable()) return + + val metadata = manager.groupMetadata(nostrGroupId) ?: return + if (metadata.adminPubkeys.contains(targetPubKey)) return + + val outboxRelayStrings = outboxRelays.flow.value.map { it.url } + val updated = + metadata + .copy(adminPubkeys = metadata.adminPubkeys + targetPubKey) + .withMergedRelays(outboxRelayStrings) + updateMarmotGroupMetadata(nostrGroupId, updated, groupRelays) + } + + /** + * Revoke admin privileges from [targetPubKey]. Rejects any change that + * would leave the group with zero admins — MIP-03's admin-depletion guard + * in [com.vitorpamplona.quartz.marmot.mls.group.MlsGroup] would otherwise + * throw at commit time. + */ + suspend fun revokeMarmotGroupAdmin( + nostrGroupId: HexKey, + targetPubKey: HexKey, + groupRelays: Set, + ) { + val manager = marmotManager ?: return + if (!isWriteable()) return + + val metadata = manager.groupMetadata(nostrGroupId) ?: return + if (!metadata.adminPubkeys.contains(targetPubKey)) return + val remaining = metadata.adminPubkeys.filter { it != targetPubKey } + check(remaining.isNotEmpty()) { + "Cannot revoke the last admin from a Marmot group (MIP-03)" + } + + val outboxRelayStrings = outboxRelays.flow.value.map { it.url } + val updated = + metadata + .copy(adminPubkeys = remaining) + .withMergedRelays(outboxRelayStrings) + updateMarmotGroupMetadata(nostrGroupId, updated, 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/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index b9e9220e6..371b73ea7 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 @@ -1551,6 +1551,22 @@ class AccountViewModel( account.removeMarmotGroupMember(nostrGroupId, targetLeafIndex, relays) } + suspend fun grantMarmotGroupAdmin( + nostrGroupId: String, + targetPubKey: String, + ) { + val relays = marmotGroupRelays(nostrGroupId) + account.grantMarmotGroupAdmin(nostrGroupId, targetPubKey, relays) + } + + suspend fun revokeMarmotGroupAdmin( + nostrGroupId: String, + targetPubKey: String, + ) { + val relays = marmotGroupRelays(nostrGroupId) + account.revokeMarmotGroupAdmin(nostrGroupId, targetPubKey, relays) + } + suspend fun updateMarmotGroupMetadata( nostrGroupId: String, name: String, 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 index 517ec7336..b83de760a 100644 --- 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 @@ -46,6 +46,8 @@ import androidx.compose.material.icons.automirrored.filled.ExitToApp import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.PersonRemove +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.outlined.StarBorder import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider @@ -115,6 +117,8 @@ fun MarmotGroupInfoScreen( var showLeaveDialog by remember { mutableStateOf(false) } var isLeaving by remember { mutableStateOf(false) } var memberToRemove by remember { mutableStateOf(null) } + var memberToPromote by remember { mutableStateOf(null) } + var memberToDemote by remember { mutableStateOf(null) } var addSearchInput by remember { mutableStateOf("") } var addStatus by remember { mutableStateOf(null) } var isAddError by remember { mutableStateOf(false) } @@ -224,17 +228,30 @@ fun MarmotGroupInfoScreen( ) } + val isMeAdmin = myPubkey in adminPubkeys items(members, key = { it.leafIndex }) { member -> val isMe = member.pubkey == myPubkey val isAdmin = member.pubkey in adminPubkeys + // Only admins can mutate other members' roles. Self-promote + // is a no-op; self-demote must go through the leave flow so + // we hide the toggle for the current user. + val canToggleAdmin = isMeAdmin && !isMe + // Guard against removing the last admin (MIP-03 depletion + // guard would otherwise reject the commit). + val canDemote = canToggleAdmin && isAdmin && adminPubkeys.size > 1 + val canPromote = canToggleAdmin && !isAdmin MemberRow( member = member, isMe = isMe, isAdmin = isAdmin, canRemove = !isMe, + canPromote = canPromote, + canDemote = canDemote, accountViewModel = accountViewModel, nav = nav, onRemoveClick = { memberToRemove = member }, + onPromoteClick = { memberToPromote = member }, + onDemoteClick = { memberToDemote = member }, ) HorizontalDivider() } @@ -347,6 +364,66 @@ fun MarmotGroupInfoScreen( onDismiss = { memberToRemove = null }, ) } + + memberToPromote?.let { member -> + ConfirmGrantAdminDialog( + memberPubkey = member.pubkey, + accountViewModel = accountViewModel, + onConfirm = { + memberToPromote = null + scope.launch(Dispatchers.IO) { + try { + accountViewModel.grantMarmotGroupAdmin(nostrGroupId, member.pubkey) + launch(Dispatchers.Main) { + Toast + .makeText(context, "Admin privileges granted", Toast.LENGTH_SHORT) + .show() + } + } catch (e: Exception) { + launch(Dispatchers.Main) { + Toast + .makeText( + context, + "Failed to grant admin: ${e.message}", + Toast.LENGTH_LONG, + ).show() + } + } + } + }, + onDismiss = { memberToPromote = null }, + ) + } + + memberToDemote?.let { member -> + ConfirmRevokeAdminDialog( + memberPubkey = member.pubkey, + accountViewModel = accountViewModel, + onConfirm = { + memberToDemote = null + scope.launch(Dispatchers.IO) { + try { + accountViewModel.revokeMarmotGroupAdmin(nostrGroupId, member.pubkey) + launch(Dispatchers.Main) { + Toast + .makeText(context, "Admin privileges revoked", Toast.LENGTH_SHORT) + .show() + } + } catch (e: Exception) { + launch(Dispatchers.Main) { + Toast + .makeText( + context, + "Failed to revoke admin: ${e.message}", + Toast.LENGTH_LONG, + ).show() + } + } + } + }, + onDismiss = { memberToDemote = null }, + ) + } } @Composable @@ -424,9 +501,13 @@ fun MemberRow( isMe: Boolean, isAdmin: Boolean, canRemove: Boolean, + canPromote: Boolean, + canDemote: Boolean, accountViewModel: AccountViewModel, nav: INav, onRemoveClick: () -> Unit, + onPromoteClick: () -> Unit, + onDemoteClick: () -> Unit, ) { Row( modifier = @@ -460,6 +541,23 @@ fun MemberRow( ) } } + if (canPromote) { + IconButton(onClick = onPromoteClick) { + Icon( + imageVector = Icons.Outlined.StarBorder, + contentDescription = "Grant admin privileges", + tint = MaterialTheme.colorScheme.primary, + ) + } + } else if (canDemote) { + IconButton(onClick = onDemoteClick) { + Icon( + imageVector = Icons.Default.Star, + contentDescription = "Revoke admin privileges", + tint = MaterialTheme.colorScheme.primary, + ) + } + } if (canRemove) { IconButton(onClick = onRemoveClick) { Icon( @@ -526,6 +624,67 @@ private fun ConfirmRemoveMemberDialog( ) } +@Composable +private fun ConfirmGrantAdminDialog( + memberPubkey: HexKey, + accountViewModel: AccountViewModel, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Grant Admin Privileges") }, + text = { + LoadUser(baseUserHex = memberPubkey, accountViewModel = accountViewModel) { user -> + val name = user?.toBestDisplayName() ?: "${memberPubkey.take(16)}..." + Text( + "Make \"$name\" an admin of this group? Admins can add or remove members, " + + "change group info, and grant admin privileges to other members.", + ) + } + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text("Grant") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + }, + ) +} + +@Composable +private fun ConfirmRevokeAdminDialog( + memberPubkey: HexKey, + accountViewModel: AccountViewModel, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Revoke Admin Privileges") }, + text = { + LoadUser(baseUserHex = memberPubkey, accountViewModel = accountViewModel) { user -> + val name = user?.toBestDisplayName() ?: "${memberPubkey.take(16)}..." + Text("Revoke admin privileges from \"$name\"? They will remain a member of the group.") + } + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text("Revoke", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + }, + ) +} + /** Window (in seconds) within which a relay is considered actively carrying this group's traffic. */ private const val RELAY_ACTIVITY_WINDOW_SECS = 7L * 24 * 60 * 60