mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 16:57:39 +00:00
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
This commit is contained in:
@@ -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<String>,
|
||||
) {
|
||||
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))
|
||||
|
||||
@@ -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<Route.RelayGroupMembers> {
|
||||
RelayGroupMembersScreen(
|
||||
id = it.id,
|
||||
relayUrl = it.relayUrl,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
composableFromBottomArgs<Route.ChannelMetadataEdit> { ChannelMetadataScreen(it.id, accountViewModel, nav) }
|
||||
composableFromBottomArgs<Route.NewEphemeralChat> { NewEphemeralChatScreen(accountViewModel, nav) }
|
||||
composableFromBottomArgs<Route.NewGroupDM> { NewGroupDMScreen(it.message, it.attachment, accountViewModel, nav) }
|
||||
|
||||
@@ -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()
|
||||
|
||||
+19
@@ -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<String>,
|
||||
) = 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<User>) = launchSigner { account.follow(users) }
|
||||
|
||||
fun follow(user: User) = launchSigner { account.follow(user) }
|
||||
|
||||
+118
@@ -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)
|
||||
}
|
||||
}
|
||||
+302
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
+22
@@ -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,
|
||||
|
||||
@@ -1950,6 +1950,20 @@
|
||||
<string name="relay_group_role_admin">Admin</string>
|
||||
<string name="relay_group_role_moderator">Moderator</string>
|
||||
<string name="relay_group_role_member">Member</string>
|
||||
<string name="relay_group_members_title">Members</string>
|
||||
<string name="relay_group_make_admin">Make admin</string>
|
||||
<string name="relay_group_make_moderator">Make moderator</string>
|
||||
<string name="relay_group_demote_member">Remove role</string>
|
||||
<string name="relay_group_remove_user">Remove from channel</string>
|
||||
<string name="relay_group_remove_user_confirm">Remove %1$s from this channel? They will lose access until re-added or re-invited.</string>
|
||||
<string name="relay_group_edit_title">Edit channel</string>
|
||||
<string name="relay_group_edit_name">Channel name</string>
|
||||
<string name="relay_group_edit_topic">Topic (optional)</string>
|
||||
<string name="relay_group_edit_private">Private (members-only read)</string>
|
||||
<string name="relay_group_edit_invite_only">Invite only</string>
|
||||
<string name="relay_group_edit_confirm">Save</string>
|
||||
<string name="relay_group_menu_members">Members</string>
|
||||
<string name="relay_group_menu_edit">Edit channel</string>
|
||||
<plurals name="relay_group_member_count">
|
||||
<item quantity="one">%1$d member</item>
|
||||
<item quantity="other">%1$d members</item>
|
||||
|
||||
Reference in New Issue
Block a user