From bf67acc39c15400e088554bde217b1803720cfaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Thu, 7 May 2026 15:16:27 +0100 Subject: [PATCH 01/11] Add ripple feedback to giphy, file, link and quoted-message taps Image attachments already render a ripple on tap via their combinedClickable. Giphy, file rows, link previews and the quoted- message preview block had indication = null, leaving them without visual feedback. Match the image-attachment pattern across all four so every interactive surface inside a message bubble ripples consistently on press. --- .../compose/ui/attachments/content/FileAttachmentContent.kt | 3 ++- .../compose/ui/attachments/content/GiphyAttachmentContent.kt | 3 ++- .../compose/ui/attachments/content/LinkAttachmentContent.kt | 3 ++- .../android/compose/ui/components/messages/MessageContent.kt | 3 ++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/FileAttachmentContent.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/FileAttachmentContent.kt index 0bbe9699001..61c5169d0d5 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/FileAttachmentContent.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/FileAttachmentContent.kt @@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -111,7 +112,7 @@ public fun FileAttachmentContent( .background(color, fileAttachmentShape) } .combinedClickable( - indication = null, + indication = ripple(), interactionSource = remember { MutableInteractionSource() }, onClick = { onItemClick(previewHandlers, attachment) }, onLongClick = { attachmentState.onLongItemClick(message) }, diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/GiphyAttachmentContent.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/GiphyAttachmentContent.kt index 632bcc101b3..538c1cabb60 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/GiphyAttachmentContent.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/GiphyAttachmentContent.kt @@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember @@ -123,7 +124,7 @@ public fun GiphyAttachmentContent( .clip(RoundedCornerShape(StreamTokens.radiusLg)) } .combinedClickable( - indication = null, + indication = ripple(), interactionSource = remember { MutableInteractionSource() }, onClick = { onItemClick( diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/LinkAttachmentContent.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/LinkAttachmentContent.kt index 1c2a742f343..b391421250a 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/LinkAttachmentContent.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/LinkAttachmentContent.kt @@ -37,6 +37,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.Text +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember @@ -111,7 +112,7 @@ public fun LinkAttachmentContent( .clip(RoundedCornerShape(StreamTokens.radiusLg)) .background(MessageStyling.attachmentBackgroundColor(state.isMine)) .combinedClickable( - indication = null, + indication = ripple(), interactionSource = remember { MutableInteractionSource() }, onClick = { onItemClick( diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageContent.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageContent.kt index c6a4feaa23c..5cec24fe3c1 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageContent.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageContent.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Icon import androidx.compose.material3.Text +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -189,7 +190,7 @@ internal fun DefaultMessageRegularContent( replyMessage = message, modifier = Modifier.combinedClickable( interactionSource = remember(::MutableInteractionSource), - indication = null, + indication = ripple(), onLongClick = { onLongItemClick(message) }, onClick = { onQuotedMessageClick(quotedMessage) }, ), From ff918528e9c2083cf87bb6274bc4595383fe33a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Thu, 7 May 2026 15:34:13 +0100 Subject: [PATCH 02/11] Move bubble ripple to message-content column-level clickable Wrap the message-content Column inside DefaultMessageRegularContent with combinedClickable + ripple(). The Column owns the click + ripple for the entire bubble interior (text, spacer, and any space around inner attachments). Inner attachment clickables (image, file, giphy, link, quoted) still consume their own taps and ripple in their own bounds. This replaces the earlier params-based bubble ripple (which had a position-translation issue between the cell's interaction source and the bubble's local coords). With the click and the ripple at the same layout node, press positions are captured in Column-local coords and the ripple renders correctly regardless of message alignment or bubble width. --- .../ui/components/messages/MessageContent.kt | 10 +++++++++- .../ui/components/messages/PollMessageContent.kt | 16 +++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageContent.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageContent.kt index 5cec24fe3c1..cb7b0315b6f 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageContent.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageContent.kt @@ -180,7 +180,15 @@ internal fun DefaultMessageRegularContent( ) { val componentFactory = ChatTheme.componentFactory - Column(horizontalAlignment = messageAlignment.contentAlignment) { + Column( + modifier = Modifier.combinedClickable( + interactionSource = remember(::MutableInteractionSource), + indication = ripple(), + onClick = {}, + onLongClick = { onLongItemClick(message) }, + ), + horizontalAlignment = messageAlignment.contentAlignment, + ) { val quotedMessage = message.replyTo if (quotedMessage != null) { componentFactory.MessageQuotedContent( diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/PollMessageContent.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/PollMessageContent.kt index 91cd9847ec1..a44bae465a4 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/PollMessageContent.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/PollMessageContent.kt @@ -19,6 +19,8 @@ package io.getstream.chat.android.compose.ui.components.messages import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -35,6 +37,7 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState @@ -143,6 +146,7 @@ public fun PollMessageContent( }, onClosePoll = onClosePoll, onAddPollOption = onAddPollOption, + onLongItemClick = onLongItemClick, ) }, ), @@ -194,6 +198,7 @@ private fun PollMessageContent( onRemoveVote: (Vote) -> Unit, onAddPollOption: (poll: Poll, option: String) -> Unit, selectPoll: (Message, Poll, PollSelectionType) -> Unit, + onLongItemClick: (Message) -> Unit = {}, ) { val context = LocalContext.current val showDialog = remember { mutableStateOf(false) } @@ -227,7 +232,16 @@ private fun PollMessageContent( ) } - Column(modifier = Modifier.padding(StreamTokens.spacingMd)) { + Column( + modifier = Modifier + .combinedClickable( + interactionSource = remember(::MutableInteractionSource), + indication = ripple(), + onClick = {}, + onLongClick = { onLongItemClick(message) }, + ) + .padding(StreamTokens.spacingMd), + ) { Text( text = poll.name, style = typography.bodyEmphasis, From c7ab0951deb05890c623b0e2ded5e6688477a3c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Thu, 7 May 2026 16:37:16 +0100 Subject: [PATCH 03/11] Plumb cell interaction source through to bubble for avatar-gap ripple Restore the ripple feedback on the bubble when the user long-presses in the avatar gap (or any cell area outside the bubble). The column-level clickable inside DefaultMessageRegularContent only fires for taps inside the bubble; cell-area presses go to MessageContainer's combinedClickable, which has indication = null. Without forwarding the cell's interaction source, those presses had no visual feedback. Add interactionSource to MessageBubbleParams and MessageContentParams. The cell hoists its MutableInteractionSource and threads it via MessageContainer -> factory.MessageContent -> DefaultMessageContent -> RegularMessageContent / PollMessageContent -> factory.MessageBubble. The MessageBubble factory default applies clip(shape).indication( source, ripple(bounded = false)) when the source is non-null, synchronised with the cell's press state. Two ripple paths now coexist by design: - Tap inside the bubble: column's combinedClickable consumes, column-level bounded ripple fires (position-aware). - Tap in avatar gap: cell's combinedClickable handles, the cell's source emits, bubble's params indication renders an unbounded ripple from the bubble centre. Both fire on the correct trigger; no double rippling thanks to gesture consumption rules. --- .../api/stream-chat-android-compose.api | 26 +++++++++++-------- .../components/messages/PollMessageContent.kt | 7 +++++ .../ui/messages/list/MessageContainer.kt | 20 +++++++++++--- .../compose/ui/theme/ChatComponentFactory.kt | 14 +++++++++- .../ui/theme/ChatComponentFactoryParams.kt | 10 +++++++ 5 files changed, 62 insertions(+), 15 deletions(-) diff --git a/stream-chat-android-compose/api/stream-chat-android-compose.api b/stream-chat-android-compose/api/stream-chat-android-compose.api index 5fa3cf32382..f021b815fb4 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -1581,7 +1581,7 @@ public final class io/getstream/chat/android/compose/ui/components/messages/Mess } public final class io/getstream/chat/android/compose/ui/components/messages/PollMessageContentKt { - public static final fun PollMessageContent (Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V + public static final fun PollMessageContent (Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Landroidx/compose/foundation/interaction/InteractionSource;Landroidx/compose/runtime/Composer;II)V } public final class io/getstream/chat/android/compose/ui/components/messages/QuotedMessageKt { @@ -2136,10 +2136,10 @@ public final class io/getstream/chat/android/compose/ui/messages/list/Composable public final class io/getstream/chat/android/compose/ui/messages/list/MessageContainerKt { public static final field HighlightFadeOutDurationMillis I - public static final fun DefaultMessageContent (Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;III)V + public static final fun DefaultMessageContent (Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Landroidx/compose/foundation/interaction/InteractionSource;Landroidx/compose/runtime/Composer;III)V public static final fun EmojiMessageContent (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V public static final fun MessageContainer (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;III)V - public static final fun RegularMessageContent (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V + public static final fun RegularMessageContent (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Landroidx/compose/foundation/interaction/InteractionSource;Landroidx/compose/runtime/Composer;II)V } public final class io/getstream/chat/android/compose/ui/messages/list/MessageItemKt { @@ -4331,20 +4331,22 @@ public final class io/getstream/chat/android/compose/ui/theme/MessageBottomParam public final class io/getstream/chat/android/compose/ui/theme/MessageBubbleParams { public static final field $stable I - public synthetic fun (Lio/getstream/chat/android/models/Message;JLandroidx/compose/ui/graphics/Shape;Lkotlin/jvm/functions/Function2;Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/BorderStroke;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public synthetic fun (Lio/getstream/chat/android/models/Message;JLandroidx/compose/ui/graphics/Shape;Lkotlin/jvm/functions/Function2;Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/BorderStroke;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Lio/getstream/chat/android/models/Message;JLandroidx/compose/ui/graphics/Shape;Lkotlin/jvm/functions/Function2;Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/BorderStroke;Landroidx/compose/foundation/interaction/InteractionSource;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Lio/getstream/chat/android/models/Message;JLandroidx/compose/ui/graphics/Shape;Lkotlin/jvm/functions/Function2;Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/BorderStroke;Landroidx/compose/foundation/interaction/InteractionSource;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lio/getstream/chat/android/models/Message; public final fun component2-0d7_KjU ()J public final fun component3 ()Landroidx/compose/ui/graphics/Shape; public final fun component4 ()Lkotlin/jvm/functions/Function2; public final fun component5 ()Landroidx/compose/ui/Modifier; public final fun component6 ()Landroidx/compose/foundation/BorderStroke; - public final fun copy-3IgeMak (Lio/getstream/chat/android/models/Message;JLandroidx/compose/ui/graphics/Shape;Lkotlin/jvm/functions/Function2;Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/BorderStroke;)Lio/getstream/chat/android/compose/ui/theme/MessageBubbleParams; - public static synthetic fun copy-3IgeMak$default (Lio/getstream/chat/android/compose/ui/theme/MessageBubbleParams;Lio/getstream/chat/android/models/Message;JLandroidx/compose/ui/graphics/Shape;Lkotlin/jvm/functions/Function2;Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/BorderStroke;ILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/theme/MessageBubbleParams; + public final fun component7 ()Landroidx/compose/foundation/interaction/InteractionSource; + public final fun copy-sW7UJKQ (Lio/getstream/chat/android/models/Message;JLandroidx/compose/ui/graphics/Shape;Lkotlin/jvm/functions/Function2;Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/BorderStroke;Landroidx/compose/foundation/interaction/InteractionSource;)Lio/getstream/chat/android/compose/ui/theme/MessageBubbleParams; + public static synthetic fun copy-sW7UJKQ$default (Lio/getstream/chat/android/compose/ui/theme/MessageBubbleParams;Lio/getstream/chat/android/models/Message;JLandroidx/compose/ui/graphics/Shape;Lkotlin/jvm/functions/Function2;Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/BorderStroke;Landroidx/compose/foundation/interaction/InteractionSource;ILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/theme/MessageBubbleParams; public fun equals (Ljava/lang/Object;)Z public final fun getBorder ()Landroidx/compose/foundation/BorderStroke; public final fun getColor-0d7_KjU ()J public final fun getContent ()Lkotlin/jvm/functions/Function2; + public final fun getInteractionSource ()Landroidx/compose/foundation/interaction/InteractionSource; public final fun getMessage ()Lio/getstream/chat/android/models/Message; public final fun getModifier ()Landroidx/compose/ui/Modifier; public final fun getShape ()Landroidx/compose/ui/graphics/Shape; @@ -4976,14 +4978,15 @@ public final class io/getstream/chat/android/compose/ui/theme/MessageContainerPa public final class io/getstream/chat/android/compose/ui/theme/MessageContentParams { public static final field $stable I - public fun (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V - public synthetic fun (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Landroidx/compose/foundation/interaction/InteractionSource;)V + public synthetic fun (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Landroidx/compose/foundation/interaction/InteractionSource;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState; public final fun component10 ()Lkotlin/jvm/functions/Function1; public final fun component11 ()Lkotlin/jvm/functions/Function1; public final fun component12 ()Lkotlin/jvm/functions/Function1; public final fun component13 ()Lkotlin/jvm/functions/Function1; public final fun component14 ()Lkotlin/jvm/functions/Function2; + public final fun component15 ()Landroidx/compose/foundation/interaction/InteractionSource; public final fun component2 ()Lkotlin/jvm/functions/Function1; public final fun component3 ()Lkotlin/jvm/functions/Function2; public final fun component4 ()Lkotlin/jvm/functions/Function3; @@ -4992,9 +4995,10 @@ public final class io/getstream/chat/android/compose/ui/theme/MessageContentPara public final fun component7 ()Lkotlin/jvm/functions/Function3; public final fun component8 ()Lkotlin/jvm/functions/Function1; public final fun component9 ()Lkotlin/jvm/functions/Function2; - public final fun copy (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)Lio/getstream/chat/android/compose/ui/theme/MessageContentParams; - public static synthetic fun copy$default (Lio/getstream/chat/android/compose/ui/theme/MessageContentParams;Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/theme/MessageContentParams; + public final fun copy (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Landroidx/compose/foundation/interaction/InteractionSource;)Lio/getstream/chat/android/compose/ui/theme/MessageContentParams; + public static synthetic fun copy$default (Lio/getstream/chat/android/compose/ui/theme/MessageContentParams;Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Landroidx/compose/foundation/interaction/InteractionSource;ILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/theme/MessageContentParams; public fun equals (Ljava/lang/Object;)Z + public final fun getInteractionSource ()Landroidx/compose/foundation/interaction/InteractionSource; public final fun getMessageItem ()Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState; public final fun getOnAddAnswer ()Lkotlin/jvm/functions/Function3; public final fun getOnAddPollOption ()Lkotlin/jvm/functions/Function2; diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/PollMessageContent.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/PollMessageContent.kt index a44bae465a4..3773ee1cb85 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/PollMessageContent.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/PollMessageContent.kt @@ -20,6 +20,7 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.InteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -100,6 +101,9 @@ import io.getstream.chat.android.ui.common.R as UiCommonR * @param onClosePoll Callback when a user closes a poll. * @param onAddPollOption Callback when a user adds a new option to the poll. * @param onLongItemClick Handler when the user selects a message, on long tap. + * @param interactionSource The interaction source from the surrounding message cell, forwarded to + * the bubble so it can render a ripple synchronised with the cell's press state. `null` outside + * a cell context (e.g. previews). */ @Suppress("LongParameterList", "LongMethod") @Composable @@ -113,6 +117,7 @@ public fun PollMessageContent( onClosePoll: (String) -> Unit, onAddPollOption: (poll: Poll, option: String) -> Unit, onLongItemClick: (Message) -> Unit = {}, + interactionSource: InteractionSource? = null, ) { val message = messageItem.message val ownsMessage = messageItem.isMine @@ -129,6 +134,7 @@ public fun PollMessageContent( message = message, shape = messageBubbleShape, color = messageBubbleColor, + interactionSource = interactionSource, content = { PollMessageContent( message = message, @@ -160,6 +166,7 @@ public fun PollMessageContent( shape = messageBubbleShape, color = messageBubbleColor, border = BorderStroke(1.dp, ChatTheme.colors.borderCoreDefault), + interactionSource = interactionSource, content = { MessageContent( message = message, diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/MessageContainer.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/MessageContainer.kt index 75934889da2..8f7df27b94f 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/MessageContainer.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/MessageContainer.kt @@ -23,6 +23,7 @@ import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.interaction.InteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -39,7 +40,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -178,9 +178,10 @@ public fun MessageContainer( val canOpenThread = message.isThreadStart() && !messageItem.isInThread val canOpenActions = !message.isDeleted() && !message.isUploading() + val interactionSource = remember { MutableInteractionSource() } val clickModifier = Modifier.combinedClickable( - interactionSource = remember { MutableInteractionSource() }, - indication = ripple(), + interactionSource = interactionSource, + indication = null, enabled = canOpenThread || canOpenActions, onClick = { if (canOpenThread) onThreadClick(message) }, onLongClick = { @@ -289,6 +290,7 @@ public fun MessageContainer( onAddAnswer = onAddAnswer, onClosePoll = onClosePoll, onAddPollOption = onAddPollOption, + interactionSource = interactionSource, ), ) }, @@ -603,6 +605,9 @@ internal fun ColumnScope.DefaultMessageBottom( * @param onRemoveVote Handler when a user cast a remove on an option. * @param onClosePoll Handler when a user close a poll. * @param onAddPollOption Handler when a user add a poll option. + * @param interactionSource The interaction source from the surrounding message cell, threaded + * through to the bubble so it can render a ripple synchronised with the cell's press state. + * `null` outside a cell context (e.g. previews). */ @Suppress("LongParameterList") @Composable @@ -622,6 +627,7 @@ public fun DefaultMessageContent( onAddAnswer: (message: Message, poll: Poll, answer: String) -> Unit, onClosePoll: (String) -> Unit, onAddPollOption: (poll: Poll, option: String) -> Unit, + interactionSource: InteractionSource? = null, ) { val finalModifier = modifier.widthIn(max = 264.dp) if (messageItem.message.isPoll() && !messageItem.message.isDeleted()) { @@ -642,6 +648,7 @@ public fun DefaultMessageContent( onAddPollOption = onAddPollOption, onLongItemClick = onLongItemClick, onAddAnswer = onAddAnswer, + interactionSource = interactionSource, ) } else if (messageItem.message.isEmojiOnlyWithoutBubble()) { EmojiMessageContent( @@ -662,6 +669,7 @@ public fun DefaultMessageContent( onQuotedMessageClick = onQuotedMessageClick, onLinkClick = onLinkClick, onUserMentionClick = onUserMentionClick, + interactionSource = interactionSource, ) } } @@ -730,6 +738,9 @@ public fun EmojiMessageContent( * @param onQuotedMessageClick Handler for quoted message click action. * @param onLinkClick Handler for clicking on a link in the message. * @param onMediaGalleryPreviewResult Handler when the user selects an option in the Media Gallery Preview screen. + * @param interactionSource The interaction source from the surrounding message cell, forwarded to + * the bubble so it can render a ripple synchronised with the cell's press state. `null` outside + * a cell context (e.g. previews). */ @Composable public fun RegularMessageContent( @@ -741,6 +752,7 @@ public fun RegularMessageContent( onLinkClick: ((Message, String) -> Unit)? = null, onUserMentionClick: (User) -> Unit = {}, onMediaGalleryPreviewResult: (MediaGalleryPreviewResult?) -> Unit = {}, + interactionSource: InteractionSource? = null, ) { val message = messageItem.message val ownsMessage = messageItem.isMine @@ -770,6 +782,7 @@ public fun RegularMessageContent( shape = messageBubbleShape, color = messageBubbleColor, content = content, + interactionSource = interactionSource, ), ) } else { @@ -781,6 +794,7 @@ public fun RegularMessageContent( shape = messageBubbleShape, color = messageBubbleColor, content = content, + interactionSource = interactionSource, ), ) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt index be3965c2175..3634e88e395 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt @@ -18,6 +18,7 @@ package io.getstream.chat.android.compose.ui.theme import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.background +import androidx.compose.foundation.indication import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope @@ -41,10 +42,12 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource @@ -979,8 +982,16 @@ public interface ChatComponentFactory { */ @Composable public fun MessageBubble(params: MessageBubbleParams) { + val source = params.interactionSource + val indicationModifier = if (source != null) { + Modifier + .clip(params.shape) + .indication(source, ripple(bounded = false)) + } else { + Modifier + } io.getstream.chat.android.compose.ui.components.messages.MessageBubble( - modifier = params.modifier, + modifier = params.modifier.then(indicationModifier), color = params.color, shape = params.shape, border = params.border, @@ -1065,6 +1076,7 @@ public interface ChatComponentFactory { onAddAnswer = params.onAddAnswer, onClosePoll = params.onClosePoll, onAddPollOption = params.onAddPollOption, + interactionSource = params.interactionSource, ) } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt index 7e52eeaa646..4086408fb59 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt @@ -18,6 +18,7 @@ package io.getstream.chat.android.compose.ui.theme import android.net.Uri import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.interaction.InteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.RowScope @@ -708,6 +709,10 @@ public data class MessageContainerParams( * @param content The content shown inside the message bubble. * @param modifier Modifier for styling. * @param border The border of the message bubble. + * @param interactionSource The interaction source from the surrounding message cell. The default + * factory implementation renders an unbounded ripple inside the bubble shape when this is non-null, + * synchronised with the cell's press state. `null` outside a cell context (e.g. quoted-message + * previews), in which case no indication is rendered. */ public data class MessageBubbleParams( val message: Message, @@ -716,6 +721,7 @@ public data class MessageBubbleParams( val content: @Composable () -> Unit, val modifier: Modifier = Modifier, val border: BorderStroke? = null, + val interactionSource: InteractionSource? = null, ) /** @@ -777,6 +783,9 @@ public data class MessageAuthorParams( * @param onUserMentionClick Action invoked when a user mention is clicked. * @param onMediaGalleryPreviewResult Action invoked with the media gallery preview result. * @param onLinkClick Action invoked when a link in a message is clicked. + * @param interactionSource The interaction source from the surrounding message cell, threaded + * through to [MessageBubbleParams.interactionSource] so the bubble can render an unbounded + * ripple synchronised with the cell's press state. `null` outside a cell context. */ public data class MessageContentParams( val messageItem: MessageItemState, @@ -793,6 +802,7 @@ public data class MessageContentParams( val onUserMentionClick: (User) -> Unit = {}, val onMediaGalleryPreviewResult: (MediaGalleryPreviewResult?) -> Unit = {}, val onLinkClick: ((Message, String) -> Unit)? = null, + val interactionSource: InteractionSource? = null, ) /** From cb2b3ae93bb8f300077900fc52eaf570e7c5b009 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 8 May 2026 09:32:19 +0100 Subject: [PATCH 04/11] Forward cell click and long-click intents to bubble Column The Column-level combinedClickable inside DefaultMessageRegularContent and the inner PollMessageContent consumed taps with onClick = {} and fired a raw onLongItemClick(message) without haptic. Two regressions followed: tapping a thread-start message inside the bubble no longer opened the thread (the cell's onClick was shadowed), and bubble long-press lost the haptic feedback that the cell triggered for avatar-gap presses. Hoist onItemClick and onItemLongClick as named lambdas in MessageContainer, both wrapping the canOpenThread / canOpenActions gates and the haptic call. Plumb them through MessageContentParams, MessageRegularContentParams, the public DefaultMessageContent / RegularMessageContent / MessageContent / PollMessageContent composables, and the factory defaults so the bubble Column can call them directly. This keeps canOpenActions in a single place (MessageContainer) and removes the LocalHapticFeedback usage from MessageContent.kt and PollMessageContent.kt: the bubble Column no longer needs to know about action-permission rules or haptic policy. The inner private PollMessageContent overload also drops its now-unused onLongItemClick: (Message) -> Unit parameter. --- .../api/stream-chat-android-compose.api | 50 +++++++++++-------- .../ui/components/messages/MessageContent.kt | 19 ++++++- .../components/messages/PollMessageContent.kt | 19 +++++-- .../ui/messages/list/MessageContainer.kt | 42 +++++++++++++--- .../compose/ui/theme/ChatComponentFactory.kt | 4 ++ .../ui/theme/ChatComponentFactoryParams.kt | 17 +++++++ 6 files changed, 117 insertions(+), 34 deletions(-) diff --git a/stream-chat-android-compose/api/stream-chat-android-compose.api b/stream-chat-android-compose/api/stream-chat-android-compose.api index f021b815fb4..0171393c516 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -1560,7 +1560,7 @@ public final class io/getstream/chat/android/compose/ui/components/messages/Mess } public final class io/getstream/chat/android/compose/ui/components/messages/MessageContentKt { - public static final fun MessageContent (Lio/getstream/chat/android/models/Message;Lio/getstream/chat/android/models/User;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lio/getstream/chat/android/compose/state/messages/MessageAlignment;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V + public static final fun MessageContent (Lio/getstream/chat/android/models/Message;Lio/getstream/chat/android/models/User;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lio/getstream/chat/android/compose/state/messages/MessageAlignment;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;III)V } public final class io/getstream/chat/android/compose/ui/components/messages/MessageFooterKt { @@ -1581,7 +1581,7 @@ public final class io/getstream/chat/android/compose/ui/components/messages/Mess } public final class io/getstream/chat/android/compose/ui/components/messages/PollMessageContentKt { - public static final fun PollMessageContent (Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Landroidx/compose/foundation/interaction/InteractionSource;Landroidx/compose/runtime/Composer;II)V + public static final fun PollMessageContent (Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Landroidx/compose/foundation/interaction/InteractionSource;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;III)V } public final class io/getstream/chat/android/compose/ui/components/messages/QuotedMessageKt { @@ -2136,10 +2136,10 @@ public final class io/getstream/chat/android/compose/ui/messages/list/Composable public final class io/getstream/chat/android/compose/ui/messages/list/MessageContainerKt { public static final field HighlightFadeOutDurationMillis I - public static final fun DefaultMessageContent (Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Landroidx/compose/foundation/interaction/InteractionSource;Landroidx/compose/runtime/Composer;III)V + public static final fun DefaultMessageContent (Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Landroidx/compose/foundation/interaction/InteractionSource;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;III)V public static final fun EmojiMessageContent (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V public static final fun MessageContainer (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;III)V - public static final fun RegularMessageContent (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Landroidx/compose/foundation/interaction/InteractionSource;Landroidx/compose/runtime/Composer;II)V + public static final fun RegularMessageContent (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Landroidx/compose/foundation/interaction/InteractionSource;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;III)V } public final class io/getstream/chat/android/compose/ui/messages/list/MessageItemKt { @@ -4978,25 +4978,27 @@ public final class io/getstream/chat/android/compose/ui/theme/MessageContainerPa public final class io/getstream/chat/android/compose/ui/theme/MessageContentParams { public static final field $stable I - public fun (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Landroidx/compose/foundation/interaction/InteractionSource;)V - public synthetic fun (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Landroidx/compose/foundation/interaction/InteractionSource;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Landroidx/compose/foundation/interaction/InteractionSource;)V + public synthetic fun (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Landroidx/compose/foundation/interaction/InteractionSource;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState; public final fun component10 ()Lkotlin/jvm/functions/Function1; - public final fun component11 ()Lkotlin/jvm/functions/Function1; + public final fun component11 ()Lkotlin/jvm/functions/Function2; public final fun component12 ()Lkotlin/jvm/functions/Function1; public final fun component13 ()Lkotlin/jvm/functions/Function1; - public final fun component14 ()Lkotlin/jvm/functions/Function2; - public final fun component15 ()Landroidx/compose/foundation/interaction/InteractionSource; - public final fun component2 ()Lkotlin/jvm/functions/Function1; - public final fun component3 ()Lkotlin/jvm/functions/Function2; - public final fun component4 ()Lkotlin/jvm/functions/Function3; - public final fun component5 ()Lkotlin/jvm/functions/Function3; + public final fun component14 ()Lkotlin/jvm/functions/Function1; + public final fun component15 ()Lkotlin/jvm/functions/Function1; + public final fun component16 ()Lkotlin/jvm/functions/Function2; + public final fun component17 ()Landroidx/compose/foundation/interaction/InteractionSource; + public final fun component2 ()Lkotlin/jvm/functions/Function0; + public final fun component3 ()Lkotlin/jvm/functions/Function0; + public final fun component4 ()Lkotlin/jvm/functions/Function1; + public final fun component5 ()Lkotlin/jvm/functions/Function2; public final fun component6 ()Lkotlin/jvm/functions/Function3; public final fun component7 ()Lkotlin/jvm/functions/Function3; - public final fun component8 ()Lkotlin/jvm/functions/Function1; - public final fun component9 ()Lkotlin/jvm/functions/Function2; - public final fun copy (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Landroidx/compose/foundation/interaction/InteractionSource;)Lio/getstream/chat/android/compose/ui/theme/MessageContentParams; - public static synthetic fun copy$default (Lio/getstream/chat/android/compose/ui/theme/MessageContentParams;Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Landroidx/compose/foundation/interaction/InteractionSource;ILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/theme/MessageContentParams; + public final fun component8 ()Lkotlin/jvm/functions/Function3; + public final fun component9 ()Lkotlin/jvm/functions/Function3; + public final fun copy (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Landroidx/compose/foundation/interaction/InteractionSource;)Lio/getstream/chat/android/compose/ui/theme/MessageContentParams; + public static synthetic fun copy$default (Lio/getstream/chat/android/compose/ui/theme/MessageContentParams;Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Landroidx/compose/foundation/interaction/InteractionSource;ILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/theme/MessageContentParams; public fun equals (Ljava/lang/Object;)Z public final fun getInteractionSource ()Landroidx/compose/foundation/interaction/InteractionSource; public final fun getMessageItem ()Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState; @@ -5005,6 +5007,8 @@ public final class io/getstream/chat/android/compose/ui/theme/MessageContentPara public final fun getOnCastVote ()Lkotlin/jvm/functions/Function3; public final fun getOnClosePoll ()Lkotlin/jvm/functions/Function1; public final fun getOnGiphyActionClick ()Lkotlin/jvm/functions/Function1; + public final fun getOnItemClick ()Lkotlin/jvm/functions/Function0; + public final fun getOnItemLongClick ()Lkotlin/jvm/functions/Function0; public final fun getOnLinkClick ()Lkotlin/jvm/functions/Function2; public final fun getOnLongItemClick ()Lkotlin/jvm/functions/Function1; public final fun getOnMediaGalleryPreviewResult ()Lkotlin/jvm/functions/Function1; @@ -5408,9 +5412,10 @@ public final class io/getstream/chat/android/compose/ui/theme/MessageReactionsPi public final class io/getstream/chat/android/compose/ui/theme/MessageRegularContentParams { public static final field $stable I - public fun (Lio/getstream/chat/android/models/Message;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/compose/state/messages/MessageAlignment;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V - public synthetic fun (Lio/getstream/chat/android/models/Message;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/compose/state/messages/MessageAlignment;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/getstream/chat/android/models/Message;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/compose/state/messages/MessageAlignment;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;)V + public synthetic fun (Lio/getstream/chat/android/models/Message;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/compose/state/messages/MessageAlignment;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lio/getstream/chat/android/models/Message; + public final fun component10 ()Lkotlin/jvm/functions/Function0; public final fun component2 ()Lio/getstream/chat/android/models/User; public final fun component3 ()Lio/getstream/chat/android/compose/state/messages/MessageAlignment; public final fun component4 ()Lkotlin/jvm/functions/Function1; @@ -5418,12 +5423,15 @@ public final class io/getstream/chat/android/compose/ui/theme/MessageRegularCont public final fun component6 ()Lkotlin/jvm/functions/Function1; public final fun component7 ()Lkotlin/jvm/functions/Function1; public final fun component8 ()Lkotlin/jvm/functions/Function2; - public final fun copy (Lio/getstream/chat/android/models/Message;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/compose/state/messages/MessageAlignment;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)Lio/getstream/chat/android/compose/ui/theme/MessageRegularContentParams; - public static synthetic fun copy$default (Lio/getstream/chat/android/compose/ui/theme/MessageRegularContentParams;Lio/getstream/chat/android/models/Message;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/compose/state/messages/MessageAlignment;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/theme/MessageRegularContentParams; + public final fun component9 ()Lkotlin/jvm/functions/Function0; + public final fun copy (Lio/getstream/chat/android/models/Message;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/compose/state/messages/MessageAlignment;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;)Lio/getstream/chat/android/compose/ui/theme/MessageRegularContentParams; + public static synthetic fun copy$default (Lio/getstream/chat/android/compose/ui/theme/MessageRegularContentParams;Lio/getstream/chat/android/models/Message;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/compose/state/messages/MessageAlignment;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/theme/MessageRegularContentParams; public fun equals (Ljava/lang/Object;)Z public final fun getCurrentUser ()Lio/getstream/chat/android/models/User; public final fun getMessage ()Lio/getstream/chat/android/models/Message; public final fun getMessageAlignment ()Lio/getstream/chat/android/compose/state/messages/MessageAlignment; + public final fun getOnItemClick ()Lkotlin/jvm/functions/Function0; + public final fun getOnItemLongClick ()Lkotlin/jvm/functions/Function0; public final fun getOnLinkClick ()Lkotlin/jvm/functions/Function2; public final fun getOnLongItemClick ()Lkotlin/jvm/functions/Function1; public final fun getOnMediaGalleryPreviewResult ()Lkotlin/jvm/functions/Function1; diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageContent.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageContent.kt index cb7b0315b6f..2f819f02a96 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageContent.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageContent.kt @@ -79,6 +79,13 @@ import io.getstream.chat.android.ui.common.utils.extensions.hasLink * @param onQuotedMessageClick Handler for quoted message click action. * @param onLinkClick Handler for clicking on a link in the message. * @param onMediaGalleryPreviewResult Handler when the user selects an option in the Media Gallery Preview screen. + * @param onItemClick Handler invoked when the bubble is tapped. Mirrors the surrounding cell's + * click intent so taps inside the bubble Column behave identically to taps in the avatar gap + * (e.g. opening a thread for thread-start messages). + * @param onItemLongClick Handler invoked when the bubble is long-pressed. Mirrors the surrounding + * cell's long-click intent (haptic feedback + action menu, gated on whether actions are + * available) so long-presses inside the bubble Column behave identically to those in the + * avatar gap. */ @Composable public fun MessageContent( @@ -92,6 +99,8 @@ public fun MessageContent( messageAlignment: MessageAlignment = MessageAlignment.Start, onLinkClick: ((Message, String) -> Unit)? = null, onMediaGalleryPreviewResult: (MediaGalleryPreviewResult?) -> Unit = {}, + onItemClick: () -> Unit = {}, + onItemLongClick: () -> Unit = {}, ) { when { message.isGiphyEphemeral() -> ChatTheme.componentFactory.MessageGiphyContent( @@ -120,6 +129,8 @@ public fun MessageContent( onQuotedMessageClick = onQuotedMessageClick, onLinkClick = onLinkClick, onUserMentionClick = onUserMentionClick, + onItemClick = onItemClick, + onItemLongClick = onItemLongClick, ), ) } @@ -165,6 +176,8 @@ internal fun DefaultMessageDeletedContent( * @param onMediaGalleryPreviewResult Handler when the user selects an option in the Media Gallery Preview screen. * @param onQuotedMessageClick Handler for quoted message click action. * @param onLinkClick Handler for clicking on a link in the message. + * @param onItemClick Handler invoked when the bubble is tapped. + * @param onItemLongClick Handler invoked when the bubble is long-pressed. */ @Composable @Suppress("LongMethod") @@ -177,6 +190,8 @@ internal fun DefaultMessageRegularContent( onQuotedMessageClick: (Message) -> Unit, onUserMentionClick: (User) -> Unit = {}, onLinkClick: ((Message, String) -> Unit)? = null, + onItemClick: () -> Unit = {}, + onItemLongClick: () -> Unit = {}, ) { val componentFactory = ChatTheme.componentFactory @@ -184,8 +199,8 @@ internal fun DefaultMessageRegularContent( modifier = Modifier.combinedClickable( interactionSource = remember(::MutableInteractionSource), indication = ripple(), - onClick = {}, - onLongClick = { onLongItemClick(message) }, + onClick = onItemClick, + onLongClick = onItemLongClick, ), horizontalAlignment = messageAlignment.contentAlignment, ) { diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/PollMessageContent.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/PollMessageContent.kt index 3773ee1cb85..09e45182b5a 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/PollMessageContent.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/PollMessageContent.kt @@ -104,6 +104,13 @@ import io.getstream.chat.android.ui.common.R as UiCommonR * @param interactionSource The interaction source from the surrounding message cell, forwarded to * the bubble so it can render a ripple synchronised with the cell's press state. `null` outside * a cell context (e.g. previews). + * @param onItemClick Handler invoked when the bubble is tapped. Mirrors the surrounding cell's + * click intent so taps inside the bubble Column behave identically to taps in the avatar gap + * (e.g. opening a thread for thread-start messages). + * @param onItemLongClick Handler invoked when the bubble is long-pressed. Mirrors the surrounding + * cell's long-click intent (haptic feedback + action menu, gated on whether actions are + * available) so long-presses inside the bubble Column behave identically to those in the + * avatar gap. */ @Suppress("LongParameterList", "LongMethod") @Composable @@ -118,6 +125,8 @@ public fun PollMessageContent( onAddPollOption: (poll: Poll, option: String) -> Unit, onLongItemClick: (Message) -> Unit = {}, interactionSource: InteractionSource? = null, + onItemClick: () -> Unit = {}, + onItemLongClick: () -> Unit = {}, ) { val message = messageItem.message val ownsMessage = messageItem.isMine @@ -152,7 +161,8 @@ public fun PollMessageContent( }, onClosePoll = onClosePoll, onAddPollOption = onAddPollOption, - onLongItemClick = onLongItemClick, + onItemClick = onItemClick, + onItemLongClick = onItemLongClick, ) }, ), @@ -205,7 +215,8 @@ private fun PollMessageContent( onRemoveVote: (Vote) -> Unit, onAddPollOption: (poll: Poll, option: String) -> Unit, selectPoll: (Message, Poll, PollSelectionType) -> Unit, - onLongItemClick: (Message) -> Unit = {}, + onItemClick: () -> Unit = {}, + onItemLongClick: () -> Unit = {}, ) { val context = LocalContext.current val showDialog = remember { mutableStateOf(false) } @@ -244,8 +255,8 @@ private fun PollMessageContent( .combinedClickable( interactionSource = remember(::MutableInteractionSource), indication = ripple(), - onClick = {}, - onLongClick = { onLongItemClick(message) }, + onClick = onItemClick, + onLongClick = onItemLongClick, ) .padding(StreamTokens.spacingMd), ) { diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/MessageContainer.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/MessageContainer.kt index 8f7df27b94f..a6f8885fbc6 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/MessageContainer.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/MessageContainer.kt @@ -179,17 +179,19 @@ public fun MessageContainer( val canOpenActions = !message.isDeleted() && !message.isUploading() val interactionSource = remember { MutableInteractionSource() } + val onItemClick: () -> Unit = { if (canOpenThread) onThreadClick(message) } + val onItemLongClick: () -> Unit = { + if (canOpenActions) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onLongItemClick(message) + } + } val clickModifier = Modifier.combinedClickable( interactionSource = interactionSource, indication = null, enabled = canOpenThread || canOpenActions, - onClick = { if (canOpenThread) onThreadClick(message) }, - onLongClick = { - if (canOpenActions) { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - onLongItemClick(message) - } - }, + onClick = onItemClick, + onLongClick = onItemLongClick, ) val highlightColor = ChatTheme.colors.backgroundCoreHighlight @@ -277,6 +279,8 @@ public fun MessageContainer( MessageContent( params = MessageContentParams( messageItem = messageItem, + onItemClick = onItemClick, + onItemLongClick = onItemLongClick, onLongItemClick = onLongItemClick, onMediaGalleryPreviewResult = onMediaGalleryPreviewResult, onGiphyActionClick = onGiphyActionClick, @@ -608,6 +612,13 @@ internal fun ColumnScope.DefaultMessageBottom( * @param interactionSource The interaction source from the surrounding message cell, threaded * through to the bubble so it can render a ripple synchronised with the cell's press state. * `null` outside a cell context (e.g. previews). + * @param onItemClick Handler invoked when the bubble is tapped. Mirrors the surrounding cell's + * click intent so taps inside the bubble Column behave identically to taps in the avatar gap + * (e.g. opening a thread for thread-start messages). + * @param onItemLongClick Handler invoked when the bubble is long-pressed. Mirrors the surrounding + * cell's long-click intent (haptic feedback + action menu, gated on whether actions are + * available) so long-presses inside the bubble Column behave identically to those in the + * avatar gap. */ @Suppress("LongParameterList") @Composable @@ -628,6 +639,8 @@ public fun DefaultMessageContent( onClosePoll: (String) -> Unit, onAddPollOption: (poll: Poll, option: String) -> Unit, interactionSource: InteractionSource? = null, + onItemClick: () -> Unit = {}, + onItemLongClick: () -> Unit = {}, ) { val finalModifier = modifier.widthIn(max = 264.dp) if (messageItem.message.isPoll() && !messageItem.message.isDeleted()) { @@ -649,6 +662,8 @@ public fun DefaultMessageContent( onLongItemClick = onLongItemClick, onAddAnswer = onAddAnswer, interactionSource = interactionSource, + onItemClick = onItemClick, + onItemLongClick = onItemLongClick, ) } else if (messageItem.message.isEmojiOnlyWithoutBubble()) { EmojiMessageContent( @@ -670,6 +685,8 @@ public fun DefaultMessageContent( onLinkClick = onLinkClick, onUserMentionClick = onUserMentionClick, interactionSource = interactionSource, + onItemClick = onItemClick, + onItemLongClick = onItemLongClick, ) } } @@ -741,6 +758,13 @@ public fun EmojiMessageContent( * @param interactionSource The interaction source from the surrounding message cell, forwarded to * the bubble so it can render a ripple synchronised with the cell's press state. `null` outside * a cell context (e.g. previews). + * @param onItemClick Handler invoked when the bubble is tapped. Mirrors the surrounding cell's + * click intent so taps inside the bubble Column behave identically to taps in the avatar gap + * (e.g. opening a thread for thread-start messages). + * @param onItemLongClick Handler invoked when the bubble is long-pressed. Mirrors the surrounding + * cell's long-click intent (haptic feedback + action menu, gated on whether actions are + * available) so long-presses inside the bubble Column behave identically to those in the + * avatar gap. */ @Composable public fun RegularMessageContent( @@ -753,6 +777,8 @@ public fun RegularMessageContent( onUserMentionClick: (User) -> Unit = {}, onMediaGalleryPreviewResult: (MediaGalleryPreviewResult?) -> Unit = {}, interactionSource: InteractionSource? = null, + onItemClick: () -> Unit = {}, + onItemLongClick: () -> Unit = {}, ) { val message = messageItem.message val ownsMessage = messageItem.isMine @@ -772,6 +798,8 @@ public fun RegularMessageContent( onQuotedMessageClick = onQuotedMessageClick, onLinkClick = onLinkClick, onUserMentionClick = onUserMentionClick, + onItemClick = onItemClick, + onItemLongClick = onItemLongClick, ) } if (!messageItem.isErrorOrFailed()) { diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt index 3634e88e395..737a42413b3 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt @@ -1063,6 +1063,8 @@ public interface ChatComponentFactory { public fun MessageContent(params: MessageContentParams) { DefaultMessageContent( messageItem = params.messageItem, + onItemClick = params.onItemClick, + onItemLongClick = params.onItemLongClick, onLongItemClick = params.onLongItemClick, onGiphyActionClick = params.onGiphyActionClick, onQuotedMessageClick = params.onQuotedMessageClick, @@ -1152,6 +1154,8 @@ public interface ChatComponentFactory { onQuotedMessageClick = params.onQuotedMessageClick, onUserMentionClick = params.onUserMentionClick, onLinkClick = params.onLinkClick, + onItemClick = params.onItemClick, + onItemLongClick = params.onItemLongClick, ) } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt index 4086408fb59..c2bb7e4de4e 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt @@ -770,6 +770,13 @@ public data class MessageAuthorParams( * Parameters for [ChatComponentFactory.MessageContent]. * * @param messageItem The message item state. + * @param onItemClick Action invoked when the bubble is tapped. Mirrors the surrounding cell's + * click intent (e.g. opening a thread for thread-start messages) so taps inside the bubble + * Column behave identically to taps in the avatar gap. + * @param onItemLongClick Action invoked when the bubble is long-pressed. Mirrors the surrounding + * cell's long-click intent (haptic feedback + action menu, gated on whether actions are + * available) so long-presses inside the bubble Column behave identically to those in the + * avatar gap. * @param onLongItemClick Action invoked when a message is long-clicked. * @param onPollUpdated Action invoked when a poll is updated. * @param onCastVote Action invoked when a vote is cast. @@ -789,6 +796,8 @@ public data class MessageAuthorParams( */ public data class MessageContentParams( val messageItem: MessageItemState, + val onItemClick: () -> Unit = {}, + val onItemLongClick: () -> Unit = {}, val onLongItemClick: (Message) -> Unit = {}, val onPollUpdated: (Message, Poll) -> Unit = { _, _ -> }, val onCastVote: (Message, Poll, Option) -> Unit = { _, _, _ -> }, @@ -851,6 +860,12 @@ public data class MessageDeletedContentParams( * @param onQuotedMessageClick Action invoked when a quoted message is clicked. * @param onUserMentionClick Action invoked when a user mention is clicked. * @param onLinkClick Action invoked when a link in a message is clicked. + * @param onItemClick Action invoked when the bubble is tapped. Mirrors the surrounding cell's + * click intent so taps inside the bubble Column behave identically to taps in the avatar gap. + * @param onItemLongClick Action invoked when the bubble is long-pressed. Mirrors the surrounding + * cell's long-click intent (haptic feedback + action menu, gated on whether actions are + * available) so long-presses inside the bubble Column behave identically to those in the + * avatar gap. */ public data class MessageRegularContentParams( val message: Message, @@ -861,6 +876,8 @@ public data class MessageRegularContentParams( val onQuotedMessageClick: (Message) -> Unit, val onUserMentionClick: (User) -> Unit, val onLinkClick: ((Message, String) -> Unit)? = null, + val onItemClick: () -> Unit = {}, + val onItemLongClick: () -> Unit = {}, ) /** From da22965ba3c1573edb2d1038423f8e13effe5f6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 8 May 2026 09:54:07 +0100 Subject: [PATCH 05/11] Rename bubble-mirror callbacks and document why they are hoisted The previous names (onItemClick / onItemLongClick) collided with the pre-existing onLongItemClick: (Message) -> Unit, which still exists for attachment routing. Reading MessageContentParams meant disambiguating three near-identical names with different shapes and purposes. Rename the new pair to onBubbleClick / onBubbleLongClick: the names now encode where the gesture happens and remove the word-swap collision. Add a one-line comment in MessageContainer near the hoisted lambdas explaining why they are extracted (shared with the bubble Column via MessageContentParams so in-bubble gestures mirror the cell). --- .../api/stream-chat-android-compose.api | 8 ++-- .../ui/components/messages/MessageContent.kt | 24 +++++------ .../components/messages/PollMessageContent.kt | 20 ++++----- .../ui/messages/list/MessageContainer.kt | 42 ++++++++++--------- .../compose/ui/theme/ChatComponentFactory.kt | 8 ++-- .../ui/theme/ChatComponentFactoryParams.kt | 16 +++---- 6 files changed, 60 insertions(+), 58 deletions(-) diff --git a/stream-chat-android-compose/api/stream-chat-android-compose.api b/stream-chat-android-compose/api/stream-chat-android-compose.api index 0171393c516..0c15541ae5c 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -5004,11 +5004,11 @@ public final class io/getstream/chat/android/compose/ui/theme/MessageContentPara public final fun getMessageItem ()Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState; public final fun getOnAddAnswer ()Lkotlin/jvm/functions/Function3; public final fun getOnAddPollOption ()Lkotlin/jvm/functions/Function2; + public final fun getOnBubbleClick ()Lkotlin/jvm/functions/Function0; + public final fun getOnBubbleLongClick ()Lkotlin/jvm/functions/Function0; public final fun getOnCastVote ()Lkotlin/jvm/functions/Function3; public final fun getOnClosePoll ()Lkotlin/jvm/functions/Function1; public final fun getOnGiphyActionClick ()Lkotlin/jvm/functions/Function1; - public final fun getOnItemClick ()Lkotlin/jvm/functions/Function0; - public final fun getOnItemLongClick ()Lkotlin/jvm/functions/Function0; public final fun getOnLinkClick ()Lkotlin/jvm/functions/Function2; public final fun getOnLongItemClick ()Lkotlin/jvm/functions/Function1; public final fun getOnMediaGalleryPreviewResult ()Lkotlin/jvm/functions/Function1; @@ -5430,8 +5430,8 @@ public final class io/getstream/chat/android/compose/ui/theme/MessageRegularCont public final fun getCurrentUser ()Lio/getstream/chat/android/models/User; public final fun getMessage ()Lio/getstream/chat/android/models/Message; public final fun getMessageAlignment ()Lio/getstream/chat/android/compose/state/messages/MessageAlignment; - public final fun getOnItemClick ()Lkotlin/jvm/functions/Function0; - public final fun getOnItemLongClick ()Lkotlin/jvm/functions/Function0; + public final fun getOnBubbleClick ()Lkotlin/jvm/functions/Function0; + public final fun getOnBubbleLongClick ()Lkotlin/jvm/functions/Function0; public final fun getOnLinkClick ()Lkotlin/jvm/functions/Function2; public final fun getOnLongItemClick ()Lkotlin/jvm/functions/Function1; public final fun getOnMediaGalleryPreviewResult ()Lkotlin/jvm/functions/Function1; diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageContent.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageContent.kt index 2f819f02a96..7ff6560cdeb 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageContent.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageContent.kt @@ -79,10 +79,10 @@ import io.getstream.chat.android.ui.common.utils.extensions.hasLink * @param onQuotedMessageClick Handler for quoted message click action. * @param onLinkClick Handler for clicking on a link in the message. * @param onMediaGalleryPreviewResult Handler when the user selects an option in the Media Gallery Preview screen. - * @param onItemClick Handler invoked when the bubble is tapped. Mirrors the surrounding cell's + * @param onBubbleClick Handler invoked when the bubble is tapped. Mirrors the surrounding cell's * click intent so taps inside the bubble Column behave identically to taps in the avatar gap * (e.g. opening a thread for thread-start messages). - * @param onItemLongClick Handler invoked when the bubble is long-pressed. Mirrors the surrounding + * @param onBubbleLongClick Handler invoked when the bubble is long-pressed. Mirrors the surrounding * cell's long-click intent (haptic feedback + action menu, gated on whether actions are * available) so long-presses inside the bubble Column behave identically to those in the * avatar gap. @@ -99,8 +99,8 @@ public fun MessageContent( messageAlignment: MessageAlignment = MessageAlignment.Start, onLinkClick: ((Message, String) -> Unit)? = null, onMediaGalleryPreviewResult: (MediaGalleryPreviewResult?) -> Unit = {}, - onItemClick: () -> Unit = {}, - onItemLongClick: () -> Unit = {}, + onBubbleClick: () -> Unit = {}, + onBubbleLongClick: () -> Unit = {}, ) { when { message.isGiphyEphemeral() -> ChatTheme.componentFactory.MessageGiphyContent( @@ -129,8 +129,8 @@ public fun MessageContent( onQuotedMessageClick = onQuotedMessageClick, onLinkClick = onLinkClick, onUserMentionClick = onUserMentionClick, - onItemClick = onItemClick, - onItemLongClick = onItemLongClick, + onBubbleClick = onBubbleClick, + onBubbleLongClick = onBubbleLongClick, ), ) } @@ -176,8 +176,8 @@ internal fun DefaultMessageDeletedContent( * @param onMediaGalleryPreviewResult Handler when the user selects an option in the Media Gallery Preview screen. * @param onQuotedMessageClick Handler for quoted message click action. * @param onLinkClick Handler for clicking on a link in the message. - * @param onItemClick Handler invoked when the bubble is tapped. - * @param onItemLongClick Handler invoked when the bubble is long-pressed. + * @param onBubbleClick Handler invoked when the bubble is tapped. + * @param onBubbleLongClick Handler invoked when the bubble is long-pressed. */ @Composable @Suppress("LongMethod") @@ -190,8 +190,8 @@ internal fun DefaultMessageRegularContent( onQuotedMessageClick: (Message) -> Unit, onUserMentionClick: (User) -> Unit = {}, onLinkClick: ((Message, String) -> Unit)? = null, - onItemClick: () -> Unit = {}, - onItemLongClick: () -> Unit = {}, + onBubbleClick: () -> Unit = {}, + onBubbleLongClick: () -> Unit = {}, ) { val componentFactory = ChatTheme.componentFactory @@ -199,8 +199,8 @@ internal fun DefaultMessageRegularContent( modifier = Modifier.combinedClickable( interactionSource = remember(::MutableInteractionSource), indication = ripple(), - onClick = onItemClick, - onLongClick = onItemLongClick, + onClick = onBubbleClick, + onLongClick = onBubbleLongClick, ), horizontalAlignment = messageAlignment.contentAlignment, ) { diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/PollMessageContent.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/PollMessageContent.kt index 09e45182b5a..542f62992f7 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/PollMessageContent.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/PollMessageContent.kt @@ -104,10 +104,10 @@ import io.getstream.chat.android.ui.common.R as UiCommonR * @param interactionSource The interaction source from the surrounding message cell, forwarded to * the bubble so it can render a ripple synchronised with the cell's press state. `null` outside * a cell context (e.g. previews). - * @param onItemClick Handler invoked when the bubble is tapped. Mirrors the surrounding cell's + * @param onBubbleClick Handler invoked when the bubble is tapped. Mirrors the surrounding cell's * click intent so taps inside the bubble Column behave identically to taps in the avatar gap * (e.g. opening a thread for thread-start messages). - * @param onItemLongClick Handler invoked when the bubble is long-pressed. Mirrors the surrounding + * @param onBubbleLongClick Handler invoked when the bubble is long-pressed. Mirrors the surrounding * cell's long-click intent (haptic feedback + action menu, gated on whether actions are * available) so long-presses inside the bubble Column behave identically to those in the * avatar gap. @@ -125,8 +125,8 @@ public fun PollMessageContent( onAddPollOption: (poll: Poll, option: String) -> Unit, onLongItemClick: (Message) -> Unit = {}, interactionSource: InteractionSource? = null, - onItemClick: () -> Unit = {}, - onItemLongClick: () -> Unit = {}, + onBubbleClick: () -> Unit = {}, + onBubbleLongClick: () -> Unit = {}, ) { val message = messageItem.message val ownsMessage = messageItem.isMine @@ -161,8 +161,8 @@ public fun PollMessageContent( }, onClosePoll = onClosePoll, onAddPollOption = onAddPollOption, - onItemClick = onItemClick, - onItemLongClick = onItemLongClick, + onBubbleClick = onBubbleClick, + onBubbleLongClick = onBubbleLongClick, ) }, ), @@ -215,8 +215,8 @@ private fun PollMessageContent( onRemoveVote: (Vote) -> Unit, onAddPollOption: (poll: Poll, option: String) -> Unit, selectPoll: (Message, Poll, PollSelectionType) -> Unit, - onItemClick: () -> Unit = {}, - onItemLongClick: () -> Unit = {}, + onBubbleClick: () -> Unit = {}, + onBubbleLongClick: () -> Unit = {}, ) { val context = LocalContext.current val showDialog = remember { mutableStateOf(false) } @@ -255,8 +255,8 @@ private fun PollMessageContent( .combinedClickable( interactionSource = remember(::MutableInteractionSource), indication = ripple(), - onClick = onItemClick, - onLongClick = onItemLongClick, + onClick = onBubbleClick, + onLongClick = onBubbleLongClick, ) .padding(StreamTokens.spacingMd), ) { diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/MessageContainer.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/MessageContainer.kt index a6f8885fbc6..07b94472a50 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/MessageContainer.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/MessageContainer.kt @@ -179,8 +179,10 @@ public fun MessageContainer( val canOpenActions = !message.isDeleted() && !message.isUploading() val interactionSource = remember { MutableInteractionSource() } - val onItemClick: () -> Unit = { if (canOpenThread) onThreadClick(message) } - val onItemLongClick: () -> Unit = { + // Shared with the bubble Column via MessageContentParams so in-bubble taps and long-presses + // behave identically to those landing in the avatar gap. + val onBubbleClick: () -> Unit = { if (canOpenThread) onThreadClick(message) } + val onBubbleLongClick: () -> Unit = { if (canOpenActions) { haptic.performHapticFeedback(HapticFeedbackType.LongPress) onLongItemClick(message) @@ -190,8 +192,8 @@ public fun MessageContainer( interactionSource = interactionSource, indication = null, enabled = canOpenThread || canOpenActions, - onClick = onItemClick, - onLongClick = onItemLongClick, + onClick = onBubbleClick, + onLongClick = onBubbleLongClick, ) val highlightColor = ChatTheme.colors.backgroundCoreHighlight @@ -279,8 +281,8 @@ public fun MessageContainer( MessageContent( params = MessageContentParams( messageItem = messageItem, - onItemClick = onItemClick, - onItemLongClick = onItemLongClick, + onBubbleClick = onBubbleClick, + onBubbleLongClick = onBubbleLongClick, onLongItemClick = onLongItemClick, onMediaGalleryPreviewResult = onMediaGalleryPreviewResult, onGiphyActionClick = onGiphyActionClick, @@ -612,10 +614,10 @@ internal fun ColumnScope.DefaultMessageBottom( * @param interactionSource The interaction source from the surrounding message cell, threaded * through to the bubble so it can render a ripple synchronised with the cell's press state. * `null` outside a cell context (e.g. previews). - * @param onItemClick Handler invoked when the bubble is tapped. Mirrors the surrounding cell's + * @param onBubbleClick Handler invoked when the bubble is tapped. Mirrors the surrounding cell's * click intent so taps inside the bubble Column behave identically to taps in the avatar gap * (e.g. opening a thread for thread-start messages). - * @param onItemLongClick Handler invoked when the bubble is long-pressed. Mirrors the surrounding + * @param onBubbleLongClick Handler invoked when the bubble is long-pressed. Mirrors the surrounding * cell's long-click intent (haptic feedback + action menu, gated on whether actions are * available) so long-presses inside the bubble Column behave identically to those in the * avatar gap. @@ -639,8 +641,8 @@ public fun DefaultMessageContent( onClosePoll: (String) -> Unit, onAddPollOption: (poll: Poll, option: String) -> Unit, interactionSource: InteractionSource? = null, - onItemClick: () -> Unit = {}, - onItemLongClick: () -> Unit = {}, + onBubbleClick: () -> Unit = {}, + onBubbleLongClick: () -> Unit = {}, ) { val finalModifier = modifier.widthIn(max = 264.dp) if (messageItem.message.isPoll() && !messageItem.message.isDeleted()) { @@ -662,8 +664,8 @@ public fun DefaultMessageContent( onLongItemClick = onLongItemClick, onAddAnswer = onAddAnswer, interactionSource = interactionSource, - onItemClick = onItemClick, - onItemLongClick = onItemLongClick, + onBubbleClick = onBubbleClick, + onBubbleLongClick = onBubbleLongClick, ) } else if (messageItem.message.isEmojiOnlyWithoutBubble()) { EmojiMessageContent( @@ -685,8 +687,8 @@ public fun DefaultMessageContent( onLinkClick = onLinkClick, onUserMentionClick = onUserMentionClick, interactionSource = interactionSource, - onItemClick = onItemClick, - onItemLongClick = onItemLongClick, + onBubbleClick = onBubbleClick, + onBubbleLongClick = onBubbleLongClick, ) } } @@ -758,10 +760,10 @@ public fun EmojiMessageContent( * @param interactionSource The interaction source from the surrounding message cell, forwarded to * the bubble so it can render a ripple synchronised with the cell's press state. `null` outside * a cell context (e.g. previews). - * @param onItemClick Handler invoked when the bubble is tapped. Mirrors the surrounding cell's + * @param onBubbleClick Handler invoked when the bubble is tapped. Mirrors the surrounding cell's * click intent so taps inside the bubble Column behave identically to taps in the avatar gap * (e.g. opening a thread for thread-start messages). - * @param onItemLongClick Handler invoked when the bubble is long-pressed. Mirrors the surrounding + * @param onBubbleLongClick Handler invoked when the bubble is long-pressed. Mirrors the surrounding * cell's long-click intent (haptic feedback + action menu, gated on whether actions are * available) so long-presses inside the bubble Column behave identically to those in the * avatar gap. @@ -777,8 +779,8 @@ public fun RegularMessageContent( onUserMentionClick: (User) -> Unit = {}, onMediaGalleryPreviewResult: (MediaGalleryPreviewResult?) -> Unit = {}, interactionSource: InteractionSource? = null, - onItemClick: () -> Unit = {}, - onItemLongClick: () -> Unit = {}, + onBubbleClick: () -> Unit = {}, + onBubbleLongClick: () -> Unit = {}, ) { val message = messageItem.message val ownsMessage = messageItem.isMine @@ -798,8 +800,8 @@ public fun RegularMessageContent( onQuotedMessageClick = onQuotedMessageClick, onLinkClick = onLinkClick, onUserMentionClick = onUserMentionClick, - onItemClick = onItemClick, - onItemLongClick = onItemLongClick, + onBubbleClick = onBubbleClick, + onBubbleLongClick = onBubbleLongClick, ) } if (!messageItem.isErrorOrFailed()) { diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt index 737a42413b3..e08cb34c4f0 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt @@ -1063,8 +1063,8 @@ public interface ChatComponentFactory { public fun MessageContent(params: MessageContentParams) { DefaultMessageContent( messageItem = params.messageItem, - onItemClick = params.onItemClick, - onItemLongClick = params.onItemLongClick, + onBubbleClick = params.onBubbleClick, + onBubbleLongClick = params.onBubbleLongClick, onLongItemClick = params.onLongItemClick, onGiphyActionClick = params.onGiphyActionClick, onQuotedMessageClick = params.onQuotedMessageClick, @@ -1154,8 +1154,8 @@ public interface ChatComponentFactory { onQuotedMessageClick = params.onQuotedMessageClick, onUserMentionClick = params.onUserMentionClick, onLinkClick = params.onLinkClick, - onItemClick = params.onItemClick, - onItemLongClick = params.onItemLongClick, + onBubbleClick = params.onBubbleClick, + onBubbleLongClick = params.onBubbleLongClick, ) } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt index c2bb7e4de4e..5f7ba1e664c 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt @@ -770,10 +770,10 @@ public data class MessageAuthorParams( * Parameters for [ChatComponentFactory.MessageContent]. * * @param messageItem The message item state. - * @param onItemClick Action invoked when the bubble is tapped. Mirrors the surrounding cell's + * @param onBubbleClick Action invoked when the bubble is tapped. Mirrors the surrounding cell's * click intent (e.g. opening a thread for thread-start messages) so taps inside the bubble * Column behave identically to taps in the avatar gap. - * @param onItemLongClick Action invoked when the bubble is long-pressed. Mirrors the surrounding + * @param onBubbleLongClick Action invoked when the bubble is long-pressed. Mirrors the surrounding * cell's long-click intent (haptic feedback + action menu, gated on whether actions are * available) so long-presses inside the bubble Column behave identically to those in the * avatar gap. @@ -796,8 +796,8 @@ public data class MessageAuthorParams( */ public data class MessageContentParams( val messageItem: MessageItemState, - val onItemClick: () -> Unit = {}, - val onItemLongClick: () -> Unit = {}, + val onBubbleClick: () -> Unit = {}, + val onBubbleLongClick: () -> Unit = {}, val onLongItemClick: (Message) -> Unit = {}, val onPollUpdated: (Message, Poll) -> Unit = { _, _ -> }, val onCastVote: (Message, Poll, Option) -> Unit = { _, _, _ -> }, @@ -860,9 +860,9 @@ public data class MessageDeletedContentParams( * @param onQuotedMessageClick Action invoked when a quoted message is clicked. * @param onUserMentionClick Action invoked when a user mention is clicked. * @param onLinkClick Action invoked when a link in a message is clicked. - * @param onItemClick Action invoked when the bubble is tapped. Mirrors the surrounding cell's + * @param onBubbleClick Action invoked when the bubble is tapped. Mirrors the surrounding cell's * click intent so taps inside the bubble Column behave identically to taps in the avatar gap. - * @param onItemLongClick Action invoked when the bubble is long-pressed. Mirrors the surrounding + * @param onBubbleLongClick Action invoked when the bubble is long-pressed. Mirrors the surrounding * cell's long-click intent (haptic feedback + action menu, gated on whether actions are * available) so long-presses inside the bubble Column behave identically to those in the * avatar gap. @@ -876,8 +876,8 @@ public data class MessageRegularContentParams( val onQuotedMessageClick: (Message) -> Unit, val onUserMentionClick: (User) -> Unit, val onLinkClick: ((Message, String) -> Unit)? = null, - val onItemClick: () -> Unit = {}, - val onItemLongClick: () -> Unit = {}, + val onBubbleClick: () -> Unit = {}, + val onBubbleLongClick: () -> Unit = {}, ) /** From e317584bf300fef43c2c35e1ff93100f040e317a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 8 May 2026 11:08:04 +0100 Subject: [PATCH 06/11] Replace bubble-mirror plumbing with non-consuming ripple modifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the onBubbleClick / onBubbleLongClick callback chain plus the interactionSource forwarding introduced earlier in this branch. The bubble Column now uses a single internal Modifier (passiveRipple) that renders a bounded position-aware ripple via a non-consuming pointerInput. The cell's combinedClickable stays as the single owner of click and long-click logic, and inner clickable children (attachments, quoted-reply previews) keep their own ripples — they are filtered out at the Column level via awaitFirstDown(requireUnconsumed = true), so they do not double-fire. passiveRipple is named after what it does (renders a ripple on press without claiming the gesture), not where it is used. It lives in ui/util and is reusable beyond message bubbles. Behaviour change: avatar-gap presses no longer render an unbounded ripple inside the bubble (the cell-source-driven indication on MessageBubble is removed). Cell click and long-press still fire there; only the visual feedback in that region is dropped, matching the WhatsApp pattern where the avatar gap is a dead zone visually. Net public API: MessageBubbleParams loses interactionSource; MessageContentParams and MessageRegularContentParams lose onBubbleClick, onBubbleLongClick, and interactionSource; DefaultMessageContent / RegularMessageContent / MessageContent / PollMessageContent lose the same trailing params. Surface shrinks back below the pre-attempt baseline. --- .../api/stream-chat-android-compose.api | 60 ++++++++---------- .../ui/components/messages/MessageContent.kt | 23 +------ .../components/messages/PollMessageContent.kt | 31 +--------- .../ui/messages/list/MessageContainer.kt | 61 +++---------------- .../compose/ui/theme/ChatComponentFactory.kt | 18 +----- .../ui/theme/ChatComponentFactoryParams.kt | 27 -------- .../android/compose/ui/util/ModifierUtils.kt | 35 +++++++++++ 7 files changed, 72 insertions(+), 183 deletions(-) diff --git a/stream-chat-android-compose/api/stream-chat-android-compose.api b/stream-chat-android-compose/api/stream-chat-android-compose.api index 0c15541ae5c..5fa3cf32382 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -1560,7 +1560,7 @@ public final class io/getstream/chat/android/compose/ui/components/messages/Mess } public final class io/getstream/chat/android/compose/ui/components/messages/MessageContentKt { - public static final fun MessageContent (Lio/getstream/chat/android/models/Message;Lio/getstream/chat/android/models/User;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lio/getstream/chat/android/compose/state/messages/MessageAlignment;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;III)V + public static final fun MessageContent (Lio/getstream/chat/android/models/Message;Lio/getstream/chat/android/models/User;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lio/getstream/chat/android/compose/state/messages/MessageAlignment;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V } public final class io/getstream/chat/android/compose/ui/components/messages/MessageFooterKt { @@ -1581,7 +1581,7 @@ public final class io/getstream/chat/android/compose/ui/components/messages/Mess } public final class io/getstream/chat/android/compose/ui/components/messages/PollMessageContentKt { - public static final fun PollMessageContent (Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Landroidx/compose/foundation/interaction/InteractionSource;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;III)V + public static final fun PollMessageContent (Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V } public final class io/getstream/chat/android/compose/ui/components/messages/QuotedMessageKt { @@ -2136,10 +2136,10 @@ public final class io/getstream/chat/android/compose/ui/messages/list/Composable public final class io/getstream/chat/android/compose/ui/messages/list/MessageContainerKt { public static final field HighlightFadeOutDurationMillis I - public static final fun DefaultMessageContent (Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Landroidx/compose/foundation/interaction/InteractionSource;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;III)V + public static final fun DefaultMessageContent (Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;III)V public static final fun EmojiMessageContent (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V public static final fun MessageContainer (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;III)V - public static final fun RegularMessageContent (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Landroidx/compose/foundation/interaction/InteractionSource;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;III)V + public static final fun RegularMessageContent (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V } public final class io/getstream/chat/android/compose/ui/messages/list/MessageItemKt { @@ -4331,22 +4331,20 @@ public final class io/getstream/chat/android/compose/ui/theme/MessageBottomParam public final class io/getstream/chat/android/compose/ui/theme/MessageBubbleParams { public static final field $stable I - public synthetic fun (Lio/getstream/chat/android/models/Message;JLandroidx/compose/ui/graphics/Shape;Lkotlin/jvm/functions/Function2;Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/BorderStroke;Landroidx/compose/foundation/interaction/InteractionSource;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public synthetic fun (Lio/getstream/chat/android/models/Message;JLandroidx/compose/ui/graphics/Shape;Lkotlin/jvm/functions/Function2;Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/BorderStroke;Landroidx/compose/foundation/interaction/InteractionSource;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Lio/getstream/chat/android/models/Message;JLandroidx/compose/ui/graphics/Shape;Lkotlin/jvm/functions/Function2;Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/BorderStroke;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Lio/getstream/chat/android/models/Message;JLandroidx/compose/ui/graphics/Shape;Lkotlin/jvm/functions/Function2;Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/BorderStroke;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lio/getstream/chat/android/models/Message; public final fun component2-0d7_KjU ()J public final fun component3 ()Landroidx/compose/ui/graphics/Shape; public final fun component4 ()Lkotlin/jvm/functions/Function2; public final fun component5 ()Landroidx/compose/ui/Modifier; public final fun component6 ()Landroidx/compose/foundation/BorderStroke; - public final fun component7 ()Landroidx/compose/foundation/interaction/InteractionSource; - public final fun copy-sW7UJKQ (Lio/getstream/chat/android/models/Message;JLandroidx/compose/ui/graphics/Shape;Lkotlin/jvm/functions/Function2;Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/BorderStroke;Landroidx/compose/foundation/interaction/InteractionSource;)Lio/getstream/chat/android/compose/ui/theme/MessageBubbleParams; - public static synthetic fun copy-sW7UJKQ$default (Lio/getstream/chat/android/compose/ui/theme/MessageBubbleParams;Lio/getstream/chat/android/models/Message;JLandroidx/compose/ui/graphics/Shape;Lkotlin/jvm/functions/Function2;Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/BorderStroke;Landroidx/compose/foundation/interaction/InteractionSource;ILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/theme/MessageBubbleParams; + public final fun copy-3IgeMak (Lio/getstream/chat/android/models/Message;JLandroidx/compose/ui/graphics/Shape;Lkotlin/jvm/functions/Function2;Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/BorderStroke;)Lio/getstream/chat/android/compose/ui/theme/MessageBubbleParams; + public static synthetic fun copy-3IgeMak$default (Lio/getstream/chat/android/compose/ui/theme/MessageBubbleParams;Lio/getstream/chat/android/models/Message;JLandroidx/compose/ui/graphics/Shape;Lkotlin/jvm/functions/Function2;Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/BorderStroke;ILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/theme/MessageBubbleParams; public fun equals (Ljava/lang/Object;)Z public final fun getBorder ()Landroidx/compose/foundation/BorderStroke; public final fun getColor-0d7_KjU ()J public final fun getContent ()Lkotlin/jvm/functions/Function2; - public final fun getInteractionSource ()Landroidx/compose/foundation/interaction/InteractionSource; public final fun getMessage ()Lio/getstream/chat/android/models/Message; public final fun getModifier ()Landroidx/compose/ui/Modifier; public final fun getShape ()Landroidx/compose/ui/graphics/Shape; @@ -4978,34 +4976,28 @@ public final class io/getstream/chat/android/compose/ui/theme/MessageContainerPa public final class io/getstream/chat/android/compose/ui/theme/MessageContentParams { public static final field $stable I - public fun (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Landroidx/compose/foundation/interaction/InteractionSource;)V - public synthetic fun (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Landroidx/compose/foundation/interaction/InteractionSource;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V + public synthetic fun (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState; public final fun component10 ()Lkotlin/jvm/functions/Function1; - public final fun component11 ()Lkotlin/jvm/functions/Function2; + public final fun component11 ()Lkotlin/jvm/functions/Function1; public final fun component12 ()Lkotlin/jvm/functions/Function1; public final fun component13 ()Lkotlin/jvm/functions/Function1; - public final fun component14 ()Lkotlin/jvm/functions/Function1; - public final fun component15 ()Lkotlin/jvm/functions/Function1; - public final fun component16 ()Lkotlin/jvm/functions/Function2; - public final fun component17 ()Landroidx/compose/foundation/interaction/InteractionSource; - public final fun component2 ()Lkotlin/jvm/functions/Function0; - public final fun component3 ()Lkotlin/jvm/functions/Function0; - public final fun component4 ()Lkotlin/jvm/functions/Function1; - public final fun component5 ()Lkotlin/jvm/functions/Function2; + public final fun component14 ()Lkotlin/jvm/functions/Function2; + public final fun component2 ()Lkotlin/jvm/functions/Function1; + public final fun component3 ()Lkotlin/jvm/functions/Function2; + public final fun component4 ()Lkotlin/jvm/functions/Function3; + public final fun component5 ()Lkotlin/jvm/functions/Function3; public final fun component6 ()Lkotlin/jvm/functions/Function3; public final fun component7 ()Lkotlin/jvm/functions/Function3; - public final fun component8 ()Lkotlin/jvm/functions/Function3; - public final fun component9 ()Lkotlin/jvm/functions/Function3; - public final fun copy (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Landroidx/compose/foundation/interaction/InteractionSource;)Lio/getstream/chat/android/compose/ui/theme/MessageContentParams; - public static synthetic fun copy$default (Lio/getstream/chat/android/compose/ui/theme/MessageContentParams;Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Landroidx/compose/foundation/interaction/InteractionSource;ILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/theme/MessageContentParams; + public final fun component8 ()Lkotlin/jvm/functions/Function1; + public final fun component9 ()Lkotlin/jvm/functions/Function2; + public final fun copy (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)Lio/getstream/chat/android/compose/ui/theme/MessageContentParams; + public static synthetic fun copy$default (Lio/getstream/chat/android/compose/ui/theme/MessageContentParams;Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/theme/MessageContentParams; public fun equals (Ljava/lang/Object;)Z - public final fun getInteractionSource ()Landroidx/compose/foundation/interaction/InteractionSource; public final fun getMessageItem ()Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState; public final fun getOnAddAnswer ()Lkotlin/jvm/functions/Function3; public final fun getOnAddPollOption ()Lkotlin/jvm/functions/Function2; - public final fun getOnBubbleClick ()Lkotlin/jvm/functions/Function0; - public final fun getOnBubbleLongClick ()Lkotlin/jvm/functions/Function0; public final fun getOnCastVote ()Lkotlin/jvm/functions/Function3; public final fun getOnClosePoll ()Lkotlin/jvm/functions/Function1; public final fun getOnGiphyActionClick ()Lkotlin/jvm/functions/Function1; @@ -5412,10 +5404,9 @@ public final class io/getstream/chat/android/compose/ui/theme/MessageReactionsPi public final class io/getstream/chat/android/compose/ui/theme/MessageRegularContentParams { public static final field $stable I - public fun (Lio/getstream/chat/android/models/Message;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/compose/state/messages/MessageAlignment;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;)V - public synthetic fun (Lio/getstream/chat/android/models/Message;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/compose/state/messages/MessageAlignment;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/getstream/chat/android/models/Message;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/compose/state/messages/MessageAlignment;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V + public synthetic fun (Lio/getstream/chat/android/models/Message;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/compose/state/messages/MessageAlignment;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lio/getstream/chat/android/models/Message; - public final fun component10 ()Lkotlin/jvm/functions/Function0; public final fun component2 ()Lio/getstream/chat/android/models/User; public final fun component3 ()Lio/getstream/chat/android/compose/state/messages/MessageAlignment; public final fun component4 ()Lkotlin/jvm/functions/Function1; @@ -5423,15 +5414,12 @@ public final class io/getstream/chat/android/compose/ui/theme/MessageRegularCont public final fun component6 ()Lkotlin/jvm/functions/Function1; public final fun component7 ()Lkotlin/jvm/functions/Function1; public final fun component8 ()Lkotlin/jvm/functions/Function2; - public final fun component9 ()Lkotlin/jvm/functions/Function0; - public final fun copy (Lio/getstream/chat/android/models/Message;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/compose/state/messages/MessageAlignment;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;)Lio/getstream/chat/android/compose/ui/theme/MessageRegularContentParams; - public static synthetic fun copy$default (Lio/getstream/chat/android/compose/ui/theme/MessageRegularContentParams;Lio/getstream/chat/android/models/Message;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/compose/state/messages/MessageAlignment;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/theme/MessageRegularContentParams; + public final fun copy (Lio/getstream/chat/android/models/Message;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/compose/state/messages/MessageAlignment;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)Lio/getstream/chat/android/compose/ui/theme/MessageRegularContentParams; + public static synthetic fun copy$default (Lio/getstream/chat/android/compose/ui/theme/MessageRegularContentParams;Lio/getstream/chat/android/models/Message;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/compose/state/messages/MessageAlignment;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/theme/MessageRegularContentParams; public fun equals (Ljava/lang/Object;)Z public final fun getCurrentUser ()Lio/getstream/chat/android/models/User; public final fun getMessage ()Lio/getstream/chat/android/models/Message; public final fun getMessageAlignment ()Lio/getstream/chat/android/compose/state/messages/MessageAlignment; - public final fun getOnBubbleClick ()Lkotlin/jvm/functions/Function0; - public final fun getOnBubbleLongClick ()Lkotlin/jvm/functions/Function0; public final fun getOnLinkClick ()Lkotlin/jvm/functions/Function2; public final fun getOnLongItemClick ()Lkotlin/jvm/functions/Function1; public final fun getOnMediaGalleryPreviewResult ()Lkotlin/jvm/functions/Function1; diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageContent.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageContent.kt index 7ff6560cdeb..02f135819d4 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageContent.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageContent.kt @@ -62,6 +62,7 @@ import io.getstream.chat.android.compose.ui.theme.MessageRegularContentParams import io.getstream.chat.android.compose.ui.theme.MessageStyling import io.getstream.chat.android.compose.ui.theme.MessageTextContentParams import io.getstream.chat.android.compose.ui.theme.StreamTokens +import io.getstream.chat.android.compose.ui.util.passiveRipple import io.getstream.chat.android.compose.ui.util.shouldBeDisplayedAsFullSizeAttachment import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.User @@ -79,13 +80,6 @@ import io.getstream.chat.android.ui.common.utils.extensions.hasLink * @param onQuotedMessageClick Handler for quoted message click action. * @param onLinkClick Handler for clicking on a link in the message. * @param onMediaGalleryPreviewResult Handler when the user selects an option in the Media Gallery Preview screen. - * @param onBubbleClick Handler invoked when the bubble is tapped. Mirrors the surrounding cell's - * click intent so taps inside the bubble Column behave identically to taps in the avatar gap - * (e.g. opening a thread for thread-start messages). - * @param onBubbleLongClick Handler invoked when the bubble is long-pressed. Mirrors the surrounding - * cell's long-click intent (haptic feedback + action menu, gated on whether actions are - * available) so long-presses inside the bubble Column behave identically to those in the - * avatar gap. */ @Composable public fun MessageContent( @@ -99,8 +93,6 @@ public fun MessageContent( messageAlignment: MessageAlignment = MessageAlignment.Start, onLinkClick: ((Message, String) -> Unit)? = null, onMediaGalleryPreviewResult: (MediaGalleryPreviewResult?) -> Unit = {}, - onBubbleClick: () -> Unit = {}, - onBubbleLongClick: () -> Unit = {}, ) { when { message.isGiphyEphemeral() -> ChatTheme.componentFactory.MessageGiphyContent( @@ -129,8 +121,6 @@ public fun MessageContent( onQuotedMessageClick = onQuotedMessageClick, onLinkClick = onLinkClick, onUserMentionClick = onUserMentionClick, - onBubbleClick = onBubbleClick, - onBubbleLongClick = onBubbleLongClick, ), ) } @@ -176,8 +166,6 @@ internal fun DefaultMessageDeletedContent( * @param onMediaGalleryPreviewResult Handler when the user selects an option in the Media Gallery Preview screen. * @param onQuotedMessageClick Handler for quoted message click action. * @param onLinkClick Handler for clicking on a link in the message. - * @param onBubbleClick Handler invoked when the bubble is tapped. - * @param onBubbleLongClick Handler invoked when the bubble is long-pressed. */ @Composable @Suppress("LongMethod") @@ -190,18 +178,11 @@ internal fun DefaultMessageRegularContent( onQuotedMessageClick: (Message) -> Unit, onUserMentionClick: (User) -> Unit = {}, onLinkClick: ((Message, String) -> Unit)? = null, - onBubbleClick: () -> Unit = {}, - onBubbleLongClick: () -> Unit = {}, ) { val componentFactory = ChatTheme.componentFactory Column( - modifier = Modifier.combinedClickable( - interactionSource = remember(::MutableInteractionSource), - indication = ripple(), - onClick = onBubbleClick, - onLongClick = onBubbleLongClick, - ), + modifier = Modifier.passiveRipple(), horizontalAlignment = messageAlignment.contentAlignment, ) { val quotedMessage = message.replyTo diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/PollMessageContent.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/PollMessageContent.kt index 542f62992f7..22788ee3df5 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/PollMessageContent.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/PollMessageContent.kt @@ -19,9 +19,6 @@ package io.getstream.chat.android.compose.ui.components.messages import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.interaction.InteractionSource -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -38,7 +35,6 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState @@ -74,6 +70,7 @@ import io.getstream.chat.android.compose.ui.theme.MessageStyling import io.getstream.chat.android.compose.ui.theme.MessageStyling.PollStyle import io.getstream.chat.android.compose.ui.theme.StreamTokens import io.getstream.chat.android.compose.ui.util.isErrorOrFailed +import io.getstream.chat.android.compose.ui.util.passiveRipple import io.getstream.chat.android.compose.util.extensions.toSet import io.getstream.chat.android.models.ChannelCapabilities import io.getstream.chat.android.models.Message @@ -101,16 +98,6 @@ import io.getstream.chat.android.ui.common.R as UiCommonR * @param onClosePoll Callback when a user closes a poll. * @param onAddPollOption Callback when a user adds a new option to the poll. * @param onLongItemClick Handler when the user selects a message, on long tap. - * @param interactionSource The interaction source from the surrounding message cell, forwarded to - * the bubble so it can render a ripple synchronised with the cell's press state. `null` outside - * a cell context (e.g. previews). - * @param onBubbleClick Handler invoked when the bubble is tapped. Mirrors the surrounding cell's - * click intent so taps inside the bubble Column behave identically to taps in the avatar gap - * (e.g. opening a thread for thread-start messages). - * @param onBubbleLongClick Handler invoked when the bubble is long-pressed. Mirrors the surrounding - * cell's long-click intent (haptic feedback + action menu, gated on whether actions are - * available) so long-presses inside the bubble Column behave identically to those in the - * avatar gap. */ @Suppress("LongParameterList", "LongMethod") @Composable @@ -124,9 +111,6 @@ public fun PollMessageContent( onClosePoll: (String) -> Unit, onAddPollOption: (poll: Poll, option: String) -> Unit, onLongItemClick: (Message) -> Unit = {}, - interactionSource: InteractionSource? = null, - onBubbleClick: () -> Unit = {}, - onBubbleLongClick: () -> Unit = {}, ) { val message = messageItem.message val ownsMessage = messageItem.isMine @@ -143,7 +127,6 @@ public fun PollMessageContent( message = message, shape = messageBubbleShape, color = messageBubbleColor, - interactionSource = interactionSource, content = { PollMessageContent( message = message, @@ -161,8 +144,6 @@ public fun PollMessageContent( }, onClosePoll = onClosePoll, onAddPollOption = onAddPollOption, - onBubbleClick = onBubbleClick, - onBubbleLongClick = onBubbleLongClick, ) }, ), @@ -176,7 +157,6 @@ public fun PollMessageContent( shape = messageBubbleShape, color = messageBubbleColor, border = BorderStroke(1.dp, ChatTheme.colors.borderCoreDefault), - interactionSource = interactionSource, content = { MessageContent( message = message, @@ -215,8 +195,6 @@ private fun PollMessageContent( onRemoveVote: (Vote) -> Unit, onAddPollOption: (poll: Poll, option: String) -> Unit, selectPoll: (Message, Poll, PollSelectionType) -> Unit, - onBubbleClick: () -> Unit = {}, - onBubbleLongClick: () -> Unit = {}, ) { val context = LocalContext.current val showDialog = remember { mutableStateOf(false) } @@ -252,12 +230,7 @@ private fun PollMessageContent( Column( modifier = Modifier - .combinedClickable( - interactionSource = remember(::MutableInteractionSource), - indication = ripple(), - onClick = onBubbleClick, - onLongClick = onBubbleLongClick, - ) + .passiveRipple() .padding(StreamTokens.spacingMd), ) { Text( diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/MessageContainer.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/MessageContainer.kt index 07b94472a50..4ba7665d729 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/MessageContainer.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/MessageContainer.kt @@ -23,7 +23,6 @@ import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.gestures.detectHorizontalDragGestures -import androidx.compose.foundation.interaction.InteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -178,22 +177,17 @@ public fun MessageContainer( val canOpenThread = message.isThreadStart() && !messageItem.isInThread val canOpenActions = !message.isDeleted() && !message.isUploading() - val interactionSource = remember { MutableInteractionSource() } - // Shared with the bubble Column via MessageContentParams so in-bubble taps and long-presses - // behave identically to those landing in the avatar gap. - val onBubbleClick: () -> Unit = { if (canOpenThread) onThreadClick(message) } - val onBubbleLongClick: () -> Unit = { - if (canOpenActions) { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - onLongItemClick(message) - } - } val clickModifier = Modifier.combinedClickable( - interactionSource = interactionSource, + interactionSource = remember(::MutableInteractionSource), indication = null, enabled = canOpenThread || canOpenActions, - onClick = onBubbleClick, - onLongClick = onBubbleLongClick, + onClick = { if (canOpenThread) onThreadClick(message) }, + onLongClick = { + if (canOpenActions) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onLongItemClick(message) + } + }, ) val highlightColor = ChatTheme.colors.backgroundCoreHighlight @@ -281,8 +275,6 @@ public fun MessageContainer( MessageContent( params = MessageContentParams( messageItem = messageItem, - onBubbleClick = onBubbleClick, - onBubbleLongClick = onBubbleLongClick, onLongItemClick = onLongItemClick, onMediaGalleryPreviewResult = onMediaGalleryPreviewResult, onGiphyActionClick = onGiphyActionClick, @@ -296,7 +288,6 @@ public fun MessageContainer( onAddAnswer = onAddAnswer, onClosePoll = onClosePoll, onAddPollOption = onAddPollOption, - interactionSource = interactionSource, ), ) }, @@ -611,16 +602,6 @@ internal fun ColumnScope.DefaultMessageBottom( * @param onRemoveVote Handler when a user cast a remove on an option. * @param onClosePoll Handler when a user close a poll. * @param onAddPollOption Handler when a user add a poll option. - * @param interactionSource The interaction source from the surrounding message cell, threaded - * through to the bubble so it can render a ripple synchronised with the cell's press state. - * `null` outside a cell context (e.g. previews). - * @param onBubbleClick Handler invoked when the bubble is tapped. Mirrors the surrounding cell's - * click intent so taps inside the bubble Column behave identically to taps in the avatar gap - * (e.g. opening a thread for thread-start messages). - * @param onBubbleLongClick Handler invoked when the bubble is long-pressed. Mirrors the surrounding - * cell's long-click intent (haptic feedback + action menu, gated on whether actions are - * available) so long-presses inside the bubble Column behave identically to those in the - * avatar gap. */ @Suppress("LongParameterList") @Composable @@ -640,9 +621,6 @@ public fun DefaultMessageContent( onAddAnswer: (message: Message, poll: Poll, answer: String) -> Unit, onClosePoll: (String) -> Unit, onAddPollOption: (poll: Poll, option: String) -> Unit, - interactionSource: InteractionSource? = null, - onBubbleClick: () -> Unit = {}, - onBubbleLongClick: () -> Unit = {}, ) { val finalModifier = modifier.widthIn(max = 264.dp) if (messageItem.message.isPoll() && !messageItem.message.isDeleted()) { @@ -663,9 +641,6 @@ public fun DefaultMessageContent( onAddPollOption = onAddPollOption, onLongItemClick = onLongItemClick, onAddAnswer = onAddAnswer, - interactionSource = interactionSource, - onBubbleClick = onBubbleClick, - onBubbleLongClick = onBubbleLongClick, ) } else if (messageItem.message.isEmojiOnlyWithoutBubble()) { EmojiMessageContent( @@ -686,9 +661,6 @@ public fun DefaultMessageContent( onQuotedMessageClick = onQuotedMessageClick, onLinkClick = onLinkClick, onUserMentionClick = onUserMentionClick, - interactionSource = interactionSource, - onBubbleClick = onBubbleClick, - onBubbleLongClick = onBubbleLongClick, ) } } @@ -757,16 +729,6 @@ public fun EmojiMessageContent( * @param onQuotedMessageClick Handler for quoted message click action. * @param onLinkClick Handler for clicking on a link in the message. * @param onMediaGalleryPreviewResult Handler when the user selects an option in the Media Gallery Preview screen. - * @param interactionSource The interaction source from the surrounding message cell, forwarded to - * the bubble so it can render a ripple synchronised with the cell's press state. `null` outside - * a cell context (e.g. previews). - * @param onBubbleClick Handler invoked when the bubble is tapped. Mirrors the surrounding cell's - * click intent so taps inside the bubble Column behave identically to taps in the avatar gap - * (e.g. opening a thread for thread-start messages). - * @param onBubbleLongClick Handler invoked when the bubble is long-pressed. Mirrors the surrounding - * cell's long-click intent (haptic feedback + action menu, gated on whether actions are - * available) so long-presses inside the bubble Column behave identically to those in the - * avatar gap. */ @Composable public fun RegularMessageContent( @@ -778,9 +740,6 @@ public fun RegularMessageContent( onLinkClick: ((Message, String) -> Unit)? = null, onUserMentionClick: (User) -> Unit = {}, onMediaGalleryPreviewResult: (MediaGalleryPreviewResult?) -> Unit = {}, - interactionSource: InteractionSource? = null, - onBubbleClick: () -> Unit = {}, - onBubbleLongClick: () -> Unit = {}, ) { val message = messageItem.message val ownsMessage = messageItem.isMine @@ -800,8 +759,6 @@ public fun RegularMessageContent( onQuotedMessageClick = onQuotedMessageClick, onLinkClick = onLinkClick, onUserMentionClick = onUserMentionClick, - onBubbleClick = onBubbleClick, - onBubbleLongClick = onBubbleLongClick, ) } if (!messageItem.isErrorOrFailed()) { @@ -812,7 +769,6 @@ public fun RegularMessageContent( shape = messageBubbleShape, color = messageBubbleColor, content = content, - interactionSource = interactionSource, ), ) } else { @@ -824,7 +780,6 @@ public fun RegularMessageContent( shape = messageBubbleShape, color = messageBubbleColor, content = content, - interactionSource = interactionSource, ), ) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt index e08cb34c4f0..be3965c2175 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt @@ -18,7 +18,6 @@ package io.getstream.chat.android.compose.ui.theme import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.background -import androidx.compose.foundation.indication import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope @@ -42,12 +41,10 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults -import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource @@ -982,16 +979,8 @@ public interface ChatComponentFactory { */ @Composable public fun MessageBubble(params: MessageBubbleParams) { - val source = params.interactionSource - val indicationModifier = if (source != null) { - Modifier - .clip(params.shape) - .indication(source, ripple(bounded = false)) - } else { - Modifier - } io.getstream.chat.android.compose.ui.components.messages.MessageBubble( - modifier = params.modifier.then(indicationModifier), + modifier = params.modifier, color = params.color, shape = params.shape, border = params.border, @@ -1063,8 +1052,6 @@ public interface ChatComponentFactory { public fun MessageContent(params: MessageContentParams) { DefaultMessageContent( messageItem = params.messageItem, - onBubbleClick = params.onBubbleClick, - onBubbleLongClick = params.onBubbleLongClick, onLongItemClick = params.onLongItemClick, onGiphyActionClick = params.onGiphyActionClick, onQuotedMessageClick = params.onQuotedMessageClick, @@ -1078,7 +1065,6 @@ public interface ChatComponentFactory { onAddAnswer = params.onAddAnswer, onClosePoll = params.onClosePoll, onAddPollOption = params.onAddPollOption, - interactionSource = params.interactionSource, ) } @@ -1154,8 +1140,6 @@ public interface ChatComponentFactory { onQuotedMessageClick = params.onQuotedMessageClick, onUserMentionClick = params.onUserMentionClick, onLinkClick = params.onLinkClick, - onBubbleClick = params.onBubbleClick, - onBubbleLongClick = params.onBubbleLongClick, ) } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt index 5f7ba1e664c..7e52eeaa646 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt @@ -18,7 +18,6 @@ package io.getstream.chat.android.compose.ui.theme import android.net.Uri import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.interaction.InteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.RowScope @@ -709,10 +708,6 @@ public data class MessageContainerParams( * @param content The content shown inside the message bubble. * @param modifier Modifier for styling. * @param border The border of the message bubble. - * @param interactionSource The interaction source from the surrounding message cell. The default - * factory implementation renders an unbounded ripple inside the bubble shape when this is non-null, - * synchronised with the cell's press state. `null` outside a cell context (e.g. quoted-message - * previews), in which case no indication is rendered. */ public data class MessageBubbleParams( val message: Message, @@ -721,7 +716,6 @@ public data class MessageBubbleParams( val content: @Composable () -> Unit, val modifier: Modifier = Modifier, val border: BorderStroke? = null, - val interactionSource: InteractionSource? = null, ) /** @@ -770,13 +764,6 @@ public data class MessageAuthorParams( * Parameters for [ChatComponentFactory.MessageContent]. * * @param messageItem The message item state. - * @param onBubbleClick Action invoked when the bubble is tapped. Mirrors the surrounding cell's - * click intent (e.g. opening a thread for thread-start messages) so taps inside the bubble - * Column behave identically to taps in the avatar gap. - * @param onBubbleLongClick Action invoked when the bubble is long-pressed. Mirrors the surrounding - * cell's long-click intent (haptic feedback + action menu, gated on whether actions are - * available) so long-presses inside the bubble Column behave identically to those in the - * avatar gap. * @param onLongItemClick Action invoked when a message is long-clicked. * @param onPollUpdated Action invoked when a poll is updated. * @param onCastVote Action invoked when a vote is cast. @@ -790,14 +777,9 @@ public data class MessageAuthorParams( * @param onUserMentionClick Action invoked when a user mention is clicked. * @param onMediaGalleryPreviewResult Action invoked with the media gallery preview result. * @param onLinkClick Action invoked when a link in a message is clicked. - * @param interactionSource The interaction source from the surrounding message cell, threaded - * through to [MessageBubbleParams.interactionSource] so the bubble can render an unbounded - * ripple synchronised with the cell's press state. `null` outside a cell context. */ public data class MessageContentParams( val messageItem: MessageItemState, - val onBubbleClick: () -> Unit = {}, - val onBubbleLongClick: () -> Unit = {}, val onLongItemClick: (Message) -> Unit = {}, val onPollUpdated: (Message, Poll) -> Unit = { _, _ -> }, val onCastVote: (Message, Poll, Option) -> Unit = { _, _, _ -> }, @@ -811,7 +793,6 @@ public data class MessageContentParams( val onUserMentionClick: (User) -> Unit = {}, val onMediaGalleryPreviewResult: (MediaGalleryPreviewResult?) -> Unit = {}, val onLinkClick: ((Message, String) -> Unit)? = null, - val interactionSource: InteractionSource? = null, ) /** @@ -860,12 +841,6 @@ public data class MessageDeletedContentParams( * @param onQuotedMessageClick Action invoked when a quoted message is clicked. * @param onUserMentionClick Action invoked when a user mention is clicked. * @param onLinkClick Action invoked when a link in a message is clicked. - * @param onBubbleClick Action invoked when the bubble is tapped. Mirrors the surrounding cell's - * click intent so taps inside the bubble Column behave identically to taps in the avatar gap. - * @param onBubbleLongClick Action invoked when the bubble is long-pressed. Mirrors the surrounding - * cell's long-click intent (haptic feedback + action menu, gated on whether actions are - * available) so long-presses inside the bubble Column behave identically to those in the - * avatar gap. */ public data class MessageRegularContentParams( val message: Message, @@ -876,8 +851,6 @@ public data class MessageRegularContentParams( val onQuotedMessageClick: (Message) -> Unit, val onUserMentionClick: (User) -> Unit, val onLinkClick: ((Message, String) -> Unit)? = null, - val onBubbleClick: () -> Unit = {}, - val onBubbleLongClick: () -> Unit = {}, ) /** diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/ModifierUtils.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/ModifierUtils.kt index a73bd0e718a..cbca7ea067b 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/ModifierUtils.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/ModifierUtils.kt @@ -17,10 +17,19 @@ package io.getstream.chat.android.compose.ui.util import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.waitForUpOrCancellation +import androidx.compose.foundation.indication +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.material3.ripple +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.compose.ui.composed import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color @@ -29,6 +38,7 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch /** * Adds drag pointer input to the modifier. @@ -98,6 +108,31 @@ internal inline fun Modifier.applyIf(condition: Boolean, block: Modifier.() -> M internal inline fun Modifier.ifNotNull(value: T?, block: Modifier.(T) -> Modifier) = if (value != null) this.block(value) else this +/** + * Renders a bounded position-aware ripple on the decorated layout in response to touch without + * claiming the gesture. Unlike `Modifier.combinedClickable`, this modifier observes presses but + * does not consume them, so an outer clickable ancestor still receives the tap and remains the + * single owner of the click logic. Presses already consumed by an inner clickable descendant are + * ignored, so the inner region's own ripple is the only one that animates in that area. + * + * Useful when you need pure visual press feedback on a layout that delegates click handling to a + * surrounding (or inner) clickable. + */ +internal fun Modifier.passiveRipple(): Modifier = composed { + val source = remember(::MutableInteractionSource) + val scope = rememberCoroutineScope() + pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = true) + val press = PressInteraction.Press(down.position) + scope.launch { source.emit(press) } + val up = waitForUpOrCancellation() + val finish = if (up != null) PressInteraction.Release(press) else PressInteraction.Cancel(press) + scope.launch { source.emit(finish) } + } + }.indication(source, ripple()) +} + internal fun Modifier.bottomBorder(color: Color, width: Dp = 1.dp): Modifier = verticalBorder(color, width, yPosition = { size.height - it / 2 }) From 12a465f4f32f5d50b98912a2036b94e59de0cee0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 8 May 2026 12:53:06 +0100 Subject: [PATCH 07/11] Ripple text-with-link bubbles on non-link character taps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local ClickableText helper used detectTapGestures, which consumes the down event on every press inside the text. With the bubble Column's passiveRipple gating on awaitFirstDown(requireUnconsumed = true), that consumption blocked the bubble ripple AND the cell's combinedClickable for any tap on a message containing a link, mention, or email — even when the touched character was plain text. Replace detectTapGestures with a custom awaitEachGesture loop that only consumes when the down position lands on a character carrying an interactive annotation. Non-link character taps propagate to ancestors normally: the bubble ripples (passiveRipple sees unconsumed down) and the cell fires its onClick / onLongClick (thread-open, haptic, action menu). Tap and long-press on link characters keep their existing handlers via the same withTimeoutOrNull + waitForUpOrCancellation flow as detectTapGestures. Note: this is a workaround on top of the legacy string-annotation plumbing. The cleaner direction is migrating to Compose Foundation's LinkAnnotation API, which handles non-link tap propagation natively and would let us delete this entire custom detector. Tracked as a follow-up. --- .../ui/components/messages/MessageText.kt | 71 +++++++++++++++---- 1 file changed, 59 insertions(+), 12 deletions(-) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageText.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageText.kt index 51da707a3d5..914afd53496 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageText.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageText.kt @@ -17,7 +17,9 @@ package io.getstream.chat.android.compose.ui.components.messages import android.content.Intent -import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.BasicText import androidx.compose.material3.Text @@ -27,6 +29,9 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.input.pointer.AwaitPointerEventScope +import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException +import androidx.compose.ui.input.pointer.PointerInputChange import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag @@ -37,6 +42,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.sp import androidx.compose.ui.util.fastAny +import androidx.compose.ui.util.fastForEach import androidx.core.net.toUri import io.getstream.chat.android.compose.ui.theme.ChatTheme import io.getstream.chat.android.compose.ui.theme.MessageStyling @@ -50,6 +56,7 @@ import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.User import io.getstream.chat.android.ui.common.utils.extensions.getUserByNameOrId import io.getstream.chat.android.ui.common.utils.extensions.isMine +import kotlinx.coroutines.withTimeoutOrNull /** * Default text element for messages, with extra styling and padding for the chat bubble. @@ -109,6 +116,12 @@ public fun MessageText( text = styledText, style = style, onLongPress = { onLongItemClick(message) }, + isInteractiveAt = { offset -> + annotations.fastAny { ann -> + (ann.tag == AnnotationTagUrl || ann.tag == AnnotationTagEmail || ann.tag == AnnotationTagMention) && + offset in ann.start..ann.end + } + }, ) { position -> val annotation = annotations.firstOrNull { position in it.start..it.end } if (annotation?.tag == AnnotationTagMention) { @@ -135,10 +148,22 @@ public fun MessageText( } /** - * A spin-off of a Foundation component that allows calling long press handlers. - * Contains only one additional parameter. + * A spin-off of a Foundation component that allows calling long press handlers and only claims + * the gesture when the press lands on an interactive character (link, mention, email). + * Non-interactive presses are left untouched so the surrounding bubble can render its passive + * ripple and the cell can still fire its click / long-press handler. + * + * Follow-up: migrate to Compose Foundation's `LinkAnnotation` API (`AnnotatedString.Builder.addLink` + * with `LinkAnnotation.Url` / `LinkAnnotation.Clickable`). Native handling propagates non-link + * taps to the parent for free, removing the need for this custom gesture detector and the + * `isInteractiveAt` plumbing. Requires reworking `TextUtils.linkify` / `tagUser` to emit link + * annotations instead of legacy string annotations and updating `MessageTextFormatter` to expose + * a `LinkInteractionListener` hook for click routing. * - * @param onLongPress Handler called on long press. + * @param onLongPress Handler called on long press of an interactive character. + * @param isInteractiveAt Returns whether the given character offset has an interactive annotation + * (link, mention, email). When `false`, the gesture is not consumed and propagates to ancestors. + * @param onClick Handler called on tap-up of an interactive character; receives the character offset. * * @see androidx.compose.foundation.text.ClickableText */ @@ -152,18 +177,33 @@ private fun ClickableText( maxLines: Int = Int.MAX_VALUE, onTextLayout: (TextLayoutResult) -> Unit = {}, onLongPress: () -> Unit, + isInteractiveAt: (Int) -> Boolean, onClick: (Int) -> Unit, ) { val layoutResult = remember { mutableStateOf(null) } - val pressIndicator = Modifier.pointerInput(onClick, onLongPress) { - detectTapGestures( - onLongPress = { onLongPress() }, - onTap = { pos -> - layoutResult.value?.let { layoutResult -> - onClick(layoutResult.getOffsetForPosition(pos)) + val pressIndicator = Modifier.pointerInput(onClick, onLongPress, isInteractiveAt) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = true) + val layout = layoutResult.value ?: return@awaitEachGesture + val charAt = layout.getOffsetForPosition(down.position) + if (!isInteractiveAt(charAt)) { + return@awaitEachGesture + } + down.consume() + val up: PointerInputChange? = try { + withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) { + waitForUpOrCancellation() } - }, - ) + } catch (_: PointerEventTimeoutCancellationException) { + onLongPress() + consumeUntilUp() + return@awaitEachGesture + } + if (up != null) { + up.consume() + onClick(charAt) + } + } } BasicText( @@ -180,6 +220,13 @@ private fun ClickableText( ) } +private suspend fun AwaitPointerEventScope.consumeUntilUp() { + do { + val event = awaitPointerEvent() + event.changes.fastForEach { it.consume() } + } while (event.changes.fastAny { it.pressed }) +} + @Preview @Composable private fun MessageTextPreview() { From 45205869b1b880e714c4010cea7be78e3a50e4e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 8 May 2026 13:34:54 +0100 Subject: [PATCH 08/11] Fix off-by-one and unreachable long-press path in ClickableText Two correctness fixes raised in PR review: - AnnotatedString.Range.end is exclusive, but the membership checks used ann.start..ann.end (inclusive). A tap on the character immediately after a link/mention/email was treated as part of the annotation. Switch both call sites to ann.start until ann.end. - The long-press branch used kotlinx.coroutines.withTimeoutOrNull, which returns null on timeout and never throws. The catch (_: PointerEventTimeoutCancellationException) block was unreachable, so onLongPress() was never invoked on long-press of a link/mention/email. Switch to AwaitPointerEventScope.withTimeout (the throwing variant matching Compose Foundation's own waitForLongPress) and drop the now-unused kotlinx.coroutines.withTimeoutOrNull import. --- .../android/compose/ui/components/messages/MessageText.kt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageText.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageText.kt index 914afd53496..8c9298392b7 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageText.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageText.kt @@ -56,7 +56,6 @@ import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.User import io.getstream.chat.android.ui.common.utils.extensions.getUserByNameOrId import io.getstream.chat.android.ui.common.utils.extensions.isMine -import kotlinx.coroutines.withTimeoutOrNull /** * Default text element for messages, with extra styling and padding for the chat bubble. @@ -119,11 +118,11 @@ public fun MessageText( isInteractiveAt = { offset -> annotations.fastAny { ann -> (ann.tag == AnnotationTagUrl || ann.tag == AnnotationTagEmail || ann.tag == AnnotationTagMention) && - offset in ann.start..ann.end + offset in ann.start until ann.end } }, ) { position -> - val annotation = annotations.firstOrNull { position in it.start..it.end } + val annotation = annotations.firstOrNull { position in it.start until it.end } if (annotation?.tag == AnnotationTagMention) { message.mentionedUsers.getUserByNameOrId(annotation.item)?.let { onUserMentionClick.invoke(it) } } else { @@ -191,7 +190,7 @@ private fun ClickableText( } down.consume() val up: PointerInputChange? = try { - withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) { + withTimeout(viewConfiguration.longPressTimeoutMillis) { waitForUpOrCancellation() } } catch (_: PointerEventTimeoutCancellationException) { From e0d0868383c41870c12e99c0c7fa03faced02ddd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 8 May 2026 13:57:57 +0100 Subject: [PATCH 09/11] Extract MessageText interactive-annotation predicates and unit-test them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pull two internal helpers out of inline lambdas in MessageText.kt: - AnnotatedString.Range.isInteractiveTag() — true for URL, email, and mention tags. - List>.hasInteractiveAt(offset) — true when any range in the list both has an interactive tag and covers the given offset, with exclusive-end semantics matching AnnotatedString.Range.end. Replaces the inline lambdas in the public MessageText composable with member references at the call sites. Add MessageTextTest covering the predicate matrix: every interactive tag, non-interactive tags, empty list, inclusive-start and exclusive-end boundaries (locks in the recent off-by-one fix), and mixed annotation lists. Pure JUnit 5 + kluent, no Compose runtime. --- .../ui/components/messages/MessageText.kt | 26 ++-- .../ui/components/messages/MessageTextTest.kt | 126 ++++++++++++++++++ 2 files changed, 142 insertions(+), 10 deletions(-) create mode 100644 stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/components/messages/MessageTextTest.kt diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageText.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageText.kt index 8c9298392b7..4725cac3661 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageText.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageText.kt @@ -104,10 +104,7 @@ public fun MessageText( } val annotations = styledText.getStringAnnotations(0, styledText.lastIndex) - if (annotations.fastAny { - it.tag == AnnotationTagUrl || it.tag == AnnotationTagEmail || it.tag == AnnotationTagMention - } - ) { + if (annotations.fastAny(AnnotatedString.Range::isInteractiveTag)) { ClickableText( modifier = modifier .padding(MessageStyling.textPadding) @@ -115,12 +112,7 @@ public fun MessageText( text = styledText, style = style, onLongPress = { onLongItemClick(message) }, - isInteractiveAt = { offset -> - annotations.fastAny { ann -> - (ann.tag == AnnotationTagUrl || ann.tag == AnnotationTagEmail || ann.tag == AnnotationTagMention) && - offset in ann.start until ann.end - } - }, + isInteractiveAt = annotations::hasInteractiveAt, ) { position -> val annotation = annotations.firstOrNull { position in it.start until it.end } if (annotation?.tag == AnnotationTagMention) { @@ -226,6 +218,20 @@ private suspend fun AwaitPointerEventScope.consumeUntilUp() { } while (event.changes.fastAny { it.pressed }) } +/** + * Whether this annotation range carries one of the interactive tags handled by [MessageText]: + * URL, email, or mention. + */ +internal fun AnnotatedString.Range.isInteractiveTag(): Boolean = + tag == AnnotationTagUrl || tag == AnnotationTagEmail || tag == AnnotationTagMention + +/** + * Whether any annotation in the list both has an interactive tag and covers [offset]. Uses + * exclusive-end semantics ([AnnotatedString.Range.end] is exclusive per Compose spec). + */ +internal fun List>.hasInteractiveAt(offset: Int): Boolean = + fastAny { it.isInteractiveTag() && offset in it.start until it.end } + @Preview @Composable private fun MessageTextPreview() { diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/components/messages/MessageTextTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/components/messages/MessageTextTest.kt new file mode 100644 index 00000000000..d20bfe07f41 --- /dev/null +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/components/messages/MessageTextTest.kt @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.ui.components.messages + +import androidx.compose.ui.text.AnnotatedString +import io.getstream.chat.android.compose.ui.util.AnnotationTagEmail +import io.getstream.chat.android.compose.ui.util.AnnotationTagMention +import io.getstream.chat.android.compose.ui.util.AnnotationTagUrl +import org.amshove.kluent.`should be equal to` +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource + +internal class MessageTextTest { + + @ParameterizedTest + @MethodSource("interactiveTagCases") + fun `isInteractiveTag returns true for URL, email and mention tags`(tag: String, expected: Boolean) { + val range = AnnotatedString.Range(item = "value", start = 0, end = 5, tag = tag) + + range.isInteractiveTag() `should be equal to` expected + } + + @Test + fun `hasInteractiveAt returns false for empty annotation list`() { + val annotations = emptyList>() + + annotations.hasInteractiveAt(offset = 0) `should be equal to` false + } + + @Test + fun `hasInteractiveAt returns true when offset falls inside a URL annotation`() { + val annotations = listOf(urlAt(start = 5, end = 15)) + + annotations.hasInteractiveAt(offset = 7) `should be equal to` true + } + + @Test + fun `hasInteractiveAt returns true at the inclusive start boundary`() { + val annotations = listOf(urlAt(start = 5, end = 15)) + + annotations.hasInteractiveAt(offset = 5) `should be equal to` true + } + + @Test + fun `hasInteractiveAt returns false at the exclusive end boundary`() { + val annotations = listOf(urlAt(start = 5, end = 15)) + + annotations.hasInteractiveAt(offset = 15) `should be equal to` false + } + + @Test + fun `hasInteractiveAt returns false just before a URL annotation`() { + val annotations = listOf(urlAt(start = 5, end = 15)) + + annotations.hasInteractiveAt(offset = 4) `should be equal to` false + } + + @Test + fun `hasInteractiveAt returns true for a mention annotation`() { + val annotations = listOf( + AnnotatedString.Range(item = "user", start = 0, end = 5, tag = AnnotationTagMention), + ) + + annotations.hasInteractiveAt(offset = 2) `should be equal to` true + } + + @Test + fun `hasInteractiveAt returns true for an email annotation`() { + val annotations = listOf( + AnnotatedString.Range(item = "x@y.z", start = 0, end = 5, tag = AnnotationTagEmail), + ) + + annotations.hasInteractiveAt(offset = 2) `should be equal to` true + } + + @Test + fun `hasInteractiveAt ignores annotations with non-interactive tags at the same offset`() { + val annotations = listOf( + AnnotatedString.Range(item = "style", start = 0, end = 10, tag = "STYLE"), + ) + + annotations.hasInteractiveAt(offset = 5) `should be equal to` false + } + + @Test + fun `hasInteractiveAt returns true when at least one of multiple annotations matches`() { + val annotations = listOf( + AnnotatedString.Range(item = "style", start = 0, end = 100, tag = "STYLE"), + urlAt(start = 5, end = 15), + ) + + annotations.hasInteractiveAt(offset = 7) `should be equal to` true + } + + private fun urlAt(start: Int, end: Int) = + AnnotatedString.Range(item = "https://example.com", start = start, end = end, tag = AnnotationTagUrl) + + companion object { + + @JvmStatic + fun interactiveTagCases(): List = listOf( + Arguments.of(AnnotationTagUrl, true), + Arguments.of(AnnotationTagEmail, true), + Arguments.of(AnnotationTagMention, true), + Arguments.of("STYLE", false), + Arguments.of("UNKNOWN", false), + Arguments.of("", false), + ) + } +} From 9cc74c041b824c092ae3a588d83c3b62bdba9df4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 8 May 2026 14:28:01 +0100 Subject: [PATCH 10/11] Cover passiveRipple and the MessageText click dispatch with tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../ui/components/messages/MessageText.kt | 53 +++-- .../android/compose/ui/util/ModifierUtils.kt | 2 +- .../ui/components/messages/MessageTextTest.kt | 184 ++++++++++++++++++ .../compose/ui/util/PassiveRippleTest.kt | 168 ++++++++++++++++ 4 files changed, 394 insertions(+), 13 deletions(-) create mode 100644 stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/util/PassiveRippleTest.kt diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageText.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageText.kt index 4725cac3661..374f63c5d50 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageText.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageText.kt @@ -114,17 +114,16 @@ public fun MessageText( onLongPress = { onLongItemClick(message) }, isInteractiveAt = annotations::hasInteractiveAt, ) { position -> - val annotation = annotations.firstOrNull { position in it.start until it.end } - if (annotation?.tag == AnnotationTagMention) { - message.mentionedUsers.getUserByNameOrId(annotation.item)?.let { onUserMentionClick.invoke(it) } - } else { - val targetUrl = annotation?.item - if (!targetUrl.isNullOrEmpty()) { - onLinkClick?.invoke(message, targetUrl) ?: context.startActivity( - Intent(Intent.ACTION_VIEW, targetUrl.toUri()), - ) - } - } + handleAnnotationClick( + annotations = annotations, + position = position, + message = message, + onLinkClick = onLinkClick, + onUserMentionClick = onUserMentionClick, + fallback = { url -> + context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) + }, + ) } } else { Text( @@ -174,7 +173,7 @@ private fun ClickableText( val layoutResult = remember { mutableStateOf(null) } val pressIndicator = Modifier.pointerInput(onClick, onLongPress, isInteractiveAt) { awaitEachGesture { - val down = awaitFirstDown(requireUnconsumed = true) + val down = awaitFirstDown() val layout = layoutResult.value ?: return@awaitEachGesture val charAt = layout.getOffsetForPosition(down.position) if (!isInteractiveAt(charAt)) { @@ -232,6 +231,36 @@ internal fun AnnotatedString.Range.isInteractiveTag(): Boolean = internal fun List>.hasInteractiveAt(offset: Int): Boolean = fastAny { it.isInteractiveTag() && offset in it.start until it.end } +/** + * Resolves the interactive annotation at the given character [position] and dispatches the right + * handler. Mention annotations route to [onUserMentionClick] after resolving the username against + * [Message.mentionedUsers]; URL/email annotations route to [onLinkClick] when set, otherwise to + * [fallback]. Annotations with empty items, non-interactive tags, or no match at [position] are + * ignored. + */ +@Suppress("LongParameterList") +internal fun handleAnnotationClick( + annotations: List>, + position: Int, + message: Message, + onLinkClick: ((Message, String) -> Unit)?, + onUserMentionClick: (User) -> Unit, + fallback: (String) -> Unit, +) { + val annotation = annotations.firstOrNull { position in it.start until it.end } ?: return + when (annotation.tag) { + AnnotationTagMention -> { + message.mentionedUsers.getUserByNameOrId(annotation.item)?.let(onUserMentionClick) + } + AnnotationTagUrl, AnnotationTagEmail -> { + val url = annotation.item + if (url.isNotEmpty()) { + if (onLinkClick != null) onLinkClick(message, url) else fallback(url) + } + } + } +} + @Preview @Composable private fun MessageTextPreview() { diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/ModifierUtils.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/ModifierUtils.kt index cbca7ea067b..8f5f4d221c4 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/ModifierUtils.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/ModifierUtils.kt @@ -123,7 +123,7 @@ internal fun Modifier.passiveRipple(): Modifier = composed { val scope = rememberCoroutineScope() pointerInput(Unit) { awaitEachGesture { - val down = awaitFirstDown(requireUnconsumed = true) + val down = awaitFirstDown() val press = PressInteraction.Press(down.position) scope.launch { source.emit(press) } val up = waitForUpOrCancellation() diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/components/messages/MessageTextTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/components/messages/MessageTextTest.kt index d20bfe07f41..7385fa72919 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/components/messages/MessageTextTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/components/messages/MessageTextTest.kt @@ -20,11 +20,19 @@ import androidx.compose.ui.text.AnnotatedString import io.getstream.chat.android.compose.ui.util.AnnotationTagEmail import io.getstream.chat.android.compose.ui.util.AnnotationTagMention import io.getstream.chat.android.compose.ui.util.AnnotationTagUrl +import io.getstream.chat.android.models.Message +import io.getstream.chat.android.models.User +import io.getstream.chat.android.randomMessage +import io.getstream.chat.android.randomUser import org.amshove.kluent.`should be equal to` import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify internal class MessageTextTest { @@ -108,6 +116,182 @@ internal class MessageTextTest { annotations.hasInteractiveAt(offset = 7) `should be equal to` true } + @Test + fun `handleAnnotationClick fires onLinkClick for a URL annotation when callback is set`() { + val onLinkClick = mock<(Message, String) -> Unit>() + val onUserMentionClick = mock<(User) -> Unit>() + val fallback = mock<(String) -> Unit>() + val message = randomMessage(text = "https://example.com") + val annotations = listOf(urlAt(start = 0, end = 19)) + + handleAnnotationClick( + annotations = annotations, + position = 5, + message = message, + onLinkClick = onLinkClick, + onUserMentionClick = onUserMentionClick, + fallback = fallback, + ) + + verify(onLinkClick).invoke(message, "https://example.com") + verify(fallback, never()).invoke(any()) + verify(onUserMentionClick, never()).invoke(any()) + } + + @Test + fun `handleAnnotationClick falls back to default handler when onLinkClick is null`() { + val onUserMentionClick = mock<(User) -> Unit>() + val fallback = mock<(String) -> Unit>() + val message = randomMessage(text = "https://example.com") + val annotations = listOf(urlAt(start = 0, end = 19)) + + handleAnnotationClick( + annotations = annotations, + position = 5, + message = message, + onLinkClick = null, + onUserMentionClick = onUserMentionClick, + fallback = fallback, + ) + + verify(fallback).invoke("https://example.com") + verify(onUserMentionClick, never()).invoke(any()) + } + + @Test + fun `handleAnnotationClick fires onLinkClick for an email annotation`() { + val onLinkClick = mock<(Message, String) -> Unit>() + val message = randomMessage(text = "alice@example.com") + val annotations = listOf( + AnnotatedString.Range( + item = "mailto:alice@example.com", + start = 0, + end = 17, + tag = AnnotationTagEmail, + ), + ) + + handleAnnotationClick( + annotations = annotations, + position = 3, + message = message, + onLinkClick = onLinkClick, + onUserMentionClick = {}, + fallback = {}, + ) + + verify(onLinkClick).invoke(message, "mailto:alice@example.com") + } + + @Test + fun `handleAnnotationClick fires onUserMentionClick with the resolved user`() { + val mentioned = randomUser(name = "alice") + val onUserMentionClick = mock<(User) -> Unit>() + val onLinkClick = mock<(Message, String) -> Unit>() + val message = randomMessage(text = "@alice", mentionedUsers = listOf(mentioned)) + val annotations = listOf( + AnnotatedString.Range(item = "alice", start = 0, end = 6, tag = AnnotationTagMention), + ) + + handleAnnotationClick( + annotations = annotations, + position = 2, + message = message, + onLinkClick = onLinkClick, + onUserMentionClick = onUserMentionClick, + fallback = {}, + ) + + verify(onUserMentionClick).invoke(mentioned) + verify(onLinkClick, never()).invoke(any(), any()) + } + + @Test + fun `handleAnnotationClick is a no-op when the mentioned user is not in the message`() { + val onUserMentionClick = mock<(User) -> Unit>() + val message = randomMessage(text = "@bob", mentionedUsers = emptyList()) + val annotations = listOf( + AnnotatedString.Range(item = "bob", start = 0, end = 4, tag = AnnotationTagMention), + ) + + handleAnnotationClick( + annotations = annotations, + position = 1, + message = message, + onLinkClick = null, + onUserMentionClick = onUserMentionClick, + fallback = {}, + ) + + verify(onUserMentionClick, never()).invoke(any()) + } + + @Test + fun `handleAnnotationClick is a no-op when no annotation covers the position`() { + val onLinkClick = mock<(Message, String) -> Unit>() + val onUserMentionClick = mock<(User) -> Unit>() + val fallback = mock<(String) -> Unit>() + val message = randomMessage(text = "https://example.com after") + val annotations = listOf(urlAt(start = 0, end = 19)) + + handleAnnotationClick( + annotations = annotations, + position = 22, + message = message, + onLinkClick = onLinkClick, + onUserMentionClick = onUserMentionClick, + fallback = fallback, + ) + + verify(onLinkClick, never()).invoke(any(), any()) + verify(onUserMentionClick, never()).invoke(any()) + verify(fallback, never()).invoke(any()) + } + + @Test + fun `handleAnnotationClick ignores non-interactive tags`() { + val onLinkClick = mock<(Message, String) -> Unit>() + val fallback = mock<(String) -> Unit>() + val message = randomMessage() + val annotations = listOf( + AnnotatedString.Range(item = "value", start = 0, end = 10, tag = "STYLE"), + ) + + handleAnnotationClick( + annotations = annotations, + position = 5, + message = message, + onLinkClick = onLinkClick, + onUserMentionClick = {}, + fallback = fallback, + ) + + verify(onLinkClick, never()).invoke(any(), any()) + verify(fallback, never()).invoke(any()) + } + + @Test + fun `handleAnnotationClick ignores URL annotations with empty item`() { + val onLinkClick = mock<(Message, String) -> Unit>() + val fallback = mock<(String) -> Unit>() + val message = randomMessage() + val annotations = listOf( + AnnotatedString.Range(item = "", start = 0, end = 5, tag = AnnotationTagUrl), + ) + + handleAnnotationClick( + annotations = annotations, + position = 2, + message = message, + onLinkClick = onLinkClick, + onUserMentionClick = {}, + fallback = fallback, + ) + + verify(onLinkClick, never()).invoke(any(), any()) + verify(fallback, never()).invoke(any()) + } + private fun urlAt(start: Int, end: Int) = AnnotatedString.Range(item = "https://example.com", start = start, end = end, tag = AnnotationTagUrl) diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/util/PassiveRippleTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/util/PassiveRippleTest.kt new file mode 100644 index 00000000000..946490d7895 --- /dev/null +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/util/PassiveRippleTest.kt @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.ui.util + +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.longClick +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [33]) +internal class PassiveRippleTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun `tap inside passiveRipple propagates to outer combinedClickable`() { + val onParentClick = mock<() -> Unit>() + + composeTestRule.setContent { + Box( + modifier = Modifier + .testTag("parent") + .size(200.dp) + .combinedClickable( + interactionSource = remember(::MutableInteractionSource), + indication = null, + onClick = onParentClick, + ), + ) { + Box( + modifier = Modifier + .testTag("inner") + .size(100.dp) + .passiveRipple(), + ) + } + } + + composeTestRule.onNodeWithTag("inner", useUnmergedTree = true).performTouchInput { + down(center) + up() + } + + verify(onParentClick).invoke() + } + + @Test + fun `long-press inside passiveRipple propagates to outer combinedClickable`() { + val onParentLongClick = mock<() -> Unit>() + + composeTestRule.setContent { + Box( + modifier = Modifier + .testTag("parent") + .size(200.dp) + .combinedClickable( + interactionSource = remember(::MutableInteractionSource), + indication = null, + onClick = {}, + onLongClick = onParentLongClick, + ), + ) { + Box( + modifier = Modifier + .testTag("inner") + .size(100.dp) + .passiveRipple(), + ) + } + } + + composeTestRule.onNodeWithTag("inner", useUnmergedTree = true).performTouchInput { + longClick() + } + + verify(onParentLongClick).invoke() + } + + @Test + fun `inner consuming clickable does not trigger outer combinedClickable`() { + val onParentClick = mock<() -> Unit>() + val onConsumerClick = mock<() -> Unit>() + + composeTestRule.setContent { + Box( + modifier = Modifier + .testTag("parent") + .size(200.dp) + .combinedClickable( + interactionSource = remember(::MutableInteractionSource), + indication = null, + onClick = onParentClick, + ) + .passiveRipple(), + ) { + Box( + modifier = Modifier + .testTag("consumer") + .size(100.dp) + .combinedClickable( + interactionSource = remember(::MutableInteractionSource), + indication = null, + onClick = onConsumerClick, + ), + ) + } + } + + composeTestRule.onNodeWithTag("consumer").performTouchInput { + down(center) + up() + } + + verify(onConsumerClick).invoke() + verify(onParentClick, never()).invoke() + } + + @Test + fun `drag out of bounds during gesture does not crash`() { + composeTestRule.setContent { + Box( + modifier = Modifier + .testTag("target") + .size(100.dp) + .passiveRipple(), + ) + } + + composeTestRule.onNodeWithTag("target").performTouchInput { + down(center) + moveTo(Offset(-200f, -200f)) + up() + } + } +} From 9b0fd36d857d14cd1be9ae5f77cd034462045675 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 8 May 2026 16:58:29 +0100 Subject: [PATCH 11/11] 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. --- .../api/stream-chat-android-compose.api | 6 +- .../ui/components/messages/MessageText.kt | 122 +++++-- .../messages/MessageTextHelpersTest.kt | 332 ++++++++++++++++++ .../ui/components/messages/MessageTextTest.kt | 297 ++-------------- ...ts.messages_MessageTextTest_plain_text.png | Bin 0 -> 8003 bytes ...ssages_MessageTextTest_text_with_email.png | Bin 0 -> 14349 bytes ...ages_MessageTextTest_text_with_mention.png | Bin 0 -> 10366 bytes ...messages_MessageTextTest_text_with_url.png | Bin 0 -> 14034 bytes ...sageTextTest_text_with_url_and_mention.png | Bin 0 -> 17516 bytes 9 files changed, 460 insertions(+), 297 deletions(-) create mode 100644 stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/components/messages/MessageTextHelpersTest.kt create mode 100644 stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.messages_MessageTextTest_plain_text.png create mode 100644 stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.messages_MessageTextTest_text_with_email.png create mode 100644 stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.messages_MessageTextTest_text_with_mention.png create mode 100644 stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.messages_MessageTextTest_text_with_url.png create mode 100644 stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.messages_MessageTextTest_text_with_url_and_mention.png diff --git a/stream-chat-android-compose/api/stream-chat-android-compose.api b/stream-chat-android-compose/api/stream-chat-android-compose.api index 5fa3cf32382..ad2dc62b33b 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -1504,7 +1504,11 @@ public final class io/getstream/chat/android/compose/ui/components/messages/Comp public final class io/getstream/chat/android/compose/ui/components/messages/ComposableSingletons$MessageTextKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/components/messages/ComposableSingletons$MessageTextKt; public fun ()V - public final fun getLambda$-1569101361$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-261803629$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-399958751$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1734337819$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1973807821$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$552887520$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/components/messages/ComposableSingletons$PollMessageContentKt { diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageText.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageText.kt index 374f63c5d50..147312a99ee 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageText.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageText.kt @@ -27,6 +27,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.input.pointer.AwaitPointerEventScope @@ -103,7 +104,9 @@ public fun MessageText( else -> MessageStyling.textStyle(outgoing = message.isMine(currentUser)) } - val annotations = styledText.getStringAnnotations(0, styledText.lastIndex) + val annotations = remember(styledText) { + styledText.getStringAnnotations(0, styledText.lastIndex) + } if (annotations.fastAny(AnnotatedString.Range::isInteractiveTag)) { ClickableText( modifier = modifier @@ -143,13 +146,6 @@ public fun MessageText( * Non-interactive presses are left untouched so the surrounding bubble can render its passive * ripple and the cell can still fire its click / long-press handler. * - * Follow-up: migrate to Compose Foundation's `LinkAnnotation` API (`AnnotatedString.Builder.addLink` - * with `LinkAnnotation.Url` / `LinkAnnotation.Clickable`). Native handling propagates non-link - * taps to the parent for free, removing the need for this custom gesture detector and the - * `isInteractiveAt` plumbing. Requires reworking `TextUtils.linkify` / `tagUser` to emit link - * annotations instead of legacy string annotations and updating `MessageTextFormatter` to expose - * a `LinkInteractionListener` hook for click routing. - * * @param onLongPress Handler called on long press of an interactive character. * @param isInteractiveAt Returns whether the given character offset has an interactive annotation * (link, mention, email). When `false`, the gesture is not consumed and propagates to ancestors. @@ -171,12 +167,20 @@ private fun ClickableText( onClick: (Int) -> Unit, ) { val layoutResult = remember { mutableStateOf(null) } - val pressIndicator = Modifier.pointerInput(onClick, onLongPress, isInteractiveAt) { + // Capture callbacks behind stable references so the pointerInput block does not restart on + // recomposition — the lambdas allocated by the caller change identity each composition. + val currentOnClick by rememberUpdatedState(onClick) + val currentOnLongPress by rememberUpdatedState(onLongPress) + val currentIsInteractiveAt by rememberUpdatedState(isInteractiveAt) + val pressIndicator = Modifier.pointerInput(Unit) { awaitEachGesture { val down = awaitFirstDown() val layout = layoutResult.value ?: return@awaitEachGesture val charAt = layout.getOffsetForPosition(down.position) - if (!isInteractiveAt(charAt)) { + if (!currentIsInteractiveAt(charAt)) { + // Non-interactive character: do not consume the down. Outer modifiers (the + // bubble Column's passiveRipple and the surrounding cell's combinedClickable) + // pick up the gesture instead. return@awaitEachGesture } down.consume() @@ -185,13 +189,16 @@ private fun ClickableText( waitForUpOrCancellation() } } catch (_: PointerEventTimeoutCancellationException) { - onLongPress() + // Long-press fired. Consume the rest of the gesture so the inner click that would + // normally ride the up event after onLongPress (matching detectTapGestures' + // semantics) cannot reach this scope's onClick. + currentOnLongPress() consumeUntilUp() return@awaitEachGesture } if (up != null) { up.consume() - onClick(charAt) + currentOnClick(charAt) } } } @@ -247,7 +254,9 @@ internal fun handleAnnotationClick( onUserMentionClick: (User) -> Unit, fallback: (String) -> Unit, ) { - val annotation = annotations.firstOrNull { position in it.start until it.end } ?: return + val annotation = annotations.firstOrNull { + it.isInteractiveTag() && position in it.start until it.end + } ?: return when (annotation.tag) { AnnotationTagMention -> { message.mentionedUsers.getUserByNameOrId(annotation.item)?.let(onUserMentionClick) @@ -261,14 +270,83 @@ internal fun handleAnnotationClick( } } -@Preview @Composable -private fun MessageTextPreview() { - ChatTheme { - MessageText( - message = Message(text = "Hello World!"), - currentUser = null, - onLongItemClick = {}, - ) - } +internal fun MessageTextPlain() { + MessageText( + message = Message(text = "Hello, this is a plain message."), + currentUser = null, + onLongItemClick = {}, + ) +} + +@Composable +internal fun MessageTextWithUrl() { + MessageText( + message = Message(text = "Check out https://getstream.io for more details."), + currentUser = null, + onLongItemClick = {}, + ) +} + +@Composable +internal fun MessageTextWithEmail() { + MessageText( + message = Message(text = "Contact us at support@getstream.io anytime."), + currentUser = null, + onLongItemClick = {}, + ) +} + +@Composable +internal fun MessageTextWithMention() { + MessageText( + message = Message( + text = "Welcome @alice to the channel!", + mentionedUsers = listOf(User(id = "alice", name = "alice")), + ), + currentUser = null, + onLongItemClick = {}, + ) +} + +@Composable +internal fun MessageTextWithUrlAndMention() { + MessageText( + message = Message( + text = "Hey @alice, the docs are at https://getstream.io/docs", + mentionedUsers = listOf(User(id = "alice", name = "alice")), + ), + currentUser = null, + onLongItemClick = {}, + ) +} + +@Preview(showBackground = true) +@Composable +private fun MessageTextPlainPreview() { + ChatTheme { MessageTextPlain() } +} + +@Preview(showBackground = true) +@Composable +private fun MessageTextWithUrlPreview() { + ChatTheme { MessageTextWithUrl() } +} + +@Preview(showBackground = true) +@Composable +private fun MessageTextWithEmailPreview() { + ChatTheme { MessageTextWithEmail() } +} + +@Preview(showBackground = true) +@Composable +private fun MessageTextWithMentionPreview() { + ChatTheme { MessageTextWithMention() } +} + +@Preview(showBackground = true) +@Composable +private fun MessageTextWithUrlAndMentionPreview() { + ChatTheme { MessageTextWithUrlAndMention() } } diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/components/messages/MessageTextHelpersTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/components/messages/MessageTextHelpersTest.kt new file mode 100644 index 00000000000..515e43078cb --- /dev/null +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/components/messages/MessageTextHelpersTest.kt @@ -0,0 +1,332 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.ui.components.messages + +import androidx.compose.ui.text.AnnotatedString +import io.getstream.chat.android.compose.ui.util.AnnotationTagEmail +import io.getstream.chat.android.compose.ui.util.AnnotationTagMention +import io.getstream.chat.android.compose.ui.util.AnnotationTagUrl +import io.getstream.chat.android.models.Message +import io.getstream.chat.android.models.User +import io.getstream.chat.android.randomMessage +import io.getstream.chat.android.randomUser +import org.amshove.kluent.`should be equal to` +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify + +internal class MessageTextHelpersTest { + + @ParameterizedTest + @MethodSource("interactiveTagCases") + fun `isInteractiveTag returns true for URL, email and mention tags`(tag: String, expected: Boolean) { + val range = AnnotatedString.Range(item = "value", start = 0, end = 5, tag = tag) + + range.isInteractiveTag() `should be equal to` expected + } + + @Test + fun `hasInteractiveAt returns false for empty annotation list`() { + val annotations = emptyList>() + + annotations.hasInteractiveAt(offset = 0) `should be equal to` false + } + + @Test + fun `hasInteractiveAt returns true when offset falls inside a URL annotation`() { + val annotations = listOf(urlAt(start = 5, end = 15)) + + annotations.hasInteractiveAt(offset = 7) `should be equal to` true + } + + @Test + fun `hasInteractiveAt returns true at the inclusive start boundary`() { + val annotations = listOf(urlAt(start = 5, end = 15)) + + annotations.hasInteractiveAt(offset = 5) `should be equal to` true + } + + @Test + fun `hasInteractiveAt returns false at the exclusive end boundary`() { + val annotations = listOf(urlAt(start = 5, end = 15)) + + annotations.hasInteractiveAt(offset = 15) `should be equal to` false + } + + @Test + fun `hasInteractiveAt returns false just before a URL annotation`() { + val annotations = listOf(urlAt(start = 5, end = 15)) + + annotations.hasInteractiveAt(offset = 4) `should be equal to` false + } + + @Test + fun `hasInteractiveAt returns true for a mention annotation`() { + val annotations = listOf( + AnnotatedString.Range(item = "user", start = 0, end = 5, tag = AnnotationTagMention), + ) + + annotations.hasInteractiveAt(offset = 2) `should be equal to` true + } + + @Test + fun `hasInteractiveAt returns true for an email annotation`() { + val annotations = listOf( + AnnotatedString.Range(item = "x@y.z", start = 0, end = 5, tag = AnnotationTagEmail), + ) + + annotations.hasInteractiveAt(offset = 2) `should be equal to` true + } + + @Test + fun `hasInteractiveAt ignores annotations with non-interactive tags at the same offset`() { + val annotations = listOf( + AnnotatedString.Range(item = "style", start = 0, end = 10, tag = "STYLE"), + ) + + annotations.hasInteractiveAt(offset = 5) `should be equal to` false + } + + @Test + fun `hasInteractiveAt returns true when at least one of multiple annotations matches`() { + val annotations = listOf( + AnnotatedString.Range(item = "style", start = 0, end = 100, tag = "STYLE"), + urlAt(start = 5, end = 15), + ) + + annotations.hasInteractiveAt(offset = 7) `should be equal to` true + } + + @Test + fun `handleAnnotationClick fires onLinkClick for a URL annotation when callback is set`() { + val onLinkClick = mock<(Message, String) -> Unit>() + val onUserMentionClick = mock<(User) -> Unit>() + val fallback = mock<(String) -> Unit>() + val message = randomMessage(text = "https://example.com") + val annotations = listOf(urlAt(start = 0, end = 19)) + + handleAnnotationClick( + annotations = annotations, + position = 5, + message = message, + onLinkClick = onLinkClick, + onUserMentionClick = onUserMentionClick, + fallback = fallback, + ) + + verify(onLinkClick).invoke(message, "https://example.com") + verify(fallback, never()).invoke(any()) + verify(onUserMentionClick, never()).invoke(any()) + } + + @Test + fun `handleAnnotationClick falls back to default handler when onLinkClick is null`() { + val onUserMentionClick = mock<(User) -> Unit>() + val fallback = mock<(String) -> Unit>() + val message = randomMessage(text = "https://example.com") + val annotations = listOf(urlAt(start = 0, end = 19)) + + handleAnnotationClick( + annotations = annotations, + position = 5, + message = message, + onLinkClick = null, + onUserMentionClick = onUserMentionClick, + fallback = fallback, + ) + + verify(fallback).invoke("https://example.com") + verify(onUserMentionClick, never()).invoke(any()) + } + + @Test + fun `handleAnnotationClick fires onLinkClick for an email annotation`() { + val onLinkClick = mock<(Message, String) -> Unit>() + val message = randomMessage(text = "alice@example.com") + val annotations = listOf( + AnnotatedString.Range( + item = "mailto:alice@example.com", + start = 0, + end = 17, + tag = AnnotationTagEmail, + ), + ) + + handleAnnotationClick( + annotations = annotations, + position = 3, + message = message, + onLinkClick = onLinkClick, + onUserMentionClick = {}, + fallback = {}, + ) + + verify(onLinkClick).invoke(message, "mailto:alice@example.com") + } + + @Test + fun `handleAnnotationClick fires onUserMentionClick with the resolved user`() { + val mentioned = randomUser(name = "alice") + val onUserMentionClick = mock<(User) -> Unit>() + val onLinkClick = mock<(Message, String) -> Unit>() + val message = randomMessage(text = "@alice", mentionedUsers = listOf(mentioned)) + val annotations = listOf( + AnnotatedString.Range(item = "alice", start = 0, end = 6, tag = AnnotationTagMention), + ) + + handleAnnotationClick( + annotations = annotations, + position = 2, + message = message, + onLinkClick = onLinkClick, + onUserMentionClick = onUserMentionClick, + fallback = {}, + ) + + verify(onUserMentionClick).invoke(mentioned) + verify(onLinkClick, never()).invoke(any(), any()) + } + + @Test + fun `handleAnnotationClick is a no-op when the mentioned user is not in the message`() { + val onUserMentionClick = mock<(User) -> Unit>() + val message = randomMessage(text = "@bob", mentionedUsers = emptyList()) + val annotations = listOf( + AnnotatedString.Range(item = "bob", start = 0, end = 4, tag = AnnotationTagMention), + ) + + handleAnnotationClick( + annotations = annotations, + position = 1, + message = message, + onLinkClick = null, + onUserMentionClick = onUserMentionClick, + fallback = {}, + ) + + verify(onUserMentionClick, never()).invoke(any()) + } + + @Test + fun `handleAnnotationClick is a no-op when no annotation covers the position`() { + val onLinkClick = mock<(Message, String) -> Unit>() + val onUserMentionClick = mock<(User) -> Unit>() + val fallback = mock<(String) -> Unit>() + val message = randomMessage(text = "https://example.com after") + val annotations = listOf(urlAt(start = 0, end = 19)) + + handleAnnotationClick( + annotations = annotations, + position = 22, + message = message, + onLinkClick = onLinkClick, + onUserMentionClick = onUserMentionClick, + fallback = fallback, + ) + + verify(onLinkClick, never()).invoke(any(), any()) + verify(onUserMentionClick, never()).invoke(any()) + verify(fallback, never()).invoke(any()) + } + + @Test + fun `handleAnnotationClick ignores non-interactive tags`() { + val onLinkClick = mock<(Message, String) -> Unit>() + val fallback = mock<(String) -> Unit>() + val message = randomMessage() + val annotations = listOf( + AnnotatedString.Range(item = "value", start = 0, end = 10, tag = "STYLE"), + ) + + handleAnnotationClick( + annotations = annotations, + position = 5, + message = message, + onLinkClick = onLinkClick, + onUserMentionClick = {}, + fallback = fallback, + ) + + verify(onLinkClick, never()).invoke(any(), any()) + verify(fallback, never()).invoke(any()) + } + + @Test + fun `handleAnnotationClick prefers an interactive annotation when a non-interactive one overlaps the position`() { + val onLinkClick = mock<(Message, String) -> Unit>() + val message = randomMessage(text = "https://example.com") + // Non-interactive annotation listed first, interactive URL listed second, both cover position 5. + val annotations = listOf( + AnnotatedString.Range(item = "decoration", start = 0, end = 19, tag = "STYLE"), + urlAt(start = 0, end = 19), + ) + + handleAnnotationClick( + annotations = annotations, + position = 5, + message = message, + onLinkClick = onLinkClick, + onUserMentionClick = {}, + fallback = {}, + ) + + verify(onLinkClick).invoke(message, "https://example.com") + } + + @Test + fun `handleAnnotationClick ignores URL annotations with empty item`() { + val onLinkClick = mock<(Message, String) -> Unit>() + val fallback = mock<(String) -> Unit>() + val message = randomMessage() + val annotations = listOf( + AnnotatedString.Range(item = "", start = 0, end = 5, tag = AnnotationTagUrl), + ) + + handleAnnotationClick( + annotations = annotations, + position = 2, + message = message, + onLinkClick = onLinkClick, + onUserMentionClick = {}, + fallback = fallback, + ) + + verify(onLinkClick, never()).invoke(any(), any()) + verify(fallback, never()).invoke(any()) + } + + private fun urlAt(start: Int, end: Int) = + AnnotatedString.Range(item = "https://example.com", start = start, end = end, tag = AnnotationTagUrl) + + companion object { + + @JvmStatic + fun interactiveTagCases(): List = listOf( + Arguments.of(AnnotationTagUrl, true), + Arguments.of(AnnotationTagEmail, true), + Arguments.of(AnnotationTagMention, true), + Arguments.of("STYLE", false), + Arguments.of("UNKNOWN", false), + Arguments.of("", false), + ) + } +} diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/components/messages/MessageTextTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/components/messages/MessageTextTest.kt index 7385fa72919..bdfe8fa5f55 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/components/messages/MessageTextTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/components/messages/MessageTextTest.kt @@ -16,295 +16,44 @@ package io.getstream.chat.android.compose.ui.components.messages -import androidx.compose.ui.text.AnnotatedString -import io.getstream.chat.android.compose.ui.util.AnnotationTagEmail -import io.getstream.chat.android.compose.ui.util.AnnotationTagMention -import io.getstream.chat.android.compose.ui.util.AnnotationTagUrl -import io.getstream.chat.android.models.Message -import io.getstream.chat.android.models.User -import io.getstream.chat.android.randomMessage -import io.getstream.chat.android.randomUser -import org.amshove.kluent.`should be equal to` -import org.junit.jupiter.api.Test -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.Arguments -import org.junit.jupiter.params.provider.MethodSource -import org.mockito.kotlin.any -import org.mockito.kotlin.mock -import org.mockito.kotlin.never -import org.mockito.kotlin.verify +import app.cash.paparazzi.DeviceConfig +import app.cash.paparazzi.Paparazzi +import com.android.ide.common.rendering.api.SessionParams +import com.android.resources.ScreenOrientation +import io.getstream.chat.android.compose.ui.PaparazziComposeTest +import org.junit.Rule +import org.junit.Test -internal class MessageTextTest { +internal class MessageTextTest : PaparazziComposeTest { - @ParameterizedTest - @MethodSource("interactiveTagCases") - fun `isInteractiveTag returns true for URL, email and mention tags`(tag: String, expected: Boolean) { - val range = AnnotatedString.Range(item = "value", start = 0, end = 5, tag = tag) - - range.isInteractiveTag() `should be equal to` expected - } - - @Test - fun `hasInteractiveAt returns false for empty annotation list`() { - val annotations = emptyList>() - - annotations.hasInteractiveAt(offset = 0) `should be equal to` false - } - - @Test - fun `hasInteractiveAt returns true when offset falls inside a URL annotation`() { - val annotations = listOf(urlAt(start = 5, end = 15)) - - annotations.hasInteractiveAt(offset = 7) `should be equal to` true - } - - @Test - fun `hasInteractiveAt returns true at the inclusive start boundary`() { - val annotations = listOf(urlAt(start = 5, end = 15)) - - annotations.hasInteractiveAt(offset = 5) `should be equal to` true - } - - @Test - fun `hasInteractiveAt returns false at the exclusive end boundary`() { - val annotations = listOf(urlAt(start = 5, end = 15)) - - annotations.hasInteractiveAt(offset = 15) `should be equal to` false - } + @get:Rule + override val paparazzi = Paparazzi( + deviceConfig = DeviceConfig.PIXEL_2.copy(orientation = ScreenOrientation.LANDSCAPE), + renderingMode = SessionParams.RenderingMode.SHRINK, + ) @Test - fun `hasInteractiveAt returns false just before a URL annotation`() { - val annotations = listOf(urlAt(start = 5, end = 15)) - - annotations.hasInteractiveAt(offset = 4) `should be equal to` false + fun `plain text`() { + snapshotWithDarkModeRow { MessageTextPlain() } } @Test - fun `hasInteractiveAt returns true for a mention annotation`() { - val annotations = listOf( - AnnotatedString.Range(item = "user", start = 0, end = 5, tag = AnnotationTagMention), - ) - - annotations.hasInteractiveAt(offset = 2) `should be equal to` true + fun `text with url`() { + snapshotWithDarkModeRow { MessageTextWithUrl() } } @Test - fun `hasInteractiveAt returns true for an email annotation`() { - val annotations = listOf( - AnnotatedString.Range(item = "x@y.z", start = 0, end = 5, tag = AnnotationTagEmail), - ) - - annotations.hasInteractiveAt(offset = 2) `should be equal to` true + fun `text with email`() { + snapshotWithDarkModeRow { MessageTextWithEmail() } } @Test - fun `hasInteractiveAt ignores annotations with non-interactive tags at the same offset`() { - val annotations = listOf( - AnnotatedString.Range(item = "style", start = 0, end = 10, tag = "STYLE"), - ) - - annotations.hasInteractiveAt(offset = 5) `should be equal to` false - } - - @Test - fun `hasInteractiveAt returns true when at least one of multiple annotations matches`() { - val annotations = listOf( - AnnotatedString.Range(item = "style", start = 0, end = 100, tag = "STYLE"), - urlAt(start = 5, end = 15), - ) - - annotations.hasInteractiveAt(offset = 7) `should be equal to` true + fun `text with mention`() { + snapshotWithDarkModeRow { MessageTextWithMention() } } @Test - fun `handleAnnotationClick fires onLinkClick for a URL annotation when callback is set`() { - val onLinkClick = mock<(Message, String) -> Unit>() - val onUserMentionClick = mock<(User) -> Unit>() - val fallback = mock<(String) -> Unit>() - val message = randomMessage(text = "https://example.com") - val annotations = listOf(urlAt(start = 0, end = 19)) - - handleAnnotationClick( - annotations = annotations, - position = 5, - message = message, - onLinkClick = onLinkClick, - onUserMentionClick = onUserMentionClick, - fallback = fallback, - ) - - verify(onLinkClick).invoke(message, "https://example.com") - verify(fallback, never()).invoke(any()) - verify(onUserMentionClick, never()).invoke(any()) - } - - @Test - fun `handleAnnotationClick falls back to default handler when onLinkClick is null`() { - val onUserMentionClick = mock<(User) -> Unit>() - val fallback = mock<(String) -> Unit>() - val message = randomMessage(text = "https://example.com") - val annotations = listOf(urlAt(start = 0, end = 19)) - - handleAnnotationClick( - annotations = annotations, - position = 5, - message = message, - onLinkClick = null, - onUserMentionClick = onUserMentionClick, - fallback = fallback, - ) - - verify(fallback).invoke("https://example.com") - verify(onUserMentionClick, never()).invoke(any()) - } - - @Test - fun `handleAnnotationClick fires onLinkClick for an email annotation`() { - val onLinkClick = mock<(Message, String) -> Unit>() - val message = randomMessage(text = "alice@example.com") - val annotations = listOf( - AnnotatedString.Range( - item = "mailto:alice@example.com", - start = 0, - end = 17, - tag = AnnotationTagEmail, - ), - ) - - handleAnnotationClick( - annotations = annotations, - position = 3, - message = message, - onLinkClick = onLinkClick, - onUserMentionClick = {}, - fallback = {}, - ) - - verify(onLinkClick).invoke(message, "mailto:alice@example.com") - } - - @Test - fun `handleAnnotationClick fires onUserMentionClick with the resolved user`() { - val mentioned = randomUser(name = "alice") - val onUserMentionClick = mock<(User) -> Unit>() - val onLinkClick = mock<(Message, String) -> Unit>() - val message = randomMessage(text = "@alice", mentionedUsers = listOf(mentioned)) - val annotations = listOf( - AnnotatedString.Range(item = "alice", start = 0, end = 6, tag = AnnotationTagMention), - ) - - handleAnnotationClick( - annotations = annotations, - position = 2, - message = message, - onLinkClick = onLinkClick, - onUserMentionClick = onUserMentionClick, - fallback = {}, - ) - - verify(onUserMentionClick).invoke(mentioned) - verify(onLinkClick, never()).invoke(any(), any()) - } - - @Test - fun `handleAnnotationClick is a no-op when the mentioned user is not in the message`() { - val onUserMentionClick = mock<(User) -> Unit>() - val message = randomMessage(text = "@bob", mentionedUsers = emptyList()) - val annotations = listOf( - AnnotatedString.Range(item = "bob", start = 0, end = 4, tag = AnnotationTagMention), - ) - - handleAnnotationClick( - annotations = annotations, - position = 1, - message = message, - onLinkClick = null, - onUserMentionClick = onUserMentionClick, - fallback = {}, - ) - - verify(onUserMentionClick, never()).invoke(any()) - } - - @Test - fun `handleAnnotationClick is a no-op when no annotation covers the position`() { - val onLinkClick = mock<(Message, String) -> Unit>() - val onUserMentionClick = mock<(User) -> Unit>() - val fallback = mock<(String) -> Unit>() - val message = randomMessage(text = "https://example.com after") - val annotations = listOf(urlAt(start = 0, end = 19)) - - handleAnnotationClick( - annotations = annotations, - position = 22, - message = message, - onLinkClick = onLinkClick, - onUserMentionClick = onUserMentionClick, - fallback = fallback, - ) - - verify(onLinkClick, never()).invoke(any(), any()) - verify(onUserMentionClick, never()).invoke(any()) - verify(fallback, never()).invoke(any()) - } - - @Test - fun `handleAnnotationClick ignores non-interactive tags`() { - val onLinkClick = mock<(Message, String) -> Unit>() - val fallback = mock<(String) -> Unit>() - val message = randomMessage() - val annotations = listOf( - AnnotatedString.Range(item = "value", start = 0, end = 10, tag = "STYLE"), - ) - - handleAnnotationClick( - annotations = annotations, - position = 5, - message = message, - onLinkClick = onLinkClick, - onUserMentionClick = {}, - fallback = fallback, - ) - - verify(onLinkClick, never()).invoke(any(), any()) - verify(fallback, never()).invoke(any()) - } - - @Test - fun `handleAnnotationClick ignores URL annotations with empty item`() { - val onLinkClick = mock<(Message, String) -> Unit>() - val fallback = mock<(String) -> Unit>() - val message = randomMessage() - val annotations = listOf( - AnnotatedString.Range(item = "", start = 0, end = 5, tag = AnnotationTagUrl), - ) - - handleAnnotationClick( - annotations = annotations, - position = 2, - message = message, - onLinkClick = onLinkClick, - onUserMentionClick = {}, - fallback = fallback, - ) - - verify(onLinkClick, never()).invoke(any(), any()) - verify(fallback, never()).invoke(any()) - } - - private fun urlAt(start: Int, end: Int) = - AnnotatedString.Range(item = "https://example.com", start = start, end = end, tag = AnnotationTagUrl) - - companion object { - - @JvmStatic - fun interactiveTagCases(): List = listOf( - Arguments.of(AnnotationTagUrl, true), - Arguments.of(AnnotationTagEmail, true), - Arguments.of(AnnotationTagMention, true), - Arguments.of("STYLE", false), - Arguments.of("UNKNOWN", false), - Arguments.of("", false), - ) + fun `text with url and mention`() { + snapshotWithDarkModeRow { MessageTextWithUrlAndMention() } } } diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.messages_MessageTextTest_plain_text.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.messages_MessageTextTest_plain_text.png new file mode 100644 index 0000000000000000000000000000000000000000..6a4bf5f7e2587ebb25b4ad5fd3065cd7e2931b3a GIT binary patch literal 8003 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%0Dw|YM%wR&H!6HwRMuW(t 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 literal 0 HcmV?d00001 diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.messages_MessageTextTest_text_with_email.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.messages_MessageTextTest_text_with_email.png new file mode 100644 index 0000000000000000000000000000000000000000..239c6411f1a80a2a38af0e0f87d625af95c4e3e2 GIT binary patch literal 14349 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 F{||m19`gVI literal 0 HcmV?d00001 diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.messages_MessageTextTest_text_with_mention.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.messages_MessageTextTest_text_with_mention.png new file mode 100644 index 0000000000000000000000000000000000000000..123ab90e280235e8fef3e65f259e210a62999591 GIT binary patch literal 10366 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 V_X=!FalLB5GbN2DB?{&t{|B$xSLXl# literal 0 HcmV?d00001 diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.messages_MessageTextTest_text_with_url_and_mention.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.messages_MessageTextTest_text_with_url_and_mention.png new file mode 100644 index 0000000000000000000000000000000000000000..3807d933be56f4ff150d9c65d9bdd15a2a0aa02a GIT binary patch literal 17516 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%0Dor(^?^@eYHI{5A{uFbu$q$ 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 literal 0 HcmV?d00001