From c8deb0f227d7898f1b36f89fd95e217d0a07de8e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 20:45:58 +0000 Subject: [PATCH] feat(commons): prototype shared rich-text rendering contract Introduces commons/ui/richtext: one cross-platform RichTextViewer that both the touch (Amethyst Android) and mouse-first (Desktop) front ends can drive, to replace the two current forks (amethyst RichTextViewer + DesktopRichTextViewer). The shared core owns the universal parts (paragraph/RTL/word layout, plain text, inline custom emoji, hashtags) and delegates the segments whose *presentation and* call-to-action diverge by platform (media, equation, quoted event, mention, payment, link preview, relay/invite, secret message) to a RichTextSegmentRenderer strategy provided via LocalRichTextSegmentRenderer -- the same CompositionLocal idiom the codebase already uses for LocalInlineQuoteRenderer. Universal actions (open url/email/phone, hashtag) go through a small RichTextInteractions callback bag. A PlainTextSegmentRenderer default keeps the core usable from previews/tests/headless callers. Contract + skeleton only; compiles in :commons. No consumer wired yet. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V --- .../amethyst/commons/ui/richtext/README.md | 64 ++++ .../ui/richtext/RichTextSegmentRenderer.kt | 264 ++++++++++++++++ .../commons/ui/richtext/RichTextViewer.kt | 295 ++++++++++++++++++ 3 files changed, 623 insertions(+) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/README.md create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/RichTextSegmentRenderer.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/RichTextViewer.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/README.md b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/README.md new file mode 100644 index 0000000000..e6117be96d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/README.md @@ -0,0 +1,64 @@ +# commons/ui/richtext — shared rich-text rendering contract + +A **prototype** of one cross-platform rich-text renderer that Amethyst Android +(touch) and Amethyst Desktop (mouse-first) can both drive, replacing the two +current forks (`amethyst/ui/components/RichTextViewer.kt` ~1031 LOC and +`desktopApp/…/DesktopRichTextViewer.kt` ~745 LOC). + +## What lives here + +- **`RichTextViewer.kt`** — the shared skeleton. Owns everything identical on + every front end: paragraph splitting, RTL, FlowRow word layout, plain text, + inline custom emoji, and hashtags. Takes an already-parsed + `RichTextViewerState` (the parser is pure and already in `commons/richtext`); + it holds **no** account, nav, or cache handle. +- **`RichTextSegmentRenderer.kt`** — the seam. Two things the host provides: + - `RichTextSegmentRenderer` (via `LocalRichTextSegmentRenderer`) — one method + per **platform-divergent** segment (media, equation, quoted event, user + mention, payment, link preview, relay/invite chip, secret message). The + platform owns both the **visual** and the **call-to-action** for these. + - `RichTextInteractions` (via `LocalRichTextInteractions`) — plain callbacks + for segments whose **action is universal** and only the trigger styling + differs (open URL/email/phone, jump to hashtag). + +## Why this split (and not desktop's callback-only bag) + +The divergent segments differ in *two* ways at once between touch and mouse: the +**presentation** (a tap-to-zoom media pager vs. an inline image that opens in a +window) *and* the **call-to-action** (a bottom sheet vs. a popover; navigate on +tap vs. a hover-card). A callbacks-only contract assumes shared rendering + a +different click handler — which isn't true here — so the platform must own the +whole rendering of those segments. Hence a renderer strategy, not just callbacks. + +This is the same idiom the codebase already uses for inline quotes +(`LocalInlineQuoteRenderer`), generalised to every divergent segment: a +CompositionLocal set at the shell, read deep in the recursive tree, re-providable +per subtree — no parameter threading through 55+ call sites. + +**Feature parity, not presentation parity.** Desktop being simpler today is a +gap, not the design; it is expected to cover the same range over time, its own +mouse-first way. Every method has a plain-text default (`PlainTextSegmentRenderer`) +so an unimplemented kind degrades to readable text and the core stays usable from +previews, `commonTest`, and headless callers. + +## How each front end plugs in (next steps — not in this prototype) + +- **Android** (`amethyst`): implement `RichTextSegmentRenderer` by wrapping the + existing leaf composables (`ZoomableContentView`, `LoadUrlPreview`, + `CashuPreview`, `MayBeInvoicePreview`, `LatexEquation`, `BechLink` → + `LocalInlineQuoteRenderer`, …), closing over `AccountViewModel`/`INav`. The + Android `RichTextViewer` becomes a thin wrapper that parses the content, then + provides the two CompositionLocals and calls this shared core — keeping its + current signature so no call site changes. The flavor-specific + `TranslatableRichTextViewer` stays native and feeds the final string in. +- **Desktop** (`desktopApp`): implement the strategy with `AsyncImage` + + window-open for media, `RenderMarkdown` for markdown, popovers for payments, + hover-cards for mentions — then delete `DesktopRichTextViewer`. + +## Status + +Compiles in `:commons` (`compileCommonMainKotlinMetadata`). No consumer is wired +yet — this is the contract + skeleton for review before the per-platform +implementations and the fork deletions land. Known follow-ups: share the custom +emoji **icon** table (hashtag icons) and a `CreateTextWithEmoji` equivalent; +unify the two `CachedRichTextParser` forks so callers don't each parse. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/RichTextSegmentRenderer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/RichTextSegmentRenderer.kt new file mode 100644 index 0000000000..1cff1901e2 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/RichTextSegmentRenderer.kt @@ -0,0 +1,264 @@ +/* + * 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.commons.ui.richtext + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.commons.richtext.ImageGalleryParagraph +import com.vitorpamplona.amethyst.commons.richtext.MathSegment +import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState +import com.vitorpamplona.amethyst.commons.richtext.SecretEmoji +import com.vitorpamplona.amethyst.commons.richtext.Segment + +/** + * The seam that lets one shared [RichTextViewer] serve every front end. + * + * ## Why a strategy and not a callback bag + * + * The parse → paragraph/word-layout → plain-text/emoji/hashtag rendering is + * *identical* on every platform, so the shared core owns it outright. But a + * handful of segment kinds — media, embedded notes, mentions, payments, link + * unfurls, LaTeX — differ between a **touch** front end (Amethyst Android) and a + * **mouse-first** one (Desktop). They differ in *two* ways at once: + * + * - **Presentation.** A phone shows an image in a full-bleed, tap-to-zoom pager; + * a desktop shows it inline with a hover affordance and opens it in a window. + * - **Call-to-action.** A phone opens a lightning invoice in a bottom sheet; a + * desktop opens a popover. A phone navigates on a mention tap; a desktop may + * show a hover-card first. + * + * Because *both* the visual and the interaction diverge (not just the click + * handler), a callback-only contract is not enough — the platform has to own the + * whole rendering of these segments. So each divergent kind is a method here, and + * the platform provides an implementation via [LocalRichTextSegmentRenderer]. + * + * This is the same idiom the codebase already uses for inline quotes + * (`LocalInlineQuoteRenderer`), generalised to every platform-divergent segment: + * a [androidx.compose.runtime.CompositionLocal] set once at the app shell, read + * deep inside the recursive render tree, and re-providable per subtree (e.g. chat + * bubbles vs. the feed) without threading a parameter through every call site. + * + * ## Feature parity, not presentation parity + * + * A platform is expected to *cover the same range* of segments over time — the + * Desktop being simpler today is a gap, not the design. What it is **not** + * expected to do is render them the same way. Every method has a plain-text + * default (see [PlainTextSegmentRenderer]) so an unimplemented kind degrades to + * readable text rather than vanishing, which also keeps the core usable from + * tests, previews, and headless callers. + * + * Every method receives a [Modifier] the core already aligned for RTL; draw into + * it. `quotesLeft` is the remaining recursion budget for embedded content — a + * renderer that recurses back into [RichTextViewer] must decrement it. + */ +@Stable +interface RichTextSegmentRenderer { + /** A single image / video / pdf / base64 / blossom-uri media word. */ + @Composable + fun Media( + segment: Segment, + state: RichTextViewerState, + modifier: Modifier, + ) + + /** A whole paragraph that is nothing but images — laid out as a grid/gallery. */ + @Composable + fun Gallery( + paragraph: ImageGalleryParagraph, + state: RichTextViewerState, + modifier: Modifier, + ) + + /** An inline/display LaTeX equation (platform math renderer). */ + @Composable + fun Equation( + segment: MathSegment, + modifier: Modifier, + ) + + /** A bare `nostr:` bech entity whose kind (user vs event) the renderer resolves. */ + @Composable + fun NostrEntity( + bech: String, + canPreview: Boolean, + quotesLeft: Int, + modifier: Modifier, + ) + + /** A `#[i]`-style event mention resolved to [eventHex]; renders the quoted note. */ + @Composable + fun QuotedEvent( + eventHex: String, + addedChars: String?, + canPreview: Boolean, + quotesLeft: Int, + modifier: Modifier, + ) + + /** A `#[i]`-style user mention resolved to [userHex]. */ + @Composable + fun UserMention( + userHex: String, + addedChars: String?, + modifier: Modifier, + ) + + /** A payable token: lightning invoice, LNURL-withdraw, Cashu token, or Clink offer. */ + @Composable + fun Payment( + segment: Segment, + modifier: Modifier, + ) + + /** An external link that may unfurl into a preview card (touch) or hover card (mouse). */ + @Composable + fun LinkPreview( + url: String, + modifier: Modifier, + ) + + /** A relay URL, NIP-29 group invite, or Concord invite chip. */ + @Composable + fun RelayLink( + segment: Segment, + modifier: Modifier, + ) + + /** A NIP-C0 secret-emoji span that expands into its own decoded rich-text message. */ + @Composable + fun SecretMessage( + segment: SecretEmoji, + state: RichTextViewerState, + canPreview: Boolean, + quotesLeft: Int, + modifier: Modifier, + ) +} + +/** + * 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 + * [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 = {}, +) + +/** + * The default: render every divergent segment as its raw text. Safe for previews, + * `commonTest`, and headless callers; a real front end replaces it wholesale. + */ +object PlainTextSegmentRenderer : RichTextSegmentRenderer { + @Composable + override fun Media( + segment: Segment, + state: RichTextViewerState, + modifier: Modifier, + ) = Text(segment.segmentText, modifier) + + @Composable + override fun Gallery( + paragraph: ImageGalleryParagraph, + state: RichTextViewerState, + modifier: Modifier, + ) = Text(paragraph.words.joinToString(" ") { it.segmentText }, modifier) + + @Composable + override fun Equation( + segment: MathSegment, + modifier: Modifier, + ) = Text(segment.segmentText, modifier) + + @Composable + override fun NostrEntity( + bech: String, + canPreview: Boolean, + quotesLeft: Int, + modifier: Modifier, + ) = Text(bech, modifier) + + @Composable + override fun QuotedEvent( + eventHex: String, + addedChars: String?, + canPreview: Boolean, + quotesLeft: Int, + modifier: Modifier, + ) = Text(addedChars?.let { "$eventHex$it" } ?: eventHex, modifier) + + @Composable + override fun UserMention( + userHex: String, + addedChars: String?, + modifier: Modifier, + ) = Text(addedChars?.let { "$userHex$it" } ?: userHex, modifier) + + @Composable + override fun Payment( + segment: Segment, + modifier: Modifier, + ) = Text(segment.segmentText, modifier) + + @Composable + override fun LinkPreview( + url: String, + modifier: Modifier, + ) = Text(url, modifier) + + @Composable + override fun RelayLink( + segment: Segment, + modifier: Modifier, + ) = Text(segment.segmentText, modifier) + + @Composable + override fun SecretMessage( + segment: SecretEmoji, + state: RichTextViewerState, + canPreview: Boolean, + quotesLeft: Int, + modifier: Modifier, + ) = Text(segment.segmentText, modifier) +} + +/** + * Deep-tree seam for platform-divergent segment rendering. Uses [compositionLocalOf] + * (not static) so a subtree can re-provide a variant — e.g. a compact renderer in a + * preview card — and only the readers under it recompose. + */ +val LocalRichTextSegmentRenderer = + compositionLocalOf { PlainTextSegmentRenderer } + +/** Universal activations for core-rendered segments. Static: it changes at the shell, rarely below. */ +val LocalRichTextInteractions = + staticCompositionLocalOf { RichTextInteractions() } 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 new file mode 100644 index 0000000000..62b91e2edf --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/RichTextViewer.kt @@ -0,0 +1,295 @@ +/* + * 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.commons.ui.richtext + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFontFamilyResolver +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.isSpecified +import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage +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.CashuSegment +import com.vitorpamplona.amethyst.commons.richtext.ClinkOfferSegment +import com.vitorpamplona.amethyst.commons.richtext.ConcordInviteLinkSegment +import com.vitorpamplona.amethyst.commons.richtext.EmailSegment +import com.vitorpamplona.amethyst.commons.richtext.EmojiSegment +import com.vitorpamplona.amethyst.commons.richtext.HashIndexEventSegment +import com.vitorpamplona.amethyst.commons.richtext.HashIndexUserSegment +import com.vitorpamplona.amethyst.commons.richtext.HashTagSegment +import com.vitorpamplona.amethyst.commons.richtext.ImageGalleryParagraph +import com.vitorpamplona.amethyst.commons.richtext.ImageSegment +import com.vitorpamplona.amethyst.commons.richtext.InvoiceSegment +import com.vitorpamplona.amethyst.commons.richtext.LinkSegment +import com.vitorpamplona.amethyst.commons.richtext.MathSegment +import com.vitorpamplona.amethyst.commons.richtext.NowhereLinkSegment +import com.vitorpamplona.amethyst.commons.richtext.ParagraphState +import com.vitorpamplona.amethyst.commons.richtext.PdfSegment +import com.vitorpamplona.amethyst.commons.richtext.PhoneSegment +import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment +import com.vitorpamplona.amethyst.commons.richtext.RelayGroupLinkSegment +import com.vitorpamplona.amethyst.commons.richtext.RelayUrlSegment +import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState +import com.vitorpamplona.amethyst.commons.richtext.SchemelessUrlSegment +import com.vitorpamplona.amethyst.commons.richtext.SecretEmoji +import com.vitorpamplona.amethyst.commons.richtext.Segment +import com.vitorpamplona.amethyst.commons.richtext.VideoSegment +import com.vitorpamplona.amethyst.commons.richtext.WithdrawSegment +import kotlinx.collections.immutable.ImmutableMap + +/** + * Cross-platform rich-text renderer. Owns everything identical on every front + * end — paragraph splitting, RTL, word layout, plain text, custom emoji, and + * hashtags — and delegates every platform-divergent segment to the + * [LocalRichTextSegmentRenderer] and universal activations to + * [LocalRichTextInteractions]. See [RichTextSegmentRenderer] for the rationale. + * + * Callers pass an already-parsed [RichTextViewerState] (the parser is pure and + * lives in `commons/richtext`), so this composable takes no account, navigation, + * or cache handle of its own — those enter through the two CompositionLocals the + * host provides. + */ +@Composable +fun RichTextViewer( + state: RichTextViewerState, + canPreview: Boolean, + quotesLeft: Int, + modifier: Modifier = Modifier, +) { + val renderer = LocalRichTextSegmentRenderer.current + val baseStyle = LocalTextStyle.current + val paragraphStyle = remember(baseStyle) { baseStyle.copy(lineHeight = 1.3.em) } + + Column(modifier) { + state.paragraphs.forEach { paragraph -> + val align = if (paragraph.isRTL) Alignment.End else Alignment.Start + if (paragraph is ImageGalleryParagraph) { + renderer.Gallery(paragraph, state, Modifier.align(align)) + } else { + CompositionLocalProvider( + LocalLayoutDirection provides if (paragraph.isRTL) LayoutDirection.Rtl else LayoutDirection.Ltr, + LocalTextStyle provides paragraphStyle, + ) { + RenderParagraph(paragraph, state, canPreview, quotesLeft, Modifier.align(align)) + } + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun RenderParagraph( + paragraph: ParagraphState, + state: RichTextViewerState, + canPreview: Boolean, + quotesLeft: Int, + modifier: Modifier, +) { + val spaceWidth = measureSpaceWidth(LocalTextStyle.current) + FlowRow( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(spaceWidth), + itemVerticalAlignment = Alignment.CenterVertically, + ) { + paragraph.words.forEach { word -> + RenderWord(word, state, canPreview, quotesLeft) + } + } +} + +@Composable +private fun RenderWord( + word: Segment, + state: RichTextViewerState, + canPreview: Boolean, + quotesLeft: Int, +) { + val renderer = LocalRichTextSegmentRenderer.current + val actions = LocalRichTextInteractions.current + + when (word) { + is RegularTextSegment -> Text(word.segmentText) + is EmojiSegment -> CustomEmojiText(word.segmentText, state.customEmoji, Modifier) + 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) } + + // Divergent media — presentation and CTA are platform-owned. + is ImageSegment, is VideoSegment, is PdfSegment, is Base64Segment, is BlossomUriSegment -> + renderer.Media(word, state, Modifier) + + is MathSegment -> renderer.Equation(word, Modifier) + + is LinkSegment -> + if (canPreview) { + renderer.LinkPreview(word.segmentText, Modifier) + } else { + ClickableSpan(word.segmentText) { actions.onOpenUrl(word.segmentText) } + } + + is SchemelessUrlSegment -> + ClickableSpan(word.segmentText) { actions.onOpenUrl("https://${word.segmentText}") } + is NowhereLinkSegment -> + ClickableSpan(word.segmentText) { actions.onOpenUrl(word.segmentText) } + + is RelayUrlSegment, is RelayGroupLinkSegment, is ConcordInviteLinkSegment -> + renderer.RelayLink(word, Modifier) + + is InvoiceSegment, is WithdrawSegment, is CashuSegment, is ClinkOfferSegment -> + renderer.Payment(word, Modifier) + + is HashIndexUserSegment -> renderer.UserMention(word.hex, word.extras, Modifier) + is HashIndexEventSegment -> renderer.QuotedEvent(word.hex, word.extras, canPreview, quotesLeft, Modifier) + is BechSegment -> renderer.NostrEntity(word.segmentText, canPreview, quotesLeft, Modifier) + is SecretEmoji -> renderer.SecretMessage(word, state, canPreview, quotesLeft, Modifier) + + // Unknown/other segments fall back to their raw text. + else -> Text(word.segmentText) + } +} + +@Composable +private fun ClickableSpan( + text: String, + onClick: () -> Unit, +) { + Text( + text = text, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.clickable(onClick = onClick), + ) +} + +/** + * A `#hashtag` chip in the theme's primary color. The icon variants Amethyst + * shows for known tags depend on an app-side icon table; until that table is + * shared this renders the text form on every platform. + */ +@Composable +private fun HashTagText( + segment: HashTagSegment, + onClick: () -> Unit, +) { + val text = remember(segment.segmentText) { "#${segment.hashtag}${segment.extras ?: ""}" } + Text( + text = text, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Medium, + modifier = Modifier.clickable(onClick = onClick), + ) +} + +/** + * Inline custom emoji: replaces each shortcode present in [emojis] with its image, + * leaving surrounding text intact. Universal to every front end, so it lives in + * the core rather than the platform seam. + */ +@Composable +private fun CustomEmojiText( + text: String, + emojis: ImmutableMap, + modifier: Modifier, +) { + if (emojis.isEmpty()) { + Text(text, modifier) + return + } + + val fontSize = LocalTextStyle.current.fontSize + val emojiSize = if (fontSize.isSpecified) fontSize else 16.sp + val inlineContent = HashMap() + + val annotated = + buildAnnotatedString { + var cursor = 0 + while (cursor < text.length) { + var bestIdx = -1 + var bestKey: String? = null + for (key in emojis.keys) { + val idx = text.indexOf(key, cursor) + if (idx >= 0 && (bestIdx == -1 || idx < bestIdx)) { + bestIdx = idx + bestKey = key + } + } + if (bestKey == null) { + append(text.substring(cursor)) + break + } + if (bestIdx > cursor) append(text.substring(cursor, bestIdx)) + + val url = emojis.getValue(bestKey) + inlineContent[bestKey] = + InlineTextContent( + Placeholder(emojiSize, emojiSize, PlaceholderVerticalAlign.Center), + ) { + AsyncImage(model = url, contentDescription = bestKey) + } + appendInlineContent(bestKey, bestKey) + cursor = bestIdx + bestKey.length + } + } + + Text(text = annotated, inlineContent = inlineContent, modifier = modifier) +} + +/** Width of a single space in [textStyle], used to space FlowRow words. */ +@Composable +fun measureSpaceWidth(textStyle: TextStyle): Dp { + val fontFamilyResolver = LocalFontFamilyResolver.current + val density = LocalDensity.current + val layoutDirection = LocalLayoutDirection.current + return remember(fontFamilyResolver, density, layoutDirection, textStyle) { + val widthPx = + TextMeasurer(fontFamilyResolver, density, layoutDirection, 1) + .measure(" ", textStyle) + .size + .width + with(density) { widthPx.toDp() } + } +}