From 2dca3069145a886ba13b8e97115abe3b324c9c52 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 22:36:07 +0000 Subject: [PATCH] feat(nip29): shareable group naddr + invite-code join flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nostr-native invites/deep-links for relay groups (no proprietary URL scheme): - RelayGroupChannel.toNAddr(): a NIP-19 `naddr` for the group's kind-39000 metadata (authored by the relay key, host relay as hint) — a cross-client coordinate that opens the group. - ClickableRoute: a clicked/rendered `naddr` for kind 39000 routes straight into the group chat (Route.RelayGroup with the relay hint) instead of the generic addressable-note view. - InviteRelayGroupDialog now shares the group `nostr:naddr…` (for discovery) plus the one-time code for closed groups; copy grabs both. - JoinRelayGroupDialog: closed groups prompt for the invite code, which the join request (9021) carries; open groups still join in one tap. Follow-up: cold-start `nostr:naddr` deep links (from outside the app) still route to the generic note view; only the in-app clicked/rendered path is wired here. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../amethyst/ui/components/ClickableRoute.kt | 16 ++++ .../relayGroup/InviteRelayGroupDialog.kt | 37 ++++++-- .../relayGroup/JoinRelayGroupDialog.kt | 87 +++++++++++++++++++ .../relayGroup/RelayGroupTopBar.kt | 19 +++- amethyst/src/main/res/values/strings.xml | 5 ++ .../nip29RelayGroups/RelayGroupChannel.kt | 11 +++ 6 files changed, 166 insertions(+), 9 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/JoinRelayGroupDialog.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt index 125d032488..e87ede251a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt @@ -77,6 +77,7 @@ 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.nip19Bech32.toNIP19 +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap @@ -201,6 +202,21 @@ private fun DisplayAddress( accountViewModel: AccountViewModel, nav: INav, ) { + // A NIP-29 group is addressed by its relay-signed kind-39000 metadata. Route + // straight into the group chat rather than the generic addressable-note view. + // The relay is only carried in the naddr's relay hint (the group id is unique + // only per relay), so we need that hint to open it. + val groupRelay = if (nip19.kind == GroupMetadataEvent.KIND) nip19.relay.firstOrNull() else null + if (groupRelay != null) { + CreateClickableText( + clickablePart = "#${nip19.dTag}", + suffix = additionalChars, + route = Route.RelayGroup(nip19.dTag, groupRelay.url), + nav = nav, + ) + return + } + var noteBase by remember(nip19) { mutableStateOf(accountViewModel.getNoteIfExists(nip19.aTag())) } if (noteBase == null) { 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 index 49ef3021b1..1d607806b3 100644 --- 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 @@ -59,23 +59,46 @@ fun InviteRelayGroupDialog( accountViewModel.createRelayGroupInvite(channel, code) } + // A shareable, cross-client coordinate for the group (opens the chat in any + // NIP-29 client). Null until the relay-signed metadata has loaded. + val nAddr = channel.toNAddr()?.let { "nostr:$it" } + val isClosed = channel.isClosed() + 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, - ) + + nAddr?.let { + Text( + text = it, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + maxLines = 2, + ) + } + + // Closed groups additionally need a one-time code to join. + if (isClosed) { + Text(stringRes(R.string.relay_group_invite_code_label)) + Text( + text = code, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary, + ) + } } }, confirmButton = { TextButton(onClick = { - clipboard.setText(AnnotatedString(code)) + // Copy the most useful thing: the group link, plus the code when + // the group is closed (so a recipient has both to join). + val toCopy = listOfNotNull(nAddr, if (isClosed) code else null).joinToString("\n") + clipboard.setText(AnnotatedString(toCopy.ifBlank { code })) onDismiss() }) { Text(stringRes(R.string.copy)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/JoinRelayGroupDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/JoinRelayGroupDialog.kt new file mode 100644 index 0000000000..c7eabc5392 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/JoinRelayGroupDialog.kt @@ -0,0 +1,87 @@ +/* + * 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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField +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.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 + +/** + * Ask for an invite code before joining a closed (invite-only) NIP-29 group, then + * send the join request (kind 9021) carrying the code. Open groups don't need this + * — they join directly. + */ +@Composable +fun JoinRelayGroupDialog( + channel: RelayGroupChannel, + accountViewModel: AccountViewModel, + onJoined: () -> Unit, + onDismiss: () -> Unit, +) { + var code by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(R.string.relay_group_join_title)) }, + text = { + Column { + Text(stringRes(R.string.relay_group_join_code_hint)) + OutlinedTextField( + value = code, + onValueChange = { code = it }, + singleLine = true, + label = { Text(stringRes(R.string.relay_group_join_code_label)) }, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + } + }, + confirmButton = { + TextButton( + enabled = code.isNotBlank(), + onClick = { + accountViewModel.joinRelayGroup(channel, code.trim()) + onJoined() + onDismiss() + }, + ) { + Text(stringRes(R.string.relay_group_join_confirm)) + } + }, + 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/RelayGroupTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt index a9a92f1909..1bd4b24aeb 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 @@ -70,6 +70,7 @@ fun RelayGroupTopBar( var menuOpen by remember { mutableStateOf(false) } var showInvite by remember { mutableStateOf(false) } + var showJoinCode by remember { mutableStateOf(false) } TopBarExtensibleWithBackButton( title = { @@ -101,8 +102,13 @@ fun RelayGroupTopBar( !displayMembership.isMember() -> { FilledTonalButton(onClick = { - requested = true - accountViewModel.joinRelayGroup(channel) + // Closed groups need an invite code; open groups join directly. + if (channel.isClosed()) { + showJoinCode = true + } else { + requested = true + accountViewModel.joinRelayGroup(channel) + } }) { Text(stringRes(R.string.join)) } @@ -139,6 +145,15 @@ fun RelayGroupTopBar( if (showInvite) { InviteRelayGroupDialog(channel, accountViewModel) { showInvite = false } } + + if (showJoinCode) { + JoinRelayGroupDialog( + channel = channel, + accountViewModel = accountViewModel, + onJoined = { requested = true }, + onDismiss = { showJoinCode = false }, + ) + } } @Composable diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 10eba0acd7..f92ef7c9e3 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1941,6 +1941,11 @@ Share this code so people can join this channel. Creating an invite… Invite code copied + Invite code (for this invite-only channel): + Join channel + Invite code + This channel is invite-only. Enter the code you were given. + Join Requested Admin Moderator diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt index 5cacbad479..e9c4136b9b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.model.Channel import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent @@ -108,6 +109,16 @@ class RelayGroupChannel( updateChannelInfo() } + /** + * The shareable NIP-19 `naddr` coordinate for this group's metadata (kind + * 39000, authored by the relay's own key, with the host relay as a hint), or + * null until the relay-signed metadata has loaded (we need its author key). + */ + fun toNAddr(): String? { + val relaySelf = event?.pubKey ?: return null + return NAddress.create(GroupMetadataEvent.KIND, relaySelf, groupId.id, groupId.relayUrl) + } + /** Number of known members (admins are members too). */ fun memberCount(): Int = (members + admins.map { it.pubKey }).size