diff --git a/.claude/hooks/pre-push-spotless.sh b/.claude/hooks/pre-push-spotless.sh index ff989f68a6..782d769d18 100755 --- a/.claude/hooks/pre-push-spotless.sh +++ b/.claude/hooks/pre-push-spotless.sh @@ -64,9 +64,13 @@ before="$(git diff HEAD -- '*.kt' '*.kts' 2>/dev/null | sha1sum)" log="$(mktemp /tmp/spotless-gate.XXXXXX.log)" if ! ./gradlew spotlessApply >"$log" 2>&1; then # Distinguish a formatting failure (block) from Gradle being unable to RUN — - # e.g. deps can't resolve in a restricted sandbox. An infra failure must not - # strand the agent; warn and let CI's spotlessCheck be the backstop. - if grep -qiE "could not resolve|could not (get|download)|handshake|connect timed out|no address|unable to (find|resolve) host|read timed out" "$log"; then + # e.g. deps can't resolve, or the wrapper can't even download the Gradle + # distribution, in a restricted sandbox. An infra failure must not strand the + # agent; warn and let CI's spotlessCheck be the backstop. The distribution + # patterns (the `gradle--bin.zip` URL, the Java download exception, and a + # proxy 40x on that fetch) only appear when `./gradlew` failed *before* running + # any task, so they can't mask a real formatting/compile error. + if grep -qiE "could not resolve|could not (get|download)|handshake|connect timed out|no address|unable to (find|resolve) host|read timed out|server returned http response code|gradle-[0-9][0-9.]*-(bin|all)\.zip|could not install gradle" "$log"; then echo "WARN: could not run spotlessApply (Gradle infra/network failure), skipping the formatting gate." >&2 echo " CI's spotlessCheck still enforces formatting on the PR." >&2 rm -f "$log" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 22d08179ab..6dac151043 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt index 116f26b199..31bba8c3fd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/AmethystRichText.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/AmethystRichText.kt new file mode 100644 index 0000000000..a9c5635992 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/AmethystRichText.kt @@ -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, + 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, + backgroundColor: MutableState, + 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, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt index b6016cf9e1..85c2d13b68 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt @@ -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) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 7d9e46150a..176b1aab3a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -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, - canPreview: Boolean, - quotesLeft: Int, - backgroundColor: MutableState, - 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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/PrefetchFeedMedia.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/PrefetchFeedMedia.kt index 27d1601cf0..abebeaaa13 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/PrefetchFeedMedia.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/PrefetchFeedMedia.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index 36ef874ded..c880e1ecc4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewState.kt index 09adafb3f7..ad97ee17c2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewState.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayUncitedHashtags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayUncitedHashtags.kt index 48e7fa591d..fa2e816825 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayUncitedHashtags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayUncitedHashtags.kt @@ -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 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 513e12f364..0adf33c233 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -310,7 +310,7 @@ Redeeming invite… Could not fetch this invite. The link may be expired or its relays unreachable. This invite link is invalid or can\'t be opened with this account. - This invite was created with a newer version of the app and can\'t be opened here yet. Ask for an updated link or try again after updating. + This invite link can\'t be opened. It may be outdated or already replaced by a newer one, or created with a newer version of the app. Ask for a fresh invite link. This invite link has been revoked and can no longer be used. Ask for a new one. Concord Channels You haven\'t joined any Concord Channels yet. Create one, or open an invite link. diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/CachedRichTextParserMarkdownTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/CachedRichTextParserMarkdownTest.kt index 366e7e322e..731a31cc5a 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/CachedRichTextParserMarkdownTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/CachedRichTextParserMarkdownTest.kt @@ -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 diff --git a/commons/plans/2026-07-16-note-ui-commons-extraction.md b/commons/plans/2026-07-16-note-ui-commons-extraction.md new file mode 100644 index 0000000000..e77a4b4ee9 --- /dev/null +++ b/commons/plans/2026-07-16-note-ui-commons-extraction.md @@ -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, + 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/...//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. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableLinks.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableLinks.kt new file mode 100644 index 0000000000..beedb404fc --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableLinks.kt @@ -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") } + }, + ) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/CustomEmojiRenderer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/CustomEmojiRenderer.kt new file mode 100644 index 0000000000..0b611c9958 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/CustomEmojiRenderer.kt @@ -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, + 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, + 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, + ) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/HashtagIcons.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/HashtagIcons.kt new file mode 100644 index 0000000000..14bda4d54e --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/HashtagIcons.kt @@ -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)) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/README.md b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/README.md new file mode 100644 index 0000000000..e6117be96d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/README.md @@ -0,0 +1,64 @@ +# commons/ui/richtext — shared rich-text rendering contract + +A **prototype** of one cross-platform rich-text renderer that Amethyst Android +(touch) and Amethyst Desktop (mouse-first) can both drive, replacing the two +current forks (`amethyst/ui/components/RichTextViewer.kt` ~1031 LOC and +`desktopApp/…/DesktopRichTextViewer.kt` ~745 LOC). + +## What lives here + +- **`RichTextViewer.kt`** — the shared skeleton. Owns everything identical on + every front end: paragraph splitting, RTL, FlowRow word layout, plain text, + inline custom emoji, and hashtags. Takes an already-parsed + `RichTextViewerState` (the parser is pure and already in `commons/richtext`); + it holds **no** account, nav, or cache handle. +- **`RichTextSegmentRenderer.kt`** — the seam. Two things the host provides: + - `RichTextSegmentRenderer` (via `LocalRichTextSegmentRenderer`) — one method + per **platform-divergent** segment (media, equation, quoted event, user + mention, payment, link preview, relay/invite chip, secret message). The + platform owns both the **visual** and the **call-to-action** for these. + - `RichTextInteractions` (via `LocalRichTextInteractions`) — plain callbacks + for segments whose **action is universal** and only the trigger styling + differs (open URL/email/phone, jump to hashtag). + +## Why this split (and not desktop's callback-only bag) + +The divergent segments differ in *two* ways at once between touch and mouse: the +**presentation** (a tap-to-zoom media pager vs. an inline image that opens in a +window) *and* the **call-to-action** (a bottom sheet vs. a popover; navigate on +tap vs. a hover-card). A callbacks-only contract assumes shared rendering + a +different click handler — which isn't true here — so the platform must own the +whole rendering of those segments. Hence a renderer strategy, not just callbacks. + +This is the same idiom the codebase already uses for inline quotes +(`LocalInlineQuoteRenderer`), generalised to every divergent segment: a +CompositionLocal set at the shell, read deep in the recursive tree, re-providable +per subtree — no parameter threading through 55+ call sites. + +**Feature parity, not presentation parity.** Desktop being simpler today is a +gap, not the design; it is expected to cover the same range over time, its own +mouse-first way. Every method has a plain-text default (`PlainTextSegmentRenderer`) +so an unimplemented kind degrades to readable text and the core stays usable from +previews, `commonTest`, and headless callers. + +## How each front end plugs in (next steps — not in this prototype) + +- **Android** (`amethyst`): implement `RichTextSegmentRenderer` by wrapping the + existing leaf composables (`ZoomableContentView`, `LoadUrlPreview`, + `CashuPreview`, `MayBeInvoicePreview`, `LatexEquation`, `BechLink` → + `LocalInlineQuoteRenderer`, …), closing over `AccountViewModel`/`INav`. The + Android `RichTextViewer` becomes a thin wrapper that parses the content, then + provides the two CompositionLocals and calls this shared core — keeping its + current signature so no call site changes. The flavor-specific + `TranslatableRichTextViewer` stays native and feeds the final string in. +- **Desktop** (`desktopApp`): implement the strategy with `AsyncImage` + + window-open for media, `RenderMarkdown` for markdown, popovers for payments, + hover-cards for mentions — then delete `DesktopRichTextViewer`. + +## Status + +Compiles in `:commons` (`compileCommonMainKotlinMetadata`). No consumer is wired +yet — this is the contract + skeleton for review before the per-platform +implementations and the fork deletions land. Known follow-ups: share the custom +emoji **icon** table (hashtag icons) and a `CreateTextWithEmoji` equivalent; +unify the two `CachedRichTextParser` forks so callers don't each parse. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/RichTextSegmentRenderer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/RichTextSegmentRenderer.kt new file mode 100644 index 0000000000..ac91226e82 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/RichTextSegmentRenderer.kt @@ -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 { PlainTextSegmentRenderer } + +/** Universal activations for core-rendered segments. Static: it changes at the shell, rarely below. */ +val LocalRichTextInteractions = + staticCompositionLocalOf { RichTextInteractions() } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/RichTextViewer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/RichTextViewer.kt new file mode 100644 index 0000000000..d89976b9b3 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/RichTextViewer.kt @@ -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() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/CachedRichTextParser.kt similarity index 89% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/CachedRichTextParser.kt index 5287ce9eb5..6f4b18c3fe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/CachedRichTextParser.kt @@ -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(500) - private val isMarkdownCache = LruCache(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(500) + private val isMarkdownCache = ConcurrentLruCache(200) private fun hashCodeCache( content: String, @@ -69,7 +75,7 @@ object CachedRichTextParser { tags: ImmutableListOfLists, 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>(10) + private val parsedUrlsCache = ConcurrentLruCache>(10) - fun cachedParseValidUrls(content: String): List = parsedUrlsCache[content.hashCode()] + fun cachedParseValidUrls(content: String): List? = parsedUrlsCache.get(content.hashCode()) fun parseValidUrls(content: String): List { if (content.isEmpty()) return emptyList() val key = content.hashCode() - val cached = parsedUrlsCache[key] + val cached = parsedUrlsCache.get(key) return if (cached != null) { cached } else { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/DesktopCachedRichTextParser.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/DesktopCachedRichTextParser.kt deleted file mode 100644 index 0d17dd3db0..0000000000 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/DesktopCachedRichTextParser.kt +++ /dev/null @@ -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(MAX_CACHE_SIZE) - - fun parseText( - content: String, - tags: ImmutableListOfLists, - 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("](") -} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopRichTextViewer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopRichText.kt similarity index 54% rename from desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopRichTextViewer.kt rename to desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopRichText.kt index 07f0b7ac51..62f67372bb 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopRichTextViewer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopRichText.kt @@ -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, -) { - 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", diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt index b23adb506c..adf0b732ca 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt @@ -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, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3d768731a6..7932dfbe55 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,7 +8,7 @@ accompanistAdaptive = "0.37.3" cachemapVersion = "0.2.4" composeMultiplatform = "1.11.1" activityCompose = "1.13.0" -agp = "9.2.1" +agp = "9.3.0" android-compileSdk = "37" android-minSdk = "26" android-targetSdk = "37" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 3e3f8c4b2c..f36e9b504c 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Wed Jan 04 09:23:50 EST 2023 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/cache/ConcurrentLruCache.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/cache/ConcurrentLruCache.kt index 94896edde8..eff79f0f92 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/cache/ConcurrentLruCache.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/cache/ConcurrentLruCache.kt @@ -87,5 +87,19 @@ class ConcurrentLruCache( } } + /** + * 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 } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/utils/cache/ConcurrentLruCacheTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/utils/cache/ConcurrentLruCacheTest.kt index 23a0ee7a66..1baa537dbf 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/utils/cache/ConcurrentLruCacheTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/utils/cache/ConcurrentLruCacheTest.kt @@ -100,6 +100,38 @@ class ConcurrentLruCacheTest { assertNull(cache.get("a")) } + @Test + fun `trimToSize evicts oldest down to the target`() { + val cache = ConcurrentLruCache(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(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(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