Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 2 additions & 0 deletions apps/flipcash/shared/chat-ui/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,6 @@ dependencies {
implementation(libs.androidx.paging.runtime)
implementation(libs.compose.paging)
implementation(project(":apps:flipcash:shared:theme"))

testImplementation(libs.robolectric)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
),
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<SpanAnnotator>,
): AnnotatedString = remember(text, annotators) {
buildAnnotatedString {
append(text)
annotators.forEach { with(it) { annotate(text) } }
}
}
Original file line number Diff line number Diff line change
@@ -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())
}
}
Loading