From 21387569e910caa62dc7a43ea2344c314d1ee865 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 22:56:21 +0000 Subject: [PATCH] feat: add NIP-29 group roster and admin moderation UI Adds a members roster screen and admin/moderator actions for relay-based groups: - Account: removeRelayGroupUser (kind 9001), putRelayGroupUser (kind 9000 promote/demote), editRelayGroupMetadata (kind 9002), all pinned to the group's host relay; plus AccountViewModel wrappers. - RelayGroupMembersScreen: relay-signed roster (39001 admins / 39002 members) with avatars, names and role badges. Moderators get a per-user menu to promote to admin/moderator, remove a role, or kick (with a confirm dialog). Menu items are gated so a moderator can't act on an admin and nobody acts on themselves. - EditRelayGroupDialog: admin-only edit of name/topic/visibility. - Wire Members, Edit channel (admin) and existing Invite/Leave into the chat top bar overflow; new Route.RelayGroupMembers registration. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../vitorpamplona/amethyst/model/Account.kt | 41 +++ .../amethyst/ui/navigation/AppNavigation.kt | 10 + .../amethyst/ui/navigation/routes/Routes.kt | 5 + .../ui/screen/loggedIn/AccountViewModel.kt | 19 ++ .../relayGroup/EditRelayGroupDialog.kt | 118 +++++++ .../relayGroup/RelayGroupMembersScreen.kt | 302 ++++++++++++++++++ .../relayGroup/RelayGroupTopBar.kt | 22 ++ amethyst/src/main/res/values/strings.xml | 14 + 8 files changed, 531 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/EditRelayGroupDialog.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt 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 75872c0b92..2ba61c755d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -207,6 +207,8 @@ import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateInviteEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent import com.vitorpamplona.quartz.nip32Labeling.LabelEvent @@ -1521,6 +1523,45 @@ class Account( signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } } + /** Kick [pubkey] out of the group with a kind 9001 remove-user event (moderator only). */ + suspend fun removeRelayGroupUser( + channel: RelayGroupChannel, + pubkey: HexKey, + ) { + val template = RemoveUserEvent.build(channel.groupId.id, listOf(pubkey)) + signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } + } + + /** + * Add [pubkey] to the group (or change its roles) with a kind 9000 put-user + * event (moderator only). Pass an empty [roles] list for a plain member. + */ + suspend fun putRelayGroupUser( + channel: RelayGroupChannel, + pubkey: HexKey, + roles: List, + ) { + val template = PutUserEvent.build(channel.groupId.id, listOf(pubkey to roles)) + signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } + } + + /** Edit the group's relay-signed metadata with a kind 9002 event (admin only). */ + suspend fun editRelayGroupMetadata( + channel: RelayGroupChannel, + name: String?, + about: String?, + isPrivate: Boolean, + isClosed: Boolean, + ) { + val status = + buildSet { + add(if (isPrivate) GroupMetadataEvent.GroupStatus.PRIVATE else GroupMetadataEvent.GroupStatus.PUBLIC) + add(if (isClosed) GroupMetadataEvent.GroupStatus.CLOSED else GroupMetadataEvent.GroupStatus.OPEN) + } + val template = EditMetadataEvent.build(channel.groupId.id, name = name, about = about, status = status) + signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } + } + suspend fun follow(community: AddressableNote) = sendMyPublicAndPrivateOutbox(communityList.follow(community)) suspend fun unfollow(community: AddressableNote) = sendMyPublicAndPrivateOutbox(communityList.unfollow(community)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index ee39529e56..4ad8e382ed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -107,6 +107,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28P import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveActivityChannelScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupChannelListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupChatScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupMembersScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.MessagesScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.share.ShareToDMScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessGameScreen @@ -576,6 +577,15 @@ fun BuildNavigation( ) } + composableFromEndArgs { + RelayGroupMembersScreen( + id = it.id, + relayUrl = it.relayUrl, + accountViewModel = accountViewModel, + nav = nav, + ) + } + composableFromBottomArgs { ChannelMetadataScreen(it.id, accountViewModel, nav) } composableFromBottomArgs { NewEphemeralChatScreen(accountViewModel, nav) } composableFromBottomArgs { NewGroupDMScreen(it.message, it.attachment, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 957963f2fc..003dd924cc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -648,6 +648,11 @@ sealed class Route { val relayUrl: String, ) : Route() + @Serializable data class RelayGroupMembers( + val id: String, + val relayUrl: String, + ) : Route() + @Serializable data class ChannelMetadataEdit( val id: String? = null, ) : Route() 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 7a4bce7efe..76b498def2 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 @@ -1452,6 +1452,25 @@ class AccountViewModel( code: String, ) = launchSigner { account.createRelayGroupInvite(channel, code) } + fun removeRelayGroupUser( + channel: RelayGroupChannel, + pubkey: HexKey, + ) = launchSigner { account.removeRelayGroupUser(channel, pubkey) } + + fun putRelayGroupUser( + channel: RelayGroupChannel, + pubkey: HexKey, + roles: List, + ) = launchSigner { account.putRelayGroupUser(channel, pubkey, roles) } + + fun editRelayGroupMetadata( + channel: RelayGroupChannel, + name: String?, + about: String?, + isPrivate: Boolean, + isClosed: Boolean, + ) = launchSigner { account.editRelayGroupMetadata(channel, name, about, isPrivate, isClosed) } + fun follow(users: List) = launchSigner { account.follow(users) } fun follow(user: User) = launchSigner { account.follow(user) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/EditRelayGroupDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/EditRelayGroupDialog.kt new file mode 100644 index 0000000000..f658198990 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/EditRelayGroupDialog.kt @@ -0,0 +1,118 @@ +/* + * 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.publicChannels.relayGroup + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +/** + * Edit a NIP-29 group's relay-signed metadata (kind 9002). Pre-filled from the + * channel's current metadata; the host relay only honours it from an admin. + */ +@Composable +fun EditRelayGroupDialog( + channel: RelayGroupChannel, + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, +) { + var name by remember(channel.groupId) { mutableStateOf(channel.event?.name() ?: "") } + var about by remember(channel.groupId) { mutableStateOf(channel.event?.about() ?: "") } + var isPrivate by remember(channel.groupId) { mutableStateOf(channel.isPrivate()) } + var isClosed by remember(channel.groupId) { mutableStateOf(channel.isClosed()) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(R.string.relay_group_edit_title)) }, + text = { + Column { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + singleLine = true, + label = { Text(stringRes(R.string.relay_group_edit_name)) }, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = about, + onValueChange = { about = it }, + label = { Text(stringRes(R.string.relay_group_edit_topic)) }, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + EditToggleRow(stringRes(R.string.relay_group_edit_private), isPrivate) { isPrivate = it } + EditToggleRow(stringRes(R.string.relay_group_edit_invite_only), isClosed) { isClosed = it } + } + }, + confirmButton = { + TextButton( + enabled = name.isNotBlank(), + onClick = { + accountViewModel.editRelayGroupMetadata( + channel = channel, + name = name.trim(), + about = about.trim().ifBlank { null }, + isPrivate = isPrivate, + isClosed = isClosed, + ) + onDismiss() + }, + ) { + Text(stringRes(R.string.relay_group_edit_confirm)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) } + }, + ) +} + +@Composable +private fun EditToggleRow( + label: String, + checked: Boolean, + onChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, modifier = Modifier.weight(1f)) + Switch(checked = checked, onCheckedChange = onChange) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt new file mode 100644 index 0000000000..aaa56543db --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt @@ -0,0 +1,302 @@ +/* + * 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.publicChannels.relayGroup + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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 com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId + +/** + * The roster of a NIP-29 group: everyone the relay lists as an admin (kind 39001) + * or member (kind 39002), each with their role badge. A moderator (admin or + * moderator role) additionally gets a per-user overflow menu to promote/demote + * (kind 9000 put-user) and kick (kind 9001 remove-user); the host relay enforces + * the actual permission, this UI just surfaces the actions. + */ +@Composable +fun RelayGroupMembersScreen( + id: HexKey, + relayUrl: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val relay = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) } ?: return + val channelId = remember(id, relay) { GroupId(id, relay) } + + LoadRelayGroupChannel(channelId, accountViewModel) { channel -> + RelayGroupMembers(channel, accountViewModel, nav) + } +} + +private class RosterEntry( + val pubkey: HexKey, + val membership: RelayGroupMembership, +) + +@Composable +private fun RelayGroupMembers( + baseChannel: RelayGroupChannel, + accountViewModel: AccountViewModel, + nav: INav, +) { + // Recompose when the relay-signed roster (39001/39002) changes. + val channelState by observeChannel(baseChannel, accountViewModel) + val channel = channelState?.channel as? RelayGroupChannel ?: baseChannel + + val myPubkey = accountViewModel.userProfile().pubkeyHex + val iCanModerate = channel.membershipOf(myPubkey).canModerate() + val iAmAdmin = channel.membershipOf(myPubkey) == RelayGroupMembership.ADMIN + + // Admins/moderators first, then plain members; alphabetical only inside a rank + // is overkill here — rely on relay order but push elevated roles to the top. + val roster = + remember(channel.admins, channel.members) { + val everyone = (channel.admins.map { it.pubKey } + channel.members).distinct() + everyone + .map { RosterEntry(it, channel.membershipOf(it)) } + .sortedBy { it.membership.rank() } + } + + Scaffold( + topBar = { + TopBarExtensibleWithBackButton( + title = { + Text( + text = stringRes(R.string.relay_group_members_title), + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + popBack = nav::popBack, + ) + }, + ) { padding -> + LazyColumn(modifier = Modifier.padding(padding)) { + items(roster, key = { it.pubkey }) { entry -> + RelayGroupMemberRow( + entry = entry, + channel = channel, + isSelf = entry.pubkey == myPubkey, + viewerCanModerate = iCanModerate, + viewerIsAdmin = iAmAdmin, + accountViewModel = accountViewModel, + nav = nav, + ) + HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) + } + } + } +} + +/** Sort key so admins float above moderators above plain members. */ +private fun RelayGroupMembership.rank(): Int = + when (this) { + RelayGroupMembership.ADMIN -> 0 + RelayGroupMembership.MODERATOR -> 1 + else -> 2 + } + +@Composable +private fun RelayGroupMemberRow( + entry: RosterEntry, + channel: RelayGroupChannel, + isSelf: Boolean, + viewerCanModerate: Boolean, + viewerIsAdmin: Boolean, + accountViewModel: AccountViewModel, + nav: INav, +) { + val user = remember(entry.pubkey) { accountViewModel.getUserIfExists(entry.pubkey) } + var menuOpen by remember { mutableStateOf(false) } + var confirmRemove by remember { mutableStateOf(false) } + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + UserPicture(entry.pubkey, Size35dp, accountViewModel = accountViewModel, nav = nav) + + Column(Modifier.weight(1f)) { + if (user != null) { + UsernameDisplay(user, accountViewModel = accountViewModel) + } else { + Text( + text = entry.pubkey.take(8), + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + MemberRoleBadge(entry.membership) + + // Moderators can act on others (not themselves); the relay is the final + // authority, but hide obviously-useless menus (a moderator can't touch an admin). + val canActOnTarget = + viewerCanModerate && + !isSelf && + (viewerIsAdmin || entry.membership != RelayGroupMembership.ADMIN) + + if (canActOnTarget) { + IconButton(onClick = { menuOpen = true }) { + Icon( + symbol = MaterialSymbols.MoreVert, + contentDescription = stringRes(R.string.more_options), + modifier = Modifier.size(20.dp), + ) + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + if (viewerIsAdmin && entry.membership != RelayGroupMembership.ADMIN) { + DropdownMenuItem( + text = { Text(stringRes(R.string.relay_group_make_admin)) }, + onClick = { + menuOpen = false + accountViewModel.putRelayGroupUser(channel, entry.pubkey, listOf(RelayGroupMembership.ROLE_ADMIN)) + }, + ) + } + if (entry.membership != RelayGroupMembership.MODERATOR && entry.membership != RelayGroupMembership.ADMIN) { + DropdownMenuItem( + text = { Text(stringRes(R.string.relay_group_make_moderator)) }, + onClick = { + menuOpen = false + accountViewModel.putRelayGroupUser(channel, entry.pubkey, listOf(RelayGroupMembership.ROLE_MODERATOR)) + }, + ) + } + if (entry.membership == RelayGroupMembership.MODERATOR || entry.membership == RelayGroupMembership.ADMIN) { + DropdownMenuItem( + text = { Text(stringRes(R.string.relay_group_demote_member)) }, + onClick = { + menuOpen = false + accountViewModel.putRelayGroupUser(channel, entry.pubkey, emptyList()) + }, + ) + } + DropdownMenuItem( + text = { + Text( + text = stringRes(R.string.relay_group_remove_user), + color = MaterialTheme.colorScheme.error, + ) + }, + onClick = { + menuOpen = false + confirmRemove = true + }, + ) + } + } + } + + if (confirmRemove) { + val displayName = user?.toBestDisplayName() ?: entry.pubkey.take(8) + AlertDialog( + onDismissRequest = { confirmRemove = false }, + title = { Text(stringRes(R.string.relay_group_remove_user)) }, + text = { Text(stringRes(R.string.relay_group_remove_user_confirm, displayName)) }, + confirmButton = { + TextButton(onClick = { + confirmRemove = false + accountViewModel.removeRelayGroupUser(channel, entry.pubkey) + }) { + Text(stringRes(R.string.relay_group_remove_user), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { confirmRemove = false }) { + Text(stringRes(R.string.cancel)) + } + }, + ) + } +} + +/** A small colored pill for an elevated role; plain members get nothing. */ +@Composable +private fun MemberRoleBadge(membership: RelayGroupMembership) { + val label = + when (membership) { + RelayGroupMembership.ADMIN -> stringRes(R.string.relay_group_role_admin) + RelayGroupMembership.MODERATOR -> stringRes(R.string.relay_group_role_moderator) + else -> return + } + + Surface( + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.primaryContainer, + ) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt index d0d1fd455d..928574d21d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt @@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChann import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -81,6 +82,7 @@ fun RelayGroupTopBar( var menuOpen by remember { mutableStateOf(false) } var showInvite by remember { mutableStateOf(false) } var showJoinCode by remember { mutableStateOf(false) } + var showEdit by remember { mutableStateOf(false) } TopBarExtensibleWithBackButton( title = { @@ -167,6 +169,22 @@ fun RelayGroupTopBar( ) } DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + DropdownMenuItem( + text = { Text(stringRes(R.string.relay_group_menu_members)) }, + onClick = { + menuOpen = false + nav.nav(Route.RelayGroupMembers(channel.groupId.id, channel.groupId.relayUrl.url)) + }, + ) + if (displayMembership == RelayGroupMembership.ADMIN) { + DropdownMenuItem( + text = { Text(stringRes(R.string.relay_group_menu_edit)) }, + onClick = { + menuOpen = false + showEdit = true + }, + ) + } if (displayMembership.canModerate()) { DropdownMenuItem( text = { Text(stringRes(R.string.relay_group_invite_title)) }, @@ -194,6 +212,10 @@ fun RelayGroupTopBar( InviteRelayGroupDialog(channel, accountViewModel) { showInvite = false } } + if (showEdit) { + EditRelayGroupDialog(channel, accountViewModel) { showEdit = false } + } + if (showJoinCode) { JoinRelayGroupDialog( channel = channel, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index f92ef7c9e3..da102bd701 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1950,6 +1950,20 @@ Admin Moderator Member + Members + Make admin + Make moderator + Remove role + Remove from channel + Remove %1$s from this channel? They will lose access until re-added or re-invited. + Edit channel + Channel name + Topic (optional) + Private (members-only read) + Invite only + Save + Members + Edit channel %1$d member %1$d members