Skip to content

Commit 5dc1d20

Browse files
authored
feat(chat): add rich text support (starting with clickable URLs) (#981)
1 parent 375362e commit 5dc1d20

4 files changed

Lines changed: 159 additions & 1 deletion

File tree

apps/flipcash/shared/chat-ui/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,6 @@ dependencies {
1818
implementation(libs.androidx.paging.runtime)
1919
implementation(libs.compose.paging)
2020
implementation(project(":apps:flipcash:shared:theme"))
21+
22+
testImplementation(libs.robolectric)
2123
}

apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@ import androidx.compose.ui.graphics.Shape
3232
import androidx.compose.ui.platform.LocalContext
3333
import androidx.compose.ui.platform.LocalInspectionMode
3434
import androidx.compose.ui.res.stringResource
35+
import androidx.compose.ui.text.SpanStyle
3536
import androidx.compose.ui.text.font.FontWeight
37+
import androidx.compose.ui.text.style.TextDecoration
3638
import androidx.compose.ui.tooling.preview.Preview
3739
import androidx.compose.ui.tooling.preview.PreviewWrapper
3840
import androidx.compose.ui.unit.Dp
@@ -113,9 +115,17 @@ private fun TextBubble(
113115
modifier: Modifier = Modifier,
114116
) {
115117
Bubble(isFromSelf, position, maxWidth, modifier) {
118+
val linkStyle = SpanStyle(
119+
color = CodeTheme.colors.textMain,
120+
textDecoration = TextDecoration.Underline,
121+
)
122+
val richText = rememberRichText(
123+
text = text,
124+
annotators = listOf(UrlAnnotator(linkStyle)),
125+
)
116126
SelectionContainer {
117127
Text(
118-
text = text,
128+
text = richText,
119129
style = CodeTheme.typography.textMedium.copy(
120130
fontWeight = FontWeight.Medium
121131
),
@@ -380,6 +390,18 @@ private fun Preview_TextBubble_Incoming() {
380390
)
381391
}
382392

393+
@Preview
394+
@PreviewWrapper(FlipcashThemeWrapper::class)
395+
@Composable
396+
private fun Preview_TextBubble_WithLink() {
397+
TextBubble(
398+
text = "Check out https://flipcash.app for more info!",
399+
isFromSelf = false,
400+
position = BubblePosition.Solo,
401+
maxWidth = 300.dp,
402+
)
403+
}
404+
383405
@Preview
384406
@PreviewWrapper(FlipcashThemeWrapper::class)
385407
@Composable
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package com.flipcash.shared.chat.ui
2+
3+
import android.util.Patterns
4+
import androidx.compose.runtime.Composable
5+
import androidx.compose.runtime.remember
6+
import androidx.compose.ui.text.AnnotatedString
7+
import androidx.compose.ui.text.LinkAnnotation
8+
import androidx.compose.ui.text.SpanStyle
9+
import androidx.compose.ui.text.TextLinkStyles
10+
import androidx.compose.ui.text.buildAnnotatedString
11+
12+
fun interface SpanAnnotator {
13+
fun AnnotatedString.Builder.annotate(text: String)
14+
}
15+
16+
class UrlAnnotator(private val linkStyle: SpanStyle) : SpanAnnotator {
17+
override fun AnnotatedString.Builder.annotate(text: String) {
18+
val matcher = Patterns.WEB_URL.matcher(text)
19+
while (matcher.find()) {
20+
val url = matcher.group() ?: continue
21+
val resolved = if (!url.startsWith("http")) "https://$url" else url
22+
addLink(
23+
LinkAnnotation.Url(
24+
url = resolved,
25+
styles = TextLinkStyles(style = linkStyle),
26+
),
27+
start = matcher.start(),
28+
end = matcher.end(),
29+
)
30+
}
31+
}
32+
}
33+
34+
@Composable
35+
fun rememberRichText(
36+
text: String,
37+
annotators: List<SpanAnnotator>,
38+
): AnnotatedString = remember(text, annotators) {
39+
buildAnnotatedString {
40+
append(text)
41+
annotators.forEach { with(it) { annotate(text) } }
42+
}
43+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package com.flipcash.shared.chat.ui
2+
3+
import androidx.compose.ui.graphics.Color
4+
import androidx.compose.ui.text.LinkAnnotation
5+
import androidx.compose.ui.text.SpanStyle
6+
import androidx.compose.ui.text.buildAnnotatedString
7+
import androidx.compose.ui.text.style.TextDecoration
8+
import org.junit.runner.RunWith
9+
import org.robolectric.RobolectricTestRunner
10+
import org.robolectric.annotation.Config
11+
import kotlin.test.Test
12+
import kotlin.test.assertEquals
13+
import kotlin.test.assertTrue
14+
15+
@RunWith(RobolectricTestRunner::class)
16+
@Config(manifest = Config.NONE)
17+
class UrlAnnotatorTest {
18+
19+
private val linkStyle = SpanStyle(
20+
color = Color.Blue,
21+
textDecoration = TextDecoration.Underline,
22+
)
23+
private val annotator = UrlAnnotator(linkStyle)
24+
25+
private fun annotate(text: String) = buildAnnotatedString {
26+
append(text)
27+
with(annotator) { annotate(text) }
28+
}
29+
30+
@Test
31+
fun `annotates https URL at correct offsets`() {
32+
val text = "Visit https://example.com today"
33+
val result = annotate(text)
34+
35+
val links = result.getLinkAnnotations(0, result.length)
36+
assertEquals(1, links.size)
37+
38+
val link = links.first()
39+
assertEquals(6, link.start)
40+
assertEquals(25, link.end)
41+
42+
val annotation = link.item as LinkAnnotation.Url
43+
assertEquals("https://example.com", annotation.url)
44+
}
45+
46+
@Test
47+
fun `annotates http URL without adding https`() {
48+
val text = "Go to http://example.com now"
49+
val result = annotate(text)
50+
51+
val links = result.getLinkAnnotations(0, result.length)
52+
assertEquals(1, links.size)
53+
54+
val annotation = links.first().item as LinkAnnotation.Url
55+
assertEquals("http://example.com", annotation.url)
56+
}
57+
58+
@Test
59+
fun `prepends https for bare domain`() {
60+
val text = "Check example.com out"
61+
val result = annotate(text)
62+
63+
val links = result.getLinkAnnotations(0, result.length)
64+
assertEquals(1, links.size)
65+
66+
val annotation = links.first().item as LinkAnnotation.Url
67+
assertEquals("https://example.com", annotation.url)
68+
}
69+
70+
@Test
71+
fun `annotates multiple URLs`() {
72+
val text = "See https://a.com and https://b.com"
73+
val result = annotate(text)
74+
75+
val links = result.getLinkAnnotations(0, result.length)
76+
assertEquals(2, links.size)
77+
78+
val urls = links.map { (it.item as LinkAnnotation.Url).url }
79+
assertTrue("https://a.com" in urls)
80+
assertTrue("https://b.com" in urls)
81+
}
82+
83+
@Test
84+
fun `no annotations for plain text`() {
85+
val text = "Just a plain message"
86+
val result = annotate(text)
87+
88+
val links = result.getLinkAnnotations(0, result.length)
89+
assertTrue(links.isEmpty())
90+
}
91+
}

0 commit comments

Comments
 (0)