Skip to content

Commit 5c61504

Browse files
committed
Tighten MessageText click dispatch and add snapshot coverage
Apply audit findings from the gesture review: - handleAnnotationClick now filters firstOrNull by isInteractiveTag. Previously, when a non-interactive annotation (e.g. a custom decoration added through AnnotatedMessageTextBuilder) overlapped a URL/email/mention range, the first-overlapping non-interactive one was returned and the when fell through silently. The link never opened. Locked in by a new MessageTextHelpersTest case covering the overlap. - ClickableText's pointerInput now keys on Unit and reads its callbacks through rememberUpdatedState. The block is no longer cancelled and restarted each composition because the caller allocates fresh lambdas. - styledText.getStringAnnotations is wrapped in remember(styledText) so the list is not reallocated on every recomposition. - A small WHY comment is added on the two non-obvious gesture decisions in ClickableText (the early-return for non-interactive characters, and consumeUntilUp after long-press). Tests: - The previous MessageTextTest is renamed to MessageTextHelpersTest to free the canonical name for the new snapshot test, matching the codebase convention (e.g. ReactionsMenuContentTest). - MessageTextTest is the new Paparazzi snapshot suite. It covers plain text, URL, email, mention, and URL + mention scenarios. The fixtures live next to the production code as internal preview-friendly composables (MessageTextPlain, MessageTextWithUrl, ...) so the @Preview composables and the snapshot tests share the same definitions. The earlier audit also recommended Compose UI gesture tests (tap / long-press / drag-out). Those are not included: the gesture loop relies on withTimeout inside awaitPointerEventScope, which the Robolectric test environment does not drive reliably for this case. The behaviour stays under manual QA.
1 parent 9cc74c0 commit 5c61504

9 files changed

Lines changed: 460 additions & 290 deletions

stream-chat-android-compose/api/stream-chat-android-compose.api

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1504,7 +1504,11 @@ public final class io/getstream/chat/android/compose/ui/components/messages/Comp
15041504
public final class io/getstream/chat/android/compose/ui/components/messages/ComposableSingletons$MessageTextKt {
15051505
public static final field INSTANCE Lio/getstream/chat/android/compose/ui/components/messages/ComposableSingletons$MessageTextKt;
15061506
public fun <init> ()V
1507-
public final fun getLambda$-1569101361$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2;
1507+
public final fun getLambda$-261803629$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2;
1508+
public final fun getLambda$-399958751$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2;
1509+
public final fun getLambda$1734337819$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2;
1510+
public final fun getLambda$1973807821$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2;
1511+
public final fun getLambda$552887520$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2;
15081512
}
15091513

