Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
38a62dc
feat: enhanced mentions wip
isekovanic Jun 2, 2026
0bf9722
Merge branch 'develop' into feat/enhanced-mentions
isekovanic Jun 2, 2026
830f231
fix: memo issues and offline support for enhanced mentions
isekovanic Jun 2, 2026
fb74d02
chore: commit jvm lockfile
isekovanic Jun 3, 2026
9495095
refactor: structure components differently
isekovanic Jun 3, 2026
8eb5fd3
refactor: standardize icons
isekovanic Jun 3, 2026
d851692
fix: mention content
isekovanic Jun 3, 2026
ff94e96
fix: mention and suggestion styles
isekovanic Jun 3, 2026
9bc4589
fix: include to deps
isekovanic Jun 3, 2026
b47a478
feat: add clipping fade bottom
isekovanic Jun 3, 2026
f512567
fix: suggestion list late frame animations
isekovanic Jun 3, 2026
69c3632
fix: mention font weight
isekovanic Jun 4, 2026
627f301
fix: rn upgrade ios text input regression for multiline text
isekovanic Jun 4, 2026
0e56471
chore: bump stream-chat to stable
isekovanic Jun 4, 2026
0abb337
chore: add separate overrides for new components
isekovanic Jun 4, 2026
d2cb7d5
fix: accessibility roles
isekovanic Jun 5, 2026
c41ef4f
fix: suggestion list accessibility
isekovanic Jun 5, 2026
a900a79
chore: port suggestion list changes to flashlist too
isekovanic Jun 5, 2026
4aebf14
chore: mention announcements
isekovanic Jun 5, 2026
b221a3c
fix: update icons
isekovanic Jun 5, 2026
23a50ae
chore: update aidocs
isekovanic Jun 5, 2026
cef2b71
chore: android a11y tree debug
isekovanic Jun 5, 2026
ffa3839
fix: tests
isekovanic Jun 5, 2026
39984f0
Merge remote-tracking branch 'origin/develop' into feat/enhanced-ment…
isekovanic Jun 10, 2026
929abc5
fix: make suggestion a11y more obvious
isekovanic Jun 10, 2026
175b609
fix: update podfile.lock
isekovanic Jun 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 44 additions & 2 deletions .claude/skills/accessibility/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,49 @@ Use this skill whenever code changes can affect screen-reader users (VoiceOver o

## 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`).
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`). **Platform caveat:** `'menu'` and `'menuitem'` are honored by iOS VoiceOver but Android TalkBack silently ignores them (no `UIAccessibilityTraits` equivalent). For interactive items that must be announceable on both platforms, use `'button'` on the leaf `Pressable`; the `'menu'` role can stay on the container as an iOS hint. iOS-supported roles that survive to VoiceOver: `button`, `link`, `search`, `image`, `keyboardkey`, `text`, `adjustable`, `imagebutton`, `header`, `summary`, `none`.
2. **Never hardcode English** in `accessibilityLabel`/`accessibilityHint`/announcement strings. For SDK `Button`, pass `accessibilityLabelKey='a11y/...'` (and `accessibilityLabelParams` when needed). For non-Button components, use `useA11yLabel('a11y/...', params)` or `t('a11y/...')` directly when you don't need the disabled-state short-circuit. Add the key to all 13 locale files in `package/src/i18n/` (`ar, en, es, fr, he, hi, it, ja, ko, nl, pt-br, ru, tr`).
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.
7. **Floating overlays need a tall parent for Android a11y.** Android's accessibility framework uses each view's measured layout bounds (`getBoundsInScreen()`) to decide what's focusable at a given screen coordinate. Children rendered *outside* their parent's measured rect get pruned / reported with inverted (empty) bounds — RN doesn't clip them by default so the visual looks fine, but TalkBack can't focus them and `uiautomator dump` shows degenerate `[x,y][x,y]` rects. **Implication:** when mounting a floating overlay (autocomplete picker, popover, tooltip), pick a parent whose measured bounds contain the rendered area. A `flex: 1` Channel-area parent works; a `position: absolute` wrapper inside a small input-row container does not. This is why `AutoCompleteSuggestionList` is mounted from `MessageList` / `MessageFlashList` (full-screen flex parent) instead of `MessageComposer` (~228px composer parent — the suggestion list overflowed it and was a11y-invisible). Verify with `adb shell uiautomator dump` after mounting; if rows show `top > bottom`, the parent isn't tall enough.

## Diagnosing Android a11y with `uiautomator dump`

When TalkBack ignores a view, can't focus a row, or seems to focus the wrong thing, dump the a11y tree and read the bounds directly. This was the load-bearing technique behind rule #7.

**Procedure:**

```bash
# 1. Put the app in the state you want to inspect (open the suggestion list, modal, etc.)
adb shell uiautomator dump /sdcard/window_dump.xml
adb pull /sdcard/window_dump.xml ./window_dump.xml

# 2. Find your view. Grep by a known accessibilityLabel, text, or resource-id.
grep -A2 'text="@channel"' window_dump.xml
grep -B1 -A1 'content-desc="Mention suggestions available"' window_dump.xml
```

**Reading the output:** each `<node>` has `bounds="[left,top][right,bottom]"` in screen pixels.

| Symptom in `bounds` | Meaning |
|---|---|
| `[0,0][0,0]` | View never measured (mid-mount or detached from a11y tree). |
| `top > bottom` or `left > right` | Clipped by parent — `getBoundsInScreen()` clamped to a smaller ancestor. TalkBack treats this as empty. **Move the mount to a taller parent.** |
| Bounds outside the screen | Off-screen or pushed by keyboard; TalkBack won't focus it. |
| Bounds present, `clickable="true"`, `focusable="true"`, but still unreachable | Check `importantForAccessibility` chain and sibling z-order — something opaque may be above it. |

**Other useful node attributes:**
- `class` — the underlying Android View class (`android.widget.HorizontalScrollView`, etc.). Useful when an RN component compiles to something unexpected.
- `package` — confirms you're looking at *your* app, not the system UI.
- `clickable`, `focusable`, `enabled` — these must all be true for a row to take TalkBack focus.
- `content-desc` — what TalkBack will speak. If empty when you expected an `accessibilityLabel`, the prop didn't bind to the right native view.

**Caveats:**
- The dump is a single snapshot. If the view animates in, dump after the animation settles.
- TalkBack can affect what gets dumped on some devices — turn it off when diagnosing layout, on when diagnosing focus order.
- The XML reflects native bounds *after* RN's layout pass, so a wrong dump usually means RN gave Android wrong layout, not that the dump lied.

## Where to put what

Expand Down Expand Up @@ -54,6 +91,8 @@ Two complementary mechanisms:

Use `useAnnounceOnStateChange(message, { debounceMs, priority })` for transitions (AI typing, indicators) — it dedups consecutive same-message calls and applies a default 250ms debounce.

Use `useAnnounceOnShow(visible, message, { delayMs, priority })` for **transient surfaces that appear and disappear repeatedly** (modals, sheets, autocomplete pickers). It announces on each `visible: false → true` transition and resets on hide, so the next show re-announces. The two announcer hooks are not interchangeable: `useAnnounceOnStateChange` dedupes on string equality (correct for "AI is typing" → "AI is generating"), while `useAnnounceOnShow` dedupes on visibility transition (correct for "Suggestions available" each time the picker reopens with the same label). Pair with `useA11yLabel('a11y/…')` for the message so the announcement is i18n'd and gated on the SDK's a11y opt-in.

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
Expand Down Expand Up @@ -179,6 +218,7 @@ Live example: `Reply.tsx` — fires when a reply preview shows in the composer.
- **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'`).
- **Auto-focusing the suggestions/listbox of a typeahead on appear** — anti-pattern for combobox-style UI. Each keystroke that produces new suggestions would re-steal focus from the active `TextInput`, breaking continuous typing. ARIA combobox spec specifically forbids this; iOS VoiceOver and Android TalkBack have the same constraint. Announce on show via `useAnnounceOnShow` instead and rely on standard screen-reader navigation gestures (swipe) for the user to reach the list when they want.
- **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
Expand Down Expand Up @@ -215,9 +255,11 @@ Recommended for non-trivial changes:
- `package/src/a11y/hooks/useAnnounceOnStateChange.ts` — announce on string-change with dedup.
- `package/src/a11y/hooks/useAnnounceOnShow.ts` — announce on `visible: false → true` transitions, resets on hide (no dedup).
- `package/src/a11y/hooks/useResolvedModalAccessibilityProps.ts` — modal a11y props.
- `package/src/a11y/hooks/useAnnounceOnShow.ts` — announce-on-visible helper for transient surfaces.
- `package/src/components/ui/Avatar/Avatar.tsx` — example of `name` + `useA11yLabel` usage.
- `package/src/components/UIComponents/BottomSheetModal.tsx` — example of `useResolvedModalAccessibilityProps`.
- `package/src/components/UIComponents/BottomSheetModal.tsx` — example of `useResolvedModalAccessibilityProps` and `useAnnounceOnShow`.
- `package/src/components/AITypingIndicatorView/AITypingIndicatorView.tsx` — example of `useAnnounceOnStateChange`.
- `package/src/components/AutoCompleteInput/AutoCompleteSuggestionList.tsx` — example of `useAnnounceOnShow` with a per-trigger label (mention/command/emoji).
- `package/src/components/Message/MessageItemView/MessageFooter.tsx` — example of cross-platform auto-compose on a View (`accessible + accessibilityRole='text'`).
- `package/src/components/Message/MessageItemView/MessageContent.tsx` — example of conditional drill-in (`accessible={hasInteractiveContent ? false : undefined}`).

