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 ->