feat(nip29): shareable group naddr + invite-code join flow

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
This commit is contained in:
Claude
2026-07-07 22:36:07 +00:00
parent 7c0f24ef57
commit 2dca306914
6 changed files with 166 additions and 9 deletions
@@ -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) {
@@ -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))
@@ -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)) }
},
)
}
@@ -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
+5
View File
@@ -1941,6 +1941,11 @@
<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="relay_group_invite_code_label">Invite code (for this invite-only channel):</string>
<string name="relay_group_join_title">Join channel</string>
<string name="relay_group_join_code_label">Invite code</string>
<string name="relay_group_join_code_hint">This channel is invite-only. Enter the code you were given.</string>
<string name="relay_group_join_confirm">Join</string>
<string name="relay_group_pending">Requested</string>
<string name="relay_group_role_admin">Admin</string>
<string name="relay_group_role_moderator">Moderator</string>
@@ -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