Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions app/src/main/kotlin/tech/torlando/eridanus/ui/screens/ChatLinks.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// 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 `(?<![0-9a-fA-F])` stops a 32-char window from matching inside a
* longer hex run;
* - a bare 32-hex hash with **no** `:/path` is not matched — those are commonly
* pasted identity/destination hashes, not page links;
* - the path class excludes `)` `]` and the trailing `(?<![.,;:])` drops a
* sentence terminator the address bumps up against, so `(see …index.mu).` →
* `…index.mu`.
*
* Only fixed-length lookbehind is used and the character classes are spelled out
* rather than relying on `\w`/`(?U)`, so this compiles and behaves identically
* on Android's ICU regex engine and the desktop JVM (see [selfMentionRanges]
* for the same Android-regex caveat).
*/
internal val NOMADNET_ADDRESS =
Regex("""(?<![0-9a-fA-F])[0-9a-fA-F]{32}:/[^\s,;!?)\]]+(?<![.,;:])""")

/**
* Detect all web and NomadNet link spans in [text], resolving overlaps so the
* returned spans never collide.
*
* Resolution: earliest start wins; ties break toward the longer span. This
* gives a bare NomadNet address precedence over a stray `http`-shaped fragment
* nested inside its path, while a genuine `http://…` that merely *contains* a
* hash still wins because it starts first.
*
* Returned spans are ordered by start index.
*/
internal fun detectChatLinks(text: String): List<ChatLink> {
val candidates = ArrayList<ChatLink>()
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)
}
if (candidates.size <= 1) return candidates
Comment thread
torlando-tech marked this conversation as resolved.
Outdated
// 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.
candidates.sortWith(compareBy({ it.range.first }, { -it.range.last }))
val resolved = ArrayList<ChatLink>(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"
124 changes: 90 additions & 34 deletions app/src/main/kotlin/tech/torlando/eridanus/ui/screens/ChatScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -578,8 +585,10 @@ private fun MessageItem(message: ChatMessage, members: List<RoomMember>, 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 (`<hash>:/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
Expand All @@ -594,76 +603,123 @@ 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)
/**
* 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()
Comment thread
torlando-tech marked this conversation as resolved.
}
}

/** Trailing characters a URL run commonly grabs but that aren't part of it. */
/** Trailing characters a web URL run commonly grabs but that aren't part of it. */
private const val URL_TRAILING_TRIM = ".,;:!?)]}\"'"

/**
* 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<IntRange>,
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 rawUrl = link.text
val trimmed = rawUrl.trimEnd(*URL_TRAILING_TRIM.toCharArray())
withLink(LinkAnnotation.Url(url = trimmed, styles = linkStyles)) {
append(trimmed)
}
// Trailing punctuation we trimmed off the link stays plain text.
if (rawUrl.length > trimmed.length) append(rawUrl.substring(trimmed.length))
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))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// 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<String> =
detectChatLinks(text).map { it.text }

/** Matched (kind, substring) pairs, in order. */
private fun kinds(text: String): List<Pair<ChatLinkKind, String>> =
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<String>(), 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<String>(), 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))
}
}
Comment thread
torlando-tech marked this conversation as resolved.
Loading