diff --git a/app/src/main/kotlin/tech/torlando/eridanus/ui/screens/ChatLinks.kt b/app/src/main/kotlin/tech/torlando/eridanus/ui/screens/ChatLinks.kt new file mode 100644 index 0000000..cd68cbf --- /dev/null +++ b/app/src/main/kotlin/tech/torlando/eridanus/ui/screens/ChatLinks.kt @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: MPL-2.0 + +package tech.torlando.eridanus.ui.screens + +/** + * Which kind of link a detected span is, so the renderer can wire the right tap + * behaviour: a [WEB] link opens in the system browser (via `LinkAnnotation.Url` + * → `LocalUriHandler`), while a [NOMADNET] address is handed to an installed + * NomadNet-capable app through an `ACTION_VIEW` intent. + */ +internal enum class ChatLinkKind { WEB, NOMADNET } + +/** + * A detected link span in a message body: the [range] (inclusive indices into + * the source text), the matched [text], and its [kind]. + */ +internal data class ChatLink(val range: IntRange, val text: String, val kind: ChatLinkKind) + +/** + * http(s) URLs. An explicit scheme is required so a bare domain — including the + * `.mu` tail of a NomadNet page path — is not mistaken for a web link. + */ +internal val WEB_URL_REGEX = Regex("""https?://[^\s]+""", RegexOption.IGNORE_CASE) + +/** + * A bare NomadNet page address: a 32-hex destination hash, then `:/`, then the + * page path — e.g. `9ce92808be498e9e05590ff27cbfdfe4:/page/index.mu`. There is + * **no** `nomadnetwork://` scheme on the wire; the address is written bare. The + * path run also captures any trailing micron field/query data appended after a + * backtick (`` `field=value|other=value ``), since `` ` ``, `=` and `|` are all + * permitted path characters. + * + * Deliberate boundaries: + * - the leading `(? { + val candidates = ArrayList() + NOMADNET_ADDRESS.findAll(text).forEach { + candidates += ChatLink(it.range, it.value, ChatLinkKind.NOMADNET) + } + WEB_URL_REGEX.findAll(text).forEach { + candidates += ChatLink(it.range, it.value, ChatLinkKind.WEB) + } + // Earliest start first; on equal start the longer span first. Then greedily + // keep a candidate only if it starts past the last one we kept. (This also + // covers the 0/1-candidate cases — the loop just yields a fresh empty or + // singleton list, rather than handing back the internal working list.) + candidates.sortWith(compareBy({ it.range.first }, { -it.range.last })) + val resolved = ArrayList(candidates.size) + var lastEnd = -1 + for (c in candidates) { + if (c.range.first <= lastEnd) continue + resolved += c + lastEnd = c.range.last + } + return resolved +} + +/** + * The browsable URI for a bare NomadNet [address] (as matched by + * [NOMADNET_ADDRESS]): the `nomadnetwork://` scheme is prepended so an + * `ACTION_VIEW` intent resolves to an installed NomadNet-capable app. The + * address — hash, path, and any backtick field tail — is passed through + * verbatim; the receiving app does its own parsing. + */ +internal fun toNomadNetUri(address: String): String = "nomadnetwork://$address" + +/** Trailing characters a web URL run commonly grabs but that aren't part of it. */ +internal const val URL_TRAILING_TRIM = ".,;:!?)]}\"'" + +/** + * Split a detected web [url] into the portion that should be linked and any + * trailing punctuation the greedy match grabbed, returned as `(link, trailing)`. + * The render layer links the first and appends the second as plain text — e.g. + * `"https://example.com."` links only `https://example.com` and shows the `.`. + * (NomadNet addresses need no trimming; their regex already excludes trailing + * punctuation.) + */ +internal fun splitWebUrlTrailing(url: String): Pair { + val link = url.trimEnd(*URL_TRAILING_TRIM.toCharArray()) + return link to url.substring(link.length) +} diff --git a/app/src/main/kotlin/tech/torlando/eridanus/ui/screens/ChatScreen.kt b/app/src/main/kotlin/tech/torlando/eridanus/ui/screens/ChatScreen.kt index 97c9ea6..26d8338 100644 --- a/app/src/main/kotlin/tech/torlando/eridanus/ui/screens/ChatScreen.kt +++ b/app/src/main/kotlin/tech/torlando/eridanus/ui/screens/ChatScreen.kt @@ -2,6 +2,11 @@ package tech.torlando.eridanus.ui.screens +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.widget.Toast import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -63,9 +68,11 @@ import androidx.compose.ui.input.key.isShiftPressed import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.LinkInteractionListener import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.TextRange @@ -578,8 +585,10 @@ private fun MessageItem(message: ChatMessage, members: List, selfNic /** * Message body text that (a) is selectable so the user can copy it (handy * for grabbing a pasted URL), (b) renders any http(s) links as tappable, - * underlined primary-colored spans that open in the system browser, and - * (c) renders self-mentions ([mentionRanges]) as a tinted chip. + * underlined primary-colored spans that open in the system browser, (c) renders + * bare NomadNet page addresses (`:/path.mu`) as tappable spans that hand + * off to an installed NomadNet app, and (d) renders self-mentions + * ([mentionRanges]) as a tinted chip. * * Tappable links and text selection coexisting in one [Text] is a Compose * 1.7 capability ([LinkAnnotation] inside a [SelectionContainer]); on 1.6 @@ -594,76 +603,119 @@ private fun LinkifiedText( val linkColor = MaterialTheme.colorScheme.primary val mentionBg = MaterialTheme.colorScheme.tertiaryContainer val mentionFg = MaterialTheme.colorScheme.onTertiaryContainer - val annotated = remember(text, linkColor, mentionRanges, mentionBg, mentionFg) { + val context = LocalContext.current + // A tap on a NomadNet link fires an ACTION_VIEW intent for its + // nomadnetwork:// URI, which an installed NomadNet-capable app (Columba, + // Sideband, …) handles; with none installed we toast instead of crashing. + // Web links need no listener — LinkAnnotation.Url routes through the + // LocalUriHandler to the platform browser. + val nomadnetListener = remember(context) { + LinkInteractionListener { link -> + (link as? LinkAnnotation.Clickable)?.tag?.let { openNomadNetLink(context, it) } + } + } + val annotated = remember(text, linkColor, mentionRanges, mentionBg, mentionFg, nomadnetListener) { val mentionStyle = SpanStyle( background = mentionBg, color = mentionFg, fontWeight = FontWeight.Medium, ) - buildMessageBody(text, linkColor, mentionRanges, mentionStyle) + buildMessageBody(text, linkColor, mentionRanges, mentionStyle, nomadnetListener) } SelectionContainer { - // LinkAnnotation.Url with no explicit listener routes taps through - // the LocalUriHandler, which opens the platform browser. Text(text = annotated, style = style) } } -private val URL_REGEX = Regex("""https?://[^\s]+""", RegexOption.IGNORE_CASE) - -/** Trailing characters a URL run commonly grabs but that aren't part of it. */ -private const val URL_TRAILING_TRIM = ".,;:!?)]}\"'" +/** + * Open a NomadNet [uri] (a `nomadnetwork://…` address) by handing it to whatever + * app on the device registers that scheme. Eridanus has no in-app NomadNet + * browser, so with no handler installed we show a toast rather than crash. + */ +private fun openNomadNetLink(context: Context, uri: String) { + try { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(uri))) + } catch (e: ActivityNotFoundException) { + Toast.makeText( + context, + "No NomadNet app installed to open this link", + Toast.LENGTH_SHORT, + ).show() + } +} /** - * Build an [AnnotatedString] for a message body, interleaving two kinds of - * styled run in a single left-to-right pass: + * Build an [AnnotatedString] for a message body, interleaving link and mention + * runs in a single left-to-right pass: * - each http(s) URL becomes a tappable [LinkAnnotation.Url] (underlined, - * [linkColor]); trailing punctuation grabbed by the match stays plain text; + * [linkColor]) that opens in the system browser; trailing punctuation grabbed + * by the match stays plain text; + * - each bare NomadNet address ([detectChatLinks]) becomes a tappable + * [LinkAnnotation.Clickable] carrying its `nomadnetwork://` URI, routed on tap + * to [nomadnetListener]; * - each range in [mentionRanges] is styled with [mentionStyle]. * - * A mention overlapping a URL is dropped (the URL wins, since it's tappable). - * Returns a plain string when there's nothing to style. + * A mention overlapping a link is dropped (the link wins, since it's tappable); + * web/NomadNet overlaps are already resolved by [detectChatLinks]. Returns a + * plain string when there's nothing to style. */ private fun buildMessageBody( text: String, linkColor: Color, mentionRanges: List, mentionStyle: SpanStyle, + nomadnetListener: LinkInteractionListener, ): AnnotatedString { - val urlMatches = URL_REGEX.findAll(text).toList() + val links = detectChatLinks(text) val mentions = mentionRanges.filter { mr -> - urlMatches.none { it.range.first <= mr.last && mr.first <= it.range.last } + links.none { it.range.first <= mr.last && mr.first <= it.range.last } } - if (urlMatches.isEmpty() && mentions.isEmpty()) return AnnotatedString(text) + if (links.isEmpty() && mentions.isEmpty()) return AnnotatedString(text) val linkStyles = TextLinkStyles( style = SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline), ) - // Neither URLs (non-overlapping by findAll) nor mentions overlap within - // their own kind, and overlapping mentions were filtered out above, so - // every special run has a distinct start index. - val urlByStart = urlMatches.associateBy { it.range.first } + // Links don't overlap (resolved in detectChatLinks) and mentions don't + // overlap each other; mentions overlapping a link were filtered out above, + // so every special run has a distinct start index. + val linkByStart = links.associateBy { it.range.first } val mentionByStart = mentions.associateBy { it.first } - val starts = (urlByStart.keys + mentionByStart.keys).sorted() + val starts = (linkByStart.keys + mentionByStart.keys).sorted() return buildAnnotatedString { var cursor = 0 for (start in starts) { if (start > cursor) append(text.substring(cursor, start)) - val url = urlByStart[start] - if (url != null) { - val rawUrl = url.value - val trimmed = rawUrl.trimEnd(*URL_TRAILING_TRIM.toCharArray()) - withLink(LinkAnnotation.Url(url = trimmed, styles = linkStyles)) { - append(trimmed) + val link = linkByStart[start] + when (link?.kind) { + ChatLinkKind.WEB -> { + val (linkUrl, trailing) = splitWebUrlTrailing(link.text) + withLink(LinkAnnotation.Url(url = linkUrl, styles = linkStyles)) { + append(linkUrl) + } + // Trailing punctuation the match grabbed stays plain text. + if (trailing.isNotEmpty()) append(trailing) + cursor = link.range.last + 1 + } + ChatLinkKind.NOMADNET -> { + // The matched address carries no trailing punctuation to + // trim (the regex excludes it). Tap → nomadnetListener. + withLink( + LinkAnnotation.Clickable( + tag = toNomadNetUri(link.text), + styles = linkStyles, + linkInteractionListener = nomadnetListener, + ), + ) { + append(link.text) + } + cursor = link.range.last + 1 + } + null -> { + val mr = mentionByStart.getValue(start) + withStyle(mentionStyle) { append(text.substring(mr.first, mr.last + 1)) } + cursor = mr.last + 1 } - // Trailing punctuation we trimmed off the link stays plain text. - if (rawUrl.length > trimmed.length) append(rawUrl.substring(trimmed.length)) - cursor = url.range.last + 1 - } else { - val mr = mentionByStart.getValue(start) - withStyle(mentionStyle) { append(text.substring(mr.first, mr.last + 1)) } - cursor = mr.last + 1 } } if (cursor < text.length) append(text.substring(cursor)) diff --git a/app/src/test/kotlin/tech/torlando/eridanus/ui/screens/ChatLinksTest.kt b/app/src/test/kotlin/tech/torlando/eridanus/ui/screens/ChatLinksTest.kt new file mode 100644 index 0000000..5894d78 --- /dev/null +++ b/app/src/test/kotlin/tech/torlando/eridanus/ui/screens/ChatLinksTest.kt @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: MPL-2.0 + +package tech.torlando.eridanus.ui.screens + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Pins down link detection in message bodies: bare NomadNet page addresses + * (`<32-hex>:/path.mu`, optionally with a backtick field tail) are linkified + * whole and kept distinct from http(s) URLs, while bare hashes and trailing + * punctuation are deliberately left out. + */ +class ChatLinksTest { + private val hash = "9ce92808be498e9e05590ff27cbfdfe4" + + /** Matched substrings, in order. */ + private fun texts(text: String): List = + detectChatLinks(text).map { it.text } + + /** Matched (kind, substring) pairs, in order. */ + private fun kinds(text: String): List> = + detectChatLinks(text).map { it.kind to it.text } + + @Test + fun bareNomadNetAddressIsLinkifiedWhole() { + val text = "$hash:/page/forum/register.mu" + assertEquals(listOf(ChatLinkKind.NOMADNET to text), kinds(text)) + } + + @Test + fun nomadNetAddressInsideSurroundingTextIsLinkifiedWhole() { + val addr = "$hash:/page/index.mu" + assertEquals(listOf(addr), texts("Verify at $addr to continue")) + } + + @Test + fun backtickFieldTailIsCapturedWithTheAddress() { + // The on-wire NomadNet link form: no scheme, hash:/path, then a + // backtick-delimited field/query tail with `=` and `|` separators. + val addr = "$hash:/page/index.mu`field1=value|field2=value" + assertEquals(listOf(ChatLinkKind.NOMADNET to addr), kinds("open $addr now")) + } + + @Test + fun trailingClosingParenIsExcluded() { + val addr = "$hash:/page/forum/register.mu" + assertEquals(listOf(addr), texts("(see $addr)")) + } + + @Test + fun trailingSentencePunctuationIsExcluded() { + val addr = "$hash:/page/index.mu" + assertEquals(listOf(addr), texts("go to $addr.")) + } + + @Test + fun bare32HexHashWithoutPathIsNotLinkified() { + // Commonly a pasted identity/destination hash — not a page link. + assertEquals(emptyList(), texts("my address is $hash ok")) + } + + @Test + fun hexRunLongerThan32IsNotMatched() { + // The leading lookbehind keeps a 32-char window from matching inside a + // longer hex run, so a 40-hex blob with a path is not a NomadNet link. + val long = hash + "0123456789abcdef0123" + assertEquals(emptyList(), texts("$long:/page/index.mu")) + } + + @Test + fun webUrlAndNomadNetAddressCoexistAsTwoSpans() { + val addr = "$hash:/page/index.mu" + assertEquals( + listOf( + ChatLinkKind.WEB to "https://example.com", + ChatLinkKind.NOMADNET to addr, + ), + kinds("web https://example.com and node $addr"), + ) + } + + @Test + fun httpUrlContainingAHashStaysOneWebLink() { + // A real http(s) URL wins by starting first; it isn't split into a + // separate NomadNet span even though its tail looks address-shaped. + val url = "https://example.com/$hash:/page/index.mu" + assertEquals(listOf(ChatLinkKind.WEB to url), kinds(url)) + } + + @Test + fun toNomadNetUriPrependsScheme() { + val addr = "$hash:/page/index.mu`a=b" + assertEquals("nomadnetwork://$addr", toNomadNetUri(addr)) + } + + @Test + fun webUrlTrailingPunctuationIsSplitOffForLinking() { + // "see https://example.com." → link "https://example.com", plain "." + assertEquals("https://example.com" to ".", splitWebUrlTrailing("https://example.com.")) + } + + @Test + fun webUrlTrailingCloserAndPeriodAreSplitOff() { + assertEquals("https://example.com/p" to ")." , splitWebUrlTrailing("https://example.com/p).")) + } + + @Test + fun webUrlWithoutTrailingPunctuationIsUnchanged() { + assertEquals("https://example.com/p" to "", splitWebUrlTrailing("https://example.com/p")) + } +}