Skip to content

Commit e3357d0

Browse files
committed
Cover passiveRipple and the MessageText click dispatch with tests
Extract the click-dispatch logic out of MessageText.ClickableText's inline lambda into an internal `handleAnnotationClick` helper. Same behaviour with one tightening: the original code silently fell through to URL handling for any non-mention annotation (treating its `item` as a URL); the helper uses an explicit `when` over the three interactive tags (Mention, URL, Email) and ignores anything else. The bubble predicate already restricts the click handler to interactive positions, so the tightening only affects pathological input. Add tests: - PassiveRippleTest (Compose UI tests via createComposeRule + Robolectric) covers the four reachable branches of Modifier.passiveRipple(): tap propagates to outer combinedClickable, long-press propagates to outer onLongClick, an inner consuming clickable shields the parent, and drag-out-of-bounds exercises the Cancel branch without crashing. - MessageTextTest gains eight pure-JUnit cases for handleAnnotationClick covering URL with and without onLinkClick, email, mention with resolved user, mention with unknown username, position outside any annotation, non-interactive tag, and empty annotation item. Also drop the redundant `requireUnconsumed = true` arguments at the two awaitFirstDown call sites — `true` is the default. The named boolean rule applies when a non-default value is being passed.
1 parent 7e87d44 commit e3357d0

4 files changed

Lines changed: 394 additions & 13 deletions

File tree

stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageText.kt

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -114,17 +114,16 @@ public fun MessageText(
114114
onLongPress = { onLongItemClick(message) },
115115
isInteractiveAt = annotations::hasInteractiveAt,
116116
) { position ->
117-
val annotation = annotations.firstOrNull { position in it.start until it.end }
118-
if (annotation?.tag == AnnotationTagMention) {
119-
message.mentionedUsers.getUserByNameOrId(annotation.item)?.let { onUserMentionClick.invoke(it) }
120-
} else {
121-
val targetUrl = annotation?.item
122-
if (!targetUrl.isNullOrEmpty()) {
123-
onLinkClick?.invoke(message, targetUrl) ?: context.startActivity(
124-
Intent(Intent.ACTION_VIEW, targetUrl.toUri()),
125-
)
126-
}
127-
}
117+
handleAnnotationClick(
118+
annotations = annotations,
119+
position = position,
120+
message = message,
121+
onLinkClick = onLinkClick,
122+
onUserMentionClick = onUserMentionClick,
123+
fallback = { url ->
124+
context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri()))
125+
},
126+
)
128127
}
129128
} else {
130129
Text(
@@ -174,7 +173,7 @@ private fun ClickableText(
174173
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
175174
val pressIndicator = Modifier.pointerInput(onClick, onLongPress, isInteractiveAt) {
176175
awaitEachGesture {
177-
val down = awaitFirstDown(requireUnconsumed = true)
176+
val down = awaitFirstDown()
178177
val layout = layoutResult.value ?: return@awaitEachGesture
179178
val charAt = layout.getOffsetForPosition(down.position)
180179
if (!isInteractiveAt(charAt)) {
@@ -232,6 +231,36 @@ internal fun AnnotatedString.Range<String>.isInteractiveTag(): Boolean =
232231
internal fun List<AnnotatedString.Range<String>>.hasInteractiveAt(offset: Int): Boolean =
233232
fastAny { it.isInteractiveTag() && offset in it.start until it.end }
234233

234+
/**
235+
* Resolves the interactive annotation at the given character [position] and dispatches the right
236+
* handler. Mention annotations route to [onUserMentionClick] after resolving the username against
237+
* [Message.mentionedUsers]; URL/email annotations route to [onLinkClick] when set, otherwise to
238+
* [fallback]. Annotations with empty items, non-interactive tags, or no match at [position] are
239+
* ignored.
240+
*/
241+
@Suppress("LongParameterList")
242+
internal fun handleAnnotationClick(
243+
annotations: List<AnnotatedString.Range<String>>,
244+
position: Int,
245+
message: Message,
246+
onLinkClick: ((Message, String) -> Unit)?,
247+
onUserMentionClick: (User) -> Unit,
248+
fallback: (String) -> Unit,
249+
) {
250+
val annotation = annotations.firstOrNull { position in it.start until it.end } ?: return
251+
when (annotation.tag) {
252+
AnnotationTagMention -> {
253+
message.mentionedUsers.getUserByNameOrId(annotation.item)?.let(onUserMentionClick)
254+
}
255+
AnnotationTagUrl, AnnotationTagEmail -> {
256+
val url = annotation.item
257+
if (url.isNotEmpty()) {
258+
if (onLinkClick != null) onLinkClick(message, url) else fallback(url)
259+
}
260+
}
261+
}
262+
}
263+
235264
@Preview
236265
@Composable
237266
private fun MessageTextPreview() {

stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/ModifierUtils.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ internal fun Modifier.passiveRipple(): Modifier = composed {
123123
val scope = rememberCoroutineScope()
124124
pointerInput(Unit) {
125125
awaitEachGesture {
126-
val down = awaitFirstDown(requireUnconsumed = true)
126+
val down = awaitFirstDown()
127127
val press = PressInteraction.Press(down.position)
128128
scope.launch { source.emit(press) }
129129
val up = waitForUpOrCancellation()

stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/components/messages/MessageTextTest.kt

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,19 @@ import androidx.compose.ui.text.AnnotatedString
2020
import io.getstream.chat.android.compose.ui.util.AnnotationTagEmail
2121
import io.getstream.chat.android.compose.ui.util.AnnotationTagMention
2222
import io.getstream.chat.android.compose.ui.util.AnnotationTagUrl
23+
import io.getstream.chat.android.models.Message
24+
import io.getstream.chat.android.models.User
25+
import io.getstream.chat.android.randomMessage
26+
import io.getstream.chat.android.randomUser
2327
import org.amshove.kluent.`should be equal to`
2428
import org.junit.jupiter.api.Test
2529
import org.junit.jupiter.params.ParameterizedTest
2630
import org.junit.jupiter.params.provider.Arguments
2731
import org.junit.jupiter.params.provider.MethodSource
32+
import org.mockito.kotlin.any
33+
import org.mockito.kotlin.mock
34+
import org.mockito.kotlin.never
35+
import org.mockito.kotlin.verify
2836

2937
internal class MessageTextTest {
3038

@@ -108,6 +116,182 @@ internal class MessageTextTest {
108116
annotations.hasInteractiveAt(offset = 7) `should be equal to` true
109117
}
110118

119+
@Test
120+
fun `handleAnnotationClick fires onLinkClick for a URL annotation when callback is set`() {
121+
val onLinkClick = mock<(Message, String) -> Unit>()
122+
val onUserMentionClick = mock<(User) -> Unit>()
123+
val fallback = mock<(String) -> Unit>()
124+
val message = randomMessage(text = "https://example.com")
125+
val annotations = listOf(urlAt(start = 0, end = 19))
126+
127+
handleAnnotationClick(
128+
annotations = annotations,
129+
position = 5,
130+
message = message,
131+
onLinkClick = onLinkClick,
132+
onUserMentionClick = onUserMentionClick,
133+
fallback = fallback,
134+
)
135+
136+
verify(onLinkClick).invoke(message, "https://example.com")
137+
verify(fallback, never()).invoke(any())
138+
verify(onUserMentionClick, never()).invoke(any())
139+
}
140+
141+
@Test
142+
fun `handleAnnotationClick falls back to default handler when onLinkClick is null`() {
143+
val onUserMentionClick = mock<(User) -> Unit>()
144+
val fallback = mock<(String) -> Unit>()
145+
val message = randomMessage(text = "https://example.com")
146+
val annotations = listOf(urlAt(start = 0, end = 19))
147+
148+
handleAnnotationClick(
149+
annotations = annotations,
150+
position = 5,
151+
message = message,
152+
onLinkClick = null,
153+
onUserMentionClick = onUserMentionClick,
154+
fallback = fallback,
155+
)
156+
157+
verify(fallback).invoke("https://example.com")
158+
verify(onUserMentionClick, never()).invoke(any())
159+
}
160+
161+
@Test
162+
fun `handleAnnotationClick fires onLinkClick for an email annotation`() {
163+
val onLinkClick = mock<(Message, String) -> Unit>()
164+
val message = randomMessage(text = "alice@example.com")
165+
val annotations = listOf(
166+
AnnotatedString.Range(
167+
item = "mailto:alice@example.com",
168+
start = 0,
169+
end = 17,
170+
tag = AnnotationTagEmail,
171+
),
172+
)
173+
174+
handleAnnotationClick(
175+
annotations = annotations,
176+
position = 3,
177+
message = message,
178+
onLinkClick = onLinkClick,
179+
onUserMentionClick = {},
180+
fallback = {},
181+
)
182+
183+
verify(onLinkClick).invoke(message, "mailto:alice@example.com")
184+
}
185+
186+
@Test
187+
fun `handleAnnotationClick fires onUserMentionClick with the resolved user`() {
188+
val mentioned = randomUser(name = "alice")
189+
val onUserMentionClick = mock<(User) -> Unit>()
190+
val onLinkClick = mock<(Message, String) -> Unit>()
191+
val message = randomMessage(text = "@alice", mentionedUsers = listOf(mentioned))
192+
val annotations = listOf(
193+
AnnotatedString.Range(item = "alice", start = 0, end = 6, tag = AnnotationTagMention),
194+
)
195+
196+
handleAnnotationClick(
197+
annotations = annotations,
198+
position = 2,
199+
message = message,
200+
onLinkClick = onLinkClick,
201+
onUserMentionClick = onUserMentionClick,
202+
fallback = {},
203+
)
204+
205+
verify(onUserMentionClick).invoke(mentioned)
206+
verify(onLinkClick, never()).invoke(any(), any())
207+
}
208+
209+
@Test
210+
fun `handleAnnotationClick is a no-op when the mentioned user is not in the message`() {
211+
val onUserMentionClick = mock<(User) -> Unit>()
212+
val message = randomMessage(text = "@bob", mentionedUsers = emptyList())
213+
val annotations = listOf(
214+
AnnotatedString.Range(item = "bob", start = 0, end = 4, tag = AnnotationTagMention),
215+
)
216+
217+
handleAnnotationClick(
218+
annotations = annotations,
219+
position = 1,
220+
message = message,
221+
onLinkClick = null,
222+
onUserMentionClick = onUserMentionClick,
223+
fallback = {},
224+
)
225+
226+
verify(onUserMentionClick, never()).invoke(any())
227+
}
228+
229+
@Test
230+
fun `handleAnnotationClick is a no-op when no annotation covers the position`() {
231+
val onLinkClick = mock<(Message, String) -> Unit>()
232+
val onUserMentionClick = mock<(User) -> Unit>()
233+
val fallback = mock<(String) -> Unit>()
234+
val message = randomMessage(text = "https://example.com after")
235+
val annotations = listOf(urlAt(start = 0, end = 19))
236+
237+
handleAnnotationClick(
238+
annotations = annotations,
239+
position = 22,
240+
message = message,
241+
onLinkClick = onLinkClick,
242+
onUserMentionClick = onUserMentionClick,
243+
fallback = fallback,
244+
)
245+
246+
verify(onLinkClick, never()).invoke(any(), any())
247+
verify(onUserMentionClick, never()).invoke(any())
248+
verify(fallback, never()).invoke(any())
249+
}
250+
251+
@Test
252+
fun `handleAnnotationClick ignores non-interactive tags`() {
253+
val onLinkClick = mock<(Message, String) -> Unit>()
254+
val fallback = mock<(String) -> Unit>()
255+
val message = randomMessage()
256+
val annotations = listOf(
257+
AnnotatedString.Range(item = "value", start = 0, end = 10, tag = "STYLE"),
258+
)
259+
260+
handleAnnotationClick(
261+
annotations = annotations,
262+
position = 5,
263+
message = message,
264+
onLinkClick = onLinkClick,
265+
onUserMentionClick = {},
266+
fallback = fallback,
267+
)
268+
269+
verify(onLinkClick, never()).invoke(any(), any())
270+
verify(fallback, never()).invoke(any())
271+
}
272+
273+
@Test
274+
fun `handleAnnotationClick ignores URL annotations with empty item`() {
275+
val onLinkClick = mock<(Message, String) -> Unit>()
276+
val fallback = mock<(String) -> Unit>()
277+
val message = randomMessage()
278+
val annotations = listOf(
279+
AnnotatedString.Range(item = "", start = 0, end = 5, tag = AnnotationTagUrl),
280+
)
281+
282+
handleAnnotationClick(
283+
annotations = annotations,
284+
position = 2,
285+
message = message,
286+
onLinkClick = onLinkClick,
287+
onUserMentionClick = {},
288+
fallback = fallback,
289+
)
290+
291+
verify(onLinkClick, never()).invoke(any(), any())
292+
verify(fallback, never()).invoke(any())
293+
}
294+
111295
private fun urlAt(start: Int, end: Int) =
112296
AnnotatedString.Range(item = "https://example.com", start = start, end = end, tag = AnnotationTagUrl)
113297

0 commit comments

Comments
 (0)