Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
17 changes: 8 additions & 9 deletions package/expo-package/src/native/NativeAIDictation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ import { TurboModuleRegistry } from 'react-native';
/**
* Codegen spec of the native dictation module.
*
* This file is duplicated (byte for byte) in `package/native-package/src/native/NativeAIDictation.ts`
* and `package/expo-package/src/native/NativeAIDictation.ts`, which are the copies codegen reads.
* `yarn validate-entry-isolation` fails if they drift apart.
* Duplicated byte for byte in `package/native-package/src/native/NativeAIDictation.ts` and
* `package/expo-package/src/native/NativeAIDictation.ts`, the copies codegen reads;
* `yarn validate-entry-isolation` fails when they drift apart.
*
* Events are delivered through `addListener`/`removeListeners` and a `NativeEventEmitter` (see
* `../native/AIDictation.ts`) rather than codegen `EventEmitter` properties, since the latter are
* not understood by the codegen of every React Native version the SDK supports (>= 0.76).
* Events go through `addListener`/`removeListeners` and a `NativeEventEmitter` (see
* `../native/AIDictation.ts`), because codegen `EventEmitter` properties are not understood by
* every React Native version the SDK supports.
*/

export type DictationStartOptions = {
Expand Down Expand Up @@ -42,7 +42,6 @@ export interface Spec extends TurboModule {
removeListeners(count: number): void;
}

// `get` rather than `getEnforcing`: the module is only compiled in when dictation is enabled
// (always on Android, opt-in on iOS), and importing the AI entry point
// must not throw when it is absent.
// `get` returns null when absent: the module is only compiled in when dictation is enabled (always
// on Android, opt-in on iOS), and importing the AI entry point must not throw without it.
export default TurboModuleRegistry.get<Spec>('AIDictation');
17 changes: 8 additions & 9 deletions package/native-package/src/native/NativeAIDictation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ import { TurboModuleRegistry } from 'react-native';
/**
* Codegen spec of the native dictation module.
*
* This file is duplicated (byte for byte) in `package/native-package/src/native/NativeAIDictation.ts`
* and `package/expo-package/src/native/NativeAIDictation.ts`, which are the copies codegen reads.
* `yarn validate-entry-isolation` fails if they drift apart.
* Duplicated byte for byte in `package/native-package/src/native/NativeAIDictation.ts` and
* `package/expo-package/src/native/NativeAIDictation.ts`, the copies codegen reads;
* `yarn validate-entry-isolation` fails when they drift apart.
*
* Events are delivered through `addListener`/`removeListeners` and a `NativeEventEmitter` (see
* `../native/AIDictation.ts`) rather than codegen `EventEmitter` properties, since the latter are
* not understood by the codegen of every React Native version the SDK supports (>= 0.76).
* Events go through `addListener`/`removeListeners` and a `NativeEventEmitter` (see
* `../native/AIDictation.ts`), because codegen `EventEmitter` properties are not understood by
* every React Native version the SDK supports.
*/

export type DictationStartOptions = {
Expand Down Expand Up @@ -42,7 +42,6 @@ export interface Spec extends TurboModule {
removeListeners(count: number): void;
}

// `get` rather than `getEnforcing`: the module is only compiled in when dictation is enabled
// (always on Android, opt-in on iOS), and importing the AI entry point
// must not throw when it is absent.
// `get` returns null when absent: the module is only compiled in when dictation is enabled (always
// on Android, opt-in on iOS), and importing the AI entry point must not throw without it.
export default TurboModuleRegistry.get<Spec>('AIDictation');
26 changes: 18 additions & 8 deletions package/src/a11y/a11yUtils.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
/**
* 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.
* Joins the non-empty `parts` into one accessibility label, separated by commas so screen readers
* pause between segments.
*
* @param parts Label segments; empty strings and non-strings are dropped.
* @returns The joined label, or an empty string when no part remains.
*/
export const composeAccessibilityLabel = (
...parts: Array<string | null | undefined | false>
): 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.
* Builds the `accessibilityValue` object for progress and seek surfaces.
*
* @param params.max Upper bound of the range.
* @param params.min Lower bound of the range. Defaults to 0.
* @param params.now Current value, clamped to `[min, max]`.
* @param params.text Optional spoken description, omitted from the result when empty.
* @returns The `accessibilityValue` object.
*/
export const formatAccessibilityValue = ({
max,
Expand All @@ -31,11 +38,14 @@ export const formatAccessibilityValue = ({
return value;
};

/**
* Merge two `accessibilityActions` arrays, deduplicating by `name` (later wins).
*/
type A11yAction = Readonly<{ name: string; label?: string }>;

/**
* Merges `accessibilityActions` lists, deduplicating by `name` (the later list wins).
*
* @param actionLists Lists of actions; `undefined` entries are skipped.
* @returns The merged actions, in first-seen order of their `name`.
*/
export const mergeAccessibilityActions = (
...actionLists: Array<ReadonlyArray<A11yAction> | undefined>
): A11yAction[] => {
Expand Down
14 changes: 8 additions & 6 deletions package/src/a11y/hooks/useA11yLabel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,22 @@ import { TranslationContext } from '../../contexts/translationContext/Translatio
import { asDynamicKey } from '../../i18n/utils';

/**
* 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.
* Returns the translated accessibility label for `key`, or `undefined` when accessibility is
* disabled, so the i18n lookup is skipped on hot list paths.
*
* Example:
* const labelParams = useMemo(() => ({ name }), [name]);
* const label = useA11yLabel('a11y/Avatar of {{name}}', labelParams);
* const label = useA11yLabel('avatar.accessibilityLabel', labelParams);
* <Image accessibilityLabel={label} />
*
* @param key Translation key, such as `avatar.accessibilityLabel`.
* @param params Interpolation values for the translation.
* @returns The label to pass to `accessibilityLabel`.
*/
export const useA11yLabel = (key: string, params?: Record<string, unknown>): string | undefined => {
const { enabled } = useAccessibilityContext();
const { t } = useContext(TranslationContext);
if (!enabled || !key) return undefined;
// `key` is a caller-supplied string, so this is the deliberate runtime-key escape hatch.
// `key` is a caller-supplied string, so it bypasses the typed translation keys.
return t(asDynamicKey(key), params);
};
11 changes: 7 additions & 4 deletions package/src/a11y/hooks/useAccessibilityActivateAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,13 @@ const accessibilityActivateActions: NonNullable<ViewProps['accessibilityActions'
];

/**
* Adds the standard screen-reader `activate` action for labeled pressables when
* SDK accessibility is enabled. Some Android pressable implementations don't
* reliably map TalkBack double-tap to `onPress`, so this bridges the generated
* accessibility action back to the existing press handler.
* Returns the screen-reader `activate` accessibility action for a pressable, wired to `onPress`.
* Some Android pressables do not map the TalkBack double-tap to `onPress`.
*
* @param params.onPress Press handler invoked on `activate`.
* @param params.shouldHandleActivate Opts the pressable in. Defaults to false.
* @returns The action props to spread on the pressable, or `undefined` when accessibility is
* disabled, the opt-in is off or there is no `onPress`.
*/
export const useAccessibilityActivateAction = <TPressEvent>({
onPress,
Expand Down
21 changes: 6 additions & 15 deletions package/src/a11y/hooks/useAccessibilityServiceEnabled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,13 @@ import { AccessibilityInfo, AppState, Platform } from 'react-native';
import { useAccessibilityContext } from '../../contexts/accessibilityContext/AccessibilityContext';

/**
* Subscribes to Android accessibility service availability and returns the live state.
* Tracks whether any Android accessibility service is running (TalkBack, Switch Access, Voice
* Access and so on, not only screen readers).
*
* `AccessibilityInfo.isAccessibilityServiceEnabled` reports whether ANY a11y service is
* running - TalkBack, Switch Access, Voice Access, Select to Speak, etc. not just
* screen readers. The signal is Android only; iOS doesn't expose it (and doesn't need to,
* since the SDK's iOS overlay trap uses `accessibilityViewIsModal`).
* React Native emits no change event for this signal, so the state is refreshed on
* `screenReaderChanged` and when the app returns to the foreground.
*
* React Native does not emit a dedicated change event for this signal, so the hook
* re-polls when the app returns to the foreground - which is the realistic path for a
* user toggling Accessibility settings and coming back to the app. The most common
* single service change (TalkBack on/off) is also covered by the `screenReaderChanged`
* listener.
*
* Returns false on non-Android platforms without subscribing.
* Returns false when the `AccessibilityContext` is disabled so consumers don't pay the
* listener cost when the SDK's a11y is opted out.
* @returns Whether a service is running. Always false on iOS and when accessibility is disabled.
*/
export const useAccessibilityServiceEnabled = (): boolean => {
const { enabled } = useAccessibilityContext();
Expand All @@ -39,7 +30,7 @@ export const useAccessibilityServiceEnabled = (): boolean => {
if (!cancelled) setIsEnabled(value);
})
.catch(() => {
// Older RN versions / certain environments may not implement this; fall back to false.
// The API may be unavailable in older RN versions; stay false.
});
};

Expand Down
17 changes: 7 additions & 10 deletions package/src/a11y/hooks/useAnnounceOnShow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,14 @@ type Options = {
};

/**
* Announces `message` once each time `visible` flips from false to true.
* Resets when `visible` flips back to false, so the next show re-announces —
* unlike `useAnnounceOnStateChange`, which announces on string change and
* dedupes consecutive identical strings.
* Announces `message` each time `visible` flips from false to true, for transient surfaces such as
* modals and bottom sheets. Hiding the surface re-arms the announcement. Does nothing while
* `message` is `undefined`.
*
* Use this for transient surfaces that appear and disappear repeatedly within
* a session (modals, autocomplete pickers, bottom sheets) where the user
* benefits from hearing the affordance on every reappearance.
*
* When `message` is undefined (typically because `useA11yLabel` returned
* undefined — a11y disabled or key missing), the hook is a no-op.
* @param visible Whether the surface is shown.
* @param message Text to announce.
* @param params.delayMs Delay before announcing. Defaults to 500.
* @param params.priority Announcement priority. Defaults to `polite`.
*/
export const useAnnounceOnShow = (
visible: boolean,
Expand Down
9 changes: 5 additions & 4 deletions package/src/a11y/hooks/useAnnounceOnStateChange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@ type UseAnnounceOnStateChangeOptions = {
};

/**
* Announces `message` whenever it changes (and is non-empty), with a debounce
* to avoid spamming the screen reader on rapid transitions.
* Announces `message` whenever it changes to a new non-empty value, debounced so rapid transitions
* do not flood the screen reader. Repeating the last announced message is skipped.
*
* Used by `AITypingIndicatorView` ("Thinking…" → "Generating…" → idle) and
* the `Indicators` family (loading → loaded → error).
* @param message Text to announce.
* @param options.debounceMs Debounce before announcing. Defaults to 250.
* @param options.priority Announcement priority. Defaults to `polite`.
*/
export const useAnnounceOnStateChange = (
message: string | null | undefined,
Expand Down
7 changes: 4 additions & 3 deletions package/src/a11y/hooks/useReducedMotionPreference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ 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.
* Tracks the OS reduce-motion setting.
*
* @returns Whether reduce motion is on. Always false when accessibility is disabled.
*/
export const useReducedMotionPreference = (): boolean => {
const { enabled } = useAccessibilityContext();
Expand All @@ -23,7 +24,7 @@ export const useReducedMotionPreference = (): boolean => {
if (!cancelled) setReduceMotion(value);
})
.catch(() => {
// Older RN or platforms without the API.
// The API may be unavailable on some platforms; stay false.
});

const subscription = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduceMotion);
Expand Down
12 changes: 4 additions & 8 deletions package/src/a11y/hooks/useResolvedModalAccessibilityProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,11 @@ export type ResolvedModalAccessibilityProps = {
};

/**
* 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 the accessibility props for a modal or sheet root: `accessibilityViewIsModal` on iOS
* to trap focus, `importantForAccessibility="yes"` on Android. On Android the caller must also set
* `"no-hide-descendants"` on background siblings.
*
* Returns an empty object when AccessibilityContext is disabled, so the modal
* stays a no-op for integrators that haven't opted in.
* @returns The props to spread on the root, or an empty object when accessibility is disabled.
*/
export const useResolvedModalAccessibilityProps = (): ResolvedModalAccessibilityProps => {
const { enabled } = useAccessibilityContext();
Expand Down
8 changes: 3 additions & 5 deletions package/src/a11y/hooks/useScreenReaderEnabled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,10 @@ import { useAccessibilityContext } from '../../contexts/accessibilityContext/Acc
import { useScreenReaderContext } from '../../contexts/screenReaderContext/ScreenReaderContext';

/**
* Returns the live screen-reader state from the app-wide {@link ScreenReaderContext}.
* 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.
* Reads the screen-reader state from the app-wide {@link ScreenReaderContext}.
*
* `forceScreenReaderMode: true` in the config short-circuits to true (used in tests
* and for integrator preview).
* @returns Whether a screen reader is on. False when accessibility is disabled, whatever the OS
* reports; true when `forceScreenReaderMode` is set in the accessibility config.
*/
export const useScreenReaderEnabled = (): boolean => {
const { enabled, forceScreenReaderMode } = useAccessibilityContext();
Expand Down
7 changes: 4 additions & 3 deletions package/src/a11y/hooks/useScreenReaderMountFocus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ import {
} from './useSetAccessibilityFocus';

/**
* Moves the screen reader cursor onto `target` as it mounts (and refires once the
* screen reader state resolves, since that is detected asynchronously). Like
* {@link useSetAccessibilityFocus}, it only moves accessibility focus.
* Moves the screen reader cursor onto `target` on mount, and again once the asynchronously detected
* screen reader state resolves. Like {@link useSetAccessibilityFocus}, it only moves focus.
*
* @param target The ref or native node handle to focus.
*/
export const useScreenReaderMountFocus = (target: AccessibilityFocusTarget) => {
const setAccessibilityFocus = useSetAccessibilityFocus();
Expand Down
33 changes: 10 additions & 23 deletions package/src/a11y/hooks/useSetAccessibilityFocus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,7 @@ import { useScreenReaderEnabled } from './useScreenReaderEnabled';

type FindNodeHandleArg = Parameters<typeof findNodeHandle>[0];

/**
* Something the screen reader cursor can be moved onto: a ref (whose `.current` is
* read at call time), a raw native node handle, or nothing.
*/
/** A ref (its `.current` is read at call time), a native node handle, or nothing. */
export type AccessibilityFocusTarget = RefObject<unknown> | number | null | undefined;

const resolveNode = (target: AccessibilityFocusTarget): number | null => {
Expand All @@ -23,29 +20,19 @@ const resolveNode = (target: AccessibilityFocusTarget): number | null => {
};

/**
* Returns a stable callback that moves the screen reader cursor onto a given
* target (a ref or a raw node handle). It ONLY moves accessibility focus and it
* does not activate the target: i.e no keyboard opens and no field becomes first
* responder. The user still double taps to act on whatever is focused.
*
* The target's `.current` is read at call time, so this is safe to wire into a
* deferred/event callback.
* Returns a stable callback that moves the screen reader cursor onto a target on the next frame.
* It only moves accessibility focus: it opens no keyboard and does not activate the target.
* Does nothing unless a screen reader is running (see {@link useScreenReaderEnabled}).
*
* Noop unless a screen reader is running. {@link useScreenReaderEnabled} returns
* false whenever the SDK's accessibility config is disabled, so this costs nothing
* for the default (a11y opted out) configuration.
* Focus set on mount only sticks on Android forward navigation. On iOS forward navigation and on
* back navigation, set it after the screen transition completes (React Navigation's
* `transitionEnd`); {@link useScreenReaderMountFocus} covers the mount case.
*
* Note: Navigation timing is platform nuanced: Android forward navigation lands a focus
* set on mount, whereas iOS forward navigation and back navigation on both
* platforms need it set after the screen transition completes (React Navigation's
* `transitionEnd`). {@link useScreenReaderMountFocus} covers the mount case; wire
* `transitionEnd` yourself for the rest (the SampleApp's
* `useScreenReaderComposerFocusEffect` is a reference implementation).
* @returns A callback taking the {@link AccessibilityFocusTarget} to focus.
*/
export const useSetAccessibilityFocus = () => {
const screenReaderEnabled = useScreenReaderEnabled();
// Track the latest value so the returned callback can stay referentially stable
// (it's typically wired into a focus effect and read much later).
// A ref keeps the returned callback referentially stable while reading the latest value.
const screenReaderEnabledRef = useRef(screenReaderEnabled);
screenReaderEnabledRef.current = screenReaderEnabled;

Expand All @@ -71,7 +58,7 @@ export const useSetAccessibilityFocus = () => {
if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current);
}
// Defer a frame so the target has laid out before we move the cursor onto it.
// Defer a frame so the target has laid out before the cursor moves onto it.
rafRef.current = requestAnimationFrame(() => {
rafRef.current = null;
AccessibilityInfo.setAccessibilityFocus(node);
Expand Down
5 changes: 2 additions & 3 deletions package/src/ai-components/AITypingIndicatorView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@ export const AITypingIndicatorView = ({ text }: { text: string }) => {
} = useTheme();
const styles = useStyles();

// The native shimmer paints behind its children, so it is laid over the text instead: the
// highlight has to sweep across the glyphs, which a transparent background would otherwise hide.
// The native shimmer paints behind its children, so it overlays the text.
return (
<View style={styles.wrapper}>
<Text style={styles.text}>{text}</Text>
Expand All @@ -35,7 +34,7 @@ const useStyles = () => {
return useMemo(
() =>
StyleSheet.create({
// The native highlight band is soft and wide, so it is kept fairly strong over the text.
// The native highlight band is soft and wide, so the opacity stays high.
shimmer: { opacity: 0.8 },
text: {
fontSize: 16,
Expand Down
2 changes: 1 addition & 1 deletion package/src/ai-components/charts/components/PieLegend.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
gap: 6,
maxWidth: '48%', // lets two items sit per row, tweak as needed
maxWidth: '48%', // Two items per row.
},
swatch: {
borderRadius: 2,
Expand Down
Loading
Loading