mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 08:27:04 +00:00
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
This commit is contained in:
+64
@@ -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.
|
||||
+264
@@ -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<RichTextSegmentRenderer> { PlainTextSegmentRenderer }
|
||||
|
||||
/** Universal activations for core-rendered segments. Static: it changes at the shell, rarely below. */
|
||||
val LocalRichTextInteractions =
|
||||
staticCompositionLocalOf { RichTextInteractions() }
|
||||
+295
@@ -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<String, String>,
|
||||
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<String, InlineTextContent>()
|
||||
|
||||
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() }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user