From 5f677280d8c48b4c42a019e6d5dbd20b451e0cf1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 22:12:11 +0000 Subject: [PATCH] feat(nip29): join / leave / create / invite actions Wire the NIP-29 membership + admin flows into the UI, all published only to the group's host relay: - Account: joinRelayGroup (9021 + follow), leaveRelayGroup (9022 + unfollow), createRelayGroup (9007 + 9002, returns the new GroupId), createRelayGroupInvite (9009); AccountViewModel wrappers. - RelayGroupTopBar: a Join button when not a member; once joined, an overflow with Invite + Leave. Membership is read from the kind-10009 list. - CreateRelayGroupDialog: name/topic/private/invite-only; mints a random group id, publishes, and navigates into the new channel. Reachable from a FAB on the relay channel-list screen. - InviteRelayGroupDialog: mints a kind-9009 invite code on open and offers copy. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../vitorpamplona/amethyst/model/Account.kt | 66 +++++++++ .../ui/screen/loggedIn/AccountViewModel.kt | 21 +++ .../relayGroup/CreateRelayGroupDialog.kt | 130 ++++++++++++++++++ .../relayGroup/InviteRelayGroupDialog.kt | 88 ++++++++++++ .../relayGroup/RelayGroupChannelListScreen.kt | 14 ++ .../relayGroup/RelayGroupTopBar.kt | 53 +++++++ amethyst/src/main/res/values/strings.xml | 10 ++ 7 files changed, 382 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/CreateRelayGroupDialog.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/InviteRelayGroupDialog.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 ccd4765b60..75872c0b92 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -202,6 +202,13 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay import com.vitorpamplona.quartz.nip19Bech32.entities.NSec +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +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.request.JoinRequestEvent +import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent import com.vitorpamplona.quartz.nip32Labeling.LabelEvent import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip37Drafts.DraftEventCache @@ -1455,6 +1462,65 @@ class Account( suspend fun unfollow(channel: RelayGroupChannel) = sendMyPublicAndPrivateOutbox(relayGroupList.unfollow(channel)) + // ── NIP-29 relay-group actions ─────────────────────────────────────────── + // All group commands are published ONLY to the group's host relay, where + // relay29 authorizes them. The relay is the source of truth; the kind-10009 + // list is our own cross-device bookkeeping of what we joined. + + /** Send a kind 9021 join request to the group's host relay and remember it. */ + suspend fun joinRelayGroup( + channel: RelayGroupChannel, + code: String? = null, + ) { + val template = JoinRequestEvent.build(channel.groupId.id, inviteCode = code) + signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } + follow(channel) + } + + /** Send a kind 9022 leave request to the host relay and drop it from our list. */ + suspend fun leaveRelayGroup(channel: RelayGroupChannel) { + val template = LeaveRequestEvent.build(channel.groupId.id) + signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } + unfollow(channel) + } + + /** + * Create a new group on [relay]: kind 9007 (create-group) then kind 9002 + * (edit-metadata) with the chosen name/visibility, then remember it. Returns + * the new group's id. + */ + suspend fun createRelayGroup( + relay: NormalizedRelayUrl, + groupId: String, + name: String, + about: String? = null, + isPrivate: Boolean = false, + isClosed: Boolean = false, + ): GroupId { + signAndSendPrivatelyOrBroadcast(CreateGroupEvent.build(groupId)) { listOf(relay) } + + val status = + buildSet { + add(if (isPrivate) GroupMetadataEvent.GroupStatus.PRIVATE else GroupMetadataEvent.GroupStatus.PUBLIC) + add(if (isClosed) GroupMetadataEvent.GroupStatus.CLOSED else GroupMetadataEvent.GroupStatus.OPEN) + } + val edit = EditMetadataEvent.build(groupId, name = name, about = about, status = status) + signAndSendPrivatelyOrBroadcast(edit) { listOf(relay) } + + val id = GroupId(groupId, relay) + follow(LocalCache.getOrCreateRelayGroupChannel(id)) + return id + } + + /** Mint a kind 9009 invite code for the group (admin/moderator only). */ + suspend fun createRelayGroupInvite( + channel: RelayGroupChannel, + code: String, + ) { + val template = CreateInviteEvent.build(channel.groupId.id, code) + 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/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 77df693cf3..7a4bce7efe 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 @@ -1431,6 +1431,27 @@ class AccountViewModel( fun unfollow(channel: EphemeralChatChannel) = launchSigner { account.unfollow(channel) } + fun joinRelayGroup( + channel: RelayGroupChannel, + code: String? = null, + ) = launchSigner { account.joinRelayGroup(channel, code) } + + fun leaveRelayGroup(channel: RelayGroupChannel) = launchSigner { account.leaveRelayGroup(channel) } + + fun createRelayGroup( + relay: NormalizedRelayUrl, + groupId: String, + name: String, + about: String?, + isPrivate: Boolean, + isClosed: Boolean, + ) = launchSigner { account.createRelayGroup(relay, groupId, name, about, isPrivate, isClosed) } + + fun createRelayGroupInvite( + channel: RelayGroupChannel, + code: String, + ) = launchSigner { account.createRelayGroupInvite(channel, code) } + 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/CreateRelayGroupDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/CreateRelayGroupDialog.kt new file mode 100644 index 0000000000..71a6038c08 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/CreateRelayGroupDialog.kt @@ -0,0 +1,130 @@ +/* + * 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.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.RandomInstance + +/** Random NIP-29 group id: 8 secure bytes, hex-encoded (matches Armada). */ +private fun randomGroupId(): String = RandomInstance.bytes(8).toHexKey() + +/** + * Create a NIP-29 group on [relay]: publishes kind 9007 + 9002 with the chosen + * name/visibility, then navigates into the new group. The user becomes its first + * admin. + */ +@Composable +fun CreateRelayGroupDialog( + relay: NormalizedRelayUrl, + accountViewModel: AccountViewModel, + nav: INav, + onDismiss: () -> Unit, +) { + var name by remember { mutableStateOf("") } + var about by remember { mutableStateOf("") } + var isPrivate by remember { mutableStateOf(false) } + var isClosed by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(R.string.relay_group_create_title)) }, + text = { + Column { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + singleLine = true, + label = { Text(stringRes(R.string.relay_group_create_name)) }, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = about, + onValueChange = { about = it }, + label = { Text(stringRes(R.string.relay_group_create_topic)) }, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + ToggleRow(stringRes(R.string.relay_group_create_private), isPrivate) { isPrivate = it } + ToggleRow(stringRes(R.string.relay_group_create_invite_only), isClosed) { isClosed = it } + } + }, + confirmButton = { + TextButton( + enabled = name.isNotBlank(), + onClick = { + val groupId = randomGroupId() + accountViewModel.createRelayGroup( + relay = relay, + groupId = groupId, + name = name.trim(), + about = about.trim().ifBlank { null }, + isPrivate = isPrivate, + isClosed = isClosed, + ) + onDismiss() + nav.nav(Route.RelayGroup(groupId, relay.url)) + }, + ) { + Text(stringRes(R.string.relay_group_create_confirm)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) } + }, + ) +} + +@Composable +private fun ToggleRow( + 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/InviteRelayGroupDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/InviteRelayGroupDialog.kt new file mode 100644 index 0000000000..49ef3021b1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/InviteRelayGroupDialog.kt @@ -0,0 +1,88 @@ +/* + * 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.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +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 +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.RandomInstance + +/** + * Mint a NIP-29 invite (kind 9009) for the group as soon as the dialog opens and + * show the code to copy and share. Requires admin/moderator rights on the relay. + */ +@Composable +fun InviteRelayGroupDialog( + channel: RelayGroupChannel, + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, +) { + val clipboard = LocalClipboardManager.current + val code by remember { mutableStateOf(RandomInstance.bytes(6).toHexKey()) } + + // Publishing the invite is a one-shot side effect when the dialog opens. + LaunchedEffect(code) { + accountViewModel.createRelayGroupInvite(channel, code) + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(R.string.relay_group_invite_title)) }, + text = { + Column { + Text(stringRes(R.string.relay_group_invite_description)) + Text( + text = code, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary, + ) + } + }, + confirmButton = { + TextButton(onClick = { + clipboard.setText(AnnotatedString(code)) + onDismiss() + }) { + Text(stringRes(R.string.copy)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt index 825b75d997..b75db2434f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt @@ -26,14 +26,17 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -62,6 +65,8 @@ fun RelayGroupChannelListScreen( ) { val relay = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) } ?: return + var showCreate by remember { mutableStateOf(false) } + RelayGroupDirectorySubscription(relay, accountViewModel.dataSources().relayGroupDirectory, accountViewModel) // The channel set grows as directory events arrive; poll the cache while the @@ -90,6 +95,11 @@ fun RelayGroupChannelListScreen( popBack = nav::popBack, ) }, + floatingActionButton = { + FloatingActionButton(onClick = { showCreate = true }) { + Text("+", style = MaterialTheme.typography.headlineMedium) + } + }, ) { padding -> LazyColumn(modifier = Modifier.padding(padding)) { items(channels, key = { it.groupId.id }) { channel -> @@ -98,6 +108,10 @@ fun RelayGroupChannelListScreen( } } } + + if (showCreate) { + CreateRelayGroupDialog(relay, accountViewModel, nav) { showCreate = false } + } } @Composable 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 0c6302f02b..10eb845c57 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 @@ -21,17 +21,28 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup import androidx.compose.foundation.layout.Column +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text 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.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel 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.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer @Composable fun RelayGroupTopBar( @@ -43,6 +54,17 @@ fun RelayGroupTopBar( val channelState by observeChannel(baseChannel, accountViewModel) val channel = channelState?.channel as? RelayGroupChannel ?: baseChannel + val joinedGroups by accountViewModel.account.relayGroupList.liveRelayGroupList + .collectAsStateWithLifecycle() + val isJoined = + joinedGroups.any { + it.groupId == channel.groupId.id && + RelayUrlNormalizer.normalizeOrNull(it.relayUrl) == channel.groupId.relayUrl + } + + var menuOpen by remember { mutableStateOf(false) } + var showInvite by remember { mutableStateOf(false) } + TopBarExtensibleWithBackButton( title = { Column { @@ -61,6 +83,37 @@ fun RelayGroupTopBar( ) } }, + actions = { + if (!isJoined) { + FilledTonalButton(onClick = { accountViewModel.joinRelayGroup(channel) }) { + Text(stringRes(R.string.join)) + } + } else { + IconButton(onClick = { menuOpen = true }) { + Text("⋮", style = MaterialTheme.typography.titleLarge) + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + DropdownMenuItem( + text = { Text(stringRes(R.string.relay_group_invite_title)) }, + onClick = { + menuOpen = false + showInvite = true + }, + ) + DropdownMenuItem( + text = { Text(stringRes(R.string.leave)) }, + onClick = { + menuOpen = false + accountViewModel.leaveRelayGroup(channel) + }, + ) + } + } + }, popBack = nav::popBack, ) + + if (showInvite) { + InviteRelayGroupDialog(channel, accountViewModel) { showInvite = false } + } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 1dd8d15966..1bccd67f01 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1931,6 +1931,16 @@ New Public or Private Group Inline By relay + Create a channel + Channel name + Topic (optional) + Private (members-only read) + Invite only + Create + Invite people + Share this code so people can join this channel. + Creating an invite… + Invite code copied Private To Subject