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 e91446a4e..aaf93751a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -2379,6 +2379,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 aa14e675e..c47d64d38 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 @@ -1560,6 +1560,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