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:
committed by
GitHub
parent
fe5d9b4308
commit
a346faba08
@@ -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() }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ internal fun DisabledComposerView(
|
||||
IconColorButton(
|
||||
onClick = {},
|
||||
imageVector = CompoundIcons.Plus(),
|
||||
contentDescription = null,
|
||||
iconColorButtonStyle = IconColorButtonStyle.Disabled,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user