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/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..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 @@ -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 @@ -61,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 @@ -179,7 +181,10 @@ internal fun DefaultMessageRegularContent( ) { val componentFactory = ChatTheme.componentFactory - Column(horizontalAlignment = messageAlignment.contentAlignment) { + Column( + modifier = Modifier.passiveRipple(), + horizontalAlignment = messageAlignment.contentAlignment, + ) { val quotedMessage = message.replyTo if (quotedMessage != null) { componentFactory.MessageQuotedContent( @@ -189,7 +194,7 @@ internal fun DefaultMessageRegularContent( replyMessage = message, modifier = Modifier.combinedClickable( interactionSource = remember(::MutableInteractionSource), - indication = null, + indication = ripple(), onLongClick = { onLongItemClick(message) }, onClick = { onQuotedMessageClick(quotedMessage) }, ), 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..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 @@ -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 @@ -25,8 +27,12 @@ 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 +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 +43,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 @@ -97,11 +104,10 @@ public fun MessageText( else -> MessageStyling.textStyle(outgoing = message.isMine(currentUser)) } - val annotations = styledText.getStringAnnotations(0, styledText.lastIndex) - if (annotations.fastAny { - it.tag == AnnotationTagUrl || it.tag == AnnotationTagEmail || it.tag == AnnotationTagMention - } - ) { + val annotations = remember(styledText) { + styledText.getStringAnnotations(0, styledText.lastIndex) + } + if (annotations.fastAny(AnnotatedString.Range::isInteractiveTag)) { ClickableText( modifier = modifier .padding(MessageStyling.textPadding) @@ -109,18 +115,18 @@ public fun MessageText( text = styledText, style = style, onLongPress = { onLongItemClick(message) }, + isInteractiveAt = annotations::hasInteractiveAt, ) { position -> - val annotation = annotations.firstOrNull { position in it.start..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( @@ -135,10 +141,15 @@ 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. * - * @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 +163,44 @@ 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)) + // 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 (!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() + val up: PointerInputChange? = try { + withTimeout(viewConfiguration.longPressTimeoutMillis) { + waitForUpOrCancellation() } - }, - ) + } catch (_: PointerEventTimeoutCancellationException) { + // 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() + currentOnClick(charAt) + } + } } BasicText( @@ -180,14 +217,136 @@ private fun ClickableText( ) } -@Preview -@Composable -private fun MessageTextPreview() { - ChatTheme { - MessageText( - message = Message(text = "Hello World!"), - currentUser = null, - onLongItemClick = {}, - ) +private suspend fun AwaitPointerEventScope.consumeUntilUp() { + do { + val event = awaitPointerEvent() + event.changes.fastForEach { it.consume() } + } 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 } + +/** + * 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 { + it.isInteractiveTag() && 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) + } + } } } + +@Composable +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/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..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 @@ -70,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 @@ -227,7 +228,11 @@ private fun PollMessageContent( ) } - Column(modifier = Modifier.padding(StreamTokens.spacingMd)) { + Column( + modifier = Modifier + .passiveRipple() + .padding(StreamTokens.spacingMd), + ) { Text( text = poll.name, style = typography.bodyEmphasis, 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..f5a0f1b1fcc 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 @@ -39,7 +39,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 @@ -173,22 +172,17 @@ public fun MessageContainer( ) { val message = messageItem.message val focusState = messageItem.focusState - val haptic = LocalHapticFeedback.current val canOpenThread = message.isThreadStart() && !messageItem.isInThread val canOpenActions = !message.isDeleted() && !message.isUploading() + val onLongItemClick = rememberHapticLongClick(onLongItemClick) val clickModifier = Modifier.combinedClickable( - interactionSource = remember { MutableInteractionSource() }, - indication = ripple(), + interactionSource = remember(::MutableInteractionSource), + indication = null, enabled = canOpenThread || canOpenActions, onClick = { if (canOpenThread) onThreadClick(message) }, - onLongClick = { - if (canOpenActions) { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - onLongItemClick(message) - } - }, + onLongClick = { if (canOpenActions) onLongItemClick(message) }, ) val highlightColor = ChatTheme.colors.backgroundCoreHighlight @@ -904,3 +898,22 @@ private fun Modifier.verticalPaddingIfNotEmpty(padding: Dp) = layout { measurabl * Represents the time the highlight fade out transition will take. */ public const val HighlightFadeOutDurationMillis: Int = 2000 + +/** + * Returns a long-click handler that fires [HapticFeedbackType.LongPress] before delegating to + * [onLongItemClick]. Applied once at the cell entrance so every leaf clickable (text, link + * characters, attachments, quoted-reply preview) gets the haptic without restating the policy. + */ +@Composable +private fun rememberHapticLongClick( + onLongItemClick: (Message) -> Unit, +): (Message) -> Unit { + val haptic = LocalHapticFeedback.current + return remember(haptic, onLongItemClick) { + { + message -> + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onLongItemClick(message) + } + } +} 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..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 @@ -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() + 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 }) 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 new file mode 100644 index 00000000000..bdfe8fa5f55 --- /dev/null +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/components/messages/MessageTextTest.kt @@ -0,0 +1,59 @@ +/* + * 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 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 : PaparazziComposeTest { + + @get:Rule + override val paparazzi = Paparazzi( + deviceConfig = DeviceConfig.PIXEL_2.copy(orientation = ScreenOrientation.LANDSCAPE), + renderingMode = SessionParams.RenderingMode.SHRINK, + ) + + @Test + fun `plain text`() { + snapshotWithDarkModeRow { MessageTextPlain() } + } + + @Test + fun `text with url`() { + snapshotWithDarkModeRow { MessageTextWithUrl() } + } + + @Test + fun `text with email`() { + snapshotWithDarkModeRow { MessageTextWithEmail() } + } + + @Test + fun `text with mention`() { + snapshotWithDarkModeRow { MessageTextWithMention() } + } + + @Test + fun `text with url and mention`() { + snapshotWithDarkModeRow { MessageTextWithUrlAndMention() } + } +} 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() + } + } +} 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 00000000000..6a4bf5f7e25 Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.messages_MessageTextTest_plain_text.png differ 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 00000000000..239c6411f1a Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.messages_MessageTextTest_text_with_email.png differ 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 00000000000..123ab90e280 Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.messages_MessageTextTest_text_with_mention.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.messages_MessageTextTest_text_with_url.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.messages_MessageTextTest_text_with_url.png new file mode 100644 index 00000000000..58ecf60dd16 Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.messages_MessageTextTest_text_with_url.png differ 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 00000000000..3807d933be5 Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.messages_MessageTextTest_text_with_url_and_mention.png differ