feat(nip29): one-tap join from invite links carrying a code

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
This commit is contained in:
Claude
2026-07-08 18:05:00 +00:00
parent 2b11dbd7d9
commit 51299400ef
7 changed files with 30 additions and 6 deletions
@@ -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)
}
@@ -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))
},
)
}
@@ -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(
@@ -568,6 +568,7 @@ fun BuildNavigation(
relayUrl = it.relayUrl,
draftId = it.draftId,
replyToId = it.replyTo,
inviteCode = it.inviteCode,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -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(
@@ -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,
@@ -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) }