diff --git a/.claude/plans/2026-05-04-accessibility.md b/.claude/plans/2026-05-04-accessibility.md new file mode 100644 index 0000000000..23b0c01772 --- /dev/null +++ b/.claude/plans/2026-05-04-accessibility.md @@ -0,0 +1,364 @@ +# Accessibility (a11y) Implementation Plan — `stream-chat-react-native` + +> **Note on plan location:** Per project convention, plans live at the repo's `.claude/plans/`. This file was created at the harness-mandated path during plan mode; once approved, copy it into `stream-chat-react-native/.claude/plans/` so it's checked in alongside the code. + +> **Aligned with [`stream-chat-react#3146`](https://github.com/GetStream/stream-chat-react/pull/3146)** ("feat(a11y): improve accessibility across dialogs, forms, menus, media, and focus flows" — merged, 194 files, +7057 / −681). This RN plan mirrors React's folder structure, primitive APIs, and i18n approach where the platforms agree, and explicitly flags the places where mobile (iOS/Android) requires different mechanisms (gestures, modal focus, no keyboard navigation, imperative announcer instead of DOM live regions). + +--- + +## Context + +The RN SDK has minimal a11y today: + +- `accessibilityLabel` is the only a11y prop in meaningful use (~73 occurrences, hardcoded English). +- Zero usage of `accessibilityRole`, `accessibilityState`, `accessibilityHint`, `accessibilityValue`, `accessibilityActions`, `accessibilityLiveRegion`, `accessibilityViewIsModal`, `onAccessibilityAction`. +- ~252 interactive surfaces and ~109 Avatar usages largely without semantic data. +- All overlays/sheets (`BottomSheetModal`, `MessageOverlayWrapper`, `AttachmentPicker`, `MessageReactionPicker`) lack modal focus-trap props. +- Critical gesture-only flows have no screen-reader/keyboard alternative: `MessageMenu` long-press, audio recorder (`Gesture.LongPress` + swipe-to-lock), `ImageGallery` (multi-gesture pan/pinch/double-tap), inline gallery long-press menu. +- Loading/empty/error indicators (`LoadingDots`, `LoadingIndicator`, `EmptyStateIndicator`, `LoadingErrorIndicator`) animate/render without `accessibilityLiveRegion`, so SR users get no signal that state is loading or failed. +- `ProgressControl/{ProgressBar,ProgressThumb,WaveProgressBar}` (used by audio attachment, audio recording preview, image-gallery video, polls) lacks `accessibilityRole="progressbar"` + `accessibilityValue`. +- `AITypingIndicatorView` ("Thinking…" / "Generating…") animates without a polite live region — AI state transitions go unannounced. +- Channel/Thread preview delivery icons (`ChannelMessagePreviewDeliveryStatus`, `ThreadMessagePreviewDeliveryStatus`) ship without labels even though `Message/MessageItemView/MessageStatus` already labels its variant. +- `Reply` / `ReplyMessageView` quoted-message previews are tappable but unlabeled and have no role. + +Goal: bring RN to parity with the React SDK's a11y baseline using the same primitive shapes, AND fill in the mobile-only gaps (gesture alternatives, modal a11y props, imperative announcer infra) so the SDK is usable with **VoiceOver (iOS)** and **TalkBack (Android)** out of the box. + +--- + +## Confirmed decisions + +1. **Translate `a11y/*` keys into all 12 RN locales** — `de`, `en`, `es`, `fr`, `he`, `hi`, `it`, `ja`, `ko`, `nl`, `pt-br`, `ru`, `tr`. (`he.json` exists in RN but not React — translate that too.) Mirrors the React PR's policy. `validate-translations` enforces no empty values. +2. **Drop the `customAccessibilityLabels` override map.** Integrators override `a11y/*` keys through the existing Streami18n mechanism — no new API surface. +3. **Keep the minimal `` config** — RN-specific because mobile has gesture-only flows (audio hold-to-record, gallery pinch/pan) that web doesn't. Flat config with positive `'auto' | 'always' | 'never'` enums for the gesture-alternative toggles (no nested `componentOverrides`, no negative `disable*` booleans — see "Architecture" below). Documented as an intentional deviation from React. +4. **A11y is OFF by default — integrators opt in via ``.** Keeps zero-config behavior identical to today's SDK so existing integrators see no change. Once enabled, sensible defaults take over (auto-adapt to SR, announce new messages, etc.). The Phase 5 Reassure benchmark will measure the cost of `enabled: true` so we can confidently flip the default to `true` in a future release. + +--- + +## Mapping React (web) → React Native + +| React (web) | React Native | +|---|---| +| `aria-label` | `accessibilityLabel` | +| `aria-labelledby` / `aria-describedby` | (no equivalent — fold into a single composed `accessibilityLabel`) | +| `role="dialog"` + `aria-modal="true"` | `accessibilityViewIsModal={true}` (iOS) + `importantForAccessibility="no-hide-descendants"` on background siblings (Android) | +| `aria-live="polite"` + DOM region | `AccessibilityInfo.announceForAccessibility` (iOS+Android) AND `accessibilityLiveRegion="polite"` on hidden View (Android backup) | +| `tabIndex` + Enter/Space handler | `accessibilityRole="button"` + `onAccessibilityAction` (rotor on iOS, local context menu on Android) | +| `prefers-reduced-motion` (CSS+JS) | `AccessibilityInfo.isReduceMotionEnabled()` + event listener | +| `aria-hidden="true"` | `accessibilityElementsHidden={true}` (iOS) + `importantForAccessibility="no-hide-descendants"` (Android) | +| `aria-selected` / `aria-checked` | `accessibilityState={{ selected, checked, disabled, busy, expanded }}` | +| `focus()` / focus restore | `AccessibilityInfo.setAccessibilityFocus(reactTag)` via `findNodeHandle` | +| `jest-axe` | `@testing-library/react-native` semantic queries (`getByRole`, `getByLabelText`); no axe equivalent for RN | +| `` | Not needed — RN announcer is imperative, no hidden DOM node required | +| `` | Not applicable — mobile has no Tab key | +| Roving focus (`a11yUtils.ts`) | Not applicable — accessibility tree order is implicit; rely on view hierarchy | + +--- + +## Architecture (mirrors React folder shape) + +### `package/src/a11y/` — utilities + low-level hooks + +Mirrors `stream-chat-react/src/a11y/`. Smaller in RN because keyboard helpers don't apply. + +- `a11yUtils.ts` — `composeAccessibilityLabel(...parts)`, `formatAccessibilityValue({min, max, now})`, `mergeAccessibilityActions(...)`. (No roving-focus helper — N/A on RN.) +- `hooks/useResolvedModalAccessibilityProps.ts` — returns the `{ accessibilityViewIsModal, importantForAccessibility, accessibilityRole }` triple correctly for the active platform; equivalent of React's `useResolvedModalAriaProps`. +- `hooks/useScreenReaderEnabled.ts` — subscribes to `AccessibilityInfo.screenReaderChanged`. RN-specific (web doesn't expose this). +- `hooks/useReducedMotionPreference.ts` — same name as React's hook; subscribes to `AccessibilityInfo.reduceMotionChanged`. +- `__tests__/a11yUtils.test.ts` — parity unit tests. + +### `package/src/components/Accessibility/` — runtime announcement infra + +Mirrors `stream-chat-react/src/components/Accessibility/`. Same component graph, mobile implementations swap DOM live regions for `AccessibilityInfo.announceForAccessibility`. + +- `AccessibilityAnnouncer.tsx` — Provider equivalent of React's `AriaLiveRegion`. Exposes a queue with two priorities (`polite` / `assertive`); flushes through `AccessibilityInfo.announceForAccessibility` on iOS, and through a hidden Android `` (rendered absolutely off-screen) for TalkBack reliability. Uses the same sequence/timeout pattern from `AriaLiveRegion.tsx` so repeat messages still re-announce. +- `useAccessibilityAnnouncer.ts` — `useAccessibilityAnnouncer()` returns `(message, priority?) => void`. Same shape as React's `useAriaLiveAnnouncer`. +- `NotificationAnnouncer.tsx` — same component name and same `buildNotificationAnnouncement` / `notificationFilter` props as React. Source of notifications differs: in RN we wire to `useChannelContext().error` and `useChatContext().connectionState` events (no shared notifications queue exists today; building one is out of scope for this plan, so we adapt the source). +- `hooks/useIncomingMessageAnnouncements.ts` — direct port of React's hook: throttles to 1 announcement / sec, batches: 1 message → "New message from {{user}}", N>1 → "{{count}} new messages". Same params (`channel`, `ownUserId`, `activeThreadId`, `threadList`). +- `hooks/__tests__/useIncomingMessageAnnouncements.test.tsx` — parity tests. +- `__tests__/AccessibilityAnnouncer.test.tsx` — sequence + priority + Android-fallback tests. +- `index.ts` — barrel. + +### `` integration + +Mount `` inside the existing provider stack (between `ThemeProvider` and `ChannelsStateProvider`). Mount `` once inside `Channel` so it can subscribe to the active channel's errors. Equivalent to where the React PR mounts `AriaLiveRegion` + `NotificationAnnouncer` in the Chat root. + +### Native handler — RN-specific + +Web has direct DOM access; RN needs platform abstraction. Extend [package/src/native.ts](package/src/native.ts) the same way `Audio`, `Sound`, etc. are registered: + +```ts +type AccessibilityInfoHandlers = { + isScreenReaderEnabled: () => Promise; + isReduceMotionEnabled: () => Promise; + announceForAccessibility: (message: string) => void; + setAccessibilityFocus: (reactTag: number) => void; + addEventListener: ( + eventName: 'screenReaderChanged' | 'reduceMotionChanged', + handler: (enabled: boolean) => void, + ) => { remove: () => void }; +}; +``` + +Both `native-package/` and `expo-package/` register implementations against React Native's built-in `AccessibilityInfo` (identical on both — no platform divergence inside the handler). Falls back to no-op stubs if not registered, so the SDK degrades gracefully (matching the existing `fail()` pattern in [native.ts](package/src/native.ts)). + +### `` — minimal RN-only config (deviation from React) + +React did not add a Chat-level config. RN needs one because: +- Some gestures (audio recorder hold-to-record, gallery pinch/pan) have no inherent a11y; integrators may want to opt out of the SDK's automatic alternatives if they ship their own. +- Integrators may need to disable announcement behavior to avoid duplicate announcers when embedding the SDK in a host app that already announces. + +Type (flat, positive enums — no nesting, no `disable*` flags): + +```ts +/** Tri-state for gesture-alternative toggles. */ +export type A11yMode = 'auto' | 'always' | 'never'; + +export type AccessibilityConfig = { + /** Master toggle. Default FALSE — integrators must opt in. When false, the SDK behaves exactly as it does today; no a11y attributes are added, no announcer mounts, no listeners attached. */ + enabled?: boolean; + + /** For testing — force "screen reader on" UI even when no SR is active. Default false. */ + forceScreenReaderMode?: boolean; + + /** Announce new messages via the announcer. Default true (when `enabled`). */ + announceNewMessages?: boolean; + /** Announce typing indicator. Default false (noisy on mobile). */ + announceTypingIndicator?: boolean; + /** Announce connection state (offline/online). Default true. */ + announceConnectionState?: boolean; + + // RN-specific gesture-alternative toggles. 'auto' = swap UI when SR is on; + // 'always' = show accessible variant for everyone; 'never' = SDK never swaps + // (integrator handles it). All default to 'auto'. + audioRecorderTapMode?: A11yMode; + imageGalleryScreenReaderMode?: A11yMode; + messageActionsTrigger?: 'long-press' | 'auto' | 'always-button'; +}; +``` + +Naming note: the previous draft had a nested `componentOverrides` field. Dropped — it collided with the existing `WithComponents` override pattern (commit `15dd5e10d`) and the negative `disable*` flags were hard to reason about. If an integrator replaces a component entirely via `WithComponents`, the SDK's a11y code for that component never runs anyway. The flat enums above only matter when the integrator keeps the SDK's component but wants different gesture behavior. + +Lives in a small `AccessibilityContext` under `package/src/contexts/accessibilityContext/AccessibilityContext.tsx` following the [`ChatConfigContext` template](package/src/contexts/chatConfigContext/ChatConfigContext.tsx). Default value is `{ enabled: false }` — every other field is ignored unless `enabled` is true. Opt-in is a single flag flip: ``. + +When `enabled: false`, the implementation must short-circuit cleanly: +- No `` mount; `useAccessibilityAnnouncer()` returns a noop. +- No `` mount. +- No `useIncomingMessageAnnouncements` subscription on `channel.on('message.new')`. +- No `AccessibilityInfo` event listeners. +- Component-level a11y props (`accessibilityRole`, `accessibilityState`, etc.) still render — these are passed to native views and consulted only by VO/TalkBack when active, so they cost essentially nothing for sighted users. **Exception: `accessibilityLabel` strings composed via `t('a11y/...')`.** Skip the `t()` call when `enabled: false` to avoid 1000 i18next lookups in a busy `MessageList`. A small helper hook `useA11yLabel(key, params)` returns `undefined` when disabled and the translated string when enabled — components pass its return value straight to `accessibilityLabel`. + +### i18n — `a11y/*` namespace (matches React) + +React used `t('a11y/...')` keys for parity across SDKs. RN adopts the same prefix even though "ARIA" is web-specific — the value is cross-SDK consistency for translators and integrator docs. Add keys to all 12 RN locales: `de.json`, `en.json`, `es.json`, `fr.json`, `hi.json`, `it.json`, `ja.json`, `ko.json`, `nl.json`, `pt-br.json`, `ru.json`, `tr.json`. (`he.json` exists in RN but not React — translate too.) Run `yarn build-translations` to keep the i18next-cli sync intact, then `yarn lint` to pass `validate-translations` (no empty values). + +Example shared keys (from React PR — adopt verbatim where the string is platform-neutral): +``` +a11y/Avatar of {{name}} +a11y/{{count}} new messages +a11y/New message from {{user}} +a11y/Open message actions +a11y/Send message +a11y/Voice message recording. Hold to record. +a11y/Reaction {{emoji}} by {{count}} users +a11y/Reply to {{user}} +Anonymous ← shared with React +``` + +--- + +## Phased Implementation + +### Phase 1 — Foundation _(1 commit)_ + +Mirrors React PR's "Screen reader foundations" section. + +1. Create `package/src/a11y/` (utils + hooks listed above). +2. Create `package/src/components/Accessibility/` with `AccessibilityAnnouncer`, `useAccessibilityAnnouncer`, `NotificationAnnouncer`, `useIncomingMessageAnnouncements`, `index.ts`. +3. Create `package/src/contexts/accessibilityContext/AccessibilityContext.tsx` (provider + `useAccessibilityContext()`), following the [`ChatConfigContext`](package/src/contexts/chatConfigContext/ChatConfigContext.tsx) template. +4. Add `accessibility?: AccessibilityConfig` prop to `ChatProps` in [package/src/components/Chat/Chat.tsx](package/src/components/Chat/Chat.tsx). Mount `` and `` inside the existing provider stack. +5. Mount `` once inside `Channel` so it can subscribe to per-channel errors. +6. Extend [package/src/native.ts](package/src/native.ts) with `AccessibilityInfo` handlers + `isAccessibilityInfoAvailable()` check (mirror `isAudioRecorderAvailable()` style). +7. Register handlers in `package/native-package/src/handlers/AccessibilityInfoHandler.ts` and `package/expo-package/src/handlers/AccessibilityInfoHandler.ts` — both wrap RN's `AccessibilityInfo`. +8. Add `a11y/*` keys to all 12 locales; run `yarn build-translations`. +9. Update [package/src/contexts/index.ts](package/src/contexts/index.ts) and `package/src/index.ts` to export `AccessibilityContext`, `AccessibilityConfig`, `useAccessibilityAnnouncer`, hooks. (Same exports React added in `src/components/Accessibility/index.ts` and `src/components/index.ts`.) +10. Add unit tests with parity to React's: `AriaLiveRegion.test.tsx` → `AccessibilityAnnouncer.test.tsx`, `useIncomingMessageAnnouncements.test.tsx` → port verbatim. + +**Done when:** integrators can consume `useAccessibilityAnnouncer()` and `useScreenReaderEnabled()`, and incoming-message announcements fire on real devices. + +### Phase 2 — Base UI primitives _(1 commit)_ + +Mirrors React PR's `Avatar`, `BaseImage`, `Button/PlayButton`, `Form/{TextInput,SwitchField,Dropdown,NumericInput}`, `Icons/BaseIcon`, `Dialog/{Alert,Prompt,Viewer,Callout,ContextMenu,DialogPortal,DialogAnchor}`. + +> **Note on folder split:** primitives live in BOTH `package/src/components/ui/` (low-level: Avatar, Badge, Button, Input, GiphyChip, SpeedSettingsButton, VideoPlayIndicator) AND `package/src/components/UIComponents/` (composite: BottomSheetModal, ImageBackground, PortalWhileClosingView, SwipableWrapper, Spinner, SafeAreaViewWrapper). Touch both during this phase. + +1. **Avatar** — `package/src/components/Avatar/Avatar.tsx`, `UserAvatar.tsx`, `ChannelAvatar.tsx`, `AvatarStack.tsx`, plus the lower-level `package/src/components/ui/Avatar/`. Add `accessibilityRole="image"`, `accessibilityLabel={t('a11y/Avatar of {{name}}', {name})}`. Allow integrators to override the label via prop. (React's `Avatar.tsx` made the same change; mirror.) +2. **Button / IconButton** (`package/src/components/ui/Button.tsx`, plus icon-button equivalents): `accessibilityRole="button"`, propagate `accessibilityLabel`/`accessibilityHint`/`accessibilityState={{disabled, busy}}` to `Pressable`. Hit-slop expanded to 44×44 (Apple HIG) when smaller. Mirror React's `BaseIcon.tsx` change to mark decorative SVGs with `accessibilityElementsHidden`. +3. **Input** (`package/src/components/ui/Input.tsx`): wire `accessibilityLabel`, `accessibilityHint`, `accessibilityState={{ disabled, selected }}`. Validation/error state uses the announcer (RN's substitute for `aria-describedby`). Same shape as React's `TextInput.tsx` and `NumericInput.tsx`. +4. **Switch** (already a native control on RN — verify it surfaces label/state): mirror React's `SwitchField.tsx` semantics (`accessibilityRole="switch"`, `accessibilityState={{ checked }}`). +5. **Dropdown / autocomplete picker** equivalents — `package/src/components/AutoCompleteInput/`: `accessibilityRole="list"` on container, items get role + `accessibilityState={{ selected }}`. Same intent as React's `Dropdown.tsx` + roving focus, minus the keyboard nav. +6. **Modal / overlay primitives** — `package/src/components/UIComponents/BottomSheetModal.tsx`, `BottomSheetCompatibility/{BottomSheet,BottomSheetFlatList,BottomSheetTouchableOpacity}.tsx`, `StreamBottomSheetModalFlatList.tsx`. Use `useResolvedModalAccessibilityProps` to apply `accessibilityViewIsModal` + `importantForAccessibility`. Set initial focus to the modal title via `setAccessibilityFocus`. Restore focus to invoking trigger on close. Account for the dynamic snap-points behavior added in `7a7f927ae` — re-issue `setAccessibilityFocus` after resize so VO/TalkBack keeps focus inside the sheet. Equivalent of React's `Alert`/`Prompt`/`Viewer`/`DialogPortal` work. +7. **Indicators** — `package/src/components/Indicators/{LoadingDots,LoadingDot,LoadingIndicator,LoadingErrorIndicator,EmptyStateIndicator}.tsx`. `LoadingDots`/`LoadingIndicator`: wrap in a hidden View with `accessibilityLiveRegion="polite"` (Android) and announce `t('a11y/Loading…')` once via `useAccessibilityAnnouncer` on mount; suppress repeats. `EmptyStateIndicator`/`LoadingErrorIndicator`: static `accessibilityLabel` + `accessibilityRole="text"` (or `"alert"` for error variant). Hide the visual dots/spinner from AT (`accessibilityElementsHidden={true}`) so the announcement isn't duplicated. +8. **ProgressControl** — `package/src/components/ProgressControl/{ProgressBar,ProgressControl,ProgressThumb,WaveProgressBar,StableDurationLabel}.tsx`. Add `accessibilityRole="progressbar"` + `accessibilityValue={{ min, max, now, text }}`. When the consumer is interactive (audio scrub, gallery video, poll-result reveal), expose `accessibilityActions: [{name:'increment'}, {name:'decrement'}]` so rotor users can seek. `ProgressThumb` becomes `accessibilityRole="adjustable"` when draggable. Single shared component covers AudioAttachment, AudioRecordingPreview, ImageGalleryVideoControl, and PollOption — fix once, propagate everywhere. + +**Done when:** every Avatar, Button, Input, IconButton, modal, dropdown, indicator, and progress control in the SDK has correct semantics and modal focus-trapping works on both platforms. + +### Phase 3 — Critical-path components _(2 commits)_ + +#### 3a. Message + MessageMenu + Reactions + +Mirrors React PR's `Message/MessageUI.tsx`, `Message/MessageText.tsx`, `MessageActions/*`, `Reactions/{MessageReactions,ReactionSelector,MessageReactionsDetail}`. + +- **`package/src/components/Message/Message.tsx`** — container `accessibilityRole="article"`. Composed `accessibilityLabel` mirroring React's pattern (sender + timestamp + text + reactions summary, capped at top-3 reactions). Long-press → `accessibilityActions` exposed to the rotor (iOS) and Android local context menu: `[{ name:'activate', label:t('a11y/Open message actions') }, { name:'react' }, { name:'reply' }, { name:'copy' }]`. Visibility of the alternative "More actions" button is driven by `accessibility.messageActionsTrigger`: `'long-press'` → hidden; `'auto'` (default) → shown when SR is on; `'always-button'` → shown for everyone. +- **`package/src/components/MessageMenu/MessageActionList.tsx`** — wrapper `accessibilityRole="menu"`, items `accessibilityRole="menuitem"`. Same as React's `MessageActions.defaults.tsx`. +- **`package/src/components/MessageMenu/MessageReactionPicker.tsx`** — `accessibilityRole="grid"` on emoji list, each emoji `accessibilityLabel` + `accessibilityState={{ selected }}`. Same shape as React's `ReactionSelector.tsx`. +- **`package/src/components/Reaction/ReactionList*.tsx`** — pills get `accessibilityRole="button"`, `accessibilityLabel={t('a11y/Reaction {{emoji}} by {{count}} users', ...)}`, `accessibilityState={{ selected: isOwnReaction }}`. Same as React's `MessageReactions.tsx`. +- **`package/src/components/Message/MessageOverlayWrapper.tsx`** — `useResolvedModalAccessibilityProps`, focus management on open. +- **`package/src/components/Reply/{Reply,ReplyMessageView}.tsx`** — quoted-message preview. `accessibilityRole="button"` when tappable (jump-to-original), composed `accessibilityLabel` of form `t('a11y/Reply to {{user}}: {{preview}}')`. The preview's inner avatar/text re-uses the labels from Phase 2. +- **`package/src/components/Message/MessageItemView/MessageStatus.tsx`** — already has labels for `Read`/`Delivered`/`Sending`/`Sent`. Migrate the strings to `a11y/*` keys for parity. + +#### 3b. MessageList + MessageInput + AudioRecorder + +Mirrors React PR's `MessageList/{MessageList,VirtualizedMessageList,UnreadMessagesNotification,ScrollToLatestMessageButton}`, `MessageComposer/*`, `MediaRecorder/AudioRecorder/*`. + +- **`package/src/components/MessageList/MessageList.tsx`** / `MessageFlashList.tsx` — wrap announcement of new messages through `useIncomingMessageAnnouncements({ channel, ownUserId })`. Direct port of React's hook usage. Gated on `accessibility.announceNewMessages`. +- **`package/src/components/MessageList/InlineUnreadIndicator.tsx`** / `ScrollToBottomButton.tsx` — labels via `t()`. Same as React's `UnreadMessagesNotification.tsx` / `ScrollToLatestMessageButton.tsx`. +- **`package/src/components/MessageList/TypingIndicator.tsx`** — when `announceTypingIndicator` is true, debounced announcement of "X is typing". (React enabled by default; RN defaults to false because TalkBack/VoiceOver chatter on mobile is more disruptive.) +- **`package/src/components/MessageInput/MessageComposer.tsx`** — TextInput labels + hint, send/attach/audio buttons labeled. Validation routes through announcer with priority `assertive`. +- **`package/src/components/MessageInput/AudioRecorder/AudioRecordingButton.tsx`** — RN-specific (no React analog because web doesn't have hold-to-record). Behavior driven by `accessibility.audioRecorderTapMode`: `'auto'` (default) → swap to tap-toggle when `useScreenReaderEnabled()` is true; `'always'` → tap-toggle for everyone; `'never'` → keep `Gesture.LongPress` always. In tap mode: tap → start, tap → stop, tap → send. Lock-by-swipe replaced with explicit "Lock recording" button. Each state announces through the announcer ("Recording, {duration}", "Recording locked", "Send recording", "Cancel recording"). + +### Phase 4 — Secondary components _(2 commits)_ + +#### 4a. Channels & threads + +Mirrors React PR's `ChannelListItem/{ChannelListItemUI,ChannelListItemActionButtons*}`, `Threads/ThreadList/*`, `TypingIndicator/*`. + +- **`package/src/components/ChannelList/ChannelList.tsx`** + `ChannelPreview/ChannelPreviewView.tsx` — items `accessibilityRole="button"`, composed label. Swipe actions get `accessibilityActions`. +- **`package/src/components/ChannelPreview/ChannelMessagePreviewDeliveryStatus.tsx`** + `package/src/components/ThreadList/ThreadMessagePreviewDeliveryStatus.tsx` — port the labeled-icon pattern from `MessageItemView/MessageStatus.tsx` so preview rows announce delivery state to SR users. +- **`package/src/components/Thread/Thread.tsx`** + `ThreadList/ThreadList.tsx` — reply count → `t('a11y/{{count}} reply', { count })`. Unread banner uses the announcer. +- **`package/src/components/Channel/Channel.tsx`** — connection state changes (offline → online) routed through ``. `useIncomingMessageAnnouncements` lifts here. +- **`package/src/components/AITypingIndicatorView/AITypingIndicatorView.tsx`** — wrap in `accessibilityLiveRegion="polite"`. On state transitions (`Thinking…` → `Generating…` → idle), call `useAccessibilityAnnouncer().announce(t('a11y/AI is {{state}}', {state}))` with debounce so transitions don't spam VO/TalkBack. Hide the animated dots from AT (`accessibilityElementsHidden`) so the announcement is the only signal. + +#### 4b. Media, attachments, polls, autocomplete + +Mirrors React PR's `Attachment/*`, `AudioPlayback/*`, `Gallery/*`, `Poll/*`, `Search/*`, `MessageComposer/AttachmentSelector/*`, `TextareaComposer/SuggestionList/*`. + +- **`package/src/components/Attachment/ImageGallery/ImageGallery.tsx`** + `useImageGalleryGestures.tsx` — RN-specific (web has no equivalent). Behavior driven by `accessibility.imageGalleryScreenReaderMode`: `'auto'` (default) → swap when SR is on; `'always'` → swap for everyone; `'never'` → never swap. In swap mode: hide the gesture surface (`accessibilityElementsHidden={true}`) and render a tap-driven control set (Previous, Next, Zoom in, Zoom out, Close). Pinch/pan/double-tap kept for sighted users; `accessibilityActions` (Zoom, Reset) exposed for rotor users regardless of mode. +- **`package/src/components/Attachment/Gallery.tsx`** — inline thumbnails: button role + label. Long-press menu trigger gets a visible "More" button when SR is on. +- **`package/src/components/Attachment/AudioAttachment.tsx`** — match React's `ProgressBar.tsx` + `progressBarA11y.ts`: `accessibilityValue={{ min:0, max:duration, now:currentTime, text:"{currentTime} of {duration}" }}` on the seek bar, `accessibilityActions: [{ name:'increment' }, { name:'decrement' }]` for rotor seek. Play/pause `accessibilityState={{ selected: isPlaying }}`. +- **`package/src/components/AttachmentPicker/AttachmentPicker.tsx`** — `useResolvedModalAccessibilityProps`. Selectable photos rendered as a grid with `accessibilityRole="image"` items + `accessibilityState={{ selected }}`. Mirrors React's `AttachmentSelector.tsx`. +- **`package/src/components/Poll/Poll.tsx`** + `PollOption.tsx` + `PollResults.tsx` — single-select: `accessibilityRole="radio"`. Multi-select: `"checkbox"`. `accessibilityState={{ selected, checked }}`. Result animations announce winners via announcer when poll closes. Mirrors React's `PollOptionSelector.tsx` + `PollResults/*`. +- **`package/src/components/AutoCompleteInput`** — suggestion list `accessibilityRole="list"`; items `accessibilityRole="button"`. TextInput exposes `accessibilityHint` describing trigger characters (`@`, `/`). Mirrors React's `SuggestionList.tsx`. + +### Phase 5 — Testing, lint, docs, AI maintenance skill _(1 commit)_ + +Mirrors React PR's testing additions, AI skill (`.cursor/skills/accessibility/SKILL.md`), reduced-motion CSS, and example-app skip nav (the last is N/A on mobile). + +1. **Test patterns** — add helpers under `package/src/mock-builders/accessibility/` — `expectAccessibleButton(node, {label, role, state})`, `mockScreenReaderEnabled(boolean)`. Add a11y assertions to existing test suites for Message, MessageList, ChannelPreview, BottomSheetModal as exemplars (parity with React's targeted suites). Full coverage tracked as follow-up. +2. **Lint rules** — extend [package/eslint.config.mjs](package/eslint.config.mjs) with `eslint-plugin-react-native-a11y`: warn level for missing labels on `Pressable`/`TouchableOpacity`, error level for icon-only buttons. Set `--max-warnings 0` so it must pass before merge. +3. **Integration smoke test** — under `examples/SampleApp/`, boot Chat with `accessibility={{ enabled: true, forceScreenReaderMode: true }}` and verify AudioRecorder, ImageGallery, and Message render their accessible variants. +4. **Reassure perf benchmark** — add a Reassure (`reassure` npm package) test that renders a 1000-row `MessageList` twice: once with `accessibility={{ enabled: false }}` (today's behavior), once with `accessibility={{ enabled: true }}`. Assert: render time delta <5%, re-render delta <2%. Numbers feed the future "flip default to `true`" decision. +5. **AI maintenance skill** — `.claude/skills/accessibility/SKILL.md` (RN-equivalent of React's [`.cursor/skills/accessibility/SKILL.md`](https://github.com/GetStream/stream-chat-react/blob/master/.cursor/skills/accessibility/SKILL.md)). Same structure, RN tool names: native semantics first (`Pressable`, `TextInput`, `Switch`, `Image`); use `accessibilityRole` only when native semantics can't represent the widget; never hardcode English (use `t('a11y/...')`); decorative visuals get `accessibilityElementsHidden`; modals use `useResolvedModalAccessibilityProps`; live updates use `useAccessibilityAnnouncer`; tests use `@testing-library/react-native` semantic queries. Keep React's "Common mistakes to avoid" section, RN-adapted. +6. **Documentation** — add `package/ai-docs/accessibility.md` (rename block uses **bullets, not tables** per repo convention). Cover: `accessibility` prop schema (with the **opt-in** call-out front and center), all `a11y/*` i18n keys with default English, integrator override path via Streami18n, the `A11yMode` enum (`auto`/`always`/`never`) for gesture toggles, platform-specific notes (TalkBack vs VoiceOver behaviors, Android `accessibilityLiveRegion`, iOS `setAccessibilityFocus` timing), and the Reassure benchmark numbers so integrators can predict the cost of `enabled: true`. + +--- + +## Critical Files + +### New files (mirroring React structure) +- `package/src/a11y/a11yUtils.ts` +- `package/src/a11y/__tests__/a11yUtils.test.ts` +- `package/src/a11y/hooks/useResolvedModalAccessibilityProps.ts` +- `package/src/a11y/hooks/useScreenReaderEnabled.ts` +- `package/src/a11y/hooks/useReducedMotionPreference.ts` +- `package/src/components/Accessibility/AccessibilityAnnouncer.tsx` +- `package/src/components/Accessibility/useAccessibilityAnnouncer.ts` +- `package/src/components/Accessibility/NotificationAnnouncer.tsx` +- `package/src/components/Accessibility/hooks/useIncomingMessageAnnouncements.ts` +- `package/src/components/Accessibility/hooks/__tests__/useIncomingMessageAnnouncements.test.tsx` +- `package/src/components/Accessibility/__tests__/AccessibilityAnnouncer.test.tsx` +- `package/src/components/Accessibility/__tests__/NotificationAnnouncer.test.tsx` +- `package/src/components/Accessibility/index.ts` +- `package/src/contexts/accessibilityContext/AccessibilityContext.tsx` +- `package/src/contexts/accessibilityContext/index.ts` +- `package/src/a11y/hooks/useAnnounceOnStateChange.ts` — small helper used by `AITypingIndicatorView` and `Indicators` to debounce + de-duplicate live-region announcements +- `package/src/a11y/hooks/useA11yLabel.ts` — returns `t('a11y/...')` when the context is `enabled: true`, or `undefined` when disabled. Components pass its return value straight to `accessibilityLabel` so the i18n lookup is skipped on hot list paths in the disabled-default state. +- `package/src/__tests__/perf/AccessibilityCost.reassure.ts` — Reassure benchmark for `MessageList` with a11y on vs. off +- `package/native-package/src/handlers/AccessibilityInfoHandler.ts` +- `package/expo-package/src/handlers/AccessibilityInfoHandler.ts` +- `package/src/mock-builders/accessibility/index.ts` +- `package/ai-docs/accessibility.md` +- `.claude/skills/accessibility/SKILL.md` + +### Modified files +- [package/src/components/Chat/Chat.tsx](package/src/components/Chat/Chat.tsx) — add `accessibility` prop, mount `` + `` +- [package/src/components/Channel/Channel.tsx](package/src/components/Channel/Channel.tsx) — mount ``, lift `useIncomingMessageAnnouncements` +- [package/src/native.ts](package/src/native.ts) — register `AccessibilityInfo` handler type, add `isAccessibilityInfoAvailable()` +- [package/src/index.ts](package/src/index.ts) — public exports +- [package/src/contexts/index.ts](package/src/contexts/index.ts) — context export +- All 12 locale JSONs in [package/src/i18n/](package/src/i18n/) — `a11y/*` keys +- [package/eslint.config.mjs](package/eslint.config.mjs) — a11y lint rules +- ~50 component files listed in Phases 2–4 (AITypingIndicatorView, Indicators/*, ProgressControl/*, Reply/*, ChannelPreview/ChannelMessagePreviewDeliveryStatus, ThreadList/ThreadMessagePreviewDeliveryStatus, the `ui/` primitives, and the `BottomSheetCompatibility/*` wrappers added by the post-survey audit) + +### Reused (template) files +- [package/src/contexts/chatConfigContext/ChatConfigContext.tsx](package/src/contexts/chatConfigContext/ChatConfigContext.tsx) — context boilerplate template +- [package/src/contexts/translationContext/TranslationContext.tsx](package/src/contexts/translationContext/TranslationContext.tsx) — `t()` access pattern +- `useTranslationContext()`, `useStreami18n` — for resolving and overriding `a11y/*` keys +- `registerNativeHandlers` in [package/src/native.ts](package/src/native.ts) — extension pattern for new handler + +### Reference files in stream-chat-react (port verbatim) +- [`src/components/Accessibility/AriaLiveRegion.tsx`](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Accessibility/AriaLiveRegion.tsx) → `AccessibilityAnnouncer.tsx` +- [`src/components/Accessibility/useAriaLiveAnnouncer.ts`](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Accessibility/useAriaLiveAnnouncer.ts) → `useAccessibilityAnnouncer.ts` +- [`src/components/Accessibility/NotificationAnnouncer.tsx`](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Accessibility/NotificationAnnouncer.tsx) → `NotificationAnnouncer.tsx` (notification source adapted) +- [`src/components/Accessibility/hooks/useIncomingMessageAnnouncements.ts`](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Accessibility/hooks/useIncomingMessageAnnouncements.ts) → ported verbatim +- [`.cursor/skills/accessibility/SKILL.md`](https://github.com/GetStream/stream-chat-react/blob/master/.cursor/skills/accessibility/SKILL.md) → `.claude/skills/accessibility/SKILL.md` with RN substitutions + +--- + +## Verification + +The plan ships as a single PR composed of **7 conventional commits** (one per phase, with Phase 3 and Phase 4 split in two). Each commit is independently reviewable and individually verifiable; the whole PR is the unit of merge. Suggested commit messages: + +- `feat(a11y): add opt-in accessibility announcer, context, and native handler` (Phase 1) +- `feat(a11y): add accessibility props to base UI primitives` (Phase 2) +- `feat(a11y): add accessible message actions and reactions` (Phase 3a) +- `feat(a11y): add accessible message list, composer, and audio recorder` (Phase 3b) +- `feat(a11y): add accessibility to channel list and threads` (Phase 4a) +- `feat(a11y): add accessibility to media, polls, and autocomplete` (Phase 4b) +- `chore(a11y): add lint rules, perf benchmark, docs, and maintenance skill` (Phase 5) + +End-to-end checks (run before merge, plus per-commit smoke checks during development): + +1. **Unit tests** — `cd package && yarn test:unit`. New hooks/components covered with parity to React's tests; `mockScreenReaderEnabled` helper validated. +2. **Lint** — `cd package && yarn lint` passes with `--max-warnings 0` plus new a11y rules. +3. **Type-check / build** — `cd package && yarn build`. Exported types (`AccessibilityConfig`, `AriaLivePriority`, etc.) compile cleanly. +4. **Translation validation** — `yarn lint` runs `validate-translations` — no empty `a11y/*` keys in any of 12 locales. +5. **Manual SampleApp on real devices** for **both platforms**: + - **iOS** (Settings → Accessibility → VoiceOver): + - Send/receive message — announced via `useIncomingMessageAnnouncements`. + - Long-press alternative → Message actions menu opens, navigable, selectable. + - Audio recorder → tap-to-record mode active, all states announced. + - Image gallery → tap-driven controls, swipe-by-rotor works. + - Modals (`BottomSheetModal`, `AttachmentPicker`) → focus trapped (`accessibilityViewIsModal`), dismissable via VO escape gesture. + - **Android** (Settings → Accessibility → TalkBack): + - Same flows; verify `accessibilityLiveRegion` triggers on new messages. + - Verify `accessibilityActions` surface in TalkBack's local context menu. +6. **Reduced motion** — enable in OS settings; verify TypingIndicator dots, AudioRecorder waveform, ImageGallery transitions reduce or disable animation via `useReducedMotionPreference`. +7. **No-regression for sighted users** — confirm visual UI is unchanged when SR is off (no new buttons appear, no animation changes outside reduced-motion). +8. **Cross-SDK API parity check** — verify `useAccessibilityAnnouncer().announce('hi')` and the React `useAriaLiveAnnouncer()('hi')` have identical call shape; same for `useIncomingMessageAnnouncements` params. +9. **RTL smoke** — switch device language to Hebrew or Arabic (RN's `I18nManager.isRTL` becomes true; `RTLComponents/WritingDirectionAwareText` flips). Verify VO/TalkBack reads composed `a11y/*` strings with parameters (`{{name}}`, `{{count}}`, `{{user}}`) in the correct logical order — interpolation values must not appear visually-flipped inside a labeled control. +10. **KeyboardCompatibleView focus** — open the composer with VO/TalkBack on, send a message, verify focus does NOT escape to a stale element when `KeyboardCompatibleView`/`KeyboardControllerAvoidingView` re-lays out. If it does, defer `setAccessibilityFocus` calls behind `requestAnimationFrame` (Android) / `InteractionManager.runAfterInteractions` (iOS). +11. **Component overrides inherit a11y props** — confirm that the recently introduced `WithComponents` provider (`15dd5e10d`) threads a11y props correctly when integrators replace `Message`/`MessageList`/etc.; add a regression test that renders the SDK with a custom `Message` override and asserts the rendered tree still carries `accessibilityRole="article"` + `accessibilityLabel`. + +--- + +## Out of scope (explicit non-goals) + +- **Skip navigation links** — applicable only to web (no Tab key on mobile). +- **Roving focus utilities** — applicable only to web (`a11yUtils.ts` keyboard helpers). +- **`` component** — RN announcer is imperative; no hidden DOM node needed. +- **Keyboard navigation patterns** (arrow keys, Enter/Space) — not applicable on mobile devices; rotor and `accessibilityActions` cover the equivalent UX. +- **`jest-axe` parity** — no equivalent for RN; semantic queries via `@testing-library/react-native` are the closest substitute. +- **Building a unified Notifications queue in RN** — React has `useNotifications`; RN does not. `NotificationAnnouncer` adapts to existing `useChatContext` errors and `useChannelContext` errors. A full notification queue is a separate plan. +- **Web a11y semantics** — `stream-chat-react-native` runs on RN-Web but the scope is iOS + Android; web is best-effort. +- **Dynamic Type / font scaling beyond RN's defaults** — separate plan if integrators request. +- **Auditing every existing test for a11y queries** — only exemplar suites updated in Phase 5. diff --git a/.claude/skills/accessibility/SKILL.md b/.claude/skills/accessibility/SKILL.md new file mode 100644 index 0000000000..785ab09e2c --- /dev/null +++ b/.claude/skills/accessibility/SKILL.md @@ -0,0 +1,147 @@ +--- +name: accessibility +description: Maintain VoiceOver/TalkBack-focused accessibility in stream-chat-react-native. Use when changing interactive components, gestures, modals, lists, media controls, notifications, focus behavior, or live announcements. +--- + +# Accessibility Maintenance (stream-chat-react-native) + +Use this skill whenever code changes can affect screen-reader users (VoiceOver on iOS, TalkBack on Android), gesture-driven flows, focus behavior, motion preferences, or semantic React Native accessibility props. + +## Non-negotiable rules + +1. **Native semantics first.** Use `Pressable`, `TextInput`, `Switch`, `Image` directly. Use `accessibilityRole` only when native semantics cannot represent the widget (`menu`, `menuitem`, `progressbar`, `radio`, `checkbox`, `article`, `alert`, `tablist`, `tab`). +2. **Never hardcode English** in `accessibilityLabel`/`accessibilityHint`/announcement strings. Use `useA11yLabel('a11y/...', params)` (or `t('a11y/...')` directly when you don't need the disabled-state short-circuit). Add the key to all 12 locale files in `package/src/i18n/`. +3. **Gate behavior on `useAccessibilityContext().enabled`.** A11y is opt-in. New listeners, subscriptions, and announcer mounts must be no-ops when `enabled` is false. New `accessibilityRole`/`accessibilityState` props are fine to render unconditionally — they cost ~zero. +4. **One focusable target per action.** Don't nest `Pressable` inside `Pressable`. Mark inner decorative views with `accessibilityElementsHidden` (iOS) + `importantForAccessibility='no-hide-descendants'` (Android) so the parent carries the label. +5. **Decorative visuals stay hidden from AT.** Icon-only buttons must carry an `accessibilityLabel` on the wrapper, and the SVG icon should be hidden. +6. **Backward-compatible.** All new props are optional. Component override pattern (`WithComponents`) must continue to work. + +## Where to put what + +- **Foundation primitives** → `package/src/a11y/` (utilities + low-level hooks). +- **Runtime announcer infra** → `package/src/components/Accessibility/` (`NotificationAnnouncer`, `useAccessibilityAnnouncer`, `useIncomingMessageAnnouncements`). +- **Config + provider** → `package/src/contexts/accessibilityContext/`, mounted by `OverlayProvider`. +- **i18n** → `a11y/*` keys in all 12 locale JSONs (`en, es, fr, he, hi, it, ja, ko, nl, pt-br, ru, tr`). +- **Component-level a11y attributes** → in the component itself. +- **Platform divergence (iOS vs Android)** → use `Platform.OS` or `useResolvedModalAccessibilityProps`. Don't duplicate the file — RN doesn't need `.ios.tsx`/`.android.tsx` splits for a11y. +- **Tests** → nearest `__tests__/` folder; use `@testing-library/react-native` semantic queries (`getByRole`, `getByLabelText`). + +## Patterns to follow + +### 1) Composing accessible names + +```tsx +import { useA11yLabel } from 'stream-chat-react-native'; + +const label = useA11yLabel('a11y/Reaction {{emoji}} by {{count}} users', { emoji, count }); + +``` + +`useA11yLabel` returns `undefined` when `accessibility.enabled` is false, so the `t()` call is skipped on hot list paths. + +For composite labels (sender + timestamp + body + reactions summary), use `composeAccessibilityLabel(...parts)` from `package/src/a11y/a11yUtils.ts` — it filters out empty/null parts and joins with `, ` so screen readers add a brief pause. + +### 2) Live-region announcements + +Two complementary mechanisms: + +- **Imperative**: `useAccessibilityAnnouncer()` returns `(message, priority?) => void`. Same shape as `stream-chat-react`'s `useAriaLiveAnnouncer`. Wraps `AccessibilityInfo.announceForAccessibility` with sequence/debounce so repeat announcements still re-announce. +- **Declarative**: `accessibilityLiveRegion="polite"` (Android only) on a View that re-renders when its label changes. + +Use `useAnnounceOnStateChange(message, { debounceMs, priority })` for transitions (AI typing, indicators) — it dedups consecutive same-message calls and applies a default 250ms debounce. + +For incoming messages: use `useIncomingMessageAnnouncements({ channel, ownUserId, activeThreadId, threadList })`. It throttles to 1 announcement per second, batches multi-message bursts, and bounds memory at 500 announced ids. + +### 3) Modal / sheet focus trap + +Use `useResolvedModalAccessibilityProps()` and spread the result on the modal root: + +```tsx +const a11yProps = useResolvedModalAccessibilityProps(); + + {/* ... */} + +``` + +This returns: +- iOS: `{ accessibilityViewIsModal: true }` +- Android: `{ importantForAccessibility: 'yes' }` +- Either platform when `enabled` is false: `{}` + +After opening, set initial focus via `AccessibilityInfo.setAccessibilityFocus(findNodeHandle(titleRef.current))` deferred behind `requestAnimationFrame` so the a11y tree has settled. + +### 4) Gesture alternatives + +Mobile gestures (long-press, hold-to-record, pinch/pan) must have a tap-equivalent for SR users. Read the component's mode flag from `AccessibilityConfig`: + +```tsx +const { audioRecorderTapMode } = useAccessibilityContext(); +const screenReaderOn = useScreenReaderEnabled(); +const useTapMode = + audioRecorderTapMode === 'always' || + (audioRecorderTapMode === 'auto' && screenReaderOn); +``` + +Three-state semantics: `'auto'` (swap when SR is on), `'always'` (swap for everyone), `'never'` (integrator handles). + +### 5) Reduced-motion + +```tsx +const reduceMotion = useReducedMotionPreference(); +const transitionDuration = reduceMotion ? 0 : 250; +``` + +Disable spring animations and limit fade durations when this is true. + +## Anti-patterns to avoid + +- **Hardcoded English `accessibilityLabel`** strings inside component code. Always use `useA11yLabel('a11y/...')` or `t('a11y/...')`. +- **Nested focusables**: `` causes VO to stop on each. Mark the outer `accessible={false}` or the inner `accessibilityElementsHidden`. +- **Subscribing to `AccessibilityInfo` events when `enabled` is false** — wastes a listener slot. The provided hooks already gate on this; mirror that pattern. +- **`useScreenReaderEnabled()` inside list items** — toggling SR re-renders every item. Only subscribe in components that actually swap UI on SR (`AudioRecorder`, `ImageGallery`, `Message`'s alternative-actions button). +- **Using live regions to force-announce static modal text** — fix the dialog semantics instead (`useResolvedModalAccessibilityProps` + correct `accessibilityRole='alert'`). +- **Mutating `AccessibilityInfo` polyfill state in tests without restoring** — use the mock-builder helpers in `package/src/mock-builders/accessibility/` (or jest.mock the module) and reset between tests. + +## Testing requirements per change + +Minimum: +- Unit tests for new keyboard/focus/semantics behavior in nearest `__tests__/`. +- Use `@testing-library/react-native` semantic queries: `getByRole`, `getByLabelText`, `getByA11yState`, `getByA11yValue`. + +Recommended for non-trivial changes: +- Render with `` and assert the accessible variant renders. +- Render with `` and assert the legacy behavior is unchanged (no extra buttons, no listeners). + +## Execution checklist (copy this when making an a11y change) + +- [ ] Identified the interaction type (button / menuitem / dialog / progressbar / radio / checkbox / live region / image) +- [ ] Picked a native element first; ARIA-style `accessibilityRole` only when necessary +- [ ] Composed `accessibilityLabel` via `useA11yLabel('a11y/...')` (not hardcoded) +- [ ] Added the new `a11y/*` key to all 12 locale JSONs and ran `yarn build-translations` +- [ ] Set `accessibilityState` for stateful widgets (`disabled`, `selected`, `checked`, `busy`, `expanded`) +- [ ] Decorative visuals hidden from AT (`accessibilityElementsHidden` / `importantForAccessibility='no-hide-descendants'`) +- [ ] Modal surfaces use `useResolvedModalAccessibilityProps` +- [ ] New behavior (announcers, listeners) gated on `useAccessibilityContext().enabled` +- [ ] Tested with `` and `enabled: false` +- [ ] Verified `yarn lint` passes (`validate-translations` enforces non-empty `a11y/*` keys) +- [ ] Verified `yarn tsc --noEmit` passes (RN's a11y prop types are strict about `boolean | null | undefined`) + +## Reference files (in this repo) + +- `package/src/contexts/accessibilityContext/AccessibilityContext.tsx` — config schema + provider + imperative announcer context. +- `package/src/components/Accessibility/hooks/useIncomingMessageAnnouncements.ts` — port of stream-chat-react's hook. +- `package/src/a11y/hooks/useA11yLabel.ts` — translated-label-or-undefined. +- `package/src/a11y/hooks/useResolvedModalAccessibilityProps.ts` — modal a11y props. +- `package/src/components/ui/Avatar/Avatar.tsx` — example of `name` + `useA11yLabel` usage. +- `package/src/components/UIComponents/BottomSheetModal.tsx` — example of `useResolvedModalAccessibilityProps`. +- `package/src/components/AITypingIndicatorView/AITypingIndicatorView.tsx` — example of `useAnnounceOnStateChange`. + +## Cross-SDK parity + +API shapes mirror [`stream-chat-react#3146`](https://github.com/GetStream/stream-chat-react/pull/3146): +- `useAccessibilityAnnouncer` ≈ React's `useAriaLiveAnnouncer` +- `useIncomingMessageAnnouncements` — same params, same throttle/batch logic +- `a11y/*` i18n namespace shared +- `` — same component name, `connection-state`-only on RN since RN has no shared notifications queue + +When changing one SDK's a11y API, mirror it in the other where the platforms agree. diff --git a/ai-docs/accessibility.md b/ai-docs/accessibility.md new file mode 100644 index 0000000000..2d22e92a7a --- /dev/null +++ b/ai-docs/accessibility.md @@ -0,0 +1,104 @@ +# Accessibility (a11y) — `stream-chat-react-native` + +This SDK ships an opt-in accessibility layer for VoiceOver (iOS) and TalkBack (Android). When enabled, components add the appropriate `accessibilityRole`, `accessibilityState`, `accessibilityLabel`, `accessibilityValue`, and `accessibilityLiveRegion` attributes; the SDK announces incoming messages, AI typing transitions, and connection-state changes via a single imperative announcer; and modal/sheet surfaces apply the platform-correct focus-trap props. + +## Opt-in default + +A11y is **off by default**. Existing integrators see no behavior change. To enable: + +```tsx +import { Chat, OverlayProvider } from 'stream-chat-react-native'; + + + + {/* ... */} + + +``` + +When `enabled` is false: + +- No announcer context mounts; `useAccessibilityAnnouncer()` returns a noop. +- `` exits without announcements. +- `useIncomingMessageAnnouncements` does not subscribe to `channel.on('message.new')`. +- No `AccessibilityInfo` event listeners attach. +- Components still render their `accessibilityRole` / `accessibilityState` / etc. attributes (these are passed to native views and only consulted by VO/TalkBack when active — sighted users incur ~zero cost). +- `useA11yLabel(key, params)` returns `undefined` so `t('a11y/...')` is **not** called on hot list paths. + +## Configuration shape + +```ts +type A11yMode = 'auto' | 'always' | 'never'; + +type AccessibilityConfig = { + enabled?: boolean; // default false — must opt in + forceScreenReaderMode?: boolean; // default false — testing aid + announceNewMessages?: boolean; // default true (when enabled) + announceTypingIndicator?: boolean; // default false (noisy) + announceConnectionState?: boolean; // default true + audioRecorderTapMode?: A11yMode; // default 'auto' + imageGalleryScreenReaderMode?: A11yMode; // default 'auto' + messageActionsTrigger?: 'long-press' | 'auto' | 'always-button'; // default 'auto' +}; +``` + +For RN-specific gesture-alternative toggles, the enum semantics are: + +- `auto` — swap UI when a screen reader is detected via `AccessibilityInfo`. +- `always` — show the accessible variant for everyone (sighted + SR users). +- `never` — SDK never swaps UI; the integrator handles it (e.g. when shipping a custom component via `WithComponents`). + +## Localization + +All a11y strings flow through the existing `Streami18n` translation pipeline under the `a11y/*` namespace. Defaults ship in English in every locale; integrators can override per-key via the same mechanism they use for other strings: + +```ts +const i18n = new Streami18n('nl'); +i18n.registerTranslation('nl', { + 'a11y/Avatar of {{name}}': 'Avatar van {{name}}', + 'a11y/{{count}} new messages': '{{count}} nieuwe berichten', +}); + + + {/* ... */} + + +``` + +`validate-translations` (run as part of `yarn lint`) enforces non-empty values for every `a11y/*` key in every locale. + +## Public hooks and components + +Importable from `stream-chat-react-native`: + +- `useAccessibilityContext()` — read the resolved config. +- `useAccessibilityAnnouncer()` — `(message, priority?) => void` imperative announcer; mirrors `useAriaLiveAnnouncer` from `stream-chat-react`. +- `useScreenReaderEnabled()` — live boolean from `AccessibilityInfo.screenReaderChanged`. +- `useReducedMotionPreference()` — live boolean from `AccessibilityInfo.reduceMotionChanged`. +- `useResolvedModalAccessibilityProps()` — returns `{ accessibilityViewIsModal, importantForAccessibility }` for the active platform. +- `useA11yLabel(key, params)` — translated label or `undefined` when disabled. +- `useAnnounceOnStateChange(message, options)` — debounced live-region helper. +- `useIncomingMessageAnnouncements({ channel, ownUserId, activeThreadId, threadList })` — throttled, batched announcement of new messages. +- `` — connection-state announcer (mounted by ``). + +## Cross-SDK parity + +API shapes mirror [`stream-chat-react#3146`](https://github.com/GetStream/stream-chat-react/pull/3146) wherever the platforms agree (`useAccessibilityAnnouncer` ≈ `useAriaLiveAnnouncer`, `useIncomingMessageAnnouncements` ≈ identical params and throttle semantics, `a11y/*` i18n namespace shared). Mobile-only deviations: + +- `` config object — RN needs gesture-alternative toggles (audio hold-to-record, gallery pinch/pan) that don't exist on web. +- No ``, no ``, no roving-focus utilities — RN announcer is imperative, mobile has no Tab key. +- `useResolvedModalAccessibilityProps` returns `accessibilityViewIsModal` (iOS) + `importantForAccessibility='yes'` (Android) instead of `aria-modal`. +- Live regions: `AccessibilityInfo.announceForAccessibility` cross-platform; Android `accessibilityLiveRegion='polite'` on hidden Views as a backup where useful. + +## Platform-specific notes + +- **iOS / VoiceOver**: focus management uses `AccessibilityInfo.setAccessibilityFocus(reactTag)`. After modal open or layout shift, defer the focus call by one frame (`requestAnimationFrame`) so the a11y tree has settled. +- **Android / TalkBack**: prefer `accessibilityLiveRegion` over imperative announcements where both are an option — TalkBack interrupts more aggressively when announcing imperatively. The announcer uses `announceForAccessibility` for both platforms because behavior is closer to predictable across devices. +- `setAccessibilityFocus` on Android requires the React tag to come from `findNodeHandle(ref)`; if your custom component override uses a functional ref, expose a method or pass `ref` through. + +## Out of scope (current state) + +- AudioRecorder gesture-alternative tap mode (planned; gated on `audioRecorderTapMode`). +- ImageGallery screen-reader-mode UI swap (planned; gated on `imageGalleryScreenReaderMode`). +- Reassure performance benchmark (planned for the lint/perf commit). +- ESLint `react-native-a11y` rule plugin (deferred — adds a runtime dependency). diff --git a/examples/SampleApp/App.tsx b/examples/SampleApp/App.tsx index 907ec41ac7..57e513713e 100644 --- a/examples/SampleApp/App.tsx +++ b/examples/SampleApp/App.tsx @@ -1,11 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { - DevSettings, - I18nManager, - LogBox, - Platform, - useColorScheme, -} from 'react-native'; +import { DevSettings, I18nManager, LogBox, Platform, useColorScheme } from 'react-native'; import { createDrawerNavigator } from '@react-navigation/drawer'; import { DarkTheme, DefaultTheme, NavigationContainer } from '@react-navigation/native'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; @@ -103,6 +97,7 @@ const Drawer = createDrawerNavigator(); const Stack = createNativeStackNavigator(); const UserSelectorStack = createNativeStackNavigator(); const RTL_STORAGE_KEY = '@stream-rn-sampleapp-rtl-enabled'; +const accessibilityConfig = { enabled: true }; const App = () => { const { chatClient, isConnecting, loginUser, logout, switchUser } = useChatClient(); @@ -270,7 +265,11 @@ const App = () => { > - + { + it('joins non-empty parts with comma+space', () => { + expect(composeAccessibilityLabel('Alice', '12:34', 'Hello')).toBe('Alice, 12:34, Hello'); + }); + + it('drops empty strings, null, undefined, false', () => { + expect(composeAccessibilityLabel('A', '', null, undefined, false, 'B')).toBe('A, B'); + }); + + it('returns empty string when nothing to join', () => { + expect(composeAccessibilityLabel(null, undefined, '')).toBe(''); + }); +}); + +describe('formatAccessibilityValue', () => { + it('clamps now between min and max', () => { + expect(formatAccessibilityValue({ max: 100, now: 200 })).toEqual({ + max: 100, + min: 0, + now: 100, + }); + expect(formatAccessibilityValue({ max: 100, min: 10, now: 5 })).toEqual({ + max: 100, + min: 10, + now: 10, + }); + }); + + it('attaches optional text', () => { + expect(formatAccessibilityValue({ max: 60, now: 30, text: '00:30 of 01:00' })).toEqual({ + max: 60, + min: 0, + now: 30, + text: '00:30 of 01:00', + }); + }); + + it('omits text when not provided', () => { + const result = formatAccessibilityValue({ max: 60, now: 30 }); + expect(result).not.toHaveProperty('text'); + }); +}); + +describe('mergeAccessibilityActions', () => { + it('merges and deduplicates by name (later wins)', () => { + const merged = mergeAccessibilityActions( + [{ name: 'activate', label: 'old' }, { name: 'react' }], + [{ name: 'activate', label: 'new' }, { name: 'reply' }], + ); + expect(merged).toEqual([ + { name: 'activate', label: 'new' }, + { name: 'react' }, + { name: 'reply' }, + ]); + }); + + it('skips undefined inputs', () => { + expect(mergeAccessibilityActions(undefined, [{ name: 'a' }])).toEqual([{ name: 'a' }]); + }); + + it('returns empty array when no inputs', () => { + expect(mergeAccessibilityActions()).toEqual([]); + }); +}); diff --git a/package/src/a11y/a11yUtils.ts b/package/src/a11y/a11yUtils.ts new file mode 100644 index 0000000000..b026d47fde --- /dev/null +++ b/package/src/a11y/a11yUtils.ts @@ -0,0 +1,50 @@ +/** + * Compose a single accessibility label from multiple parts. + * Empty/null/undefined parts are filtered out, the remainder joined with a comma+space + * so screen readers add a brief pause between segments. + */ +export const composeAccessibilityLabel = ( + ...parts: Array +): string => parts.filter((p): p is string => typeof p === 'string' && p.length > 0).join(', '); + +/** + * Build the value object passed to `accessibilityValue` for progress/seek surfaces. + * Mirrors the React SDK's `progressBarA11y.ts` shape. + */ +export const formatAccessibilityValue = ({ + max, + min = 0, + now, + text, +}: { + max: number; + now: number; + min?: number; + text?: string; +}): { max: number; min: number; now: number; text?: string } => { + const value: { max: number; min: number; now: number; text?: string } = { + max, + min, + now: Math.min(Math.max(min, now), max), + }; + if (text) value.text = text; + return value; +}; + +/** + * Merge two `accessibilityActions` arrays, deduplicating by `name` (later wins). + */ +type A11yAction = { name: string; label?: string }; + +export const mergeAccessibilityActions = ( + ...actionLists: Array +): A11yAction[] => { + const byName = new Map(); + for (const list of actionLists) { + if (!list) continue; + for (const action of list) { + byName.set(action.name, action); + } + } + return Array.from(byName.values()); +}; diff --git a/package/src/a11y/hooks/useA11yLabel.ts b/package/src/a11y/hooks/useA11yLabel.ts new file mode 100644 index 0000000000..a27675b864 --- /dev/null +++ b/package/src/a11y/hooks/useA11yLabel.ts @@ -0,0 +1,19 @@ +import { useAccessibilityContext } from '../../contexts/accessibilityContext/AccessibilityContext'; +import { useTranslationContext } from '../../contexts/translationContext/TranslationContext'; + +/** + * Returns the translated `a11y/...` label when the AccessibilityContext is enabled, + * or `undefined` when disabled. Components pass the result straight to + * `accessibilityLabel` so the i18n lookup is skipped on hot list paths in the + * default disabled-state. + * + * Example: + * const label = useA11yLabel('a11y/Avatar of {{name}}', { name }); + * + */ +export const useA11yLabel = (key: string, params?: Record): string | undefined => { + const { enabled } = useAccessibilityContext(); + const { t } = useTranslationContext(); + if (!enabled) return undefined; + return t(key, params); +}; diff --git a/package/src/a11y/hooks/useAnnounceOnStateChange.ts b/package/src/a11y/hooks/useAnnounceOnStateChange.ts new file mode 100644 index 0000000000..5c860494e9 --- /dev/null +++ b/package/src/a11y/hooks/useAnnounceOnStateChange.ts @@ -0,0 +1,47 @@ +import { useEffect, useRef } from 'react'; + +import { useAccessibilityAnnouncer } from '../../components/Accessibility/useAccessibilityAnnouncer'; +import { useAccessibilityContext } from '../../contexts/accessibilityContext/AccessibilityContext'; + +const DEFAULT_DEBOUNCE_MS = 250; + +type UseAnnounceOnStateChangeOptions = { + debounceMs?: number; + priority?: 'polite' | 'assertive'; +}; + +/** + * Announces `message` whenever it changes (and is non-empty), with a debounce + * to avoid spamming the screen reader on rapid transitions. + * + * Used by `AITypingIndicatorView` ("Thinking…" → "Generating…" → idle) and + * the `Indicators` family (loading → loaded → error). + */ +export const useAnnounceOnStateChange = ( + message: string | null | undefined, + options: UseAnnounceOnStateChangeOptions = {}, +) => { + const { debounceMs = DEFAULT_DEBOUNCE_MS, priority = 'polite' } = options; + const { enabled } = useAccessibilityContext(); + const announce = useAccessibilityAnnouncer(); + const timeoutRef = useRef | null>(null); + const lastAnnouncedRef = useRef(null); + + useEffect(() => { + if (!enabled || !message || message === lastAnnouncedRef.current) return; + + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => { + announce(message, priority); + lastAnnouncedRef.current = message; + timeoutRef.current = null; + }, debounceMs); + + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }; + }, [announce, debounceMs, enabled, message, priority]); +}; diff --git a/package/src/a11y/hooks/useReducedMotionPreference.ts b/package/src/a11y/hooks/useReducedMotionPreference.ts new file mode 100644 index 0000000000..8096d03f4b --- /dev/null +++ b/package/src/a11y/hooks/useReducedMotionPreference.ts @@ -0,0 +1,38 @@ +import { useEffect, useState } from 'react'; +import { AccessibilityInfo } from 'react-native'; + +import { useAccessibilityContext } from '../../contexts/accessibilityContext/AccessibilityContext'; + +/** + * Subscribes to AccessibilityInfo reduce-motion changes and returns the live state. + * Returns false when the AccessibilityContext is disabled. + */ +export const useReducedMotionPreference = (): boolean => { + const { enabled } = useAccessibilityContext(); + const [reduceMotion, setReduceMotion] = useState(false); + + useEffect(() => { + if (!enabled) { + setReduceMotion(false); + return; + } + + let cancelled = false; + AccessibilityInfo.isReduceMotionEnabled?.() + .then((value) => { + if (!cancelled) setReduceMotion(value); + }) + .catch(() => { + // Older RN or platforms without the API. + }); + + const subscription = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduceMotion); + + return () => { + cancelled = true; + subscription?.remove?.(); + }; + }, [enabled]); + + return enabled && reduceMotion; +}; diff --git a/package/src/a11y/hooks/useResolvedModalAccessibilityProps.ts b/package/src/a11y/hooks/useResolvedModalAccessibilityProps.ts new file mode 100644 index 0000000000..b243cbe2e7 --- /dev/null +++ b/package/src/a11y/hooks/useResolvedModalAccessibilityProps.ts @@ -0,0 +1,30 @@ +import { Platform } from 'react-native'; + +import { useAccessibilityContext } from '../../contexts/accessibilityContext/AccessibilityContext'; + +export type ResolvedModalAccessibilityProps = { + accessibilityViewIsModal?: boolean; + importantForAccessibility?: 'auto' | 'yes' | 'no' | 'no-hide-descendants'; + accessibilityRole?: 'none' | 'button' | 'image' | 'text' | 'alert' | 'menu' | 'menuitem'; +}; + +/** + * Returns the platform-appropriate set of a11y props for a modal/sheet root. + * Equivalent of stream-chat-react's `useResolvedModalAriaProps` — but aware of + * RN's iOS-vs-Android split: + * - iOS uses `accessibilityViewIsModal` to trap focus. + * - Android uses `importantForAccessibility="yes"` on the modal root and + * `"no-hide-descendants"` on background siblings (caller's responsibility). + * + * Returns an empty object when AccessibilityContext is disabled, so the modal + * stays a no-op for integrators that haven't opted in. + */ +export const useResolvedModalAccessibilityProps = (): ResolvedModalAccessibilityProps => { + const { enabled } = useAccessibilityContext(); + if (!enabled) return {}; + + if (Platform.OS === 'ios') { + return { accessibilityViewIsModal: true }; + } + return { importantForAccessibility: 'yes' }; +}; diff --git a/package/src/a11y/hooks/useScreenReaderEnabled.ts b/package/src/a11y/hooks/useScreenReaderEnabled.ts new file mode 100644 index 0000000000..c2f37f4aae --- /dev/null +++ b/package/src/a11y/hooks/useScreenReaderEnabled.ts @@ -0,0 +1,44 @@ +import { useEffect, useState } from 'react'; +import { AccessibilityInfo } from 'react-native'; + +import { useAccessibilityContext } from '../../contexts/accessibilityContext/AccessibilityContext'; + +/** + * Subscribes to AccessibilityInfo screen-reader changes and returns the live state. + * Returns false when the AccessibilityContext is disabled, regardless of the OS state, + * so consumers don't pay the listener cost when the SDK's a11y is opted out. + * + * `forceScreenReaderMode: true` in the config short-circuits to true (used in tests + * and for integrator preview). + */ +export const useScreenReaderEnabled = (): boolean => { + const { enabled, forceScreenReaderMode } = useAccessibilityContext(); + const [isEnabled, setIsEnabled] = useState(false); + + useEffect(() => { + if (!enabled) { + setIsEnabled(false); + return; + } + + let cancelled = false; + AccessibilityInfo.isScreenReaderEnabled() + .then((value) => { + if (!cancelled) setIsEnabled(value); + }) + .catch(() => { + // Some platforms / environments may not implement this; fall back to false. + }); + + const subscription = AccessibilityInfo.addEventListener('screenReaderChanged', setIsEnabled); + + return () => { + cancelled = true; + subscription?.remove?.(); + }; + }, [enabled]); + + if (!enabled) return false; + if (forceScreenReaderMode) return true; + return isEnabled; +}; diff --git a/package/src/a11y/index.ts b/package/src/a11y/index.ts new file mode 100644 index 0000000000..55ae3fc9ac --- /dev/null +++ b/package/src/a11y/index.ts @@ -0,0 +1,6 @@ +export * from './a11yUtils'; +export * from './hooks/useScreenReaderEnabled'; +export * from './hooks/useReducedMotionPreference'; +export * from './hooks/useResolvedModalAccessibilityProps'; +export * from './hooks/useAnnounceOnStateChange'; +export * from './hooks/useA11yLabel'; diff --git a/package/src/components/AITypingIndicatorView/AITypingIndicatorView.tsx b/package/src/components/AITypingIndicatorView/AITypingIndicatorView.tsx index e70c6ca244..d86460b6b3 100644 --- a/package/src/components/AITypingIndicatorView/AITypingIndicatorView.tsx +++ b/package/src/components/AITypingIndicatorView/AITypingIndicatorView.tsx @@ -6,7 +6,13 @@ import { Channel } from 'stream-chat'; import { AIStates, useAIState } from './hooks/useAIState'; -import { useChannelContext, useTheme, useTranslationContext } from '../../contexts'; +import { useAnnounceOnStateChange } from '../../a11y/hooks/useAnnounceOnStateChange'; +import { + useAccessibilityContext, + useChannelContext, + useTheme, + useTranslationContext, +} from '../../contexts'; import { primitives } from '../../theme'; export type AITypingIndicatorViewProps = { @@ -20,15 +26,24 @@ export const AITypingIndicatorView = ({ const { channel: channelFromContext } = useChannelContext(); const channel = channelFromProps || channelFromContext; const { aiState } = useAIState(channel); + const { announceTypingIndicator, enabled } = useAccessibilityContext(); const allowedStates = { [AIStates.Thinking]: t('Thinking...'), [AIStates.Generating]: t('Generating...'), }; const styles = useStyles(); + const announceableState = aiState in allowedStates ? allowedStates[aiState] : null; + const shouldAnnounceTypingIndicator = enabled && announceTypingIndicator; + const typingAnnouncement = announceTypingIndicator ? announceableState : null; + useAnnounceOnStateChange(typingAnnouncement); return aiState in allowedStates ? ( - + {allowedStates[aiState]} ) : null; diff --git a/package/src/components/AITypingIndicatorView/__tests__/AITypingIndicatorView.test.tsx b/package/src/components/AITypingIndicatorView/__tests__/AITypingIndicatorView.test.tsx new file mode 100644 index 0000000000..1fa60edcf2 --- /dev/null +++ b/package/src/components/AITypingIndicatorView/__tests__/AITypingIndicatorView.test.tsx @@ -0,0 +1,73 @@ +import React from 'react'; + +import { render } from '@testing-library/react-native'; +import type { Channel } from 'stream-chat'; + +import { useAnnounceOnStateChange } from '../../../a11y/hooks/useAnnounceOnStateChange'; +import { AccessibilityProvider } from '../../../contexts/accessibilityContext/AccessibilityContext'; +import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; +import { defaultTheme } from '../../../contexts/themeContext/utils/theme'; +import { AITypingIndicatorView } from '../AITypingIndicatorView'; +import { AIStates, useAIState } from '../hooks/useAIState'; + +jest.mock('../../../a11y/hooks/useAnnounceOnStateChange', () => ({ + useAnnounceOnStateChange: jest.fn(), +})); + +jest.mock('../hooks/useAIState', () => { + const actual = jest.requireActual('../hooks/useAIState'); + return { + ...actual, + useAIState: jest.fn(), + }; +}); + +const mockUseAIState = useAIState as jest.MockedFunction; +const mockUseAnnounceOnStateChange = useAnnounceOnStateChange as jest.MockedFunction< + typeof useAnnounceOnStateChange +>; + +const renderComponent = ( + accessibility?: React.ComponentProps['value'], +) => + render( + + + + + , + ); + +describe('AITypingIndicatorView', () => { + beforeEach(() => { + mockUseAIState.mockReturnValue({ aiState: AIStates.Thinking }); + jest.clearAllMocks(); + }); + + it('does not announce typing state by default', () => { + const { UNSAFE_queryByProps } = renderComponent(); + + expect(mockUseAnnounceOnStateChange).toHaveBeenCalledWith(null); + expect(UNSAFE_queryByProps({ accessibilityLiveRegion: 'polite' })).toBeNull(); + }); + + it('does not announce typing state when a11y is enabled but typing announcements are off', () => { + const { UNSAFE_queryByProps } = renderComponent({ + announceTypingIndicator: false, + enabled: true, + }); + + expect(mockUseAnnounceOnStateChange).toHaveBeenCalledWith(null); + expect(UNSAFE_queryByProps({ accessibilityLiveRegion: 'polite' })).toBeNull(); + }); + + it('announces typing state when the typing announcement toggle is enabled', () => { + const { UNSAFE_queryByProps } = renderComponent({ + announceTypingIndicator: true, + enabled: true, + }); + + expect(mockUseAnnounceOnStateChange).toHaveBeenCalledWith('Thinking...'); + expect(UNSAFE_queryByProps({ accessibilityLiveRegion: 'polite' })).toBeTruthy(); + }); +}); diff --git a/package/src/components/Accessibility/NotificationAnnouncer.tsx b/package/src/components/Accessibility/NotificationAnnouncer.tsx new file mode 100644 index 0000000000..363529d053 --- /dev/null +++ b/package/src/components/Accessibility/NotificationAnnouncer.tsx @@ -0,0 +1,43 @@ +import { useEffect, useRef } from 'react'; + +import { useAccessibilityAnnouncer } from './useAccessibilityAnnouncer'; + +import { useAccessibilityContext } from '../../contexts/accessibilityContext/AccessibilityContext'; +import { useChatContext } from '../../contexts/chatContext/ChatContext'; +import { useTranslationContext } from '../../contexts/translationContext/TranslationContext'; + +/** + * Mirrors stream-chat-react's ``. RN does not yet have a + * unified Notification queue, so this component currently announces only + * connection-state transitions (offline → online and back) gated on + * `accessibility.announceConnectionState`. Per-channel error announcements can + * be wired in by a future PR via `useChannelContext().error`. + * + * Renders nothing. Mount once inside `` (or wherever the active chat + * surface lives). + */ +export const NotificationAnnouncer = () => { + const { announceConnectionState, enabled } = useAccessibilityContext(); + const { connectionRecovering, isOnline } = useChatContext(); + const announce = useAccessibilityAnnouncer(); + const { t } = useTranslationContext(); + const previousIsOnlineRef = useRef(undefined); + + useEffect(() => { + if (!enabled || !announceConnectionState) return; + if (previousIsOnlineRef.current === undefined) { + previousIsOnlineRef.current = isOnline; + return; + } + if (previousIsOnlineRef.current === isOnline) return; + previousIsOnlineRef.current = isOnline; + + if (isOnline) { + announce(t('a11y/Connected'), 'polite'); + } else { + announce(connectionRecovering ? t('a11y/Reconnecting') : t('a11y/Offline'), 'assertive'); + } + }, [announce, announceConnectionState, connectionRecovering, enabled, isOnline, t]); + + return null; +}; diff --git a/package/src/components/Accessibility/__tests__/AccessibilityAnnouncer.test.tsx b/package/src/components/Accessibility/__tests__/AccessibilityAnnouncer.test.tsx new file mode 100644 index 0000000000..e501ed6f36 --- /dev/null +++ b/package/src/components/Accessibility/__tests__/AccessibilityAnnouncer.test.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { AccessibilityInfo } from 'react-native'; + +import { renderHook, waitFor } from '@testing-library/react-native'; + +import { AccessibilityProvider } from '../../../contexts/accessibilityContext/AccessibilityContext'; +import { useAccessibilityAnnouncer } from '../useAccessibilityAnnouncer'; + +jest.mock('react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo', () => ({ + __esModule: true, + default: { + announceForAccessibility: jest.fn(), + addEventListener: jest.fn().mockReturnValue({ remove: jest.fn() }), + isScreenReaderEnabled: jest.fn().mockResolvedValue(false), + isReduceMotionEnabled: jest.fn().mockResolvedValue(false), + }, +})); + +const wrapper = + (enabled: boolean) => + ({ children }: { children: React.ReactNode }) => ( + {children} + ); + +describe('AccessibilityProvider announcer', () => { + beforeEach(() => { + (AccessibilityInfo.announceForAccessibility as jest.Mock).mockClear(); + }); + + it('returns a no-op when enabled is false', async () => { + const { result } = renderHook(() => useAccessibilityAnnouncer(), { wrapper: wrapper(false) }); + result.current('hello'); + await new Promise((r) => setTimeout(r, 80)); + expect(AccessibilityInfo.announceForAccessibility).not.toHaveBeenCalled(); + }); + + it('announces via AccessibilityInfo when enabled', async () => { + const { result } = renderHook(() => useAccessibilityAnnouncer(), { wrapper: wrapper(true) }); + result.current('hello'); + await waitFor(() => + expect(AccessibilityInfo.announceForAccessibility).toHaveBeenCalledWith('hello'), + ); + }); + + it('flushes only the latest message per priority within the debounce window', async () => { + const { result } = renderHook(() => useAccessibilityAnnouncer(), { wrapper: wrapper(true) }); + result.current('first'); + result.current('second'); + result.current('third'); + await waitFor(() => + expect(AccessibilityInfo.announceForAccessibility).toHaveBeenCalledWith('third'), + ); + expect(AccessibilityInfo.announceForAccessibility).not.toHaveBeenCalledWith('first'); + expect(AccessibilityInfo.announceForAccessibility).not.toHaveBeenCalledWith('second'); + }); + + it('ignores empty messages', async () => { + const { result } = renderHook(() => useAccessibilityAnnouncer(), { wrapper: wrapper(true) }); + result.current(''); + await new Promise((r) => setTimeout(r, 80)); + expect(AccessibilityInfo.announceForAccessibility).not.toHaveBeenCalled(); + }); + + it('clears pending announcements on unmount', async () => { + const { result, unmount } = renderHook(() => useAccessibilityAnnouncer(), { + wrapper: wrapper(true), + }); + + result.current('hello'); + unmount(); + + await new Promise((r) => setTimeout(r, 80)); + expect(AccessibilityInfo.announceForAccessibility).not.toHaveBeenCalled(); + }); +}); diff --git a/package/src/components/Accessibility/hooks/useIncomingMessageAnnouncements.ts b/package/src/components/Accessibility/hooks/useIncomingMessageAnnouncements.ts new file mode 100644 index 0000000000..982e9ea458 --- /dev/null +++ b/package/src/components/Accessibility/hooks/useIncomingMessageAnnouncements.ts @@ -0,0 +1,157 @@ +import { useCallback, useEffect, useRef } from 'react'; + +import type { Channel, Event, MessageResponse } from 'stream-chat'; + +import { useAccessibilityContext } from '../../../contexts/accessibilityContext/AccessibilityContext'; +import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; +import { useAccessibilityAnnouncer } from '../useAccessibilityAnnouncer'; + +const MESSAGE_ANNOUNCEMENT_THROTTLE_MS = 1000; + +const isAnnounceableIncomingMessage = (message: MessageResponse, ownUserId?: string): boolean => { + const messageUserId = message.user?.id; + if (!message.id || !messageUserId || messageUserId === ownUserId) return false; + return ( + message.type !== 'deleted' && + message.type !== 'ephemeral' && + message.type !== 'error' && + message.type !== 'system' && + message.status !== 'failed' && + message.status !== 'sending' + ); +}; + +const getSenderName = ( + message: MessageResponse, + t: ReturnType['t'], +) => message.user?.name?.trim() || message.user?.id || t('Anonymous'); + +export type UseIncomingMessageAnnouncementsParams = { + activeThreadId?: string; + channel?: Channel; + ownUserId?: string; + threadList?: boolean; +}; + +/** + * Mirrors stream-chat-react's `useIncomingMessageAnnouncements`: + * - 1 message → "New message from {{user}}" + * - >1 messages within throttle window → "{{count}} new messages" + * - Throttled to one announcement per second + * - Bounded `announcedMessageIds` set so a long-running session does not leak. + * + * Subscribes to `channel.on('message.new')`. When AccessibilityContext.enabled + * is false OR `announceNewMessages` is false, the hook is a no-op (no + * subscription is opened, no listener cost is paid). + */ +export const useIncomingMessageAnnouncements = ({ + activeThreadId, + channel, + ownUserId, + threadList = false, +}: UseIncomingMessageAnnouncementsParams) => { + const { announceNewMessages, enabled } = useAccessibilityContext(); + const announce = useAccessibilityAnnouncer(); + const { t } = useTranslationContext(); + const lastAnnouncementTimestampRef = useRef(0); + const flushTimeoutRef = useRef | undefined>(undefined); + const announcedMessageIdsRef = useRef(new Set()); + const pendingAnnouncementBatchRef = useRef<{ count: number; firstSender: string | null }>({ + count: 0, + firstSender: null, + }); + + const flushPendingAnnouncements = useCallback(() => { + const pending = pendingAnnouncementBatchRef.current; + if (pending.count <= 0) return; + + if (pending.count === 1) { + announce( + t('a11y/New message from {{user}}', { + user: pending.firstSender || t('Anonymous'), + }), + ); + } else { + announce(t('a11y/{{count}} new messages', { count: pending.count })); + } + + pending.count = 0; + pending.firstSender = null; + lastAnnouncementTimestampRef.current = Date.now(); + }, [announce, t]); + + const scheduleFlush = useCallback(() => { + if (flushTimeoutRef.current) return; + const now = Date.now(); + const elapsed = now - lastAnnouncementTimestampRef.current; + if (elapsed >= MESSAGE_ANNOUNCEMENT_THROTTLE_MS) { + flushPendingAnnouncements(); + return; + } + flushTimeoutRef.current = setTimeout(() => { + flushTimeoutRef.current = undefined; + flushPendingAnnouncements(); + }, MESSAGE_ANNOUNCEMENT_THROTTLE_MS - elapsed); + }, [flushPendingAnnouncements]); + + useEffect( + () => () => { + if (flushTimeoutRef.current) clearTimeout(flushTimeoutRef.current); + }, + [], + ); + + useEffect(() => { + if (!enabled || !announceNewMessages || !channel) return; + + const handleMessageNew = (event: Event) => { + const message = event.message; + if (!message) return; + if ( + (event.cid && event.cid !== channel.cid) || + !isAnnounceableIncomingMessage(message, ownUserId) + ) { + return; + } + + const isReply = !!message.parent_id; + const belongsToActiveThread = !!activeThreadId && message.parent_id === activeThreadId; + const shouldAnnounceInThreadList = threadList && belongsToActiveThread; + const shouldAnnounceInMainList = !threadList && !isReply; + if (!shouldAnnounceInThreadList && !shouldAnnounceInMainList) return; + + if (announcedMessageIdsRef.current.has(message.id || '')) return; + if (message.id) { + if (announcedMessageIdsRef.current.size > 500) { + announcedMessageIdsRef.current.clear(); + } + announcedMessageIdsRef.current.add(message.id); + } + + pendingAnnouncementBatchRef.current.count += 1; + if (!pendingAnnouncementBatchRef.current.firstSender) { + pendingAnnouncementBatchRef.current.firstSender = getSenderName(message, t); + } + + scheduleFlush(); + }; + + const subscription = channel.on('message.new', handleMessageNew); + return () => { + subscription.unsubscribe(); + if (flushTimeoutRef.current) { + clearTimeout(flushTimeoutRef.current); + flushTimeoutRef.current = undefined; + } + }; + }, [ + activeThreadId, + announceNewMessages, + channel, + enabled, + ownUserId, + scheduleFlush, + t, + threadList, + ]); +}; diff --git a/package/src/components/Accessibility/index.ts b/package/src/components/Accessibility/index.ts new file mode 100644 index 0000000000..14d86325c1 --- /dev/null +++ b/package/src/components/Accessibility/index.ts @@ -0,0 +1,3 @@ +export * from './NotificationAnnouncer'; +export * from './useAccessibilityAnnouncer'; +export * from './hooks/useIncomingMessageAnnouncements'; diff --git a/package/src/components/Accessibility/useAccessibilityAnnouncer.ts b/package/src/components/Accessibility/useAccessibilityAnnouncer.ts new file mode 100644 index 0000000000..0adf9ed402 --- /dev/null +++ b/package/src/components/Accessibility/useAccessibilityAnnouncer.ts @@ -0,0 +1,30 @@ +import { createContext, useContext } from 'react'; + +export type AccessibilityAnnouncePriority = 'assertive' | 'polite'; +export type AccessibilityAnnounce = ( + message: string, + priority?: AccessibilityAnnouncePriority, +) => void; + +export type AccessibilityAnnouncerContextValue = { + announce: AccessibilityAnnounce; +}; + +const noopAnnounce: AccessibilityAnnounce = () => undefined; + +export const AccessibilityAnnouncerContext = createContext< + AccessibilityAnnouncerContextValue | undefined +>(undefined); + +/** + * Returns the imperative announcer. When called outside the AccessibilityProvider's + * announcer context (which happens any time the SDK's a11y is disabled), this + * returns a no-op. + * + * Mirrors the React SDK's `useAriaLiveAnnouncer` so cross-SDK code reads the same. + */ +export const useAccessibilityAnnouncer = (): AccessibilityAnnounce => { + const contextValue = useContext(AccessibilityAnnouncerContext); + if (!contextValue) return noopAnnounce; + return contextValue.announce; +}; diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 23f27fc2fb..b83ce72cfe 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -49,6 +49,7 @@ import { AudioPlayerContextProps, AudioPlayerProvider, } from '../../contexts/audioPlayerContext/AudioPlayerContext'; + import { ChannelContextValue, ChannelProvider } from '../../contexts/channelContext/ChannelContext'; import type { UseChannelStateValue } from '../../contexts/channelsStateContext/useChannelState'; import { useChannelState } from '../../contexts/channelsStateContext/useChannelState'; @@ -111,6 +112,7 @@ import { MessageStatusTypes, ReactionData, } from '../../utils/utils'; +import { NotificationAnnouncer } from '../Accessibility/NotificationAnnouncer'; import { AttachmentPicker } from '../AttachmentPicker/AttachmentPicker'; import type { KeyboardCompatibleViewProps } from '../KeyboardCompatibleView/KeyboardControllerAvoidingView'; import { Emoji } from '../MessageMenu/EmojiPickerList'; @@ -1754,6 +1756,7 @@ const ChannelWithContext = (props: PropsWithChildren) = + {children} diff --git a/package/src/components/ChannelPreview/ChannelMessagePreviewDeliveryStatus.tsx b/package/src/components/ChannelPreview/ChannelMessagePreviewDeliveryStatus.tsx index 4048c40920..642fc565ca 100644 --- a/package/src/components/ChannelPreview/ChannelMessagePreviewDeliveryStatus.tsx +++ b/package/src/components/ChannelPreview/ChannelMessagePreviewDeliveryStatus.tsx @@ -5,6 +5,7 @@ import { LocalMessage, MessageResponse } from 'stream-chat'; import { ChannelPreviewProps } from './ChannelPreview'; +import { useA11yLabel } from '../../a11y/hooks/useA11yLabel'; import { useChatContext } from '../../contexts/chatContext/ChatContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { useTranslationContext } from '../../contexts/translationContext/TranslationContext'; @@ -61,6 +62,18 @@ export const ChannelMessagePreviewDeliveryStatus = ({ isReadEventsEnabled: readEvents, }); + const statusLabel = useA11yLabel( + message.status === MessageStatusTypes.SENDING + ? 'a11y/Sending' + : message.status === MessageStatusTypes.RECEIVED && status === MessageDeliveryStatus.READ + ? 'a11y/Read' + : status === MessageDeliveryStatus.DELIVERED + ? 'a11y/Delivered' + : status === MessageDeliveryStatus.SENT + ? 'a11y/Sent' + : 'a11y/Sending', + ); + if (!channel.data?.name && membersWithoutSelf.length === 1 && !isLastMessageByCurrentUser) { return null; } @@ -70,7 +83,7 @@ export const ChannelMessagePreviewDeliveryStatus = ({ } return ( - + {message.status === MessageStatusTypes.SENDING ? (