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