Improve accessibility of the timeline (#4579)

* Make whole messages selectable and readable as a single unit when possible.
* Make most UI components not clickable when talkback is enabled.
* Make voice messages work with talkback too.
* Read grouped state events even if the events are collapsed.
* Move image and video item actions to the timeline item.
* Improve accessibility in the message context menu too
* Fix a11y issue on add attachment button.
* Add `contentDescription` to file icon so it's read aloud

---------

Co-authored-by: Benoit Marty <benoit@matrix.org>
This commit is contained in:
Jorge Martin Espinosa 2025-04-15 17:28:29 +02:00 committed by GitHub
parent fe5d9b4308
commit a346faba08
18 changed files with 226 additions and 70 deletions

View file

@ -39,6 +39,8 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.traversalIndex
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
@ -188,6 +190,7 @@ private fun ActionListViewContent(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.clearAndSetSemantics {},
) )
if (target.event.messageShield != null) { if (target.event.messageShield != null) {
MessageShieldView( MessageShieldView(
@ -347,12 +350,21 @@ private fun EmojiReactionsRow(
) )
for (emoji in defaultEmojis) { for (emoji in defaultEmojis) {
val isHighlighted = highlightedEmojis.contains(emoji) val isHighlighted = highlightedEmojis.contains(emoji)
EmojiButton(emoji, isHighlighted, onEmojiReactionClick) EmojiButton(
modifier = Modifier
// Make it appear after the more useful actions for the accessibility service
.semantics {
traversalIndex = 1f
},
emoji = emoji,
isHighlighted = isHighlighted,
onClick = onEmojiReactionClick
)
} }
Box( Box(
modifier = Modifier modifier = Modifier
.size(48.dp), .size(48.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center,
) { ) {
Icon( Icon(
imageVector = CompoundIcons.ReactionAdd(), imageVector = CompoundIcons.ReactionAdd(),
@ -366,6 +378,10 @@ private fun EmojiReactionsRow(
indication = ripple(bounded = false, radius = emojiRippleRadius), indication = ripple(bounded = false, radius = emojiRippleRadius),
interactionSource = remember { MutableInteractionSource() } interactionSource = remember { MutableInteractionSource() }
) )
// Make it appear after the more useful actions for the accessibility service
.semantics {
traversalIndex = 1f
}
) )
} }
} }
@ -413,6 +429,7 @@ private fun EmojiButton(
emoji: String, emoji: String,
isHighlighted: Boolean, isHighlighted: Boolean,
onClick: (String) -> Unit, onClick: (String) -> Unit,
modifier: Modifier = Modifier,
) { ) {
val backgroundColor = if (isHighlighted) { val backgroundColor = if (isHighlighted) {
ElementTheme.colors.bgActionPrimaryRest ElementTheme.colors.bgActionPrimaryRest
@ -425,10 +442,16 @@ private fun EmojiButton(
stringResource(id = CommonStrings.a11y_react_with, emoji) stringResource(id = CommonStrings.a11y_react_with, emoji)
} }
Box( Box(
modifier = Modifier modifier = modifier
.size(48.dp) .size(48.dp)
.background(backgroundColor, CircleShape) .background(backgroundColor, CircleShape)
.clearAndSetSemantics { .clickable(
enabled = true,
onClick = { onClick(emoji) },
indication = ripple(bounded = false, radius = emojiRippleRadius),
interactionSource = remember { MutableInteractionSource() }
)
.semantics {
contentDescription = description contentDescription = description
}, },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
@ -436,13 +459,6 @@ private fun EmojiButton(
Text( Text(
emoji, emoji,
style = ElementTheme.typography.fontBodyLgRegular.copy(fontSize = 24.dp.toSp(), color = Color.White), style = ElementTheme.typography.fontBodyLgRegular.copy(fontSize = 24.dp.toSp(), color = Color.White),
modifier = Modifier
.clickable(
enabled = true,
onClick = { onClick(emoji) },
indication = ripple(bounded = false, radius = emojiRippleRadius),
interactionSource = remember { MutableInteractionSource() }
)
) )
} }
} }

View file

@ -48,6 +48,7 @@ internal fun DisabledComposerView(
IconColorButton( IconColorButton(
onClick = {}, onClick = {},
imageVector = CompoundIcons.Plus(), imageVector = CompoundIcons.Plus(),
contentDescription = null,
iconColorButtonStyle = IconColorButtonStyle.Disabled, iconColorButtonStyle = IconColorButtonStyle.Disabled,
) )

View file

@ -8,7 +8,6 @@
package io.element.android.features.messages.impl.timeline package io.element.android.features.messages.impl.timeline
import android.view.HapticFeedbackConstants import android.view.HapticFeedbackConstants
import android.view.accessibility.AccessibilityManager
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
@ -75,6 +74,7 @@ import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.time.isTalkbackActive
import io.element.android.wysiwyg.link.Link import io.element.android.wysiwyg.link.Link
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
@ -126,10 +126,7 @@ fun TimelineView(
val context = LocalContext.current val context = LocalContext.current
val view = LocalView.current val view = LocalView.current
// Disable reverse layout when TalkBack is enabled to avoid incorrect ordering issues seen in the current Compose UI version // Disable reverse layout when TalkBack is enabled to avoid incorrect ordering issues seen in the current Compose UI version
val useReverseLayout = remember { val useReverseLayout = !isTalkbackActive()
val accessibilityManager = context.getSystemService(AccessibilityManager::class.java)
accessibilityManager.isTouchExplorationEnabled.not()
}
fun inReplyToClick(eventId: EventId) { fun inReplyToClick(eventId: EventId) {
state.eventSink(TimelineEvents.FocusOnEvent(eventId)) state.eventSink(TimelineEvents.FocusOnEvent(eventId))
@ -159,7 +156,7 @@ fun TimelineView(
.testTag(TestTags.timeline), .testTag(TestTags.timeline),
state = lazyListState, state = lazyListState,
reverseLayout = useReverseLayout, reverseLayout = useReverseLayout,
contentPadding = PaddingValues(vertical = 8.dp), contentPadding = PaddingValues(top = 64.dp, bottom = 8.dp),
) { ) {
items( items(
items = state.timelineItems, items = state.timelineItems,

View file

@ -49,6 +49,7 @@ import io.element.android.libraries.designsystem.theme.messageFromMeBackground
import io.element.android.libraries.designsystem.theme.messageFromOtherBackground import io.element.android.libraries.designsystem.theme.messageFromOtherBackground
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.utils.time.isTalkbackActive
private val BUBBLE_RADIUS = 12.dp private val BUBBLE_RADIUS = 12.dp
private val avatarRadius = AvatarSize.TimelineSender.dp / 2 private val avatarRadius = AvatarSize.TimelineSender.dp / 2
@ -95,6 +96,17 @@ fun MessageEventBubble(
} }
} }
val clickableModifier = if (isTalkbackActive()) {
Modifier
} else {
Modifier.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
indication = ripple(),
interactionSource = interactionSource
)
}
// Ignore state.isHighlighted for now, we need a design decision on it. // Ignore state.isHighlighted for now, we need a design decision on it.
val backgroundBubbleColor = when { val backgroundBubbleColor = when {
state.isMine -> ElementTheme.colors.messageFromMeBackground state.isMine -> ElementTheme.colors.messageFromMeBackground
@ -137,12 +149,7 @@ fun MessageEventBubble(
.toDp() .toDp()
) )
.clip(bubbleShape) .clip(bubbleShape)
.combinedClickable( .then(clickableModifier),
onClick = onClick,
onLongClick = onLongClick,
indication = ripple(),
interactionSource = interactionSource
),
color = backgroundBubbleColor, color = backgroundBubbleColor,
shape = bubbleShape, shape = bubbleShape,
content = content content = content

View file

@ -18,6 +18,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.invisibleToUser
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
@ -77,7 +79,8 @@ fun TimelineEventTimestampView(
.size(15.dp, 18.dp) .size(15.dp, 18.dp)
.clickable(isVerifiedUserSendFailure) { .clickable(isVerifiedUserSendFailure) {
eventSink(TimelineEvents.ComputeVerifiedUserSendFailure(event)) eventSink(TimelineEvents.ComputeVerifiedUserSendFailure(event))
}, }
.semantics { invisibleToUser() }
) )
} }
@ -91,7 +94,8 @@ fun TimelineEventTimestampView(
.size(15.dp) .size(15.dp)
.clickable { .clickable {
eventSink(TimelineEvents.ShowShieldDialog(shield)) eventSink(TimelineEvents.ShowShieldDialog(shield))
}, }
.semantics { invisibleToUser() },
tint = shield.toIconColor(), tint = shield.toIconColor(),
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))

View file

@ -37,13 +37,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.platform.ViewConfiguration import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.invisibleToUser import androidx.compose.ui.semantics.invisibleToUser
import androidx.compose.ui.semantics.isTraversalGroup
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag import androidx.compose.ui.semantics.traversalIndex
import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -96,6 +95,7 @@ import io.element.android.libraries.matrix.ui.messages.sender.SenderNameMode
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.time.isTalkbackActive
import io.element.android.wysiwyg.link.Link import io.element.android.wysiwyg.link.Link
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.abs import kotlin.math.abs
@ -245,7 +245,7 @@ fun TimelineItemEventRow(
), ),
renderReadReceipts = renderReadReceipts, renderReadReceipts = renderReadReceipts,
onReadReceiptsClick = { onReadReceiptClick(event) }, onReadReceiptsClick = { onReadReceiptClick(event) },
modifier = Modifier.padding(top = 4.dp), modifier = Modifier.padding(top = 4.dp)
) )
} }
} }
@ -595,7 +595,10 @@ private fun MessageEventBubbleContent(
timestampPosition = timestampPosition, timestampPosition = timestampPosition,
eventSink = eventSink, eventSink = eventSink,
canShrinkContent = canShrinkContent, canShrinkContent = canShrinkContent,
modifier = timestampLayoutModifier, modifier = timestampLayoutModifier.semantics(mergeDescendants = false) {
isTraversalGroup = true
traversalIndex = -1f
},
content = { onContentLayoutChange -> content = { onContentLayoutChange ->
eventContentView(contentModifier, onContentLayoutChange) eventContentView(contentModifier, onContentLayoutChange)
} }
@ -607,17 +610,23 @@ private fun MessageEventBubbleContent(
val inReplyToModifier = Modifier val inReplyToModifier = Modifier
.padding(top = topPadding, start = 8.dp, end = 8.dp) .padding(top = topPadding, start = 8.dp, end = 8.dp)
.clip(RoundedCornerShape(6.dp)) .clip(RoundedCornerShape(6.dp))
// FIXME when a node is clickable, its contents won't be added to the semantics tree of its parent
.clickable(onClick = inReplyToClick) val talkbackCompatModifier = if (isTalkbackActive()) {
// Use z-index to make the replied to text being read after the message
// Usually, you'd use traversalIndex for that, but it's not working for some reason
inReplyToModifier.zIndex(1f)
} else {
inReplyToModifier.clickable(onClick = inReplyToClick)
}
InReplyToView( InReplyToView(
inReplyTo = inReplyTo, inReplyTo = inReplyTo,
hideImage = timelineProtectionState.hideMediaContent(inReplyTo.eventId()), hideImage = timelineProtectionState.hideMediaContent(inReplyTo.eventId()),
modifier = inReplyToModifier, modifier = talkbackCompatModifier,
) )
} }
if (inReplyToDetails != null) { if (inReplyToDetails != null) {
// Use SubComposeLayout only if necessary as it can have consequences on the performance. // Use SubComposeLayout only if necessary as it can have consequences on the performance.
EqualWidthColumn(modifier = modifier, spacing = 8.dp) { EqualWidthColumn(spacing = 8.dp) {
threadDecoration() threadDecoration()
inReplyTo(inReplyToDetails) inReplyTo(inReplyToDetails)
contentWithTimestamp() contentWithTimestamp()
@ -651,9 +660,7 @@ private fun MessageEventBubbleContent(
paddingBehaviour = paddingBehaviour, paddingBehaviour = paddingBehaviour,
inReplyToDetails = event.inReplyTo, inReplyToDetails = event.inReplyTo,
canShrinkContent = event.content is TimelineItemVoiceContent, canShrinkContent = event.content is TimelineItemVoiceContent,
modifier = bubbleModifier.semantics(mergeDescendants = true) { modifier = bubbleModifier,
contentDescription = event.safeSenderName
}
) )
} }

View file

@ -14,6 +14,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import io.element.android.features.messages.impl.R import io.element.android.features.messages.impl.R
import io.element.android.features.messages.impl.timeline.TimelineEvents import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
@ -25,6 +27,7 @@ import io.element.android.features.messages.impl.timeline.components.layout.Cont
import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
@ -140,7 +143,16 @@ private fun TimelineItemGroupedEventsRowContent(
}, },
) { ) {
Column(modifier = modifier.animateContentSize()) { Column(modifier = modifier.animateContentSize()) {
val groupedEventsTitle = pluralStringResource(
id = R.plurals.screen_room_timeline_state_changes,
count = timelineItem.events.size,
timelineItem.events.size
)
GroupHeaderView( GroupHeaderView(
modifier = Modifier.clearAndSetSemantics {
val groupedEventsContent = timelineItem.events.reversed().joinToString(separator = "\n") { (it.content as TimelineItemStateContent).body }
contentDescription = groupedEventsTitle + groupedEventsContent
},
text = pluralStringResource( text = pluralStringResource(
id = R.plurals.screen_room_timeline_state_changes, id = R.plurals.screen_room_timeline_state_changes,
count = timelineItem.events.size, count = timelineItem.events.size,

View file

@ -16,6 +16,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.invisibleToUser
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.R import io.element.android.features.messages.impl.R
@ -40,7 +42,9 @@ fun TimelineItemReactionsView(
) { ) {
var expanded: Boolean by rememberSaveable { mutableStateOf(false) } var expanded: Boolean by rememberSaveable { mutableStateOf(false) }
TimelineItemReactionsView( TimelineItemReactionsView(
modifier = modifier, modifier = modifier.semantics {
invisibleToUser()
},
reactions = reactionsState.reactions, reactions = reactionsState.reactions,
userCanSendReaction = userCanSendReaction, userCanSendReaction = userCanSendReaction,
expanded = expanded, expanded = expanded,

View file

@ -7,6 +7,8 @@
package io.element.android.features.messages.impl.timeline.components package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@ -17,6 +19,9 @@ import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
@ -28,6 +33,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
@ -37,8 +43,12 @@ import io.element.android.libraries.designsystem.theme.LocalBuildMeta
import io.element.android.libraries.designsystem.theme.highlightedMessageBackgroundColor import io.element.android.libraries.designsystem.theme.highlightedMessageBackgroundColor
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.time.isTalkbackActive
import io.element.android.wysiwyg.link.Link import io.element.android.wysiwyg.link.Link
import kotlin.time.DurationUnit
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
internal fun TimelineItemRow( internal fun TimelineItemRow(
timelineItem: TimelineItem, timelineItem: TimelineItem,
@ -120,7 +130,28 @@ internal fun TimelineItemRow(
) )
} }
else -> { else -> {
val a11yVoiceMessage = stringResource(CommonStrings.a11y_voice_message)
TimelineItemEventRow( TimelineItemEventRow(
modifier = Modifier
.semantics(mergeDescendants = true) {
contentDescription = if (timelineItem.content is TimelineItemVoiceContent) {
val voiceMessageText = String.format(a11yVoiceMessage, timelineItem.content.duration.toString(DurationUnit.MINUTES))
"${timelineItem.safeSenderName}, $voiceMessageText"
} else {
timelineItem.safeSenderName
}
}
// Custom clickable that applies over the whole item for accessibility
.then(
if (isTalkbackActive()) {
Modifier.combinedClickable(
onClick = { onContentClick(timelineItem) },
onLongClick = { onLongClick(timelineItem) }
)
} else {
Modifier
}
),
event = timelineItem, event = timelineItem,
timelineRoomInfo = timelineRoomInfo, timelineRoomInfo = timelineRoomInfo,
renderReadReceipts = renderReadReceipts, renderReadReceipts = renderReadReceipts,

View file

@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
@ -21,6 +22,7 @@ import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun TimelineItemFileView( fun TimelineItemFileView(
@ -37,7 +39,7 @@ fun TimelineItemFileView(
icon = { icon = {
Icon( Icon(
resourceId = CompoundDrawables.ic_compound_attachment, resourceId = CompoundDrawables.ic_compound_attachment,
contentDescription = null, contentDescription = stringResource(CommonStrings.common_file),
tint = ElementTheme.colors.iconPrimary, tint = ElementTheme.colors.iconPrimary,
modifier = Modifier modifier = Modifier
.size(16.dp) .size(16.dp)

View file

@ -33,8 +33,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
@ -55,6 +53,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.time.isTalkbackActive
import io.element.android.wysiwyg.compose.EditorStyledText import io.element.android.wysiwyg.compose.EditorStyledText
import io.element.android.wysiwyg.link.Link import io.element.android.wysiwyg.link.Link
@ -71,10 +70,9 @@ fun TimelineItemImageView(
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val description = stringResource(CommonStrings.common_image) val a11yLabel = stringResource(CommonStrings.common_image)
Column( val description = content.caption?.let { "$a11yLabel: $it" } ?: a11yLabel
modifier = modifier.semantics { contentDescription = description }, Column(modifier = modifier) {
) {
val containerModifier = if (content.showCaption) { val containerModifier = if (content.showCaption) {
Modifier.clip(RoundedCornerShape(10.dp)) Modifier.clip(RoundedCornerShape(10.dp))
} else { } else {
@ -93,7 +91,16 @@ fun TimelineItemImageView(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.then(if (isLoaded) Modifier.background(Color.White) else Modifier) .then(if (isLoaded) Modifier.background(Color.White) else Modifier)
.then(if (onContentClick != null) Modifier.combinedClickable(onClick = onContentClick, onLongClick = onLongClick) else Modifier), .then(
if (!isTalkbackActive() && onContentClick != null) {
Modifier.combinedClickable(
onClick = onContentClick,
onLongClick = onLongClick
)
} else {
Modifier
}
),
model = content.thumbnailMediaRequestData, model = content.thumbnailMediaRequestData,
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
alignment = Alignment.Center, alignment = Alignment.Center,

View file

@ -36,7 +36,7 @@ import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.invisibleToUser
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -63,6 +63,7 @@ import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH
import io.element.android.libraries.matrix.ui.media.MediaRequestData import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.time.isTalkbackActive
import io.element.android.wysiwyg.compose.EditorStyledText import io.element.android.wysiwyg.compose.EditorStyledText
import io.element.android.wysiwyg.link.Link import io.element.android.wysiwyg.link.Link
@ -79,10 +80,10 @@ fun TimelineItemVideoView(
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val description = stringResource(CommonStrings.common_video) val isTalkbackActive = isTalkbackActive()
Column( val a11yLabel = stringResource(CommonStrings.common_video)
modifier = modifier.semantics { contentDescription = description } val description = content.caption?.let { "$a11yLabel: $it" } ?: a11yLabel
) { Column(modifier = modifier) {
val containerModifier = if (content.showCaption) { val containerModifier = if (content.showCaption) {
Modifier Modifier
.padding(top = 6.dp) .padding(top = 6.dp)
@ -104,7 +105,16 @@ fun TimelineItemVideoView(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.then(if (isLoaded) Modifier.background(Color.White) else Modifier) .then(if (isLoaded) Modifier.background(Color.White) else Modifier)
.then(if (onContentClick != null) Modifier.combinedClickable(onClick = onContentClick, onLongClick = onLongClick) else Modifier), .then(
if (!isTalkbackActive && onContentClick != null) {
Modifier.combinedClickable(
onClick = onContentClick,
onLongClick = onLongClick
)
} else {
Modifier
}
),
model = MediaRequestData( model = MediaRequestData(
source = content.thumbnailSource, source = content.thumbnailSource,
kind = MediaRequestData.Kind.Thumbnail( kind = MediaRequestData.Kind.Thumbnail(
@ -126,6 +136,7 @@ fun TimelineItemVideoView(
imageVector = CompoundIcons.PlaySolid(), imageVector = CompoundIcons.PlaySolid(),
contentDescription = stringResource(id = CommonStrings.a11y_play), contentDescription = stringResource(id = CommonStrings.a11y_play),
colorFilter = ColorFilter.tint(Color.White), colorFilter = ColorFilter.tint(Color.White),
modifier = Modifier.semantics { invisibleToUser() }
) )
} }
} }

View file

@ -27,10 +27,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
@ -40,7 +41,6 @@ import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContentProvider import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContentProvider
import io.element.android.libraries.androidutils.accessibility.isScreenReaderEnabled
import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -49,6 +49,7 @@ import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.time.isTalkbackActive
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvents import io.element.android.libraries.voiceplayer.api.VoiceMessageEvents
import io.element.android.libraries.voiceplayer.api.VoiceMessageState import io.element.android.libraries.voiceplayer.api.VoiceMessageState
import io.element.android.libraries.voiceplayer.api.VoiceMessageStateProvider import io.element.android.libraries.voiceplayer.api.VoiceMessageStateProvider
@ -66,10 +67,27 @@ fun TimelineItemVoiceView(
} }
val a11y = stringResource(CommonStrings.common_voice_message) val a11y = stringResource(CommonStrings.common_voice_message)
val a11yActionLabel = stringResource(
when (state.button) {
VoiceMessageState.Button.Play -> CommonStrings.a11y_play
VoiceMessageState.Button.Pause -> CommonStrings.a11y_pause
VoiceMessageState.Button.Downloading -> CommonStrings.common_downloading
VoiceMessageState.Button.Retry -> CommonStrings.action_retry
VoiceMessageState.Button.Disabled -> CommonStrings.error_unknown
}
)
Row( Row(
modifier = modifier modifier = modifier
.semantics { .clearAndSetSemantics {
contentDescription = a11y contentDescription = a11y
if (state.button == VoiceMessageState.Button.Disabled) {
disabled()
} else if (state.button in listOf(VoiceMessageState.Button.Play, VoiceMessageState.Button.Pause)) {
onClick(label = a11yActionLabel) {
playPause()
true
}
}
} }
.onSizeChanged { .onSizeChanged {
onContentLayoutChange( onContentLayoutChange(
@ -81,12 +99,14 @@ fun TimelineItemVoiceView(
}, },
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
when (state.button) { if (!isTalkbackActive()) {
VoiceMessageState.Button.Play -> PlayButton(onClick = ::playPause) when (state.button) {
VoiceMessageState.Button.Pause -> PauseButton(onClick = ::playPause) VoiceMessageState.Button.Play -> PlayButton(onClick = ::playPause)
VoiceMessageState.Button.Downloading -> ProgressButton() VoiceMessageState.Button.Pause -> PauseButton(onClick = ::playPause)
VoiceMessageState.Button.Retry -> RetryButton(onClick = ::playPause) VoiceMessageState.Button.Downloading -> ProgressButton()
VoiceMessageState.Button.Disabled -> PlayButton(onClick = {}, enabled = false) VoiceMessageState.Button.Retry -> RetryButton(onClick = ::playPause)
VoiceMessageState.Button.Disabled -> PlayButton(onClick = {}, enabled = false)
}
} }
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text( Text(
@ -97,13 +117,12 @@ fun TimelineItemVoiceView(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
val context = LocalContext.current
WaveformPlaybackView( WaveformPlaybackView(
showCursor = state.showCursor, showCursor = state.showCursor,
playbackProgress = state.progress, playbackProgress = state.progress,
waveform = content.waveform, waveform = content.waveform,
modifier = Modifier.height(34.dp), modifier = Modifier.height(34.dp),
seekEnabled = !context.isScreenReaderEnabled(), seekEnabled = !isTalkbackActive(),
onSeek = { state.eventSink(VoiceMessageEvents.Seek(it)) }, onSeek = { state.eventSink(VoiceMessageEvents.Seek(it)) },
) )
} }

View file

@ -26,6 +26,7 @@ import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.invisibleToUser
import androidx.compose.ui.semantics.testTag import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -56,7 +57,11 @@ fun TimelineItemReadReceiptView(
) { ) {
if (state.receipts.isNotEmpty()) { if (state.receipts.isNotEmpty()) {
if (renderReadReceipts) { if (renderReadReceipts) {
ReadReceiptsRow(modifier = modifier) { ReadReceiptsRow(
modifier = modifier.clearAndSetSemantics {
invisibleToUser()
}
) {
ReadReceiptsAvatars( ReadReceiptsAvatars(
receipts = state.receipts, receipts = state.receipts,
modifier = Modifier modifier = Modifier

View file

@ -19,13 +19,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.ui.strings.CommonStrings
/** /**
* Button with colored background. * Button with colored background.
@ -35,6 +33,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
fun IconColorButton( fun IconColorButton(
onClick: () -> Unit, onClick: () -> Unit,
imageVector: ImageVector, imageVector: ImageVector,
contentDescription: String?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
buttonSize: ButtonSize = ButtonSize.Large, buttonSize: ButtonSize = ButtonSize.Large,
iconColorButtonStyle: IconColorButtonStyle = IconColorButtonStyle.Primary, iconColorButtonStyle: IconColorButtonStyle = IconColorButtonStyle.Primary,
@ -55,7 +54,7 @@ fun IconColorButton(
.background(bgColor) .background(bgColor)
.padding(buttonSize.toContainerPadding()), .padding(buttonSize.toContainerPadding()),
imageVector = imageVector, imageVector = imageVector,
contentDescription = stringResource(CommonStrings.action_close), contentDescription = contentDescription,
tint = ElementTheme.colors.iconOnSolidPrimary tint = ElementTheme.colors.iconOnSolidPrimary
) )
} }
@ -101,6 +100,7 @@ internal fun IconColorButtonPreview() = ElementPreview {
IconColorButton( IconColorButton(
onClick = {}, onClick = {},
imageVector = CompoundIcons.Close(), imageVector = CompoundIcons.Close(),
contentDescription = null,
buttonSize = size, buttonSize = size,
iconColorButtonStyle = style, iconColorButtonStyle = style,
) )

View file

@ -24,7 +24,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.isTraversalGroup
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.traversalIndex
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@ -58,7 +60,7 @@ fun InReplyToView(
senderId = inReplyTo.senderId, senderId = inReplyTo.senderId,
senderProfile = inReplyTo.senderProfile, senderProfile = inReplyTo.senderProfile,
metadata = inReplyTo.metadata(hideImage), metadata = inReplyTo.metadata(hideImage),
modifier = modifier modifier = modifier,
) )
} }
is InReplyToDetails.Error -> is InReplyToDetails.Error ->
@ -96,13 +98,18 @@ private fun ReplyToReadyContent(
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
} }
val a11InReplyToText = stringResource(CommonStrings.common_in_reply_to, senderProfile.getDisambiguatedDisplayName(senderId)) val a11InReplyToText = stringResource(CommonStrings.common_in_reply_to, senderProfile.getDisambiguatedDisplayName(senderId))
Column(verticalArrangement = Arrangement.SpaceBetween) { Column(
modifier = Modifier.semantics(mergeDescendants = false) { isTraversalGroup = true },
verticalArrangement = Arrangement.SpaceBetween
) {
SenderName( SenderName(
senderId = senderId, senderId = senderId,
senderProfile = senderProfile, senderProfile = senderProfile,
senderNameMode = SenderNameMode.Reply, senderNameMode = SenderNameMode.Reply,
modifier = Modifier.semantics { modifier = Modifier.semantics {
contentDescription = a11InReplyToText contentDescription = a11InReplyToText
isTraversalGroup = true
traversalIndex = 1f
}, },
) )
ReplyToContentText(metadata) ReplyToContentText(metadata)
@ -169,6 +176,10 @@ private fun ReplyToContentText(metadata: InReplyToMetadata?) {
else -> FontStyle.Normal else -> FontStyle.Normal
} }
Row( Row(
modifier = Modifier.semantics(mergeDescendants = false) {
isTraversalGroup = true
traversalIndex = -1f
},
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
if (iconResourceId != null) { if (iconResourceId != null) {

View file

@ -148,6 +148,7 @@ fun TextComposer(
IconColorButton( IconColorButton(
onClick = onAddAttachment, onClick = onAddAttachment,
imageVector = CompoundIcons.Plus(), imageVector = CompoundIcons.Plus(),
contentDescription = stringResource(R.string.rich_text_editor_a11y_add_attachment),
) )
} }
} }
@ -292,6 +293,7 @@ fun TextComposer(
IconColorButton( IconColorButton(
onClick = onDismissTextFormatting, onClick = onDismissTextFormatting,
imageVector = CompoundIcons.Close(), imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_close),
) )
}, },
textFormatting = textFormattingOptions, textFormatting = textFormattingOptions,

View file

@ -0,0 +1,20 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.ui.utils.time
import android.view.accessibility.AccessibilityManager
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
@Composable
fun isTalkbackActive(): Boolean {
val context = LocalContext.current
val accessibilityManager = remember { context.getSystemService(AccessibilityManager::class.java) }
return accessibilityManager.isTouchExplorationEnabled
}