From 7aca3da631582ab948e9bf530beba44acb8d6942 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 15:37:37 +0000 Subject: [PATCH] feat(buzz): intercept Buzz invite links into the in-app window.nostr browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Buzz workspace invite (`https:///invite/`) 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 Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8 --- amethyst/src/main/AndroidManifest.xml | 11 + .../vitorpamplona/amethyst/ui/MainActivity.kt | 14 ++ .../ui/components/AmethystRichText.kt | 2 + .../ui/components/ClickableBuzzInviteLink.kt | 75 +++++++ .../amethyst/ui/components/RichTextViewer.kt | 3 + .../amethyst/ui/navigation/AppNavigation.kt | 3 + .../amethyst/ui/navigation/routes/Routes.kt | 4 + .../screen/loggedIn/buzz/BuzzInviteScreen.kt | 189 ++++++++++++++++++ .../loggedIn/search/SearchBarViewModel.kt | 6 + amethyst/src/main/res/values/strings.xml | 8 + .../commons/richtext/RichTextParser.kt | 6 + .../richtext/RichTextParserSegments.kt | 10 + .../commons/ui/richtext/RichTextViewer.kt | 3 +- 13 files changed, 333 insertions(+), 1 deletion(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBuzzInviteLink.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzInviteScreen.kt diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index b2e245363b..de5ea4ed7a 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -212,6 +212,17 @@ + + + + + + + + + + + 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 bfb3a0f6d7..2f2ac4eba9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -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:///invite/`) — 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 + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/AmethystRichText.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/AmethystRichText.kt index a9c5635992..77a5e03800 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/AmethystRichText.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/AmethystRichText.kt @@ -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) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBuzzInviteLink.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBuzzInviteLink.kt new file mode 100644 index 0000000000..29f71fbda8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBuzzInviteLink.kt @@ -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:///invite/`) 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, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 176b1aab3a..57405c8359 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -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) } 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 46b6fe585b..606b81d857 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 @@ -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 { BuzzInviteScreen(it.link, accountViewModel, nav) } + composableFromEnd { ConcordHomeScreen(accountViewModel, nav) } composableFromEnd { ConcordCreateScreen(accountViewModel, 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 60ba1cc593..c5db3ef3d4 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 @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzInviteScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzInviteScreen.kt new file mode 100644 index 0000000000..ad72073b9d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzInviteScreen.kt @@ -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:///invite/`), 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, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt index 0a1b687fc4..03af2a7865 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt @@ -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/`) 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 } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index d0e6ca58b9..24e28b8f81 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3323,6 +3323,14 @@ Start conversation Opening… Remove + Workspace invite + Join this workspace + Workspace + Role + 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. + Continue in browser + This doesn\'t look like a valid Buzz invite link. + This invite has expired. Ask for a new one. Message Delivery Close diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index 378b2e9145..505c14111a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -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/` 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) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt index d5f5890fce..459b192b77 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt @@ -174,6 +174,16 @@ class ConcordInviteLinkSegment( segment: String, ) : Segment(segment) +/** + * A Buzz workspace invite link (`https:///invite/`). 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, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/RichTextViewer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/RichTextViewer.kt index d89976b9b3..15ef8a4f9e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/RichTextViewer.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/RichTextViewer.kt @@ -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 ->