15101514
public final class io/getstream/chat/android/compose/ui/components/messages/ComposableSingletons$PollMessageContentKt {

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

Lines changed: 100 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import androidx.compose.runtime.Composable
2727
import androidx.compose.runtime.getValue
2828
import androidx.compose.runtime.mutableStateOf
2929
import androidx.compose.runtime.remember
30+
import androidx.compose.runtime.rememberUpdatedState
3031
import androidx.compose.ui.Modifier
3132
import androidx.compose.ui.draw.clipToBounds
3233
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
@@ -103,7 +104,9 @@ public fun MessageText(
103104
else -> MessageStyling.textStyle(outgoing = message.isMine(currentUser))
104105
}
105106

106-
val annotations = styledText.getStringAnnotations(0, styledText.lastIndex)
107+
val annotations = remember(styledText) {
108+
styledText.getStringAnnotations(0, styledText.lastIndex)
109+
}
107110
if (annotations.fastAny(AnnotatedString.Range<String>::isInteractiveTag)) {
108111
ClickableText(
109112
modifier = modifier
@@ -171,12 +174,20 @@ private fun ClickableText(
171174
onClick: (Int) -> Unit,
172175
) {
173176
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
174-
val pressIndicator = Modifier.pointerInput(onClick, onLongPress, isInteractiveAt) {
177+
// Capture callbacks behind stable references so the pointerInput block does not restart on
178+
// recomposition — the lambdas allocated by the caller change identity each composition.
179+
val currentOnClick by rememberUpdatedState(onClick)
180+
val currentOnLongPress by rememberUpdatedState(onLongPress)
181+
val currentIsInteractiveAt by rememberUpdatedState(isInteractiveAt)
182+
val pressIndicator = Modifier.pointerInput(Unit) {
175183
awaitEachGesture {
176184
val down = awaitFirstDown()
177185
val layout = layoutResult.value ?: return@awaitEachGesture
178186
val charAt = layout.getOffsetForPosition(down.position)
179-
if (!isInteractiveAt(charAt)) {
187+
if (!currentIsInteractiveAt(charAt)) {
188+
// Non-interactive character: do not consume the down. Outer modifiers (the
189+
// bubble Column's passiveRipple and the surrounding cell's combinedClickable)
190+
// pick up the gesture instead.
180191
return@awaitEachGesture
181192
}
182193
down.consume()
@@ -185,13 +196,16 @@ private fun ClickableText(
185196
waitForUpOrCancellation()
186197
}
187198
} catch (_: PointerEventTimeoutCancellationException) {
188-
onLongPress()
199+
// Long-press fired. Consume the rest of the gesture so the inner click that would
200+
// normally ride the up event after onLongPress (matching detectTapGestures'
201+
// semantics) cannot reach this scope's onClick.
202+
currentOnLongPress()
189203
consumeUntilUp()
190204
return@awaitEachGesture
191205
}
192206
if (up != null) {
193207
up.consume()
194-
onClick(charAt)
208+
currentOnClick(charAt)
195209
}
196210
}
197211
}
@@ -247,7 +261,9 @@ internal fun handleAnnotationClick(
247261
onUserMentionClick: (User) -> Unit,
248262
fallback: (String) -> Unit,
249263
) {
250-
val annotation = annotations.firstOrNull { position in it.start until it.end } ?: return
264+
val annotation = annotations.firstOrNull {
265+
it.isInteractiveTag() && position in it.start until it.end
266+
} ?: return
251267
when (annotation.tag) {
252268
AnnotationTagMention -> {
253269
message.mentionedUsers.getUserByNameOrId(annotation.item)?.let(onUserMentionClick)
@@ -261,14 +277,83 @@ internal fun handleAnnotationClick(
261277
}
262278
}
263279

264-
@Preview
265280
@Composable
266-
private fun MessageTextPreview() {
267-
ChatTheme {
268-
MessageText(
269-
message = Message(text = "Hello World!"),
270-
currentUser = null,
271-
onLongItemClick = {},
272-
)
273-
}
281+
internal fun MessageTextPlain() {
282+
MessageText(
283+
message = Message(text = "Hello, this is a plain message."),
284+
currentUser = null,
285+
onLongItemClick = {},
286+
)
287+
}
288+
289+
@Composable
290+
internal fun MessageTextWithUrl() {
291+
MessageText(
292+
message = Message(text = "Check out https://getstream.io for more details."),
293+
currentUser = null,
294+
onLongItemClick = {},
295+
)
296+
}
297+
298+
@Composable
299+
internal fun MessageTextWithEmail() {
300+
MessageText(
301+
message = Message(text = "Contact us at support@getstream.io anytime."),
302+
currentUser = null,
303+
onLongItemClick = {},
304+
)
305+
}
306+
307+
@Composable
308+
internal fun MessageTextWithMention() {
309+
MessageText(
310+
message = Message(
311+
text = "Welcome @alice to the channel!",
312+
mentionedUsers = listOf(User(id = "alice", name = "alice")),
313+
),
314+
currentUser = null,
315+
onLongItemClick = {},
316+
)
317+
}
318+
319+
@Composable
320+
internal fun MessageTextWithUrlAndMention() {
321+
MessageText(
322+
message = Message(
323+
text = "Hey @alice, the docs are at https://getstream.io/docs",
324+
mentionedUsers = listOf(User(id = "alice", name = "alice")),
325+
),
326+
currentUser = null,
327+
onLongItemClick = {},
328+
)
329+
}
330+
331+
@Preview(showBackground = true)
332+
@Composable
333+
private fun MessageTextPlainPreview() {
334+
ChatTheme { MessageTextPlain() }
335+
}
336+
337+
@Preview(showBackground = true)
338+
@Composable
339+
private fun MessageTextWithUrlPreview() {
340+
ChatTheme { MessageTextWithUrl() }
341+
}
342+
343+
@Preview(showBackground = true)
344+
@Composable
345+
private fun MessageTextWithEmailPreview() {
346+
ChatTheme { MessageTextWithEmail() }
347+
}
348+
349+
@Preview(showBackground = true)
350+
@Composable
351+
private fun MessageTextWithMentionPreview() {
352+
ChatTheme { MessageTextWithMention() }
353+
}
354+
355+
@Preview(showBackground = true)
356+
@Composable
357+
private fun MessageTextWithUrlAndMentionPreview() {
358+
ChatTheme { MessageTextWithUrlAndMention() }
274359
}

0 commit comments

Comments
 (0)