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.
This commit is contained in:
Claude
2026-04-23 00:28:54 +00:00
parent 95f189338a
commit e6e3120cd2
3 changed files with 232 additions and 0 deletions
@@ -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<NormalizedRelayUrl>,
) {
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<NormalizedRelayUrl>,
) {
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) {
@@ -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,
@@ -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<GroupMemberInfo?>(null) }
var memberToPromote by remember { mutableStateOf<GroupMemberInfo?>(null) }
var memberToDemote by remember { mutableStateOf<GroupMemberInfo?>(null) }
var addSearchInput by remember { mutableStateOf("") }
var addStatus by remember { mutableStateOf<String?>(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