Skip to content

Commit e9b0bbe

Browse files
authored
compose: Announce message sender to screen readers (#6504)
* compose: Announce message sender to screen readers Add per-message sender attribution for TalkBack so every message announces who sent it, independent of the visual grouping that hides the author name on consecutive messages from the same user. The sender is announced once per message: - Text messages prefix the text with "You said" / "{sender} said". - Attachment messages (image, video, multiple media, file, voice message, giphy) announce the sender on the attachment label, with the attachment card still individually focusable. - Poll and deleted messages are attributed too. - Reply messages announce "replied" instead of "said". A shared senderAwareContentDescription helper builds the label, and an additive AttachmentState.announceSender flag marks the single attachment that carries the sender. Adds translations for the new strings in all supported locales. * compose: Announce reply cue on poll and deleted messages Pass isReply to the sender-aware description for poll and deleted messages so a reply announces "replied" instead of "said", matching text and attachment messages.
1 parent 73f5f7e commit e9b0bbe

19 files changed

Lines changed: 584 additions & 115 deletions

File tree

stream-chat-android-compose/api/stream-chat-android-compose.api

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -317,15 +317,17 @@ public abstract interface class io/getstream/chat/android/compose/state/messages
317317

318318
public final class io/getstream/chat/android/compose/state/messages/attachments/AttachmentState {
319319
public static final field $stable I
320-
public fun <init> (Lio/getstream/chat/android/models/Message;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V
321-
public synthetic fun <init> (Lio/getstream/chat/android/models/Message;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
320+
public fun <init> (Lio/getstream/chat/android/models/Message;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Z)V
321+
public synthetic fun <init> (Lio/getstream/chat/android/models/Message;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V
322322
public final fun component1 ()Lio/getstream/chat/android/models/Message;
323323
public final fun component2 ()Z
324324
public final fun component3 ()Lkotlin/jvm/functions/Function1;
325325
public final fun component4 ()Lkotlin/jvm/functions/Function1;
326-
public final fun copy (Lio/getstream/chat/android/models/Message;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Lio/getstream/chat/android/compose/state/messages/attachments/AttachmentState;
327-
public static synthetic fun copy$default (Lio/getstream/chat/android/compose/state/messages/attachments/AttachmentState;Lio/getstream/chat/android/models/Message;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lio/getstream/chat/android/compose/state/messages/attachments/AttachmentState;
326+
public final fun component5 ()Z
327+
public final fun copy (Lio/getstream/chat/android/models/Message;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Z)Lio/getstream/chat/android/compose/state/messages/attachments/AttachmentState;
328+
public static synthetic fun copy$default (Lio/getstream/chat/android/compose/state/messages/attachments/AttachmentState;Lio/getstream/chat/android/models/Message;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ZILjava/lang/Object;)Lio/getstream/chat/android/compose/state/messages/attachments/AttachmentState;
328329
public fun equals (Ljava/lang/Object;)Z
330+
public final fun getAnnounceSender ()Z
329331
public final fun getMessage ()Lio/getstream/chat/android/models/Message;
330332
public final fun getOnLongItemClick ()Lkotlin/jvm/functions/Function1;
331333
public final fun getOnMediaGalleryPreviewResult ()Lkotlin/jvm/functions/Function1;

stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/state/messages/attachments/AttachmentState.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,14 @@ import io.getstream.chat.android.models.Message
2727
* @param onLongItemClick Handler for a long click on the message item.
2828
* @param onMediaGalleryPreviewResult Handler used when the user selects an action to perform from
2929
* [io.getstream.chat.android.compose.ui.attachments.preview.MediaGalleryPreviewActivity].
30+
* @param announceSender Whether this attachment should prefix its accessibility label with the
31+
* sender. Set for the first attachment of a message that has no text, so screen readers attribute
32+
* attachment-only messages without announcing the sender more than once.
3033
*/
3134
public data class AttachmentState(
3235
val message: Message,
3336
val isMine: Boolean = false,
3437
val onLongItemClick: (Message) -> Unit = {},
3538
val onMediaGalleryPreviewResult: (MediaGalleryPreviewResult?) -> Unit = {},
39+
val announceSender: Boolean = false,
3640
)

stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/AudioRecordAttachmentContent.kt

Lines changed: 36 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package io.getstream.chat.android.compose.ui.attachments.content
1818

1919
import androidx.compose.foundation.background
2020
import androidx.compose.foundation.layout.Arrangement
21+
import androidx.compose.foundation.layout.Box
2122
import androidx.compose.foundation.layout.Column
2223
import androidx.compose.foundation.layout.PaddingValues
2324
import androidx.compose.foundation.layout.Row
@@ -41,6 +42,9 @@ import androidx.compose.ui.Modifier
4142
import androidx.compose.ui.graphics.Color
4243
import androidx.compose.ui.res.painterResource
4344
import androidx.compose.ui.res.stringResource
45+
import androidx.compose.ui.semantics.contentDescription
46+
import androidx.compose.ui.semantics.isTraversalGroup
47+
import androidx.compose.ui.semantics.semantics
4448
import androidx.compose.ui.tooling.preview.Preview
4549
import androidx.compose.ui.unit.Dp
4650
import androidx.compose.ui.unit.dp
@@ -66,6 +70,7 @@ import io.getstream.chat.android.compose.ui.theme.ChatTheme
6670
import io.getstream.chat.android.compose.ui.theme.MessageStyling
6771
import io.getstream.chat.android.compose.ui.theme.StreamTokens
6872
import io.getstream.chat.android.compose.ui.util.applyIf
73+
import io.getstream.chat.android.compose.ui.util.senderAwareDescription
6974
import io.getstream.chat.android.compose.ui.util.shouldBeDisplayedAsFullSizeAttachment
7075
import io.getstream.chat.android.compose.viewmodel.messages.AudioPlayerViewModel
7176
import io.getstream.chat.android.compose.viewmodel.messages.AudioPlayerViewModelFactory
@@ -99,26 +104,38 @@ public fun AudioRecordAttachmentContent(
99104
val playerState by viewModel.state.collectAsStateWithLifecycle()
100105

101106
val shouldBeFullSize = attachmentState.message.shouldBeDisplayedAsFullSizeAttachment()
102-
Column(
103-
modifier = modifier.applyIf(!shouldBeFullSize) { padding(MessageStyling.messageSectionPadding) },
104-
verticalArrangement = Arrangement.spacedBy(MessageStyling.sectionsDistance),
107+
// Mirror the multi-attachment grid: a non-clickable wrapper carries the "Voice message" label
108+
// (and the sender when this is the sender-bearing attachment) so the message row announces it,
109+
// while the playback controls stay individually focusable.
110+
val voiceMessageLabel = stringResource(R.string.stream_compose_audio_recording_preview)
111+
val rowDescription = attachmentState.senderAwareDescription(voiceMessageLabel)
112+
Box(
113+
modifier = Modifier.semantics {
114+
contentDescription = rowDescription
115+
isTraversalGroup = true
116+
},
105117
) {
106-
audioRecordings.forEach { audioRecording ->
107-
AudioRecordAttachmentContentItem(
108-
modifier = Modifier.applyIf(!shouldBeFullSize) {
109-
background(
110-
MessageStyling.attachmentBackgroundColor(attachmentState.isMine),
111-
RoundedCornerShape(StreamTokens.radiusLg),
112-
)
113-
},
114-
attachment = audioRecording,
115-
playerState = playerState,
116-
isMine = attachmentState.isMine,
117-
onPlayToggleClick = viewModel::playOrPause,
118-
onPlaySpeedClick = viewModel::changeSpeed,
119-
onThumbDragStart = viewModel::startSeek,
120-
onThumbDragStop = viewModel::seekTo,
121-
)
118+
Column(
119+
modifier = modifier.applyIf(!shouldBeFullSize) { padding(MessageStyling.messageSectionPadding) },
120+
verticalArrangement = Arrangement.spacedBy(MessageStyling.sectionsDistance),
121+
) {
122+
audioRecordings.forEach { audioRecording ->
123+
AudioRecordAttachmentContentItem(
124+
modifier = Modifier.applyIf(!shouldBeFullSize) {
125+
background(
126+
MessageStyling.attachmentBackgroundColor(attachmentState.isMine),
127+
RoundedCornerShape(StreamTokens.radiusLg),
128+
)
129+
},
130+
attachment = audioRecording,
131+
playerState = playerState,
132+
isMine = attachmentState.isMine,
133+
onPlayToggleClick = viewModel::playOrPause,
134+
onPlaySpeedClick = viewModel::changeSpeed,
135+
onThumbDragStart = viewModel::startSeek,
136+
onThumbDragStop = viewModel::seekTo,
137+
)
138+
}
122139
}
123140
}
124141

stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/FileAttachmentContent.kt

Lines changed: 55 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import androidx.compose.foundation.background
2020
import androidx.compose.foundation.combinedClickable
2121
import androidx.compose.foundation.interaction.MutableInteractionSource
2222
import androidx.compose.foundation.layout.Arrangement
23+
import androidx.compose.foundation.layout.Box
2324
import androidx.compose.foundation.layout.Column
2425
import androidx.compose.foundation.layout.Row
2526
import androidx.compose.foundation.layout.RowScope
@@ -38,6 +39,9 @@ import androidx.compose.ui.graphics.Color
3839
import androidx.compose.ui.layout.ContentScale
3940
import androidx.compose.ui.platform.testTag
4041
import androidx.compose.ui.res.stringResource
42+
import androidx.compose.ui.semantics.contentDescription
43+
import androidx.compose.ui.semantics.isTraversalGroup
44+
import androidx.compose.ui.semantics.semantics
4145
import androidx.compose.ui.text.TextStyle
4246
import androidx.compose.ui.text.style.TextOverflow
4347
import androidx.compose.ui.tooling.preview.Preview
@@ -59,6 +63,7 @@ import io.getstream.chat.android.compose.ui.util.MimeTypeIconProvider
5963
import io.getstream.chat.android.compose.ui.util.StreamAsyncImage
6064
import io.getstream.chat.android.compose.ui.util.applyIf
6165
import io.getstream.chat.android.compose.ui.util.extensions.internal.imagePreviewData
66+
import io.getstream.chat.android.compose.ui.util.senderAwareDescription
6267
import io.getstream.chat.android.compose.ui.util.shouldBeDisplayedAsFullSizeAttachment
6368
import io.getstream.chat.android.models.Attachment
6469
import io.getstream.chat.android.models.Attachment.UploadState
@@ -67,6 +72,7 @@ import io.getstream.chat.android.models.Message
6772
import io.getstream.chat.android.ui.common.model.MimeType
6873
import io.getstream.chat.android.ui.common.utils.MediaStringUtil
6974
import io.getstream.chat.android.ui.common.utils.extensions.getDisplayableName
75+
import io.getstream.chat.android.ui.common.R as UiCommonR
7076

7177
/**
7278
* Builds a file attachment message which shows a list of files.
@@ -92,40 +98,57 @@ public fun FileAttachmentContent(
9298

9399
val shouldBeFullSize = message.shouldBeDisplayedAsFullSizeAttachment()
94100

95-
Column(
96-
modifier = modifier
97-
.combinedClickable(
98-
indication = null,
99-
interactionSource = remember { MutableInteractionSource() },
100-
onClick = {},
101-
onLongClick = { attachmentState.onLongItemClick(message) },
102-
)
103-
.testTag("Stream_MultipleFileAttachmentsColumn"),
101+
val fileAttachments = message.attachments.filter { it.isFile() || it.isAudio() }
102+
// Mirror the multi-attachment grid: a non-clickable wrapper carries the sender so the message
103+
// row announces it, while the clickable file rows stay individually focusable.
104+
val fileLabel = if (fileAttachments.size == 1) {
105+
fileAttachments.first().getDisplayableName().orEmpty()
106+
} else {
107+
stringResource(UiCommonR.string.stream_ui_message_list_semantics_message_attachments, fileAttachments.size)
108+
}
109+
val rowDescription = attachmentState.senderAwareDescription(fileLabel)
110+
111+
Box(
112+
modifier = Modifier.semantics {
113+
contentDescription = rowDescription
114+
isTraversalGroup = true
115+
},
104116
) {
105-
val openAttachmentLabel = stringResource(R.string.stream_compose_message_attachment_open)
106-
for (attachment in message.attachments) {
107-
if (attachment.isFile() || attachment.isAudio()) {
108-
ChatTheme.componentFactory.FileAttachmentItem(
109-
params = FileAttachmentItemParams(
110-
modifier = Modifier
111-
.fillMaxWidth()
112-
.applyIf(!shouldBeFullSize) {
113-
val color = MessageStyling.attachmentBackgroundColor(attachmentState.isMine)
114-
padding(MessageStyling.messageSectionPadding)
115-
.background(color, fileAttachmentShape)
116-
}
117-
.combinedClickable(
118-
indication = ripple(),
119-
interactionSource = remember { MutableInteractionSource() },
120-
onClickLabel = openAttachmentLabel,
121-
onClick = { onItemClick(previewHandlers, attachment) },
122-
onLongClick = { attachmentState.onLongItemClick(message) },
123-
),
124-
attachment = attachment,
125-
isMine = attachmentState.isMine,
126-
showFileSize = showFileSize,
127-
),
117+
Column(
118+
modifier = modifier
119+
.combinedClickable(
120+
indication = null,
121+
interactionSource = remember { MutableInteractionSource() },
122+
onClick = {},
123+
onLongClick = { attachmentState.onLongItemClick(message) },
128124
)
125+
.testTag("Stream_MultipleFileAttachmentsColumn"),
126+
) {
127+
val openAttachmentLabel = stringResource(R.string.stream_compose_message_attachment_open)
128+
for (attachment in message.attachments) {
129+
if (attachment.isFile() || attachment.isAudio()) {
130+
ChatTheme.componentFactory.FileAttachmentItem(
131+
params = FileAttachmentItemParams(
132+
modifier = Modifier
133+
.fillMaxWidth()
134+
.applyIf(!shouldBeFullSize) {
135+
val color = MessageStyling.attachmentBackgroundColor(attachmentState.isMine)
136+
padding(MessageStyling.messageSectionPadding)
137+
.background(color, fileAttachmentShape)
138+
}
139+
.combinedClickable(
140+
indication = ripple(),
141+
interactionSource = remember { MutableInteractionSource() },
142+
onClickLabel = openAttachmentLabel,
143+
onClick = { onItemClick(previewHandlers, attachment) },
144+
onLongClick = { attachmentState.onLongItemClick(message) },
145+
),
146+
attachment = attachment,
147+
isMine = attachmentState.isMine,
148+
showFileSize = showFileSize,
149+
),
150+
)
151+
}
129152
}
130153
}
131154
}

0 commit comments

Comments
 (0)