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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
This commit is contained in:
Claude
2026-07-16 22:24:27 +00:00
parent 9eb827fed6
commit 19f5065af4
2 changed files with 100 additions and 45 deletions
@@ -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") }
},
)
}
@@ -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,