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:
parent
fe5d9b4308
commit
a346faba08
18 changed files with 226 additions and 70 deletions
|
|
@ -39,6 +39,8 @@ import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.semantics.traversalIndex
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
|
@ -188,6 +190,7 @@ private fun ActionListViewContent(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
|
.clearAndSetSemantics {},
|
||||||
)
|
)
|
||||||
if (target.event.messageShield != null) {
|
if (target.event.messageShield != null) {
|
||||||
MessageShieldView(
|
MessageShieldView(
|
||||||
|
|
@ -347,12 +350,21 @@ private fun EmojiReactionsRow(
|
||||||
)
|
)
|
||||||
for (emoji in defaultEmojis) {
|
for (emoji in defaultEmojis) {
|
||||||
val isHighlighted = highlightedEmojis.contains(emoji)
|
val isHighlighted = highlightedEmojis.contains(emoji)
|
||||||
EmojiButton(emoji, isHighlighted, onEmojiReactionClick)
|
EmojiButton(
|
||||||
|
modifier = Modifier
|
||||||
|
// Make it appear after the more useful actions for the accessibility service
|
||||||
|
.semantics {
|
||||||
|
traversalIndex = 1f
|
||||||
|
},
|
||||||
|
emoji = emoji,
|
||||||
|
isHighlighted = isHighlighted,
|
||||||
|
onClick = onEmojiReactionClick
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(48.dp),
|
.size(48.dp),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = CompoundIcons.ReactionAdd(),
|
imageVector = CompoundIcons.ReactionAdd(),
|
||||||
|
|
@ -366,6 +378,10 @@ private fun EmojiReactionsRow(
|
||||||
indication = ripple(bounded = false, radius = emojiRippleRadius),
|
indication = ripple(bounded = false, radius = emojiRippleRadius),
|
||||||
interactionSource = remember { MutableInteractionSource() }
|
interactionSource = remember { MutableInteractionSource() }
|
||||||
)
|
)
|
||||||
|
// Make it appear after the more useful actions for the accessibility service
|
||||||
|
.semantics {
|
||||||
|
traversalIndex = 1f
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -413,6 +429,7 @@ private fun EmojiButton(
|
||||||
emoji: String,
|
emoji: String,
|
||||||
isHighlighted: Boolean,
|
isHighlighted: Boolean,
|
||||||
onClick: (String) -> Unit,
|
onClick: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val backgroundColor = if (isHighlighted) {
|
val backgroundColor = if (isHighlighted) {
|
||||||
ElementTheme.colors.bgActionPrimaryRest
|
ElementTheme.colors.bgActionPrimaryRest
|
||||||
|
|
@ -425,10 +442,16 @@ private fun EmojiButton(
|
||||||
stringResource(id = CommonStrings.a11y_react_with, emoji)
|
stringResource(id = CommonStrings.a11y_react_with, emoji)
|
||||||
}
|
}
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = modifier
|
||||||
.size(48.dp)
|
.size(48.dp)
|
||||||
.background(backgroundColor, CircleShape)
|
.background(backgroundColor, CircleShape)
|
||||||
.clearAndSetSemantics {
|
.clickable(
|
||||||
|
enabled = true,
|
||||||
|
onClick = { onClick(emoji) },
|
||||||
|
indication = ripple(bounded = false, radius = emojiRippleRadius),
|
||||||
|
interactionSource = remember { MutableInteractionSource() }
|
||||||
|
)
|
||||||
|
.semantics {
|
||||||
contentDescription = description
|
contentDescription = description
|
||||||
},
|
},
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
|
|
@ -436,13 +459,6 @@ private fun EmojiButton(
|
||||||
Text(
|
Text(
|
||||||
emoji,
|
emoji,
|
||||||
style = ElementTheme.typography.fontBodyLgRegular.copy(fontSize = 24.dp.toSp(), color = Color.White),
|
style = ElementTheme.typography.fontBodyLgRegular.copy(fontSize = 24.dp.toSp(), color = Color.White),
|
||||||
modifier = Modifier
|
|
||||||
.clickable(
|
|
||||||
enabled = true,
|
|
||||||
onClick = { onClick(emoji) },
|
|
||||||
indication = ripple(bounded = false, radius = emojiRippleRadius),
|
|
||||||
interactionSource = remember { MutableInteractionSource() }
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ internal fun DisabledComposerView(
|
||||||
IconColorButton(
|
IconColorButton(
|
||||||
onClick = {},
|
onClick = {},
|
||||||
imageVector = CompoundIcons.Plus(),
|
imageVector = CompoundIcons.Plus(),
|
||||||
|
contentDescription = null,
|
||||||
iconColorButtonStyle = IconColorButtonStyle.Disabled,
|
iconColorButtonStyle = IconColorButtonStyle.Disabled,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@
|
||||||
package io.element.android.features.messages.impl.timeline
|
package io.element.android.features.messages.impl.timeline
|
||||||
|
|
||||||
import android.view.HapticFeedbackConstants
|
import android.view.HapticFeedbackConstants
|
||||||
import android.view.accessibility.AccessibilityManager
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
|
|
@ -75,6 +74,7 @@ import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
import io.element.android.libraries.testtags.TestTags
|
import io.element.android.libraries.testtags.TestTags
|
||||||
import io.element.android.libraries.testtags.testTag
|
import io.element.android.libraries.testtags.testTag
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
import io.element.android.libraries.ui.utils.time.isTalkbackActive
|
||||||
import io.element.android.wysiwyg.link.Link
|
import io.element.android.wysiwyg.link.Link
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
|
@ -126,10 +126,7 @@ fun TimelineView(
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
// Disable reverse layout when TalkBack is enabled to avoid incorrect ordering issues seen in the current Compose UI version
|
// Disable reverse layout when TalkBack is enabled to avoid incorrect ordering issues seen in the current Compose UI version
|
||||||
val useReverseLayout = remember {
|
val useReverseLayout = !isTalkbackActive()
|
||||||
val accessibilityManager = context.getSystemService(AccessibilityManager::class.java)
|
|
||||||
accessibilityManager.isTouchExplorationEnabled.not()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun inReplyToClick(eventId: EventId) {
|
fun inReplyToClick(eventId: EventId) {
|
||||||
state.eventSink(TimelineEvents.FocusOnEvent(eventId))
|
state.eventSink(TimelineEvents.FocusOnEvent(eventId))
|
||||||
|
|
@ -159,7 +156,7 @@ fun TimelineView(
|
||||||
.testTag(TestTags.timeline),
|
.testTag(TestTags.timeline),
|
||||||
state = lazyListState,
|
state = lazyListState,
|
||||||
reverseLayout = useReverseLayout,
|
reverseLayout = useReverseLayout,
|
||||||
contentPadding = PaddingValues(vertical = 8.dp),
|
contentPadding = PaddingValues(top = 64.dp, bottom = 8.dp),
|
||||||
) {
|
) {
|
||||||
items(
|
items(
|
||||||
items = state.timelineItems,
|
items = state.timelineItems,
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ import io.element.android.libraries.designsystem.theme.messageFromMeBackground
|
||||||
import io.element.android.libraries.designsystem.theme.messageFromOtherBackground
|
import io.element.android.libraries.designsystem.theme.messageFromOtherBackground
|
||||||
import io.element.android.libraries.testtags.TestTags
|
import io.element.android.libraries.testtags.TestTags
|
||||||
import io.element.android.libraries.testtags.testTag
|
import io.element.android.libraries.testtags.testTag
|
||||||
|
import io.element.android.libraries.ui.utils.time.isTalkbackActive
|
||||||
|
|
||||||
private val BUBBLE_RADIUS = 12.dp
|
private val BUBBLE_RADIUS = 12.dp
|
||||||
private val avatarRadius = AvatarSize.TimelineSender.dp / 2
|
private val avatarRadius = AvatarSize.TimelineSender.dp / 2
|
||||||
|
|
@ -95,6 +96,17 @@ fun MessageEventBubble(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val clickableModifier = if (isTalkbackActive()) {
|
||||||
|
Modifier
|
||||||
|
} else {
|
||||||
|
Modifier.combinedClickable(
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
indication = ripple(),
|
||||||
|
interactionSource = interactionSource
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Ignore state.isHighlighted for now, we need a design decision on it.
|
// Ignore state.isHighlighted for now, we need a design decision on it.
|
||||||
val backgroundBubbleColor = when {
|
val backgroundBubbleColor = when {
|
||||||
state.isMine -> ElementTheme.colors.messageFromMeBackground
|
state.isMine -> ElementTheme.colors.messageFromMeBackground
|
||||||
|
|
@ -137,12 +149,7 @@ fun MessageEventBubble(
|
||||||
.toDp()
|
.toDp()
|
||||||
)
|
)
|
||||||
.clip(bubbleShape)
|
.clip(bubbleShape)
|
||||||
.combinedClickable(
|
.then(clickableModifier),
|
||||||
onClick = onClick,
|
|
||||||
onLongClick = onLongClick,
|
|
||||||
indication = ripple(),
|
|
||||||
interactionSource = interactionSource
|
|
||||||
),
|
|
||||||
color = backgroundBubbleColor,
|
color = backgroundBubbleColor,
|
||||||
shape = bubbleShape,
|
shape = bubbleShape,
|
||||||
content = content
|
content = content
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.invisibleToUser
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.compound.theme.ElementTheme
|
import io.element.android.compound.theme.ElementTheme
|
||||||
|
|
@ -77,7 +79,8 @@ fun TimelineEventTimestampView(
|
||||||
.size(15.dp, 18.dp)
|
.size(15.dp, 18.dp)
|
||||||
.clickable(isVerifiedUserSendFailure) {
|
.clickable(isVerifiedUserSendFailure) {
|
||||||
eventSink(TimelineEvents.ComputeVerifiedUserSendFailure(event))
|
eventSink(TimelineEvents.ComputeVerifiedUserSendFailure(event))
|
||||||
},
|
}
|
||||||
|
.semantics { invisibleToUser() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -91,7 +94,8 @@ fun TimelineEventTimestampView(
|
||||||
.size(15.dp)
|
.size(15.dp)
|
||||||
.clickable {
|
.clickable {
|
||||||
eventSink(TimelineEvents.ShowShieldDialog(shield))
|
eventSink(TimelineEvents.ShowShieldDialog(shield))
|
||||||
},
|
}
|
||||||
|
.semantics { invisibleToUser() },
|
||||||
tint = shield.toIconColor(),
|
tint = shield.toIconColor(),
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
|
|
||||||
|
|
@ -37,13 +37,12 @@ import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.platform.LocalViewConfiguration
|
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||||
import androidx.compose.ui.platform.ViewConfiguration
|
import androidx.compose.ui.platform.ViewConfiguration
|
||||||
import androidx.compose.ui.platform.testTag
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
|
||||||
import androidx.compose.ui.semantics.invisibleToUser
|
import androidx.compose.ui.semantics.invisibleToUser
|
||||||
|
import androidx.compose.ui.semantics.isTraversalGroup
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.semantics.testTag
|
import androidx.compose.ui.semantics.traversalIndex
|
||||||
import androidx.compose.ui.unit.DpOffset
|
import androidx.compose.ui.unit.DpOffset
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
@ -96,6 +95,7 @@ import io.element.android.libraries.matrix.ui.messages.sender.SenderNameMode
|
||||||
import io.element.android.libraries.testtags.TestTags
|
import io.element.android.libraries.testtags.TestTags
|
||||||
import io.element.android.libraries.testtags.testTag
|
import io.element.android.libraries.testtags.testTag
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
import io.element.android.libraries.ui.utils.time.isTalkbackActive
|
||||||
import io.element.android.wysiwyg.link.Link
|
import io.element.android.wysiwyg.link.Link
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
@ -245,7 +245,7 @@ fun TimelineItemEventRow(
|
||||||
),
|
),
|
||||||
renderReadReceipts = renderReadReceipts,
|
renderReadReceipts = renderReadReceipts,
|
||||||
onReadReceiptsClick = { onReadReceiptClick(event) },
|
onReadReceiptsClick = { onReadReceiptClick(event) },
|
||||||
modifier = Modifier.padding(top = 4.dp),
|
modifier = Modifier.padding(top = 4.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -595,7 +595,10 @@ private fun MessageEventBubbleContent(
|
||||||
timestampPosition = timestampPosition,
|
timestampPosition = timestampPosition,
|
||||||
eventSink = eventSink,
|
eventSink = eventSink,
|
||||||
canShrinkContent = canShrinkContent,
|
canShrinkContent = canShrinkContent,
|
||||||
modifier = timestampLayoutModifier,
|
modifier = timestampLayoutModifier.semantics(mergeDescendants = false) {
|
||||||
|
isTraversalGroup = true
|
||||||
|
traversalIndex = -1f
|
||||||
|
},
|
||||||
content = { onContentLayoutChange ->
|
content = { onContentLayoutChange ->
|
||||||
eventContentView(contentModifier, onContentLayoutChange)
|
eventContentView(contentModifier, onContentLayoutChange)
|
||||||
}
|
}
|
||||||
|
|
@ -607,17 +610,23 @@ private fun MessageEventBubbleContent(
|
||||||
val inReplyToModifier = Modifier
|
val inReplyToModifier = Modifier
|
||||||
.padding(top = topPadding, start = 8.dp, end = 8.dp)
|
.padding(top = topPadding, start = 8.dp, end = 8.dp)
|
||||||
.clip(RoundedCornerShape(6.dp))
|
.clip(RoundedCornerShape(6.dp))
|
||||||
// FIXME when a node is clickable, its contents won't be added to the semantics tree of its parent
|
|
||||||
.clickable(onClick = inReplyToClick)
|
val talkbackCompatModifier = if (isTalkbackActive()) {
|
||||||
|
// Use z-index to make the replied to text being read after the message
|
||||||
|
// Usually, you'd use traversalIndex for that, but it's not working for some reason
|
||||||
|
inReplyToModifier.zIndex(1f)
|
||||||
|
} else {
|
||||||
|
inReplyToModifier.clickable(onClick = inReplyToClick)
|
||||||
|
}
|
||||||
InReplyToView(
|
InReplyToView(
|
||||||
inReplyTo = inReplyTo,
|
inReplyTo = inReplyTo,
|
||||||
hideImage = timelineProtectionState.hideMediaContent(inReplyTo.eventId()),
|
hideImage = timelineProtectionState.hideMediaContent(inReplyTo.eventId()),
|
||||||
modifier = inReplyToModifier,
|
modifier = talkbackCompatModifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (inReplyToDetails != null) {
|
if (inReplyToDetails != null) {
|
||||||
// Use SubComposeLayout only if necessary as it can have consequences on the performance.
|
// Use SubComposeLayout only if necessary as it can have consequences on the performance.
|
||||||
EqualWidthColumn(modifier = modifier, spacing = 8.dp) {
|
EqualWidthColumn(spacing = 8.dp) {
|
||||||
threadDecoration()
|
threadDecoration()
|
||||||
inReplyTo(inReplyToDetails)
|
inReplyTo(inReplyToDetails)
|
||||||
contentWithTimestamp()
|
contentWithTimestamp()
|
||||||
|
|
@ -651,9 +660,7 @@ private fun MessageEventBubbleContent(
|
||||||
paddingBehaviour = paddingBehaviour,
|
paddingBehaviour = paddingBehaviour,
|
||||||
inReplyToDetails = event.inReplyTo,
|
inReplyToDetails = event.inReplyTo,
|
||||||
canShrinkContent = event.content is TimelineItemVoiceContent,
|
canShrinkContent = event.content is TimelineItemVoiceContent,
|
||||||
modifier = bubbleModifier.semantics(mergeDescendants = true) {
|
modifier = bubbleModifier,
|
||||||
contentDescription = event.safeSenderName
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import io.element.android.features.messages.impl.R
|
import io.element.android.features.messages.impl.R
|
||||||
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
||||||
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
|
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
|
||||||
|
|
@ -25,6 +27,7 @@ import io.element.android.features.messages.impl.timeline.components.layout.Cont
|
||||||
import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState
|
import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState
|
||||||
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
|
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
|
||||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
|
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
|
||||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||||
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
|
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
|
||||||
|
|
@ -140,7 +143,16 @@ private fun TimelineItemGroupedEventsRowContent(
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Column(modifier = modifier.animateContentSize()) {
|
Column(modifier = modifier.animateContentSize()) {
|
||||||
|
val groupedEventsTitle = pluralStringResource(
|
||||||
|
id = R.plurals.screen_room_timeline_state_changes,
|
||||||
|
count = timelineItem.events.size,
|
||||||
|
timelineItem.events.size
|
||||||
|
)
|
||||||
GroupHeaderView(
|
GroupHeaderView(
|
||||||
|
modifier = Modifier.clearAndSetSemantics {
|
||||||
|
val groupedEventsContent = timelineItem.events.reversed().joinToString(separator = "\n") { (it.content as TimelineItemStateContent).body }
|
||||||
|
contentDescription = groupedEventsTitle + groupedEventsContent
|
||||||
|
},
|
||||||
text = pluralStringResource(
|
text = pluralStringResource(
|
||||||
id = R.plurals.screen_room_timeline_state_changes,
|
id = R.plurals.screen_room_timeline_state_changes,
|
||||||
count = timelineItem.events.size,
|
count = timelineItem.events.size,
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.invisibleToUser
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.unit.LayoutDirection
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.features.messages.impl.R
|
import io.element.android.features.messages.impl.R
|
||||||
|
|
@ -40,7 +42,9 @@ fun TimelineItemReactionsView(
|
||||||
) {
|
) {
|
||||||
var expanded: Boolean by rememberSaveable { mutableStateOf(false) }
|
var expanded: Boolean by rememberSaveable { mutableStateOf(false) }
|
||||||
TimelineItemReactionsView(
|
TimelineItemReactionsView(
|
||||||
modifier = modifier,
|
modifier = modifier.semantics {
|
||||||
|
invisibleToUser()
|
||||||
|
},
|
||||||
reactions = reactionsState.reactions,
|
reactions = reactionsState.reactions,
|
||||||
userCanSendReaction = userCanSendReaction,
|
userCanSendReaction = userCanSendReaction,
|
||||||
expanded = expanded,
|
expanded = expanded,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.timeline.components
|
package io.element.android.features.messages.impl.timeline.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
|
@ -17,6 +19,9 @@ import androidx.compose.ui.draw.drawWithCache
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.geometry.Size
|
import androidx.compose.ui.geometry.Size
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.compound.theme.ElementTheme
|
import io.element.android.compound.theme.ElementTheme
|
||||||
|
|
@ -28,6 +33,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
|
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
|
||||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
|
@ -37,8 +43,12 @@ import io.element.android.libraries.designsystem.theme.LocalBuildMeta
|
||||||
import io.element.android.libraries.designsystem.theme.highlightedMessageBackgroundColor
|
import io.element.android.libraries.designsystem.theme.highlightedMessageBackgroundColor
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
import io.element.android.libraries.ui.utils.time.isTalkbackActive
|
||||||
import io.element.android.wysiwyg.link.Link
|
import io.element.android.wysiwyg.link.Link
|
||||||
|
import kotlin.time.DurationUnit
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TimelineItemRow(
|
internal fun TimelineItemRow(
|
||||||
timelineItem: TimelineItem,
|
timelineItem: TimelineItem,
|
||||||
|
|
@ -120,7 +130,28 @@ internal fun TimelineItemRow(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
|
val a11yVoiceMessage = stringResource(CommonStrings.a11y_voice_message)
|
||||||
TimelineItemEventRow(
|
TimelineItemEventRow(
|
||||||
|
modifier = Modifier
|
||||||
|
.semantics(mergeDescendants = true) {
|
||||||
|
contentDescription = if (timelineItem.content is TimelineItemVoiceContent) {
|
||||||
|
val voiceMessageText = String.format(a11yVoiceMessage, timelineItem.content.duration.toString(DurationUnit.MINUTES))
|
||||||
|
"${timelineItem.safeSenderName}, $voiceMessageText"
|
||||||
|
} else {
|
||||||
|
timelineItem.safeSenderName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Custom clickable that applies over the whole item for accessibility
|
||||||
|
.then(
|
||||||
|
if (isTalkbackActive()) {
|
||||||
|
Modifier.combinedClickable(
|
||||||
|
onClick = { onContentClick(timelineItem) },
|
||||||
|
onLongClick = { onLongClick(timelineItem) }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
}
|
||||||
|
),
|
||||||
event = timelineItem,
|
event = timelineItem,
|
||||||
timelineRoomInfo = timelineRoomInfo,
|
timelineRoomInfo = timelineRoomInfo,
|
||||||
renderReadReceipts = renderReadReceipts,
|
renderReadReceipts = renderReadReceipts,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.rotate
|
import androidx.compose.ui.draw.rotate
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.compound.theme.ElementTheme
|
import io.element.android.compound.theme.ElementTheme
|
||||||
|
|
@ -21,6 +22,7 @@ import io.element.android.libraries.designsystem.icons.CompoundDrawables
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TimelineItemFileView(
|
fun TimelineItemFileView(
|
||||||
|
|
@ -37,7 +39,7 @@ fun TimelineItemFileView(
|
||||||
icon = {
|
icon = {
|
||||||
Icon(
|
Icon(
|
||||||
resourceId = CompoundDrawables.ic_compound_attachment,
|
resourceId = CompoundDrawables.ic_compound_attachment,
|
||||||
contentDescription = null,
|
contentDescription = stringResource(CommonStrings.common_file),
|
||||||
tint = ElementTheme.colors.iconPrimary,
|
tint = ElementTheme.colors.iconPrimary,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(16.dp)
|
.size(16.dp)
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,6 @@ import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalInspectionMode
|
import androidx.compose.ui.platform.LocalInspectionMode
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
|
|
@ -55,6 +53,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
|
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
import io.element.android.libraries.ui.utils.time.isTalkbackActive
|
||||||
import io.element.android.wysiwyg.compose.EditorStyledText
|
import io.element.android.wysiwyg.compose.EditorStyledText
|
||||||
import io.element.android.wysiwyg.link.Link
|
import io.element.android.wysiwyg.link.Link
|
||||||
|
|
||||||
|
|
@ -71,10 +70,9 @@ fun TimelineItemImageView(
|
||||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val description = stringResource(CommonStrings.common_image)
|
val a11yLabel = stringResource(CommonStrings.common_image)
|
||||||
Column(
|
val description = content.caption?.let { "$a11yLabel: $it" } ?: a11yLabel
|
||||||
modifier = modifier.semantics { contentDescription = description },
|
Column(modifier = modifier) {
|
||||||
) {
|
|
||||||
val containerModifier = if (content.showCaption) {
|
val containerModifier = if (content.showCaption) {
|
||||||
Modifier.clip(RoundedCornerShape(10.dp))
|
Modifier.clip(RoundedCornerShape(10.dp))
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -93,7 +91,16 @@ fun TimelineItemImageView(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.then(if (isLoaded) Modifier.background(Color.White) else Modifier)
|
.then(if (isLoaded) Modifier.background(Color.White) else Modifier)
|
||||||
.then(if (onContentClick != null) Modifier.combinedClickable(onClick = onContentClick, onLongClick = onLongClick) else Modifier),
|
.then(
|
||||||
|
if (!isTalkbackActive() && onContentClick != null) {
|
||||||
|
Modifier.combinedClickable(
|
||||||
|
onClick = onContentClick,
|
||||||
|
onLongClick = onLongClick
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
}
|
||||||
|
),
|
||||||
model = content.thumbnailMediaRequestData,
|
model = content.thumbnailMediaRequestData,
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Fit,
|
||||||
alignment = Alignment.Center,
|
alignment = Alignment.Center,
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ import androidx.compose.ui.graphics.ColorFilter
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalInspectionMode
|
import androidx.compose.ui.platform.LocalInspectionMode
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.invisibleToUser
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
@ -63,6 +63,7 @@ import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH
|
||||||
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||||
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
|
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
import io.element.android.libraries.ui.utils.time.isTalkbackActive
|
||||||
import io.element.android.wysiwyg.compose.EditorStyledText
|
import io.element.android.wysiwyg.compose.EditorStyledText
|
||||||
import io.element.android.wysiwyg.link.Link
|
import io.element.android.wysiwyg.link.Link
|
||||||
|
|
||||||
|
|
@ -79,10 +80,10 @@ fun TimelineItemVideoView(
|
||||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val description = stringResource(CommonStrings.common_video)
|
val isTalkbackActive = isTalkbackActive()
|
||||||
Column(
|
val a11yLabel = stringResource(CommonStrings.common_video)
|
||||||
modifier = modifier.semantics { contentDescription = description }
|
val description = content.caption?.let { "$a11yLabel: $it" } ?: a11yLabel
|
||||||
) {
|
Column(modifier = modifier) {
|
||||||
val containerModifier = if (content.showCaption) {
|
val containerModifier = if (content.showCaption) {
|
||||||
Modifier
|
Modifier
|
||||||
.padding(top = 6.dp)
|
.padding(top = 6.dp)
|
||||||
|
|
@ -104,7 +105,16 @@ fun TimelineItemVideoView(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.then(if (isLoaded) Modifier.background(Color.White) else Modifier)
|
.then(if (isLoaded) Modifier.background(Color.White) else Modifier)
|
||||||
.then(if (onContentClick != null) Modifier.combinedClickable(onClick = onContentClick, onLongClick = onLongClick) else Modifier),
|
.then(
|
||||||
|
if (!isTalkbackActive && onContentClick != null) {
|
||||||
|
Modifier.combinedClickable(
|
||||||
|
onClick = onContentClick,
|
||||||
|
onLongClick = onLongClick
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
}
|
||||||
|
),
|
||||||
model = MediaRequestData(
|
model = MediaRequestData(
|
||||||
source = content.thumbnailSource,
|
source = content.thumbnailSource,
|
||||||
kind = MediaRequestData.Kind.Thumbnail(
|
kind = MediaRequestData.Kind.Thumbnail(
|
||||||
|
|
@ -126,6 +136,7 @@ fun TimelineItemVideoView(
|
||||||
imageVector = CompoundIcons.PlaySolid(),
|
imageVector = CompoundIcons.PlaySolid(),
|
||||||
contentDescription = stringResource(id = CommonStrings.a11y_play),
|
contentDescription = stringResource(id = CommonStrings.a11y_play),
|
||||||
colorFilter = ColorFilter.tint(Color.White),
|
colorFilter = ColorFilter.tint(Color.White),
|
||||||
|
modifier = Modifier.semantics { invisibleToUser() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,11 @@ import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.disabled
|
||||||
|
import androidx.compose.ui.semantics.onClick
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
|
@ -40,7 +41,6 @@ import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContentProvider
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContentProvider
|
||||||
import io.element.android.libraries.androidutils.accessibility.isScreenReaderEnabled
|
|
||||||
import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView
|
import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
|
@ -49,6 +49,7 @@ import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
import io.element.android.libraries.ui.utils.time.isTalkbackActive
|
||||||
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvents
|
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvents
|
||||||
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
|
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
|
||||||
import io.element.android.libraries.voiceplayer.api.VoiceMessageStateProvider
|
import io.element.android.libraries.voiceplayer.api.VoiceMessageStateProvider
|
||||||
|
|
@ -66,10 +67,27 @@ fun TimelineItemVoiceView(
|
||||||
}
|
}
|
||||||
|
|
||||||
val a11y = stringResource(CommonStrings.common_voice_message)
|
val a11y = stringResource(CommonStrings.common_voice_message)
|
||||||
|
val a11yActionLabel = stringResource(
|
||||||
|
when (state.button) {
|
||||||
|
VoiceMessageState.Button.Play -> CommonStrings.a11y_play
|
||||||
|
VoiceMessageState.Button.Pause -> CommonStrings.a11y_pause
|
||||||
|
VoiceMessageState.Button.Downloading -> CommonStrings.common_downloading
|
||||||
|
VoiceMessageState.Button.Retry -> CommonStrings.action_retry
|
||||||
|
VoiceMessageState.Button.Disabled -> CommonStrings.error_unknown
|
||||||
|
}
|
||||||
|
)
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.semantics {
|
.clearAndSetSemantics {
|
||||||
contentDescription = a11y
|
contentDescription = a11y
|
||||||
|
if (state.button == VoiceMessageState.Button.Disabled) {
|
||||||
|
disabled()
|
||||||
|
} else if (state.button in listOf(VoiceMessageState.Button.Play, VoiceMessageState.Button.Pause)) {
|
||||||
|
onClick(label = a11yActionLabel) {
|
||||||
|
playPause()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onSizeChanged {
|
.onSizeChanged {
|
||||||
onContentLayoutChange(
|
onContentLayoutChange(
|
||||||
|
|
@ -81,12 +99,14 @@ fun TimelineItemVoiceView(
|
||||||
},
|
},
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
when (state.button) {
|
if (!isTalkbackActive()) {
|
||||||
VoiceMessageState.Button.Play -> PlayButton(onClick = ::playPause)
|
when (state.button) {
|
||||||
VoiceMessageState.Button.Pause -> PauseButton(onClick = ::playPause)
|
VoiceMessageState.Button.Play -> PlayButton(onClick = ::playPause)
|
||||||
VoiceMessageState.Button.Downloading -> ProgressButton()
|
VoiceMessageState.Button.Pause -> PauseButton(onClick = ::playPause)
|
||||||
VoiceMessageState.Button.Retry -> RetryButton(onClick = ::playPause)
|
VoiceMessageState.Button.Downloading -> ProgressButton()
|
||||||
VoiceMessageState.Button.Disabled -> PlayButton(onClick = {}, enabled = false)
|
VoiceMessageState.Button.Retry -> RetryButton(onClick = ::playPause)
|
||||||
|
VoiceMessageState.Button.Disabled -> PlayButton(onClick = {}, enabled = false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -97,13 +117,12 @@ fun TimelineItemVoiceView(
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
val context = LocalContext.current
|
|
||||||
WaveformPlaybackView(
|
WaveformPlaybackView(
|
||||||
showCursor = state.showCursor,
|
showCursor = state.showCursor,
|
||||||
playbackProgress = state.progress,
|
playbackProgress = state.progress,
|
||||||
waveform = content.waveform,
|
waveform = content.waveform,
|
||||||
modifier = Modifier.height(34.dp),
|
modifier = Modifier.height(34.dp),
|
||||||
seekEnabled = !context.isScreenReaderEnabled(),
|
seekEnabled = !isTalkbackActive(),
|
||||||
onSeek = { state.eventSink(VoiceMessageEvents.Seek(it)) },
|
onSeek = { state.eventSink(VoiceMessageEvents.Seek(it)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.invisibleToUser
|
||||||
import androidx.compose.ui.semantics.testTag
|
import androidx.compose.ui.semantics.testTag
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
@ -56,7 +57,11 @@ fun TimelineItemReadReceiptView(
|
||||||
) {
|
) {
|
||||||
if (state.receipts.isNotEmpty()) {
|
if (state.receipts.isNotEmpty()) {
|
||||||
if (renderReadReceipts) {
|
if (renderReadReceipts) {
|
||||||
ReadReceiptsRow(modifier = modifier) {
|
ReadReceiptsRow(
|
||||||
|
modifier = modifier.clearAndSetSemantics {
|
||||||
|
invisibleToUser()
|
||||||
|
}
|
||||||
|
) {
|
||||||
ReadReceiptsAvatars(
|
ReadReceiptsAvatars(
|
||||||
receipts = state.receipts,
|
receipts = state.receipts,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
|
||||||
|
|
@ -19,13 +19,11 @@ import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.compound.theme.ElementTheme
|
import io.element.android.compound.theme.ElementTheme
|
||||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Button with colored background.
|
* Button with colored background.
|
||||||
|
|
@ -35,6 +33,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
fun IconColorButton(
|
fun IconColorButton(
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
imageVector: ImageVector,
|
imageVector: ImageVector,
|
||||||
|
contentDescription: String?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
buttonSize: ButtonSize = ButtonSize.Large,
|
buttonSize: ButtonSize = ButtonSize.Large,
|
||||||
iconColorButtonStyle: IconColorButtonStyle = IconColorButtonStyle.Primary,
|
iconColorButtonStyle: IconColorButtonStyle = IconColorButtonStyle.Primary,
|
||||||
|
|
@ -55,7 +54,7 @@ fun IconColorButton(
|
||||||
.background(bgColor)
|
.background(bgColor)
|
||||||
.padding(buttonSize.toContainerPadding()),
|
.padding(buttonSize.toContainerPadding()),
|
||||||
imageVector = imageVector,
|
imageVector = imageVector,
|
||||||
contentDescription = stringResource(CommonStrings.action_close),
|
contentDescription = contentDescription,
|
||||||
tint = ElementTheme.colors.iconOnSolidPrimary
|
tint = ElementTheme.colors.iconOnSolidPrimary
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -101,6 +100,7 @@ internal fun IconColorButtonPreview() = ElementPreview {
|
||||||
IconColorButton(
|
IconColorButton(
|
||||||
onClick = {},
|
onClick = {},
|
||||||
imageVector = CompoundIcons.Close(),
|
imageVector = CompoundIcons.Close(),
|
||||||
|
contentDescription = null,
|
||||||
buttonSize = size,
|
buttonSize = size,
|
||||||
iconColorButtonStyle = style,
|
iconColorButtonStyle = style,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,9 @@ import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.isTraversalGroup
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.semantics.traversalIndex
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
|
@ -58,7 +60,7 @@ fun InReplyToView(
|
||||||
senderId = inReplyTo.senderId,
|
senderId = inReplyTo.senderId,
|
||||||
senderProfile = inReplyTo.senderProfile,
|
senderProfile = inReplyTo.senderProfile,
|
||||||
metadata = inReplyTo.metadata(hideImage),
|
metadata = inReplyTo.metadata(hideImage),
|
||||||
modifier = modifier
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is InReplyToDetails.Error ->
|
is InReplyToDetails.Error ->
|
||||||
|
|
@ -96,13 +98,18 @@ private fun ReplyToReadyContent(
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
}
|
}
|
||||||
val a11InReplyToText = stringResource(CommonStrings.common_in_reply_to, senderProfile.getDisambiguatedDisplayName(senderId))
|
val a11InReplyToText = stringResource(CommonStrings.common_in_reply_to, senderProfile.getDisambiguatedDisplayName(senderId))
|
||||||
Column(verticalArrangement = Arrangement.SpaceBetween) {
|
Column(
|
||||||
|
modifier = Modifier.semantics(mergeDescendants = false) { isTraversalGroup = true },
|
||||||
|
verticalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
SenderName(
|
SenderName(
|
||||||
senderId = senderId,
|
senderId = senderId,
|
||||||
senderProfile = senderProfile,
|
senderProfile = senderProfile,
|
||||||
senderNameMode = SenderNameMode.Reply,
|
senderNameMode = SenderNameMode.Reply,
|
||||||
modifier = Modifier.semantics {
|
modifier = Modifier.semantics {
|
||||||
contentDescription = a11InReplyToText
|
contentDescription = a11InReplyToText
|
||||||
|
isTraversalGroup = true
|
||||||
|
traversalIndex = 1f
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
ReplyToContentText(metadata)
|
ReplyToContentText(metadata)
|
||||||
|
|
@ -169,6 +176,10 @@ private fun ReplyToContentText(metadata: InReplyToMetadata?) {
|
||||||
else -> FontStyle.Normal
|
else -> FontStyle.Normal
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
|
modifier = Modifier.semantics(mergeDescendants = false) {
|
||||||
|
isTraversalGroup = true
|
||||||
|
traversalIndex = -1f
|
||||||
|
},
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
if (iconResourceId != null) {
|
if (iconResourceId != null) {
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,7 @@ fun TextComposer(
|
||||||
IconColorButton(
|
IconColorButton(
|
||||||
onClick = onAddAttachment,
|
onClick = onAddAttachment,
|
||||||
imageVector = CompoundIcons.Plus(),
|
imageVector = CompoundIcons.Plus(),
|
||||||
|
contentDescription = stringResource(R.string.rich_text_editor_a11y_add_attachment),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -292,6 +293,7 @@ fun TextComposer(
|
||||||
IconColorButton(
|
IconColorButton(
|
||||||
onClick = onDismissTextFormatting,
|
onClick = onDismissTextFormatting,
|
||||||
imageVector = CompoundIcons.Close(),
|
imageVector = CompoundIcons.Close(),
|
||||||
|
contentDescription = stringResource(CommonStrings.action_close),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
textFormatting = textFormattingOptions,
|
textFormatting = textFormattingOptions,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue