From 19f5065af45bff2b6a5d60b8a941d597695db9a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 22:24:27 +0000 Subject: [PATCH] refactor(commons): shared ClickableUrl/ClickableEmail; Desktop drops its duplicate Desktop's ClickableLink and Amethyst's ClickableUrl were near-identical: the only real differences are mouse-first styling (Desktop underlines + shows a hand cursor) and the open mechanism. But LocalUriHandler.openUri opens the browser on Android AND Desktop (and the mail client for mailto:), so the "open a link" logic never needed to be platform-specific. Add ClickableUrl/ClickableEmail to commons/ui/components on LocalUriHandler, with an `underline` flag so each front end keeps its exact look. Desktop's rich-text renderer now reuses them (underline = true) for url/email/link-preview/withdraw and its bespoke ClickableLink is deleted. Amethyst keeps its own blossom-intent- aware ClickableUrl (blossom never applies to plain links, so this path is equivalent); Phone stays platform-specific (Android dials, Desktop has no dialer). Verified: :commons JVM and :desktopApp compile. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V --- .../commons/ui/components/ClickableLinks.kt | 93 +++++++++++++++++++ .../desktop/ui/note/DesktopRichText.kt | 52 ++--------- 2 files changed, 100 insertions(+), 45 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableLinks.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableLinks.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableLinks.kt new file mode 100644 index 0000000000..beedb404fc --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableLinks.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow + +/** + * A primary-colored, single-line clickable URL that opens via the platform + * [LocalUriHandler] — the browser on Android, `Desktop.browse` on JVM — so the + * "open a link" behavior is identical on every front end without a seam. + * + * [underline] + the hover cursor are mouse-first affordances: Desktop passes + * `true`; touch front ends leave it `false`. A scheme-less [url] is opened as + * `https://`. + */ +@Composable +fun ClickableUrl( + url: String, + displayText: String = url, + modifier: Modifier = Modifier, + underline: Boolean = false, + style: TextStyle = LocalTextStyle.current, +) { + val uriHandler = LocalUriHandler.current + Text( + text = displayText, + style = style, + color = MaterialTheme.colorScheme.primary, + textDecoration = if (underline) TextDecoration.Underline else null, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + modifier = + modifier.pointerHoverIcon(PointerIcon.Hand).clickable { + runCatching { + uriHandler.openUri(if (url.contains("://")) url else "https://$url") + } + }, + ) +} + +/** + * A primary-colored clickable email that opens the platform mail client via + * [LocalUriHandler] (`mailto:`). Strips a leading `mailto:` from the display text. + * [underline] + hover cursor are the Desktop mouse-first affordances. + */ +@Composable +fun ClickableEmail( + address: String, + modifier: Modifier = Modifier, + underline: Boolean = false, +) { + val uriHandler = LocalUriHandler.current + val display = remember(address) { address.removePrefix("mailto:") } + Text( + text = display, + color = MaterialTheme.colorScheme.primary, + textDecoration = if (underline) TextDecoration.Underline else null, + modifier = + modifier.pointerHoverIcon(PointerIcon.Hand).clickable { + runCatching { uriHandler.openUri("mailto:$display") } + }, + ) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopRichText.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopRichText.kt index 9f70abdca0..62f67372bb 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopRichText.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopRichText.kt @@ -70,6 +70,8 @@ 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.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 @@ -204,7 +206,7 @@ class DesktopRichTextSegmentRenderer( contentScale = ContentScale.Fit, ) is PdfSegment -> RenderPdfCard(segment.segmentText) - else -> ClickableLink(segment.segmentText, segment.segmentText) + else -> ClickableUrl(segment.segmentText, underline = true) } } @@ -293,7 +295,7 @@ class DesktopRichTextSegmentRenderer( when (segment) { is InvoiceSegment -> RenderInvoiceCard(segment.segmentText, callbacks) is CashuSegment -> RenderCashuCard(segment.segmentText) - is WithdrawSegment -> ClickableLink(segment.segmentText, segment.segmentText) + is WithdrawSegment -> ClickableUrl(segment.segmentText, underline = true) else -> Text(segment.segmentText, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface) } } @@ -302,35 +304,20 @@ class DesktopRichTextSegmentRenderer( override fun LinkPreview( url: String, modifier: Modifier, - ) = ClickableLink(url, url) + ) = ClickableUrl(url, underline = true) @Composable override fun Url( url: String, displayText: String, modifier: Modifier, - ) = ClickableLink(url, displayText) + ) = ClickableUrl(url, displayText, underline = true) @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")) - } - }, - ) + ) = ClickableEmail(address, underline = true) @Composable override fun Phone( @@ -376,31 +363,6 @@ class DesktopRichTextSegmentRenderer( ) = RenderSecretEmoji(segment.segmentText) } -@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 private fun RenderBechSegment( segment: BechSegment,