From a346faba084e844b6541a563f36e65756c4ca8b5 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Tue, 15 Apr 2025 17:28:29 +0200 Subject: [PATCH] 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 --- .../impl/actionlist/ActionListView.kt | 38 +++++++++++----- .../messagecomposer/DisabledComposerView.kt | 1 + .../messages/impl/timeline/TimelineView.kt | 9 ++-- .../timeline/components/MessageEventBubble.kt | 19 +++++--- .../components/TimelineEventTimestampView.kt | 8 +++- .../components/TimelineItemEventRow.kt | 31 +++++++------ .../TimelineItemGroupedEventsRow.kt | 12 ++++++ .../components/TimelineItemReactionsView.kt | 6 ++- .../timeline/components/TimelineItemRow.kt | 31 +++++++++++++ .../components/event/TimelineItemFileView.kt | 4 +- .../components/event/TimelineItemImageView.kt | 21 ++++++--- .../components/event/TimelineItemVideoView.kt | 23 +++++++--- .../components/event/TimelineItemVoiceView.kt | 43 +++++++++++++------ .../receipt/TimelineItemReadReceiptView.kt | 7 ++- .../theme/components/IconColorButton.kt | 6 +-- .../matrix/ui/messages/reply/InReplyToView.kt | 15 ++++++- .../libraries/textcomposer/TextComposer.kt | 2 + .../ui/utils/time/IsTalkbackEnabled.kt | 20 +++++++++ 18 files changed, 226 insertions(+), 70 deletions(-) create mode 100644 libraries/ui-utils/src/main/kotlin/io/element/android/libraries/ui/utils/time/IsTalkbackEnabled.kt diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt index ba58d326e2..8eb4bb8554 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt @@ -39,6 +39,8 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics 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.TextOverflow import androidx.compose.ui.tooling.preview.PreviewParameter @@ -188,6 +190,7 @@ private fun ActionListViewContent( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp) + .clearAndSetSemantics {}, ) if (target.event.messageShield != null) { MessageShieldView( @@ -347,12 +350,21 @@ private fun EmojiReactionsRow( ) for (emoji in defaultEmojis) { 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( modifier = Modifier .size(48.dp), - contentAlignment = Alignment.Center + contentAlignment = Alignment.Center, ) { Icon( imageVector = CompoundIcons.ReactionAdd(), @@ -366,6 +378,10 @@ private fun EmojiReactionsRow( indication = ripple(bounded = false, radius = emojiRippleRadius), 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, isHighlighted: Boolean, onClick: (String) -> Unit, + modifier: Modifier = Modifier, ) { val backgroundColor = if (isHighlighted) { ElementTheme.colors.bgActionPrimaryRest @@ -425,10 +442,16 @@ private fun EmojiButton( stringResource(id = CommonStrings.a11y_react_with, emoji) } Box( - modifier = Modifier + modifier = modifier .size(48.dp) .background(backgroundColor, CircleShape) - .clearAndSetSemantics { + .clickable( + enabled = true, + onClick = { onClick(emoji) }, + indication = ripple(bounded = false, radius = emojiRippleRadius), + interactionSource = remember { MutableInteractionSource() } + ) + .semantics { contentDescription = description }, contentAlignment = Alignment.Center @@ -436,13 +459,6 @@ private fun EmojiButton( Text( emoji, 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() } - ) ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/DisabledComposerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/DisabledComposerView.kt index e72e094a5e..02d009d2ce 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/DisabledComposerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/DisabledComposerView.kt @@ -48,6 +48,7 @@ internal fun DisabledComposerView( IconColorButton( onClick = {}, imageVector = CompoundIcons.Plus(), + contentDescription = null, iconColorButtonStyle = IconColorButtonStyle.Disabled, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index 7dea7c6696..6e677e5984 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -8,7 +8,6 @@ package io.element.android.features.messages.impl.timeline import android.view.HapticFeedbackConstants -import android.view.accessibility.AccessibilityManager import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween 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.testTag 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 kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview @@ -126,10 +126,7 @@ fun TimelineView( val context = LocalContext.current val view = LocalView.current // Disable reverse layout when TalkBack is enabled to avoid incorrect ordering issues seen in the current Compose UI version - val useReverseLayout = remember { - val accessibilityManager = context.getSystemService(AccessibilityManager::class.java) - accessibilityManager.isTouchExplorationEnabled.not() - } + val useReverseLayout = !isTalkbackActive() fun inReplyToClick(eventId: EventId) { state.eventSink(TimelineEvents.FocusOnEvent(eventId)) @@ -159,7 +156,7 @@ fun TimelineView( .testTag(TestTags.timeline), state = lazyListState, reverseLayout = useReverseLayout, - contentPadding = PaddingValues(vertical = 8.dp), + contentPadding = PaddingValues(top = 64.dp, bottom = 8.dp), ) { items( items = state.timelineItems, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt index c5f243a18a..ad5337031c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt @@ -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.testtags.TestTags import io.element.android.libraries.testtags.testTag +import io.element.android.libraries.ui.utils.time.isTalkbackActive private val BUBBLE_RADIUS = 12.dp 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. val backgroundBubbleColor = when { state.isMine -> ElementTheme.colors.messageFromMeBackground @@ -137,12 +149,7 @@ fun MessageEventBubble( .toDp() ) .clip(bubbleShape) - .combinedClickable( - onClick = onClick, - onLongClick = onLongClick, - indication = ripple(), - interactionSource = interactionSource - ), + .then(clickableModifier), color = backgroundBubbleColor, shape = bubbleShape, content = content diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt index 9a467eeea7..a29c13ccd5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt @@ -18,6 +18,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.unit.dp import io.element.android.compound.theme.ElementTheme @@ -77,7 +79,8 @@ fun TimelineEventTimestampView( .size(15.dp, 18.dp) .clickable(isVerifiedUserSendFailure) { eventSink(TimelineEvents.ComputeVerifiedUserSendFailure(event)) - }, + } + .semantics { invisibleToUser() } ) } @@ -91,7 +94,8 @@ fun TimelineEventTimestampView( .size(15.dp) .clickable { eventSink(TimelineEvents.ShowShieldDialog(shield)) - }, + } + .semantics { invisibleToUser() }, tint = shield.toIconColor(), ) Spacer(modifier = Modifier.width(4.dp)) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index 1b0a4df559..f3b890cb18 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -37,13 +37,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.platform.ViewConfiguration -import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics -import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.invisibleToUser +import androidx.compose.ui.semantics.isTraversalGroup 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.IntOffset 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.testTag 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 kotlinx.coroutines.launch import kotlin.math.abs @@ -245,7 +245,7 @@ fun TimelineItemEventRow( ), renderReadReceipts = renderReadReceipts, onReadReceiptsClick = { onReadReceiptClick(event) }, - modifier = Modifier.padding(top = 4.dp), + modifier = Modifier.padding(top = 4.dp) ) } } @@ -595,7 +595,10 @@ private fun MessageEventBubbleContent( timestampPosition = timestampPosition, eventSink = eventSink, canShrinkContent = canShrinkContent, - modifier = timestampLayoutModifier, + modifier = timestampLayoutModifier.semantics(mergeDescendants = false) { + isTraversalGroup = true + traversalIndex = -1f + }, content = { onContentLayoutChange -> eventContentView(contentModifier, onContentLayoutChange) } @@ -607,17 +610,23 @@ private fun MessageEventBubbleContent( val inReplyToModifier = Modifier .padding(top = topPadding, start = 8.dp, end = 8.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( inReplyTo = inReplyTo, hideImage = timelineProtectionState.hideMediaContent(inReplyTo.eventId()), - modifier = inReplyToModifier, + modifier = talkbackCompatModifier, ) } if (inReplyToDetails != null) { // Use SubComposeLayout only if necessary as it can have consequences on the performance. - EqualWidthColumn(modifier = modifier, spacing = 8.dp) { + EqualWidthColumn(spacing = 8.dp) { threadDecoration() inReplyTo(inReplyToDetails) contentWithTimestamp() @@ -651,9 +660,7 @@ private fun MessageEventBubbleContent( paddingBehaviour = paddingBehaviour, inReplyToDetails = event.inReplyTo, canShrinkContent = event.content is TimelineItemVoiceContent, - modifier = bubbleModifier.semantics(mergeDescendants = true) { - contentDescription = event.safeSenderName - } + modifier = bubbleModifier, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt index 112b3dbea6..8f868370b3 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt @@ -14,6 +14,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier 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.timeline.TimelineEvents 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.TimelineItemReadReceiptView 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.TimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState @@ -140,7 +143,16 @@ private fun TimelineItemGroupedEventsRowContent( }, ) { Column(modifier = modifier.animateContentSize()) { + val groupedEventsTitle = pluralStringResource( + id = R.plurals.screen_room_timeline_state_changes, + count = timelineItem.events.size, + timelineItem.events.size + ) GroupHeaderView( + modifier = Modifier.clearAndSetSemantics { + val groupedEventsContent = timelineItem.events.reversed().joinToString(separator = "\n") { (it.content as TimelineItemStateContent).body } + contentDescription = groupedEventsTitle + groupedEventsContent + }, text = pluralStringResource( id = R.plurals.screen_room_timeline_state_changes, count = timelineItem.events.size, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsView.kt index 8c72ae7710..02cfa39830 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsView.kt @@ -16,6 +16,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalLayoutDirection 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.dp import io.element.android.features.messages.impl.R @@ -40,7 +42,9 @@ fun TimelineItemReactionsView( ) { var expanded: Boolean by rememberSaveable { mutableStateOf(false) } TimelineItemReactionsView( - modifier = modifier, + modifier = modifier.semantics { + invisibleToUser() + }, reactions = reactionsState.reactions, userCanSendReaction = userCanSendReaction, expanded = expanded, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt index 8595364f2c..88b19c43fe 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt @@ -7,6 +7,8 @@ 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.fillMaxWidth 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.Size 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 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.TimelineItemLegacyCallInviteContent 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.TimelineProtectionState 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.matrix.api.core.EventId 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 kotlin.time.DurationUnit +@OptIn(ExperimentalFoundationApi::class) @Composable internal fun TimelineItemRow( timelineItem: TimelineItem, @@ -120,7 +130,28 @@ internal fun TimelineItemRow( ) } else -> { + val a11yVoiceMessage = stringResource(CommonStrings.a11y_voice_message) 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, timelineRoomInfo = timelineRoomInfo, renderReadReceipts = renderReadReceipts, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt index 06e0ea6616..86218b8e25 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp 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.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.ui.strings.CommonStrings @Composable fun TimelineItemFileView( @@ -37,7 +39,7 @@ fun TimelineItemFileView( icon = { Icon( resourceId = CompoundDrawables.ic_compound_attachment, - contentDescription = null, + contentDescription = stringResource(CommonStrings.common_file), tint = ElementTheme.colors.iconPrimary, modifier = Modifier .size(16.dp) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt index b9e512cd9c..680489d3bb 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt @@ -33,8 +33,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalInspectionMode 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.unit.dp 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.textcomposer.ElementRichTextEditorStyle 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.link.Link @@ -71,10 +70,9 @@ fun TimelineItemImageView( onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, ) { - val description = stringResource(CommonStrings.common_image) - Column( - modifier = modifier.semantics { contentDescription = description }, - ) { + val a11yLabel = stringResource(CommonStrings.common_image) + val description = content.caption?.let { "$a11yLabel: $it" } ?: a11yLabel + Column(modifier = modifier) { val containerModifier = if (content.showCaption) { Modifier.clip(RoundedCornerShape(10.dp)) } else { @@ -93,7 +91,16 @@ fun TimelineItemImageView( modifier = Modifier .fillMaxWidth() .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, contentScale = ContentScale.Fit, alignment = Alignment.Center, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt index 6855849793..4ac09f106d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt @@ -36,7 +36,7 @@ import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalInspectionMode 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.tooling.preview.PreviewParameter 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.textcomposer.ElementRichTextEditorStyle 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.link.Link @@ -79,10 +80,10 @@ fun TimelineItemVideoView( onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, ) { - val description = stringResource(CommonStrings.common_video) - Column( - modifier = modifier.semantics { contentDescription = description } - ) { + val isTalkbackActive = isTalkbackActive() + val a11yLabel = stringResource(CommonStrings.common_video) + val description = content.caption?.let { "$a11yLabel: $it" } ?: a11yLabel + Column(modifier = modifier) { val containerModifier = if (content.showCaption) { Modifier .padding(top = 6.dp) @@ -104,7 +105,16 @@ fun TimelineItemVideoView( modifier = Modifier .fillMaxWidth() .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( source = content.thumbnailSource, kind = MediaRequestData.Kind.Thumbnail( @@ -126,6 +136,7 @@ fun TimelineItemVideoView( imageVector = CompoundIcons.PlaySolid(), contentDescription = stringResource(id = CommonStrings.a11y_play), colorFilter = ColorFilter.tint(Color.White), + modifier = Modifier.semantics { invisibleToUser() } ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt index 1d357bdd2f..02d08a8f43 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt @@ -27,10 +27,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics 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.tooling.preview.PreviewParameter 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.model.event.TimelineItemVoiceContent 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.preview.ElementPreview 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.Text 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.VoiceMessageState import io.element.android.libraries.voiceplayer.api.VoiceMessageStateProvider @@ -66,10 +67,27 @@ fun TimelineItemVoiceView( } 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( modifier = modifier - .semantics { + .clearAndSetSemantics { 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 { onContentLayoutChange( @@ -81,12 +99,14 @@ fun TimelineItemVoiceView( }, verticalAlignment = Alignment.CenterVertically, ) { - when (state.button) { - VoiceMessageState.Button.Play -> PlayButton(onClick = ::playPause) - VoiceMessageState.Button.Pause -> PauseButton(onClick = ::playPause) - VoiceMessageState.Button.Downloading -> ProgressButton() - VoiceMessageState.Button.Retry -> RetryButton(onClick = ::playPause) - VoiceMessageState.Button.Disabled -> PlayButton(onClick = {}, enabled = false) + if (!isTalkbackActive()) { + when (state.button) { + VoiceMessageState.Button.Play -> PlayButton(onClick = ::playPause) + VoiceMessageState.Button.Pause -> PauseButton(onClick = ::playPause) + VoiceMessageState.Button.Downloading -> ProgressButton() + VoiceMessageState.Button.Retry -> RetryButton(onClick = ::playPause) + VoiceMessageState.Button.Disabled -> PlayButton(onClick = {}, enabled = false) + } } Spacer(Modifier.width(8.dp)) Text( @@ -97,13 +117,12 @@ fun TimelineItemVoiceView( overflow = TextOverflow.Ellipsis, ) Spacer(Modifier.width(8.dp)) - val context = LocalContext.current WaveformPlaybackView( showCursor = state.showCursor, playbackProgress = state.progress, waveform = content.waveform, modifier = Modifier.height(34.dp), - seekEnabled = !context.isScreenReaderEnabled(), + seekEnabled = !isTalkbackActive(), onSeek = { state.eventSink(VoiceMessageEvents.Seek(it)) }, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt index 80d594d4d7..e7d80a9d6c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.invisibleToUser import androidx.compose.ui.semantics.testTag import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp @@ -56,7 +57,11 @@ fun TimelineItemReadReceiptView( ) { if (state.receipts.isNotEmpty()) { if (renderReadReceipts) { - ReadReceiptsRow(modifier = modifier) { + ReadReceiptsRow( + modifier = modifier.clearAndSetSemantics { + invisibleToUser() + } + ) { ReadReceiptsAvatars( receipts = state.receipts, modifier = Modifier diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/IconColorButton.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/IconColorButton.kt index 8e7407c6ac..cc863bcf8d 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/IconColorButton.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/IconColorButton.kt @@ -19,13 +19,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.ui.strings.CommonStrings /** * Button with colored background. @@ -35,6 +33,7 @@ import io.element.android.libraries.ui.strings.CommonStrings fun IconColorButton( onClick: () -> Unit, imageVector: ImageVector, + contentDescription: String?, modifier: Modifier = Modifier, buttonSize: ButtonSize = ButtonSize.Large, iconColorButtonStyle: IconColorButtonStyle = IconColorButtonStyle.Primary, @@ -55,7 +54,7 @@ fun IconColorButton( .background(bgColor) .padding(buttonSize.toContainerPadding()), imageVector = imageVector, - contentDescription = stringResource(CommonStrings.action_close), + contentDescription = contentDescription, tint = ElementTheme.colors.iconOnSolidPrimary ) } @@ -101,6 +100,7 @@ internal fun IconColorButtonPreview() = ElementPreview { IconColorButton( onClick = {}, imageVector = CompoundIcons.Close(), + contentDescription = null, buttonSize = size, iconColorButtonStyle = style, ) diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToView.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToView.kt index d3135d9fd8..1b8e8bfd26 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToView.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToView.kt @@ -24,7 +24,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.isTraversalGroup import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.traversalIndex import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -58,7 +60,7 @@ fun InReplyToView( senderId = inReplyTo.senderId, senderProfile = inReplyTo.senderProfile, metadata = inReplyTo.metadata(hideImage), - modifier = modifier + modifier = modifier, ) } is InReplyToDetails.Error -> @@ -96,13 +98,18 @@ private fun ReplyToReadyContent( Spacer(modifier = Modifier.width(8.dp)) } 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( senderId = senderId, senderProfile = senderProfile, senderNameMode = SenderNameMode.Reply, modifier = Modifier.semantics { contentDescription = a11InReplyToText + isTraversalGroup = true + traversalIndex = 1f }, ) ReplyToContentText(metadata) @@ -169,6 +176,10 @@ private fun ReplyToContentText(metadata: InReplyToMetadata?) { else -> FontStyle.Normal } Row( + modifier = Modifier.semantics(mergeDescendants = false) { + isTraversalGroup = true + traversalIndex = -1f + }, verticalAlignment = Alignment.CenterVertically, ) { if (iconResourceId != null) { diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt index b2adb257d1..83d51612d2 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt @@ -148,6 +148,7 @@ fun TextComposer( IconColorButton( onClick = onAddAttachment, imageVector = CompoundIcons.Plus(), + contentDescription = stringResource(R.string.rich_text_editor_a11y_add_attachment), ) } } @@ -292,6 +293,7 @@ fun TextComposer( IconColorButton( onClick = onDismissTextFormatting, imageVector = CompoundIcons.Close(), + contentDescription = stringResource(CommonStrings.action_close), ) }, textFormatting = textFormattingOptions, diff --git a/libraries/ui-utils/src/main/kotlin/io/element/android/libraries/ui/utils/time/IsTalkbackEnabled.kt b/libraries/ui-utils/src/main/kotlin/io/element/android/libraries/ui/utils/time/IsTalkbackEnabled.kt new file mode 100644 index 0000000000..d8be4cb54a --- /dev/null +++ b/libraries/ui-utils/src/main/kotlin/io/element/android/libraries/ui/utils/time/IsTalkbackEnabled.kt @@ -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 +}