diff --git a/apps/flipcash/shared/chat-ui/build.gradle.kts b/apps/flipcash/shared/chat-ui/build.gradle.kts index 312c40105..441580f6e 100644 --- a/apps/flipcash/shared/chat-ui/build.gradle.kts +++ b/apps/flipcash/shared/chat-ui/build.gradle.kts @@ -18,4 +18,6 @@ dependencies { implementation(libs.androidx.paging.runtime) implementation(libs.compose.paging) implementation(project(":apps:flipcash:shared:theme")) + + testImplementation(libs.robolectric) } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index 7d7632e08..13112f693 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -32,7 +32,9 @@ import androidx.compose.ui.graphics.Shape import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper import androidx.compose.ui.unit.Dp @@ -113,9 +115,17 @@ private fun TextBubble( modifier: Modifier = Modifier, ) { Bubble(isFromSelf, position, maxWidth, modifier) { + val linkStyle = SpanStyle( + color = CodeTheme.colors.textMain, + textDecoration = TextDecoration.Underline, + ) + val richText = rememberRichText( + text = text, + annotators = listOf(UrlAnnotator(linkStyle)), + ) SelectionContainer { Text( - text = text, + text = richText, style = CodeTheme.typography.textMedium.copy( fontWeight = FontWeight.Medium ), @@ -380,6 +390,18 @@ private fun Preview_TextBubble_Incoming() { ) } +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_TextBubble_WithLink() { + TextBubble( + text = "Check out https://flipcash.app for more info!", + isFromSelf = false, + position = BubblePosition.Solo, + maxWidth = 300.dp, + ) +} + @Preview @PreviewWrapper(FlipcashThemeWrapper::class) @Composable diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/RichText.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/RichText.kt new file mode 100644 index 000000000..a6fad3dcc --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/RichText.kt @@ -0,0 +1,43 @@ +package com.flipcash.shared.chat.ui + +import android.util.Patterns +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.buildAnnotatedString + +fun interface SpanAnnotator { + fun AnnotatedString.Builder.annotate(text: String) +} + +class UrlAnnotator(private val linkStyle: SpanStyle) : SpanAnnotator { + override fun AnnotatedString.Builder.annotate(text: String) { + val matcher = Patterns.WEB_URL.matcher(text) + while (matcher.find()) { + val url = matcher.group() ?: continue + val resolved = if (!url.startsWith("http")) "https://$url" else url + addLink( + LinkAnnotation.Url( + url = resolved, + styles = TextLinkStyles(style = linkStyle), + ), + start = matcher.start(), + end = matcher.end(), + ) + } + } +} + +@Composable +fun rememberRichText( + text: String, + annotators: List, +): AnnotatedString = remember(text, annotators) { + buildAnnotatedString { + append(text) + annotators.forEach { with(it) { annotate(text) } } + } +} diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/UrlAnnotatorTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/UrlAnnotatorTest.kt new file mode 100644 index 000000000..b6be4eb55 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/UrlAnnotatorTest.kt @@ -0,0 +1,91 @@ +package com.flipcash.shared.chat.ui + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextDecoration +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class UrlAnnotatorTest { + + private val linkStyle = SpanStyle( + color = Color.Blue, + textDecoration = TextDecoration.Underline, + ) + private val annotator = UrlAnnotator(linkStyle) + + private fun annotate(text: String) = buildAnnotatedString { + append(text) + with(annotator) { annotate(text) } + } + + @Test + fun `annotates https URL at correct offsets`() { + val text = "Visit https://example.com today" + val result = annotate(text) + + val links = result.getLinkAnnotations(0, result.length) + assertEquals(1, links.size) + + val link = links.first() + assertEquals(6, link.start) + assertEquals(25, link.end) + + val annotation = link.item as LinkAnnotation.Url + assertEquals("https://example.com", annotation.url) + } + + @Test + fun `annotates http URL without adding https`() { + val text = "Go to http://example.com now" + val result = annotate(text) + + val links = result.getLinkAnnotations(0, result.length) + assertEquals(1, links.size) + + val annotation = links.first().item as LinkAnnotation.Url + assertEquals("http://example.com", annotation.url) + } + + @Test + fun `prepends https for bare domain`() { + val text = "Check example.com out" + val result = annotate(text) + + val links = result.getLinkAnnotations(0, result.length) + assertEquals(1, links.size) + + val annotation = links.first().item as LinkAnnotation.Url + assertEquals("https://example.com", annotation.url) + } + + @Test + fun `annotates multiple URLs`() { + val text = "See https://a.com and https://b.com" + val result = annotate(text) + + val links = result.getLinkAnnotations(0, result.length) + assertEquals(2, links.size) + + val urls = links.map { (it.item as LinkAnnotation.Url).url } + assertTrue("https://a.com" in urls) + assertTrue("https://b.com" in urls) + } + + @Test + fun `no annotations for plain text`() { + val text = "Just a plain message" + val result = annotate(text) + + val links = result.getLinkAnnotations(0, result.length) + assertTrue(links.isEmpty()) + } +}