mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
feat(commons): route url/email/phone through the platform strategy
Closes the last rich-text fidelity residual. url/email/phone were rendered generically by the shared core via RichTextInteractions callbacks, losing each front end's per-type styling and open behavior. They now go through the RichTextSegmentRenderer strategy: - Add Url(url, displayText)/Email(address)/Phone(number) to the contract (with plain-text defaults). - Core routes LinkSegment(no-preview)/SchemelessUrl -> Url, Email -> Email, Phone -> Phone; drop the in-core ClickableSpan. - Amethyst renders them with ClickableUrl/ClickableEmail/ClickablePhone (blossom intent + dial preserved); Desktop with ClickableLink / underlined mailto / plain phone text. - RichTextInteractions now carries only onClickHashtag (the one segment the core draws itself, with shared icons); the onOpen* callbacks are gone. Verified: :commons JVM, :desktopApp, :amethyst play debug compile. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
This commit is contained in:
+20
-6
@@ -32,7 +32,6 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.commons.richtext.BlossomUriSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
|
||||
@@ -178,6 +177,25 @@ class AmethystRichTextSegmentRenderer(
|
||||
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,
|
||||
@@ -266,13 +284,9 @@ fun CommonsBackedRichTextViewer(
|
||||
AmethystRichTextSegmentRenderer(accountViewModel, nav, backgroundColor, callbackUri, canPreview)
|
||||
}
|
||||
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val interactions =
|
||||
remember(nav, uriHandler) {
|
||||
remember(nav) {
|
||||
RichTextInteractions(
|
||||
onOpenUrl = { runCatching { uriHandler.openUri(it) } },
|
||||
onOpenEmail = { runCatching { uriHandler.openUri("mailto:$it") } },
|
||||
onOpenPhone = { runCatching { uriHandler.openUri("tel:$it") } },
|
||||
onClickHashtag = { nav.nav(Route.Hashtag(it.lowercase())) },
|
||||
)
|
||||
}
|
||||
|
||||
+49
-8
@@ -141,6 +141,32 @@ interface RichTextSegmentRenderer {
|
||||
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(
|
||||
@@ -168,18 +194,14 @@ interface RichTextSegmentRenderer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Presentation-agnostic activations for the segments the shared core renders
|
||||
* itself. The *action* is unambiguous on every platform (open a URL, dial a
|
||||
* number, jump to a hashtag); only how the trigger looks/feels differs, which is
|
||||
* a Modifier concern the core applies. Anything whose action itself diverges by
|
||||
* platform (a mention that navigates vs. pops a hover-card) belongs in
|
||||
* 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 onOpenUrl: (url: String) -> Unit = {},
|
||||
val onOpenEmail: (address: String) -> Unit = {},
|
||||
val onOpenPhone: (number: String) -> Unit = {},
|
||||
val onClickHashtag: (hashtag: String) -> Unit = {},
|
||||
)
|
||||
|
||||
@@ -244,6 +266,25 @@ object PlainTextSegmentRenderer : RichTextSegmentRenderer {
|
||||
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,
|
||||
|
||||
+7
-16
@@ -156,8 +156,11 @@ private fun RenderWord(
|
||||
is RegularTextSegment -> Text(word.segmentText)
|
||||
is EmojiSegment -> RenderCustomEmoji(word.segmentText, state.customEmoji)
|
||||
is HashTagSegment -> HashTagText(word) { actions.onClickHashtag(word.hashtag) }
|
||||
is EmailSegment -> ClickableSpan(word.segmentText) { actions.onOpenEmail(word.segmentText) }
|
||||
is PhoneSegment -> ClickableSpan(word.segmentText) { actions.onOpenPhone(word.segmentText) }
|
||||
|
||||
// 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 ->
|
||||
@@ -169,11 +172,11 @@ private fun RenderWord(
|
||||
if (canPreview) {
|
||||
renderer.LinkPreview(word.segmentText, Modifier)
|
||||
} else {
|
||||
ClickableSpan(word.segmentText) { actions.onOpenUrl(word.segmentText) }
|
||||
renderer.Url(word.segmentText, word.segmentText, Modifier)
|
||||
}
|
||||
|
||||
is SchemelessUrlSegment ->
|
||||
ClickableSpan(word.segmentText) { actions.onOpenUrl("https://${word.segmentText}") }
|
||||
renderer.Url("https://${word.segmentText}", word.segmentText, Modifier)
|
||||
is NowhereLinkSegment -> renderer.NowhereLink(word, canPreview, Modifier)
|
||||
|
||||
is RelayUrlSegment, is RelayGroupLinkSegment, is ConcordInviteLinkSegment ->
|
||||
@@ -192,18 +195,6 @@ private fun RenderWord(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ClickableSpan(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
)
|
||||
}
|
||||
|
||||
private val HashtagIconPlaceholder =
|
||||
Placeholder(width = 17.sp, height = 17.sp, placeholderVerticalAlign = PlaceholderVerticalAlign.Center)
|
||||
|
||||
|
||||
+38
-15
@@ -125,21 +125,6 @@ fun DesktopRichText(
|
||||
val interactions =
|
||||
remember(callbacks) {
|
||||
RichTextInteractions(
|
||||
onOpenUrl = {
|
||||
runCatching {
|
||||
java.awt.Desktop
|
||||
.getDesktop()
|
||||
.browse(URI(it))
|
||||
}
|
||||
},
|
||||
onOpenEmail = {
|
||||
runCatching {
|
||||
java.awt.Desktop
|
||||
.getDesktop()
|
||||
.browse(URI("mailto:$it"))
|
||||
}
|
||||
},
|
||||
onOpenPhone = { },
|
||||
onClickHashtag = { callbacks.onHashtagClick?.invoke(it) },
|
||||
)
|
||||
}
|
||||
@@ -319,6 +304,44 @@ class DesktopRichTextSegmentRenderer(
|
||||
modifier: Modifier,
|
||||
) = ClickableLink(url, url)
|
||||
|
||||
@Composable
|
||||
override fun Url(
|
||||
url: String,
|
||||
displayText: String,
|
||||
modifier: Modifier,
|
||||
) = ClickableLink(url, displayText)
|
||||
|
||||
@Composable
|
||||
override fun Email(
|
||||
address: String,
|
||||
modifier: Modifier,
|
||||
) = Text(
|
||||
text = address,
|
||||
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:$address"))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@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,
|
||||
|
||||
Reference in New Issue
Block a user