Skip to content

Commit b8be9ca

Browse files
authored
Scope message cell ripple to the bubble shape (#6425)
* 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. * 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. * 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. * 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. * 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). * Replace bubble-mirror plumbing with non-consuming ripple modifier 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. * Ripple text-with-link bubbles on non-link character taps 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. * 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. * Extract MessageText interactive-annotation predicates and unit-test them Pull two internal helpers out of inline lambdas in MessageText.kt: - AnnotatedString.Range<String>.isInteractiveTag() — true for URL, email, and mention tags. - List<AnnotatedString.Range<String>>.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. * Cover passiveRipple and the MessageText click dispatch with tests Extract the click-dispatch logic out of MessageText.ClickableText's inline lambda into an internal `handleAnnotationClick` helper. Same behaviour with one tightening: the original code silently fell through to URL handling for any non-mention annotation (treating its `item` as a URL); the helper uses an explicit `when` over the three interactive tags (Mention, URL, Email) and ignores anything else. The bubble predicate already restricts the click handler to interactive positions, so the tightening only affects pathological input. Add tests: - PassiveRippleTest (Compose UI tests via createComposeRule + Robolectric) covers the four reachable branches of Modifier.passiveRipple(): tap propagates to outer combinedClickable, long-press propagates to outer onLongClick, an inner consuming clickable shields the parent, and drag-out-of-bounds exercises the Cancel branch without crashing. - MessageTextTest gains eight pure-JUnit cases for handleAnnotationClick covering URL with and without onLinkClick, email, mention with resolved user, mention with unknown username, position outside any annotation, non-interactive tag, and empty annotation item. Also drop the redundant `requireUnconsumed = true` arguments at the two awaitFirstDown call sites — `true` is the default. The named boolean rule applies when a non-default value is being passed. * 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. * Fire haptic on long-press of link or mention characters ClickableText's onLongPress callback used to call onLongItemClick(message) without haptic feedback. The cell's combinedClickable.onLongClick — which handles long-press for every other region — does fire haptic. Long-press on a link / mention / email character was the one path where the action menu opened silently, because the cell is shielded there (ClickableText consumes the down). Capture LocalHapticFeedback at the MessageText composable level and include the same haptic call in the lambda passed to ClickableText, so the user feels the press acknowledgement regardless of which character they long-press. * Centralize long-press haptic at the message cell entrance The cell and ClickableText each fired HapticFeedbackType.LongPress before calling the upstream onLongItemClick. Attachments (image / file / giphy / link / quoted-reply preview) did not, so long-press on those felt different. "Long-press anywhere in a message cell fires haptic before the upstream handler" is one rule; restating it at every leaf is duplication and easy to forget when adding a new clickable. Wrap the onLongItemClick callback once at the top of MessageContainer via a small rememberHapticLongClick helper. The wrapped callback is what flows down through every existing parameter slot — cell combinedClickable, MessageText.ClickableText, attachment combinedClickables (via AttachmentState), and the quoted-reply preview — so every leaf calls onLongItemClick(message) and gets the haptic transparently. The cell's canOpenActions gate still works because the gate runs before the wrapped call: a long-press on a deleted or uploading message neither fires haptic nor dispatches. Net: one helper, one wrapping line, no duplicated haptic calls in the leaf gesture handlers. Attachments now have parity with text and the rest of the cell. No public API change (the helper is private and the callback contract from outside the cell is unchanged). * spotless * Clip quoted-card ripple to the card's rounded shape The quoted-message ripple was bleeding past the card's rounded corners because the click area didn't match the visible card bounds. Two fixes align them: - `MessageQuotedContent` factory now applies `messageSectionPadding` as the outer-most modifier and chains the caller's modifier after it. Previously the padding was appended after the caller's modifier, so a caller-supplied `combinedClickable` covered both the card and the section padding, making the touch and ripple area larger than the visible card. - `DefaultMessageRegularContent` now also clips to `RoundedCornerShape` matching the card's background, so the ripple respects the card's rounded corners.
1 parent 738b087 commit b8be9ca

18 files changed

Lines changed: 849 additions & 60 deletions

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

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

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

stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/FileAttachmentContent.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.padding
2828
import androidx.compose.foundation.layout.size
2929
import androidx.compose.foundation.shape.RoundedCornerShape
3030
import androidx.compose.material3.Text
31+
import androidx.compose.material3.ripple
3132
import androidx.compose.runtime.Composable
3233
import androidx.compose.runtime.remember
3334
import androidx.compose.ui.Alignment
@@ -111,7 +112,7 @@ public fun FileAttachmentContent(
111112
.background(color, fileAttachmentShape)
112113
}
113114
.combinedClickable(
114-
indication = null,
115+
indication = ripple(),
115116
interactionSource = remember { MutableInteractionSource() },
116117
onClick = { onItemClick(previewHandlers, attachment) },
117118
onLongClick = { attachmentState.onLongItemClick(message) },

stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/GiphyAttachmentContent.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.padding
3030
import androidx.compose.foundation.layout.size
3131
import androidx.compose.foundation.shape.RoundedCornerShape
3232
import androidx.compose.material3.Text
33+
import androidx.compose.material3.ripple
3334
import androidx.compose.runtime.Composable
3435
import androidx.compose.runtime.CompositionLocalProvider
3536
import androidx.compose.runtime.remember
@@ -123,7 +124,7 @@ public fun GiphyAttachmentContent(
123124
.clip(RoundedCornerShape(StreamTokens.radiusLg))
124125
}
125126
.combinedClickable(
126-
indication = null,
127+
indication = ripple(),
127128
interactionSource = remember { MutableInteractionSource() },
128129
onClick = {
129130
onItemClick(

stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/LinkAttachmentContent.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import androidx.compose.foundation.layout.size
3737
import androidx.compose.foundation.shape.RoundedCornerShape
3838
import androidx.compose.material3.Icon
3939
import androidx.compose.material3.Text
40+
import androidx.compose.material3.ripple
4041
import androidx.compose.runtime.Composable
4142
import androidx.compose.runtime.CompositionLocalProvider
4243
import androidx.compose.runtime.remember
@@ -111,7 +112,7 @@ public fun LinkAttachmentContent(
111112
.clip(RoundedCornerShape(StreamTokens.radiusLg))
112113
.background(MessageStyling.attachmentBackgroundColor(state.isMine))
113114
.combinedClickable(
114-
indication = null,
115+
indication = ripple(),
115116
interactionSource = remember { MutableInteractionSource() },
116117
onClick = {
117118
onItemClick(

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

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,15 @@ import androidx.compose.foundation.layout.Spacer
2525
import androidx.compose.foundation.layout.height
2626
import androidx.compose.foundation.layout.padding
2727
import androidx.compose.foundation.layout.size
28+
import androidx.compose.foundation.shape.RoundedCornerShape
2829
import androidx.compose.material3.Icon
2930
import androidx.compose.material3.Text
31+
import androidx.compose.material3.ripple
3032
import androidx.compose.runtime.Composable
3133
import androidx.compose.runtime.remember
3234
import androidx.compose.ui.Alignment
3335
import androidx.compose.ui.Modifier
36+
import androidx.compose.ui.draw.clip
3437
import androidx.compose.ui.platform.testTag
3538
import androidx.compose.ui.res.painterResource
3639
import androidx.compose.ui.res.stringResource
@@ -61,6 +64,7 @@ import io.getstream.chat.android.compose.ui.theme.MessageRegularContentParams
6164
import io.getstream.chat.android.compose.ui.theme.MessageStyling
6265
import io.getstream.chat.android.compose.ui.theme.MessageTextContentParams
6366
import io.getstream.chat.android.compose.ui.theme.StreamTokens
67+
import io.getstream.chat.android.compose.ui.util.passiveRipple
6468
import io.getstream.chat.android.compose.ui.util.shouldBeDisplayedAsFullSizeAttachment
6569
import io.getstream.chat.android.models.Message
6670
import io.getstream.chat.android.models.User
@@ -179,20 +183,25 @@ internal fun DefaultMessageRegularContent(
179183
) {
180184
val componentFactory = ChatTheme.componentFactory
181185

182-
Column(horizontalAlignment = messageAlignment.contentAlignment) {
186+
Column(
187+
modifier = Modifier.passiveRipple(),
188+
horizontalAlignment = messageAlignment.contentAlignment,
189+
) {
183190
val quotedMessage = message.replyTo
184191
if (quotedMessage != null) {
185192
componentFactory.MessageQuotedContent(
186193
params = MessageQuotedContentParams(
187194
message = quotedMessage,
188195
currentUser = currentUser,
189196
replyMessage = message,
190-
modifier = Modifier.combinedClickable(
191-
interactionSource = remember(::MutableInteractionSource),
192-
indication = null,
193-
onLongClick = { onLongItemClick(message) },
194-
onClick = { onQuotedMessageClick(quotedMessage) },
195-
),
197+
modifier = Modifier
198+
.clip(RoundedCornerShape(StreamTokens.radiusLg))
199+
.combinedClickable(
200+
interactionSource = remember(::MutableInteractionSource),
201+
indication = ripple(),
202+
onLongClick = { onLongItemClick(message) },
203+
onClick = { onQuotedMessageClick(quotedMessage) },
204+
),
196205
),
197206
)
198207
}

0 commit comments

Comments
 (0)