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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
This commit is contained in:
Claude
2026-07-07 22:12:11 +00:00
parent d5d4125978
commit 5f677280d8
7 changed files with 382 additions and 0 deletions
@@ -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))
@@ -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<User>) = launchSigner { account.follow(users) }
fun follow(user: User) = launchSigner { account.follow(user) }
@@ -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)
}
}
@@ -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)) }
},
)
}
@@ -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
@@ -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 }
}
}
+10
View File
@@ -1931,6 +1931,16 @@
<string name="messages_create_public_private_chat_description">New Public or Private Group</string>
<string name="relay_group_view_inline">Inline</string>
<string name="relay_group_view_grouped">By relay</string>
<string name="relay_group_create_title">Create a channel</string>
<string name="relay_group_create_name">Channel name</string>
<string name="relay_group_create_topic">Topic (optional)</string>
<string name="relay_group_create_private">Private (members-only read)</string>
<string name="relay_group_create_invite_only">Invite only</string>
<string name="relay_group_create_confirm">Create</string>
<string name="relay_group_invite_title">Invite people</string>
<string name="relay_group_invite_description">Share this code so people can join this channel.</string>
<string name="relay_group_invite_generating">Creating an invite…</string>
<string name="relay_group_invite_copied">Invite code copied</string>
<string name="messages_new_message">Private</string>
<string name="messages_new_message_to">To</string>
<string name="messages_new_message_subject">Subject</string>