diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/poll/PollOptionVotingRow.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/poll/PollOptionVotingRow.kt index a409f1fcc9e..4778fb11878 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/poll/PollOptionVotingRow.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/poll/PollOptionVotingRow.kt @@ -17,6 +17,8 @@ package io.getstream.chat.android.compose.ui.components.poll import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -24,7 +26,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.Text @@ -40,12 +41,15 @@ import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.hideFromAccessibility import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.toggleableState +import androidx.compose.ui.state.ToggleableState import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.getstream.chat.android.compose.R import io.getstream.chat.android.compose.ui.components.avatar.AvatarSize import io.getstream.chat.android.compose.ui.components.avatar.UserAvatarStack import io.getstream.chat.android.compose.ui.components.common.RadioCheck +import io.getstream.chat.android.compose.ui.messages.list.LocalMessageOnLongClick import io.getstream.chat.android.compose.ui.theme.ChatTheme import io.getstream.chat.android.compose.ui.theme.MessageStyling.PollStyle import io.getstream.chat.android.compose.ui.theme.StreamTokens @@ -81,6 +85,7 @@ import io.getstream.chat.android.models.VotingVisibility * @param onRemoveVote Invoked when the user removes their vote from this option. * @param modifier Modifier applied to the row container. */ +@OptIn(ExperimentalFoundationApi::class) @Suppress("LongParameterList", "LongMethod") @Composable internal fun PollOptionVotingRow( @@ -105,18 +110,27 @@ internal fun PollOptionVotingRow( onRemoveVote() } } + // Forward long-press up to the message row's actions-menu handler. Without this, the + // toggle would consume the long-press as a tap and TalkBack's double-tap-and-hold would + // never open the actions menu over a poll option. + val onMessageLongClick = LocalMessageOnLongClick.current Row( modifier = modifier .fillMaxWidth() .applyIf(!poll.closed) { - toggleable( - value = checked, + combinedClickable( role = toggleRole, - onValueChange = onToggle, + onClick = { onToggle(!checked) }, + onLongClick = onMessageLongClick, ) - } - .semantics(mergeDescendants = true) {}, + // `combinedClickable` sets the role but not the toggle state — restore the + // "checked" / "not checked" announce that the previous `toggleable` modifier + // contributed so TalkBack still reads the current vote state on each option. + .semantics { + toggleableState = if (checked) ToggleableState.On else ToggleableState.Off + } + }, horizontalArrangement = Arrangement.spacedBy(StreamTokens.spacingSm), verticalAlignment = Alignment.CenterVertically, ) { 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 a0c6d964e27..61db923fffc 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 @@ -41,6 +41,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf @@ -48,6 +49,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds @@ -120,6 +122,18 @@ import kotlinx.coroutines.launch import kotlin.math.absoluteValue import kotlin.math.roundToInt +/** + * Forwards a long-press from an interactive descendant of the message bubble (poll option, + * attachment item, etc.) up to the message row's actions-menu handler. Provided by + * [MessageContainer] when long-press is supported for the current message; `null` otherwise. + * + * Compose's nested gesture handling means a child `toggleable` consumes the long-press as a tap + * before the row's `combinedClickable` can react. Children inside composite content read this + * local and pass it as `onLongClick` to their own `combinedClickable`, so the long-press routes + * to the actions menu regardless of which leaf the user holds. + */ +internal val LocalMessageOnLongClick = staticCompositionLocalOf<(() -> Unit)?> { null } + /** * The default message container for a regular message in the Conversation/Messages screen. * @@ -211,6 +225,15 @@ public fun MessageContainer( // No click handler on deleted/uploading messages; merge into one TalkBack stop. else -> Modifier.semantics(mergeDescendants = true) {} } + // Provide the row's long-press handler to descendants so an interactive leaf (e.g. a poll + // option's combinedClickable) can forward `onLongClick` here and reliably open the actions + // menu instead of being intercepted by its own gesture handling. Null when the row isn't + // long-pressable so descendants fall back to no-op. + val messageOnLongClick: (() -> Unit)? = if (canOpenActions) { + { currentOnLongItemClick(currentMessage) } + } else { + null + } val highlightColor = ChatTheme.colors.backgroundCoreHighlight val backgroundColor = when { @@ -243,86 +266,88 @@ public fun MessageContainer( onReply = { onReply(replyMessage) }, isSwipeable = isSwipeable, ) { - Row( - modifier = Modifier - .testTag("Stream_MessageCell") - .fillMaxWidth() - .wrapContentHeight() - .background(color = color) - .then(clickModifier), - horizontalArrangement = if (messageAlignment == MessageAlignment.Start) { - Arrangement.Start - } else { - Arrangement.End - }, - ) { - with(ChatTheme.componentFactory) { - when (messageAlignment) { - MessageAlignment.Start -> MessageAuthor( - params = MessageAuthorParams( - messageItem = messageItem, - onUserAvatarClick = onUserAvatarClick, - ), - ) + CompositionLocalProvider(LocalMessageOnLongClick provides messageOnLongClick) { + Row( + modifier = Modifier + .testTag("Stream_MessageCell") + .fillMaxWidth() + .wrapContentHeight() + .background(color = color) + .then(clickModifier), + horizontalArrangement = if (messageAlignment == MessageAlignment.Start) { + Arrangement.Start + } else { + Arrangement.End + }, + ) { + with(ChatTheme.componentFactory) { + when (messageAlignment) { + MessageAlignment.Start -> MessageAuthor( + params = MessageAuthorParams( + messageItem = messageItem, + onUserAvatarClick = onUserAvatarClick, + ), + ) - MessageAlignment.End -> MessageSpacer(params = MessageSpacerParams(messageItem)) - } + MessageAlignment.End -> MessageSpacer(params = MessageSpacerParams(messageItem)) + } - Column( - modifier = Modifier.weight(1f, fill = false), - horizontalAlignment = messageAlignment.contentAlignment, - ) { - MessageTop( - params = MessageTopParams( - messageItem = messageItem, - onThreadClick = onThreadClick, - ), - ) - MessageContentWithReactions( - messageAlignment = messageAlignment, - reactions = rememberMessageReactions(message)?.let { reactions -> - { - MessageReactions( - params = MessageReactionsParams( - message = message, - reactions = reactions, - onClick = onReactionsClick, + Column( + modifier = Modifier.weight(1f, fill = false), + horizontalAlignment = messageAlignment.contentAlignment, + ) { + MessageTop( + params = MessageTopParams( + messageItem = messageItem, + onThreadClick = onThreadClick, + ), + ) + MessageContentWithReactions( + messageAlignment = messageAlignment, + reactions = rememberMessageReactions(message)?.let { reactions -> + { + MessageReactions( + params = MessageReactionsParams( + message = message, + reactions = reactions, + onClick = onReactionsClick, + ), + ) + } + }, + content = { + MessageContent( + params = MessageContentParams( + messageItem = messageItem, + onLongItemClick = onLongItemClick, + onMediaGalleryPreviewResult = onMediaGalleryPreviewResult, + onGiphyActionClick = onGiphyActionClick, + onQuotedMessageClick = onQuotedMessageClick, + onLinkClick = onLinkClick, + onUserMentionClick = onUserMentionClick, + onPollUpdated = onPollUpdated, + onCastVote = onCastVote, + onRemoveVote = onRemoveVote, + selectPoll = selectPoll, + onAddAnswer = onAddAnswer, + onClosePoll = onClosePoll, + onAddPollOption = onAddPollOption, ), ) - } - }, - content = { - MessageContent( - params = MessageContentParams( - messageItem = messageItem, - onLongItemClick = onLongItemClick, - onMediaGalleryPreviewResult = onMediaGalleryPreviewResult, - onGiphyActionClick = onGiphyActionClick, - onQuotedMessageClick = onQuotedMessageClick, - onLinkClick = onLinkClick, - onUserMentionClick = onUserMentionClick, - onPollUpdated = onPollUpdated, - onCastVote = onCastVote, - onRemoveVote = onRemoveVote, - selectPoll = selectPoll, - onAddAnswer = onAddAnswer, - onClosePoll = onClosePoll, - onAddPollOption = onAddPollOption, - ), - ) - }, - ) - MessageBottom(params = MessageBottomParams(messageItem = messageItem)) - } + }, + ) + MessageBottom(params = MessageBottomParams(messageItem = messageItem)) + } - when (messageAlignment) { - MessageAlignment.Start -> MessageSpacer(params = MessageSpacerParams(messageItem)) - MessageAlignment.End -> MessageAuthor( - params = MessageAuthorParams( - messageItem = messageItem, - onUserAvatarClick = onUserAvatarClick, - ), - ) + when (messageAlignment) { + MessageAlignment.Start -> MessageSpacer(params = MessageSpacerParams(messageItem)) + MessageAlignment.End -> MessageAuthor( + params = MessageAuthorParams( + messageItem = messageItem, + onUserAvatarClick = onUserAvatarClick, + ), + ) + } } } }