From e0137d9cd5e2efba9717ad4f61c09cb32dbb6c97 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Mon, 9 Mar 2026 09:44:30 +0100 Subject: [PATCH 1/2] Disable swipe-to-reply for deleted messages Co-Authored-By: Claude --- .../compose/ui/messages/list/MessageItem.kt | 19 +++++++-------- .../MessageOptionItemVisibilityTest.kt | 24 +++++++++++++++---- .../ui/common/utils/CapabilitiesHelper.kt | 6 ++++- .../ui/common/utils/CapabilitiesHelperTest.kt | 24 +++++++++++++++---- .../MessageListViewExtensionsKtTest.kt | 24 +++++++++++++++---- 5 files changed, 74 insertions(+), 23 deletions(-) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/MessageItem.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/MessageItem.kt index 6f64febfadb..f43bafbf252 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/MessageItem.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/MessageItem.kt @@ -267,18 +267,17 @@ public fun MessageItem( val messageAlignment = ChatTheme.messageAlignmentProvider.provideMessageAlignment(messageItem) val description = stringResource(id = R.string.stream_compose_cd_message_item) - val isSwipable = ChatTheme.messageOptionsTheme.optionVisibility - .canReplyToMessage( - message = message, - ownCapabilities = messageItem.ownCapabilities, - ) + val optionVisibility = ChatTheme.messageOptionsTheme.optionVisibility + val isSwipeable = remember(message, messageItem.ownCapabilities, optionVisibility) { + optionVisibility.canReplyToMessage(message, messageItem.ownCapabilities) + } // Remember the message to ensure updated values are captured in the onReply lambda val replyMessage by rememberUpdatedState(message) SwipeToReply( modifier = modifier, onReply = { onReply(replyMessage) }, - isSwipeable = { isSwipable }, + isSwipeable = isSwipeable, swipeToReplyContent = swipeToReplyContent, ) { Box( @@ -735,7 +734,7 @@ private fun getMessageBubbleShape(position: List, ownsMessage: * * @param modifier Modifier for styling. * @param onReply Handler when the user swipes to reply. - * @param isSwipeable Handler to determine if the message is swipeable. + * @param isSwipeable Indicator if swipe-te-reply is enabled. * @param swipeToReplyContent The content to show when swiping to reply. * @param content The swipeable content to show when not swiping to reply. */ @@ -744,7 +743,7 @@ private fun getMessageBubbleShape(position: List, ownsMessage: private fun SwipeToReply( modifier: Modifier = Modifier, onReply: () -> Unit = {}, - isSwipeable: () -> Boolean = { true }, + isSwipeable: Boolean = true, swipeToReplyContent: @Composable RowScope.() -> Unit, content: @Composable () -> Unit, ) { @@ -779,8 +778,8 @@ private fun SwipeToReply( .fillMaxWidth() .onSizeChanged { rowWidth = it.width.toFloat() } .offset { IntOffset(x = offset.value.roundToInt(), y = 0) } - .pointerInput(swipeToReplyWidth) { - if (isSwipeable()) { + .pointerInput(swipeToReplyWidth, isSwipeable) { + if (isSwipeable) { detectHorizontalDragGestures( onHorizontalDrag = { change, dragAmount -> // Only consume if horizontal drag dominates vertical diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/util/extensions/MessageOptionItemVisibilityTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/util/extensions/MessageOptionItemVisibilityTest.kt index efb7696a179..ba0c276d9b2 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/util/extensions/MessageOptionItemVisibilityTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/util/extensions/MessageOptionItemVisibilityTest.kt @@ -32,6 +32,7 @@ import org.amshove.kluent.`should be` import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource +import java.util.Date internal class MessageOptionItemVisibilityTest { @@ -157,9 +158,10 @@ internal class MessageOptionItemVisibilityTest { @JvmStatic fun canReplyToMessageArguments() = listOf( + // case: reply disabled Arguments.of( MessageOptionItemVisibility(isReplyVisible = false), - randomMessage(), + randomMessage(deletedAt = null, deletedForMe = false), randomChannelCapabilities(), false, ), @@ -171,13 +173,21 @@ internal class MessageOptionItemVisibilityTest { ), Arguments.of( MessageOptionItemVisibility(), - randomMessage(), + randomMessage(deletedAt = null, deletedForMe = false), randomChannelCapabilities(exclude = setOf(ChannelCapabilities.QUOTE_MESSAGE)), false, ), + // case: message is deleted Arguments.of( MessageOptionItemVisibility(isReplyVisible = true), - randomMessage(syncStatus = SyncStatus.COMPLETED), + randomMessage(syncStatus = SyncStatus.COMPLETED, deletedAt = Date(), deletedForMe = false), + randomChannelCapabilities(include = setOf(ChannelCapabilities.QUOTE_MESSAGE)), + false, + ), + // case: all conditions met + Arguments.of( + MessageOptionItemVisibility(isReplyVisible = true), + randomMessage(syncStatus = SyncStatus.COMPLETED, deletedAt = null, deletedForMe = false), randomChannelCapabilities(include = setOf(ChannelCapabilities.QUOTE_MESSAGE)), true, ), @@ -190,6 +200,7 @@ internal class MessageOptionItemVisibilityTest { randomChannelCapabilities(), false, ), + // case: message not synced Arguments.of( MessageOptionItemVisibility(), randomChannelCapabilities(exclude = setOf(ChannelCapabilities.READ_EVENTS)), @@ -210,9 +221,14 @@ internal class MessageOptionItemVisibilityTest { randomChannelCapabilities(), false, ), + // case: no QUOTE_MESSAGE capability Arguments.of( MessageOptionItemVisibility(), - randomMessage(syncStatus = randomSyncStatus(exclude = listOf(SyncStatus.COMPLETED))), + randomMessage( + syncStatus = randomSyncStatus(exclude = listOf(SyncStatus.COMPLETED)), + deletedAt = null, + deletedForMe = false, + ), randomChannelCapabilities(), false, ), diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/utils/CapabilitiesHelper.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/utils/CapabilitiesHelper.kt index d0d7d2cba90..0851b0f6b40 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/utils/CapabilitiesHelper.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/utils/CapabilitiesHelper.kt @@ -20,6 +20,7 @@ package io.getstream.chat.android.ui.common.utils import io.getstream.chat.android.client.utils.attachment.isGiphy import io.getstream.chat.android.client.utils.message.hasSharedLocation +import io.getstream.chat.android.client.utils.message.isDeleted import io.getstream.chat.android.client.utils.message.isThreadReply import io.getstream.chat.android.models.AttachmentType import io.getstream.chat.android.models.ChannelCapabilities @@ -59,7 +60,10 @@ public fun canReplyToMessage( replyEnabled: Boolean, message: Message, ownCapabilities: Set, -): Boolean = replyEnabled && message.isSynced() && ownCapabilities.contains(ChannelCapabilities.QUOTE_MESSAGE) +): Boolean = replyEnabled && + message.isSynced() && + !message.isDeleted() && + ownCapabilities.contains(ChannelCapabilities.QUOTE_MESSAGE) /** * Determines whether a thread reply can be made to the given message. diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/utils/CapabilitiesHelperTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/utils/CapabilitiesHelperTest.kt index 736843285d8..e5ffe474ff2 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/utils/CapabilitiesHelperTest.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/utils/CapabilitiesHelperTest.kt @@ -31,6 +31,7 @@ import org.amshove.kluent.`should be` import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource +import java.util.Date internal class CapabilitiesHelperTest { @@ -152,6 +153,7 @@ internal class CapabilitiesHelperTest { @JvmStatic fun canReplyToMessageArguments() = listOf( + // case: reply disabled Arguments.of( false, randomMessage(), @@ -166,13 +168,21 @@ internal class CapabilitiesHelperTest { ), Arguments.of( randomBoolean(), - randomMessage(), + randomMessage(deletedAt = null, deletedForMe = false), randomChannelCapabilities(exclude = setOf(ChannelCapabilities.QUOTE_MESSAGE)), false, ), + // case: message is deleted Arguments.of( true, - randomMessage(syncStatus = SyncStatus.COMPLETED), + randomMessage(syncStatus = SyncStatus.COMPLETED, deletedAt = Date(), deletedForMe = false), + randomChannelCapabilities(include = setOf(ChannelCapabilities.QUOTE_MESSAGE)), + false, + ), + // case: all conditions met + Arguments.of( + true, + randomMessage(syncStatus = SyncStatus.COMPLETED, deletedAt = null, deletedForMe = false), randomChannelCapabilities(include = setOf(ChannelCapabilities.QUOTE_MESSAGE)), true, ), @@ -185,6 +195,7 @@ internal class CapabilitiesHelperTest { randomChannelCapabilities(), false, ), + // case: message not synced Arguments.of( randomBoolean(), randomChannelCapabilities(exclude = setOf(ChannelCapabilities.READ_EVENTS)), @@ -201,13 +212,18 @@ internal class CapabilitiesHelperTest { fun canPinMessageArguments() = listOf( Arguments.of( false, - randomMessage(), + randomMessage(deletedAt = null, deletedForMe = false), randomChannelCapabilities(), false, ), + // case: no QUOTE_MESSAGE capability Arguments.of( randomBoolean(), - randomMessage(syncStatus = randomSyncStatus(exclude = listOf(SyncStatus.COMPLETED))), + randomMessage( + syncStatus = randomSyncStatus(exclude = listOf(SyncStatus.COMPLETED)), + deletedAt = null, + deletedForMe = false, + ), randomChannelCapabilities(), false, ), diff --git a/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/feature/messages/list/internal/MessageListViewExtensionsKtTest.kt b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/feature/messages/list/internal/MessageListViewExtensionsKtTest.kt index a18a9b8ad72..e3518973c30 100644 --- a/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/feature/messages/list/internal/MessageListViewExtensionsKtTest.kt +++ b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/feature/messages/list/internal/MessageListViewExtensionsKtTest.kt @@ -33,6 +33,7 @@ import org.amshove.kluent.`should be` import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource +import java.util.Date internal class MessageListViewExtensionsKtTest { @@ -154,27 +155,42 @@ internal class MessageListViewExtensionsKtTest { @JvmStatic fun canReplyToMessageArguments() = listOf( + // case: reply disabled Arguments.of( randomMessageListViewStyle(replyEnabled = false), - randomMessage(), + randomMessage(deletedAt = null, deletedForMe = false), randomChannelCapabilities(), false, ), + // case: message not synced Arguments.of( randomMessageListViewStyle(), - randomMessage(syncStatus = randomSyncStatus(exclude = listOf(SyncStatus.COMPLETED))), + randomMessage( + syncStatus = randomSyncStatus(exclude = listOf(SyncStatus.COMPLETED)), + deletedAt = null, + deletedForMe = false, + ), randomChannelCapabilities(), false, ), + // case: no QUOTE_MESSAGE capability Arguments.of( randomMessageListViewStyle(), - randomMessage(), + randomMessage(deletedAt = null, deletedForMe = false), randomChannelCapabilities(exclude = setOf(ChannelCapabilities.QUOTE_MESSAGE)), false, ), + // case: message is deleted Arguments.of( randomMessageListViewStyle(replyEnabled = true), - randomMessage(syncStatus = SyncStatus.COMPLETED), + randomMessage(syncStatus = SyncStatus.COMPLETED, deletedAt = Date(), deletedForMe = false), + randomChannelCapabilities(include = setOf(ChannelCapabilities.QUOTE_MESSAGE)), + false, + ), + // case: all conditions met + Arguments.of( + randomMessageListViewStyle(replyEnabled = true), + randomMessage(syncStatus = SyncStatus.COMPLETED, deletedAt = null, deletedForMe = false), randomChannelCapabilities(include = setOf(ChannelCapabilities.QUOTE_MESSAGE)), true, ), From 9e2ad66fb34e6069bb86d4bb1e07accabebd906c Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Mon, 9 Mar 2026 10:07:04 +0100 Subject: [PATCH 2/2] PR remarks. --- .../android/compose/ui/messages/list/MessageItem.kt | 2 +- .../extensions/MessageOptionItemVisibilityTest.kt | 10 +++------- .../ui/common/utils/CapabilitiesHelperTest.kt | 12 ++++-------- 3 files changed, 8 insertions(+), 16 deletions(-) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/MessageItem.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/MessageItem.kt index f43bafbf252..672d00f13e6 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/MessageItem.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/MessageItem.kt @@ -734,7 +734,7 @@ private fun getMessageBubbleShape(position: List, ownsMessage: * * @param modifier Modifier for styling. * @param onReply Handler when the user swipes to reply. - * @param isSwipeable Indicator if swipe-te-reply is enabled. + * @param isSwipeable Indicator if swipe-to-reply is enabled. * @param swipeToReplyContent The content to show when swiping to reply. * @param content The swipeable content to show when not swiping to reply. */ diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/util/extensions/MessageOptionItemVisibilityTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/util/extensions/MessageOptionItemVisibilityTest.kt index ba0c276d9b2..33db376c5e6 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/util/extensions/MessageOptionItemVisibilityTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/util/extensions/MessageOptionItemVisibilityTest.kt @@ -165,12 +165,14 @@ internal class MessageOptionItemVisibilityTest { randomChannelCapabilities(), false, ), + // case: message not synced Arguments.of( MessageOptionItemVisibility(), randomMessage(syncStatus = randomSyncStatus(exclude = listOf(SyncStatus.COMPLETED))), randomChannelCapabilities(), false, ), + // case: no QUOTE_MESSAGE capability Arguments.of( MessageOptionItemVisibility(), randomMessage(deletedAt = null, deletedForMe = false), @@ -200,7 +202,6 @@ internal class MessageOptionItemVisibilityTest { randomChannelCapabilities(), false, ), - // case: message not synced Arguments.of( MessageOptionItemVisibility(), randomChannelCapabilities(exclude = setOf(ChannelCapabilities.READ_EVENTS)), @@ -221,14 +222,9 @@ internal class MessageOptionItemVisibilityTest { randomChannelCapabilities(), false, ), - // case: no QUOTE_MESSAGE capability Arguments.of( MessageOptionItemVisibility(), - randomMessage( - syncStatus = randomSyncStatus(exclude = listOf(SyncStatus.COMPLETED)), - deletedAt = null, - deletedForMe = false, - ), + randomMessage(syncStatus = randomSyncStatus(exclude = listOf(SyncStatus.COMPLETED))), randomChannelCapabilities(), false, ), diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/utils/CapabilitiesHelperTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/utils/CapabilitiesHelperTest.kt index e5ffe474ff2..d72b6e7de7c 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/utils/CapabilitiesHelperTest.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/utils/CapabilitiesHelperTest.kt @@ -160,12 +160,14 @@ internal class CapabilitiesHelperTest { randomChannelCapabilities(), false, ), + // case: message not synced Arguments.of( randomBoolean(), randomMessage(syncStatus = randomSyncStatus(exclude = listOf(SyncStatus.COMPLETED))), randomChannelCapabilities(), false, ), + // case: no QUOTE_MESSAGE capability Arguments.of( randomBoolean(), randomMessage(deletedAt = null, deletedForMe = false), @@ -195,7 +197,6 @@ internal class CapabilitiesHelperTest { randomChannelCapabilities(), false, ), - // case: message not synced Arguments.of( randomBoolean(), randomChannelCapabilities(exclude = setOf(ChannelCapabilities.READ_EVENTS)), @@ -212,18 +213,13 @@ internal class CapabilitiesHelperTest { fun canPinMessageArguments() = listOf( Arguments.of( false, - randomMessage(deletedAt = null, deletedForMe = false), + randomMessage(), randomChannelCapabilities(), false, ), - // case: no QUOTE_MESSAGE capability Arguments.of( randomBoolean(), - randomMessage( - syncStatus = randomSyncStatus(exclude = listOf(SyncStatus.COMPLETED)), - deletedAt = null, - deletedForMe = false, - ), + randomMessage(syncStatus = randomSyncStatus(exclude = listOf(SyncStatus.COMPLETED))), randomChannelCapabilities(), false, ),