Merge pull request #3600 from vitorpamplona/claude/amethyst-note-ui-refactor-n36nvd

refactor: converge rich-text rendering across Android + Desktop into commons
This commit is contained in:
Vitor Pamplona
2026-07-16 19:49:51 -04:00
committed by GitHub
23 changed files with 2038 additions and 606 deletions
@@ -28,6 +28,7 @@ import coil3.disk.DiskCache
import coil3.memory.MemoryCache
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.relayClient.BlockedRelayFilteringClient
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStore
import com.vitorpamplona.amethyst.commons.service.lnurl.OkHttpLnurlEndpointResolver
@@ -51,7 +52,6 @@ import com.vitorpamplona.amethyst.model.torState.AccountsTorStateConnector
import com.vitorpamplona.amethyst.model.torState.TorRelayState
import com.vitorpamplona.amethyst.napplet.DataStoreNappletPermissionStore
import com.vitorpamplona.amethyst.napplet.DataStoreNostrSignerPermissionStore
import com.vitorpamplona.amethyst.service.CachedRichTextParser
import com.vitorpamplona.amethyst.service.calendar.CalendarReminderPrefs
import com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker
import com.vitorpamplona.amethyst.service.cast.CastRegistry
@@ -20,99 +20,12 @@
*/
package com.vitorpamplona.amethyst.model
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.hashtags.Amethyst
import com.vitorpamplona.amethyst.commons.hashtags.Btc
import com.vitorpamplona.amethyst.commons.hashtags.Cashu
import com.vitorpamplona.amethyst.commons.hashtags.Coffee
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
import com.vitorpamplona.amethyst.commons.hashtags.Flowerstr
import com.vitorpamplona.amethyst.commons.hashtags.Footstr
import com.vitorpamplona.amethyst.commons.hashtags.Gamestr
import com.vitorpamplona.amethyst.commons.hashtags.Grownostr
import com.vitorpamplona.amethyst.commons.hashtags.Lightning
import com.vitorpamplona.amethyst.commons.hashtags.Mate
import com.vitorpamplona.amethyst.commons.hashtags.Nostr
import com.vitorpamplona.amethyst.commons.hashtags.Plebs
import com.vitorpamplona.amethyst.commons.hashtags.Skull
import com.vitorpamplona.amethyst.commons.hashtags.Tunestr
import com.vitorpamplona.amethyst.commons.hashtags.Weed
import com.vitorpamplona.amethyst.commons.hashtags.Zap
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.richtext.HashTagSegment
import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment
import com.vitorpamplona.amethyst.ui.components.HashTag
import com.vitorpamplona.amethyst.ui.components.RenderRegular
import com.vitorpamplona.amethyst.ui.components.RenderTextParagraph
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.ui.richtext.HashtagIcon as CommonsHashtagIcon
import com.vitorpamplona.amethyst.commons.ui.richtext.checkForHashtagWithIcon as commonsCheckForHashtagWithIcon
@Preview
@Composable
fun RenderHashTagIconsPreview() {
ThemeComparisonColumn {
RenderRegular(
"Testing rendering of hashtags: #flowerstr #Bitcoin, #nostr, #lightning, #zap, #amethyst, #cashu, #plebs, #coffee, #skullofsatoshi, #grownostr, #footstr, #tunestr, #weed, #mate, #gamestr, #gamechain",
EmptyTagList,
) { paragraph, _, spaceWidth, modifier ->
RenderTextParagraph(paragraph, spaceWidth, modifier) { word ->
when (word) {
is HashTagSegment -> HashTag(word, EmptyNav())
is RegularTextSegment -> Text(word.segmentText)
}
}
}
}
}
// The hashtag-icon table now lives in commons/ui/richtext so the shared
// RichTextViewer and both front ends resolve the same icons. These re-exports keep
// the historical `com.vitorpamplona.amethyst.model` call sites working.
typealias HashtagIcon = CommonsHashtagIcon
fun checkForHashtagWithIcon(tag: String): HashtagIcon? =
when (tag.lowercase()) {
"₿itcoin", "bitcoin", "btc", "timechain", "bitcoiner", "bitcoiners" -> bitcoin
"nostr", "nostrich", "nostriches", "thenostr" -> nostr
"lightning", "lightningnetwork" -> lightning
"zap", "zaps", "zapper", "zappers", "zapping", "zapped", "zapathon", "zapraiser", "zaplife", "zapchain" -> zap
"amethyst" -> amethyst
"cashu", "ecash", "nut", "nuts", "deeznuts" -> cashu
"plebs", "pleb", "plebchain" -> plebs
"coffee", "coffeechain", "cafe" -> coffee
"skullofsatoshi" -> skull
"grownostr", "gardening", "garden" -> growstr
"footstr" -> footstr
"flowerstr" -> flowerstr
"tunestr", "music", "nowplaying" -> tunestr
"mate", "matechain", "matestr" -> matestr
"weed", "weedstr", "420", "cannabis", "marijuana" -> weed
"gamestr", "gaming", "gamechain" -> gamestr
else -> null
}
val bitcoin = HashtagIcon(CustomHashTagIcons.Btc, "Bitcoin", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val nostr = HashtagIcon(CustomHashTagIcons.Nostr, "Nostr", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val lightning = HashtagIcon(CustomHashTagIcons.Lightning, "Lightning", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val zap = HashtagIcon(CustomHashTagIcons.Zap, "Zap", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val amethyst = HashtagIcon(CustomHashTagIcons.Amethyst, "Amethyst", Modifier.padding(start = 2.dp, bottom = 1.dp, top = 1.dp))
val cashu = HashtagIcon(CustomHashTagIcons.Cashu, "Cashu", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val plebs = HashtagIcon(CustomHashTagIcons.Plebs, "Pleb", Modifier.padding(start = 2.dp, bottom = 1.dp, top = 1.dp))
val coffee = HashtagIcon(CustomHashTagIcons.Coffee, "Coffee", Modifier.padding(start = 3.dp, bottom = 1.dp, top = 1.dp))
val skull = HashtagIcon(CustomHashTagIcons.Skull, "SkullofSatoshi", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val growstr = HashtagIcon(CustomHashTagIcons.Grownostr, "GrowNostr", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val footstr = HashtagIcon(CustomHashTagIcons.Footstr, "Footstr", Modifier.padding(start = 2.dp, bottom = 1.dp, top = 1.dp))
val flowerstr = HashtagIcon(CustomHashTagIcons.Flowerstr, "Flowerstr", Modifier.padding(start = 2.dp, bottom = 1.dp, top = 1.dp))
val tunestr = HashtagIcon(CustomHashTagIcons.Tunestr, "Tunestr", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val weed = HashtagIcon(CustomHashTagIcons.Weed, "Weed", Modifier.padding(start = 1.dp, bottom = 0.dp, top = 0.dp))
val matestr = HashtagIcon(CustomHashTagIcons.Mate, "Mate", Modifier.padding(start = 1.dp, bottom = 0.dp, top = 0.dp))
val gamestr = HashtagIcon(CustomHashTagIcons.Gamestr, "GameStr", Modifier.padding(start = 1.dp, bottom = 0.dp, top = 0.dp))
@Immutable
class HashtagIcon(
val icon: ImageVector,
val description: String,
val modifier: Modifier = Modifier,
)
fun checkForHashtagWithIcon(tag: String): HashtagIcon? = commonsCheckForHashtagWithIcon(tag)
@@ -0,0 +1,308 @@
/*
* 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.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
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.CachedRichTextParser
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.HashIndexEventSegment
import com.vitorpamplona.amethyst.commons.richtext.HashIndexUserSegment
import com.vitorpamplona.amethyst.commons.richtext.ImageGalleryParagraph
import com.vitorpamplona.amethyst.commons.richtext.InvoiceSegment
import com.vitorpamplona.amethyst.commons.richtext.MathSegment
import com.vitorpamplona.amethyst.commons.richtext.NowhereLinkSegment
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.SecretEmoji
import com.vitorpamplona.amethyst.commons.richtext.Segment
import com.vitorpamplona.amethyst.commons.richtext.WithdrawSegment
import com.vitorpamplona.amethyst.commons.ui.richtext.LocalRichTextInteractions
import com.vitorpamplona.amethyst.commons.ui.richtext.LocalRichTextSegmentRenderer
import com.vitorpamplona.amethyst.commons.ui.richtext.RichTextInteractions
import com.vitorpamplona.amethyst.commons.ui.richtext.RichTextSegmentRenderer
import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.creators.invoice.ClinkOfferPreview
import com.vitorpamplona.amethyst.ui.note.creators.invoice.MayBeInvoicePreview
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding
import com.vitorpamplona.amethyst.commons.ui.richtext.RichTextViewer as CommonsRichTextViewer
/**
* The Android implementation of the shared [RichTextSegmentRenderer] contract: it
* wires each platform-divergent segment to Amethyst's existing touch-first leaf
* composables, closing over the [AccountViewModel], [INav], the parent
* [backgroundColor], the [callbackUri], and the [canPreview] mode that those
* leaves need but that the cross-platform contract deliberately keeps out of its
* method signatures.
*
* Recreate one per [CommonsBackedRichTextViewer] call (it captures per-call state);
* it is cheap and [remember]ed against its inputs so the CompositionLocal doesn't
* churn its readers.
*/
class AmethystRichTextSegmentRenderer(
private val accountViewModel: AccountViewModel,
private val nav: INav,
private val backgroundColor: MutableState<Color>,
private val callbackUri: String?,
private val canPreview: Boolean,
) : RichTextSegmentRenderer {
@Composable
override fun Media(
segment: Segment,
state: RichTextViewerState,
modifier: Modifier,
) {
if (segment is BlossomUriSegment) {
if (canPreview) {
BlossomUriRenderer(segment.segmentText, state, callbackUri, accountViewModel)
} else {
BlossomUriRendererNoPreview(segment.segmentText, accountViewModel)
}
return
}
if (canPreview) {
state.mediaForPager[segment.segmentText]?.let {
Box(HalfVertPadding) {
ZoomableContentView(
content = it,
images = state.mediaList,
roundedCorner = true,
contentScale = ContentScale.FillWidth,
accountViewModel = accountViewModel,
)
}
}
} else {
ClickableUrl(segment.segmentText, segment.segmentText)
}
}
@Composable
override fun Gallery(
paragraph: ImageGalleryParagraph,
state: RichTextViewerState,
modifier: Modifier,
) = ImageGallery(paragraph, state, accountViewModel, modifier, roundedCorner = true)
@Composable
override fun Equation(
segment: MathSegment,
modifier: Modifier,
) = LatexEquation(segment.latex, segment.displayMode, segment.leading, segment.trailing)
@Composable
override fun NostrEntity(
bech: String,
canPreview: Boolean,
quotesLeft: Int,
modifier: Modifier,
) = BechLink(bech, canPreview, quotesLeft, backgroundColor, accountViewModel, nav)
@Composable
override fun QuotedEvent(
eventHex: String,
addedChars: String?,
canPreview: Boolean,
quotesLeft: Int,
modifier: Modifier,
) {
val segment = remember(eventHex, addedChars) { HashIndexEventSegment(eventHex, eventHex, addedChars) }
TagLink(segment, canPreview, quotesLeft, backgroundColor, accountViewModel, nav)
}
@Composable
override fun UserMention(
userHex: String,
addedChars: String?,
modifier: Modifier,
) {
val segment = remember(userHex, addedChars) { HashIndexUserSegment(userHex, userHex, addedChars) }
TagLink(segment, accountViewModel, nav)
}
@Composable
override fun Payment(
segment: Segment,
modifier: Modifier,
) {
when (segment) {
// Matches the original no-preview switchboard: don't surface a pay/withdraw
// affordance when previews are suppressed — show the raw text instead.
is InvoiceSegment -> if (canPreview) MayBeInvoicePreview(segment.segmentText, accountViewModel) else Text(segment.segmentText)
is WithdrawSegment -> if (canPreview) MayBeWithdrawal(segment.segmentText, accountViewModel) else Text(segment.segmentText)
// Cashu + Clink decode locally (network only on tap), so they render in both modes.
is CashuSegment -> CashuPreview(segment.segmentText, accountViewModel)
is ClinkOfferSegment -> ClinkOfferPreview(segment.offer, accountViewModel, nav)
else -> Text(segment.segmentText)
}
}
@Composable
override fun LinkPreview(
url: String,
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,
modifier: Modifier,
) {
when (segment) {
is RelayUrlSegment -> ClickableRelayUrl(segment.segmentText, nav)
is RelayGroupLinkSegment ->
if (canPreview) {
RelayGroupCard(segment.segmentText, accountViewModel, nav)
} else {
ClickableRelayGroupLink(segment.segmentText, nav)
}
is ConcordInviteLinkSegment ->
if (canPreview) {
ConcordInviteCard(segment.segmentText, accountViewModel, nav)
} else {
ClickableConcordInviteLink(segment.segmentText, nav)
}
else -> Text(segment.segmentText)
}
}
@Composable
override fun NowhereLink(
segment: NowhereLinkSegment,
canPreview: Boolean,
modifier: Modifier,
) {
if (canPreview) {
NowhereLinkCard(segment)
} else {
ClickableUrl(segment.segmentText, segment.segmentText)
}
}
@Composable
override fun SecretMessage(
segment: SecretEmoji,
state: RichTextViewerState,
canPreview: Boolean,
quotesLeft: Int,
modifier: Modifier,
) = DisplaySecretEmoji(segment, state, callbackUri, canPreview, quotesLeft, backgroundColor, accountViewModel, nav)
}
/**
* Renders rich text through the shared cross-platform [CommonsRichTextViewer] core.
* This is the production plain-text path that [RichTextViewer] delegates to: it
* parses with the existing [CachedRichTextParser], keeps the markdown path native,
* and for plain rich text provides the Android segment renderer
* ([AmethystRichTextSegmentRenderer]) plus the universal interactions into the two
* CompositionLocals the core reads.
*
* The core renders text, custom emoji, and hashtags (with their shared inline
* icons) itself; every divergent segment routes back to Amethyst's existing
* leaves through the renderer. Note: url/email/phone open via the platform URI
* handler rather than Amethyst's per-type Clickable* composables, so their tap
* behavior is the generic open action (rendering is unchanged clickable text).
*/
@Composable
fun CommonsBackedRichTextViewer(
content: String,
canPreview: Boolean,
quotesLeft: Int,
modifier: Modifier,
tags: ImmutableListOfLists<String>,
backgroundColor: MutableState<Color>,
callbackUri: String? = null,
authorPubKey: String? = null,
accountViewModel: AccountViewModel,
nav: INav,
) {
Column(modifier = modifier) {
if (remember(content) { CachedRichTextParser.isMarkdown(content) }) {
RenderContentAsMarkdown(content, tags, canPreview, quotesLeft, backgroundColor, callbackUri, accountViewModel, nav)
return@Column
}
val state by remember(content, tags) {
mutableStateOf(CachedRichTextParser.parseText(content, tags, callbackUri, authorPubKey))
}
val renderer =
remember(accountViewModel, nav, backgroundColor, callbackUri, canPreview) {
AmethystRichTextSegmentRenderer(accountViewModel, nav, backgroundColor, callbackUri, canPreview)
}
val interactions =
remember(nav) {
RichTextInteractions(
onClickHashtag = { nav.nav(Route.Hashtag(it.lowercase())) },
)
}
CompositionLocalProvider(
LocalRichTextSegmentRenderer provides renderer,
LocalRichTextInteractions provides interactions,
) {
CommonsRichTextViewer(
state = state,
canPreview = canPreview,
quotesLeft = quotesLeft,
)
}
}
}
@@ -23,12 +23,18 @@ package com.vitorpamplona.amethyst.ui.components
import androidx.compose.material3.LocalTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextPrimary
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.openBlossomUriAsIntent
import com.vitorpamplona.amethyst.commons.ui.components.ClickableUrl as SharedClickableUrl
/**
* A clickable URL. Plain http(s) links delegate to the shared cross-platform
* [SharedClickableUrl] (opens via `LocalUriHandler`); only the Android-specific
* `blossom:` case — opening a Blossom media URI in an external app via an Intent —
* stays here. Touch front end, so no underline.
*/
@Composable
fun ClickableUrl(
urlText: String,
@@ -36,23 +42,16 @@ fun ClickableUrl(
style: TextStyle = LocalTextStyle.current,
onError: (Int, Int) -> Unit = { _, _ -> },
) {
val uri = LocalUriHandler.current
val context = LocalContext.current
ClickableTextPrimary(
text = urlText,
style = style,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
onClick = {
if (url.startsWith("blossom:")) {
openBlossomUriAsIntent(context, url, onError)
} else {
runCatching {
val doubleCheckedUrl = if (url.contains("://")) url else "https://$url"
uri.openUri(doubleCheckedUrl)
}
}
},
)
if (url.startsWith("blossom:")) {
val context = LocalContext.current
ClickableTextPrimary(
text = urlText,
style = style,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
onClick = { openBlossomUriAsIntent(context, url, onError) },
)
} else {
SharedClickableUrl(url = url, displayText = urlText, style = style)
}
}
@@ -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.CachedRichTextParser
import com.vitorpamplona.amethyst.commons.richtext.CashuSegment
import com.vitorpamplona.amethyst.commons.richtext.ClinkOfferSegment
import com.vitorpamplona.amethyst.commons.richtext.ConcordInviteLinkSegment
@@ -77,7 +78,6 @@ 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
@@ -103,13 +103,11 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
import com.vitorpamplona.amethyst.service.CachedRichTextParser
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserNickname
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.openBlossomUriAsIntent
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
@@ -129,6 +127,12 @@ import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
/**
* Renders Nostr rich text. The plain (non-markdown) path now runs through the
* cross-platform [com.vitorpamplona.amethyst.commons.ui.richtext.RichTextViewer]
* core via [CommonsBackedRichTextViewer]; this entry is kept so its ~55 call sites
* are untouched. The Android-specific markdown path stays here.
*/
@Composable
fun RichTextViewer(
content: String,
@@ -141,15 +145,18 @@ fun RichTextViewer(
authorPubKey: String? = null,
accountViewModel: AccountViewModel,
nav: INav,
) {
Column(modifier = modifier) {
if (remember(content) { CachedRichTextParser.isMarkdown(content) }) {
RenderContentAsMarkdown(content, tags, canPreview, quotesLeft, backgroundColor, callbackUri, accountViewModel, nav)
} else {
RenderRegular(content, tags, canPreview, quotesLeft, backgroundColor, callbackUri, authorPubKey, accountViewModel, nav)
}
}
}
) = CommonsBackedRichTextViewer(
content = content,
canPreview = canPreview,
quotesLeft = quotesLeft,
modifier = modifier,
tags = tags,
backgroundColor = backgroundColor,
callbackUri = callbackUri,
authorPubKey = authorPubKey,
accountViewModel = accountViewModel,
nav = nav,
)
@Preview
@Composable
@@ -338,57 +345,6 @@ fun RenderRegularPreview3() {
}
}
@Composable
private fun RenderRegular(
content: String,
tags: ImmutableListOfLists<String>,
canPreview: Boolean,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
callbackUri: String? = null,
authorPubKey: String? = null,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (canPreview) {
RenderRegular(content, tags, callbackUri, authorPubKey) { paragraph, state, spaceWidth, modifier ->
if (paragraph is ImageGalleryParagraph) {
ImageGallery(
images = paragraph,
state = state,
accountViewModel = accountViewModel,
modifier = modifier,
roundedCorner = true,
)
} else {
RenderTextParagraph(paragraph, spaceWidth, modifier) { word ->
RenderWordWithPreview(
word,
state,
backgroundColor,
quotesLeft,
callbackUri,
accountViewModel,
nav,
)
}
}
}
} else {
RenderRegular(content, tags, callbackUri, authorPubKey) { paragraph, state, spaceWidth, modifier ->
RenderTextParagraph(paragraph, spaceWidth, modifier) { word ->
RenderWordWithoutPreview(
word,
state,
backgroundColor,
accountViewModel,
nav,
)
}
}
}
}
@Composable
fun RenderTextParagraph(
paragraph: ParagraphState,
@@ -35,6 +35,7 @@ import coil3.imageLoader
import coil3.request.ImageRequest
import coil3.request.SuccessResult
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
@@ -44,7 +45,6 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import com.vitorpamplona.amethyst.service.CachedRichTextParser
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
@@ -69,11 +69,11 @@ import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
import com.vitorpamplona.amethyst.commons.hashtags.Cashu
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
import com.vitorpamplona.amethyst.commons.ui.components.AnimatedBorderTextCornerRadius
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.CachedRichTextParser
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserContactCardsScore
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPicture
@@ -21,7 +21,7 @@
package com.vitorpamplona.amethyst.ui.note.creators.previews
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.service.CachedUrlParser
import com.vitorpamplona.amethyst.commons.richtext.CachedUrlParser
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
@@ -27,9 +27,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
import com.vitorpamplona.amethyst.commons.richtext.HashTagSegment
import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextColor
import com.vitorpamplona.amethyst.service.CachedRichTextParser
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -20,7 +20,7 @@
*/
package com.vitorpamplona.amethyst
import com.vitorpamplona.amethyst.service.CachedRichTextParser
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -0,0 +1,390 @@
---
title: "refactor(commons): extract note-type rendering to commons"
type: refactor
status: proposed
date: 2026-07-16
owner: commons
consumers: amethyst, desktopApp, cli
---
# refactor(commons): extract `ui.note` rendering into commons
> **Status:** review / proposal. No code moved yet. This is the study
> requested on branch `claude/amethyst-note-ui-refactor` — a per-event
> generalization plan for pulling the *rendering* half of
> `com.vitorpamplona.amethyst.ui.note` down into `commons`, leaving only the
> nav/AccountViewModel-bound *entry* composables in the Android app.
> _Authored 2026-07-16._
## 0. Goal (as requested)
Leave the **entry composable** — the one that takes `AccountViewModel` + `INav`
and decodes the `Note`/`Event` — in the mobile app. Move the **actual
rendering** (the Compose layout, specific functions, and their local UI state)
into `commons`, where it takes plain values, immutable state objects, and
**lambda callbacks** instead of `AccountViewModel`/`INav`. The Android package
then shrinks to a thin adapter that wires the lambdas.
This is deliberately narrower than the two standing plans and complementary to
both:
- `commons/plans/2026-04-21-event-renderer.md` — a UI-agnostic
`RenderedEvent` data model. Ambitious, still "proposed", nothing built.
- `commons/plans/2026-05-30-amethyst-to-commons-migration.md` — the full app
migration; its §4 puts `ui/note` (184 files, ~50–70% shareable) behind the
**Account keystone (Phase A)**, which is untouched.
The key insight of *this* plan: **we do not need the Account keystone to start.**
The `Render → Display` split lets each event's layout move now, because the
`Display` half never touches `AccountViewModel` — the `Render` half (staying in
`amethyst`) does the account/relay work and hands down primitives + callbacks.
## 1. What the package looks like today
`amethyst/ui/note`: **214 files, ~48.7k LOC**.
- `types/` — **89 files, ~21.9k LOC**: one `RenderXxx` composable per Nostr
event kind (the dispatch targets).
- top level — 39 files, ~14.7k LOC: `NoteCompose.kt` (2180 LOC, the central
dispatcher), avatars, reaction/zap rows, dialogs, formatters.
- `creators/` (post editor), `elements/`, `buttons/`, `nip22Comments/`,
`share/` — mostly interactive, largely stays native (see §7).
### The dispatcher stays native
`NoteCompose.kt` → `RenderNoteRow()` is a giant `when (baseNote.event)` that
routes each kind to its `RenderXxx(...)`. This is the natural home of the
**entry** layer and should *not* move — it is inherently coupled to
`AccountViewModel`, `INav`, feed state, the quick-action menu, drag/swipe, and
report/block gating.
### A two-tier pattern is already emerging
Several types already split `RenderXxx(note, …)` (decodes the event) from a
`DisplayXxx(…decomposed params…)` (renders). `Highlight.kt` is the clearest:
`RenderHighlight(note, …)` → `DisplayHighlight(comment, highlight, context,
authorHex, url, postAddress, …)`. **But `DisplayHighlight` still takes
`accountViewModel` + `nav`.** The work of this plan is to push that boundary the
last mile so the `Display*` layer takes callbacks/state instead — then relocate
it to `commons`.
### The target already has a reference implementation
`commons/ui/note/StaticWebsiteCard.kt` is exactly the end-state shape: pure
value params, lambda slots (`onOpen: (() -> Unit)?`, `headerActions:
@Composable (() -> Unit)?`), commons `Res.string`, commons icons, Coil
`AsyncImage` — **zero `AccountViewModel`/`INav`**. Also already extracted into
`commons/ui/note/`: `CollapsedSpamNote`, `HeaderPill`, `QuietMark`,
`ReplyContext`, `ReplyToLabel`. Do not re-propose these — copy their convention.
## 2. The canonical signature (the generalization anchor)
Across `types/`, the entry composable is remarkably uniform. Parameter frequency
(public `@Composable` entry fns):
```
accountViewModel 198 note/baseNote 152 backgroundColor 71
nav 157 quotesLeft 41 makeItShort 38 canPreview 35
```
So the near-universal entry shape is:
```kotlin
@Composable
fun RenderXxx(
note: Note,
quotesLeft: Int, // recursion budget for embedded quotes
backgroundColor: MutableState<Color>,
makeItShort: Boolean, // compact feed vs full thread view
canPreview: Boolean, // expand link/media previews inline
accountViewModel: AccountViewModel,
nav: INav,
)
```
`backgroundColor`, `quotesLeft`, `makeItShort`, `canPreview` are **pure display
inputs** and travel straight into `commons` unchanged. Only `accountViewModel`
and `nav` need a seam.
## 3. The dependency surface to cross
### 3a. `AccountViewModel` — smaller behavioral surface than it looks
`accountViewModel` appears 198× in `types/`, but most are **pass-through** to
leaf composables, not direct calls. The actual method surface across the whole
`note` package, grouped:
- **Lookups** (resolve a hex/address to a `Note`/`User`): `getNoteIfExists`,
`getOrCreateAddressableNote`, `getUserIfExists`, `checkGetOrCreateUser`,
`loadParticipants`, `loadUsers`, `userProfile`. → **read through the commons
cache port, not a lambda** (see §3d). Async participant/user loads that need
relay round-trips still resolve on the entry side.
- **Config/settings**: `settings`, `zapAmountChoices`, `showSensitiveContent`,
`httpClientBuilder`, `nip`. → passed as plain values / a small settings holder.
- **Actions (write + signer)**: `zap`, `follow`/`unfollow`, `delete`, `hide`,
`reactToOrDelete`, `decrypt`, `launchSigner`, `addPublicBookmark`,
`muteThread`. → become **callback lambdas** (`onZap: () -> Unit`, …).
- **Predicates**: `isLoggedUser`, `isWriteable`, `isThreadMutedFor`. → plain
`Boolean` params.
- **Infra**: `toastManager`, `runOnIO`, `tempManualPaymentCache`. → stay native;
surfaced as callbacks (`onError: (msg) -> Unit`) where they cross the seam.
### 3b. `INav` — trivial
Already an interface; the migration plan §1 prescribes the answer: shared
composables take `onClick*` / `onNavigate` lambdas, not `INav`. Each nav target
in a `Display*` body becomes one lambda (`onClickUser: (User) -> Unit`,
`onClickNote`, `onClickHashtag`, …).
### 3c. `R.string` (52/89 type files) — solved pattern
Migrate the needed strings to `commons/.../composeResources/values/strings.xml`
and swap `stringResource(R.string.x)` → `stringResource(Res.string.x)`. No new
abstraction (see `StaticWebsiteCard`, which already does this). This is the
single biggest *mechanical* cost, but it is rote.
### 3d. Reads cross the seam via the **cache port**, not lambdas
The cache is already commons-shaped — we should read through it rather than
re-passing every lookup as a lambda:
- `LocalCache` is declared `object LocalCache : ILocalCache, ICacheProvider`
(`amethyst/model/LocalCache.kt:351`). The read ports `ICacheProvider` +
`ICacheEventStream` already live in `commons/model/cache/`.
- `Account` **already holds the cache as an injected instance**:
`Account.kt:379` `val cache: LocalCache`. So `account.cache` is a real path
today — and once `LocalCache` becomes a class (migration §8 end-state) it
becomes the correct *per-account* instance instead of a global singleton.
- **`IAccount` does not expose it yet** — it only carries
`privateZapsDecryptionCache`. **Proposed:** add `val cache: ICacheProvider`
to `IAccount` (commons), implemented by `Account` (it already has the field).
Two consequences for the split:
1. **`AccountViewModel`'s lookups currently bypass `account.cache`** and call the
`object LocalCache` singleton directly (`getNoteIfExists =
LocalCache.getNoteIfExists(hex)`, and ~30 more). Re-point these at
`account.cache.…` as part of the extraction — it's the same call, but through
the instance/port, which is what makes them commons-reachable and
multi-account-correct.
2. **Commons render code that needs a read takes the port, not a lambda.** A
`Display*` body that must resolve an embedded note/user depends on
`ICacheProvider` (or `IAccount`, for hide/mute predicates like
`isAcceptable`/`isHidden`) — both already commons-side. **Lambdas are then
reserved for nav and write/signer actions**, which genuinely can't move.
Prefer this over the "loader lambda" framing in §3a wherever the lookup is a
synchronous cache read.
### 3e. The **leaf-composable toolkit** — the real blocker
The render bodies don't just lay out boxes; they recursively call a handful of
heavy shared composables that *themselves* take `accountViewModel`/`nav`. Top
leaf deps in `types/`:
```
TranslatableRichTextViewer 55 observeNoteEvent 23 LoadNote 18
UserPicture 16 observeNote 15 LoadUser 13
NoteCompose 12 LoadDecryptedContent 6 ClickableUrl 6
```
Two sub-problems:
1. **`TranslatableRichTextViewer` is flavor-specific** (#1 dependency, 55 uses)
and cannot be naively moved. But the fix is not a per-call slot — it is a
one-time restructure of the rich-text stack (**§3f, Phase 0**). After that
restructure, nested/inline rich text in `Display*` bodies calls the **commons
renderer directly**; only the single top-level translatable post body still
routes through the native translation wrapper, supplied by the entry.
2. **`LoadNote`/`LoadUser`/`observe*` are relay-subscription + LocalCache
bound.** These live in `amethyst/service/relayClient/reqCommand/`. Per the
migration plan they stay native orchestration. **Resolution:** resolve on the
entry side and pass the loaded `Note`/`User` (or a small immutable snapshot)
down; or pass embedded-content as a slot the same way as rich text.
### 3f. Phase 0 — restructure the rich-text stack (gating prerequisite)
Rich text is the #1 dependency and blocks all of Tier 2. It is **not** a wall,
because the stack is already layered — it just needs the core decoupled:
```
TranslatableRichTextViewer flavor-specific (play=ML-Kit, fdroid=no-op) ← translation
└─ ExpandableRichTextViewer "show more/less" expansion ← middle
└─ RichTextViewer.kt (1031 LOC) paragraphs, URLs, blossom, ← RENDER CORE
custom emoji, bech links, hashtags, note/user embeds
```
Two facts make the core movable:
- **The translation *state* is already in commons** —
`commons/ui/components/TranslationConfig`. Only the *service*
(`service/lang/LanguageTranslatorService`, `TranslationsCache`, ML-Kit) is
Android. So translation was never really entangled with rendering; it wraps it.
- **The 1031-LOC core's real `accountViewModel` surface is only 5 members**
(despite 87 pass-through refs): `account` (for language settings only),
`bechLinkCache`, `checkGetOrCreateNote`, `getNoteIfExists`, `toastManager`.
Three are cache reads → the **`ICacheProvider` port (§3d)**; `toastManager` →
an `onError`/`onToast` callback; `nav` → `onClick*` callbacks.
**Restructure:**
1. Move `RichTextViewer` + `ExpandableRichTextViewer` to
`commons/ui/text/` (or `ui/components/`), taking `ICacheProvider` + nav/error
callbacks instead of `AccountViewModel`/`INav`. Language settings arrive as
plain values.
2. Leave `TranslatableRichTextViewer` native and thin: it computes the
`TranslationConfig` via the native service, renders the status bar (drivable
from the commons `TranslationConfig`), and calls the **commons** renderer with
the final string. The `src/play`↔`src/fdroid` flavor split stays exactly where
it is.
3. **The one edge that stays a slot:** `RichTextViewer` embeds *full notes*
inline (`DisplayFullNote`/`BechLink` → `NoteCompose`). That recurses into the
native dispatcher (§1), so full-note embeds take a
`renderEmbeddedNote: @Composable (Note) -> Unit` slot. Inline text, URLs,
emoji, hashtags, user mentions — everything else — moves cleanly.
**Payoff:** once the core is in commons, Tier 2's rich text is a direct call, not
a slot on every renderer. This is why Phase 0 gates the rest and should land
before scaling Tier 2. It also directly advances migration-plan §4's
"formatters → `ui/text`" line.
**This is the crux of the generalization:** the seam is not "primitives only"
(too limiting for content that embeds other notes/users), it is
**primitives + immutable state objects + `@Composable` slots for recursive
content + lambda callbacks for actions.** `StaticWebsiteCard`'s `headerActions`
slot is the pattern in miniature.
## 4. The proposed split contract
For each event kind, produce two composables:
**Entry (stays in `amethyst/ui/note/types/`), thin adapter:**
```kotlin
@Composable
fun RenderXxx(note, quotesLeft, backgroundColor, makeItShort, canPreview,
accountViewModel, nav) {
val event = note.event as? XxxEvent ?: return
// decode event → values; resolve users/notes via accountViewModel;
// build callbacks that close over accountViewModel + nav
XxxCard( // commons
title = event.title(), …,
makeItShort = makeItShort,
richText = { txt -> TranslatableRichTextViewer(txt, …, accountViewModel, nav) },
onClickAuthor = { nav.nav(routeFor(author)) },
onZap = { accountViewModel.zap(note, …) },
)
}
```
**Display (moves to `commons`), pure:**
```kotlin
@Composable
fun XxxCard(
title: String?, …, // decoded values
makeItShort: Boolean,
richText: @Composable (String) -> Unit, // slot for flavor-coupled leaf
onClickAuthor: () -> Unit, // nav callback
onZap: (() -> Unit)? = null, // action callback (null = hidden)
) { /* Compose layout only */ }
```
**Seam types to introduce in `commons/ui/note/`:**
- A small `@Immutable` snapshot per family where many primitives travel together
(e.g. `AuthorLine(name, pubkeyHex, avatarUrl, nip05)`), so signatures stay
sane. Prefer these over 15-arg functions.
- Reuse existing commons atoms: `UserAvatar`, `Nip05OrPubkeyLine`,
`ClickableTexts`, `RobohashImage`, `GenericLoadable`, the icon set, `Res`
strings. Several avatar/user-line needs are **already** in
`commons/ui/components`.
**Where files land** (per `commons/ARCHITECTURE.md` + migration §4): a
cross-cutting card → `commons/ui/note/`; a NIP-specific renderer → the owning
`commons/.../<nipNN>/ui/` package. Follow whatever the existing
`commons/ui/note/*` files already do.
## 5. Tiered categorization of the 89 `types/`
Classified by the seam each needs (method: `accountViewModel` refs, leaf-dep
grep, `launchSigner`/dialog grep).
### Tier 0 — no `AccountViewModel` at all (13 files, ~2.2k LOC) — **do first**
`ActivityCard, Birdex, CodeSnippet, EcashMint, GitDiffView, GitStatusPill,
ImetaContent, MusicFormatting, PodcastChips, PodcastSoundbites,
PodcastValueSplits, Ps1Save, RoadEvent`.
These are already value-in/Compose-out. Mostly just an `R.string` sweep + a
package move. **Ideal pilots** — several are also self-contained cards (no rich
text, no loaders).
### Tier 1 — decode-only, no loaders, no rich text
Read-only renderers that only need `nav` callbacks + `R.string` (e.g. relay/list
cards, badge display, goal/fundraiser headers, calendar/road cards). Split is
mechanical: decode in entry, pass primitives + `onClick*` down. Bulk of the
value.
### Tier 2 — need recursive content slots (rich text and/or embedded note/user)
23 use `TranslatableRichTextViewer`; 23 use `LoadNote/LoadUser/observe*`:
`Text, TextModification, Highlight, PublicMessage, PrivateMessage, Report,
Chat, CommunityHeader, FollowList, Git, InteractiveStory, MusicTrack,
MusicPlaylist, PodcastEpisode, PodcastMetadata, Video, PictureDisplay,
Attestation, NIP90ContentDiscoveryResponse, ZapPoll, AudioTrack, …`.
Movable, but **only via the `@Composable` slot pattern** (§3e/§4). The layout
moves to commons; rich-text/embeds stay app-supplied. `Highlight` is the
recommended Tier-2 pilot (already half-split).
### Tier 3 — interactive: signer / dialogs / write actions — **stay native or last**
5 use `launchSigner`: `AppDefinition, Badge, CalendarRsvpRow, GitStatusActions,
Poll`. Plus zap/reaction dialogs, `Poll`/`ZapPoll` voting, `Chess`. The
**visual card** can still move (as a Tier-2 body with `onVote`/`onSign`
callbacks), but the interaction/signer flow stays in `amethyst`. Don't force
these early.
## 6. Recommended sequencing
1. **Pilot (1 PR, Tier 0):** move 2–3 pure cards (e.g. `RoadEvent`, `EcashMint`,
`CodeSnippet`) to `commons/ui/note/`, migrate their strings, leave a thin
`RenderXxx` entry. Proves the string + package + preview-test loop end to end
and sets the reviewable template. Verify with `./gradlew :commons:build` and
a Compose `@Preview`.
2. **Bulk Tier 0/1** — mechanical, parallelizable, one small PR per event family.
These need neither rich text nor the cache port, so they don't wait on Phase 0.
3. **Phase 0 — rich-text restructure (§3f), the gate for Tier 2.** Move
`RichTextViewer`/`ExpandableRichTextViewer` to commons behind `ICacheProvider`
+ callbacks, with a `renderEmbeddedNote` slot for full-note embeds; leave
`TranslatableRichTextViewer` native and thin. Depends on adding
`IAccount.cache: ICacheProvider` (§3d). This is the load-bearing PR — review
it carefully before scaling.
4. **Tier 2 pilot:** `Highlight` → move `DisplayHighlight` to commons calling the
new commons renderer directly + `onClickAuthor`/`onClickNote` callbacks.
Confirms the post-Phase-0 shape.
5. **Tier 2 family by family** (podcast ×13, git ×5, calendar ×4, music ×3,
chat ×3) once the pilot is settled.
6. **Tier 3** last, and only the visual shell; leave signer flows native.
Each step is small, compile-verifiable, and deletes an app-side render body —
exactly the "manually, event by event" cadence you called for.
## 7. Out of scope (stays native)
- `NoteCompose.kt` dispatcher, feed/thread scaffolding, drag/swipe,
quick-action menu, report/block gating.
- `creators/` (post editor), `buttons/`, most of `share/` — interactive,
signer- and Context-bound.
- The flavor-specific `TranslatableRichTextViewer` **translation wrapper** and
the ML-Kit `service/lang/*` — stay native (the *renderer core* underneath them
moves; see §3f).
- The `service/relayClient/reqCommand` observers/loaders — resolved on the entry
side or consumed via slots, not moved.
## 8. Risks
- **Slot proliferation.** If every embed becomes a slot, entries get noisy.
Mitigate with per-family `@Immutable` snapshot types and by reusing existing
commons atoms rather than re-passing avatars/user-lines as slots.
- **String drift.** Moving strings piecemeal risks duplicate keys between
`amethyst` `R.string` and commons `Res.string`. Migrate + delete the app copy
in the same PR; don't leave both.
- **Preview coverage.** The `types/` files carry `@Preview`s (see
`Highlight.kt`). Move the previews with the body so commons keeps visual
regression cover; they also become desktop/cli-testable fixtures.
- **Desktop double-benefit vs. drift.** Desktop currently re-parses some of
this; extracting to commons is the chance to converge — but only if Desktop is
pointed at the new card in the same or an immediately following PR.
@@ -0,0 +1,93 @@
/*
* 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.components
import androidx.compose.foundation.clickable
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerIcon
import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
/**
* A primary-colored, single-line clickable URL that opens via the platform
* [LocalUriHandler] — the browser on Android, `Desktop.browse` on JVM — so the
* "open a link" behavior is identical on every front end without a seam.
*
* [underline] + the hover cursor are mouse-first affordances: Desktop passes
* `true`; touch front ends leave it `false`. A scheme-less [url] is opened as
* `https://`.
*/
@Composable
fun ClickableUrl(
url: String,
displayText: String = url,
modifier: Modifier = Modifier,
underline: Boolean = false,
style: TextStyle = LocalTextStyle.current,
) {
val uriHandler = LocalUriHandler.current
Text(
text = displayText,
style = style,
color = MaterialTheme.colorScheme.primary,
textDecoration = if (underline) TextDecoration.Underline else null,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
modifier =
modifier.pointerHoverIcon(PointerIcon.Hand).clickable {
runCatching {
uriHandler.openUri(if (url.contains("://")) url else "https://$url")
}
},
)
}
/**
* A primary-colored clickable email that opens the platform mail client via
* [LocalUriHandler] (`mailto:`). Strips a leading `mailto:` from the display text.
* [underline] + hover cursor are the Desktop mouse-first affordances.
*/
@Composable
fun ClickableEmail(
address: String,
modifier: Modifier = Modifier,
underline: Boolean = false,
) {
val uriHandler = LocalUriHandler.current
val display = remember(address) { address.removePrefix("mailto:") }
Text(
text = display,
color = MaterialTheme.colorScheme.primary,
textDecoration = if (underline) TextDecoration.Underline else null,
modifier =
modifier.pointerHoverIcon(PointerIcon.Hand).clickable {
runCatching { uriHandler.openUri("mailto:$display") }
},
)
}
@@ -0,0 +1,129 @@
/*
* 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.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
/**
* Inline custom emoji: renders [text], replacing each `:shortcode:` present in
* [emojis] with its image. Universal to every front end, so it lives in the
* shared core rather than the platform seam. Falls back to plain [Text] when the
* text carries no known emoji.
*
* Cross-platform equivalent of Amethyst's `CreateTextWithEmoji`, built on quartz's
* [CustomEmoji.assembleAnnotatedList] + [InLineIconRenderer].
*/
@Composable
fun RenderCustomEmoji(
text: String,
emojis: ImmutableMap<String, String>,
modifier: Modifier = Modifier,
) {
val renderable = remember(text, emojis) { CustomEmoji.assembleAnnotatedList(text, emojis) }
if (renderable.isNullOrEmpty()) {
Text(text, modifier)
} else {
InLineIconRenderer(renderable, LocalTextStyle.current.toSpanStyle(), modifier = modifier)
}
}
/**
* Renders an already-assembled list of [CustomEmoji.Renderable]s: text spans as
* text, image spans as inline [AsyncImage]s sized to ~1.1× the current font.
*/
@Composable
fun InLineIconRenderer(
wordsInOrder: ImmutableList<CustomEmoji.Renderable>,
style: SpanStyle,
fontSize: TextUnit = TextUnit.Unspecified,
modifier: Modifier = Modifier,
) {
val placeholderSize =
remember(fontSize) {
if (fontSize == TextUnit.Unspecified) 22.sp else fontSize.times(1.1f)
}
val inlineContent =
wordsInOrder
.mapIndexedNotNull { idx, value ->
if (value is CustomEmoji.ImageUrlType) {
"inlineContent$idx" to
InlineTextContent(
Placeholder(
width = placeholderSize,
height = placeholderSize,
placeholderVerticalAlign = PlaceholderVerticalAlign.Center,
),
) {
AsyncImage(
model = value.url,
contentDescription = null,
modifier = Modifier.fillMaxSize().padding(horizontal = 0.dp),
)
}
} else {
null
}
}.associate { it.first to it.second }
val annotatedText =
remember(wordsInOrder, style) {
buildAnnotatedString {
wordsInOrder.forEachIndexed { idx, value ->
withStyle(style) {
when (value) {
is CustomEmoji.TextType -> append(value.text)
is CustomEmoji.ImageUrlType -> appendInlineContent("inlineContent$idx", "[icon]")
else -> {}
}
}
}
}
}
Text(
text = annotatedText,
inlineContent = inlineContent,
fontSize = fontSize,
modifier = modifier,
)
}
@@ -0,0 +1,96 @@
/*
* 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.layout.padding
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.hashtags.Amethyst
import com.vitorpamplona.amethyst.commons.hashtags.Btc
import com.vitorpamplona.amethyst.commons.hashtags.Cashu
import com.vitorpamplona.amethyst.commons.hashtags.Coffee
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
import com.vitorpamplona.amethyst.commons.hashtags.Flowerstr
import com.vitorpamplona.amethyst.commons.hashtags.Footstr
import com.vitorpamplona.amethyst.commons.hashtags.Gamestr
import com.vitorpamplona.amethyst.commons.hashtags.Grownostr
import com.vitorpamplona.amethyst.commons.hashtags.Lightning
import com.vitorpamplona.amethyst.commons.hashtags.Mate
import com.vitorpamplona.amethyst.commons.hashtags.Nostr
import com.vitorpamplona.amethyst.commons.hashtags.Plebs
import com.vitorpamplona.amethyst.commons.hashtags.Skull
import com.vitorpamplona.amethyst.commons.hashtags.Tunestr
import com.vitorpamplona.amethyst.commons.hashtags.Weed
import com.vitorpamplona.amethyst.commons.hashtags.Zap
/**
* A custom vector icon rendered inline next to a well-known hashtag. The icon set
* itself already lives in `commons/hashtags`; this table is the mapping the shared
* [RichTextViewer] uses so every front end shows the same badge for `#bitcoin`,
* `#nostr`, `#zap`, etc.
*/
@Immutable
class HashtagIcon(
val icon: ImageVector,
val description: String,
val modifier: Modifier = Modifier,
)
/** The known-hashtag → [HashtagIcon] mapping (case-insensitive, includes common aliases). */
fun checkForHashtagWithIcon(tag: String): HashtagIcon? =
when (tag.lowercase()) {
"₿itcoin", "bitcoin", "btc", "timechain", "bitcoiner", "bitcoiners" -> bitcoin
"nostr", "nostrich", "nostriches", "thenostr" -> nostr
"lightning", "lightningnetwork" -> lightning
"zap", "zaps", "zapper", "zappers", "zapping", "zapped", "zapathon", "zapraiser", "zaplife", "zapchain" -> zap
"amethyst" -> amethyst
"cashu", "ecash", "nut", "nuts", "deeznuts" -> cashu
"plebs", "pleb", "plebchain" -> plebs
"coffee", "coffeechain", "cafe" -> coffee
"skullofsatoshi" -> skull
"grownostr", "gardening", "garden" -> growstr
"footstr" -> footstr
"flowerstr" -> flowerstr
"tunestr", "music", "nowplaying" -> tunestr
"mate", "matechain", "matestr" -> matestr
"weed", "weedstr", "420", "cannabis", "marijuana" -> weed
"gamestr", "gaming", "gamechain" -> gamestr
else -> null
}
val bitcoin = HashtagIcon(CustomHashTagIcons.Btc, "Bitcoin", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val nostr = HashtagIcon(CustomHashTagIcons.Nostr, "Nostr", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val lightning = HashtagIcon(CustomHashTagIcons.Lightning, "Lightning", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val zap = HashtagIcon(CustomHashTagIcons.Zap, "Zap", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val amethyst = HashtagIcon(CustomHashTagIcons.Amethyst, "Amethyst", Modifier.padding(start = 2.dp, bottom = 1.dp, top = 1.dp))
val cashu = HashtagIcon(CustomHashTagIcons.Cashu, "Cashu", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val plebs = HashtagIcon(CustomHashTagIcons.Plebs, "Pleb", Modifier.padding(start = 2.dp, bottom = 1.dp, top = 1.dp))
val coffee = HashtagIcon(CustomHashTagIcons.Coffee, "Coffee", Modifier.padding(start = 3.dp, bottom = 1.dp, top = 1.dp))
val skull = HashtagIcon(CustomHashTagIcons.Skull, "SkullofSatoshi", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val growstr = HashtagIcon(CustomHashTagIcons.Grownostr, "GrowNostr", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val footstr = HashtagIcon(CustomHashTagIcons.Footstr, "Footstr", Modifier.padding(start = 2.dp, bottom = 1.dp, top = 1.dp))
val flowerstr = HashtagIcon(CustomHashTagIcons.Flowerstr, "Flowerstr", Modifier.padding(start = 2.dp, bottom = 1.dp, top = 1.dp))
val tunestr = HashtagIcon(CustomHashTagIcons.Tunestr, "Tunestr", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val weed = HashtagIcon(CustomHashTagIcons.Weed, "Weed", Modifier.padding(start = 1.dp, bottom = 0.dp, top = 0.dp))
val matestr = HashtagIcon(CustomHashTagIcons.Mate, "Mate", Modifier.padding(start = 1.dp, bottom = 0.dp, top = 0.dp))
val gamestr = HashtagIcon(CustomHashTagIcons.Gamestr, "GameStr", Modifier.padding(start = 1.dp, bottom = 0.dp, top = 0.dp))
@@ -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.
@@ -0,0 +1,321 @@
/*
* 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.NowhereLinkSegment
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 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(
segment: Segment,
modifier: Modifier,
)
/** A "nowhere.ink" ephemeral-tool link — a card when previewing, a link otherwise. */
@Composable
fun NowhereLink(
segment: NowhereLinkSegment,
canPreview: Boolean,
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,
)
}
/**
* 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 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 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,
modifier: Modifier,
) = Text(segment.segmentText, modifier)
@Composable
override fun NowhereLink(
segment: NowhereLinkSegment,
canPreview: Boolean,
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() }
@@ -0,0 +1,262 @@
/*
* 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.Icon
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.graphics.Color
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.SpanStyle
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.em
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.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
/**
* 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 -> RenderCustomEmoji(word.segmentText, state.customEmoji)
is HashTagSegment -> HashTagText(word) { actions.onClickHashtag(word.hashtag) }
// 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 ->
renderer.Media(word, state, Modifier)
is MathSegment -> renderer.Equation(word, Modifier)
is LinkSegment ->
if (canPreview) {
renderer.LinkPreview(word.segmentText, Modifier)
} else {
renderer.Url(word.segmentText, word.segmentText, Modifier)
}
is SchemelessUrlSegment ->
renderer.Url("https://${word.segmentText}", word.segmentText, Modifier)
is NowhereLinkSegment -> renderer.NowhereLink(word, canPreview, Modifier)
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)
}
}
private val HashtagIconPlaceholder =
Placeholder(width = 17.sp, height = 17.sp, placeholderVerticalAlign = PlaceholderVerticalAlign.Center)
/**
* A `#hashtag` chip in the theme's primary color, with the shared inline icon for
* well-known tags (see [checkForHashtagWithIcon]). Any trailing punctuation glued
* to the tag ([HashTagSegment.extras]) renders in the normal text color.
*/
@Composable
private fun HashTagText(
segment: HashTagSegment,
onClick: () -> Unit,
) {
val primary = MaterialTheme.colorScheme.primary
val background = MaterialTheme.colorScheme.onBackground
val hashtagIcon = remember(segment.hashtag) { checkForHashtagWithIcon(segment.hashtag) }
val annotated =
remember(segment.segmentText, primary, background) {
buildAnnotatedString {
withStyle(SpanStyle(color = primary)) { append("#${segment.hashtag}") }
if (hashtagIcon != null) {
withStyle(SpanStyle(color = primary)) { appendInlineContent("inlineContent", "[icon]") }
}
segment.extras?.let { withStyle(SpanStyle(color = background)) { append(it) } }
}
}
Text(
text = annotated,
modifier = Modifier.clickable(onClick = onClick),
inlineContent =
if (hashtagIcon != null) {
mapOf(
"inlineContent" to
InlineTextContent(HashtagIconPlaceholder) {
Icon(
imageVector = hashtagIcon.icon,
contentDescription = hashtagIcon.description,
tint = Color.Unspecified,
modifier = hashtagIcon.modifier,
)
},
)
} else {
emptyMap()
},
)
}
/** 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() }
}
}
@@ -18,21 +18,27 @@
* 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.service
package com.vitorpamplona.amethyst.commons.richtext
import android.util.LruCache
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
import com.vitorpamplona.quartz.utils.cache.ConcurrentLruCache
/**
* The shared, cross-platform cache in front of [RichTextParser]. Both Amethyst
* Android and Amethyst Desktop render the same parsed [RichTextViewerState] from
* one place, so the same content quoted in multiple notes is only parsed once.
*
* Lives in `jvmAndroid` because it depends on [ConcurrentLruCache] (a JCA-free,
* lock-free-read LRU that is not in `commonMain`). iOS/`commonMain` callers use
* the uncached [RichTextParser] directly until a KMP cache is available.
*/
object CachedRichTextParser {
// Global across every feed. Sized to hold the active feed's visible + prefetched
// (see PrefetchFeedMedia) working set plus a few other feeds' recent entries, so
// pre-parsed bodies survive until the render reads them and feed switches don't
// thrash. Each entry is one note's parsed segments — typically single-digit KB.
private val richTextCache = LruCache<Int, RichTextViewerState>(500)
private val isMarkdownCache = LruCache<Int, Boolean>(200)
// working set plus a few other feeds' recent entries, so pre-parsed bodies survive
// until the render reads them and feed switches don't thrash. Each entry is one
// note's parsed segments — typically single-digit KB.
private val richTextCache = ConcurrentLruCache<Int, RichTextViewerState>(500)
private val isMarkdownCache = ConcurrentLruCache<Int, Boolean>(200)
private fun hashCodeCache(
content: String,
@@ -69,7 +75,7 @@ object CachedRichTextParser {
tags: ImmutableListOfLists<String>,
callbackUri: String? = null,
authorPubKey: String? = null,
): RichTextViewerState? = richTextCache[hashCodeCache(content, tags, callbackUri, authorPubKey)]
): RichTextViewerState? = richTextCache.get(hashCodeCache(content, tags, callbackUri, authorPubKey))
fun parseText(
content: String,
@@ -78,13 +84,13 @@ object CachedRichTextParser {
authorPubKey: String? = null,
): RichTextViewerState {
val key = hashCodeCache(content, tags, callbackUri, authorPubKey)
val cached = richTextCache[key]
val cached = richTextCache.get(key)
return if (cached != null) {
cached
} else {
val newUrls = RichTextParser().parseText(content, tags, callbackUri, authorPubKey)
richTextCache.put(key, newUrls)
newUrls
val newState = RichTextParser().parseText(content, tags, callbackUri, authorPubKey)
richTextCache.put(key, newState)
newState
}
}
@@ -92,7 +98,7 @@ object CachedRichTextParser {
// notes only pays for the scan once. The decision is purely a function of `content`.
fun isMarkdown(content: String): Boolean {
val key = content.hashCode()
isMarkdownCache[key]?.let { return it }
isMarkdownCache.get(key)?.let { return it }
val result = computeIsMarkdown(content)
isMarkdownCache.put(key, result)
return result
@@ -296,15 +302,15 @@ object CachedRichTextParser {
}
object CachedUrlParser {
private val parsedUrlsCache = LruCache<Int, List<String>>(10)
private val parsedUrlsCache = ConcurrentLruCache<Int, List<String>>(10)
fun cachedParseValidUrls(content: String): List<String> = parsedUrlsCache[content.hashCode()]
fun cachedParseValidUrls(content: String): List<String>? = parsedUrlsCache.get(content.hashCode())
fun parseValidUrls(content: String): List<String> {
if (content.isEmpty()) return emptyList()
val key = content.hashCode()
val cached = parsedUrlsCache[key]
val cached = parsedUrlsCache.get(key)
return if (cached != null) {
cached
} else {
@@ -1,54 +0,0 @@
/*
* 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.desktop.service
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState
import com.vitorpamplona.quartz.utils.cache.ConcurrentLruCache
object DesktopCachedRichTextParser {
private const val MAX_CACHE_SIZE = 50
// Lock-free get on the feed rich-text render path; the previous access-order
// synchronizedMap took a monitor even on reads.
private val cache = ConcurrentLruCache<String, RichTextViewerState>(MAX_CACHE_SIZE)
fun parseText(
content: String,
tags: ImmutableListOfLists<String>,
callbackUri: String? = null,
): RichTextViewerState {
cache.get(content)?.let { return it }
val state = RichTextParser().parseText(content, tags, callbackUri)
cache.put(content, state)
return state
}
fun isMarkdown(content: String): Boolean =
content.startsWith("> ") ||
content.startsWith("# ") ||
content.contains("##") ||
content.contains("__") ||
content.contains("**") ||
content.contains("```") ||
content.contains("](")
}
@@ -23,8 +23,6 @@ package com.vitorpamplona.amethyst.desktop.ui.note
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.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@@ -39,6 +37,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -58,41 +57,37 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
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.CachedRichTextParser
import com.vitorpamplona.amethyst.commons.richtext.CashuSegment
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.PdfSegment
import com.vitorpamplona.amethyst.commons.richtext.PhoneSegment
import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment
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 com.vitorpamplona.amethyst.commons.ui.components.ClickableEmail
import com.vitorpamplona.amethyst.commons.ui.components.ClickableUrl
import com.vitorpamplona.amethyst.commons.ui.markdown.RenderMarkdown
import com.vitorpamplona.amethyst.commons.ui.richtext.LocalRichTextInteractions
import com.vitorpamplona.amethyst.commons.ui.richtext.LocalRichTextSegmentRenderer
import com.vitorpamplona.amethyst.commons.ui.richtext.RichTextInteractions
import com.vitorpamplona.amethyst.commons.ui.richtext.RichTextSegmentRenderer
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.service.DesktopCachedRichTextParser
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import kotlinx.collections.immutable.ImmutableMap
import java.awt.Toolkit
import java.awt.datatransfer.StringSelection
import java.net.URI
import com.vitorpamplona.amethyst.commons.ui.richtext.RichTextViewer as CommonsRichTextViewer
data class RichTextCallbacks(
val onMentionClick: ((String) -> Unit)? = null,
@@ -102,335 +97,270 @@ data class RichTextCallbacks(
val onPayInvoice: ((String) -> Unit)? = null,
)
@OptIn(ExperimentalLayoutApi::class)
/**
* Renders Nostr rich text on Desktop through the shared cross-platform
* [CommonsRichTextViewer] core. Markdown stays on the native [RenderMarkdown]
* path; plain rich text is dispatched by the shared core, with every
* platform-divergent segment drawn mouse-first by [DesktopRichTextSegmentRenderer].
*
* Replaces the former hand-rolled `DesktopRichTextViewer` switchboard so Desktop
* and Android render the same segment model from one place.
*/
@Composable
fun DesktopRichTextViewer(
fun DesktopRichText(
content: String,
state: RichTextViewerState,
localCache: DesktopLocalCache? = null,
callbacks: RichTextCallbacks = RichTextCallbacks(),
modifier: Modifier = Modifier,
) {
if (DesktopCachedRichTextParser.isMarkdown(content)) {
if (CachedRichTextParser.isMarkdown(content)) {
RenderMarkdown(
content = content,
onLinkClick = { url ->
when {
url.startsWith("nostr:") -> {
val parsed = Nip19Parser.uriToRoute(url)
when (val entity = parsed?.entity) {
is NPub -> {
callbacks.onMentionClick?.invoke(entity.hex)
}
is NProfile -> {
callbacks.onMentionClick?.invoke(entity.hex)
}
is NNote -> {
callbacks.onNavigateToThread?.invoke(entity.hex)
}
is NEvent -> {
callbacks.onNavigateToThread?.invoke(entity.hex)
}
else -> {}
}
}
else -> {
runCatching {
java.awt.Desktop
.getDesktop()
.browse(URI(url))
}
}
}
},
onLinkClick = { url -> handleDesktopLinkClick(url, callbacks) },
modifier = modifier,
)
return
}
Column(modifier = modifier) {
for (paragraph in state.paragraphs) {
when (paragraph) {
is ImageGalleryParagraph -> {
val urls = paragraph.words.map { it.segmentText }
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth(),
) {
for ((index, segment) in paragraph.words.withIndex()) {
AsyncImage(
model = segment.segmentText,
contentDescription = null,
modifier =
Modifier
.weight(1f)
.heightIn(max = 300.dp)
.clip(MaterialTheme.shapes.small)
.then(
if (callbacks.onImageClick != null) {
Modifier.clickable { callbacks.onImageClick.invoke(urls, index) }
} else {
Modifier
},
),
contentScale = ContentScale.Crop,
)
}
}
}
val renderer = remember(localCache, callbacks) { DesktopRichTextSegmentRenderer(localCache, callbacks) }
val interactions =
remember(callbacks) {
RichTextInteractions(
onClickHashtag = { callbacks.onHashtagClick?.invoke(it) },
)
}
else -> {
val hasOnlyText = paragraph.words.all { it is RegularTextSegment }
if (hasOnlyText) {
Text(
text = paragraph.words.joinToString("") { it.segmentText },
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.fillMaxWidth(),
)
} else {
// RichTextParser splits paragraphs on ' ' so each segment is one
// space-delimited token; the source space lives BETWEEN segments,
// not within them. spacedBy(4.dp) restores that inter-word gap
// so mixed-content paragraphs (text + mention/hashtag/link) don't
// render as a wall of glued-together words.
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement =
Arrangement.spacedBy(
4.dp,
if (paragraph.isRTL) Alignment.End else Alignment.Start,
),
) {
for (word in paragraph.words) {
RenderSegment(word, state, localCache, callbacks)
}
}
}
}
CompositionLocalProvider(
LocalRichTextSegmentRenderer provides renderer,
LocalRichTextInteractions provides interactions,
) {
CommonsRichTextViewer(
state = state,
canPreview = true,
quotesLeft = 1,
modifier = modifier,
)
}
}
private fun handleDesktopLinkClick(
url: String,
callbacks: RichTextCallbacks,
) {
when {
url.startsWith("nostr:") -> {
val parsed = Nip19Parser.uriToRoute(url)
when (val entity = parsed?.entity) {
is NPub -> callbacks.onMentionClick?.invoke(entity.hex)
is NProfile -> callbacks.onMentionClick?.invoke(entity.hex)
is NNote -> callbacks.onNavigateToThread?.invoke(entity.hex)
is NEvent -> callbacks.onNavigateToThread?.invoke(entity.hex)
else -> {}
}
}
else -> {
runCatching {
java.awt.Desktop
.getDesktop()
.browse(URI(url))
}
}
}
}
@Composable
private fun RenderSegment(
segment: Segment,
state: RichTextViewerState,
localCache: DesktopLocalCache?,
callbacks: RichTextCallbacks,
) {
when (segment) {
is RegularTextSegment -> {
Text(
text = segment.segmentText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
is LinkSegment -> {
ClickableLink(segment.segmentText, segment.segmentText)
}
is SchemelessUrlSegment -> {
ClickableLink("https://${segment.segmentText}", segment.segmentText)
}
is BechSegment -> {
RenderBechSegment(segment, localCache, callbacks)
}
is HashTagSegment -> {
val display = "#${segment.hashtag}" + (segment.extras ?: "")
Text(
text = display,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
modifier =
Modifier
.pointerHoverIcon(PointerIcon.Hand)
.clickable { callbacks.onHashtagClick?.invoke(segment.hashtag) },
)
}
is HashIndexUserSegment -> {
val user = localCache?.getUserIfExists(segment.hex)
val display = "@${user?.toBestDisplayName() ?: segment.hex.take(8) + "..."}"
Text(
text = display + (segment.extras ?: ""),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
modifier =
Modifier
.pointerHoverIcon(PointerIcon.Hand)
.clickable { callbacks.onMentionClick?.invoke(segment.hex) },
)
}
is HashIndexEventSegment -> {
QuotedNoteEmbed(
noteId = segment.hex,
localCache = localCache,
onMentionClick = callbacks.onMentionClick,
onNavigateToThread = callbacks.onNavigateToThread,
)
}
is EmailSegment -> {
Text(
text = segment.segmentText,
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:${segment.segmentText}"))
}
},
)
}
is PhoneSegment -> {
Text(
text = segment.segmentText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
is RelayUrlSegment -> {
Text(
text = segment.segmentText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
modifier =
Modifier
.pointerHoverIcon(PointerIcon.Hand)
.clickable { copyToClipboard(segment.segmentText) },
)
}
is EmojiSegment -> {
RenderCustomEmojiSegment(segment.segmentText, state.customEmoji)
}
is NowhereLinkSegment -> {
RenderNowhereLinkCard(segment)
}
is InvoiceSegment -> {
RenderInvoiceCard(segment.segmentText, callbacks)
}
is CashuSegment -> {
RenderCashuCard(segment.segmentText)
}
is WithdrawSegment -> {
ClickableLink(segment.segmentText, segment.segmentText)
}
is BlossomUriSegment -> {
ClickableLink(segment.segmentText, segment.segmentText)
}
is PdfSegment -> {
RenderPdfCard(segment.segmentText)
}
is Base64Segment -> {
AsyncImage(
model = segment.segmentText,
contentDescription = null,
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = 300.dp)
.clip(MaterialTheme.shapes.small),
contentScale = ContentScale.Fit,
)
}
is ImageSegment -> {
AsyncImage(
model = segment.segmentText,
contentDescription = null,
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = 300.dp)
.clip(MaterialTheme.shapes.small),
contentScale = ContentScale.Fit,
)
}
is VideoSegment -> {
Text(
text = segment.segmentText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline,
modifier =
Modifier
.pointerHoverIcon(PointerIcon.Hand)
.clickable {
runCatching {
java.awt.Desktop
.getDesktop()
.browse(URI(segment.segmentText))
}
},
)
}
is SecretEmoji -> {
RenderSecretEmoji(segment.segmentText)
}
else -> {
Text(
text = segment.segmentText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
/**
* Desktop (mouse-first) implementation of the shared [RichTextSegmentRenderer].
* Each method draws a segment with the existing Desktop leaf composables in this
* file (and `NoteCard`), so Desktop keeps its own presentation and interaction
* while sharing the parse + dispatch with Android.
*/
class DesktopRichTextSegmentRenderer(
private val localCache: DesktopLocalCache?,
private val callbacks: RichTextCallbacks,
) : RichTextSegmentRenderer {
@Composable
override fun Media(
segment: Segment,
state: RichTextViewerState,
modifier: Modifier,
) {
when (segment) {
is ImageSegment, is Base64Segment ->
AsyncImage(
model = segment.segmentText,
contentDescription = null,
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = 300.dp)
.clip(MaterialTheme.shapes.small)
.then(
if (callbacks.onImageClick != null) {
Modifier.clickable { callbacks.onImageClick.invoke(listOf(segment.segmentText), 0) }
} else {
Modifier
},
),
contentScale = ContentScale.Fit,
)
is PdfSegment -> RenderPdfCard(segment.segmentText)
else -> ClickableUrl(segment.segmentText, underline = true)
}
}
}
@Composable
private fun ClickableLink(
url: String,
displayText: String,
) {
Text(
text = displayText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.pointerHoverIcon(PointerIcon.Hand)
.clickable {
runCatching {
java.awt.Desktop
.getDesktop()
.browse(URI(url))
}
},
@Composable
override fun Gallery(
paragraph: ImageGalleryParagraph,
state: RichTextViewerState,
modifier: Modifier,
) {
val urls = remember(paragraph) { paragraph.words.map { it.segmentText } }
Row(horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier.fillMaxWidth()) {
paragraph.words.forEachIndexed { index, segment ->
AsyncImage(
model = segment.segmentText,
contentDescription = null,
modifier =
Modifier
.weight(1f)
.heightIn(max = 300.dp)
.clip(MaterialTheme.shapes.small)
.then(
if (callbacks.onImageClick != null) {
Modifier.clickable { callbacks.onImageClick.invoke(urls, index) }
} else {
Modifier
},
),
contentScale = ContentScale.Crop,
)
}
}
}
@Composable
override fun Equation(
segment: MathSegment,
modifier: Modifier,
) = Text(segment.segmentText, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface)
@Composable
override fun NostrEntity(
bech: String,
canPreview: Boolean,
quotesLeft: Int,
modifier: Modifier,
) {
val segment = remember(bech) { BechSegment(bech) }
RenderBechSegment(segment, localCache, callbacks)
}
@Composable
override fun QuotedEvent(
eventHex: String,
addedChars: String?,
canPreview: Boolean,
quotesLeft: Int,
modifier: Modifier,
) = QuotedNoteEmbed(
noteId = eventHex,
localCache = localCache,
onMentionClick = callbacks.onMentionClick,
onNavigateToThread = callbacks.onNavigateToThread,
)
@Composable
override fun UserMention(
userHex: String,
addedChars: String?,
modifier: Modifier,
) {
val user = localCache?.getUserIfExists(userHex)
val display = "@${user?.toBestDisplayName() ?: (userHex.take(8) + "...")}"
Text(
text = display + (addedChars ?: ""),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.pointerHoverIcon(PointerIcon.Hand).clickable { callbacks.onMentionClick?.invoke(userHex) },
)
}
@Composable
override fun Payment(
segment: Segment,
modifier: Modifier,
) {
when (segment) {
is InvoiceSegment -> RenderInvoiceCard(segment.segmentText, callbacks)
is CashuSegment -> RenderCashuCard(segment.segmentText)
is WithdrawSegment -> ClickableUrl(segment.segmentText, underline = true)
else -> Text(segment.segmentText, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface)
}
}
@Composable
override fun LinkPreview(
url: String,
modifier: Modifier,
) = ClickableUrl(url, underline = true)
@Composable
override fun Url(
url: String,
displayText: String,
modifier: Modifier,
) = ClickableUrl(url, displayText, underline = true)
@Composable
override fun Email(
address: String,
modifier: Modifier,
) = ClickableEmail(address, underline = true)
@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,
modifier: Modifier,
) {
if (segment is RelayUrlSegment) {
Text(
text = segment.segmentText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.pointerHoverIcon(PointerIcon.Hand).clickable { copyToClipboard(segment.segmentText) },
)
} else {
Text(segment.segmentText, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface)
}
}
@Composable
override fun NowhereLink(
segment: NowhereLinkSegment,
canPreview: Boolean,
modifier: Modifier,
) = RenderNowhereLinkCard(segment)
@Composable
override fun SecretMessage(
segment: SecretEmoji,
state: RichTextViewerState,
canPreview: Boolean,
quotesLeft: Int,
modifier: Modifier,
) = RenderSecretEmoji(segment.segmentText)
}
@Composable
@@ -664,33 +594,6 @@ private fun RenderSecretEmoji(text: String) {
}
}
@Composable
private fun RenderCustomEmojiSegment(
word: String,
customEmoji: ImmutableMap<String, String>,
) {
val matchedEmoji = remember(word, customEmoji) { customEmoji.entries.firstOrNull { word.contains(it.key) } }
if (matchedEmoji != null) {
val parts = word.split(matchedEmoji.key, limit = 2)
Row(verticalAlignment = Alignment.CenterVertically) {
if (parts[0].isNotEmpty()) {
Text(parts[0], style = MaterialTheme.typography.bodyMedium)
}
AsyncImage(
model = matchedEmoji.value,
contentDescription = matchedEmoji.key,
modifier = Modifier.size(20.dp),
contentScale = ContentScale.Fit,
)
if (parts.size > 1 && parts[1].isNotEmpty()) {
Text(parts[1], style = MaterialTheme.typography.bodyMedium)
}
}
} else {
Text(word, style = MaterialTheme.typography.bodyMedium)
}
}
private val nowhereToolLabels =
mapOf(
"e" to "Nowhere Event",
@@ -56,12 +56,12 @@ import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
import com.vitorpamplona.amethyst.commons.ui.note.ReplyContext
import com.vitorpamplona.amethyst.commons.ui.note.ReplyToLabel
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.service.DesktopCachedRichTextParser
import com.vitorpamplona.amethyst.desktop.ui.components.ToggleableTimeAgoText
import com.vitorpamplona.amethyst.desktop.ui.media.AnimatedGifImage
import com.vitorpamplona.amethyst.desktop.ui.media.AudioPlayer
@@ -293,9 +293,9 @@ fun NoteCard(
if (strippedContent.isNotBlank()) {
val richState =
remember(strippedContent, note.tags) {
DesktopCachedRichTextParser.parseText(strippedContent, note.tags)
CachedRichTextParser.parseText(strippedContent, note.tags)
}
DesktopRichTextViewer(
DesktopRichText(
content = strippedContent,
state = richState,
localCache = localCache,
@@ -87,5 +87,19 @@ class ConcurrentLruCache<K : Any, V : Any>(
}
}
/**
* Evicts the oldest entries until at most [maxItems] remain. Used to shed
* memory on pressure (e.g. `onTrimMemory`). A [maxItems] of 0 or less clears
* the cache; values above the current size are a no-op.
*/
fun trimToSize(maxItems: Int) {
synchronized(writeLock) {
while (order.size > maxItems) {
val oldest = order.removeFirstOrNull() ?: break
map.remove(oldest)
}
}
}
fun size(): Int = map.size
}
@@ -100,6 +100,38 @@ class ConcurrentLruCacheTest {
assertNull(cache.get("a"))
}
@Test
fun `trimToSize evicts oldest down to the target`() {
val cache = ConcurrentLruCache<String, Int>(10)
cache.put("a", 1)
cache.put("b", 2)
cache.put("c", 3)
cache.trimToSize(1)
assertEquals(1, cache.size())
// Oldest (a, b) evicted; newest (c) kept.
assertNull(cache.get("a"))
assertNull(cache.get("b"))
assertEquals(3, cache.get("c"))
}
@Test
fun `trimToSize above current size is a no-op`() {
val cache = ConcurrentLruCache<String, Int>(10)
cache.put("a", 1)
cache.put("b", 2)
cache.trimToSize(5)
assertEquals(2, cache.size())
}
@Test
fun `trimToSize to zero clears`() {
val cache = ConcurrentLruCache<String, Int>(10)
cache.put("a", 1)
cache.trimToSize(0)
assertEquals(0, cache.size())
assertNull(cache.get("a"))
}
@Test
fun `size never exceeds capacity under concurrent puts`() {
val cap = 100