Skip to content

Improve message list TalkBack accessibility#6488

Open
andremion wants to merge 6 commits into
developfrom
fix/compose-message-list-a11y
Open

Improve message list TalkBack accessibility#6488
andremion wants to merge 6 commits into
developfrom
fix/compose-message-list-a11y

Conversation

@andremion

@andremion andremion commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

AND-1180

Goal

Two TalkBack issues on the message list, bundled because they share a screen and a verification path.

  1. Quoted reply context is invisible to TalkBack. When focus lands on a quoted preview inside a reply, the bubble announces only the quoted sender and text. Screen-reader users have no signal about who replied to whom — they have to swipe to the reply row to piece the relationship together.
  2. Message rows announce "double-tap to activate" but the tap does nothing. For regular messages (no thread started yet, the common case) the row's combinedClickable exposes a click action to the accessibility tree even though onClick is guarded to a no-op. TalkBack promises an action that doesn't fire.

Implementation

Quoted reply context (QuotedMessage.kt)

Extend the existing accessibilityName override on QuotedMessageUserName to cover the in-list case (replyMessage != null). The quoted bubble's sender label now reads as one of:

  • "You replied to your message" — replier and quoted are both the current user (self-thread).
  • "You replied to {name}'s message" — current user replied to someone else.
  • "{name} replied to your message" — someone else replied to the current user.
  • "{name} replied to {other name}'s message" — someone else replied to a third party.

TalkBack then reads the bubble as "{relationship}, {quoted text}, double-tap to activate" — context first, content second. The composer-banner case (replyMessage == null) is unchanged. Two new string keys (stream_compose_quoted_message_replied_to_your_message, stream_compose_quoted_message_replied_to_their_message) translated across the 7 supported locales.

Row click hint (MessageContainer.kt)

Split the row's modifier into three branches by capability so the accessibility tree only carries the actions the row actually performs:

  • canOpenThread: keep combinedClickable with both onClick (open thread) and onLongClick (open actions, when available).
  • canOpenActions only: Modifier.pointerInput { detectTapGestures(onLongPress = …) } + Modifier.semantics(mergeDescendants = true) { onLongClick(label) }. Long-press still opens the actions menu; TalkBack reads only "double-tap and hold to show message options"; descendants merge into a single row focus.
  • Neither (deleted, uploading): empty Modifier.semantics(mergeDescendants = true) {} — the row stays a single focus target with no interaction hints.

Trade-off: the canOpenActions-only branch loses the press-down ripple that combinedClickable applied through its default indication. Sighted users still get feedback from the actions menu opening; the ripple loss is the smaller of the two regressions to keep.

Public API unchanged. The modifier is a local val in MessageContainer.

Testing

Enable TalkBack on the device and open a channel in the Compose sample.

Quoted reply announcement

  1. Have user A send a message; reply to it as user B. Focus the quoted preview inside B's reply.
    • Expected: "{A} replied to your message, {quoted text}, double-tap to activate".
  2. As user A, reply to one of A's own messages (self-thread). Focus the quoted preview.
    • Expected: "You replied to your message, {quoted text}, …".
  3. As user A, reply to one of user B's messages. Focus the quoted preview.
    • Expected: "You replied to {B}'s message, {quoted text}, …".
  4. As user C, look at a reply where user A replied to user B. Focus the quoted preview.
    • Expected: "{A} replied to {B}'s message, {quoted text}, …".
  5. Open the composer reply banner (tap Reply on a message). Focus the quoted preview in the banner.
    • Expected: announcement unchanged from before this PR (the in-list relational copy is not applied here).

Row click hint

  1. Find a message with no thread started in the list. Focus the row.
    • Expected: TalkBack reads the message content followed by "double-tap and hold to show message options". No "double-tap to activate" hint.
    • Long-press still opens the actions menu.
  2. Find a message with an existing thread (reply count > 0). Focus the row.
    • Expected: TalkBack reads the message followed by "double-tap to open thread" and the long-press hint.
    • Tap opens the thread; long-press opens actions.
  3. Find a deleted message in the list. Focus the row.
    • Expected: row receives a single TalkBack focus with no double-tap hint and no action.
  4. Send a new message so it briefly shows as uploading. Focus the row before it resolves.
    • Expected: same as deleted — single focus, no hint.

Localization smoke test

Switch the device language to one of the supported locales (es, fr, it, ja, ko, hi, in) and repeat the four quoted-reply cases. Verify the translated strings render with the names substituted in the correct positions.

Summary by CodeRabbit

  • New Features

    • Enhanced accessibility for quoted message replies with improved screen reader support, including context-aware descriptions for who replied to whose message
    • Improved touch interaction handling for message actions and thread navigation
  • Localization

    • Added quoted message reply translations for Spanish, French, Hindi, Indonesian, Italian, Japanese, and Korean

