mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+75
@@ -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
|
||||
|
||||
+189
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+6
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
|
||||
+6
@@ -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)
|
||||
}
|
||||
|
||||
+10
@@ -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,
|
||||
|
||||
+2
-1
@@ -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 ->
|
||||
|
||||
Reference in New Issue
Block a user