diff --git a/package/src/components/Accessibility/OverlayA11yShield.tsx b/package/src/components/Accessibility/OverlayA11yShield.tsx
new file mode 100644
index 0000000000..4c8857fdfc
--- /dev/null
+++ b/package/src/components/Accessibility/OverlayA11yShield.tsx
@@ -0,0 +1,49 @@
+import React, { PropsWithChildren } from 'react';
+import { Platform, StyleSheet, View } from 'react-native';
+
+import { useOverlayContext } from '../../contexts/overlayContext/OverlayContext';
+import { useStateStore } from '../../hooks';
+import { overlayStore } from '../../state-store/message-overlay-store';
+
+const messageOverlayActiveSelector = (state: { id: string | undefined }) => ({
+ isMessageOverlayActive: state.id !== undefined,
+});
+
+/**
+ * Android only accessibility focus trap for the OverlayProvider's children
+ * tree. iOS handles modal focus traps natively via `accessibilityViewIsModal`
+ * on each overlay's root, but Android has no equivalent prop - the only
+ * JS side mechanism is to mark siblings as `'no-hide-descendants'`.
+ *
+ * The shield wraps `{children}` in a single View whose `importantForAccessibility`
+ * flips to `'no-hide-descendants'` whenever any focus trapping overlay is
+ * active (the full screen image/video gallery or the message context menu).
+ * When closed, the wrapper is a transparent passthrough.
+ *
+ * In terms of rerendering, only the wrapper View commits new props on overlay state
+ * transitions. The `{children}` element reference is stable across renders,
+ * so React reconciliation does not rerender any component below the wrapper.
+ *
+ * On iOS the wrapper is skipped entirely.
+ */
+export function OverlayA11yShield({ children }: PropsWithChildren) {
+ const { overlay } = useOverlayContext();
+ const { isMessageOverlayActive } = useStateStore(overlayStore, messageOverlayActiveSelector);
+
+ if (Platform.OS !== 'android') {
+ return <>{children}>;
+ }
+
+ const isAnyOverlayActive = overlay === 'gallery' || isMessageOverlayActive;
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/package/src/components/Accessibility/__tests__/OverlayA11yShield.test.tsx b/package/src/components/Accessibility/__tests__/OverlayA11yShield.test.tsx
new file mode 100644
index 0000000000..3da9bd7cfd
--- /dev/null
+++ b/package/src/components/Accessibility/__tests__/OverlayA11yShield.test.tsx
@@ -0,0 +1,83 @@
+import React from 'react';
+import { Platform, Text } from 'react-native';
+
+import { act, render, screen } from '@testing-library/react-native';
+
+import { OverlayContext } from '../../../contexts/overlayContext/OverlayContext';
+import { overlayStore } from '../../../state-store/message-overlay-store';
+import { OverlayA11yShield } from '../OverlayA11yShield';
+
+const setPlatform = (os: typeof Platform.OS) => {
+ Object.defineProperty(Platform, 'OS', { configurable: true, get: () => os });
+};
+
+const renderShield = (overlay: 'none' | 'gallery' = 'none') =>
+ render(
+ undefined,
+ style: undefined,
+ } as never
+ }
+ >
+
+ child
+
+ ,
+ );
+
+// The wrapper sets `accessibilityElementsHidden` / `importantForAccessibility`
+// when an overlay is active — RTL v13 filters those elements out of a11y queries
+// by default. `includeHiddenElements: true` keeps them queryable for assertion.
+const wrapper = () => screen.queryByTestId('overlay-a11y-shield', { includeHiddenElements: true });
+
+describe('OverlayA11yShield', () => {
+ const originalOS = Platform.OS;
+ afterAll(() => setPlatform(originalOS));
+
+ beforeEach(() => {
+ act(() => {
+ overlayStore.partialNext({ closing: false, id: undefined, messageId: undefined });
+ });
+ });
+
+ describe('on Android', () => {
+ beforeAll(() => setPlatform('android'));
+
+ it('renders children inside the wrapper', () => {
+ renderShield();
+ expect(screen.getByTestId('child')).toBeTruthy();
+ expect(wrapper()).toBeTruthy();
+ });
+
+ it('does not hide descendants when no overlay is active', () => {
+ renderShield('none');
+ expect(wrapper()?.props.importantForAccessibility).toBe('auto');
+ });
+
+ it('hides descendants when the gallery overlay is active', () => {
+ renderShield('gallery');
+ expect(wrapper()?.props.importantForAccessibility).toBe('no-hide-descendants');
+ });
+
+ it('hides descendants when the message overlay opens', () => {
+ renderShield('none');
+ act(() => {
+ overlayStore.partialNext({ id: 'msg-1' });
+ });
+ expect(wrapper()?.props.importantForAccessibility).toBe('no-hide-descendants');
+ });
+ });
+
+ describe('on iOS', () => {
+ beforeAll(() => setPlatform('ios'));
+
+ it('renders children without a wrapper', () => {
+ renderShield();
+ expect(screen.getByTestId('child')).toBeTruthy();
+ expect(wrapper()).toBeNull();
+ });
+ });
+});
diff --git a/package/src/components/Attachment/Gallery.tsx b/package/src/components/Attachment/Gallery.tsx
index 0b933819eb..5a1b0bd103 100644
--- a/package/src/components/Attachment/Gallery.tsx
+++ b/package/src/components/Attachment/Gallery.tsx
@@ -1,5 +1,5 @@
-import React, { useMemo } from 'react';
-import { Pressable, StyleSheet, Text, View } from 'react-native';
+import React, { useMemo, useRef } from 'react';
+import { findNodeHandle, Pressable, StyleSheet, Text, View } from 'react-native';
import type { Attachment, LocalMessage } from 'stream-chat';
@@ -14,6 +14,7 @@ import {
import { openUrlSafely } from './utils/openUrlSafely';
+import { useA11yLabel } from '../../a11y/hooks/useA11yLabel';
import { useTranslationContext } from '../../contexts';
import { useChatConfigContext } from '../../contexts/chatConfigContext/ChatConfigContext';
import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext';
@@ -236,12 +237,19 @@ const GalleryThumbnail = ({
} = useTheme();
const { t } = useTranslationContext();
const styles = useStyles();
+ const isVideo = thumbnail.type === FileTypes.Video;
+ const thumbnailAccessibilityLabel = useA11yLabel(
+ isVideo ? 'a11y/Gallery Video' : 'a11y/Gallery Image',
+ );
+ const thumbnailAccessibilityHint = useA11yLabel('a11y/Double tap to open');
+ const thumbnailRef = useRef(null);
const openImageViewer = () => {
if (!message) {
return;
}
imageGalleryStateStore.openImageGallery({
messages: [message],
+ requesterNode: findNodeHandle(thumbnailRef.current),
selectedAttachmentUrl: thumbnail.url,
});
setOverlay('gallery');
@@ -260,8 +268,12 @@ const GalleryThumbnail = ({
};
return (
{
if (onLongPress) {
onLongPress({
diff --git a/package/src/components/Channel/__tests__/ownCapabilities.test.tsx b/package/src/components/Channel/__tests__/ownCapabilities.test.tsx
index d8a9f012be..23461cee0d 100644
--- a/package/src/components/Channel/__tests__/ownCapabilities.test.tsx
+++ b/package/src/components/Channel/__tests__/ownCapabilities.test.tsx
@@ -3,7 +3,7 @@ import { FlatList } from 'react-native';
import { SafeAreaProvider } from 'react-native-safe-area-context';
-import { act, fireEvent, render, waitFor } from '@testing-library/react-native';
+import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react-native';
import type { Channel as ChannelType, LocalMessage, StreamChat } from 'stream-chat';
import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvider';
@@ -16,6 +16,7 @@ import { generateMessage } from '../../../mock-builders/generator/message';
import { generateUser } from '../../../mock-builders/generator/user';
import { getTestClientWithUser } from '../../../mock-builders/mock';
import { registerNativeHandlers } from '../../../native';
+import { closeOverlay, finalizeCloseOverlay } from '../../../state-store';
import { Channel } from '../../Channel/Channel';
import { Chat } from '../../Chat/Chat';
import { MessageComposer } from '../../MessageInput/MessageComposer';
@@ -49,6 +50,15 @@ describe('Own capabilities', () => {
});
});
+ afterEach(() => {
+ cleanup();
+ // The overlay state-store is module-level, so an overlay opened by a
+ // previous test would still be `active` when the next test mounts.
+ // Reset it so each test starts with `isActive=false`.
+ closeOverlay();
+ finalizeCloseOverlay();
+ });
+
const getComponent = (props: Partial> = {}) => (
@@ -76,7 +86,20 @@ describe('Own capabilities', () => {
targetMessage: LocalMessage,
props: Partial> = {},
) => {
- const { findByTestId, queryByLabelText, queryByText, unmount } = render(getComponent(props));
+ const {
+ findByTestId,
+ queryByLabelText: rawQueryByLabelText,
+ queryByText,
+ unmount,
+ } = render(getComponent(props));
+ // After the overlay opens, the host layer's `accessibilityViewIsModal`
+ // marks the chat tree as hidden to RNTL. The message action list is
+ // Portal-teleported into the overlay but its React parent stays inside
+ // the chat, so the visible-elements filter excludes it. The list IS
+ // rendered (and visible in production); we pass `includeHiddenElements`
+ // to look past the modal-sibling heuristic.
+ const queryByLabelText: typeof rawQueryByLabelText = (text, options) =>
+ rawQueryByLabelText(text, { includeHiddenElements: true, ...options });
await waitFor(() => queryByText(targetMessage.text as string));
act(() => {
diff --git a/package/src/components/ChannelPreview/ChannelPreviewStatus.tsx b/package/src/components/ChannelPreview/ChannelPreviewStatus.tsx
index e17d835b1e..4dfe7ecce2 100644
--- a/package/src/components/ChannelPreview/ChannelPreviewStatus.tsx
+++ b/package/src/components/ChannelPreview/ChannelPreviewStatus.tsx
@@ -9,7 +9,7 @@ import { useTheme } from '../../contexts/themeContext/ThemeContext';
import { useTranslationContext } from '../../contexts/translationContext/TranslationContext';
import { primitives } from '../../theme';
-import { getDateString } from '../../utils/i18n/getDateString';
+import { getDateString, getDateStringForA11y } from '../../utils/i18n/getDateString';
export type ChannelPreviewStatusProps = Pick<
ChannelPreviewViewPropsWithContext,
@@ -19,7 +19,7 @@ export type ChannelPreviewStatusProps = Pick<
export const ChannelPreviewStatus = (props: ChannelPreviewStatusProps) => {
const { formatLatestMessageDate, lastMessage } = props;
- const { t, tDateTimeParser } = useTranslationContext();
+ const { t, tDateTimeParser, userLanguage } = useTranslationContext();
const styles = useStyles();
const created_at = lastMessage?.created_at;
@@ -36,11 +36,25 @@ export const ChannelPreviewStatus = (props: ChannelPreviewStatusProps) => {
[created_at, t, tDateTimeParser],
);
+ const a11yDate = useMemo(
+ () =>
+ getDateStringForA11y({
+ calendarFormatOverrides: { sameDay: 'LT' },
+ date: created_at,
+ tDateTimeParser,
+ userLanguage,
+ }),
+ [created_at, tDateTimeParser, userLanguage],
+ );
+
const visibleDate =
formatLatestMessageDate && latestMessageDate
? formatLatestMessageDate(latestMessageDate).toString()
: formattedDate;
- const labelParams = useMemo(() => ({ date: visibleDate ?? '' }), [visibleDate]);
+ const labelParams = useMemo(
+ () => ({ date: a11yDate ?? visibleDate ?? '' }),
+ [a11yDate, visibleDate],
+ );
const accessibilityLabel = useA11yLabel('a11y/Last message {{date}}', labelParams);
return (
diff --git a/package/src/components/ImageGallery/ImageGallery.tsx b/package/src/components/ImageGallery/ImageGallery.tsx
index a07076bc59..439d788339 100644
--- a/package/src/components/ImageGallery/ImageGallery.tsx
+++ b/package/src/components/ImageGallery/ImageGallery.tsx
@@ -1,5 +1,12 @@
-import React, { useCallback, useEffect, useState } from 'react';
-import { Image, ImageStyle, StyleSheet, ViewStyle } from 'react-native';
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import {
+ AccessibilityInfo,
+ Image,
+ ImageStyle,
+ Platform,
+ StyleSheet,
+ ViewStyle,
+} from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
@@ -21,6 +28,8 @@ import type {
import { useImageGalleryGestures } from './hooks/useImageGalleryGestures';
+import { useA11yLabel } from '../../a11y/hooks/useA11yLabel';
+import { useAccessibilityContext } from '../../contexts/accessibilityContext/AccessibilityContext';
import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext';
import {
ImageGalleryProviderProps,
@@ -280,13 +289,82 @@ export const ImageGalleryWithContext = (props: ImageGalleryWithContextProps) =>
setIsGridViewVisible(true);
};
+ const { enabled: isAccessibilityEnabled } = useAccessibilityContext();
+ const assetsCount = assets.length;
+ const isAdjustable = isAccessibilityEnabled;
+ const accessibilityValueParams = useMemo(
+ () => ({ count: assetsCount, position: currentIndex + 1 }),
+ [currentIndex, assetsCount],
+ );
+ const accessibilityValueText = useA11yLabel(
+ 'a11y/{{position}} of {{count}}',
+ accessibilityValueParams,
+ );
+ const accessibilityValue = useMemo(
+ () => (accessibilityValueText ? { text: accessibilityValueText } : undefined),
+ [accessibilityValueText],
+ );
+ const adjustableActions = useMemo(
+ () =>
+ isAdjustable ? [{ name: 'increment' as const }, { name: 'decrement' as const }] : undefined,
+ [isAdjustable],
+ );
+
+ const onAccessibilityAction = useCallback(
+ (event: { nativeEvent: { actionName: string } }) => {
+ if (!isAccessibilityEnabled) return;
+ const latest = imageGalleryStateStore.state.getLatestValue();
+ const latestCount = latest.assets.length;
+ const latestIndex = latest.currentIndex;
+ if (latestCount <= 1) return;
+ if (event.nativeEvent.actionName === 'increment') {
+ if (latestIndex < latestCount - 1) {
+ imageGalleryStateStore.currentIndex = latestIndex + 1;
+ }
+ } else if (event.nativeEvent.actionName === 'decrement') {
+ if (latestIndex > 0) {
+ imageGalleryStateStore.currentIndex = latestIndex - 1;
+ }
+ }
+ },
+ [imageGalleryStateStore, isAccessibilityEnabled],
+ );
+
+ useEffect(() => {
+ return () => {
+ const handle = imageGalleryStateStore.requesterNode;
+ if (handle == null) return;
+ imageGalleryStateStore.requesterNode = null;
+ // Because of the fact that iOS and Android handle supressing
+ // the content underneath differently, we have to wait a frame
+ // before iOS is allowed to attempt to refocus (it takes a frame
+ // for the native a11y tree to become aware that it no longer has
+ // an accessibilityViewIsModal sibling).
+ if (Platform.OS === 'android') {
+ AccessibilityInfo.setAccessibilityFocus(handle);
+ } else {
+ requestAnimationFrame(() => {
+ AccessibilityInfo.setAccessibilityFocus(handle);
+ });
+ }
+ };
+ }, [imageGalleryStateStore]);
+
return (
-
+
{
);
await waitFor(() => {
- expect(screen.queryAllByLabelText('Image Item')).toHaveLength(2);
- expect(screen.queryAllByLabelText('Image Gallery Video')).toHaveLength(1);
+ // The pager subtree is marked `accessibilityElementsHidden` /
+ // `importantForAccessibility='no-hide-descendants'` so screen readers
+ // can't land on the silent shadow views inside it; the queries here
+ // opt back into hidden elements so tests can still find the assets.
+ expect(
+ screen.queryAllByLabelText('Image Item', { includeHiddenElements: true }),
+ ).toHaveLength(2);
+ expect(
+ screen.queryAllByLabelText('Image Gallery Video', { includeHiddenElements: true }),
+ ).toHaveLength(1);
});
});
@@ -116,7 +124,9 @@ describe('ImageGallery', () => {
);
await waitFor(() => {
- const pagerStyle = StyleSheet.flatten(screen.getByTestId('image-gallery-pager').props.style);
+ const pagerStyle = StyleSheet.flatten(
+ screen.getByTestId('image-gallery-pager', { includeHiddenElements: true }).props.style,
+ );
expect(pagerStyle.direction).toBe('ltr');
});
});
diff --git a/package/src/components/ImageGallery/__tests__/ImageGalleryAdjustable.test.tsx b/package/src/components/ImageGallery/__tests__/ImageGalleryAdjustable.test.tsx
new file mode 100644
index 0000000000..9a1de6739a
--- /dev/null
+++ b/package/src/components/ImageGallery/__tests__/ImageGalleryAdjustable.test.tsx
@@ -0,0 +1,141 @@
+import React, { useEffect } from 'react';
+
+import type { SharedValue } from 'react-native-reanimated';
+
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react-native';
+
+import { LocalMessage } from 'stream-chat';
+
+import {
+ ImageGalleryContext,
+ ImageGalleryContextValue,
+} from '../../../contexts/imageGalleryContext/ImageGalleryContext';
+import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvider';
+import {
+ generateImageAttachment,
+ generateVideoAttachment,
+} from '../../../mock-builders/generator/attachment';
+import { generateMessage } from '../../../mock-builders/generator/message';
+import { ImageGalleryStateStore } from '../../../state-store/image-gallery-state-store';
+import { ImageGallery } from '../ImageGallery';
+
+jest.mock('../../../native.ts', () => {
+ const { View } = require('react-native');
+ return {
+ isFileSystemAvailable: jest.fn(() => true),
+ isImageMediaLibraryAvailable: jest.fn(() => true),
+ isShareImageAvailable: jest.fn(() => true),
+ isVideoPlayerAvailable: jest.fn(() => true),
+ NativeHandlers: { Video: View },
+ };
+});
+
+type HarnessProps = {
+ message: LocalMessage;
+ accessibilityEnabled?: boolean;
+ store: ImageGalleryStateStore;
+};
+
+const Harness = ({ accessibilityEnabled = true, message, store }: HarnessProps) => {
+ useEffect(() => {
+ const unsubscribe = store.registerSubscriptions();
+ return () => unsubscribe();
+ }, [store]);
+
+ const { attachments } = message;
+ store.openImageGallery({
+ messages: [message],
+ selectedAttachmentUrl: attachments?.[0]?.asset_url || attachments?.[0]?.image_url || '',
+ });
+
+ return (
+ }}
+ >
+
+
+
+
+ );
+};
+
+const findGalleryRoot = () =>
+ screen.getByLabelText('Image Gallery', { includeHiddenElements: true });
+
+const fireAccessibilityAction = (actionName: 'increment' | 'decrement') => {
+ fireEvent(findGalleryRoot(), 'accessibilityAction', { nativeEvent: { actionName } });
+};
+
+const renderWithAssets = (assetsCount: number, accessibilityEnabled = true) => {
+ const attachments = [
+ ...Array.from({ length: Math.max(assetsCount - 1, 0) }, () => generateImageAttachment()),
+ ...(assetsCount > 0 ? [generateVideoAttachment({ type: 'video' })] : []),
+ ];
+ const message = generateMessage({ attachments });
+ const store = new ImageGalleryStateStore();
+ render();
+ return { store };
+};
+
+describe('ImageGallery adjustable cycling', () => {
+ it('marks the root as adjustable with the position value when a11y is enabled and there is more than one asset', async () => {
+ renderWithAssets(3);
+
+ await waitFor(() => {
+ const root = findGalleryRoot();
+ expect(root.props.accessibilityRole).toBe('adjustable');
+ expect(root.props.accessibilityValue).toEqual({ text: '1 of 3' });
+ expect(root.props.accessibilityActions).toEqual([
+ { name: 'increment' },
+ { name: 'decrement' },
+ ]);
+ });
+ });
+
+ it('does not apply the adjustable role when accessibility is disabled', async () => {
+ renderWithAssets(3, false);
+
+ await waitFor(() => {
+ const root = findGalleryRoot();
+ expect(root.props.accessibilityRole).toBeUndefined();
+ expect(root.props.accessibilityActions).toBeUndefined();
+ });
+ });
+
+ it('moves to the next asset on increment and clamps at the last asset', async () => {
+ const { store } = renderWithAssets(3);
+
+ await waitFor(() => expect(findGalleryRoot().props.accessibilityRole).toBe('adjustable'));
+
+ act(() => fireAccessibilityAction('increment'));
+ expect(store.state.getLatestValue().currentIndex).toBe(1);
+
+ act(() => fireAccessibilityAction('increment'));
+ expect(store.state.getLatestValue().currentIndex).toBe(2);
+
+ act(() => fireAccessibilityAction('increment'));
+ expect(store.state.getLatestValue().currentIndex).toBe(2);
+ });
+
+ it('moves to the previous asset on decrement and clamps at the first asset', async () => {
+ const { store } = renderWithAssets(3);
+
+ await waitFor(() => expect(findGalleryRoot().props.accessibilityRole).toBe('adjustable'));
+
+ act(() => fireAccessibilityAction('increment'));
+ act(() => fireAccessibilityAction('increment'));
+ expect(store.state.getLatestValue().currentIndex).toBe(2);
+
+ act(() => fireAccessibilityAction('decrement'));
+ expect(store.state.getLatestValue().currentIndex).toBe(1);
+
+ act(() => fireAccessibilityAction('decrement'));
+ expect(store.state.getLatestValue().currentIndex).toBe(0);
+
+ act(() => fireAccessibilityAction('decrement'));
+ expect(store.state.getLatestValue().currentIndex).toBe(0);
+ });
+});
diff --git a/package/src/components/Message/MessageItemView/Headers/MessageReminderHeader.tsx b/package/src/components/Message/MessageItemView/Headers/MessageReminderHeader.tsx
index 578e417e2b..9b33ad7dc9 100644
--- a/package/src/components/Message/MessageItemView/Headers/MessageReminderHeader.tsx
+++ b/package/src/components/Message/MessageItemView/Headers/MessageReminderHeader.tsx
@@ -33,7 +33,13 @@ const MessageReminderHeaderWithContext = (props: MessageReminderHeaderPropsWithC
{isReminderTimeLeft ? t('Reminder set') : t('Reminder overdue')}
- ·
+
+ ·
+
{t('duration/Message reminder', {
milliseconds: timeLeftMs,
diff --git a/package/src/components/Message/MessageItemView/Headers/SentToChannelHeader.tsx b/package/src/components/Message/MessageItemView/Headers/SentToChannelHeader.tsx
index 15e775e975..2f785e617a 100644
--- a/package/src/components/Message/MessageItemView/Headers/SentToChannelHeader.tsx
+++ b/package/src/components/Message/MessageItemView/Headers/SentToChannelHeader.tsx
@@ -43,7 +43,13 @@ const SentToChannelHeaderWithContext = (props: SentToChannelHeaderPropsWithConte
{showViewText ? (
<>
- ·
+
+ ·
+
{t('View')}
diff --git a/package/src/components/Message/MessageItemView/MessageContent.tsx b/package/src/components/Message/MessageItemView/MessageContent.tsx
index cded1cabbb..716dd4d651 100644
--- a/package/src/components/Message/MessageItemView/MessageContent.tsx
+++ b/package/src/components/Message/MessageItemView/MessageContent.tsx
@@ -1,5 +1,5 @@
import React, { useMemo } from 'react';
-import { ColorValue, Pressable, StyleSheet, View, ViewStyle } from 'react-native';
+import { ColorValue, Platform, Pressable, StyleSheet, View, ViewStyle } from 'react-native';
import { MessageTextContainer } from './MessageTextContainer';
@@ -55,6 +55,7 @@ export type MessageContentPropsWithContext = Pick<
| 'alignment'
| 'goToMessage'
| 'groupStyles'
+ | 'hasInteractiveAccessibilityContent'
| 'isMyMessage'
| 'message'
| 'messageContentOrder'
@@ -111,6 +112,7 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => {
enableMessageGroupingByUser,
groupStyles,
goToMessage,
+ hasInteractiveAccessibilityContent,
isMessageAIGenerated,
isMyMessage,
isVeryLastMessage,
@@ -129,6 +131,10 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => {
} = props;
const { client } = useChatContext();
const accessibilityHint = useA11yLabel('a11y/Double tap and hold to activate contextual menu');
+ const a11ySenderLabel = useA11yLabel(
+ isMyMessage ? 'a11y/Message from you' : 'a11y/Message from {{sender}}',
+ isMyMessage ? undefined : { sender: message.user?.name || message.user?.id || '' },
+ );
const {
Attachment,
FileAttachmentGroup,
@@ -317,22 +323,18 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => {
)}
>
);
-
- // Drop the Pressable's single-focus-stop behavior when the message contains
- // interactive children (poll options, attachment cells, the quoted-reply
- // navigator, shared location). Without this, VO/TalkBack subsume those
- // children into the row's one announcement and they can't be activated.
- const hasInteractiveContent = !!(
- message.poll_id ||
- message.quoted_message ||
- message.attachments?.length ||
- message.shared_location
- );
+ const a11yPressableLabel = useMemo(() => {
+ if (!a11ySenderLabel) return undefined;
+ return message.text && !hasInteractiveAccessibilityContent
+ ? `${a11ySenderLabel}. ${message.text}`
+ : a11ySenderLabel;
+ }, [a11ySenderLabel, hasInteractiveAccessibilityContent, message.text]);
return (
{
if (onLongPress) {
@@ -382,6 +384,15 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => {
]}
testID='message-content-wrapper'
>
+ {a11ySenderLabel && Platform.OS !== 'android' && hasInteractiveAccessibilityContent ? (
+
+ ) : null}
{MessageContentTopView ? : null}
{hasContentSideViews ? (
;
export const MessageContent = (props: MessageContentProps) => {
const {
alignment,
+ files,
goToMessage,
groupStyles,
+ hasInteractiveAccessibilityContent,
+ images,
isMessageAIGenerated,
isMyMessage,
message,
@@ -581,8 +601,6 @@ export const MessageContent = (props: MessageContentProps) => {
otherAttachments,
preventPress,
threadList,
- files,
- images,
videos,
} = useMessageContext();
const {
@@ -637,6 +655,7 @@ export const MessageContent = (props: MessageContentProps) => {
enableMessageGroupingByUser,
goToMessage,
groupStyles,
+ hasInteractiveAccessibilityContent,
isAttachmentEqual,
isMessageAIGenerated,
isMyMessage,
diff --git a/package/src/components/Message/MessageItemView/MessageReplies.tsx b/package/src/components/Message/MessageItemView/MessageReplies.tsx
index 1b24042bf2..3e17926e36 100644
--- a/package/src/components/Message/MessageItemView/MessageReplies.tsx
+++ b/package/src/components/Message/MessageItemView/MessageReplies.tsx
@@ -75,10 +75,18 @@ const MessageRepliesWithContext = (props: MessageRepliesPropsWithContext) => {
return null;
}
+ const replyCountLabel =
+ message.reply_count === 1
+ ? t('1 Reply')
+ : t('{{ replyCount }} Replies', { replyCount: message.reply_count });
+
return (
{alignment === 'left' ? connector : null}
{
if (onLongPress) {
@@ -114,13 +122,7 @@ const MessageRepliesWithContext = (props: MessageRepliesPropsWithContext) => {
testID='message-replies'
>
-
- {message.reply_count === 1
- ? t('1 Reply')
- : t('{{ replyCount }} Replies', {
- replyCount: message.reply_count,
- })}
-
+ {replyCountLabel}
{alignment === 'right' ? connector : null}
diff --git a/package/src/components/Message/MessageItemView/MessageRepliesAvatars.tsx b/package/src/components/Message/MessageItemView/MessageRepliesAvatars.tsx
index 7194ca9335..d7f9d4d2c8 100644
--- a/package/src/components/Message/MessageItemView/MessageRepliesAvatars.tsx
+++ b/package/src/components/Message/MessageItemView/MessageRepliesAvatars.tsx
@@ -24,7 +24,11 @@ export const MessageRepliesAvatarsWithContext = (props: MessageRepliesAvatarsPro
const avatars = message?.thread_participants || [];
return (
-
+
);
diff --git a/package/src/components/Message/MessageItemView/MessageTextContainer.tsx b/package/src/components/Message/MessageItemView/MessageTextContainer.tsx
index bc2d06f7ce..b681283b92 100644
--- a/package/src/components/Message/MessageItemView/MessageTextContainer.tsx
+++ b/package/src/components/Message/MessageItemView/MessageTextContainer.tsx
@@ -31,7 +31,13 @@ export type MessageTextProps = MessageTextContainerProps & {
export type MessageTextContainerPropsWithContext = Pick<
MessageContextValue,
- 'message' | 'onLongPress' | 'onlyEmojis' | 'onPress' | 'preventPress' | 'isMyMessage'
+ | 'hasInteractiveAccessibilityContent'
+ | 'isMyMessage'
+ | 'message'
+ | 'onLongPress'
+ | 'onlyEmojis'
+ | 'onPress'
+ | 'preventPress'
> &
Pick & {
markdownStyles?: MarkdownStyle;
@@ -45,6 +51,7 @@ const MessageTextContainerWithContext = (props: MessageTextContainerPropsWithCon
const theme = useTheme();
const {
+ hasInteractiveAccessibilityContent,
isMyMessage,
markdownRules,
markdownStyles: markdownStylesProp = {},
@@ -81,6 +88,8 @@ const MessageTextContainerWithContext = (props: MessageTextContainerPropsWithCon
return (
@@ -113,18 +122,24 @@ const areEqual = (
nextProps: MessageTextContainerPropsWithContext,
) => {
const {
+ hasInteractiveAccessibilityContent: prevHasInteractiveAccessibilityContent,
markdownStyles: prevMarkdownStyles,
message: prevMessage,
myMessageTheme: prevMyMessageTheme,
onlyEmojis: prevOnlyEmojis,
} = prevProps;
const {
+ hasInteractiveAccessibilityContent: nextHasInteractiveAccessibilityContent,
markdownStyles: nextMarkdownStyles,
message: nextMessage,
myMessageTheme: nextMyMessageTheme,
onlyEmojis: nextOnlyEmojis,
} = nextProps;
+ if (prevHasInteractiveAccessibilityContent !== nextHasInteractiveAccessibilityContent) {
+ return false;
+ }
+
const messageStatusEqual = prevMessage.status === nextMessage.status;
if (!messageStatusEqual) {
return false;
@@ -182,16 +197,24 @@ const MemoizedMessageTextContainer = React.memo(
export type MessageTextContainerProps = Partial;
export const MessageTextContainer = (props: MessageTextContainerProps) => {
- const { message, onLongPress, onlyEmojis, onPress, preventPress, isMyMessage } =
- useMessageContext();
+ const {
+ hasInteractiveAccessibilityContent,
+ isMyMessage,
+ message,
+ onLongPress,
+ onlyEmojis,
+ onPress,
+ preventPress,
+ } = useMessageContext();
const { markdownRules, messageTextNumberOfLines, myMessageTheme } = useMessagesContext();
return (
{
return (
{
fireEvent(getByTestId('custom-overlay-trigger'), 'longPress');
+ // Once the overlay opens, the host layer's `accessibilityViewIsModal`
+ // marks the chat-side subtree as hidden to RNTL. The overlay target's
+ // children are Portal-teleported but their React parent stays in the
+ // chat, so default visibility filtering excludes them. Pass
+ // `includeHiddenElements` to look past the modal-sibling heuristic.
await waitFor(() => {
- expect(getByText('outside:normal')).toBeTruthy();
- expect(getByText('inside:overlay')).toBeTruthy();
- expect(getByTestId('custom-overlay-target-placeholder')).toBeTruthy();
+ expect(getByText('outside:normal', { includeHiddenElements: true })).toBeTruthy();
+ expect(getByText('inside:overlay', { includeHiddenElements: true })).toBeTruthy();
+ expect(
+ getByTestId('custom-overlay-target-placeholder', { includeHiddenElements: true }),
+ ).toBeTruthy();
});
});
});
diff --git a/package/src/components/Message/MessageItemView/__tests__/MessageReplies.test.tsx b/package/src/components/Message/MessageItemView/__tests__/MessageReplies.test.tsx
index 4f2c7963e3..7c97d36db1 100644
--- a/package/src/components/Message/MessageItemView/__tests__/MessageReplies.test.tsx
+++ b/package/src/components/Message/MessageItemView/__tests__/MessageReplies.test.tsx
@@ -33,7 +33,11 @@ describe('MessageReplies', () => {
);
await waitFor(() => {
- expect(screen.getByTestId('message-replies')).toBeTruthy();
+ const pluralPressable = screen.getByTestId('message-replies');
+ expect(pluralPressable).toBeTruthy();
+ expect(pluralPressable.props.accessibilityRole).toBe('button');
+ expect(pluralPressable.props.accessibilityLabel).toBe('{{ replyCount }} Replies');
+ expect(pluralPressable.props.accessibilityHint).toBe('a11y/Double tap to view thread');
expect(t).toHaveBeenCalledWith('{{ replyCount }} Replies', {
replyCount: message.reply_count,
});
@@ -57,7 +61,11 @@ describe('MessageReplies', () => {
await waitFor(() => {
expect(onPressMock).toHaveBeenCalled();
- expect(screen.getByTestId('message-replies')).toBeTruthy();
+ const singularPressable = screen.getByTestId('message-replies');
+ expect(singularPressable).toBeTruthy();
+ expect(singularPressable.props.accessibilityRole).toBe('button');
+ expect(singularPressable.props.accessibilityLabel).toBe('1 Reply');
+ expect(singularPressable.props.accessibilityHint).toBe('a11y/Double tap to view thread');
expect(t).toHaveBeenCalledWith('1 Reply');
expect(screen.getByText('1 Reply')).toBeTruthy();
});
diff --git a/package/src/components/Message/hooks/useCreateMessageContext.ts b/package/src/components/Message/hooks/useCreateMessageContext.ts
index 3c2141699e..8557b507ef 100644
--- a/package/src/components/Message/hooks/useCreateMessageContext.ts
+++ b/package/src/components/Message/hooks/useCreateMessageContext.ts
@@ -1,5 +1,6 @@
import { useMemo, useRef } from 'react';
+import { useAccessibilityContext } from '../../../contexts/accessibilityContext/AccessibilityContext';
import type { MessageContextValue } from '../../../contexts/messageContext/MessageContext';
import { stringifyMessage } from '../../../utils/utils';
@@ -58,7 +59,7 @@ export const useCreateMessageContext = ({
threadList,
videos,
setQuotedMessage,
-}: MessageContextValue) => {
+}: Omit) => {
const stableGroupStyles = useStableRefValue(groupStyles);
const reactionsValue = reactions.map(({ count, own, type }) => `${own}${type}${count}`).join();
const stringifiedMessage = stringifyMessage({ message });
@@ -70,6 +71,14 @@ export const useCreateMessageContext = ({
? stringifyMessage({ includeReactions: false, message: message.quoted_message })
: '';
+ // Resolved here (not at each consumer) so the boolean lives on MessageContext
+ // and downstream components (MessageContent, MessageTextContainer) read it
+ // directly. The predicate's identity is stable in the default case; an
+ // integrator override is expected to be stable too (documented on the config).
+ const { hasInteractiveAccessibilityContent: hasInteractiveAccessibilityContentPredicate } =
+ useAccessibilityContext();
+ const hasInteractiveAccessibilityContent = hasInteractiveAccessibilityContentPredicate(message);
+
const messageContext: MessageContextValue = useMemo(
() => ({
actionsEnabled,
@@ -85,6 +94,7 @@ export const useCreateMessageContext = ({
hasAttachmentActions,
handleReaction,
handleToggleReaction,
+ hasInteractiveAccessibilityContent,
hasReactions,
messageHasOnlySingleAttachment,
images,
@@ -123,6 +133,7 @@ export const useCreateMessageContext = ({
goToMessage,
stableGroupStyles,
hasAttachmentActions,
+ hasInteractiveAccessibilityContent,
hasReactions,
messageHasOnlySingleAttachment,
lastGroupMessage,
diff --git a/package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.tsx.snap b/package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.tsx.snap
index 98851e8371..584ab311e1 100644
--- a/package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.tsx.snap
+++ b/package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.tsx.snap
@@ -818,6 +818,7 @@ exports[`AttachButton should call handleAttachButtonPress when the button is cli
{
- const { t, tDateTimeParser } = useTranslationContext();
+ const { t, tDateTimeParser, userLanguage } = useTranslationContext();
const styles = useStyles();
const dateString = useMemo(
@@ -31,9 +31,16 @@ export const InlineDateSeparator = ({ date }: InlineDateSeparatorProps) => {
[date, t, tDateTimeParser],
);
+ const a11yDateString = useMemo(
+ () => getDateStringForA11y({ date, tDateTimeParser, userLanguage }),
+ [date, tDateTimeParser, userLanguage],
+ );
+
return (
- {dateString}
+
+ {dateString}
+
);
};
diff --git a/package/src/components/MessageMenu/ReactionButton.tsx b/package/src/components/MessageMenu/ReactionButton.tsx
index f5ed0f82b2..5541ce77f8 100644
--- a/package/src/components/MessageMenu/ReactionButton.tsx
+++ b/package/src/components/MessageMenu/ReactionButton.tsx
@@ -49,17 +49,10 @@ export const ReactionButton = (props: ReactionButtonProps) => {
() => ,
[Icon, reactionIconSize],
);
- const selectedLabelState = selected ? 'selected' : 'unselected';
- const accessibilityLabelParams = useMemo(
- () => ({ selected: selectedLabelState, type }),
- [selectedLabelState, type],
- );
return (
{showProgressBar ? (
diff --git a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.tsx.snap b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.tsx.snap
index 9f68c78a50..f905a03e95 100644
--- a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.tsx.snap
+++ b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.tsx.snap
@@ -1017,6 +1017,7 @@ exports[`Thread should match thread snapshot 1`] = `
testID="date-separator"
>
boolean;
+
+/**
+ * Integrator facing override shape. Receives the SDK's baseline boolean as the
+ * second argument so overrides can extend rather than replace:
+ *
+ * ```
+ * hasInteractiveAccessibilityContent: (message, defaultValue) =>
+ * defaultValue || !!message.my_custom_field
+ * ```
+ *
+ * To replace fully, ignore the second argument. Must be stable across renders
+ * an unstable function will rerender every Message context consumer on every
+ * downstream render.
+ */
+export type HasInteractiveAccessibilityContentConfig = (
+ message: LocalMessage,
+ defaultValue: boolean,
+) => boolean;
+
+/** SDK baseline — true when the message renders interactive children. */
+const defaultHasInteractiveAccessibilityContent: HasInteractiveAccessibilityContent = (message) =>
+ !!(
+ message.poll_id ||
+ message.quoted_message ||
+ message.attachments?.length ||
+ message.shared_location
+ );
+
export type AccessibilityConfig = {
/**
* Master toggle. Default FALSE — integrators must opt in. When false, the SDK
@@ -45,10 +80,22 @@ export type AccessibilityConfig = {
imageGalleryScreenReaderMode?: A11yMode;
/** Message actions trigger. 'long-press' (no alt button), 'auto' (default — show button when SR is on), 'always-button'. */
messageActionsTrigger?: 'long-press' | 'auto' | 'always-button';
+ /**
+ * Override the SDK's "this message has interactive children" decision.
+ * Will control what it means for a message to have interactive content.
+ * This affects the way we mark the message bubble as accessible and whether
+ * we let the children handle it on their own or whether we want the bubble to
+ * handle everything. See {@link HasInteractiveAccessibilityContentConfig}.
+ */
+ hasInteractiveAccessibilityContent?: HasInteractiveAccessibilityContentConfig;
};
/** Fully-resolved config — every field is populated with its default. */
-export type ResolvedAccessibilityConfig = Required;
+export type ResolvedAccessibilityConfig = Required<
+ Omit
+> & {
+ hasInteractiveAccessibilityContent: HasInteractiveAccessibilityContent;
+};
export const accessibilityContextDefaultValue: ResolvedAccessibilityConfig = {
announceConnectionState: true,
@@ -57,6 +104,7 @@ export const accessibilityContextDefaultValue: ResolvedAccessibilityConfig = {
audioRecorderTapMode: 'auto',
enabled: false,
forceScreenReaderMode: false,
+ hasInteractiveAccessibilityContent: defaultHasInteractiveAccessibilityContent,
imageGalleryScreenReaderMode: 'auto',
messageActionsTrigger: 'auto',
};
@@ -124,6 +172,7 @@ export const AccessibilityProvider = ({
audioRecorderTapMode = accessibilityContextDefaultValue.audioRecorderTapMode,
enabled = accessibilityContextDefaultValue.enabled,
forceScreenReaderMode = accessibilityContextDefaultValue.forceScreenReaderMode,
+ hasInteractiveAccessibilityContent,
imageGalleryScreenReaderMode = accessibilityContextDefaultValue.imageGalleryScreenReaderMode,
messageActionsTrigger = accessibilityContextDefaultValue.messageActionsTrigger,
} = value ?? {};
@@ -136,6 +185,13 @@ export const AccessibilityProvider = ({
audioRecorderTapMode,
enabled,
forceScreenReaderMode,
+ hasInteractiveAccessibilityContent: hasInteractiveAccessibilityContent
+ ? (message) =>
+ hasInteractiveAccessibilityContent(
+ message,
+ accessibilityContextDefaultValue.hasInteractiveAccessibilityContent(message),
+ )
+ : accessibilityContextDefaultValue.hasInteractiveAccessibilityContent,
imageGalleryScreenReaderMode,
messageActionsTrigger,
}),
@@ -146,6 +202,7 @@ export const AccessibilityProvider = ({
audioRecorderTapMode,
enabled,
forceScreenReaderMode,
+ hasInteractiveAccessibilityContent,
imageGalleryScreenReaderMode,
messageActionsTrigger,
],
@@ -162,38 +219,5 @@ export const AccessibilityProvider = ({
);
};
-export const useAccessibilityContext = (): ResolvedAccessibilityConfig => {
- const {
- announceConnectionState,
- announceNewMessages,
- announceTypingIndicator,
- audioRecorderTapMode,
- enabled,
- forceScreenReaderMode,
- imageGalleryScreenReaderMode,
- messageActionsTrigger,
- } = useContext(AccessibilityContext);
-
- return useMemo(
- () => ({
- announceConnectionState,
- announceNewMessages,
- announceTypingIndicator,
- audioRecorderTapMode,
- enabled,
- forceScreenReaderMode,
- imageGalleryScreenReaderMode,
- messageActionsTrigger,
- }),
- [
- announceConnectionState,
- announceNewMessages,
- announceTypingIndicator,
- audioRecorderTapMode,
- enabled,
- forceScreenReaderMode,
- imageGalleryScreenReaderMode,
- messageActionsTrigger,
- ],
- );
-};
+export const useAccessibilityContext = (): ResolvedAccessibilityConfig =>
+ useContext(AccessibilityContext);
diff --git a/package/src/contexts/messageContext/MessageContext.tsx b/package/src/contexts/messageContext/MessageContext.tsx
index 1396116d6b..6dcd1585e4 100644
--- a/package/src/contexts/messageContext/MessageContext.tsx
+++ b/package/src/contexts/messageContext/MessageContext.tsx
@@ -45,6 +45,14 @@ export type MessageContextValue = {
/** Whether or not any message attachment exposes actions. */
hasAttachmentActions: boolean;
handleToggleReaction: (reactionType: string) => Promise;
+ /**
+ * Whether the message renders interactive children (poll options, attachment
+ * cells, the quoted-reply navigator, shared location) and the row should
+ * therefore drop its single focusstop so VO/TalkBack can drill into them.
+ * Resolved from `AccessibilityContext.hasInteractiveAccessibilityContent` -
+ * integrators override there to include their own custom content.
+ */
+ hasInteractiveAccessibilityContent: boolean;
/** Whether or not message has reactions */
hasReactions: boolean;
/** Whether or not message has only a single attachment */
diff --git a/package/src/contexts/overlayContext/MessageOverlayHostLayer.tsx b/package/src/contexts/overlayContext/MessageOverlayHostLayer.tsx
index cc08772d39..f1c7d49855 100644
--- a/package/src/contexts/overlayContext/MessageOverlayHostLayer.tsx
+++ b/package/src/contexts/overlayContext/MessageOverlayHostLayer.tsx
@@ -269,7 +269,11 @@ export const MessageOverlayHostLayer = () => {
return (
-
+
{isActive ? (
diff --git a/package/src/contexts/overlayContext/OverlayProvider.tsx b/package/src/contexts/overlayContext/OverlayProvider.tsx
index c367ddcc76..ec92fba33a 100644
--- a/package/src/contexts/overlayContext/OverlayProvider.tsx
+++ b/package/src/contexts/overlayContext/OverlayProvider.tsx
@@ -9,6 +9,7 @@ import { PortalProvider } from 'react-native-teleport';
import { MessageOverlayHostLayer } from './MessageOverlayHostLayer';
import { OverlayContext, OverlayProviderProps } from './OverlayContext';
+import { OverlayA11yShield } from '../../components/Accessibility/OverlayA11yShield';
import { ImageGallery } from '../../components/ImageGallery/ImageGallery';
import { useStreami18n } from '../../hooks/useStreami18n';
@@ -107,7 +108,7 @@ export const OverlayProvider = (props: PropsWithChildren)
- {children}
+ {children}
{overlay === 'gallery' && }
diff --git a/package/src/i18n/ar.json b/package/src/i18n/ar.json
index c8441fd0ca..7220c8fa3b 100644
--- a/package/src/i18n/ar.json
+++ b/package/src/i18n/ar.json
@@ -260,6 +260,7 @@
"a11y/Direct chat with {{name}}": "محادثة مباشرة مع {{name}}",
"a11y/Double tap to open": "انقر مرتين للفتح",
"a11y/Double tap to view reactions": "انقر مرتين لعرض التفاعلات",
+ "a11y/Double tap to view thread": "انقر مرتين لعرض المحادثة الفرعية",
"a11y/Editing message": "تعديل الرسالة",
"a11y/Editing message: {{text}}": "تعديل الرسالة: {{text}}",
"a11y/Last message {{date}}": "آخر رسالة {{date}}",
@@ -371,5 +372,10 @@
"a11y/Swipe right to go through different actions": "اسحب لليمين للتنقل بين الإجراءات المختلفة",
"a11y/Close": "Close",
"a11y/Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.": "Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.",
- "a11y/{{count}} unread messages": "{{count}} رسائل غير مقروءة"
+ "a11y/{{count}} unread messages": "{{count}} رسائل غير مقروءة",
+ "a11y/Message from you": "رسالة منك",
+ "a11y/Message from {{sender}}": "رسالة من {{sender}}",
+ "a11y/Gallery Image": "صورة من المعرض",
+ "a11y/Gallery Video": "فيديو من المعرض",
+ "a11y/{{position}} of {{count}}": "{{position}} من {{count}}"
}
diff --git a/package/src/i18n/en.json b/package/src/i18n/en.json
index fac2d54a8f..37ba4bddc8 100644
--- a/package/src/i18n/en.json
+++ b/package/src/i18n/en.json
@@ -260,12 +260,15 @@
"a11y/Direct chat with {{name}}": "Direct chat with {{name}}",
"a11y/Double tap to open": "Double tap to open",
"a11y/Double tap to view reactions": "Double tap to view reactions",
+ "a11y/Double tap to view thread": "Double tap to view thread",
"a11y/Editing message": "Editing message",
"a11y/Editing message: {{text}}": "Editing message: {{text}}",
"a11y/Last message {{date}}": "Last message {{date}}",
"a11y/Loading": "Loading",
"a11y/Loading failed": "Loading failed",
"a11y/Message actions": "Message actions",
+ "a11y/Message from you": "Message from you",
+ "a11y/Message from {{sender}}": "Message from {{sender}}",
"a11y/Muted": "Muted",
"a11y/New message from {{user}}": "New message from {{user}}",
"a11y/Offline": "Offline",
@@ -371,5 +374,8 @@
"size limit": "size limit",
"unknown error": "unknown error",
"unsupported file type": "unsupported file type",
- "a11y/{{count}} unread messages": "{{count}} unread messages"
+ "a11y/{{count}} unread messages": "{{count}} unread messages",
+ "a11y/Gallery Image": "Gallery image",
+ "a11y/Gallery Video": "Gallery video",
+ "a11y/{{position}} of {{count}}": "{{position}} of {{count}}"
}
diff --git a/package/src/i18n/es.json b/package/src/i18n/es.json
index 3755ab3a59..a153f414c9 100644
--- a/package/src/i18n/es.json
+++ b/package/src/i18n/es.json
@@ -260,6 +260,7 @@
"a11y/Direct chat with {{name}}": "Chat directo con {{name}}",
"a11y/Double tap to open": "Toca dos veces para abrir",
"a11y/Double tap to view reactions": "Toca dos veces para ver las reacciones",
+ "a11y/Double tap to view thread": "Toca dos veces para ver el hilo",
"a11y/Editing message": "Editando mensaje",
"a11y/Editing message: {{text}}": "Editando mensaje: {{text}}",
"a11y/Last message {{date}}": "Último mensaje {{date}}",
@@ -371,5 +372,10 @@
"a11y/Swipe right to go through different actions": "Desliza a la derecha para recorrer las diferentes acciones",
"a11y/Close": "Close",
"a11y/Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.": "Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.",
- "a11y/{{count}} unread messages": "{{count}} mensajes sin leer"
+ "a11y/{{count}} unread messages": "{{count}} mensajes sin leer",
+ "a11y/Message from you": "Mensaje tuyo",
+ "a11y/Message from {{sender}}": "Mensaje de {{sender}}",
+ "a11y/Gallery Image": "Imagen de la galería",
+ "a11y/Gallery Video": "Vídeo de la galería",
+ "a11y/{{position}} of {{count}}": "{{position}} de {{count}}"
}
diff --git a/package/src/i18n/fr.json b/package/src/i18n/fr.json
index 0c264d6978..efac795164 100644
--- a/package/src/i18n/fr.json
+++ b/package/src/i18n/fr.json
@@ -260,6 +260,7 @@
"a11y/Direct chat with {{name}}": "Discussion directe avec {{name}}",
"a11y/Double tap to open": "Appuyez deux fois pour ouvrir",
"a11y/Double tap to view reactions": "Appuyez deux fois pour voir les réactions",
+ "a11y/Double tap to view thread": "Appuyez deux fois pour voir le fil",
"a11y/Editing message": "Modification du message",
"a11y/Editing message: {{text}}": "Modification du message : {{text}}",
"a11y/Last message {{date}}": "Dernier message {{date}}",
@@ -371,5 +372,10 @@
"a11y/Swipe right to go through different actions": "Glissez vers la droite pour parcourir les différentes actions",
"a11y/Close": "Close",
"a11y/Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.": "Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.",
- "a11y/{{count}} unread messages": "{{count}} messages non lus"
+ "a11y/{{count}} unread messages": "{{count}} messages non lus",
+ "a11y/Message from you": "Votre message",
+ "a11y/Message from {{sender}}": "Message de {{sender}}",
+ "a11y/Gallery Image": "Image de la galerie",
+ "a11y/Gallery Video": "Vidéo de la galerie",
+ "a11y/{{position}} of {{count}}": "{{position}} sur {{count}}"
}
diff --git a/package/src/i18n/he.json b/package/src/i18n/he.json
index b59b165300..5ada05628d 100644
--- a/package/src/i18n/he.json
+++ b/package/src/i18n/he.json
@@ -260,6 +260,7 @@
"a11y/Direct chat with {{name}}": "צ׳אט ישיר עם {{name}}",
"a11y/Double tap to open": "הקש פעמיים כדי לפתוח",
"a11y/Double tap to view reactions": "הקש פעמיים כדי לראות תגובות",
+ "a11y/Double tap to view thread": "הקש פעמיים כדי לראות את השרשור",
"a11y/Editing message": "עריכת הודעה",
"a11y/Editing message: {{text}}": "עריכת הודעה: {{text}}",
"a11y/Last message {{date}}": "הודעה אחרונה {{date}}",
@@ -371,5 +372,10 @@
"a11y/Swipe right to go through different actions": "החלק ימינה כדי לעבור בין הפעולות השונות",
"a11y/Close": "Close",
"a11y/Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.": "Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.",
- "a11y/{{count}} unread messages": "{{count}} הודעות שלא נקראו"
+ "a11y/{{count}} unread messages": "{{count}} הודעות שלא נקראו",
+ "a11y/Message from you": "הודעה ממך",
+ "a11y/Message from {{sender}}": "הודעה מאת {{sender}}",
+ "a11y/Gallery Image": "תמונה מהגלריה",
+ "a11y/Gallery Video": "סרטון מהגלריה",
+ "a11y/{{position}} of {{count}}": "{{position}} מתוך {{count}}"
}
diff --git a/package/src/i18n/hi.json b/package/src/i18n/hi.json
index aeed1621c3..20e4cfb534 100644
--- a/package/src/i18n/hi.json
+++ b/package/src/i18n/hi.json
@@ -260,6 +260,7 @@
"a11y/Direct chat with {{name}}": "{{name}} के साथ सीधी चैट",
"a11y/Double tap to open": "खोलने के लिए दो बार टैप करें",
"a11y/Double tap to view reactions": "प्रतिक्रियाएँ देखने के लिए दो बार टैप करें",
+ "a11y/Double tap to view thread": "थ्रेड देखने के लिए दो बार टैप करें",
"a11y/Editing message": "संदेश संपादित कर रहे हैं",
"a11y/Editing message: {{text}}": "संदेश संपादित कर रहे हैं: {{text}}",
"a11y/Last message {{date}}": "अंतिम संदेश {{date}}",
@@ -371,5 +372,10 @@
"a11y/Swipe right to go through different actions": "विभिन्न क्रियाओं के बीच जाने के लिए दाएं स्वाइप करें",
"a11y/Close": "Close",
"a11y/Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.": "Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.",
- "a11y/{{count}} unread messages": "{{count}} अपठित संदेश"
+ "a11y/{{count}} unread messages": "{{count}} अपठित संदेश",
+ "a11y/Message from you": "आपका संदेश",
+ "a11y/Message from {{sender}}": "{{sender}} से संदेश",
+ "a11y/Gallery Image": "गैलरी छवि",
+ "a11y/Gallery Video": "गैलरी वीडियो",
+ "a11y/{{position}} of {{count}}": "{{count}} में से {{position}}"
}
diff --git a/package/src/i18n/it.json b/package/src/i18n/it.json
index cafe8dcdf8..e2d571d401 100644
--- a/package/src/i18n/it.json
+++ b/package/src/i18n/it.json
@@ -260,6 +260,7 @@
"a11y/Direct chat with {{name}}": "Chat diretta con {{name}}",
"a11y/Double tap to open": "Tocca due volte per aprire",
"a11y/Double tap to view reactions": "Tocca due volte per vedere le reazioni",
+ "a11y/Double tap to view thread": "Tocca due volte per vedere il thread",
"a11y/Editing message": "Modifica del messaggio",
"a11y/Editing message: {{text}}": "Modifica del messaggio: {{text}}",
"a11y/Last message {{date}}": "Ultimo messaggio {{date}}",
@@ -371,5 +372,10 @@
"a11y/Swipe right to go through different actions": "Scorri a destra per passare in rassegna le diverse azioni",
"a11y/Close": "Close",
"a11y/Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.": "Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.",
- "a11y/{{count}} unread messages": "{{count}} messaggi non letti"
+ "a11y/{{count}} unread messages": "{{count}} messaggi non letti",
+ "a11y/Message from you": "Messaggio da te",
+ "a11y/Message from {{sender}}": "Messaggio da {{sender}}",
+ "a11y/Gallery Image": "Immagine della galleria",
+ "a11y/Gallery Video": "Video della galleria",
+ "a11y/{{position}} of {{count}}": "{{position}} di {{count}}"
}
diff --git a/package/src/i18n/ja.json b/package/src/i18n/ja.json
index d9ba98adf1..da5fa54df9 100644
--- a/package/src/i18n/ja.json
+++ b/package/src/i18n/ja.json
@@ -260,6 +260,7 @@
"a11y/Direct chat with {{name}}": "{{name}}とのダイレクトチャット",
"a11y/Double tap to open": "ダブルタップで開く",
"a11y/Double tap to view reactions": "ダブルタップでリアクションを表示",
+ "a11y/Double tap to view thread": "ダブルタップでスレッドを表示",
"a11y/Editing message": "メッセージを編集中",
"a11y/Editing message: {{text}}": "メッセージを編集中: {{text}}",
"a11y/Last message {{date}}": "最後のメッセージ {{date}}",
@@ -371,5 +372,10 @@
"a11y/Swipe right to go through different actions": "右にスワイプして異なるアクションを切り替えます",
"a11y/Close": "Close",
"a11y/Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.": "Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.",
- "a11y/{{count}} unread messages": "未読メッセージ{{count}}件"
+ "a11y/{{count}} unread messages": "未読メッセージ{{count}}件",
+ "a11y/Message from you": "あなたからのメッセージ",
+ "a11y/Message from {{sender}}": "{{sender}}からのメッセージ",
+ "a11y/Gallery Image": "ギャラリー画像",
+ "a11y/Gallery Video": "ギャラリービデオ",
+ "a11y/{{position}} of {{count}}": "{{count}} 中 {{position}}"
}
diff --git a/package/src/i18n/ko.json b/package/src/i18n/ko.json
index a9dfc16186..1bd28ddeba 100644
--- a/package/src/i18n/ko.json
+++ b/package/src/i18n/ko.json
@@ -260,6 +260,7 @@
"a11y/Direct chat with {{name}}": "{{name}}님과의 다이렉트 채팅",
"a11y/Double tap to open": "두 번 탭하여 열기",
"a11y/Double tap to view reactions": "두 번 탭하여 반응 보기",
+ "a11y/Double tap to view thread": "두 번 탭하여 스레드 보기",
"a11y/Editing message": "메시지 편집 중",
"a11y/Editing message: {{text}}": "메시지 편집 중: {{text}}",
"a11y/Last message {{date}}": "마지막 메시지 {{date}}",
@@ -371,5 +372,10 @@
"a11y/Swipe right to go through different actions": "다른 작업을 탐색하려면 오른쪽으로 스와이프하세요",
"a11y/Close": "Close",
"a11y/Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.": "Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.",
- "a11y/{{count}} unread messages": "읽지 않은 메시지 {{count}}개"
+ "a11y/{{count}} unread messages": "읽지 않은 메시지 {{count}}개",
+ "a11y/Message from you": "내가 보낸 메시지",
+ "a11y/Message from {{sender}}": "{{sender}}님의 메시지",
+ "a11y/Gallery Image": "갤러리 이미지",
+ "a11y/Gallery Video": "갤러리 동영상",
+ "a11y/{{position}} of {{count}}": "{{count}}개 중 {{position}}번째"
}
diff --git a/package/src/i18n/nl.json b/package/src/i18n/nl.json
index e748128250..f128f4fb1d 100644
--- a/package/src/i18n/nl.json
+++ b/package/src/i18n/nl.json
@@ -260,6 +260,7 @@
"a11y/Direct chat with {{name}}": "Direct chat met {{name}}",
"a11y/Double tap to open": "Dubbeltik om te openen",
"a11y/Double tap to view reactions": "Dubbeltik om reacties te bekijken",
+ "a11y/Double tap to view thread": "Dubbeltik om de thread te bekijken",
"a11y/Editing message": "Bericht bewerken",
"a11y/Editing message: {{text}}": "Bericht bewerken: {{text}}",
"a11y/Last message {{date}}": "Laatste bericht {{date}}",
@@ -371,5 +372,10 @@
"a11y/Swipe right to go through different actions": "Veeg naar rechts om door verschillende acties te bladeren",
"a11y/Close": "Close",
"a11y/Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.": "Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.",
- "a11y/{{count}} unread messages": "{{count}} ongelezen berichten"
+ "a11y/{{count}} unread messages": "{{count}} ongelezen berichten",
+ "a11y/Message from you": "Bericht van jou",
+ "a11y/Message from {{sender}}": "Bericht van {{sender}}",
+ "a11y/Gallery Image": "Galerij-afbeelding",
+ "a11y/Gallery Video": "Galerij-video",
+ "a11y/{{position}} of {{count}}": "{{position}} van {{count}}"
}
diff --git a/package/src/i18n/pt-br.json b/package/src/i18n/pt-br.json
index 149dd79384..761f3cdeb0 100644
--- a/package/src/i18n/pt-br.json
+++ b/package/src/i18n/pt-br.json
@@ -260,6 +260,7 @@
"a11y/Direct chat with {{name}}": "Chat direto com {{name}}",
"a11y/Double tap to open": "Toque duas vezes para abrir",
"a11y/Double tap to view reactions": "Toque duas vezes para ver as reações",
+ "a11y/Double tap to view thread": "Toque duas vezes para ver o tópico",
"a11y/Editing message": "Editando mensagem",
"a11y/Editing message: {{text}}": "Editando mensagem: {{text}}",
"a11y/Last message {{date}}": "Última mensagem {{date}}",
@@ -371,5 +372,10 @@
"a11y/Swipe right to go through different actions": "Deslize para a direita para percorrer as diferentes ações",
"a11y/Close": "Close",
"a11y/Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.": "Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.",
- "a11y/{{count}} unread messages": "{{count}} mensagens não lidas"
+ "a11y/{{count}} unread messages": "{{count}} mensagens não lidas",
+ "a11y/Message from you": "Mensagem sua",
+ "a11y/Message from {{sender}}": "Mensagem de {{sender}}",
+ "a11y/Gallery Image": "Imagem da galeria",
+ "a11y/Gallery Video": "Vídeo da galeria",
+ "a11y/{{position}} of {{count}}": "{{position}} de {{count}}"
}
diff --git a/package/src/i18n/ru.json b/package/src/i18n/ru.json
index 0040618446..9cc17b3766 100644
--- a/package/src/i18n/ru.json
+++ b/package/src/i18n/ru.json
@@ -260,6 +260,7 @@
"a11y/Direct chat with {{name}}": "Прямой чат с {{name}}",
"a11y/Double tap to open": "Дважды коснитесь, чтобы открыть",
"a11y/Double tap to view reactions": "Дважды коснитесь, чтобы посмотреть реакции",
+ "a11y/Double tap to view thread": "Дважды коснитесь, чтобы посмотреть тред",
"a11y/Editing message": "Редактирование сообщения",
"a11y/Editing message: {{text}}": "Редактирование сообщения: {{text}}",
"a11y/Last message {{date}}": "Последнее сообщение {{date}}",
@@ -371,5 +372,10 @@
"a11y/Swipe right to go through different actions": "Смахните вправо, чтобы переключаться между действиями",
"a11y/Close": "Close",
"a11y/Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.": "Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.",
- "a11y/{{count}} unread messages": "{{count}} непрочитанных сообщений"
+ "a11y/{{count}} unread messages": "{{count}} непрочитанных сообщений",
+ "a11y/Message from you": "Сообщение от вас",
+ "a11y/Message from {{sender}}": "Сообщение от {{sender}}",
+ "a11y/Gallery Image": "Изображение из галереи",
+ "a11y/Gallery Video": "Видео из галереи",
+ "a11y/{{position}} of {{count}}": "{{position}} из {{count}}"
}
diff --git a/package/src/i18n/tr.json b/package/src/i18n/tr.json
index 9008b4dd16..49fb6797e2 100644
--- a/package/src/i18n/tr.json
+++ b/package/src/i18n/tr.json
@@ -260,6 +260,7 @@
"a11y/Direct chat with {{name}}": "{{name}} ile doğrudan sohbet",
"a11y/Double tap to open": "Açmak için iki kez dokun",
"a11y/Double tap to view reactions": "Tepkileri görmek için iki kez dokun",
+ "a11y/Double tap to view thread": "Konuyu görmek için iki kez dokun",
"a11y/Editing message": "Mesaj düzenleniyor",
"a11y/Editing message: {{text}}": "Mesaj düzenleniyor: {{text}}",
"a11y/Last message {{date}}": "Son mesaj {{date}}",
@@ -371,5 +372,10 @@
"a11y/Swipe right to go through different actions": "Farklı eylemler arasında geçiş yapmak için sağa kaydır",
"a11y/Close": "Close",
"a11y/Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.": "Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.",
- "a11y/{{count}} unread messages": "{{count}} okunmamış mesaj"
+ "a11y/{{count}} unread messages": "{{count}} okunmamış mesaj",
+ "a11y/Message from you": "Senden mesaj",
+ "a11y/Message from {{sender}}": "{{sender}} kullanıcısından mesaj",
+ "a11y/Gallery Image": "Galeri görüntüsü",
+ "a11y/Gallery Video": "Galeri videosu",
+ "a11y/{{position}} of {{count}}": "{{count}} öğeden {{position}}"
}
diff --git a/package/src/state-store/__tests__/image-gallery-state-store.test.ts b/package/src/state-store/__tests__/image-gallery-state-store.test.ts
index 76a620cd4b..377673a1d6 100644
--- a/package/src/state-store/__tests__/image-gallery-state-store.test.ts
+++ b/package/src/state-store/__tests__/image-gallery-state-store.test.ts
@@ -88,6 +88,7 @@ describe('ImageGalleryStateStore', () => {
assets: [],
currentIndex: 0,
messages: [],
+ requesterNode: null,
selectedAttachmentUrl: undefined,
});
});
diff --git a/package/src/state-store/image-gallery-state-store.ts b/package/src/state-store/image-gallery-state-store.ts
index 997892f1d5..3a024012e6 100644
--- a/package/src/state-store/image-gallery-state-store.ts
+++ b/package/src/state-store/image-gallery-state-store.ts
@@ -41,12 +41,14 @@ export type ImageGalleryState = {
messages: LocalMessage[];
selectedAttachmentUrl?: string;
currentIndex: number;
+ requesterNode: number | null;
};
const INITIAL_STATE: ImageGalleryState = {
assets: [],
currentIndex: 0,
messages: [],
+ requesterNode: null,
selectedAttachmentUrl: undefined,
};
@@ -80,6 +82,10 @@ export class ImageGalleryStateStore {
return this.state.getLatestValue().selectedAttachmentUrl;
}
+ get requesterNode() {
+ return this.state.getLatestValue().requesterNode;
+ }
+
get attachmentsWithMessage() {
const messages = this.messages;
@@ -151,6 +157,10 @@ export class ImageGalleryStateStore {
this.state.partialNext({ currentIndex });
}
+ set requesterNode(requesterNode: number | null) {
+ this.state.partialNext({ requesterNode });
+ }
+
// APIs for managing messages
appendMessages = (messages: LocalMessage[]) => {
this.state.partialNext({ messages: [...this.messages, ...messages] });
@@ -164,12 +174,14 @@ export class ImageGalleryStateStore {
openImageGallery = ({
messages,
+ requesterNode = null,
selectedAttachmentUrl,
}: {
messages: LocalMessage[];
+ requesterNode?: number | null;
selectedAttachmentUrl?: string;
}) => {
- this.state.partialNext({ messages, selectedAttachmentUrl });
+ this.state.partialNext({ messages, requesterNode, selectedAttachmentUrl });
};
subscribeToMessages = () => {
diff --git a/package/src/utils/i18n/getDateString.ts b/package/src/utils/i18n/getDateString.ts
index f836a8a818..d747ff87fa 100644
--- a/package/src/utils/i18n/getDateString.ts
+++ b/package/src/utils/i18n/getDateString.ts
@@ -1,3 +1,6 @@
+import type { TranslationLanguages } from 'stream-chat';
+
+import { calendarFormats } from './calendarFormats';
import { TimestampFormatterOptions } from './types';
import { TranslatorFunctions } from '../../contexts/translationContext';
@@ -74,3 +77,57 @@ export function getDateString({
return new Date(date).toDateString();
}
+
+type DateStringForA11yOptions = {
+ /**
+ * Optional calendar-format overrides applied on top of the locale defaults
+ * and the SDK's `sameElse: 'LL'` substitution. Use this when the visible
+ * date format diverges from the locale defaults (e.g. ChannelPreviewStatus
+ * uses `sameDay: 'LT'` to show the time instead of "Today").
+ */
+ calendarFormatOverrides?: Partial<{
+ lastDay: string;
+ lastWeek: string;
+ nextDay: string;
+ nextWeek: string;
+ sameDay: string;
+ sameElse: string;
+ }>;
+ date?: string | Date;
+ tDateTimeParser?: TranslatorFunctions['tDateTimeParser'];
+ userLanguage?: TranslationLanguages;
+};
+
+/**
+ * Produce a TTS-friendly calendar string. iOS VoiceOver reads numeric dates
+ * like "04/08/2026" character-by-character; substituting `LL` ("April 8, 2026")
+ * for the calendar `sameElse` slot fixes that. The relative slots
+ * (sameDay/lastDay/nextDay/lastWeek/nextWeek) are preserved from the locale,
+ * so "Today"/"Yesterday"/weekday names still come through.
+ */
+export const getDateStringForA11y = ({
+ calendarFormatOverrides,
+ date,
+ tDateTimeParser,
+ userLanguage,
+}: DateStringForA11yOptions): string | undefined => {
+ if (!date || (typeof date === 'string' && !Date.parse(date))) {
+ return undefined;
+ }
+ if (!tDateTimeParser) {
+ return undefined;
+ }
+
+ const parsedTime = tDateTimeParser(date);
+ if (!isDayOrMoment(parsedTime) || !parsedTime.calendar) {
+ return undefined;
+ }
+
+ const localeFormats = (userLanguage && calendarFormats[userLanguage]) || calendarFormats.en;
+
+ return parsedTime.calendar(undefined, {
+ ...localeFormats,
+ sameElse: 'LL',
+ ...calendarFormatOverrides,
+ });
+};