Skip to content

Commit c2a34f7

Browse files
authored
Improve message composer TalkBack accessibility: action verbs, banner merge, audio recording lifecycle (#6441)
* Add action verbs to composer Send / Save / attach buttons `MessageComposerSendButton`, `MessageComposerSaveButton`, and the attachments toggle in `MessageComposerLeadingContent` are wrapped in Material3 `FilledIconButton`, which does not expose an `onClickLabel`. TalkBack therefore announced each as `"<icon label>, button, double-tap to activate"` — the generic activate hint had no verb. Layer a `Modifier.semantics { onClick(label = …) { …; true } }` on each button so TalkBack reads a concrete action: - Send → `"Send message"` (`stream_compose_message_composer_send_action`). - Save → `"Save changes"` (`stream_compose_message_composer_save_action`). - Attachments → state-aware label that pairs with the existing `stateDescription`: `"Open attachments"` when collapsed, `"Close attachments"` when expanded (`stream_compose_message_composer_attachments_open` / `…_close`). The semantics action call-through (`onClick(); true`) keeps the actual click handler intact while overriding only the announced verb. The attachments toggle button is also extracted into a private `AttachmentPickerButton` composable to keep `MessageComposerLeadingContent` under detekt's method-length cap. Translates the four new strings across all 7 supported locales. No public API surface change. * Merge composer edit/reply banner for a11y Wraps the edit-indicator row and the reply quoted-message in `Modifier.semantics(mergeDescendants = true) {}` so TalkBack reads the banner as a single chunk instead of fragment by fragment. Cancel icons now announce the action being cancelled ("Cancel editing" / "Cancel reply") rather than the generic "Cancel". When replying to your own message, the username Text leaf overrides `contentDescription` to "Reply to you" so TalkBack mirrors the "Reply to {name}" symmetry while the visible "You" label stays compact. * Improve composer audio recording accessibility Mic button content description includes the press-and-hold gesture hint and exposes Role.Button, so TalkBack reads "Record audio message. Press and hold to record. Button." without a misleading "double-tap to activate" hint. One-shot announcements via View.announceForAccessibility on recording state transitions: - Idle → Hold: "Recording started. Slide left to cancel, up to lock." - non-Idle → Idle: "Recording cancelled", gated on a cancelRequested flag set by wrapped onCancelRecording / onDeleteRecording. Confirm and send paths leave the flag unset so they stay silent and let the attachment item announce itself. Focus moves on state transitions: - Hold → Locked: focus on the merged recording-bar row. - Locked → Overview: focus on the merged playback row. Overview's outer Row is split into a separately-focusable play button + an inner focusable summary so the play action stays reachable. - A 100ms delay before requestFocus gives Compose layout and the accessibility tree time to settle, beating TalkBack's fallback recovery from the previously-focused node. Cancel and Delete focus is left to Compose's natural recovery — focus returns to the mic button when it becomes visible again. * Consume recording-transition flag only on announce Move `onTransitionConsumed()` inside the announcement branches so the caller's `cancelRequested` flag only clears when an announcement has actually fired. The bug described in the review doesn't manifest with the current state writers (the wrapped `onCancelRecording` / `onDeleteRecording` set the flag and `setState(Idle)` in the same Compose snapshot) but separating "transition observed" from "transition consumed" tightens the helper's contract and makes it robust to future changes to the flag writers or the `LaunchedEffect` keys. * Label audio attachment play / pause button * Let integrators supply composer click labels
1 parent b888d8f commit c2a34f7

21 files changed

Lines changed: 579 additions & 144 deletions

File tree

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

Lines changed: 62 additions & 40 deletions
Large diffs are not rendered by default.

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import androidx.compose.ui.Alignment
4040
import androidx.compose.ui.Modifier
4141
import androidx.compose.ui.graphics.Color
4242
import androidx.compose.ui.res.painterResource
43+
import androidx.compose.ui.res.stringResource
4344
import androidx.compose.ui.tooling.preview.Preview
4445
import androidx.compose.ui.unit.Dp
4546
import androidx.compose.ui.unit.dp
@@ -283,6 +284,13 @@ internal fun PlaybackToggleButton(
283284
} else {
284285
painterResource(id = R.drawable.stream_design_ic_play_fill)
285286
}
287+
val label = stringResource(
288+
if (playing) {
289+
R.string.stream_compose_audio_recording_pause
290+
} else {
291+
R.string.stream_compose_audio_recording_play
292+
},
293+
)
286294

287295
StreamButton(
288296
onClick = onClick,
@@ -293,7 +301,7 @@ internal fun PlaybackToggleButton(
293301
Icon(
294302
painter = icon,
295303
modifier = Modifier.size(20.dp),
296-
contentDescription = null,
304+
contentDescription = label,
297305
)
298306
}
299307
}

stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/composer/MessageInput.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ import io.getstream.chat.android.ui.common.state.messages.composer.RecordingStat
7575
* @param onLinkPreviewClick Handler when a link preview is clicked.
7676
* @param onCancelLinkPreviewClick Handler when the cancel link preview button is clicked.
7777
* @param onSendClick Handler when the send button is clicked.
78+
* @param sendActionLabel Semantic / accessibility label for [onSendClick] when sending a new message.
79+
* @param saveActionLabel Semantic / accessibility label for [onSendClick] when saving an edit.
7880
* @param onAlsoSendToChannelChange Handler when the "Also send to channel" checkbox is changed.
7981
* @param recordingActions The [AudioRecordingActions] to be applied to the input.
8082
* @param onActiveCommandDismiss Called when the user taps the dismiss button on the command chip.
@@ -90,6 +92,8 @@ public fun MessageInput(
9092
onLinkPreviewClick: ((LinkPreview) -> Unit)? = null,
9193
onCancelLinkPreviewClick: (() -> Unit)? = null,
9294
onSendClick: (String, List<Attachment>) -> Unit = { _, _ -> },
95+
sendActionLabel: String? = null,
96+
saveActionLabel: String? = null,
9397
onAlsoSendToChannelChange: (Boolean) -> Unit = {},
9498
recordingActions: AudioRecordingActions = AudioRecordingActions.None,
9599
onActiveCommandDismiss: () -> Unit = {},
@@ -166,6 +170,8 @@ public fun MessageInput(
166170
state = messageComposerState,
167171
recordingActions = recordingActions,
168172
onSendClick = onSendClick,
173+
sendActionLabel = sendActionLabel,
174+
saveActionLabel = saveActionLabel,
169175
),
170176
)
171177
}

stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/QuotedMessage.kt

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ import androidx.compose.ui.layout.ContentScale
4343
import androidx.compose.ui.platform.testTag
4444
import androidx.compose.ui.res.painterResource
4545
import androidx.compose.ui.res.stringResource
46+
import androidx.compose.ui.semantics.contentDescription
47+
import androidx.compose.ui.semantics.semantics
4648
import androidx.compose.ui.text.style.TextOverflow
4749
import androidx.compose.ui.tooling.preview.Preview
4850
import androidx.compose.ui.unit.dp
@@ -125,12 +127,14 @@ internal fun MessageComposerQuotedMessage(
125127
message = message,
126128
currentUser = currentUser,
127129
replyMessage = null,
130+
modifier = Modifier.semantics(mergeDescendants = true) {},
128131
)
129132

130133
ComposerCancelIcon(
131134
modifier = Modifier
132135
.align(Alignment.TopEnd)
133136
.offset(StreamTokens.spacing2xs, -StreamTokens.spacing2xs),
137+
contentDescription = stringResource(R.string.stream_compose_message_composer_cancel_reply),
134138
onClick = onCancelClick,
135139
)
136140
}
@@ -143,15 +147,25 @@ private fun QuotedMessageUserName(
143147
currentUser: User?,
144148
color: Color,
145149
) {
146-
val userName = if (message.isMine(currentUser)) {
147-
stringResource(R.string.stream_compose_quoted_message_you)
148-
} else if (replyMessage == null) {
149-
stringResource(R.string.stream_compose_quoted_message_reply_to, message.user.name)
150+
val isMine = message.isMine(currentUser)
151+
val isComposerBanner = replyMessage == null
152+
val userName = when {
153+
isMine -> stringResource(R.string.stream_compose_quoted_message_you)
154+
isComposerBanner -> stringResource(R.string.stream_compose_quoted_message_reply_to, message.user.name)
155+
else -> message.user.name
156+
}
157+
val accessibilityName = if (isMine && isComposerBanner) {
158+
stringResource(R.string.stream_compose_quoted_message_reply_to_you)
150159
} else {
151-
message.user.name
160+
null
152161
}
153162

154163
Text(
164+
modifier = if (accessibilityName != null) {
165+
Modifier.semantics { contentDescription = accessibilityName }
166+
} else {
167+
Modifier
168+
},
155169
text = userName,
156170
style = ChatTheme.typography.metadataEmphasis,
157171
color = color,

stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/MessageComposer.kt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,10 @@ import io.getstream.chat.android.ui.common.utils.MediaStringUtil
9090
* @param isAttachmentPickerVisible If the attachment picker is visible or not.
9191
* @param onSendMessage Handler when the user sends a message. By default it delegates this to the
9292
* ViewModel, but the user can override if they want more custom behavior.
93+
* @param sendActionLabel Semantic / accessibility label for [onSendMessage] when sending a new message.
94+
* @param saveActionLabel Semantic / accessibility label for [onSendMessage] when saving an edit.
9395
* @param onAttachmentsClick Handler for the default Attachments integration.
96+
* @param attachmentsActionLabel Semantic / accessibility label for [onAttachmentsClick].
9497
* @param onValueChange Handler when the input field value changes.
9598
* @param onAttachmentRemoved Handler when the user taps on the cancel/delete attachment action.
9699
* @param onCancelAction Handler for the cancel button on Message actions, such as Edit and Reply.
@@ -112,7 +115,10 @@ public fun MessageComposer(
112115
modifier: Modifier = Modifier,
113116
isAttachmentPickerVisible: Boolean = false,
114117
onSendMessage: (Message) -> Unit = { viewModel.sendMessage(it) },
118+
sendActionLabel: String? = null,
119+
saveActionLabel: String? = null,
115120
onAttachmentsClick: () -> Unit = {},
121+
attachmentsActionLabel: String? = null,
116122
onValueChange: (String) -> Unit = { viewModel.setMessageInput(it) },
117123
onAttachmentRemoved: (Attachment) -> Unit = { viewModel.removeAttachment(it) },
118124
onCancelAction: () -> Unit = { viewModel.dismissMessageActions() },
@@ -195,6 +201,9 @@ public fun MessageComposer(
195201
onAttachmentRemoved = onAttachmentRemoved,
196202
onLinkPreviewClick = onLinkPreviewClick,
197203
onCancelLinkPreviewClick = onCancelLinkPreviewClick,
204+
sendActionLabel = sendActionLabel,
205+
saveActionLabel = saveActionLabel,
206+
attachmentsActionLabel = attachmentsActionLabel,
198207
),
199208
)
200209
}
@@ -215,9 +224,12 @@ internal val LocalMessageComposerSnackbarHostState =
215224
*
216225
* @param messageComposerState The state of the message input.
217226
* @param onSendMessage Handler when the user wants to send a message.
227+
* @param sendActionLabel Semantic / accessibility label for [onSendMessage] when sending a new message.
228+
* @param saveActionLabel Semantic / accessibility label for [onSendMessage] when saving an edit.
218229
* @param modifier Modifier for styling.
219230
* @param isAttachmentPickerVisible If the attachment picker is visible or not.
220231
* @param onAttachmentsClick Handler for the default Attachments integration.
232+
* @param attachmentsActionLabel Semantic / accessibility label for [onAttachmentsClick].
221233
* @param onValueChange Handler when the input field value changes.
222234
* @param onAttachmentRemoved Handler when the user taps on the cancel/delete attachment action.
223235
* @param onCancelAction Handler for the cancel button on Message actions, such as Edit and Reply.
@@ -236,9 +248,12 @@ internal val LocalMessageComposerSnackbarHostState =
236248
public fun MessageComposer(
237249
messageComposerState: MessageComposerState,
238250
onSendMessage: (String, List<Attachment>) -> Unit,
251+
sendActionLabel: String? = null,
252+
saveActionLabel: String? = null,
239253
modifier: Modifier = Modifier,
240254
isAttachmentPickerVisible: Boolean = false,
241255
onAttachmentsClick: () -> Unit = {},
256+
attachmentsActionLabel: String? = null,
242257
onValueChange: (String) -> Unit = {},
243258
onAttachmentRemoved: (Attachment) -> Unit = {},
244259
onCancelAction: () -> Unit = {},
@@ -263,6 +278,8 @@ public fun MessageComposer(
263278
onAlsoSendToChannelChange = onAlsoSendToChannelChange,
264279
recordingActions = recordingActions,
265280
onActiveCommandDismiss = onActiveCommandDismiss,
281+
sendActionLabel = sendActionLabel,
282+
saveActionLabel = saveActionLabel,
266283
),
267284
)
268285
},
@@ -318,6 +335,7 @@ public fun MessageComposer(
318335
state = messageComposerState,
319336
isAttachmentPickerVisible = isAttachmentPickerVisible,
320337
onAttachmentsClick = onAttachmentsClick,
338+
onAttachmentsClickLabel = attachmentsActionLabel,
321339
),
322340
)
323341

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/*
2+
* Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
3+
*
4+
* Licensed under the Stream License;
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://github.com/GetStream/stream-chat-android/blob/main/LICENSE
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.getstream.chat.android.compose.ui.messages.composer.internal
18+
19+
import androidx.compose.runtime.Composable
20+
import androidx.compose.runtime.LaunchedEffect
21+
import androidx.compose.runtime.getValue
22+
import androidx.compose.runtime.mutableStateOf
23+
import androidx.compose.runtime.remember
24+
import androidx.compose.runtime.setValue
25+
import androidx.compose.ui.Modifier
26+
import androidx.compose.ui.platform.LocalView
27+
import androidx.compose.ui.res.stringResource
28+
import androidx.compose.ui.semantics.Role
29+
import androidx.compose.ui.semantics.contentDescription
30+
import androidx.compose.ui.semantics.role
31+
import androidx.compose.ui.semantics.semantics
32+
import io.getstream.chat.android.compose.R
33+
import io.getstream.chat.android.ui.common.state.messages.composer.RecordingState
34+
35+
/**
36+
* Sets the localized content description (which includes the press-and-hold gesture hint) and
37+
* tags the node as a button so TalkBack announces "Button".
38+
*
39+
* The actual recording is started by the press-and-hold gesture on `pointerInput`; lifecycle
40+
* announcements ("Recording started" / "Recording cancelled") are emitted by
41+
* [AnnounceRecordingTransitions], not by this modifier.
42+
*/
43+
@Composable
44+
internal fun Modifier.micButtonSemantics(): Modifier {
45+
val buttonDescription = stringResource(R.string.stream_compose_audio_recording_start)
46+
return this.semantics {
47+
contentDescription = buttonDescription
48+
role = Role.Button
49+
}
50+
}
51+
52+
/**
53+
* Observes [recordingState] transitions and emits one-shot TalkBack announcements:
54+
* - `Idle → Hold` announces "Recording started"
55+
* - `non-Idle → Idle` announces "Recording cancelled" only when [cancelRequested] is `true`
56+
*
57+
* The caller flips [cancelRequested] on when the user invokes a cancel or delete action; this
58+
* distinguishes a real cancellation from the confirm / send paths, which also transition back
59+
* to `Idle` but emit through a brief `Complete` state that Compose batches away.
60+
*
61+
* @param recordingState Current recording state to observe.
62+
* @param cancelRequested `true` when the user invoked a cancel or delete action since the last
63+
* transition was processed.
64+
* @param onTransitionConsumed Invoked only when a transition is actually announced, so the
65+
* caller can reset [cancelRequested] to `false`. Transitions that don't result in an
66+
* announcement leave the flag untouched.
67+
*/
68+
@Composable
69+
internal fun AnnounceRecordingTransitions(
70+
recordingState: RecordingState,
71+
cancelRequested: Boolean,
72+
onTransitionConsumed: () -> Unit,
73+
) {
74+
val view = LocalView.current
75+
val startedAnnouncement = stringResource(R.string.stream_compose_audio_recording_state_started)
76+
val cancelledAnnouncement = stringResource(R.string.stream_compose_audio_recording_state_cancelled)
77+
var previousWasIdle by remember { mutableStateOf(true) }
78+
val currentIsIdle = recordingState is RecordingState.Idle
79+
val currentIsHold = recordingState is RecordingState.Hold
80+
81+
LaunchedEffect(currentIsIdle, currentIsHold) {
82+
val wasIdle = previousWasIdle
83+
previousWasIdle = currentIsIdle
84+
var consumed = false
85+
when (recordingState) {
86+
is RecordingState.Hold -> if (wasIdle) {
87+
view.announceForAccessibility(startedAnnouncement)
88+
consumed = true
89+
}
90+
is RecordingState.Idle -> if (!wasIdle && cancelRequested) {
91+
view.announceForAccessibility(cancelledAnnouncement)
92+
consumed = true
93+
}
94+
is RecordingState.Locked,
95+
is RecordingState.Overview,
96+
is RecordingState.Complete,
97+
-> Unit
98+
}
99+
if (consumed) onTransitionConsumed()
100+
}
101+
}

stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/internal/AudioRecordingButton.kt

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,6 @@ import androidx.compose.ui.platform.LocalLayoutDirection
6363
import androidx.compose.ui.platform.testTag
6464
import androidx.compose.ui.res.painterResource
6565
import androidx.compose.ui.res.stringResource
66-
import androidx.compose.ui.semantics.contentDescription
67-
import androidx.compose.ui.semantics.semantics
6866
import androidx.compose.ui.tooling.preview.Preview
6967
import androidx.compose.ui.unit.IntOffset
7068
import androidx.compose.ui.unit.LayoutDirection
@@ -118,11 +116,31 @@ internal fun AudioRecordingButton(
118116
val showFloatingIcons = recordingState is RecordingState.Hold || recordingState is RecordingState.Locked
119117
val floatingMic = rememberFloatingMicState(recordingState)
120118

119+
var cancelRequested by remember { mutableStateOf(false) }
120+
val cancelTrackingActions = remember(recordingActions) {
121+
recordingActions.copy(
122+
onCancelRecording = {
123+
cancelRequested = true
124+
recordingActions.onCancelRecording()
125+
},
126+
onDeleteRecording = {
127+
cancelRequested = true
128+
recordingActions.onDeleteRecording()
129+
},
130+
)
131+
}
132+
133+
AnnounceRecordingTransitions(
134+
recordingState = recordingState,
135+
cancelRequested = cancelRequested,
136+
onTransitionConsumed = { cancelRequested = false },
137+
)
138+
121139
Box(modifier = modifier.applyIf(isRecording) { fillMaxWidth() }) {
122140
if (isRecording) {
123141
AudioRecordingContent(
124142
recordingState = recordingState,
125-
recordingActions = recordingActions,
143+
recordingActions = cancelTrackingActions,
126144
modifier = Modifier.fillMaxWidth(),
127145
)
128146
}
@@ -131,7 +149,7 @@ internal fun AudioRecordingButton(
131149
modifier = Modifier.align(Alignment.CenterEnd),
132150
isVisible = floatingMic.isVisible,
133151
recordingState = recordingState,
134-
recordingActions = recordingActions,
152+
recordingActions = cancelTrackingActions,
135153
floatingActive = floatingMic.isFloating,
136154
floatingOffset = floatingMic.offset,
137155
)
@@ -310,13 +328,11 @@ private fun MicButtonGestureArea(
310328
val showPressed = isFingerDown || hint.snackbarHostState.currentSnackbarData != null
311329
PressInteractionEffect(showPressed, pressOffset, interactionSource)
312330

313-
val buttonDescription = stringResource(R.string.stream_compose_audio_recording_start)
314-
315331
val buttonSize = if (isVisible) MicButtonSize else 0.dp
316332
Box(
317333
modifier = Modifier
318334
.size(buttonSize)
319-
.semantics { contentDescription = buttonDescription }
335+
.micButtonSemantics()
320336
.pointerInput(Unit) {
321337
awaitEachGesture {
322338
val down = awaitFirstDown()

0 commit comments

Comments
 (0)