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