feat(buzz): intercept Buzz invite links into the in-app window.nostr browser

A Buzz workspace invite (`https://<host>/invite/<token>`) is redeemed over HTTP,
and the Buzz web app already drives that flow (policy consent + NIP-98 claim)
through `window.nostr`. Amethyst's existing in-app browser (NappletBrowserActivity
via FavoriteAppLauncher.launchUrl) injects an origin-scoped window.nostr backed by
the user's signer into any https origin — so routing Buzz invites there lets the
SPA claim membership as the user's key, with no native re-implementation of the
consent UI.

Mirrors the Concord invite intercept, at all three entry points, keeping every
other link external by default:
- Deep link: AndroidManifest intent-filter for *.communities.buzz.xyz/invite/ +
  a `buzzInviteRoute()` branch in MainActivity.uriToRoute.
- Tapped in-content link: a BuzzInviteLinkSegment classified in RichTextParser
  (commons) + a ClickableBuzzInviteLink that routes to Route.BuzzInvite.
- Search bar: a branch in SearchBarViewModel.directRouteResolver.

Route.BuzzInvite → BuzzInviteScreen confirms the workspace (host + role parsed by
the quartz BuzzInviteLink), marks its relay as a Buzz dialect, and opens the
in-app browser at the invite URL to finish joining. The matcher is host-agnostic
(BuzzInviteLink.parse), so self-hosted Buzz invites intercept via tap/search even
without a manifest filter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
This commit is contained in:
Claude
2026-07-22 15:37:37 +00:00
parent bb39fb29fc
commit 7aca3da631
13 changed files with 333 additions and 1 deletions
+11
View File
@@ -212,6 +212,17 @@
<data android:pathPrefix="/invite/" />
</intent-filter>
<!-- Buzz workspace invite links: https://<team>.communities.buzz.xyz/invite/<token> -->
<intent-filter android:label="Amethyst">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="communities.buzz.xyz" />
<data android:host="*.communities.buzz.xyz" />
<data android:pathPrefix="/invite/" />
</intent-filter>
<intent-filter android:label="zap.stream">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
@@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.elements.NowProvider
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import com.vitorpamplona.quartz.buzz.invite.BuzzInviteLink
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
@@ -233,6 +234,7 @@ fun uriToRoute(
relayGroupInviteRoute(uri)?.let { return it }
concordInviteRoute(uri)?.let { return it }
buzzInviteRoute(uri)?.let { return it }
val parsedNip19 = Nip19Parser.uriToRoute(uri)
val nip19 = parsedNip19?.entity
@@ -384,3 +386,15 @@ private fun concordInviteRoute(uri: String): Route? =
} else {
null
}
/**
* A Buzz workspace invite (`https://<host>/invite/<token>`) — a plain `/invite/` https URL with
* no fragment, so disjoint from the Concord shape above. Opens the in-app join flow
* ([Route.BuzzInvite]) instead of the external browser so the claim signs with the user's key.
*/
private fun buzzInviteRoute(uri: String): Route? =
if (uri.contains("/invite/") && BuzzInviteLink.parse(uri) != null) {
Route.BuzzInvite(uri)
} else {
null
}
@@ -34,6 +34,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.richtext.BlossomUriSegment
import com.vitorpamplona.amethyst.commons.richtext.BuzzInviteLinkSegment
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
import com.vitorpamplona.amethyst.commons.richtext.CashuSegment
import com.vitorpamplona.amethyst.commons.richtext.ClinkOfferSegment
@@ -218,6 +219,7 @@ class AmethystRichTextSegmentRenderer(
} else {
ClickableConcordInviteLink(segment.segmentText, nav)
}
is BuzzInviteLinkSegment -> ClickableBuzzInviteLink(segment.segmentText, nav)
else -> Text(segment.segmentText)
}
}
@@ -0,0 +1,75 @@
/*
* 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.components
import androidx.compose.foundation.combinedClickable
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.quartz.buzz.invite.BuzzInviteLink
import kotlinx.coroutines.launch
/**
* Renders a Buzz workspace invite link (`https://<host>/invite/<token>`) inline as a tappable
* link that opens the in-app join flow ([Route.BuzzInvite], which confirms the workspace and
* hands off to the `window.nostr` browser for the policy + NIP-98 claim) instead of the
* external browser. Long-press copies the full link. Falls back to plain text if the literal
* can't be parsed (detection should guarantee it does).
*/
@Composable
fun ClickableBuzzInviteLink(
linkText: String,
nav: INav,
) {
val clipboardManager = LocalClipboard.current
val scope = rememberCoroutineScope()
val parsed = remember(linkText) { BuzzInviteLink.parse(linkText) }
if (parsed == null) {
Text(text = linkText)
return
}
val clickableModifier =
remember(linkText) {
Modifier.combinedClickable(
onLongClick = { scope.launch { clipboardManager.setText(linkText) } },
onClick = { nav.nav(Route.BuzzInvite(linkText)) },
)
}
Text(
text = linkText,
modifier = clickableModifier,
color = MaterialTheme.colorScheme.primary,
overflow = TextOverflow.MiddleEllipsis,
maxLines = 1,
)
}
@@ -69,6 +69,7 @@ import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.richtext.Base64Segment
import com.vitorpamplona.amethyst.commons.richtext.BechSegment
import com.vitorpamplona.amethyst.commons.richtext.BlossomUriSegment
import com.vitorpamplona.amethyst.commons.richtext.BuzzInviteLinkSegment
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
import com.vitorpamplona.amethyst.commons.richtext.CashuSegment
import com.vitorpamplona.amethyst.commons.richtext.ClinkOfferSegment
@@ -492,6 +493,7 @@ private fun RenderWordWithoutPreview(
is RelayGroupLinkSegment -> ClickableRelayGroupLink(word.segmentText, nav)
is ConcordInviteLinkSegment -> ClickableConcordInviteLink(word.segmentText, nav)
is BuzzInviteLinkSegment -> ClickableBuzzInviteLink(word.segmentText, nav)
is BlossomUriSegment -> BlossomUriRendererNoPreview(word.segmentText, accountViewModel)
@@ -533,6 +535,7 @@ private fun RenderWordWithPreview(
is RelayUrlSegment -> ClickableRelayUrl(word.segmentText, nav)
is RelayGroupLinkSegment -> RelayGroupCard(word.segmentText, accountViewModel, nav)
is ConcordInviteLinkSegment -> ConcordInviteCard(word.segmentText, accountViewModel, nav)
is BuzzInviteLinkSegment -> ClickableBuzzInviteLink(word.segmentText, nav)
is BlossomUriSegment -> BlossomUriRenderer(word.segmentText, state, callbackUri, accountViewModel)
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word.segmentText)
}
@@ -105,6 +105,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.AgentPersonaEditScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzCanvasScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzDmListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzForumPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzInviteScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzNewDmScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzWorkspacesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarCollectionsScreen
@@ -745,6 +746,8 @@ fun BuildNavigation(
)
}
composableFromEndArgs<Route.BuzzInvite> { BuzzInviteScreen(it.link, accountViewModel, nav) }
composableFromEnd<Route.Concords> { ConcordHomeScreen(accountViewModel, nav) }
composableFromEnd<Route.ConcordCreate> { ConcordCreateScreen(accountViewModel, nav) }
@@ -756,6 +756,10 @@ sealed class Route {
val link: String,
) : Route()
@Serializable data class BuzzInvite(
val link: String,
) : Route()
@Serializable object Concords : Route()
// The "minichat" of a chat message: its kind-1111 thread replies, opened from the message and
@@ -0,0 +1,189 @@
/*
* 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.buzz
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.buzz.invite.BuzzInviteLink
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* Landing screen for a Buzz workspace invite link (`https://<host>/invite/<token>`), reached
* from the deep-link / tapped-link / search interceptors instead of the external browser.
*
* A Buzz invite is redeemed over HTTP (accept the join policy, then a NIP-98-signed claim)
* a flow the Buzz web app already implements and drives through `window.nostr`. So rather than
* re-implement the legally-sensitive age/privacy consent natively, this confirms the workspace,
* marks its relay as a Buzz dialect, and hands the URL to the in-app `window.nostr` browser
* ([FavoriteAppLauncher.launchUrl] the sandboxed WebView), where the SPA signs the claim with
* the user's key. The user returns here (or to the app) once enrolled.
*/
@Composable
fun BuzzInviteScreen(
link: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val invite = remember(link) { BuzzInviteLink.parse(link) }
val context = LocalContext.current
Scaffold(
topBar = { TopBarWithBackButton(stringRes(R.string.buzz_invite_title), nav) },
) { padding ->
Column(
modifier = Modifier.padding(padding).fillMaxSize().padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (invite == null) {
Text(stringRes(R.string.buzz_invite_invalid), style = MaterialTheme.typography.bodyLarge)
return@Column
}
val expired = remember(invite) { invite.isExpired(TimeUtils.now()) }
Spacer(Modifier.size(8.dp))
Box(
modifier =
Modifier
.size(72.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Icon(
symbol = MaterialSymbols.AutoAwesome,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(36.dp),
)
}
Text(
text = stringRes(R.string.buzz_invite_heading),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
InviteRow(stringRes(R.string.buzz_invite_workspace), invite.host)
InviteRow(stringRes(R.string.buzz_invite_role), invite.role)
}
}
Text(
text = stringRes(R.string.buzz_invite_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (expired) {
Text(
text = stringRes(R.string.buzz_invite_expired),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
}
Spacer(Modifier.weight(1f))
Button(
onClick = {
// Recognize the workspace's relay as Buzz-dialect so its events are materialized
// as workspace channels once membership is granted, then hand off to the in-app
// window.nostr browser to accept terms + sign the claim.
RelayUrlNormalizer.normalizeOrNull(invite.relayUrl())?.let { BuzzRelayDialect.mark(it) }
FavoriteAppLauncher.launchUrl(context, link)
},
enabled = !expired,
modifier = Modifier.fillMaxWidth(),
) {
Icon(symbol = MaterialSymbols.AutoMirrored.OpenInNew, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(10.dp))
Text(stringRes(R.string.buzz_invite_continue))
}
}
}
}
@Composable
private fun InviteRow(
label: String,
value: String,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.width(96.dp),
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@@ -44,6 +44,7 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.userUriPrefixes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder
import com.vitorpamplona.quartz.buzz.invite.BuzzInviteLink
import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -197,6 +198,11 @@ class SearchBarViewModel(
return@mapLatest Route.ConcordInvite(term)
}
// A pasted Buzz workspace invite (`…/invite/<token>`) opens the in-app join flow.
if (term.contains("/invite/") && BuzzInviteLink.parse(term) != null) {
return@mapLatest Route.BuzzInvite(term)
}
val parsed =
runCatching { Nip19Parser.uriToRoute(term)?.entity }
.onFailure { if (it is CancellationException) throw it }
+8
View File
@@ -3323,6 +3323,14 @@
<string name="buzz_dm_start">Start conversation</string>
<string name="buzz_dm_opening">Opening…</string>
<string name="buzz_dm_remove">Remove</string>
<string name="buzz_invite_title">Workspace invite</string>
<string name="buzz_invite_heading">Join this workspace</string>
<string name="buzz_invite_workspace">Workspace</string>
<string name="buzz_invite_role">Role</string>
<string name="buzz_invite_body">You\'ll open the workspace in a secure in-app browser to review its terms and finish joining. It signs you in with your Amethyst key — no password.</string>
<string name="buzz_invite_continue">Continue in browser</string>
<string name="buzz_invite_invalid">This doesn\'t look like a valid Buzz invite link.</string>
<string name="buzz_invite_expired">This invite has expired. Ask for a new one.</string>
<string name="chat_delivery_details_title">Message Delivery</string>
<string name="close">Close</string>
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.util.isValidUrl
import com.vitorpamplona.quartz.buzz.invite.BuzzInviteLink
import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser
import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer
import com.vitorpamplona.quartz.experimental.inlineMetadata.Nip54InlineMetadata
@@ -392,6 +393,11 @@ class RichTextParser {
if (word.contains("/invite/") && word.contains('#') && ConcordActions.parseInviteLink(word) != null) {
return ConcordInviteLinkSegment(word)
}
// A Buzz invite is a plain `…/invite/<token>` https URL (no fragment, so disjoint from
// the Concord shape above). Same cheap gate before the base64 parse.
if (word.contains("/invite/") && BuzzInviteLink.parse(word) != null) {
return BuzzInviteLinkSegment(word)
}
parseNowhereLink(word)?.let { return it }
return LinkSegment(word)
}
@@ -174,6 +174,16 @@ class ConcordInviteLinkSegment(
segment: String,
) : Segment(segment)
/**
* A Buzz workspace invite link (`https://<host>/invite/<token>`). Rendered as a tappable
* chip that opens the in-app join browser instead of the external browser, so the Buzz web
* app can sign the NIP-98 claim with the user's key via the injected `window.nostr`.
*/
@Immutable
class BuzzInviteLinkSegment(
segment: String,
) : Segment(segment)
@Immutable
class BlossomUriSegment(
segment: String,
@@ -54,6 +54,7 @@ import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.commons.richtext.Base64Segment
import com.vitorpamplona.amethyst.commons.richtext.BechSegment
import com.vitorpamplona.amethyst.commons.richtext.BlossomUriSegment
import com.vitorpamplona.amethyst.commons.richtext.BuzzInviteLinkSegment
import com.vitorpamplona.amethyst.commons.richtext.CashuSegment
import com.vitorpamplona.amethyst.commons.richtext.ClinkOfferSegment
import com.vitorpamplona.amethyst.commons.richtext.ConcordInviteLinkSegment
@@ -179,7 +180,7 @@ private fun RenderWord(
renderer.Url("https://${word.segmentText}", word.segmentText, Modifier)
is NowhereLinkSegment -> renderer.NowhereLink(word, canPreview, Modifier)
is RelayUrlSegment, is RelayGroupLinkSegment, is ConcordInviteLinkSegment ->
is RelayUrlSegment, is RelayGroupLinkSegment, is ConcordInviteLinkSegment, is BuzzInviteLinkSegment ->
renderer.RelayLink(word, Modifier)
is InvoiceSegment, is WithdrawSegment, is CashuSegment, is ClinkOfferSegment ->