Expand Down
3 changes: 3 additions & 0 deletions .yarnrc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ nodeLinker: node-modules

npmMinimalAgeGate: 3d

npmPreapprovedPackages:
- stream-chat

npmPublishProvenance: true

yarnPath: .yarn/releases/yarn-4.15.0.cjs
3 changes: 2 additions & 1 deletion ai-docs/accessibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,8 @@ Importable from `stream-chat-react-native`:
- `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.
- `useAnnounceOnStateChange(message, options)` — debounced live-region helper that announces on message **change** and dedupes consecutive identical strings (good for state-driven labels like loading/error transitions).
- `useAnnounceOnShow(visible, message, { delayMs?, priority? })` — announces on each `visible: false → true` transition and resets on hide, so re-shows re-announce. Pair with `useA11yLabel(...)` for the message. Used by `BottomSheetModal` and `AutoCompleteSuggestionList`.
- `useIncomingMessageAnnouncements({ channel, ownUserId, activeThreadId, threadList })` — throttled, batched announcement of new messages.
- `<NotificationAnnouncer />` — connection-state announcer (mounted by `<Channel>`).

Expand Down
12 changes: 12 additions & 0 deletions examples/SampleApp/android/gradle/gradle-daemon-jvm.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect
toolchainVersion=21
4 changes: 2 additions & 2 deletions examples/SampleApp/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2476,7 +2476,7 @@ PODS:
- ReactNativeDependencies
- RNFBApp
- Yoga
- RNGestureHandler (3.0.0):
- RNGestureHandler (2.31.2):
- hermes-engine
- RCTRequired
- RCTTypeSafety
Expand Down Expand Up @@ -3322,7 +3322,7 @@ SPEC CHECKSUMS:
RNFastImage: 14580cef91660b889645fb9e87f58a53621db993
RNFBApp: 3b942e786ca88524ba17df665a1a360fb3eee525
RNFBMessaging: b82ba0933288d710f5371f57d3115092abf64903
RNGestureHandler: ae4b9960c2e7d0fb3991255345bf424cca8e09e4
RNGestureHandler: a97cc64efbfcb7a53969a38310a189a3d5246c65
RNNotifee: 5e3b271e8ea7456a36eec994085543c9adca9168
RNReactNativeHapticFeedback: 9dc72312c12cb53ee240b5b7aae1e167f3d940a6
RNReanimated: 8aac6baab55e39ca4e02afd69f77fb127b26520c
Expand Down
2 changes: 1 addition & 1 deletion examples/SampleApp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
"react-native-teleport": "^1.1.7",
"react-native-video": "^6.19.2",
"react-native-worklets": "^0.8.3",
"stream-chat": "^9.44.2",
"stream-chat": "^9.45.0",
"stream-chat-react-native": "workspace:^",
"stream-chat-react-native-core": "workspace:^"
},
Expand Down
2 changes: 1 addition & 1 deletion package/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
"path": "0.12.7",
"react-native-markdown-package": "1.8.2",
"react-native-url-polyfill": "^2.0.0",
"stream-chat": "^9.44.2",
"stream-chat": "^9.45.0",
"use-sync-external-store": "^1.5.0"
},
"peerDependencies": {
Expand Down
13 changes: 13 additions & 0 deletions package/src/components/AutoCompleteInput/AutoCompleteInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,20 @@ const AutoCompleteInputWithContext = (props: AutoCompleteInputPropsWithContext)
}
}, [text]);

const nativeInputRef = useRef<RNTextInput | null>(null);

const clearState = useCallback(() => {
setLocalText('');
// iOS UITextView caches its intrinsicContentSize while focused, so a
// controlled `value` change to '' after a multiline send doesn't shrink
// the input back to single line height and UIKit keeps rendering at the
// previously cached focused size until blur. Not particularly sure which
// RN version regressed this, but 0.85.3 for sure has the bug. Forcebly
// setting its native prop forces UITextView to reconcile its content size
// and update accordingly.
if (Platform.OS === 'ios') {
nativeInputRef.current?.setNativeProps({ text: '' });
}
}, []);

const restoreState = useStableCallback((restoredText: string) => {
Expand All @@ -175,6 +187,7 @@ const AutoCompleteInputWithContext = (props: AutoCompleteInputPropsWithContext)

const setExtendedInputRef = useCallback(
(ref: RNTextInput | null) => {
nativeInputRef.current = ref;
if (!ref) {
setRef(setInputBoxRef, null);
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const CommandsHeader: React.FC<AutoCompleteSuggestionHeaderProps> = () =>
return (
<View style={[styles.container, container]}>
<Text
accessibilityRole='header'
style={[styles.title, { color: semantics.textTertiary }, title]}
testID='commands-header-title'
>
Expand All @@ -52,7 +53,7 @@ export const EmojiHeader: React.FC<AutoCompleteSuggestionHeaderProps> = ({ query
return (
<View style={[styles.container, container]}>
<Smile pathFill={semantics.accentPrimary} />
<Text style={[styles.title, title]} testID='emojis-header-title'>
<Text accessibilityRole='header' style={[styles.title, title]} testID='emojis-header-title'>
{`Emoji matching "${queryText}"`}
</Text>
</View>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,46 +1,48 @@
import React, { useCallback, useMemo } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';

import type { CommandSuggestion, TextComposerSuggestion, UserSuggestion } from 'stream-chat';
import type { CommandSuggestion, MentionSuggestion, TextComposerSuggestion } from 'stream-chat';

import { AutoCompleteSuggestionCommandIcon } from './AutoCompleteSuggestionCommandIcon';

import {
MentionBroadcastItem,
MentionRoleItem,
MentionUserGroupItem,
MentionUserItem,
} from './mentionItems';

import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext';
import { useIsCommandDisabled } from '../../contexts/messageInputContext/hooks/useIsCommandDisabled';
import { useMessageComposer } from '../../contexts/messageInputContext/hooks/useMessageComposer';
import { useTheme } from '../../contexts/themeContext/ThemeContext';
import { primitives } from '../../theme';
import type { Emoji } from '../../types/types';

import { UserAvatar } from '../ui/Avatar/UserAvatar';

export type AutoCompleteSuggestionItemProps = {
itemProps: TextComposerSuggestion;
triggerType?: string;
};

export const MentionSuggestionItem = (item: UserSuggestion) => {
const { id, name, online } = item;
const {
theme: {
messageComposer: {
suggestions: {
mention: { column, container: mentionContainer, name: nameStyle },
},
},
},
} = useTheme();
const styles = useStyles();

return (
<View style={[styles.container, mentionContainer]}>
<UserAvatar user={item} size='md' showOnlineIndicator={online} />
<View style={[styles.column, column]}>
<Text style={[styles.name, nameStyle]} testID='mentions-item-name'>
{name || id}
</Text>
</View>
</View>
);
/**
* Default `@`-trigger row dispatcher. Routes a `MentionSuggestion` to the
* per type component. Each per type component is its own export and can be
* composed by integrators who override this dispatcher via
* `ComponentsContext.MentionSuggestionItem`.
*/
export const MentionSuggestionItem = (item: MentionSuggestion) => {
switch (item.mentionType) {
case 'user':
return <MentionUserItem entity={item} />;
case 'channel':
case 'here':
return <MentionBroadcastItem entity={item} />;
case 'role':
return <MentionRoleItem entity={item} />;
case 'user_group':
return <MentionUserGroupItem entity={item} />;
default:
return null;
}
};

export const EmojiSuggestionItem = (item: Emoji) => {
Expand Down Expand Up @@ -114,9 +116,13 @@ const SuggestionItem = ({
item: TextComposerSuggestion;
triggerType?: string;
}) => {
// Resolve via context so integrators can swap the mention dispatcher alone
// (e.g. to render a custom @channel row) without re-implementing the
// emoji/command branches of AutoCompleteSuggestionItem.
const { MentionSuggestionItem } = useComponentsContext();
switch (triggerType) {
case 'mention':
return <MentionSuggestionItem {...(item as UserSuggestion)} />;
return <MentionSuggestionItem {...(item as MentionSuggestion)} />;
case 'emoji':
return <EmojiSuggestionItem {...(item as Emoji)} />;
case 'command':
Expand Down Expand Up @@ -147,6 +153,7 @@ const UnMemoizedAutoCompleteSuggestionItem = ({

return (
<Pressable
accessibilityRole='button'
onPress={handlePress}
style={({ pressed }) => [{ opacity: pressed ? 0.8 : 1 }, itemStyle]}
testID='suggestion-item'
Expand Down Expand Up @@ -194,11 +201,6 @@ const useStyles = () => {
fontSize: primitives.typographyFontSizeMd,
color: semantics.textTertiary,
},
column: {
flex: 1,
justifyContent: 'space-evenly',
paddingLeft: 8,
},
container: {
alignItems: 'center',
flexDirection: 'row',
Expand All @@ -211,16 +213,6 @@ const useStyles = () => {
paddingHorizontal: primitives.spacingSm,
paddingVertical: primitives.spacingXs,
},
name: {
fontSize: primitives.typographyFontSizeMd,
lineHeight: primitives.typographyLineHeightNormal,
color: semantics.textPrimary,
paddingBottom: 2,
},
tag: {
fontSize: 12,
fontWeight: '600',
},
text: {
fontSize: primitives.typographyFontSizeMd,
fontWeight: primitives.typographyFontWeightRegular,
Expand Down
Loading
Loading