diff --git a/changelog.d/+improve-accessibility-in-timeline.bugfix b/changelog.d/+improve-accessibility-in-timeline.bugfix new file mode 100644 index 0000000000..a0f37c2072 --- /dev/null +++ b/changelog.d/+improve-accessibility-in-timeline.bugfix @@ -0,0 +1 @@ +Improve how Talkback works with the timeline. Sadly, it's still not 100% working, but there is some issue with the `LazyColumn` using `reverseLayout` that only Google can fix. 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 79ef6efe40..6805cd926e 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 @@ -18,9 +18,11 @@ package io.element.android.features.messages.impl.timeline +import android.view.accessibility.AccessibilityManager import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.foundation.layout.Box @@ -45,8 +47,8 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.rotate +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp @@ -64,7 +66,6 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.typing.TypingNotificationState import io.element.android.features.messages.impl.typing.TypingNotificationView import io.element.android.features.messages.impl.typing.aTypingNotificationState -import io.element.android.libraries.designsystem.animation.alphaAnimation import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.FloatingActionButton @@ -99,7 +100,13 @@ fun TimelineView( state.eventSink(TimelineEvents.OnScrollFinished(firstVisibleIndex)) } + val context = LocalContext.current val lazyListState = rememberLazyListState() + // 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() + } @Suppress("UNUSED_PARAMETER") fun inReplyToClicked(eventId: EventId) { @@ -107,67 +114,67 @@ fun TimelineView( } // Animate alpha when timeline is first displayed, to avoid flashes or glitching when viewing rooms - val alpha by alphaAnimation(label = "alpha for timeline") - - Box(modifier = modifier.alpha(alpha)) { - LazyColumn( - modifier = Modifier.fillMaxSize(), - state = lazyListState, - reverseLayout = true, - contentPadding = PaddingValues(vertical = 8.dp), - ) { - item { - TypingNotificationView(state = typingNotificationState) - } - items( - items = state.timelineItems, - contentType = { timelineItem -> timelineItem.contentType() }, - key = { timelineItem -> timelineItem.identifier() }, - ) { timelineItem -> - TimelineItemRow( - timelineItem = timelineItem, - timelineRoomInfo = state.timelineRoomInfo, - renderReadReceipts = state.renderReadReceipts, - isLastOutgoingMessage = (timelineItem as? TimelineItem.Event)?.isMine == true && - state.timelineItems.first().identifier() == timelineItem.identifier(), - highlightedItem = state.highlightedEventId?.value, - onClick = onMessageClicked, - onLongClick = onMessageLongClicked, - onUserDataClick = onUserDataClicked, - inReplyToClick = ::inReplyToClicked, - onReactionClick = onReactionClicked, - onReactionLongClick = onReactionLongClicked, - onMoreReactionsClick = onMoreReactionsClicked, - onReadReceiptClick = onReadReceiptClick, - onTimestampClicked = onTimestampClicked, - sessionState = state.sessionState, - eventSink = state.eventSink, - onSwipeToReply = onSwipeToReply, - ) - } - if (state.paginationState.hasMoreToLoadBackwards) { - // Do not use key parameter to avoid wrong positioning - item(contentType = "TimelineLoadingMoreIndicator") { - TimelineLoadingMoreIndicator() - LaunchedEffect(Unit) { - onReachedLoadMore() + AnimatedVisibility(visible = true, enter = fadeIn()) { + Box(modifier) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = lazyListState, + reverseLayout = useReverseLayout, + contentPadding = PaddingValues(vertical = 8.dp), + ) { + item { + TypingNotificationView(state = typingNotificationState) + } + items( + items = state.timelineItems, + contentType = { timelineItem -> timelineItem.contentType() }, + key = { timelineItem -> timelineItem.identifier() }, + ) { timelineItem -> + TimelineItemRow( + timelineItem = timelineItem, + timelineRoomInfo = state.timelineRoomInfo, + renderReadReceipts = state.renderReadReceipts, + isLastOutgoingMessage = (timelineItem as? TimelineItem.Event)?.isMine == true && + state.timelineItems.first().identifier() == timelineItem.identifier(), + highlightedItem = state.highlightedEventId?.value, + onClick = onMessageClicked, + onLongClick = onMessageLongClicked, + onUserDataClick = onUserDataClicked, + inReplyToClick = ::inReplyToClicked, + onReactionClick = onReactionClicked, + onReactionLongClick = onReactionLongClicked, + onMoreReactionsClick = onMoreReactionsClicked, + onReadReceiptClick = onReadReceiptClick, + onTimestampClicked = onTimestampClicked, + sessionState = state.sessionState, + eventSink = state.eventSink, + onSwipeToReply = onSwipeToReply, + ) + } + if (state.paginationState.hasMoreToLoadBackwards) { + // Do not use key parameter to avoid wrong positioning + item(contentType = "TimelineLoadingMoreIndicator") { + TimelineLoadingMoreIndicator() + LaunchedEffect(Unit) { + onReachedLoadMore() + } + } + } + if (state.paginationState.beginningOfRoomReached && !state.timelineRoomInfo.isDirect) { + item(contentType = "BeginningOfRoomReached") { + TimelineItemRoomBeginningView(roomName = roomName) } } } - if (state.paginationState.beginningOfRoomReached && !state.timelineRoomInfo.isDirect) { - item(contentType = "BeginningOfRoomReached") { - TimelineItemRoomBeginningView(roomName = roomName) - } - } - } - TimelineScrollHelper( - isTimelineEmpty = state.timelineItems.isEmpty(), - lazyListState = lazyListState, - forceJumpToBottomVisibility = forceJumpToBottomVisibility, - newEventState = state.newEventState, - onScrollFinishedAt = ::onScrollFinishedAt - ) + TimelineScrollHelper( + isTimelineEmpty = state.timelineItems.isEmpty(), + lazyListState = lazyListState, + forceJumpToBottomVisibility = forceJumpToBottomVisibility, + newEventState = state.newEventState, + onScrollFinishedAt = ::onScrollFinishedAt + ) + } } } 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 146827085d..75f4a7ce6d 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 @@ -45,6 +45,7 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds @@ -53,6 +54,11 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.platform.ViewConfiguration 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.semantics +import androidx.compose.ui.semantics.testTag import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -107,6 +113,7 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.room.Mention import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail +import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.coroutines.launch import kotlin.math.abs @@ -256,6 +263,7 @@ private fun SwipeSensitivity( } } +@OptIn(ExperimentalComposeUiApi::class) @Composable private fun TimelineItemEventRowContent( event: TimelineItem.Event, @@ -305,6 +313,11 @@ private fun TimelineItemEventRowContent( .padding(horizontal = 16.dp) .zIndex(1f) .clickable(onClick = onUserDataClicked) + // This is redundant when using talkback + .clearAndSetSemantics { + invisibleToUser() + testTag = TestTags.timelineItemSenderInfo.value + } ) } @@ -413,6 +426,7 @@ private fun MessageSenderInformation( private fun MessageEventBubbleContent( event: TimelineItem.Event, onMessageLongClick: () -> Unit, + @Suppress("UNUSED_PARAMETER") inReplyToClick: () -> Unit, onTimestampClicked: () -> Unit, onMentionClicked: (Mention) -> Unit, @@ -445,6 +459,7 @@ private fun MessageEventBubbleContent( text = stringResource(CommonStrings.common_thread), style = ElementTheme.typography.fontBodyXsRegular, color = ElementTheme.colors.textPrimary, + modifier = Modifier.clearAndSetSemantics { } ) } } @@ -580,7 +595,8 @@ private fun MessageEventBubbleContent( modifier = Modifier .padding(top = topPadding, start = 8.dp, end = 8.dp) .clip(RoundedCornerShape(6.dp)) - .clickable(enabled = true, onClick = inReplyToClick), + // FIXME when a node is clickable, its contents won't be added to the semantics tree of its parent +// .clickable(enabled = true, onClick = inReplyToClick) ) } if (inReplyToDetails != null) { @@ -611,7 +627,9 @@ private fun MessageEventBubbleContent( timestampPosition = timestampPosition, inReplyToDetails = event.inReplyTo, canShrinkContent = event.content is TimelineItemVoiceContent, - modifier = bubbleModifier + modifier = bubbleModifier.semantics(mergeDescendants = true) { + contentDescription = event.safeSenderName + } ) } @@ -641,8 +659,12 @@ private fun ReplyToContent( ) Spacer(modifier = Modifier.width(8.dp)) } + val a11InReplyToText = stringResource(CommonStrings.common_in_reply_to, senderName) Column(verticalArrangement = Arrangement.SpaceBetween) { Text( + modifier = Modifier.semantics { + contentDescription = a11InReplyToText + }, text = senderName, style = ElementTheme.typography.fontBodySmMedium, textAlign = TextAlign.Start, 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 7bebd9e6d7..d058df9996 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 @@ -18,6 +18,9 @@ package io.element.android.features.messages.impl.timeline.components.event import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +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 io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContentProvider @@ -25,15 +28,17 @@ import io.element.android.libraries.designsystem.components.BlurHashAsyncImage import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.matrix.ui.media.MediaRequestData +import io.element.android.libraries.ui.strings.CommonStrings @Composable fun TimelineItemImageView( content: TimelineItemImageContent, modifier: Modifier = Modifier, ) { + val description = stringResource(CommonStrings.common_image) TimelineItemAspectRatioBox( aspectRatio = content.aspectRatio, - modifier = modifier, + modifier = modifier.semantics { contentDescription = description }, ) { BlurHashAsyncImage( model = MediaRequestData(content.preferredMediaSource, MediaRequestData.Kind.File(content.body, content.mimeType)), diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt index 2df3766175..c7e1b37bb2 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt @@ -23,6 +23,8 @@ import androidx.compose.material3.LocalTextStyle import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.PreviewParameter import io.element.android.compound.theme.ElementTheme import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout @@ -48,7 +50,7 @@ fun TimelineItemTextView( val formattedBody = content.formattedBody val body = SpannableString(formattedBody ?: content.body) - Box(modifier) { + Box(modifier.semantics { contentDescription = body.toString() }) { EditorStyledText( text = body, onLinkClickedListener = onLinkClicked, 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 4900180800..d017446539 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 @@ -27,6 +27,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.layout.ContentScale 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 io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContentProvider @@ -42,9 +44,10 @@ fun TimelineItemVideoView( content: TimelineItemVideoContent, modifier: Modifier = Modifier, ) { + val description = stringResource(CommonStrings.common_image) TimelineItemAspectRatioBox( aspectRatio = content.aspectRatio, - modifier = modifier, + modifier = modifier.semantics { contentDescription = description }, contentAlignment = Alignment.Center, ) { BlurHashAsyncImage( diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt index 144cb14561..b931dc9860 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt @@ -326,8 +326,7 @@ class MessagesViewTest { state = state, onUserDataClicked = callback, ) - val senderName = (timelineItem as? TimelineItem.Event)?.senderDisplayName.orEmpty() - rule.onNodeWithText(senderName).performClick() + rule.onNodeWithTag(TestTags.timelineItemSenderInfo.value).performClick() } } diff --git a/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt b/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt index ed93c19bc3..c2acd98bfe 100644 --- a/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt +++ b/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt @@ -74,4 +74,9 @@ object TestTags { val dialogPositive = TestTag("dialog-positive") val dialogNegative = TestTag("dialog-negative") val dialogNeutral = TestTag("dialog-neutral") + + /** + * Timeline item. + */ + val timelineItemSenderInfo = TestTag("timeline_item-sender_info") }