@andremion andremion added the pr:improvement Improvement label Jun 3, 2026
@andremion

Copy link
Copy Markdown
Contributor Author

@CodeRabbit review

@github-actions

github-actions Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

PR checklist ✅

All required conditions are satisfied:

  • Title length is OK (or ignored by label).
  • At least one pr: label exists.
  • Sections ### Goal, ### Implementation, and ### Testing are filled, or the PR is bot-authored.
  • An issue is linked (Linear ticket or GitHub issue), or the PR is bot-authored.

🎉 Great job! This PR is ready for review.

@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 5d958947-69b3-491b-92e3-6bd152f1aa21

📥 Commits

Reviewing files that changed from the base of the PR and between f7e674a and 150716c.

📒 Files selected for processing (10)
  • stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/QuotedMessage.kt
  • stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/MessageContainer.kt
  • stream-chat-android-compose/src/main/res/values-es/strings.xml
  • stream-chat-android-compose/src/main/res/values-fr/strings.xml
  • stream-chat-android-compose/src/main/res/values-hi/strings.xml
  • stream-chat-android-compose/src/main/res/values-in/strings.xml
  • stream-chat-android-compose/src/main/res/values-it/strings.xml
  • stream-chat-android-compose/src/main/res/values-ja/strings.xml
  • stream-chat-android-compose/src/main/res/values-ko/strings.xml
  • stream-chat-android-compose/src/main/res/values/strings.xml

Walkthrough

This PR enhances accessibility in the Compose UI by expanding quoted message reply context naming and refactoring message container interaction modifiers. Quoted message accessibility now selects localized strings based on reply ownership, supported by new localized string resources across ten languages. Message container interactions are refactored to separate gesture handling for thread vs. action scenarios with improved accessibility semantics.

Changes

Compose Accessibility Improvements

Layer / File(s) Summary
Quoted message accessibility and localization
src/main/java/io/getstream/chat/android/compose/ui/components/messages/QuotedMessage.kt, src/main/res/values/strings.xml, src/main/res/values-{es,fr,hi,in,it,ja,ko}/strings.xml
QuotedMessageUserName computes accessibilityName via a when that distinguishes composer banner vs. actual reply targets and selects string resources based on message ownership. New localized string resources provide formatted reply text for "replied to your/their message" across base and 9 locale variants.
Message container gesture and accessibility semantics
src/main/java/io/getstream/chat/android/compose/ui/messages/list/MessageContainer.kt
clickModifier refactored to branch on capabilities: thread-opening uses combinedClickable, action-opening uses pointerInput + detectTapGestures + semantics { onLongClick(...) }, fallback applies empty merged semantics. Imports updated for detectTapGestures and semantics APIs.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested reviewers

  • gpunto
  • VelikovPetar

Poem

🐰 A rabbit hops through Compose views so bright,
With quoted replies now labeled just right,
Accessibility strings in ten tongues sung clear,
And gestures that flow both far and near—
A dance of semantics, touch, and care,
Making messages accessible everywhere!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and accurately summarizes the main objective of the PR—improving TalkBack accessibility in the message list—matching the core changes across multiple files.
Description check ✅ Passed The PR description is comprehensive, covering goal, implementation details, testing instructions, and localization smoke tests. However, it is missing the required checklist items and UI change sections (screenshots/videos) from the template.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/compose-message-list-a11y

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

github-actions Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

SDK Size Comparison 📏

SDK Before After Difference Status
stream-chat-android-client 5.86 MB 5.86 MB 0.00 MB 🟢
stream-chat-android-ui-components 11.09 MB 11.09 MB 0.00 MB 🟢
stream-chat-android-compose 12.50 MB 12.51 MB 0.01 MB 🟢

@andremion andremion marked this pull request as ready for review June 3, 2026 10:57
@andremion andremion requested a review from a team as a code owner June 3, 2026 10:57
@andremion andremion enabled auto-merge (squash) June 3, 2026 10:57
@andremion andremion force-pushed the fix/compose-message-list-a11y branch from a3142a9 to fe87078 Compare June 3, 2026 15:07
@andremion andremion marked this pull request as draft June 3, 2026 16:36
auto-merge was automatically disabled June 3, 2026 16:36

Pull request was converted to draft

andremion added 5 commits June 8, 2026 11:15
When TalkBack focused on the quoted preview inside a reply, the bubble
announced only the quoted message's sender and text. Screen-reader
users had no signal about who replied to whom — they had to swipe to
the reply row to piece the relationship together.

Extend the `accessibilityName` override in `QuotedMessageUserName` to
handle the in-list case (`replyMessage != null`). The quoted bubble's
sender label now reads as one of:

- `"You replied to your message"` (both the replier and the quoted
  message are the current user — e.g. a self-thread)
