From 51299400ef2f58ac5aca8cf2ede5a4fcdd273dfc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 18:05:00 +0000 Subject: [PATCH] feat(nip29): one-tap join from invite links carrying a code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An invite link's `?code=` now flows all the way to the join: tapping a `wss://relay'id?code=…` card/link (or opening it as a deep link) opens the group and, because the tap is itself the opt-in, fires the kind-9021 join with that code once — no more re-typing it into the join dialog. Plain (code-less) group links still just open the group for viewing. - Route.RelayGroup gains inviteCode; threaded through AppNavigation → RelayGroupChatScreen → RelayGroupTopBar - RelayGroupTopBar auto-joins once when a code is present and we're not already a member (reuses the optimistic "requested" state) - RelayGroupCard / ClickableRelayGroupLink / uriToRoute carry the parsed code Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../com/vitorpamplona/amethyst/ui/MainActivity.kt | 2 +- .../ui/components/ClickableRelayGroupLink.kt | 2 +- .../amethyst/ui/components/RelayGroupCard.kt | 11 ++++++++--- .../amethyst/ui/navigation/AppNavigation.kt | 1 + .../amethyst/ui/navigation/routes/Routes.kt | 3 +++ .../relayGroup/RelayGroupChatScreen.kt | 3 ++- .../publicChannels/relayGroup/RelayGroupTopBar.kt | 14 ++++++++++++++ 7 files changed, 30 insertions(+), 6 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index 6bd9833387..caf3ea93d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -337,5 +337,5 @@ private fun relayGroupDirectRoute(nip19: NAddress): Route? { */ private fun relayGroupInviteRoute(uri: String): Route? { val link = GroupInviteLink.parse(uri.removePrefix(NOSTR_URI_PREFIX)) ?: return null - return Route.RelayGroup(link.groupId, link.relayUrl.url) + return Route.RelayGroup(link.groupId, link.relayUrl.url, inviteCode = link.code) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRelayGroupLink.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRelayGroupLink.kt index 081ae833b7..4e25814a33 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRelayGroupLink.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRelayGroupLink.kt @@ -65,7 +65,7 @@ fun ClickableRelayGroupLink( } }, onClick = { - nav.nav(Route.RelayGroup(invite.groupId, invite.relayUrl.url)) + nav.nav(Route.RelayGroup(invite.groupId, invite.relayUrl.url, inviteCode = invite.code)) }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt index 9a00bf6899..6c9c901422 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt @@ -46,7 +46,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.LoadRelayGroupChannel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupPreviewSubscription @@ -78,13 +78,14 @@ fun RelayGroupCard( val groupId = remember(invite) { invite.toGroupId() } LoadRelayGroupChannel(groupId, accountViewModel) { channel -> - RelayGroupCardContent(channel, accountViewModel, nav) + RelayGroupCardContent(channel, invite.code, accountViewModel, nav) } } @Composable private fun RelayGroupCardContent( baseChannel: RelayGroupChannel, + inviteCode: String?, accountViewModel: AccountViewModel, nav: INav, ) { @@ -111,7 +112,11 @@ private fun RelayGroupCardContent( } OutlinedCard( - onClick = { nav.nav(routeFor(channel)) }, + onClick = { + nav.nav( + Route.RelayGroup(channel.groupId.id, channel.groupId.relayUrl.url, inviteCode = inviteCode), + ) + }, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), ) { Row( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 3c9474a0cb..ae9b329d87 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -568,6 +568,7 @@ fun BuildNavigation( relayUrl = it.relayUrl, draftId = it.draftId, replyToId = it.replyTo, + inviteCode = it.inviteCode, accountViewModel = accountViewModel, nav = nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 9b08794a4b..9bab7e56b8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -642,6 +642,9 @@ sealed class Route { val relayUrl: String, val draftId: HexKey? = null, val replyTo: HexKey? = null, + // NIP-29 invite code from a `wss://relay'id?code=…` link — auto-joins a closed + // group when the screen opens. Null for a plain (view-only) group link. + val inviteCode: String? = null, ) : Route() @Serializable data class RelayGroupServer( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChatScreen.kt index 5bf356ce4f..8e3b894cd0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChatScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChatScreen.kt @@ -38,6 +38,7 @@ fun RelayGroupChatScreen( relayUrl: String, draftId: HexKey? = null, replyToId: HexKey? = null, + inviteCode: String? = null, accountViewModel: AccountViewModel, nav: INav, ) { @@ -50,7 +51,7 @@ fun RelayGroupChatScreen( isInvertedLayout = true, topBar = { LoadRelayGroupChannel(channelId, accountViewModel) { - RelayGroupTopBar(it, accountViewModel, nav) + RelayGroupTopBar(it, inviteCode, accountViewModel, nav) } }, accountViewModel = accountViewModel, 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 d7d615e020..ce63adbb11 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 @@ -64,6 +64,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl @Composable fun RelayGroupTopBar( baseChannel: RelayGroupChannel, + inviteCode: String? = null, accountViewModel: AccountViewModel, nav: INav, ) { @@ -81,6 +82,19 @@ fun RelayGroupTopBar( LaunchedEffect(membership) { if (membership.isMember()) requested = false } + + // Arrived via an invite link carrying a join code (`wss://relay'id?code=…`): the + // user already opted in by tapping it, so fire the kind-9021 join once (with the + // code) instead of making them re-enter it. Guarded to exactly once and skipped if + // the roster already shows us as a member. + var autoJoined by remember(channel.groupId) { mutableStateOf(false) } + LaunchedEffect(channel.groupId, inviteCode, membership) { + if (inviteCode != null && !autoJoined && !membership.isMember()) { + autoJoined = true + requested = true + accountViewModel.joinRelayGroup(channel, inviteCode) + } + } val displayMembership = if (!membership.isMember() && requested) RelayGroupMembership.PENDING else membership var menuOpen by remember { mutableStateOf(false) }