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:
Claude
2026-07-07 22:56:21 +00:00
parent 7eefdd2fb5
commit 21387569e9
8 changed files with 531 additions and 0 deletions
@@ -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()
@@ -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) }
@@ -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)
}
}
@@ -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),
)
}
}
@@ -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,
+14
View File
@@ -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>