- `"You replied to {name}'s message"` (current user replied to someone
  else)
- `"{name} replied to your message"` (someone else replied to the
  current user)
- `"{name} replied to {other name}'s message"` (someone else replied
  to a third party's message)

TalkBack then reads the bubble as "{relationship}, {quoted text},
double-tap to activate" — context first, content second.

Two new strings (`stream_compose_quoted_message_replied_to_your_message`
and `stream_compose_quoted_message_replied_to_their_message`) translated
across the 7 supported locales. The composer-banner case
(`replyMessage == null`) is unchanged.
The row's `combinedClickable` was always enabled when either
`canOpenThread` or `canOpenActions` was true, with `onClick` guarded to
fire `onThreadClick` only when a thread existed. For regular messages
(no thread started yet, common case) `onClick` was a no-op but the
`combinedClickable` still exposed a click action to the accessibility
tree, so TalkBack announced "double-tap to activate" — promising an
action that didn't fire. The long-press path worked fine.

Refactor the modifier into three branches by row capability, so the
accessibility tree only carries the actions the row actually performs:

- `canOpenThread`: keep `combinedClickable` with both `onClick` (open
  thread) and `onLongClick` (open actions, if available).
- `canOpenActions` only: use `Modifier.pointerInput { detectTapGestures
  (onLongPress = ...) }` plus `Modifier.semantics(mergeDescendants =
  true) { onLongClick(label) }` — long-press still opens the menu,
  TalkBack reads only "double-tap and hold to show message options",
  and descendants merge into a single row focus.
- Neither (deleted, uploading): empty `Modifier.semantics
  (mergeDescendants = true) {}` so the row stays a single focus
  target for TalkBack with no interaction hints.

Trade-off: the `canOpenActions`-only case loses the press-down ripple
that `combinedClickable` applies through its default `indication`.
Sighted users still get feedback from the actions menu opening; SR
users get a TalkBack hint that matches what tapping actually does. The
ripple loss is the smaller of the two regressions to keep.

Public API unchanged; the modifier is a local val in `MessageContainer`.
The new `accessibilityName` logic in `QuotedMessageUserName` only fires
when `replyMessage != null`, but every existing snapshot test
constructs the composable with `replyMessage = null`. Sonar reported 6
uncovered lines and 2 uncovered conditions on the file as a result.

Add two preview composables (`QuotedMessageReplyByMeToOther`,
`QuotedMessageReplyByOtherToMe`) plus matching Paparazzi tests. The
pair is chosen so each new conditional branch is hit in both
directions:

- Case A (me replies to other): `replyMessage.isMine = true`,
  `message.isMine = false` -> "You replied to {other}'s message".
- Case B (other replies to me): `replyMessage.isMine = false`,
  `message.isMine = true` -> "{other} replied to your message".

The file crossed the detekt `TooManyFunctions` threshold (21/20) once
the two preview wrappers were added, so apply the existing project
pattern of `@file:Suppress("TooManyFunctions")` (already used on
`ChannelItem`, `MessageInput`, etc.).
CI's clean-cache `apiCheck` picked up two new `ComposableSingletons$QuotedMessageKt`
lambda accessors emitted for the `@Preview` wrappers added in the previous commit.
Local `apiCheck` had served a cached `apiBuild` output and missed the diff.
The previous commit keyed the gesture detector on `message.id`:

    Modifier.pointerInput(message.id) {
        detectTapGestures(onLongPress = { onLongItemClick(message) })
    }

`pointerInput` re-launches its block only when its key changes, so for
the lifetime of a message row (id stable) the captured `message` and
`onLongItemClick` references stay frozen to the values from the first
composition. When the message updates (for example, after the user
adds a reaction), the long-press still fires with the pre-reaction
message — the actions menu opens against stale state, and the menu's
reaction-toggle decides "add" instead of "remove" because the captured
message has no reaction yet. The user-driven add->remove E2E flows
(`test_deletesReaction`, `test_removesReaction_whenUnReactingToParticipantsMessage`)
broke for that reason.

`combinedClickable` (used in the `canOpenThread` branch) does not have
this problem because its handler lambdas are reinstalled on every
recomposition.

Capture the message and the long-click handler with
`rememberUpdatedState` and key `pointerInput` on `Unit`. The gesture
detector survives recomposition, but every fire reads the current
values, so the menu always opens against the latest message state.
@andremion andremion force-pushed the fix/compose-message-list-a11y branch from fe87078 to 4cb4e92 Compare June 8, 2026 10:17
@andremion andremion marked this pull request as ready for review June 8, 2026 10:33
@andremion andremion enabled auto-merge (squash) June 8, 2026 15:30
@sonarqubecloud

sonarqubecloud Bot commented Jun 8, 2026

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr:improvement Improvement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant