mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
@@ -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) {
|
||||
|
||||
+30
-7
@@ -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))
|
||||
|
||||
+87
@@ -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)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
+17
-2
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
+11
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user