stream-chat-react-native-core 9.3.1-beta.7 → 9.3.1-beta.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (206) hide show
  1. package/lib/commonjs/components/Accessibility/OverlayA11yShield.js +37 -0
  2. package/lib/commonjs/components/Accessibility/OverlayA11yShield.js.map +1 -0
  3. package/lib/commonjs/components/Attachment/Gallery.js +10 -0
  4. package/lib/commonjs/components/Attachment/Gallery.js.map +1 -1
  5. package/lib/commonjs/components/ChannelPreview/ChannelPreviewStatus.js +12 -3
  6. package/lib/commonjs/components/ChannelPreview/ChannelPreviewStatus.js.map +1 -1
  7. package/lib/commonjs/components/ImageGallery/ImageGallery.js +56 -1
  8. package/lib/commonjs/components/ImageGallery/ImageGallery.js.map +1 -1
  9. package/lib/commonjs/components/Message/MessageItemView/Headers/MessageReminderHeader.js +2 -0
  10. package/lib/commonjs/components/Message/MessageItemView/Headers/MessageReminderHeader.js.map +1 -1
  11. package/lib/commonjs/components/Message/MessageItemView/Headers/SentToChannelHeader.js +2 -0
  12. package/lib/commonjs/components/Message/MessageItemView/Headers/SentToChannelHeader.js.map +1 -1
  13. package/lib/commonjs/components/Message/MessageItemView/MessageContent.js +26 -5
  14. package/lib/commonjs/components/Message/MessageItemView/MessageContent.js.map +1 -1
  15. package/lib/commonjs/components/Message/MessageItemView/MessageReplies.js +7 -3
  16. package/lib/commonjs/components/Message/MessageItemView/MessageReplies.js.map +1 -1
  17. package/lib/commonjs/components/Message/MessageItemView/MessageRepliesAvatars.js +2 -0
  18. package/lib/commonjs/components/Message/MessageItemView/MessageRepliesAvatars.js.map +1 -1
  19. package/lib/commonjs/components/Message/MessageItemView/MessageTextContainer.js +16 -6
  20. package/lib/commonjs/components/Message/MessageItemView/MessageTextContainer.js.map +1 -1
  21. package/lib/commonjs/components/Message/MessageItemView/ReactionList/ReactionListItem.js +4 -0
  22. package/lib/commonjs/components/Message/MessageItemView/ReactionList/ReactionListItem.js.map +1 -1
  23. package/lib/commonjs/components/Message/hooks/useCreateMessageContext.js +6 -1
  24. package/lib/commonjs/components/Message/hooks/useCreateMessageContext.js.map +1 -1
  25. package/lib/commonjs/components/MessageList/InlineDateSeparator.js +8 -1
  26. package/lib/commonjs/components/MessageList/InlineDateSeparator.js.map +1 -1
  27. package/lib/commonjs/components/MessageMenu/ReactionButton.js +0 -7
  28. package/lib/commonjs/components/MessageMenu/ReactionButton.js.map +1 -1
  29. package/lib/commonjs/components/Poll/Poll.js +2 -0
  30. package/lib/commonjs/components/Poll/Poll.js.map +1 -1
  31. package/lib/commonjs/components/Poll/components/PollOption.js +6 -1
  32. package/lib/commonjs/components/Poll/components/PollOption.js.map +1 -1
  33. package/lib/commonjs/contexts/accessibilityContext/AccessibilityContext.js +6 -22
  34. package/lib/commonjs/contexts/accessibilityContext/AccessibilityContext.js.map +1 -1
  35. package/lib/commonjs/contexts/messageContext/MessageContext.js.map +1 -1
  36. package/lib/commonjs/contexts/overlayContext/MessageOverlayHostLayer.js +1 -0
  37. package/lib/commonjs/contexts/overlayContext/MessageOverlayHostLayer.js.map +1 -1
  38. package/lib/commonjs/contexts/overlayContext/OverlayProvider.js +4 -1
  39. package/lib/commonjs/contexts/overlayContext/OverlayProvider.js.map +1 -1
  40. package/lib/commonjs/i18n/ar.json +7 -1
  41. package/lib/commonjs/i18n/en.json +7 -1
  42. package/lib/commonjs/i18n/es.json +7 -1
  43. package/lib/commonjs/i18n/fr.json +7 -1
  44. package/lib/commonjs/i18n/he.json +7 -1
  45. package/lib/commonjs/i18n/hi.json +7 -1
  46. package/lib/commonjs/i18n/it.json +7 -1
  47. package/lib/commonjs/i18n/ja.json +7 -1
  48. package/lib/commonjs/i18n/ko.json +7 -1
  49. package/lib/commonjs/i18n/nl.json +7 -1
  50. package/lib/commonjs/i18n/pt-br.json +7 -1
  51. package/lib/commonjs/i18n/ru.json +7 -1
  52. package/lib/commonjs/i18n/tr.json +7 -1
  53. package/lib/commonjs/state-store/image-gallery-state-store.js +13 -0
  54. package/lib/commonjs/state-store/image-gallery-state-store.js.map +1 -1
  55. package/lib/commonjs/utils/i18n/getDateString.js +26 -1
  56. package/lib/commonjs/utils/i18n/getDateString.js.map +1 -1
  57. package/lib/commonjs/version.json +1 -1
  58. package/lib/module/components/Accessibility/OverlayA11yShield.js +37 -0
  59. package/lib/module/components/Accessibility/OverlayA11yShield.js.map +1 -0
  60. package/lib/module/components/Attachment/Gallery.js +10 -0
  61. package/lib/module/components/Attachment/Gallery.js.map +1 -1
  62. package/lib/module/components/ChannelPreview/ChannelPreviewStatus.js +12 -3
  63. package/lib/module/components/ChannelPreview/ChannelPreviewStatus.js.map +1 -1
  64. package/lib/module/components/ImageGallery/ImageGallery.js +56 -1
  65. package/lib/module/components/ImageGallery/ImageGallery.js.map +1 -1
  66. package/lib/module/components/Message/MessageItemView/Headers/MessageReminderHeader.js +2 -0
  67. package/lib/module/components/Message/MessageItemView/Headers/MessageReminderHeader.js.map +1 -1
  68. package/lib/module/components/Message/MessageItemView/Headers/SentToChannelHeader.js +2 -0
  69. package/lib/module/components/Message/MessageItemView/Headers/SentToChannelHeader.js.map +1 -1
  70. package/lib/module/components/Message/MessageItemView/MessageContent.js +26 -5
  71. package/lib/module/components/Message/MessageItemView/MessageContent.js.map +1 -1
  72. package/lib/module/components/Message/MessageItemView/MessageReplies.js +7 -3
  73. package/lib/module/components/Message/MessageItemView/MessageReplies.js.map +1 -1
  74. package/lib/module/components/Message/MessageItemView/MessageRepliesAvatars.js +2 -0
  75. package/lib/module/components/Message/MessageItemView/MessageRepliesAvatars.js.map +1 -1
  76. package/lib/module/components/Message/MessageItemView/MessageTextContainer.js +16 -6
  77. package/lib/module/components/Message/MessageItemView/MessageTextContainer.js.map +1 -1
  78. package/lib/module/components/Message/MessageItemView/ReactionList/ReactionListItem.js +4 -0
  79. package/lib/module/components/Message/MessageItemView/ReactionList/ReactionListItem.js.map +1 -1
  80. package/lib/module/components/Message/hooks/useCreateMessageContext.js +6 -1
  81. package/lib/module/components/Message/hooks/useCreateMessageContext.js.map +1 -1
  82. package/lib/module/components/MessageList/InlineDateSeparator.js +8 -1
  83. package/lib/module/components/MessageList/InlineDateSeparator.js.map +1 -1
  84. package/lib/module/components/MessageMenu/ReactionButton.js +0 -7
  85. package/lib/module/components/MessageMenu/ReactionButton.js.map +1 -1
  86. package/lib/module/components/Poll/Poll.js +2 -0
  87. package/lib/module/components/Poll/Poll.js.map +1 -1
  88. package/lib/module/components/Poll/components/PollOption.js +6 -1
  89. package/lib/module/components/Poll/components/PollOption.js.map +1 -1
  90. package/lib/module/contexts/accessibilityContext/AccessibilityContext.js +6 -22
  91. package/lib/module/contexts/accessibilityContext/AccessibilityContext.js.map +1 -1
  92. package/lib/module/contexts/messageContext/MessageContext.js.map +1 -1
  93. package/lib/module/contexts/overlayContext/MessageOverlayHostLayer.js +1 -0
  94. package/lib/module/contexts/overlayContext/MessageOverlayHostLayer.js.map +1 -1
  95. package/lib/module/contexts/overlayContext/OverlayProvider.js +4 -1
  96. package/lib/module/contexts/overlayContext/OverlayProvider.js.map +1 -1
  97. package/lib/module/i18n/ar.json +7 -1
  98. package/lib/module/i18n/en.json +7 -1
  99. package/lib/module/i18n/es.json +7 -1
  100. package/lib/module/i18n/fr.json +7 -1
  101. package/lib/module/i18n/he.json +7 -1
  102. package/lib/module/i18n/hi.json +7 -1
  103. package/lib/module/i18n/it.json +7 -1
  104. package/lib/module/i18n/ja.json +7 -1
  105. package/lib/module/i18n/ko.json +7 -1
  106. package/lib/module/i18n/nl.json +7 -1
  107. package/lib/module/i18n/pt-br.json +7 -1
  108. package/lib/module/i18n/ru.json +7 -1
  109. package/lib/module/i18n/tr.json +7 -1
  110. package/lib/module/state-store/image-gallery-state-store.js +13 -0
  111. package/lib/module/state-store/image-gallery-state-store.js.map +1 -1
  112. package/lib/module/utils/i18n/getDateString.js +26 -1
  113. package/lib/module/utils/i18n/getDateString.js.map +1 -1
  114. package/lib/module/version.json +1 -1
  115. package/lib/typescript/components/Accessibility/OverlayA11yShield.d.ts +20 -0
  116. package/lib/typescript/components/Accessibility/OverlayA11yShield.d.ts.map +1 -0
  117. package/lib/typescript/components/Attachment/Gallery.d.ts.map +1 -1
  118. package/lib/typescript/components/ChannelPreview/ChannelPreviewStatus.d.ts.map +1 -1
  119. package/lib/typescript/components/ImageGallery/ImageGallery.d.ts.map +1 -1
  120. package/lib/typescript/components/Message/MessageItemView/Headers/MessageReminderHeader.d.ts.map +1 -1
  121. package/lib/typescript/components/Message/MessageItemView/Headers/SentToChannelHeader.d.ts.map +1 -1
  122. package/lib/typescript/components/Message/MessageItemView/MessageContent.d.ts +1 -1
  123. package/lib/typescript/components/Message/MessageItemView/MessageContent.d.ts.map +1 -1
  124. package/lib/typescript/components/Message/MessageItemView/MessageReplies.d.ts.map +1 -1
  125. package/lib/typescript/components/Message/MessageItemView/MessageRepliesAvatars.d.ts.map +1 -1
  126. package/lib/typescript/components/Message/MessageItemView/MessageTextContainer.d.ts +1 -1
  127. package/lib/typescript/components/Message/MessageItemView/MessageTextContainer.d.ts.map +1 -1
  128. package/lib/typescript/components/Message/MessageItemView/ReactionList/ReactionListItem.d.ts.map +1 -1
  129. package/lib/typescript/components/Message/hooks/useCreateMessageContext.d.ts +1 -1
  130. package/lib/typescript/components/Message/hooks/useCreateMessageContext.d.ts.map +1 -1
  131. package/lib/typescript/components/MessageList/InlineDateSeparator.d.ts.map +1 -1
  132. package/lib/typescript/components/MessageMenu/ReactionButton.d.ts.map +1 -1
  133. package/lib/typescript/components/Poll/components/PollOption.d.ts.map +1 -1
  134. package/lib/typescript/contexts/accessibilityContext/AccessibilityContext.d.ts +32 -2
  135. package/lib/typescript/contexts/accessibilityContext/AccessibilityContext.d.ts.map +1 -1
  136. package/lib/typescript/contexts/messageContext/MessageContext.d.ts +8 -0
  137. package/lib/typescript/contexts/messageContext/MessageContext.d.ts.map +1 -1
  138. package/lib/typescript/contexts/overlayContext/MessageOverlayHostLayer.d.ts.map +1 -1
  139. package/lib/typescript/contexts/overlayContext/OverlayProvider.d.ts.map +1 -1
  140. package/lib/typescript/i18n/ar.json +7 -1
  141. package/lib/typescript/i18n/en.json +7 -1
  142. package/lib/typescript/i18n/es.json +7 -1
  143. package/lib/typescript/i18n/fr.json +7 -1
  144. package/lib/typescript/i18n/he.json +7 -1
  145. package/lib/typescript/i18n/hi.json +7 -1
  146. package/lib/typescript/i18n/it.json +7 -1
  147. package/lib/typescript/i18n/ja.json +7 -1
  148. package/lib/typescript/i18n/ko.json +7 -1
  149. package/lib/typescript/i18n/nl.json +7 -1
  150. package/lib/typescript/i18n/pt-br.json +7 -1
  151. package/lib/typescript/i18n/ru.json +7 -1
  152. package/lib/typescript/i18n/tr.json +7 -1
  153. package/lib/typescript/state-store/image-gallery-state-store.d.ts +5 -1
  154. package/lib/typescript/state-store/image-gallery-state-store.d.ts.map +1 -1
  155. package/lib/typescript/utils/i18n/Streami18n.d.ts +6 -0
  156. package/lib/typescript/utils/i18n/Streami18n.d.ts.map +1 -1
  157. package/lib/typescript/utils/i18n/getDateString.d.ts +28 -0
  158. package/lib/typescript/utils/i18n/getDateString.d.ts.map +1 -1
  159. package/package.json +1 -1
  160. package/src/components/Accessibility/OverlayA11yShield.tsx +49 -0
  161. package/src/components/Accessibility/__tests__/OverlayA11yShield.test.tsx +83 -0
  162. package/src/components/Attachment/Gallery.tsx +14 -2
  163. package/src/components/Channel/__tests__/ownCapabilities.test.tsx +25 -2
  164. package/src/components/ChannelPreview/ChannelPreviewStatus.tsx +17 -3
  165. package/src/components/ImageGallery/ImageGallery.tsx +82 -4
  166. package/src/components/ImageGallery/__tests__/ImageGallery.test.tsx +13 -3
  167. package/src/components/ImageGallery/__tests__/ImageGalleryAdjustable.test.tsx +141 -0
  168. package/src/components/Message/MessageItemView/Headers/MessageReminderHeader.tsx +7 -1
  169. package/src/components/Message/MessageItemView/Headers/SentToChannelHeader.tsx +7 -1
  170. package/src/components/Message/MessageItemView/MessageContent.tsx +34 -15
  171. package/src/components/Message/MessageItemView/MessageReplies.tsx +9 -7
  172. package/src/components/Message/MessageItemView/MessageRepliesAvatars.tsx +5 -1
  173. package/src/components/Message/MessageItemView/MessageTextContainer.tsx +27 -4
  174. package/src/components/Message/MessageItemView/ReactionList/ReactionListItem.tsx +2 -0
  175. package/src/components/Message/MessageItemView/__tests__/Message.test.tsx +10 -3
  176. package/src/components/Message/MessageItemView/__tests__/MessageReplies.test.tsx +10 -2
  177. package/src/components/Message/hooks/useCreateMessageContext.ts +12 -1
  178. package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.tsx.snap +3 -0
  179. package/src/components/MessageInput/__tests__/__snapshots__/SendButton.test.tsx.snap +2 -0
  180. package/src/components/MessageList/InlineDateSeparator.tsx +10 -3
  181. package/src/components/MessageMenu/ReactionButton.tsx +0 -7
  182. package/src/components/MessageMenu/__tests__/ReactionButton.test.tsx +1 -14
  183. package/src/components/Poll/Poll.tsx +1 -1
  184. package/src/components/Poll/components/PollOption.tsx +7 -1
  185. package/src/components/Thread/__tests__/__snapshots__/Thread.test.tsx.snap +1 -0
  186. package/src/contexts/accessibilityContext/AccessibilityContext.tsx +60 -36
  187. package/src/contexts/messageContext/MessageContext.tsx +8 -0
  188. package/src/contexts/overlayContext/MessageOverlayHostLayer.tsx +5 -1
  189. package/src/contexts/overlayContext/OverlayProvider.tsx +2 -1
  190. package/src/i18n/ar.json +7 -1
  191. package/src/i18n/en.json +7 -1
  192. package/src/i18n/es.json +7 -1
  193. package/src/i18n/fr.json +7 -1
  194. package/src/i18n/he.json +7 -1
  195. package/src/i18n/hi.json +7 -1
  196. package/src/i18n/it.json +7 -1
  197. package/src/i18n/ja.json +7 -1
  198. package/src/i18n/ko.json +7 -1
  199. package/src/i18n/nl.json +7 -1
  200. package/src/i18n/pt-br.json +7 -1
  201. package/src/i18n/ru.json +7 -1
  202. package/src/i18n/tr.json +7 -1
  203. package/src/state-store/__tests__/image-gallery-state-store.test.ts +1 -0
  204. package/src/state-store/image-gallery-state-store.ts +13 -1
  205. package/src/utils/i18n/getDateString.ts +57 -0
  206. package/src/version.json +1 -1
