From 926fdfbb3a02283d2930bbe0632fb7293bd4a71f Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Mon, 8 Jun 2026 18:14:06 +0200 Subject: [PATCH 01/14] fix: message bubble anchor point --- .../MessageItemView/MessageContent.tsx | 23 +++++++++++- .../AccessibilityContext.tsx | 37 +------------------ package/src/i18n/ar.json | 4 +- package/src/i18n/en.json | 2 + package/src/i18n/es.json | 4 +- package/src/i18n/fr.json | 4 +- package/src/i18n/he.json | 4 +- package/src/i18n/hi.json | 4 +- package/src/i18n/it.json | 4 +- package/src/i18n/ja.json | 4 +- package/src/i18n/ko.json | 4 +- package/src/i18n/nl.json | 4 +- package/src/i18n/pt-br.json | 4 +- package/src/i18n/ru.json | 4 +- package/src/i18n/tr.json | 4 +- 15 files changed, 62 insertions(+), 48 deletions(-) diff --git a/package/src/components/Message/MessageItemView/MessageContent.tsx b/package/src/components/Message/MessageItemView/MessageContent.tsx index cded1cabbb..e02c4433f0 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'; @@ -129,6 +129,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, @@ -329,8 +333,16 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => { message.shared_location ); + const a11yPressableLabel = useMemo(() => { + if (!a11ySenderLabel) return undefined; + return message.text && !hasInteractiveContent + ? `${a11ySenderLabel}. ${message.text}` + : a11ySenderLabel; + }, [a11ySenderLabel, hasInteractiveContent, message.text]); + return ( { ]} testID='message-content-wrapper' > + {a11ySenderLabel && Platform.OS !== 'android' && hasInteractiveContent ? ( + + ) : null} {MessageContentTopView ? : null} {hasContentSideViews ? ( { - 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/i18n/ar.json b/package/src/i18n/ar.json index c8441fd0ca..6e5a0195fd 100644 --- a/package/src/i18n/ar.json +++ b/package/src/i18n/ar.json @@ -371,5 +371,7 @@ "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}}" } diff --git a/package/src/i18n/en.json b/package/src/i18n/en.json index fac2d54a8f..061dfdcda0 100644 --- a/package/src/i18n/en.json +++ b/package/src/i18n/en.json @@ -266,6 +266,8 @@ "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", diff --git a/package/src/i18n/es.json b/package/src/i18n/es.json index 3755ab3a59..f0dec3e74a 100644 --- a/package/src/i18n/es.json +++ b/package/src/i18n/es.json @@ -371,5 +371,7 @@ "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}}" } diff --git a/package/src/i18n/fr.json b/package/src/i18n/fr.json index 0c264d6978..42c469073b 100644 --- a/package/src/i18n/fr.json +++ b/package/src/i18n/fr.json @@ -371,5 +371,7 @@ "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}}" } diff --git a/package/src/i18n/he.json b/package/src/i18n/he.json index b59b165300..451de88bbd 100644 --- a/package/src/i18n/he.json +++ b/package/src/i18n/he.json @@ -371,5 +371,7 @@ "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}}" } diff --git a/package/src/i18n/hi.json b/package/src/i18n/hi.json index aeed1621c3..2f57426953 100644 --- a/package/src/i18n/hi.json +++ b/package/src/i18n/hi.json @@ -371,5 +371,7 @@ "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}} से संदेश" } diff --git a/package/src/i18n/it.json b/package/src/i18n/it.json index cafe8dcdf8..859172eb6d 100644 --- a/package/src/i18n/it.json +++ b/package/src/i18n/it.json @@ -371,5 +371,7 @@ "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}}" } diff --git a/package/src/i18n/ja.json b/package/src/i18n/ja.json index d9ba98adf1..f6ac6e2278 100644 --- a/package/src/i18n/ja.json +++ b/package/src/i18n/ja.json @@ -371,5 +371,7 @@ "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}}からのメッセージ" } diff --git a/package/src/i18n/ko.json b/package/src/i18n/ko.json index a9dfc16186..b3b0337f49 100644 --- a/package/src/i18n/ko.json +++ b/package/src/i18n/ko.json @@ -371,5 +371,7 @@ "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}}님의 메시지" } diff --git a/package/src/i18n/nl.json b/package/src/i18n/nl.json index e748128250..2ee0603027 100644 --- a/package/src/i18n/nl.json +++ b/package/src/i18n/nl.json @@ -371,5 +371,7 @@ "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}}" } diff --git a/package/src/i18n/pt-br.json b/package/src/i18n/pt-br.json index 149dd79384..3920cffd0d 100644 --- a/package/src/i18n/pt-br.json +++ b/package/src/i18n/pt-br.json @@ -371,5 +371,7 @@ "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}}" } diff --git a/package/src/i18n/ru.json b/package/src/i18n/ru.json index 0040618446..18e5a62637 100644 --- a/package/src/i18n/ru.json +++ b/package/src/i18n/ru.json @@ -371,5 +371,7 @@ "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}}" } diff --git a/package/src/i18n/tr.json b/package/src/i18n/tr.json index 9008b4dd16..06c7029af8 100644 --- a/package/src/i18n/tr.json +++ b/package/src/i18n/tr.json @@ -371,5 +371,7 @@ "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" } From 3f2721c78204aa2e309208df38d53b420da9c6cf Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Mon, 8 Jun 2026 22:06:28 +0200 Subject: [PATCH 02/14] fix: gallery thumbnails announcement --- package/src/components/Attachment/Gallery.tsx | 9 +++++++++ package/src/i18n/ar.json | 4 +++- package/src/i18n/en.json | 4 +++- package/src/i18n/es.json | 4 +++- package/src/i18n/fr.json | 4 +++- package/src/i18n/he.json | 4 +++- package/src/i18n/hi.json | 4 +++- package/src/i18n/it.json | 4 +++- package/src/i18n/ja.json | 4 +++- package/src/i18n/ko.json | 4 +++- package/src/i18n/nl.json | 4 +++- package/src/i18n/pt-br.json | 4 +++- package/src/i18n/ru.json | 4 +++- package/src/i18n/tr.json | 4 +++- 14 files changed, 48 insertions(+), 13 deletions(-) diff --git a/package/src/components/Attachment/Gallery.tsx b/package/src/components/Attachment/Gallery.tsx index 0b933819eb..5c619beb78 100644 --- a/package/src/components/Attachment/Gallery.tsx +++ b/package/src/components/Attachment/Gallery.tsx @@ -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,6 +237,11 @@ 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 openImageViewer = () => { if (!message) { return; @@ -260,6 +266,9 @@ const GalleryThumbnail = ({ }; return ( { diff --git a/package/src/i18n/ar.json b/package/src/i18n/ar.json index 6e5a0195fd..e82fac3745 100644 --- a/package/src/i18n/ar.json +++ b/package/src/i18n/ar.json @@ -373,5 +373,7 @@ "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/Message from you": "رسالة منك", - "a11y/Message from {{sender}}": "رسالة من {{sender}}" + "a11y/Message from {{sender}}": "رسالة من {{sender}}", + "a11y/Gallery Image": "صورة من المعرض", + "a11y/Gallery Video": "فيديو من المعرض" } diff --git a/package/src/i18n/en.json b/package/src/i18n/en.json index 061dfdcda0..175be02f45 100644 --- a/package/src/i18n/en.json +++ b/package/src/i18n/en.json @@ -373,5 +373,7 @@ "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" } diff --git a/package/src/i18n/es.json b/package/src/i18n/es.json index f0dec3e74a..49a3d32fd7 100644 --- a/package/src/i18n/es.json +++ b/package/src/i18n/es.json @@ -373,5 +373,7 @@ "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/Message from you": "Mensaje tuyo", - "a11y/Message from {{sender}}": "Mensaje de {{sender}}" + "a11y/Message from {{sender}}": "Mensaje de {{sender}}", + "a11y/Gallery Image": "Imagen de la galería", + "a11y/Gallery Video": "Vídeo de la galería" } diff --git a/package/src/i18n/fr.json b/package/src/i18n/fr.json index 42c469073b..447004d411 100644 --- a/package/src/i18n/fr.json +++ b/package/src/i18n/fr.json @@ -373,5 +373,7 @@ "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/Message from you": "Votre message", - "a11y/Message from {{sender}}": "Message de {{sender}}" + "a11y/Message from {{sender}}": "Message de {{sender}}", + "a11y/Gallery Image": "Image de la galerie", + "a11y/Gallery Video": "Vidéo de la galerie" } diff --git a/package/src/i18n/he.json b/package/src/i18n/he.json index 451de88bbd..bcf3b8495b 100644 --- a/package/src/i18n/he.json +++ b/package/src/i18n/he.json @@ -373,5 +373,7 @@ "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/Message from you": "הודעה ממך", - "a11y/Message from {{sender}}": "הודעה מאת {{sender}}" + "a11y/Message from {{sender}}": "הודעה מאת {{sender}}", + "a11y/Gallery Image": "תמונה מהגלריה", + "a11y/Gallery Video": "סרטון מהגלריה" } diff --git a/package/src/i18n/hi.json b/package/src/i18n/hi.json index 2f57426953..dbf344ae7c 100644 --- a/package/src/i18n/hi.json +++ b/package/src/i18n/hi.json @@ -373,5 +373,7 @@ "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/Message from you": "आपका संदेश", - "a11y/Message from {{sender}}": "{{sender}} से संदेश" + "a11y/Message from {{sender}}": "{{sender}} से संदेश", + "a11y/Gallery Image": "गैलरी छवि", + "a11y/Gallery Video": "गैलरी वीडियो" } diff --git a/package/src/i18n/it.json b/package/src/i18n/it.json index 859172eb6d..7ba7476a44 100644 --- a/package/src/i18n/it.json +++ b/package/src/i18n/it.json @@ -373,5 +373,7 @@ "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/Message from you": "Messaggio da te", - "a11y/Message from {{sender}}": "Messaggio da {{sender}}" + "a11y/Message from {{sender}}": "Messaggio da {{sender}}", + "a11y/Gallery Image": "Immagine della galleria", + "a11y/Gallery Video": "Video della galleria" } diff --git a/package/src/i18n/ja.json b/package/src/i18n/ja.json index f6ac6e2278..e056f60abc 100644 --- a/package/src/i18n/ja.json +++ b/package/src/i18n/ja.json @@ -373,5 +373,7 @@ "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/Message from you": "あなたからのメッセージ", - "a11y/Message from {{sender}}": "{{sender}}からのメッセージ" + "a11y/Message from {{sender}}": "{{sender}}からのメッセージ", + "a11y/Gallery Image": "ギャラリー画像", + "a11y/Gallery Video": "ギャラリービデオ" } diff --git a/package/src/i18n/ko.json b/package/src/i18n/ko.json index b3b0337f49..0dfc640d22 100644 --- a/package/src/i18n/ko.json +++ b/package/src/i18n/ko.json @@ -373,5 +373,7 @@ "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/Message from you": "내가 보낸 메시지", - "a11y/Message from {{sender}}": "{{sender}}님의 메시지" + "a11y/Message from {{sender}}": "{{sender}}님의 메시지", + "a11y/Gallery Image": "갤러리 이미지", + "a11y/Gallery Video": "갤러리 동영상" } diff --git a/package/src/i18n/nl.json b/package/src/i18n/nl.json index 2ee0603027..c75693fa75 100644 --- a/package/src/i18n/nl.json +++ b/package/src/i18n/nl.json @@ -373,5 +373,7 @@ "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/Message from you": "Bericht van jou", - "a11y/Message from {{sender}}": "Bericht van {{sender}}" + "a11y/Message from {{sender}}": "Bericht van {{sender}}", + "a11y/Gallery Image": "Galerij-afbeelding", + "a11y/Gallery Video": "Galerij-video" } diff --git a/package/src/i18n/pt-br.json b/package/src/i18n/pt-br.json index 3920cffd0d..d7aeedf9a9 100644 --- a/package/src/i18n/pt-br.json +++ b/package/src/i18n/pt-br.json @@ -373,5 +373,7 @@ "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/Message from you": "Mensagem sua", - "a11y/Message from {{sender}}": "Mensagem de {{sender}}" + "a11y/Message from {{sender}}": "Mensagem de {{sender}}", + "a11y/Gallery Image": "Imagem da galeria", + "a11y/Gallery Video": "Vídeo da galeria" } diff --git a/package/src/i18n/ru.json b/package/src/i18n/ru.json index 18e5a62637..baffacb742 100644 --- a/package/src/i18n/ru.json +++ b/package/src/i18n/ru.json @@ -373,5 +373,7 @@ "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/Message from you": "Сообщение от вас", - "a11y/Message from {{sender}}": "Сообщение от {{sender}}" + "a11y/Message from {{sender}}": "Сообщение от {{sender}}", + "a11y/Gallery Image": "Изображение из галереи", + "a11y/Gallery Video": "Видео из галереи" } diff --git a/package/src/i18n/tr.json b/package/src/i18n/tr.json index 06c7029af8..1a07e23965 100644 --- a/package/src/i18n/tr.json +++ b/package/src/i18n/tr.json @@ -373,5 +373,7 @@ "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/Message from you": "Senden mesaj", - "a11y/Message from {{sender}}": "{{sender}} kullanıcısından mesaj" + "a11y/Message from {{sender}}": "{{sender}} kullanıcısından mesaj", + "a11y/Gallery Image": "Galeri görüntüsü", + "a11y/Gallery Video": "Galeri videosu" } From cee4912f8a65692b8e122f18ae287e1baecd997d Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Mon, 8 Jun 2026 22:49:57 +0200 Subject: [PATCH 03/14] fix: relevant text fields in message --- .../Message/MessageItemView/MessageTextContainer.tsx | 9 +++++++++ package/src/components/Poll/Poll.tsx | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/package/src/components/Message/MessageItemView/MessageTextContainer.tsx b/package/src/components/Message/MessageItemView/MessageTextContainer.tsx index bc2d06f7ce..cb8a162d37 100644 --- a/package/src/components/Message/MessageItemView/MessageTextContainer.tsx +++ b/package/src/components/Message/MessageItemView/MessageTextContainer.tsx @@ -79,8 +79,17 @@ const MessageTextContainerWithContext = (props: MessageTextContainerPropsWithCon const markdownStyles = { ...markdown, ...markdownStylesProp }; + const hasInteractiveContent = !!( + message.poll_id || + message.quoted_message || + message.attachments?.length || + message.shared_location + ); + return ( diff --git a/package/src/components/Poll/Poll.tsx b/package/src/components/Poll/Poll.tsx index 0716485fbc..2b6b1e8a13 100644 --- a/package/src/components/Poll/Poll.tsx +++ b/package/src/components/Poll/Poll.tsx @@ -50,7 +50,7 @@ export const PollHeader = () => { } = useTheme(); return ( - + {name} {subtitle} From 347f6184193e39139bec4be4b9d592cad281da8d Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Tue, 9 Jun 2026 00:50:52 +0200 Subject: [PATCH 04/14] fix: add way to control whether a message is interactive for a11y --- .../MessageItemView/MessageContent.tsx | 34 +++++------ .../MessageItemView/MessageTextContainer.tsx | 40 +++++++++---- .../Message/hooks/useCreateMessageContext.ts | 13 +++- .../AccessibilityContext.tsx | 59 ++++++++++++++++++- .../messageContext/MessageContext.tsx | 8 +++ 5 files changed, 121 insertions(+), 33 deletions(-) diff --git a/package/src/components/Message/MessageItemView/MessageContent.tsx b/package/src/components/Message/MessageItemView/MessageContent.tsx index e02c4433f0..716dd4d651 100644 --- a/package/src/components/Message/MessageItemView/MessageContent.tsx +++ b/package/src/components/Message/MessageItemView/MessageContent.tsx @@ -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, @@ -321,30 +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 && !hasInteractiveContent + return message.text && !hasInteractiveAccessibilityContent ? `${a11ySenderLabel}. ${message.text}` : a11ySenderLabel; - }, [a11ySenderLabel, hasInteractiveContent, message.text]); + }, [a11ySenderLabel, hasInteractiveAccessibilityContent, message.text]); return ( { if (onLongPress) { @@ -394,7 +384,7 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => { ]} testID='message-content-wrapper' > - {a11ySenderLabel && Platform.OS !== 'android' && hasInteractiveContent ? ( + {a11ySenderLabel && Platform.OS !== 'android' && hasInteractiveAccessibilityContent ? ( ; export const MessageContent = (props: MessageContentProps) => { const { alignment, + files, goToMessage, groupStyles, + hasInteractiveAccessibilityContent, + images, isMessageAIGenerated, isMyMessage, message, @@ -602,8 +601,6 @@ export const MessageContent = (props: MessageContentProps) => { otherAttachments, preventPress, threadList, - files, - images, videos, } = useMessageContext(); const { @@ -658,6 +655,7 @@ export const MessageContent = (props: MessageContentProps) => { enableMessageGroupingByUser, goToMessage, groupStyles, + hasInteractiveAccessibilityContent, isAttachmentEqual, isMessageAIGenerated, isMyMessage, diff --git a/package/src/components/Message/MessageItemView/MessageTextContainer.tsx b/package/src/components/Message/MessageItemView/MessageTextContainer.tsx index cb8a162d37..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 = {}, @@ -79,17 +86,10 @@ const MessageTextContainerWithContext = (props: MessageTextContainerPropsWithCon const markdownStyles = { ...markdown, ...markdownStylesProp }; - const hasInteractiveContent = !!( - message.poll_id || - message.quoted_message || - message.attachments?.length || - message.shared_location - ); - return ( @@ -122,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; @@ -191,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 ( { +}: 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/contexts/accessibilityContext/AccessibilityContext.tsx b/package/src/contexts/accessibilityContext/AccessibilityContext.tsx index 033860f3d0..27281abdc8 100644 --- a/package/src/contexts/accessibilityContext/AccessibilityContext.tsx +++ b/package/src/contexts/accessibilityContext/AccessibilityContext.tsx @@ -8,6 +8,8 @@ import React, { } from 'react'; import { AccessibilityInfo } from 'react-native'; +import type { LocalMessage } from 'stream-chat'; + import { AccessibilityAnnouncerContext } from '../../components/Accessibility/useAccessibilityAnnouncer'; import type { AccessibilityAnnounce, @@ -24,6 +26,39 @@ type TimeoutByPriority = { /** Tri-state for gesture-alternative toggles. */ export type A11yMode = 'auto' | 'always' | 'never'; +/** + * Resolved predicate stored on the context - consumers call `(message)` and + * receive the boolean directly. + */ +export type HasInteractiveAccessibilityContent = (message: LocalMessage) => 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, ], 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 */ From 16c1f1e8a68d228b98ab1426d5a245be45557a94 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Tue, 9 Jun 2026 02:03:24 +0200 Subject: [PATCH 05/14] fix: ios modal declarations --- package/src/components/ImageGallery/ImageGallery.tsx | 1 + .../src/contexts/overlayContext/MessageOverlayHostLayer.tsx | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/package/src/components/ImageGallery/ImageGallery.tsx b/package/src/components/ImageGallery/ImageGallery.tsx index a07076bc59..d71e7038d5 100644 --- a/package/src/components/ImageGallery/ImageGallery.tsx +++ b/package/src/components/ImageGallery/ImageGallery.tsx @@ -284,6 +284,7 @@ export const ImageGalleryWithContext = (props: ImageGalleryWithContextProps) => 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 ? ( From fbd81d51e600f27b41c5dba8a31a482efee6c65f Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Tue, 9 Jun 2026 10:08:22 +0200 Subject: [PATCH 06/14] fix: implement a11y trap --- .../Accessibility/OverlayA11yShield.tsx | 49 +++++++++++ .../__tests__/OverlayA11yShield.test.tsx | 83 +++++++++++++++++++ .../overlayContext/OverlayProvider.tsx | 3 +- 3 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 package/src/components/Accessibility/OverlayA11yShield.tsx create mode 100644 package/src/components/Accessibility/__tests__/OverlayA11yShield.test.tsx 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..c978b4decf --- /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: 'android' | 'ios') => { + 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/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' && } From 35f94023caeac87a801b4459fa4b18005675e3b4 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Tue, 9 Jun 2026 13:44:53 +0200 Subject: [PATCH 07/14] fix: gallery actions --- .../components/ImageGallery/ImageGallery.tsx | 58 ++++++- .../__tests__/ImageGallery.test.tsx | 16 +- .../__tests__/ImageGalleryAdjustable.test.tsx | 141 ++++++++++++++++++ package/src/i18n/ar.json | 3 +- package/src/i18n/en.json | 3 +- package/src/i18n/es.json | 3 +- package/src/i18n/fr.json | 3 +- package/src/i18n/he.json | 3 +- package/src/i18n/hi.json | 3 +- package/src/i18n/it.json | 3 +- package/src/i18n/ja.json | 3 +- package/src/i18n/ko.json | 3 +- package/src/i18n/nl.json | 3 +- package/src/i18n/pt-br.json | 3 +- package/src/i18n/ru.json | 3 +- package/src/i18n/tr.json | 3 +- 16 files changed, 234 insertions(+), 20 deletions(-) create mode 100644 package/src/components/ImageGallery/__tests__/ImageGalleryAdjustable.test.tsx diff --git a/package/src/components/ImageGallery/ImageGallery.tsx b/package/src/components/ImageGallery/ImageGallery.tsx index d71e7038d5..fef4f58cf1 100644 --- a/package/src/components/ImageGallery/ImageGallery.tsx +++ b/package/src/components/ImageGallery/ImageGallery.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Image, ImageStyle, StyleSheet, ViewStyle } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; @@ -21,6 +21,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,14 +282,62 @@ 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], + ); + 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/i18n/ar.json b/package/src/i18n/ar.json index e82fac3745..ff589ebb51 100644 --- a/package/src/i18n/ar.json +++ b/package/src/i18n/ar.json @@ -375,5 +375,6 @@ "a11y/Message from you": "رسالة منك", "a11y/Message from {{sender}}": "رسالة من {{sender}}", "a11y/Gallery Image": "صورة من المعرض", - "a11y/Gallery Video": "فيديو من المعرض" + "a11y/Gallery Video": "فيديو من المعرض", + "a11y/{{position}} of {{count}}": "{{position}} من {{count}}" } diff --git a/package/src/i18n/en.json b/package/src/i18n/en.json index 175be02f45..d3af7b7054 100644 --- a/package/src/i18n/en.json +++ b/package/src/i18n/en.json @@ -375,5 +375,6 @@ "unsupported file type": "unsupported file type", "a11y/{{count}} unread messages": "{{count}} unread messages", "a11y/Gallery Image": "Gallery image", - "a11y/Gallery Video": "Gallery video" + "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 49a3d32fd7..1257e6bac1 100644 --- a/package/src/i18n/es.json +++ b/package/src/i18n/es.json @@ -375,5 +375,6 @@ "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/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 447004d411..4f5ff54a34 100644 --- a/package/src/i18n/fr.json +++ b/package/src/i18n/fr.json @@ -375,5 +375,6 @@ "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/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 bcf3b8495b..7b8678ba3c 100644 --- a/package/src/i18n/he.json +++ b/package/src/i18n/he.json @@ -375,5 +375,6 @@ "a11y/Message from you": "הודעה ממך", "a11y/Message from {{sender}}": "הודעה מאת {{sender}}", "a11y/Gallery Image": "תמונה מהגלריה", - "a11y/Gallery Video": "סרטון מהגלריה" + "a11y/Gallery Video": "סרטון מהגלריה", + "a11y/{{position}} of {{count}}": "{{position}} מתוך {{count}}" } diff --git a/package/src/i18n/hi.json b/package/src/i18n/hi.json index dbf344ae7c..794d0e19e2 100644 --- a/package/src/i18n/hi.json +++ b/package/src/i18n/hi.json @@ -375,5 +375,6 @@ "a11y/Message from you": "आपका संदेश", "a11y/Message from {{sender}}": "{{sender}} से संदेश", "a11y/Gallery Image": "गैलरी छवि", - "a11y/Gallery Video": "गैलरी वीडियो" + "a11y/Gallery Video": "गैलरी वीडियो", + "a11y/{{position}} of {{count}}": "{{count}} में से {{position}}" } diff --git a/package/src/i18n/it.json b/package/src/i18n/it.json index 7ba7476a44..abbc538a25 100644 --- a/package/src/i18n/it.json +++ b/package/src/i18n/it.json @@ -375,5 +375,6 @@ "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/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 e056f60abc..8679658939 100644 --- a/package/src/i18n/ja.json +++ b/package/src/i18n/ja.json @@ -375,5 +375,6 @@ "a11y/Message from you": "あなたからのメッセージ", "a11y/Message from {{sender}}": "{{sender}}からのメッセージ", "a11y/Gallery Image": "ギャラリー画像", - "a11y/Gallery Video": "ギャラリービデオ" + "a11y/Gallery Video": "ギャラリービデオ", + "a11y/{{position}} of {{count}}": "{{count}} 中 {{position}}" } diff --git a/package/src/i18n/ko.json b/package/src/i18n/ko.json index 0dfc640d22..e2708ebc9c 100644 --- a/package/src/i18n/ko.json +++ b/package/src/i18n/ko.json @@ -375,5 +375,6 @@ "a11y/Message from you": "내가 보낸 메시지", "a11y/Message from {{sender}}": "{{sender}}님의 메시지", "a11y/Gallery Image": "갤러리 이미지", - "a11y/Gallery Video": "갤러리 동영상" + "a11y/Gallery Video": "갤러리 동영상", + "a11y/{{position}} of {{count}}": "{{count}}개 중 {{position}}번째" } diff --git a/package/src/i18n/nl.json b/package/src/i18n/nl.json index c75693fa75..e8c7792eeb 100644 --- a/package/src/i18n/nl.json +++ b/package/src/i18n/nl.json @@ -375,5 +375,6 @@ "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/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 d7aeedf9a9..7e977a0fbb 100644 --- a/package/src/i18n/pt-br.json +++ b/package/src/i18n/pt-br.json @@ -375,5 +375,6 @@ "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/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 baffacb742..644d821681 100644 --- a/package/src/i18n/ru.json +++ b/package/src/i18n/ru.json @@ -375,5 +375,6 @@ "a11y/Message from you": "Сообщение от вас", "a11y/Message from {{sender}}": "Сообщение от {{sender}}", "a11y/Gallery Image": "Изображение из галереи", - "a11y/Gallery Video": "Видео из галереи" + "a11y/Gallery Video": "Видео из галереи", + "a11y/{{position}} of {{count}}": "{{position}} из {{count}}" } diff --git a/package/src/i18n/tr.json b/package/src/i18n/tr.json index 1a07e23965..c6cd08f922 100644 --- a/package/src/i18n/tr.json +++ b/package/src/i18n/tr.json @@ -375,5 +375,6 @@ "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/Gallery Video": "Galeri videosu", + "a11y/{{position}} of {{count}}": "{{count}} öğeden {{position}}" } From 08d52d552608a69df61cfc60f29045ad33da2c07 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Tue, 9 Jun 2026 15:58:20 +0200 Subject: [PATCH 08/14] fix: gallery refoxus --- package/src/components/Attachment/Gallery.tsx | 7 +++-- .../components/ImageGallery/ImageGallery.tsx | 29 ++++++++++++++++++- .../image-gallery-state-store.test.ts | 1 + .../state-store/image-gallery-state-store.ts | 14 ++++++++- 4 files changed, 47 insertions(+), 4 deletions(-) diff --git a/package/src/components/Attachment/Gallery.tsx b/package/src/components/Attachment/Gallery.tsx index 5c619beb78..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'; @@ -242,12 +242,14 @@ const GalleryThumbnail = ({ 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'); @@ -271,6 +273,7 @@ const GalleryThumbnail = ({ accessibilityRole='button' disabled={preventPress} key={`gallery-item-${message.id}/${colIndex}/${rowIndex}/${imagesAndVideos.length}`} + ref={thumbnailRef} onLongPress={(event) => { if (onLongPress) { onLongPress({ diff --git a/package/src/components/ImageGallery/ImageGallery.tsx b/package/src/components/ImageGallery/ImageGallery.tsx index fef4f58cf1..439d788339 100644 --- a/package/src/components/ImageGallery/ImageGallery.tsx +++ b/package/src/components/ImageGallery/ImageGallery.tsx @@ -1,5 +1,12 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { Image, ImageStyle, StyleSheet, ViewStyle } from 'react-native'; +import { + AccessibilityInfo, + Image, + ImageStyle, + Platform, + StyleSheet, + ViewStyle, +} from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { @@ -323,6 +330,26 @@ export const ImageGalleryWithContext = (props: ImageGalleryWithContextProps) => [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 ( { 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 = () => { From 4940141966ad244702cada4473e2231c5b48ca4d Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Tue, 9 Jun 2026 16:29:24 +0200 Subject: [PATCH 09/14] fix: message reaction announcements --- .../MessageItemView/ReactionList/ReactionListItem.tsx | 2 ++ package/src/components/MessageMenu/ReactionButton.tsx | 7 ------- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/package/src/components/Message/MessageItemView/ReactionList/ReactionListItem.tsx b/package/src/components/Message/MessageItemView/ReactionList/ReactionListItem.tsx index e91f7e9635..5d1b49159c 100644 --- a/package/src/components/Message/MessageItemView/ReactionList/ReactionListItem.tsx +++ b/package/src/components/Message/MessageItemView/ReactionList/ReactionListItem.tsx @@ -72,6 +72,8 @@ export const ReactionListItem = (props: ReactionListItemProps) => { return ( { () => , [Icon, reactionIconSize], ); - const selectedLabelState = selected ? 'selected' : 'unselected'; - const accessibilityLabelParams = useMemo( - () => ({ selected: selectedLabelState, type }), - [selectedLabelState, type], - ); return (