Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,15 @@
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
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
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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,
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,15 @@ 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
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
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -210,6 +224,15 @@ public fun MessageContainer(
}
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 {
Expand Down Expand Up @@ -242,86 +265,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,
),
)
}
}
}
}
Expand Down
Loading