Merge remote-tracking branch 'origin/main' into claude/fix-marmot-polling-R83Bs
This commit is contained in:
@@ -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<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) {
|
||||
|
||||
+16
@@ -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,
|
||||
|
||||
+159
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user