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 (