feat(commons): route url/email/phone through the platform strategy

Closes the last rich-text fidelity residual. url/email/phone were rendered
generically by the shared core via RichTextInteractions callbacks, losing each
front end's per-type styling and open behavior. They now go through the
RichTextSegmentRenderer strategy:
- Add Url(url, displayText)/Email(address)/Phone(number) to the contract (with
  plain-text defaults).
- Core routes LinkSegment(no-preview)/SchemelessUrl -> Url, Email -> Email,
  Phone -> Phone; drop the in-core ClickableSpan.
- Amethyst renders them with ClickableUrl/ClickableEmail/ClickablePhone (blossom
  intent + dial preserved); Desktop with ClickableLink / underlined mailto / plain
  phone text.
- RichTextInteractions now carries only onClickHashtag (the one segment the core
  draws itself, with shared icons); the onOpen* callbacks are gone.

Verified: :commons JVM, :desktopApp, :amethyst play debug compile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
This commit is contained in:
Claude
2026-07-16 22:12:28 +00:00
parent 51f005d22f
commit 9eb827fed6
4 changed files with 114 additions and 45 deletions
@@ -32,7 +32,6 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalUriHandler
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.richtext.BlossomUriSegment
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
@@ -178,6 +177,25 @@ class AmethystRichTextSegmentRenderer(
modifier: Modifier,
) = LoadUrlPreview(url, url, callbackUri, accountViewModel, nav)
@Composable
override fun Url(
url: String,
displayText: String,
modifier: Modifier,
) = ClickableUrl(displayText, url)
@Composable
override fun Email(
address: String,
modifier: Modifier,
) = ClickableEmail(address)
@Composable
override fun Phone(
number: String,
modifier: Modifier,
) = ClickablePhone(number)
@Composable
override fun RelayLink(
segment: Segment,
@@ -266,13 +284,9 @@ fun CommonsBackedRichTextViewer(
AmethystRichTextSegmentRenderer(accountViewModel, nav, backgroundColor, callbackUri, canPreview)
}
val uriHandler = LocalUriHandler.current
val interactions =
remember(nav, uriHandler) {
remember(nav) {
RichTextInteractions(
onOpenUrl = { runCatching { uriHandler.openUri(it) } },
onOpenEmail = { runCatching { uriHandler.openUri("mailto:$it") } },
onOpenPhone = { runCatching { uriHandler.openUri("tel:$it") } },
onClickHashtag = { nav.nav(Route.Hashtag(it.lowercase())) },
)
}
@@ -141,6 +141,32 @@ interface RichTextSegmentRenderer {
modifier: Modifier,
)
/**
* A plain external link rendered inline (no preview): the no-preview [LinkPreview]
* fallback and schemeless URLs. [displayText] is what the user sees; [url] is where
* it opens. Each front end styles the link and owns how it opens.
*/
@Composable
fun Url(
url: String,
displayText: String,
modifier: Modifier,
)
/** An email address. Each front end styles it and owns the compose/open action. */
@Composable
fun Email(
address: String,
modifier: Modifier,
)
/** A phone number. Each front end styles it and owns the dial action. */
@Composable
fun Phone(
number: String,
modifier: Modifier,
)
/** A relay URL, NIP-29 group invite, or Concord invite chip. */
@Composable
fun RelayLink(
@@ -168,18 +194,14 @@ interface RichTextSegmentRenderer {
}
/**
* Presentation-agnostic activations for the segments the shared core renders
* itself. The *action* is unambiguous on every platform (open a URL, dial a
* number, jump to a hashtag); only how the trigger looks/feels differs, which is
* a Modifier concern the core applies. Anything whose action itself diverges by
* platform (a mention that navigates vs. pops a hover-card) belongs in
* Activations for the segments the shared core renders itself. Only hashtags
* qualify: the core draws the chip (with the shared inline icon) identically on
* every platform, and only the navigation target differs — a pure callback. Links,
* mail, phone, mentions, etc. render *and* act per-platform, so they live on
* [RichTextSegmentRenderer], not here.
*/
@Immutable
data class RichTextInteractions(
val onOpenUrl: (url: String) -> Unit = {},
val onOpenEmail: (address: String) -> Unit = {},
val onOpenPhone: (number: String) -> Unit = {},
val onClickHashtag: (hashtag: String) -> Unit = {},
)
@@ -244,6 +266,25 @@ object PlainTextSegmentRenderer : RichTextSegmentRenderer {
modifier: Modifier,
) = Text(url, modifier)
@Composable
override fun Url(
url: String,
displayText: String,
modifier: Modifier,
) = Text(displayText, modifier)
@Composable
override fun Email(
address: String,
modifier: Modifier,
) = Text(address, modifier)
@Composable
override fun Phone(
number: String,
modifier: Modifier,
) = Text(number, modifier)
@Composable
override fun RelayLink(
segment: Segment,
@@ -156,8 +156,11 @@ private fun RenderWord(
is RegularTextSegment -> Text(word.segmentText)
is EmojiSegment -> RenderCustomEmoji(word.segmentText, state.customEmoji)
is HashTagSegment -> HashTagText(word) { actions.onClickHashtag(word.hashtag) }
is EmailSegment -> ClickableSpan(word.segmentText) { actions.onOpenEmail(word.segmentText) }
is PhoneSegment -> ClickableSpan(word.segmentText) { actions.onOpenPhone(word.segmentText) }
// Presentation + CTA are platform-owned: each front end styles its own
// clickable link/mail/phone and decides how to open it.
is EmailSegment -> renderer.Email(word.segmentText, Modifier)
is PhoneSegment -> renderer.Phone(word.segmentText, Modifier)
// Divergent media — presentation and CTA are platform-owned.
is ImageSegment, is VideoSegment, is PdfSegment, is Base64Segment, is BlossomUriSegment ->
@@ -169,11 +172,11 @@ private fun RenderWord(
if (canPreview) {
renderer.LinkPreview(word.segmentText, Modifier)
} else {
ClickableSpan(word.segmentText) { actions.onOpenUrl(word.segmentText) }
renderer.Url(word.segmentText, word.segmentText, Modifier)
}
is SchemelessUrlSegment ->
ClickableSpan(word.segmentText) { actions.onOpenUrl("https://${word.segmentText}") }
renderer.Url("https://${word.segmentText}", word.segmentText, Modifier)
is NowhereLinkSegment -> renderer.NowhereLink(word, canPreview, Modifier)
is RelayUrlSegment, is RelayGroupLinkSegment, is ConcordInviteLinkSegment ->
@@ -192,18 +195,6 @@ private fun RenderWord(
}
}
@Composable
private fun ClickableSpan(
text: String,
onClick: () -> Unit,
) {
Text(
text = text,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable(onClick = onClick),
)
}
private val HashtagIconPlaceholder =
Placeholder(width = 17.sp, height = 17.sp, placeholderVerticalAlign = PlaceholderVerticalAlign.Center)
@@ -125,21 +125,6 @@ fun DesktopRichText(
val interactions =
remember(callbacks) {
RichTextInteractions(
onOpenUrl = {
runCatching {
java.awt.Desktop
.getDesktop()
.browse(URI(it))
}
},
onOpenEmail = {
runCatching {
java.awt.Desktop
.getDesktop()
.browse(URI("mailto:$it"))
}
},
onOpenPhone = { },
onClickHashtag = { callbacks.onHashtagClick?.invoke(it) },
)
}
@@ -319,6 +304,44 @@ class DesktopRichTextSegmentRenderer(
modifier: Modifier,
) = ClickableLink(url, url)
@Composable
override fun Url(
url: String,
displayText: String,
modifier: Modifier,
) = ClickableLink(url, displayText)
@Composable
override fun Email(
address: String,
modifier: Modifier,
) = Text(
text = address,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline,
modifier =
Modifier
.pointerHoverIcon(PointerIcon.Hand)
.clickable {
runCatching {
java.awt.Desktop
.getDesktop()
.browse(URI("mailto:$address"))
}
},
)
@Composable
override fun Phone(
number: String,
modifier: Modifier,
) = Text(
text = number,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
@Composable
override fun RelayLink(
segment: Segment,