@@ -0,0 +1,83 @@
1
+ import React from 'react';
2
+ import { Platform, Text } from 'react-native';
3
+
4
+ import { act, render, screen } from '@testing-library/react-native';
5
+
6
+ import { OverlayContext } from '../../../contexts/overlayContext/OverlayContext';
7
+ import { overlayStore } from '../../../state-store/message-overlay-store';
8
+ import { OverlayA11yShield } from '../OverlayA11yShield';
9
+
10
+ const setPlatform = (os: typeof Platform.OS) => {
11
+ Object.defineProperty(Platform, 'OS', { configurable: true, get: () => os });
12
+ };
13
+
14
+ const renderShield = (overlay: 'none' | 'gallery' = 'none') =>
15
+ render(
16
+ <OverlayContext.Provider
17
+ value={
18
+ {
19
+ overlay,
20
+ setOverlay: () => undefined,
21
+ style: undefined,
22
+ } as never
23
+ }
24
+ >
25
+ <OverlayA11yShield>
26
+ <Text testID='child'>child</Text>
27
+ </OverlayA11yShield>
28
+ </OverlayContext.Provider>,
29
+ );
30
+
31
+ // The wrapper sets `accessibilityElementsHidden` / `importantForAccessibility`
32
+ // when an overlay is active — RTL v13 filters those elements out of a11y queries
33
+ // by default. `includeHiddenElements: true` keeps them queryable for assertion.
34
+ const wrapper = () => screen.queryByTestId('overlay-a11y-shield', { includeHiddenElements: true });
35
+
36
+ describe('OverlayA11yShield', () => {
37
+ const originalOS = Platform.OS;
38
+ afterAll(() => setPlatform(originalOS));
39
+
40
+ beforeEach(() => {
41
+ act(() => {
42
+ overlayStore.partialNext({ closing: false, id: undefined, messageId: undefined });
43
+ });
44
+ });
45
+
46
+ describe('on Android', () => {
47
+ beforeAll(() => setPlatform('android'));
48
+
49
+ it('renders children inside the wrapper', () => {
50
+ renderShield();
51
+ expect(screen.getByTestId('child')).toBeTruthy();
52
+ expect(wrapper()).toBeTruthy();
53
+ });
54
+
55
+ it('does not hide descendants when no overlay is active', () => {
56
+ renderShield('none');
57
+ expect(wrapper()?.props.importantForAccessibility).toBe('auto');
58
+ });
59
+
60
+ it('hides descendants when the gallery overlay is active', () => {
61
+ renderShield('gallery');
62
+ expect(wrapper()?.props.importantForAccessibility).toBe('no-hide-descendants');
63
+ });
64
+
65
+ it('hides descendants when the message overlay opens', () => {
66
+ renderShield('none');
67
+ act(() => {
68
+ overlayStore.partialNext({ id: 'msg-1' });
69
+ });
70
+ expect(wrapper()?.props.importantForAccessibility).toBe('no-hide-descendants');
71
+ });
72
+ });
73
+
74
+ describe('on iOS', () => {
75
+ beforeAll(() => setPlatform('ios'));
76
+
77
+ it('renders children without a wrapper', () => {
78
+ renderShield();
79
+ expect(screen.getByTestId('child')).toBeTruthy();
80
+ expect(wrapper()).toBeNull();
81
+ });
82
+ });
83
+ });
@@ -1,5 +1,5 @@
1
- import React, { useMemo } from 'react';
2
- import { Pressable, StyleSheet, Text, View } from 'react-native';
1
+ import React, { useMemo, useRef } from 'react';
2
+ import { findNodeHandle, Pressable, StyleSheet, Text, View } from 'react-native';
3
3
 
4
4
  import type { Attachment, LocalMessage } from 'stream-chat';
5
5
 
@@ -14,6 +14,7 @@ import {
14
14
 
15
15
  import { openUrlSafely } from './utils/openUrlSafely';
16
16
 
17
+ import { useA11yLabel } from '../../a11y/hooks/useA11yLabel';
17
18
  import { useTranslationContext } from '../../contexts';
18
19
  import { useChatConfigContext } from '../../contexts/chatConfigContext/ChatConfigContext';
19
20
  import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext';
@@ -236,12 +237,19 @@ const GalleryThumbnail = ({
236
237
  } = useTheme();
237
238
  const { t } = useTranslationContext();
238
239
  const styles = useStyles();
240
+ const isVideo = thumbnail.type === FileTypes.Video;
241
+ const thumbnailAccessibilityLabel = useA11yLabel(
242
+ isVideo ? 'a11y/Gallery Video' : 'a11y/Gallery Image',
243
+ );
244
+ const thumbnailAccessibilityHint = useA11yLabel('a11y/Double tap to open');
245
+ const thumbnailRef = useRef<View>(null);
239
246
  const openImageViewer = () => {
240
247
  if (!message) {
241
248
  return;
242
249
  }
243
250
  imageGalleryStateStore.openImageGallery({
244
251
  messages: [message],
252
+ requesterNode: findNodeHandle(thumbnailRef.current),
245
253
  selectedAttachmentUrl: thumbnail.url,
246
254
  });
247
255
  setOverlay('gallery');
@@ -260,8 +268,12 @@ const GalleryThumbnail = ({
260
268
  };
261
269
  return (
262
270
  <Pressable
271
+ accessibilityHint={thumbnailAccessibilityHint}
272
+ accessibilityLabel={thumbnailAccessibilityLabel}
273
+ accessibilityRole='button'
263
274
  disabled={preventPress}
264
275
  key={`gallery-item-${message.id}/${colIndex}/${rowIndex}/${imagesAndVideos.length}`}
276
+ ref={thumbnailRef}
265
277
  onLongPress={(event) => {
266
278
  if (onLongPress) {
267
279
  onLongPress({
@@ -3,7 +3,7 @@ import { FlatList } from 'react-native';
3
3
 
4
4
  import { SafeAreaProvider } from 'react-native-safe-area-context';
5
5
 
6
- import { act, fireEvent, render, waitFor } from '@testing-library/react-native';
6
+ import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react-native';
7
7
  import type { Channel as ChannelType, LocalMessage, StreamChat } from 'stream-chat';
8
8
 
9
9
  import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvider';
@@ -16,6 +16,7 @@ import { generateMessage } from '../../../mock-builders/generator/message';
16
16
  import { generateUser } from '../../../mock-builders/generator/user';
17
17
  import { getTestClientWithUser } from '../../../mock-builders/mock';
18
18
  import { registerNativeHandlers } from '../../../native';
19
+ import { closeOverlay, finalizeCloseOverlay } from '../../../state-store';
19
20
  import { Channel } from '../../Channel/Channel';
20
21
  import { Chat } from '../../Chat/Chat';
21
22
  import { MessageComposer } from '../../MessageInput/MessageComposer';
@@ -49,6 +50,15 @@ describe('Own capabilities', () => {
49
50
  });
50
51
  });
51
52
 
53
+ afterEach(() => {
54
+ cleanup();
55
+ // The overlay state-store is module-level, so an overlay opened by a
56
+ // previous test would still be `active` when the next test mounts.
57
+ // Reset it so each test starts with `isActive=false`.
58
+ closeOverlay();
59
+ finalizeCloseOverlay();
60
+ });
61
+
52
62
  const getComponent = (props: Partial<React.ComponentProps<typeof Channel>> = {}) => (
53
63
  <SafeAreaProvider>
54
64
  <OverlayProvider>
@@ -76,7 +86,20 @@ describe('Own capabilities', () => {
76
86
  targetMessage: LocalMessage,
77
87
  props: Partial<React.ComponentProps<typeof Channel>> = {},
78
88
  ) => {
79
- const { findByTestId, queryByLabelText, queryByText, unmount } = render(getComponent(props));
89
+ const {
90
+ findByTestId,
91
+ queryByLabelText: rawQueryByLabelText,
92
+ queryByText,
93
+ unmount,
94
+ } = render(getComponent(props));
95
+ // After the overlay opens, the host layer's `accessibilityViewIsModal`
96
+ // marks the chat tree as hidden to RNTL. The message action list is
97
+ // Portal-teleported into the overlay but its React parent stays inside
98
+ // the chat, so the visible-elements filter excludes it. The list IS
99
+ // rendered (and visible in production); we pass `includeHiddenElements`
100
+ // to look past the modal-sibling heuristic.
101
+ const queryByLabelText: typeof rawQueryByLabelText = (text, options) =>
102
+ rawQueryByLabelText(text, { includeHiddenElements: true, ...options });
80
103
  await waitFor(() => queryByText(targetMessage.text as string));
81
104
 
82
105
  act(() => {
@@ -9,7 +9,7 @@ import { useTheme } from '../../contexts/themeContext/ThemeContext';
9
9
  import { useTranslationContext } from '../../contexts/translationContext/TranslationContext';
10
10
 
11
11
  import { primitives } from '../../theme';
12
- import { getDateString } from '../../utils/i18n/getDateString';
12
+ import { getDateString, getDateStringForA11y } from '../../utils/i18n/getDateString';
13
13
 
14
14
  export type ChannelPreviewStatusProps = Pick<
15
15
  ChannelPreviewViewPropsWithContext,
@@ -19,7 +19,7 @@ export type ChannelPreviewStatusProps = Pick<
19
19
 
20
20
  export const ChannelPreviewStatus = (props: ChannelPreviewStatusProps) => {
21
21
  const { formatLatestMessageDate, lastMessage } = props;
22
- const { t, tDateTimeParser } = useTranslationContext();
22
+ const { t, tDateTimeParser, userLanguage } = useTranslationContext();
23
23
  const styles = useStyles();
24
24
 
25
25
  const created_at = lastMessage?.created_at;
@@ -36,11 +36,25 @@ export const ChannelPreviewStatus = (props: ChannelPreviewStatusProps) => {
36
36
  [created_at, t, tDateTimeParser],
37
37
  );
38
38
 
39
+ const a11yDate = useMemo(
40
+ () =>
41
+ getDateStringForA11y({
42
+ calendarFormatOverrides: { sameDay: 'LT' },
43
+ date: created_at,
44
+ tDateTimeParser,
45
+ userLanguage,
46
+ }),
47
+ [created_at, tDateTimeParser, userLanguage],
48
+ );
49
+
39
50
  const visibleDate =
40
51
  formatLatestMessageDate && latestMessageDate
41
52
  ? formatLatestMessageDate(latestMessageDate).toString()
42
53
  : formattedDate;
43
- const labelParams = useMemo(() => ({ date: visibleDate ?? '' }), [visibleDate]);
54
+ const labelParams = useMemo(
55
+ () => ({ date: a11yDate ?? visibleDate ?? '' }),
56
+ [a11yDate, visibleDate],
57
+ );
44
58
  const accessibilityLabel = useA11yLabel('a11y/Last message {{date}}', labelParams);
45
59
 
46
60
  return (
@@ -1,5 +1,12 @@
1
- import React, { useCallback, useEffect, useState } from 'react';
2
- import { Image, ImageStyle, StyleSheet, ViewStyle } from 'react-native';
1
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
2
+ import {
3
+ AccessibilityInfo,
4
+ Image,
5
+ ImageStyle,
6
+ Platform,
7
+ StyleSheet,
8
+ ViewStyle,
9
+ } from 'react-native';
3
10
  import { Gesture, GestureDetector } from 'react-native-gesture-handler';
4
11
 
5
12
  import Animated, {
@@ -21,6 +28,8 @@ import type {
21
28
 
22
29
  import { useImageGalleryGestures } from './hooks/useImageGalleryGestures';
23
30
 
31
+ import { useA11yLabel } from '../../a11y/hooks/useA11yLabel';
32
+ import { useAccessibilityContext } from '../../contexts/accessibilityContext/AccessibilityContext';
24
33
  import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext';
25
34
  import {
26
35
  ImageGalleryProviderProps,
@@ -280,13 +289,82 @@ export const ImageGalleryWithContext = (props: ImageGalleryWithContextProps) =>
280
289
  setIsGridViewVisible(true);
281
290
  };
282
291
 
292
+ const { enabled: isAccessibilityEnabled } = useAccessibilityContext();
293
+ const assetsCount = assets.length;
294
+ const isAdjustable = isAccessibilityEnabled;
295
+ const accessibilityValueParams = useMemo(
296
+ () => ({ count: assetsCount, position: currentIndex + 1 }),
297
+ [currentIndex, assetsCount],
298
+ );
299
+ const accessibilityValueText = useA11yLabel(
300
+ 'a11y/{{position}} of {{count}}',
301
+ accessibilityValueParams,
302
+ );
303
+ const accessibilityValue = useMemo(
304
+ () => (accessibilityValueText ? { text: accessibilityValueText } : undefined),
305
+ [accessibilityValueText],
306
+ );
307
+ const adjustableActions = useMemo(
308
+ () =>
309
+ isAdjustable ? [{ name: 'increment' as const }, { name: 'decrement' as const }] : undefined,
310
+ [isAdjustable],
311
+ );
312
+
313
+ const onAccessibilityAction = useCallback(
314
+ (event: { nativeEvent: { actionName: string } }) => {
315
+ if (!isAccessibilityEnabled) return;
316
+ const latest = imageGalleryStateStore.state.getLatestValue();
317
+ const latestCount = latest.assets.length;
318
+ const latestIndex = latest.currentIndex;
319
+ if (latestCount <= 1) return;
320
+ if (event.nativeEvent.actionName === 'increment') {
321
+ if (latestIndex < latestCount - 1) {
322
+ imageGalleryStateStore.currentIndex = latestIndex + 1;
323
+ }
324
+ } else if (event.nativeEvent.actionName === 'decrement') {
325
+ if (latestIndex > 0) {
326
+ imageGalleryStateStore.currentIndex = latestIndex - 1;
327
+ }
328
+ }
329
+ },
330
+ [imageGalleryStateStore, isAccessibilityEnabled],
331
+ );
332
+
333
+ useEffect(() => {
334
+ return () => {
335
+ const handle = imageGalleryStateStore.requesterNode;
336
+ if (handle == null) return;
337
+ imageGalleryStateStore.requesterNode = null;
338
+ // Because of the fact that iOS and Android handle supressing
339
+ // the content underneath differently, we have to wait a frame
340
+ // before iOS is allowed to attempt to refocus (it takes a frame
341
+ // for the native a11y tree to become aware that it no longer has
342
+ // an accessibilityViewIsModal sibling).
343
+ if (Platform.OS === 'android') {
344
+ AccessibilityInfo.setAccessibilityFocus(handle);
345
+ } else {
346
+ requestAnimationFrame(() => {
347
+ AccessibilityInfo.setAccessibilityFocus(handle);
348
+ });
349
+ }
350
+ };
351
+ }, [imageGalleryStateStore]);
352
+
283
353
  return (
284
354
  <Animated.View
285
- accessibilityLabel='Image Gallery'
355
+ accessibilityViewIsModal
286
356
  pointerEvents={'auto'}
287
357
  style={[StyleSheet.absoluteFill, showScreenStyle]}
288
358
  >
289
- <Animated.View style={[StyleSheet.absoluteFill, containerBackground]} />
359
+ <Animated.View
360
+ accessible
361
+ accessibilityActions={adjustableActions}
362
+ accessibilityLabel='Image Gallery'
363
+ accessibilityRole={isAdjustable ? 'adjustable' : undefined}
364
+ accessibilityValue={isAdjustable ? accessibilityValue : undefined}
365
+ onAccessibilityAction={isAdjustable ? onAccessibilityAction : undefined}
366
+ style={[StyleSheet.absoluteFill, containerBackground]}
367
+ />
290
368
  <GestureDetector gesture={Gesture.Simultaneous(singleTap, doubleTap, pinch, pan)}>
291
369
  <Animated.View style={StyleSheet.absoluteFill}>
292
370
  <Animated.View
@@ -99,8 +99,16 @@ describe('ImageGallery', () => {
99
99
  );
100
100
 
101
101
  await waitFor(() => {
102
- expect(screen.queryAllByLabelText('Image Item')).toHaveLength(2);
103
- expect(screen.queryAllByLabelText('Image Gallery Video')).toHaveLength(1);
102
+ // The pager subtree is marked `accessibilityElementsHidden` /
103
+ // `importantForAccessibility='no-hide-descendants'` so screen readers
104
+ // can't land on the silent shadow views inside it; the queries here
105
+ // opt back into hidden elements so tests can still find the assets.
106
+ expect(
107
+ screen.queryAllByLabelText('Image Item', { includeHiddenElements: true }),
108
+ ).toHaveLength(2);
109
+ expect(
110
+ screen.queryAllByLabelText('Image Gallery Video', { includeHiddenElements: true }),
111
+ ).toHaveLength(1);
104
112
  });
105
113
  });
106
114
 
@@ -116,7 +124,9 @@ describe('ImageGallery', () => {
116
124
  );
117
125
 
118
126
  await waitFor(() => {
119
- const pagerStyle = StyleSheet.flatten(screen.getByTestId('image-gallery-pager').props.style);
127
+ const pagerStyle = StyleSheet.flatten(
128
+ screen.getByTestId('image-gallery-pager', { includeHiddenElements: true }).props.style,
129
+ );
120
130
  expect(pagerStyle.direction).toBe('ltr');
121
131
  });
122
132
  });
@@ -0,0 +1,141 @@
1
+ import React, { useEffect } from 'react';
2
+
3
+ import type { SharedValue } from 'react-native-reanimated';
4
+
5
+ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react-native';
6
+
7
+ import { LocalMessage } from 'stream-chat';
8
+
9
+ import {
10
+ ImageGalleryContext,
11
+ ImageGalleryContextValue,
12
+ } from '../../../contexts/imageGalleryContext/ImageGalleryContext';
13
+ import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvider';
14
+ import {
15
+ generateImageAttachment,
16
+ generateVideoAttachment,
17
+ } from '../../../mock-builders/generator/attachment';
18
+ import { generateMessage } from '../../../mock-builders/generator/message';
19
+ import { ImageGalleryStateStore } from '../../../state-store/image-gallery-state-store';
20
+ import { ImageGallery } from '../ImageGallery';
21
+
22
+ jest.mock('../../../native.ts', () => {
23
+ const { View } = require('react-native');
24
+ return {
25
+ isFileSystemAvailable: jest.fn(() => true),
26
+ isImageMediaLibraryAvailable: jest.fn(() => true),
27
+ isShareImageAvailable: jest.fn(() => true),
28
+ isVideoPlayerAvailable: jest.fn(() => true),
29
+ NativeHandlers: { Video: View },
30
+ };
31
+ });
32
+
33
+ type HarnessProps = {
34
+ message: LocalMessage;
35
+ accessibilityEnabled?: boolean;
36
+ store: ImageGalleryStateStore;
37
+ };
38
+
39
+ const Harness = ({ accessibilityEnabled = true, message, store }: HarnessProps) => {
40
+ useEffect(() => {
41
+ const unsubscribe = store.registerSubscriptions();
42
+ return () => unsubscribe();
43
+ }, [store]);
44
+
45
+ const { attachments } = message;
46
+ store.openImageGallery({
47
+ messages: [message],
48
+ selectedAttachmentUrl: attachments?.[0]?.asset_url || attachments?.[0]?.image_url || '',
49
+ });
50
+
51
+ return (
52
+ <OverlayProvider
53
+ accessibility={{ enabled: accessibilityEnabled }}
54
+ value={{ overlayOpacity: { value: 1 } as SharedValue<number> }}
55
+ >
56
+ <ImageGalleryContext.Provider
57
+ value={{ imageGalleryStateStore: store } as unknown as ImageGalleryContextValue}
58
+ >
59
+ <ImageGallery />
60
+ </ImageGalleryContext.Provider>
61
+ </OverlayProvider>
62
+ );
63
+ };
64
+
65
+ const findGalleryRoot = () =>
66
+ screen.getByLabelText('Image Gallery', { includeHiddenElements: true });
67
+
68
+ const fireAccessibilityAction = (actionName: 'increment' | 'decrement') => {
69
+ fireEvent(findGalleryRoot(), 'accessibilityAction', { nativeEvent: { actionName } });
70
+ };
71
+
72
+ const renderWithAssets = (assetsCount: number, accessibilityEnabled = true) => {
73
+ const attachments = [
74
+ ...Array.from({ length: Math.max(assetsCount - 1, 0) }, () => generateImageAttachment()),
75
+ ...(assetsCount > 0 ? [generateVideoAttachment({ type: 'video' })] : []),
76
+ ];
77
+ const message = generateMessage({ attachments });
78
+ const store = new ImageGalleryStateStore();
79
+ render(<Harness accessibilityEnabled={accessibilityEnabled} message={message} store={store} />);
80
+ return { store };
81
+ };
82
+
83
+ describe('ImageGallery adjustable cycling', () => {
84
+ it('marks the root as adjustable with the position value when a11y is enabled and there is more than one asset', async () => {
85
+ renderWithAssets(3);
86
+
87
+ await waitFor(() => {
88
+ const root = findGalleryRoot();
89
+ expect(root.props.accessibilityRole).toBe('adjustable');
90
+ expect(root.props.accessibilityValue).toEqual({ text: '1 of 3' });
91
+ expect(root.props.accessibilityActions).toEqual([
92
+ { name: 'increment' },
93
+ { name: 'decrement' },
94
+ ]);
95
+ });
96
+ });
97
+
98
+ it('does not apply the adjustable role when accessibility is disabled', async () => {
99
+ renderWithAssets(3, false);
100
+
101
+ await waitFor(() => {
102
+ const root = findGalleryRoot();
103
+ expect(root.props.accessibilityRole).toBeUndefined();
104
+ expect(root.props.accessibilityActions).toBeUndefined();
105
+ });
106
+ });
107
+
108
+ it('moves to the next asset on increment and clamps at the last asset', async () => {
109
+ const { store } = renderWithAssets(3);
110
+
111
+ await waitFor(() => expect(findGalleryRoot().props.accessibilityRole).toBe('adjustable'));
112
+
113
+ act(() => fireAccessibilityAction('increment'));
114
+ expect(store.state.getLatestValue().currentIndex).toBe(1);
115
+
116
+ act(() => fireAccessibilityAction('increment'));
117
+ expect(store.state.getLatestValue().currentIndex).toBe(2);
118
+
119
+ act(() => fireAccessibilityAction('increment'));
120
+ expect(store.state.getLatestValue().currentIndex).toBe(2);
121
+ });
122
+
123
+ it('moves to the previous asset on decrement and clamps at the first asset', async () => {
124
+ const { store } = renderWithAssets(3);
125
+
126
+ await waitFor(() => expect(findGalleryRoot().props.accessibilityRole).toBe('adjustable'));
127
+
128
+ act(() => fireAccessibilityAction('increment'));
129
+ act(() => fireAccessibilityAction('increment'));
130
+ expect(store.state.getLatestValue().currentIndex).toBe(2);
131
+
132
+ act(() => fireAccessibilityAction('decrement'));
133
+ expect(store.state.getLatestValue().currentIndex).toBe(1);
134
+
135
+ act(() => fireAccessibilityAction('decrement'));
136
+ expect(store.state.getLatestValue().currentIndex).toBe(0);
137
+
138
+ act(() => fireAccessibilityAction('decrement'));
139
+ expect(store.state.getLatestValue().currentIndex).toBe(0);
140
+ });
141
+ });
@@ -33,7 +33,13 @@ const MessageReminderHeaderWithContext = (props: MessageReminderHeaderPropsWithC
33
33
  <Text style={styles.label}>
34
34
  {isReminderTimeLeft ? t('Reminder set') : t('Reminder overdue')}
35
35
  </Text>
36
- <Text style={styles.dot}>·</Text>
36
+ <Text
37
+ accessibilityElementsHidden
38
+ importantForAccessibility='no-hide-descendants'
39
+ style={styles.dot}
40
+ >
41
+ ·
42
+ </Text>
37
43
  <Text style={styles.time}>
38
44
  {t('duration/Message reminder', {
39
45
  milliseconds: timeLeftMs,
@@ -43,7 +43,13 @@ const SentToChannelHeaderWithContext = (props: SentToChannelHeaderPropsWithConte
43
43
  </Text>
44
44
  {showViewText ? (
45
45
  <>
46
- <Text style={styles.dot}>·</Text>
46
+ <Text
47
+ accessibilityElementsHidden
48
+ importantForAccessibility='no-hide-descendants'
49
+ style={styles.dot}
50
+ >
51
+ ·
52
+ </Text>
47
53
  <Pressable onPress={onPress}>
48
54
  <Text style={styles.link}>{t('View')}</Text>
49
55
  </Pressable>
@@ -1,5 +1,5 @@
1
1
  import React, { useMemo } from 'react';
2
- import { ColorValue, Pressable, StyleSheet, View, ViewStyle } from 'react-native';
2
+ import { ColorValue, Platform, Pressable, StyleSheet, View, ViewStyle } from 'react-native';
3
3
 
4
4
  import { MessageTextContainer } from './MessageTextContainer';
5
5
 
@@ -55,6 +55,7 @@ export type MessageContentPropsWithContext = Pick<
55
55
  | 'alignment'
56
56
  | 'goToMessage'
57
57
  | 'groupStyles'
58
+ | 'hasInteractiveAccessibilityContent'
58
59
  | 'isMyMessage'
59
60
  | 'message'
60
61
  | 'messageContentOrder'
@@ -111,6 +112,7 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => {
111
112
  enableMessageGroupingByUser,
112
113
  groupStyles,
113
114
  goToMessage,
115
+ hasInteractiveAccessibilityContent,
114
116
  isMessageAIGenerated,
115
117
  isMyMessage,
116
118
  isVeryLastMessage,
@@ -129,6 +131,10 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => {
129
131
  } = props;
130
132
  const { client } = useChatContext();
131
133
  const accessibilityHint = useA11yLabel('a11y/Double tap and hold to activate contextual menu');
134
+ const a11ySenderLabel = useA11yLabel(
135
+ isMyMessage ? 'a11y/Message from you' : 'a11y/Message from {{sender}}',
136
+ isMyMessage ? undefined : { sender: message.user?.name || message.user?.id || '' },
137
+ );
132
138
  const {
133
139
  Attachment,
134
140
  FileAttachmentGroup,
@@ -317,22 +323,18 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => {
317
323
  )}
318
324
  </>
319
325
  );
320
-
321
- // Drop the Pressable's single-focus-stop behavior when the message contains
322
- // interactive children (poll options, attachment cells, the quoted-reply
323
- // navigator, shared location). Without this, VO/TalkBack subsume those
324
- // children into the row's one announcement and they can't be activated.
325
- const hasInteractiveContent = !!(
326
- message.poll_id ||
327
- message.quoted_message ||
328
- message.attachments?.length ||
329
- message.shared_location
330
- );
326
+ const a11yPressableLabel = useMemo(() => {
327
+ if (!a11ySenderLabel) return undefined;
328
+ return message.text && !hasInteractiveAccessibilityContent
329
+ ? `${a11ySenderLabel}. ${message.text}`
330
+ : a11ySenderLabel;
331
+ }, [a11ySenderLabel, hasInteractiveAccessibilityContent, message.text]);
331
332
 
332
333
  return (
333
334
  <Pressable
335
+ accessibilityLabel={a11yPressableLabel}
334
336
  accessibilityHint={accessibilityHint}
335
- accessible={hasInteractiveContent ? false : undefined}
337
+ accessible={hasInteractiveAccessibilityContent ? false : undefined}
336
338
  disabled={preventPress}
337
339
  onLongPress={(event) => {
338
340
  if (onLongPress) {
@@ -382,6 +384,15 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => {
382
384
  ]}
383
385
  testID='message-content-wrapper'
384
386
  >
387
+ {a11ySenderLabel && Platform.OS !== 'android' && hasInteractiveAccessibilityContent ? (
388
+ <View
389
+ accessibilityLabel={a11ySenderLabel}
390
+ accessibilityHint={accessibilityHint}
391
+ accessible
392
+ pointerEvents='none'
393
+ style={StyleSheet.absoluteFill}
394
+ />
395
+ ) : null}
385
396
  {MessageContentTopView ? <MessageContentTopView /> : null}
386
397
  {hasContentSideViews ? (
387
398
  <View
@@ -414,6 +425,7 @@ const areEqual = (
414
425
  preventPress: prevPreventPress,
415
426
  goToMessage: prevGoToMessage,
416
427
  groupStyles: prevGroupStyles,
428
+ hasInteractiveAccessibilityContent: prevHasInteractiveAccessibilityContent,
417
429
  isAttachmentEqual,
418
430
  message: prevMessage,
419
431
  messageContentOrder: prevMessageContentOrder,
@@ -427,6 +439,7 @@ const areEqual = (
427
439
  preventPress: nextPreventPress,
428
440
  goToMessage: nextGoToMessage,
429
441
  groupStyles: nextGroupStyles,
442
+ hasInteractiveAccessibilityContent: nextHasInteractiveAccessibilityContent,
430
443
  message: nextMessage,
431
444
  messageContentOrder: nextMessageContentOrder,
432
445
  myMessageTheme: nextMyMessageTheme,
@@ -434,6 +447,10 @@ const areEqual = (
434
447
  t: nextT,
435
448
  } = nextProps;
436
449
 
450
+ if (prevHasInteractiveAccessibilityContent !== nextHasInteractiveAccessibilityContent) {
451
+ return false;
452
+ }
453
+
437
454
  if (prevBackgroundColor !== nextBackgroundColor) {
438
455
  return false;
439
456
  }
@@ -569,8 +586,11 @@ export type MessageContentProps = Partial<MessageContentPropsWithContext>;
569
586
  export const MessageContent = (props: MessageContentProps) => {
570
587
  const {
571
588
  alignment,
589
+ files,
572
590
  goToMessage,
573
591
  groupStyles,
592
+ hasInteractiveAccessibilityContent,
593
+ images,
574
594
  isMessageAIGenerated,
575
595
  isMyMessage,
576
596
  message,
@@ -581,8 +601,6 @@ export const MessageContent = (props: MessageContentProps) => {
581
601
  otherAttachments,
582
602
  preventPress,
583
603
  threadList,
584
- files,
585
- images,
586
604
  videos,
587
605
  } = useMessageContext();
588
606
  const {
@@ -637,6 +655,7 @@ export const MessageContent = (props: MessageContentProps) => {
637
655
  enableMessageGroupingByUser,
638
656
  goToMessage,
639
657
  groupStyles,
658
+ hasInteractiveAccessibilityContent,
640
659
  isAttachmentEqual,
641
660
  isMessageAIGenerated,
642
661
  isMyMessage,