From 04fecc7918c109110944a748d0ac12524a5fd8a9 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Wed, 21 Oct 2026 08:35:54 +0200 Subject: [PATCH 1/8] fix(i18n): correct permission, error and accessibility strings - Fix the ungrammatical shared camera and gallery permission copy, and give the image-only channel image flow its own keys instead of "photos or videos". - Translate the reaction picker and user reaction labels when accessibility is enabled, keeping the legacy English labels as the fallback. - useMessagesContext names Channel, which provides the context, and links the live Channel docs page instead of a 404. - Clearer attachment picker warning, an accurate gallery video placeholder label, and fixed docs for previewVoiceRecording and useIsMountedRef. - Regenerate keys.ts and update the SampleApp German and Italian copy. --- examples/SampleApp/src/i18n/de.ts | 13 ++++-- examples/SampleApp/src/i18n/it.ts | 13 ++++-- .../__tests__/useEditChannelImage.test.tsx | 4 +- .../hooks/useEditChannelImage.ts | 9 ++-- .../__tests__/ImageGallery.test.tsx | 26 ++++++++++++ .../components/AnimatedGalleryVideo.tsx | 2 +- .../MessageMenu/MessageReactionPicker.tsx | 4 +- .../MessageMenu/MessageUserReactionsItem.tsx | 13 +++++- .../__tests__/MessageReactionPicker.test.tsx | 34 +++++++++++++++ .../MessageUserReactionsItem.test.tsx | 41 +++++++++++++++++++ .../MessageInputContext.tsx | 7 +--- .../__tests__/filePickers.test.tsx | 10 +++++ .../useAttachmentPickerBottomSheet.test.ts | 27 ++++++++++++ .../hooks/useAttachmentPickerBottomSheet.ts | 4 +- package/src/hooks/useIsMountedRef.ts | 8 ++-- .../src/i18n/__tests__/catalog.fixture.json | 10 ++++- package/src/i18n/keys.ts | 14 ++++++- package/src/i18n/runtimeDefaults.ts | 4 ++ package/src/nativeMultipartUpload.ts | 2 +- package/src/state-store/audio-player.ts | 6 ++- 20 files changed, 221 insertions(+), 30 deletions(-) create mode 100644 package/src/hooks/__tests__/useAttachmentPickerBottomSheet.test.ts diff --git a/examples/SampleApp/src/i18n/de.ts b/examples/SampleApp/src/i18n/de.ts index 619e6b4695..d1e833fbc9 100644 --- a/examples/SampleApp/src/i18n/de.ts +++ b/examples/SampleApp/src/i18n/de.ts @@ -158,9 +158,13 @@ export const de: TranslationDictionary = { 'channelDetails.editChannel.name.placeholder': 'Kanalname', 'channelDetails.editChannel.upload.accessibilityLabel': 'Kanalbild hochladen', 'channelDetails.editChannel.upload.label': 'Hochladen', + 'channelDetails.editImageSheet.cameraPermission.text': + 'Die Kamera wird verwendet, um das Gruppenbild aufzunehmen.', 'channelDetails.editImageSheet.chooseImage.label': 'Bild auswählen', 'channelDetails.editImageSheet.close.accessibilityLabel': 'Fenster zum Bearbeiten des Bildes schließen', + 'channelDetails.editImageSheet.galleryPermission.text': + 'Der Galeriezugriff wird verwendet, um das Gruppenbild auszuwählen.', 'channelDetails.editImageSheet.resetPicture.label': 'Bild zurücksetzen', 'channelDetails.editImageSheet.takePhoto.label': 'Foto aufnehmen', 'channelDetails.editImageSheet.title': 'Gruppenbild bearbeiten', @@ -222,15 +226,14 @@ export const de: TranslationDictionary = { // Shared labels reused across screens 'common.anonymousUser.label': 'Anonym', - 'common.cameraPermission.text': - 'Die Kamera des Geräts wird verwendet, um Fotos oder Videos aufzunehmen.', + 'common.cameraPermission.text': 'Die Kamera wird verwendet, um Fotos und Videos aufzunehmen.', 'common.cameraPermission.title': 'Kamerazugriff in den Geräteeinstellungen erlauben', 'common.cancel.label': 'Abbrechen', 'common.close.accessibilityLabel': 'Schließen', 'common.draft.label': 'Entwurf', 'common.editMessageFailed.error': 'Anfrage zum Bearbeiten der Nachricht fehlgeschlagen', 'common.galleryPermission.text': - 'Die Galerie-Berechtigungen des Geräts werden verwendet, um Fotos oder Videos aufzunehmen.', + 'Der Galeriezugriff wird verwendet, um Fotos und Videos zu teilen.', 'common.galleryPermission.title': 'Zugriff auf deine Galerie erlauben', 'common.linksDisabled.text': 'Das Senden von Links ist in dieser Unterhaltung nicht erlaubt', 'common.linksDisabled.title': 'Links sind deaktiviert', @@ -376,8 +379,12 @@ export const de: TranslationDictionary = { // Message action menu 'messageMenu.actionList.accessibilityLabel': 'Nachrichtenaktionen', + 'messageMenu.reactionPicker.accessibilityLabel': 'Reaktionsauswahl', 'messageMenu.reactionPicker.moreReactions.accessibilityLabel': 'Weitere Reaktionen öffnen', 'messageMenu.userReactions.fetchFailed.error': 'Fehler beim Laden der Reaktionen', + 'messageMenu.userReactions.item.accessibilityLabel': '{{user}} hat mit {{reaction}} reagiert', + 'messageMenu.userReactions.ownItem.accessibilityLabel': 'Du hast mit {{reaction}} reagiert', + 'messageMenu.userReactions.removeHint.accessibilityLabel': 'Zum Entfernen doppeltippen', 'messageMenu.userReactions.tapToRemove.description': 'Zum Entfernen tippen', 'messageMenu.userReactions.title_one': '{{count}} Reaktion', 'messageMenu.userReactions.title_other': '{{count}} Reaktionen', diff --git a/examples/SampleApp/src/i18n/it.ts b/examples/SampleApp/src/i18n/it.ts index 7949c4c73a..89e4ad6cfb 100644 --- a/examples/SampleApp/src/i18n/it.ts +++ b/examples/SampleApp/src/i18n/it.ts @@ -153,9 +153,13 @@ export const it: TranslationDictionary = { 'channelDetails.editChannel.name.placeholder': 'Nome del canale', 'channelDetails.editChannel.upload.accessibilityLabel': 'Carica immagine del canale', 'channelDetails.editChannel.upload.label': 'Carica', + 'channelDetails.editImageSheet.cameraPermission.text': + "La fotocamera viene utilizzata per scattare l'immagine del gruppo.", 'channelDetails.editImageSheet.chooseImage.label': 'Scegli immagine', 'channelDetails.editImageSheet.close.accessibilityLabel': 'Chiudi il pannello di modifica immagine', + 'channelDetails.editImageSheet.galleryPermission.text': + "L'accesso alla galleria viene utilizzato per scegliere l'immagine del gruppo.", 'channelDetails.editImageSheet.resetPicture.label': 'Reimposta immagine', 'channelDetails.editImageSheet.takePhoto.label': 'Scatta foto', 'channelDetails.editImageSheet.title': 'Modifica immagine del gruppo', @@ -216,8 +220,7 @@ export const it: TranslationDictionary = { // Shared labels reused across screens 'common.anonymousUser.label': 'Anonimo', - 'common.cameraPermission.text': - 'La fotocamera del dispositivo viene utilizzata per scattare foto o video.', + 'common.cameraPermission.text': 'La fotocamera viene utilizzata per scattare foto e video.', 'common.cameraPermission.title': "Consenti l'accesso alla fotocamera nelle impostazioni del dispositivo", 'common.cancel.label': 'Annulla', @@ -225,7 +228,7 @@ export const it: TranslationDictionary = { 'common.draft.label': 'Bozza', 'common.editMessageFailed.error': 'Richiesta di modifica del messaggio non riuscita', 'common.galleryPermission.text': - 'Le autorizzazioni della galleria del dispositivo vengono utilizzate per scattare foto o video.', + "L'accesso alla galleria viene utilizzato per condividere foto e video.", 'common.galleryPermission.title': "Consenti l'accesso alla tua galleria", 'common.linksDisabled.text': "L'invio di link non è consentito in questa conversazione", 'common.linksDisabled.title': 'I link sono disabilitati', @@ -372,8 +375,12 @@ export const it: TranslationDictionary = { // Message action menu 'messageMenu.actionList.accessibilityLabel': 'Azioni del messaggio', + 'messageMenu.reactionPicker.accessibilityLabel': 'Selettore di reazioni', 'messageMenu.reactionPicker.moreReactions.accessibilityLabel': 'Apri altre reazioni', 'messageMenu.userReactions.fetchFailed.error': 'Errore durante il recupero delle reazioni', + 'messageMenu.userReactions.item.accessibilityLabel': '{{user}} ha reagito con {{reaction}}', + 'messageMenu.userReactions.ownItem.accessibilityLabel': 'Hai reagito con {{reaction}}', + 'messageMenu.userReactions.removeHint.accessibilityLabel': 'Tocca due volte per rimuovere', 'messageMenu.userReactions.tapToRemove.description': 'Tocca per rimuovere', 'messageMenu.userReactions.title_one': '{{count}} reazione', 'messageMenu.userReactions.title_other': '{{count}} reazioni', diff --git a/package/src/components/ChannelDetails/__tests__/useEditChannelImage.test.tsx b/package/src/components/ChannelDetails/__tests__/useEditChannelImage.test.tsx index e8b38bc3da..e37dd4fdd3 100644 --- a/package/src/components/ChannelDetails/__tests__/useEditChannelImage.test.tsx +++ b/package/src/components/ChannelDetails/__tests__/useEditChannelImage.test.tsx @@ -88,7 +88,7 @@ describe('useEditChannelImage', () => { expect(Alert.alert).toHaveBeenCalledTimes(1); expect(Alert.alert).toHaveBeenCalledWith( 'Allow camera access in device settings', - 'Device camera is used to take photos or videos.', + 'The camera is used to take the channel image.', expect.any(Array), ); expect(returned).toBeUndefined(); @@ -202,7 +202,7 @@ describe('useEditChannelImage', () => { expect(Alert.alert).toHaveBeenCalledTimes(1); expect(Alert.alert).toHaveBeenCalledWith( 'Allow access to your Gallery', - 'Device gallery permissions is used to take photos or videos.', + 'Gallery access is used to choose the channel image.', expect.any(Array), ); expect(returned).toBeUndefined(); diff --git a/package/src/components/ChannelDetails/hooks/useEditChannelImage.ts b/package/src/components/ChannelDetails/hooks/useEditChannelImage.ts index 2f243b7819..3087b0dcbf 100644 --- a/package/src/components/ChannelDetails/hooks/useEditChannelImage.ts +++ b/package/src/components/ChannelDetails/hooks/useEditChannelImage.ts @@ -40,7 +40,10 @@ export const useEditChannelImage = (): UseEditChannelImageResult => { if (file.askToOpenSettings) { Alert.alert( t('common.cameraPermission.title', 'Allow camera access in device settings'), - t('common.cameraPermission.text', 'Device camera is used to take photos or videos.'), + t( + 'channelDetails.editImageSheet.cameraPermission.text', + 'The camera is used to take the channel image.', + ), [ { style: 'cancel', text: t('common.cancel.label', 'Cancel') }, { @@ -67,8 +70,8 @@ export const useEditChannelImage = (): UseEditChannelImageResult => { Alert.alert( t('common.galleryPermission.title', 'Allow access to your Gallery'), t( - 'common.galleryPermission.text', - 'Device gallery permissions is used to take photos or videos.', + 'channelDetails.editImageSheet.galleryPermission.text', + 'Gallery access is used to choose the channel image.', ), [ { style: 'cancel', text: t('common.cancel.label', 'Cancel') }, diff --git a/package/src/components/ImageGallery/__tests__/ImageGallery.test.tsx b/package/src/components/ImageGallery/__tests__/ImageGallery.test.tsx index 28f0f505ff..8cf7058009 100644 --- a/package/src/components/ImageGallery/__tests__/ImageGallery.test.tsx +++ b/package/src/components/ImageGallery/__tests__/ImageGallery.test.tsx @@ -115,6 +115,32 @@ describe('ImageGallery', () => { }); }); + it('renders a labelled placeholder for a video outside the playback window', async () => { + render( + , + ); + + await waitFor(() => { + expect( + screen.queryAllByLabelText('Image Gallery Video Placeholder', { + includeHiddenElements: true, + }), + ).toHaveLength(1); + }); + expect( + screen.queryAllByLabelText('Image Gallery Video', { includeHiddenElements: true }), + ).toHaveLength(0); + }); + it('keeps the pager in ltr coordinates when rtl is enabled', async () => { setRTL(true); diff --git a/package/src/components/ImageGallery/components/AnimatedGalleryVideo.tsx b/package/src/components/ImageGallery/components/AnimatedGalleryVideo.tsx index c8029211d5..67037ea679 100644 --- a/package/src/components/ImageGallery/components/AnimatedGalleryVideo.tsx +++ b/package/src/components/ImageGallery/components/AnimatedGalleryVideo.tsx @@ -160,7 +160,7 @@ const AnimatedGalleryVideoComponent = (props: AnimatedGalleryVideoType) => { if (!shouldRender) { return ( ); diff --git a/package/src/components/MessageMenu/MessageReactionPicker.tsx b/package/src/components/MessageMenu/MessageReactionPicker.tsx index d8f3f99898..af7ce13eae 100644 --- a/package/src/components/MessageMenu/MessageReactionPicker.tsx +++ b/package/src/components/MessageMenu/MessageReactionPicker.tsx @@ -6,6 +6,7 @@ import { EmojiPickerList } from './EmojiPickerList'; import { useMessageOwnReactions } from './hooks/useMessageOwnReactions'; import { ReactionButton } from './ReactionButton'; +import { useA11yLabel } from '../../a11y/hooks/useA11yLabel'; import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; import { MessageContextValue } from '../../contexts/messageContext/MessageContext'; import { @@ -147,6 +148,7 @@ export const MessageReactionPicker = (props: MessageReactionPickerProps) => { } = useTheme(); const styles = useStyles(); const own_capabilities = useOwnCapabilitiesContext(); + const a11yLabel = useA11yLabel('messageMenu.reactionPicker.accessibilityLabel'); const onSelectReaction = useStableCallback((type: string) => { NativeHandlers.triggerHaptic('impactLight'); @@ -162,7 +164,7 @@ export const MessageReactionPicker = (props: MessageReactionPickerProps) => { return ( diff --git a/package/src/components/MessageMenu/MessageUserReactionsItem.tsx b/package/src/components/MessageMenu/MessageUserReactionsItem.tsx index 58a18a501c..abf14614f2 100644 --- a/package/src/components/MessageMenu/MessageUserReactionsItem.tsx +++ b/package/src/components/MessageMenu/MessageUserReactionsItem.tsx @@ -2,6 +2,7 @@ import React, { useMemo } from 'react'; import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { useA11yLabel } from '../../a11y/hooks/useA11yLabel'; import { useMessageContext } from '../../contexts'; import { useChatContext } from '../../contexts/chatContext/ChatContext'; import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; @@ -41,6 +42,15 @@ export const MessageUserReactionsItem = ({ const { handleReaction } = useMessageContext(); + // Separate keys for the own reaction, so languages that conjugate "you reacted" can. + const a11yLabel = useA11yLabel( + isOwnReaction + ? 'messageMenu.userReactions.ownItem.accessibilityLabel' + : 'messageMenu.userReactions.item.accessibilityLabel', + { reaction: type, user: name || id }, + ); + const removeHint = useA11yLabel('messageMenu.userReactions.removeHint.accessibilityLabel'); + const onPress = useStableCallback(() => { if (isOwnReaction && handleReaction) { handleReaction(type); @@ -51,7 +61,8 @@ export const MessageUserReactionsItem = ({ return ( { }); }); + it('uses the translated label when accessibility is enabled', async () => { + const { getByLabelText, queryByLabelText } = render( + + + + + + + + + + + + + , + ); + + await waitFor(() => { + expect(getByLabelText('Reaction picker')).toBeTruthy(); + }); + expect(queryByLabelText('Reaction Selector on long pressing message')).toBeNull(); + }); + it('does not render when sendReaction capability is false', () => { const { queryByLabelText } = renderComponent({}, { sendReaction: false }); expect(queryByLabelText('Reaction Selector on long pressing message')).toBeNull(); diff --git a/package/src/components/MessageMenu/__tests__/MessageUserReactionsItem.test.tsx b/package/src/components/MessageMenu/__tests__/MessageUserReactionsItem.test.tsx index 11833dee94..18d34ec49a 100644 --- a/package/src/components/MessageMenu/__tests__/MessageUserReactionsItem.test.tsx +++ b/package/src/components/MessageMenu/__tests__/MessageUserReactionsItem.test.tsx @@ -4,12 +4,15 @@ import { Text } from 'react-native'; import { render } from '@testing-library/react-native'; +import { AccessibilityProvider } from '../../../contexts/accessibilityContext/AccessibilityContext'; import { ChatContextValue, ChatProvider } from '../../../contexts/chatContext/ChatContext'; import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; import { defaultTheme } from '../../../contexts/themeContext/utils/theme'; +import { TranslationProvider } from '../../../contexts/translationContext/TranslationContext'; import { generateUser } from '../../../mock-builders/generator/user'; import { getTestClientWithUser } from '../../../mock-builders/mock'; +import { Streami18n } from '../../../utils/i18n/Streami18n'; import { MessageUserReactionsItem } from '../MessageUserReactionsItem'; jest.mock('../../../icons/Unknown', () => ({ @@ -83,4 +86,42 @@ describe('MessageUserReactionsItem', () => { expect(getByTestId('like-icon')).toBeTruthy(); expect(queryByTestId('unknown-icon')).toBeNull(); }); + + describe('with accessibility enabled', () => { + const renderAccessible = async (clientUserID: string) => { + const translators = await new Streami18n().init(); + return render( + + + + + + + + + , + ); + }; + + it("names the user and the reaction of another user's reaction", async () => { + const { getByLabelText } = await renderAccessible('user2'); + const item = getByLabelText('John Doe reacted with like'); + expect(item.props.accessibilityHint).toBeUndefined(); + }); + + it('hints that an own reaction can be removed', async () => { + const { getByLabelText } = await renderAccessible('user1'); + const item = getByLabelText('You reacted with like'); + expect(item.props.accessibilityHint).toBe('Double tap to remove'); + }); + }); }); diff --git a/package/src/contexts/messageInputContext/MessageInputContext.tsx b/package/src/contexts/messageInputContext/MessageInputContext.tsx index 75bd10a6cc..4669610a0d 100644 --- a/package/src/contexts/messageInputContext/MessageInputContext.tsx +++ b/package/src/contexts/messageInputContext/MessageInputContext.tsx @@ -201,7 +201,7 @@ export const MessageInputProvider = ({ if (file.askToOpenSettings && disableAttachmentPicker) { Alert.alert( t('common.cameraPermission.title', 'Allow camera access in device settings'), - t('common.cameraPermission.text', 'Device camera is used to take photos or videos.'), + t('common.cameraPermission.text', 'The camera is used to take photos and videos.'), [ { style: 'cancel', text: t('common.cancel.label', 'Cancel') }, { @@ -234,10 +234,7 @@ export const MessageInputProvider = ({ if (result.askToOpenSettings) { Alert.alert( t('common.galleryPermission.title', 'Allow access to your Gallery'), - t( - 'common.galleryPermission.text', - 'Device gallery permissions is used to take photos or videos.', - ), + t('common.galleryPermission.text', 'Gallery access is used to share photos and videos.'), [ { style: 'cancel', text: t('common.cancel.label', 'Cancel') }, { diff --git a/package/src/contexts/messageInputContext/__tests__/filePickers.test.tsx b/package/src/contexts/messageInputContext/__tests__/filePickers.test.tsx index 27b58a952d..95bcaf17ef 100644 --- a/package/src/contexts/messageInputContext/__tests__/filePickers.test.tsx +++ b/package/src/contexts/messageInputContext/__tests__/filePickers.test.tsx @@ -195,6 +195,11 @@ describe("MessageInputContext's pickAndUploadImageFromNativePicker", () => { }); expect(Alert.alert).toHaveBeenCalledTimes(1); + expect(Alert.alert).toHaveBeenCalledWith( + 'Allow access to your Gallery', + 'Gallery access is used to share photos and videos.', + expect.any(Array), + ); }); it.each([ @@ -335,6 +340,11 @@ describe("MessageInputContext's takeAndUploadImage", () => { }); expect(Alert.alert).toHaveBeenCalledTimes(1); + expect(Alert.alert).toHaveBeenCalledWith( + 'Allow camera access in device settings', + 'The camera is used to take photos and videos.', + expect.any(Array), + ); }); it.each([ diff --git a/package/src/hooks/__tests__/useAttachmentPickerBottomSheet.test.ts b/package/src/hooks/__tests__/useAttachmentPickerBottomSheet.test.ts new file mode 100644 index 0000000000..4d29f8e093 --- /dev/null +++ b/package/src/hooks/__tests__/useAttachmentPickerBottomSheet.test.ts @@ -0,0 +1,27 @@ +import { renderHook } from '@testing-library/react-native'; + +import { useAttachmentPickerBottomSheet } from '../useAttachmentPickerBottomSheet'; + +describe('useAttachmentPickerBottomSheet', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('opens the sheet at its first snap point', () => { + const snapToIndex = jest.fn(); + const { result } = renderHook(() => useAttachmentPickerBottomSheet()); + + result.current.openPicker({ current: { snapToIndex } as never }); + + expect(snapToIndex).toHaveBeenCalledWith(0); + }); + + it('warns that the sheet cannot open when the ref has no snapToIndex', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const { result } = renderHook(() => useAttachmentPickerBottomSheet()); + + result.current.openPicker({ current: null }); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('snapToIndex')); + }); +}); diff --git a/package/src/hooks/useAttachmentPickerBottomSheet.ts b/package/src/hooks/useAttachmentPickerBottomSheet.ts index 1adbd17965..18fd3182b0 100644 --- a/package/src/hooks/useAttachmentPickerBottomSheet.ts +++ b/package/src/hooks/useAttachmentPickerBottomSheet.ts @@ -16,7 +16,9 @@ export const useAttachmentPickerBottomSheet = () => { if (ref.current?.snapToIndex) { ref.current.snapToIndex(0); } else { - console.warn('bottom and top insets must be set for the image picker to work correctly'); + console.warn( + 'The attachment picker cannot open: its bottom sheet ref has no snapToIndex. Make sure the attachment picker is mounted.', + ); } }, []); diff --git a/package/src/hooks/useIsMountedRef.ts b/package/src/hooks/useIsMountedRef.ts index c0a8546133..3a567cf9f5 100644 --- a/package/src/hooks/useIsMountedRef.ts +++ b/package/src/hooks/useIsMountedRef.ts @@ -9,10 +9,10 @@ import { useEffect, useRef } from 'react'; * const [dummyValue, setDummyValue] = useState(false); * * useEffect(() => { - * someAsyncOperation().then(() => { - * if (isMounted.current) setDummyValue(true); - * }) - * }) + * someAsyncOperation().then(() => { + * if (isMounted.current) setDummyValue(true); + * }); + * }, []); * ``` * * @returns A ref whose `current` is true until the component unmounts. diff --git a/package/src/i18n/__tests__/catalog.fixture.json b/package/src/i18n/__tests__/catalog.fixture.json index cdadf0884a..2157cb94ed 100644 --- a/package/src/i18n/__tests__/catalog.fixture.json +++ b/package/src/i18n/__tests__/catalog.fixture.json @@ -118,8 +118,10 @@ "channelDetails.editChannel.name.placeholder": "Channel name", "channelDetails.editChannel.upload.accessibilityLabel": "Upload channel image", "channelDetails.editChannel.upload.label": "Upload", + "channelDetails.editImageSheet.cameraPermission.text": "The camera is used to take the channel image.", "channelDetails.editImageSheet.chooseImage.label": "Choose Image", "channelDetails.editImageSheet.close.accessibilityLabel": "Close edit picture sheet", + "channelDetails.editImageSheet.galleryPermission.text": "Gallery access is used to choose the channel image.", "channelDetails.editImageSheet.resetPicture.label": "Reset Picture", "channelDetails.editImageSheet.takePhoto.label": "Take Photo", "channelDetails.editImageSheet.title": "Edit Group Picture", @@ -170,13 +172,13 @@ "channelPreview.unreadCount.accessibilityLabel_one": "{{count}} unread message", "channelPreview.unreadCount.accessibilityLabel_other": "{{count}} unread messages", "common.anonymousUser.label": "Anonymous", - "common.cameraPermission.text": "Device camera is used to take photos or videos.", + "common.cameraPermission.text": "The camera is used to take photos and videos.", "common.cameraPermission.title": "Allow camera access in device settings", "common.cancel.label": "Cancel", "common.close.accessibilityLabel": "Close", "common.draft.label": "Draft", "common.editMessageFailed.error": "Edit message request failed", - "common.galleryPermission.text": "Device gallery permissions is used to take photos or videos.", + "common.galleryPermission.text": "Gallery access is used to share photos and videos.", "common.galleryPermission.title": "Allow access to your Gallery", "common.linksDisabled.text": "Sending links is not allowed in this conversation", "common.linksDisabled.title": "Links are disabled", @@ -300,8 +302,12 @@ "messageList.unreadMessages.label": "Unread Messages", "messageList.unreadMessages.withCount.label": "{{count}} unread", "messageMenu.actionList.accessibilityLabel": "Message actions", + "messageMenu.reactionPicker.accessibilityLabel": "Reaction picker", "messageMenu.reactionPicker.moreReactions.accessibilityLabel": "Open more reactions", "messageMenu.userReactions.fetchFailed.error": "Error fetching reactions", + "messageMenu.userReactions.item.accessibilityLabel": "{{user}} reacted with {{reaction}}", + "messageMenu.userReactions.ownItem.accessibilityLabel": "You reacted with {{reaction}}", + "messageMenu.userReactions.removeHint.accessibilityLabel": "Double tap to remove", "messageMenu.userReactions.tapToRemove.description": "Tap to remove", "messageMenu.userReactions.title_one": "{{count}} Reaction", "messageMenu.userReactions.title_other": "{{count}} Reactions", diff --git a/package/src/i18n/keys.ts b/package/src/i18n/keys.ts index 976bfed276..d06037a9b8 100644 --- a/package/src/i18n/keys.ts +++ b/package/src/i18n/keys.ts @@ -129,8 +129,10 @@ export type TranslationCatalog = { 'channelDetails.editChannel.name.placeholder': 'Channel name'; 'channelDetails.editChannel.upload.accessibilityLabel': 'Upload channel image'; 'channelDetails.editChannel.upload.label': 'Upload'; + 'channelDetails.editImageSheet.cameraPermission.text': 'The camera is used to take the channel image.'; 'channelDetails.editImageSheet.chooseImage.label': 'Choose Image'; 'channelDetails.editImageSheet.close.accessibilityLabel': 'Close edit picture sheet'; + 'channelDetails.editImageSheet.galleryPermission.text': 'Gallery access is used to choose the channel image.'; 'channelDetails.editImageSheet.resetPicture.label': 'Reset Picture'; 'channelDetails.editImageSheet.takePhoto.label': 'Take Photo'; 'channelDetails.editImageSheet.title': 'Edit Group Picture'; @@ -181,13 +183,13 @@ export type TranslationCatalog = { 'channelPreview.unreadCount.accessibilityLabel_one': '{{count}} unread message'; 'channelPreview.unreadCount.accessibilityLabel_other': '{{count}} unread messages'; 'common.anonymousUser.label': 'Anonymous'; - 'common.cameraPermission.text': 'Device camera is used to take photos or videos.'; + 'common.cameraPermission.text': 'The camera is used to take photos and videos.'; 'common.cameraPermission.title': 'Allow camera access in device settings'; 'common.cancel.label': 'Cancel'; 'common.close.accessibilityLabel': 'Close'; 'common.draft.label': 'Draft'; 'common.editMessageFailed.error': 'Edit message request failed'; - 'common.galleryPermission.text': 'Device gallery permissions is used to take photos or videos.'; + 'common.galleryPermission.text': 'Gallery access is used to share photos and videos.'; 'common.galleryPermission.title': 'Allow access to your Gallery'; 'common.linksDisabled.text': 'Sending links is not allowed in this conversation'; 'common.linksDisabled.title': 'Links are disabled'; @@ -311,8 +313,12 @@ export type TranslationCatalog = { 'messageList.unreadMessages.label': 'Unread Messages'; 'messageList.unreadMessages.withCount.label': '{{count}} unread'; 'messageMenu.actionList.accessibilityLabel': 'Message actions'; + 'messageMenu.reactionPicker.accessibilityLabel': 'Reaction picker'; 'messageMenu.reactionPicker.moreReactions.accessibilityLabel': 'Open more reactions'; 'messageMenu.userReactions.fetchFailed.error': 'Error fetching reactions'; + 'messageMenu.userReactions.item.accessibilityLabel': '{{user}} reacted with {{reaction}}'; + 'messageMenu.userReactions.ownItem.accessibilityLabel': 'You reacted with {{reaction}}'; + 'messageMenu.userReactions.removeHint.accessibilityLabel': 'Double tap to remove'; 'messageMenu.userReactions.tapToRemove.description': 'Tap to remove'; 'messageMenu.userReactions.title_one': '{{count}} Reaction'; 'messageMenu.userReactions.title_other': '{{count}} Reactions'; @@ -498,7 +504,11 @@ export type BundledTranslationKey = | 'messageList.dismissUnread.accessibilityLabel' | 'messageList.scrollToBottom.accessibilityLabel' | 'messageMenu.actionList.accessibilityLabel' + | 'messageMenu.reactionPicker.accessibilityLabel' | 'messageMenu.reactionPicker.moreReactions.accessibilityLabel' + | 'messageMenu.userReactions.item.accessibilityLabel' + | 'messageMenu.userReactions.ownItem.accessibilityLabel' + | 'messageMenu.userReactions.removeHint.accessibilityLabel' | 'notifications.attachmentUploadFailed.error' | 'notifications.attachmentUploadFailed.withReason.error' | 'notifications.pollCreateFailed.error' diff --git a/package/src/i18n/runtimeDefaults.ts b/package/src/i18n/runtimeDefaults.ts index 114b56d297..0cb751480c 100644 --- a/package/src/i18n/runtimeDefaults.ts +++ b/package/src/i18n/runtimeDefaults.ts @@ -123,7 +123,11 @@ export const runtimeDefaults = { 'messageList.scrollToBottom.withCount.accessibilityLabel_other': 'Scroll to bottom, {{count}} new messages', 'messageMenu.actionList.accessibilityLabel': 'Message actions', + 'messageMenu.reactionPicker.accessibilityLabel': 'Reaction picker', 'messageMenu.reactionPicker.moreReactions.accessibilityLabel': 'Open more reactions', + 'messageMenu.userReactions.item.accessibilityLabel': '{{user}} reacted with {{reaction}}', + 'messageMenu.userReactions.ownItem.accessibilityLabel': 'You reacted with {{reaction}}', + 'messageMenu.userReactions.removeHint.accessibilityLabel': 'Double tap to remove', 'notifications.attachmentUploadFailed.error': 'Error uploading attachment', 'notifications.attachmentUploadFailed.withReason.error': 'Attachment upload failed due to {{reason}}', diff --git a/package/src/nativeMultipartUpload.ts b/package/src/nativeMultipartUpload.ts index c14e391ddf..9436fb05bf 100644 --- a/package/src/nativeMultipartUpload.ts +++ b/package/src/nativeMultipartUpload.ts @@ -132,7 +132,7 @@ const createCanceledError = (): NativeMultipartCanceledError => { const error = new Error('Request aborted') as NativeMultipartCanceledError; error.name = 'CanceledError'; error.code = CANCELED_ERROR_CODE; - // eslint-disable-next-line no-underscore-dangle -- Axios marks cancellation with this legacy field, and callers still use axios.isCancel. + // eslint-disable-next-line no-underscore-dangle -- sets the `__CANCEL__` flag that `axios.isCancel` checks, so callers see a cancellation. error.__CANCEL__ = true; return error; }; diff --git a/package/src/state-store/audio-player.ts b/package/src/state-store/audio-player.ts index 0aba7445a6..287091526e 100644 --- a/package/src/state-store/audio-player.ts +++ b/package/src/state-store/audio-player.ts @@ -48,6 +48,10 @@ const INITIAL_STATE: AudioPlayerState = { export type AudioPlayerOptions = AudioDescriptor & { onError?: AudioPlayerErrorHandler; playbackRates?: number[]; + /** + * Plays through the recorder's own player, `NativeHandlers.Audio`, instead of the regular + * `NativeHandlers.Sound` player. Defaults to false. + */ previewVoiceRecording?: boolean; }; @@ -63,7 +67,7 @@ export class AudioPlayer { private lastPlaybackStatusError?: string; private lastSeekNotSupportedNotificationAt?: number; - /** Keeps the composer preview on the recorder-backed player. */ + /** Plays through the recorder's own player, `NativeHandlers.Audio`, instead of `Sound`. */ private previewVoiceRecording?: boolean; constructor(options: AudioPlayerOptions) { From 91f5f44c8733a621a8215468d29834e57ad1c1e1 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Wed, 21 Oct 2026 08:36:20 +0200 Subject: [PATCH 2/8] fix(offline): store a draft's quoted and parent messages upsertDraft discarded the result of queries.concat(), so the quoted and parent messages were never written, although getDraft reads the quoted message back from the messages table. Also drop an unreachable null check in getReactionsForFilterSort. --- .../store/apis/__tests__/upsertDraft.test.ts | 55 +++++++++++++++++++ .../store/apis/getReactionsforFilterSort.ts | 4 -- package/src/store/apis/upsertDraft.ts | 3 +- 3 files changed, 56 insertions(+), 6 deletions(-) create mode 100644 package/src/store/apis/__tests__/upsertDraft.test.ts diff --git a/package/src/store/apis/__tests__/upsertDraft.test.ts b/package/src/store/apis/__tests__/upsertDraft.test.ts new file mode 100644 index 0000000000..423535d557 --- /dev/null +++ b/package/src/store/apis/__tests__/upsertDraft.test.ts @@ -0,0 +1,55 @@ +import type { DraftResponse, MessageResponse } from 'stream-chat'; +import { v4 as uuidv4 } from 'uuid'; + +import { generateChannelResponse } from '../../../mock-builders/generator/channel'; +import { generateMessage } from '../../../mock-builders/generator/message'; +import { BetterSqlite } from '../../../test-utils/BetterSqlite'; +import { SqliteClient } from '../../SqliteClient'; +import { getDraft } from '../getDraft'; +import { upsertChannels } from '../upsertChannels'; +import { upsertDraft } from '../upsertDraft'; + +describe('upsertDraft', () => { + beforeEach(async () => { + await SqliteClient.initializeDatabase(); + await BetterSqlite.openDB(); + }); + + afterEach(() => { + BetterSqlite.dropAllTables(); + BetterSqlite.closeDB(); + jest.clearAllMocks(); + }); + + it('stores the quoted and parent messages so the draft can be read back with them', async () => { + const channelResponse = generateChannelResponse({ members: [], messages: [] }); + const cid = channelResponse.channel.cid as string; + await upsertChannels({ + channels: [channelResponse] as unknown as Parameters[0]['channels'], + }); + + const parentMessage = generateMessage({ cid, id: uuidv4() }) as unknown as MessageResponse; + const quotedMessage = generateMessage({ cid, id: uuidv4() }) as unknown as MessageResponse; + const draft = { + channel_cid: cid, + created_at: new Date().toISOString(), + message: { id: uuidv4(), quoted_message_id: quotedMessage.id, text: 'draft text' }, + parent_id: parentMessage.id, + parent_message: parentMessage, + quoted_message: quotedMessage, + } as unknown as DraftResponse; + + const queries = await upsertDraft({ draft }); + + const messageUpserts = queries.filter(([query]) => query.includes('INTO messages')); + expect(messageUpserts).toHaveLength(2); + + const messageIds = (await BetterSqlite.selectFromTable<{ id: string }>('messages')).map( + ({ id }) => id, + ); + expect(messageIds).toEqual(expect.arrayContaining([parentMessage.id, quotedMessage.id])); + + const storedDraft = await getDraft({ cid, parent_id: parentMessage.id, userId: uuidv4() }); + expect(storedDraft?.quoted_message?.id).toBe(quotedMessage.id); + }); +}); diff --git a/package/src/store/apis/getReactionsforFilterSort.ts b/package/src/store/apis/getReactionsforFilterSort.ts index b279452189..673b0414fd 100644 --- a/package/src/store/apis/getReactionsforFilterSort.ts +++ b/package/src/store/apis/getReactionsforFilterSort.ts @@ -34,10 +34,6 @@ export const getReactionsForFilterSort = async ({ const reactions = await selectReactionsForMessages([messageId], limit, filters, sort); - if (!reactions) { - return null; - } - if (reactions.length === 0) { return []; } diff --git a/package/src/store/apis/upsertDraft.ts b/package/src/store/apis/upsertDraft.ts index 1141e90f29..2cf78efb77 100644 --- a/package/src/store/apis/upsertDraft.ts +++ b/package/src/store/apis/upsertDraft.ts @@ -51,8 +51,7 @@ export const upsertDraft = async ({ } if (messagesToUpsert.length > 0) { - const query = await upsertMessages({ execute: false, messages: messagesToUpsert }); - queries.concat(query); + queries.push(...(await upsertMessages({ execute: false, messages: messagesToUpsert }))); } if (execute) { From feee60897213f5ce33c9152ca5b12ad7d7c17a9b Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Wed, 21 Oct 2026 08:36:49 +0200 Subject: [PATCH 3/8] fix: handle edge cases in markdown links, galleries, emoji and file icons - generateMarkdownText converted only the last link and lost the trim; links are now converted in one pass, longest first. - A rejected ai-components onLink no longer leaves an unhandled rejection. - parseJsonChart reports invalid JSON as an unknown chart type. - buildGalleryOfTwoImages no longer reverses the caller's array, and buildGalleryOfSingleImage clamps a landscape width with the width limits. - The emoji middleware skips emoji without a native glyph instead of throwing. - FileIcon maps .xlsb to its own MIME type and drops duplicate entries. - getTrimmedAttachmentTitle uses integer slice lengths; a dead ?? 0 is removed from hashStringToNumber; useRAFCoalescedValue gets tests for re-enabling. --- .../ai-components/__tests__/Markdown.test.tsx | 20 +++++ .../ai-components/__tests__/charts.test.ts | 5 +- .../charts/utils/parse-json-chart.ts | 6 +- package/src/ai-components/markdown/rules.ts | 3 +- .../src/components/Attachment/FileIcon.tsx | 15 +--- .../Attachment/__tests__/FileIcon.test.tsx | 46 +++++++++++ .../Attachment/__tests__/buildGallery.test.ts | 26 ++++++ .../buildGallery/buildGalleryOfSingleImage.ts | 2 +- .../buildGallery/buildGalleryOfTwoImages.ts | 2 +- .../utils/generateMarkdownText.test.ts | 9 +++ .../utils/generateMarkdownText.ts | 33 +++++--- .../__tests__/useRAFCoalescedValue.test.tsx | 80 +++++++++++++++++++ package/src/hooks/useRAFCoalescedValue.ts | 4 +- .../__tests__/emojiControl.test.ts | 65 +++++++++++++++ package/src/middlewares/emojiControl.ts | 26 +++--- .../getTrimmedAttachmentTitle.test.ts | 19 +++++ .../src/utils/getTrimmedAttachmentTitle.ts | 11 ++- package/src/utils/utils.ts | 2 +- 18 files changed, 321 insertions(+), 53 deletions(-) create mode 100644 package/src/components/Attachment/__tests__/FileIcon.test.tsx create mode 100644 package/src/hooks/__tests__/useRAFCoalescedValue.test.tsx create mode 100644 package/src/middlewares/__tests__/emojiControl.test.ts create mode 100644 package/src/utils/__tests__/getTrimmedAttachmentTitle.test.ts diff --git a/package/src/ai-components/__tests__/Markdown.test.tsx b/package/src/ai-components/__tests__/Markdown.test.tsx index c691af40a7..bec3a50777 100644 --- a/package/src/ai-components/__tests__/Markdown.test.tsx +++ b/package/src/ai-components/__tests__/Markdown.test.tsx @@ -40,6 +40,26 @@ describe('ai-components Markdown', () => { expect(onLink).toHaveBeenCalledWith('https://getstream.io'); }); + it('logs a rejected onLink without leaving an unhandled rejection', async () => { + const unhandled = jest.fn(); + process.on('unhandledRejection', unhandled); + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + const onLink = jest.fn().mockRejectedValue(new Error('cannot open')); + + try { + renderMarkdown('[docs](https://getstream.io)', { onLink }); + fireEvent.press(screen.getByText('docs')); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(onLink).toHaveBeenCalledWith('https://getstream.io'); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('cannot open')); + expect(unhandled).not.toHaveBeenCalled(); + } finally { + process.off('unhandledRejection', unhandled); + logSpy.mockRestore(); + } + }); + it('renders tables, block quotes, inline code and horizontal rules', () => { renderMarkdown('| a | b |\n|---|---|\n| 1 | 2 |\n\n> quoted\n\nuse `npm i`\n\n---\n\nafter'); diff --git a/package/src/ai-components/__tests__/charts.test.ts b/package/src/ai-components/__tests__/charts.test.ts index f5a38095e5..65eda7f66c 100644 --- a/package/src/ai-components/__tests__/charts.test.ts +++ b/package/src/ai-components/__tests__/charts.test.ts @@ -97,7 +97,10 @@ describe('ai-components chart parsers', () => { }), ), ).toThrow(); - expect(() => parseJsonChart('{ not json')).toThrow(); + }); + + it('rejects invalid JSON with the same error as an unsupported chart', () => { + expect(() => parseJsonChart('{ not json')).toThrow('Unknown type of JSON formatted chart.'); }); }); diff --git a/package/src/ai-components/charts/utils/parse-json-chart.ts b/package/src/ai-components/charts/utils/parse-json-chart.ts index 94acd2d58a..cb96503f4d 100644 --- a/package/src/ai-components/charts/utils/parse-json-chart.ts +++ b/package/src/ai-components/charts/utils/parse-json-chart.ts @@ -2,13 +2,11 @@ import type { ChartSpec } from '../types'; import { parseVegalite, VegaLiteSchema } from '../vega-lite'; export const parseJsonChart = (code: string): ChartSpec => { - const json = JSON.parse(code); - try { - const parsedVegaLite = VegaLiteSchema.parse(json); + const parsedVegaLite = VegaLiteSchema.parse(JSON.parse(code)); return parseVegalite(parsedVegaLite); } catch (_error) { - // Not a supported chart spec: fall through to the error below. + // Invalid JSON or not a supported chart spec: fall through to the error below. } throw new Error('Unknown type of JSON formatted chart.'); diff --git a/package/src/ai-components/markdown/rules.ts b/package/src/ai-components/markdown/rules.ts index 72ed13d94b..e14548a9e8 100644 --- a/package/src/ai-components/markdown/rules.ts +++ b/package/src/ai-components/markdown/rules.ts @@ -58,7 +58,7 @@ export const getLocalRules = ( const { onLink, paragraphNumberOfLines } = opts; const openLinkHandler = (target: string) => { if (onLink) { - // `onLink` may be async: a rejection is logged and rethrown. + // `onLink` may be async: nothing awaits this press handler, so a rejection is only logged. Promise.resolve(onLink(target)).catch((error: unknown) => { const msg = error && typeof error === 'object' && 'toString' in error @@ -66,7 +66,6 @@ export const getLocalRules = ( : 'Unknown error'; console.log('There has been a problem with this action. ' + msg); - throw error; }); } }; diff --git a/package/src/components/Attachment/FileIcon.tsx b/package/src/components/Attachment/FileIcon.tsx index 4d308d9bd6..1f376b72d0 100644 --- a/package/src/components/Attachment/FileIcon.tsx +++ b/package/src/components/Attachment/FileIcon.tsx @@ -191,14 +191,6 @@ const docMimeTypes = [ 'application/vnd.ms-word.document.macroEnabled.12', // .dotm (no test) 'application/vnd.ms-word.template.macroEnabled.12', - - // LibreOffice/OpenOffice Writer - // .odt - 'application/vnd.oasis.opendocument.text', - // .ott - 'application/vnd.oasis.opendocument.text-template', - // .fodt - 'application/vnd.oasis.opendocument.text-flat-xml', ]; const excelMimeTypes = [ @@ -225,7 +217,7 @@ const excelMimeTypes = [ // .xlam (no test) 'application/vnd.ms-excel.addin.macroEnabled.12', // .xlsb (no test) - 'application/vnd.ms-excel.addin.macroEnabled.12', + 'application/vnd.ms-excel.sheet.binary.macroEnabled.12', ]; const powerpointMimeTypes = [ @@ -259,7 +251,7 @@ const powerpointMimeTypes = [ ]; const zipFileTypes = [ - // .gzip + // .gzip .tar.gz 'application/gzip', // .zip 'application/zip', @@ -268,8 +260,6 @@ const zipFileTypes = [ 'application/x-archive', // .tar 'application/x-tar', - // .tar.gz - 'application/gzip', // .tar.Z 'application/x-compress', // .tar.bz2 @@ -324,7 +314,6 @@ const videoFileTypes = [ 'video/wmv', 'video/yuv', 'video/quicktime', - 'video/webm', 'video/x-flv', ]; diff --git a/package/src/components/Attachment/__tests__/FileIcon.test.tsx b/package/src/components/Attachment/__tests__/FileIcon.test.tsx new file mode 100644 index 0000000000..d00196256f --- /dev/null +++ b/package/src/components/Attachment/__tests__/FileIcon.test.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { Text } from 'react-native'; + +import { render, screen } from '@testing-library/react-native'; + +import { WithComponents } from '../../../contexts/componentsContext/ComponentsContext'; +import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; +import { defaultTheme } from '../../../contexts/themeContext/utils/theme'; +import { FileIcon } from '../FileIcon'; + +const renderFileIcon = (mimeType: string) => + render( + doc, + OtherFileIcon: () => other, + SpreadSheet: () => spreadsheet, + }, + }} + > + + + + , + ); + +describe('FileIcon', () => { + it.each([ + ['application/vnd.ms-excel.addin.macroEnabled.12'], + ['application/vnd.ms-excel.sheet.binary.macroEnabled.12'], + ])('shows the spreadsheet icon for %s', (mimeType) => { + renderFileIcon(mimeType); + expect(screen.getByText('spreadsheet')).toBeTruthy(); + }); + + it('shows the document icon for an OpenDocument text', () => { + renderFileIcon('application/vnd.oasis.opendocument.text'); + expect(screen.getByText('doc')).toBeTruthy(); + }); + + it('falls back to the generic icon for an unknown type', () => { + renderFileIcon('application/x-unknown'); + expect(screen.getByText('other')).toBeTruthy(); + }); +}); diff --git a/package/src/components/Attachment/__tests__/buildGallery.test.ts b/package/src/components/Attachment/__tests__/buildGallery.test.ts index 6506324333..f8f4c4a268 100644 --- a/package/src/components/Attachment/__tests__/buildGallery.test.ts +++ b/package/src/components/Attachment/__tests__/buildGallery.test.ts @@ -81,6 +81,32 @@ describe('buildGallery', () => { }); }); + it('clamps a height-limited landscape image width with the width limits', () => { + // 500x400 fills maxWidth (256), so its height (204.8) is clamped to maxHeight (192). The width + // follows from that height (192 * 1.25 = 240), which is within the width limits. + const image = generateImageAttachment({ original_height: 400, original_width: 500 }); + + const { height, width } = buildGallery({ + images: [image], + sizeConfig: defaultSizeConfig, + }); + + expect(height).toBe(192); + expect(width).toBe(240); + }); + + it('does not reorder the images passed in', () => { + const portrait = generateImageAttachment({ original_height: 400, original_width: 200 }); + const landscape = generateImageAttachment({ original_height: 200, original_width: 400 }); + const images = [portrait, landscape]; + + const { thumbnailGrid } = buildGallery({ images, sizeConfig: defaultSizeConfig }); + + expect(images).toEqual([portrait, landscape]); + // The landscape image still comes first in the grid. + expect(thumbnailGrid[0][0].url).toBe(landscape.image_url); + }); + it('gallery size should default to gridHeight and gridWidth if original image size is unavailable', () => { const attachments: Attachment[] = []; for (let numOfImages = 0; numOfImages < 4; numOfImages++) { diff --git a/package/src/components/Attachment/utils/buildGallery/buildGalleryOfSingleImage.ts b/package/src/components/Attachment/utils/buildGallery/buildGalleryOfSingleImage.ts index ec9e08a30e..632740583c 100644 --- a/package/src/components/Attachment/utils/buildGallery/buildGalleryOfSingleImage.ts +++ b/package/src/components/Attachment/utils/buildGallery/buildGalleryOfSingleImage.ts @@ -50,7 +50,7 @@ function getContainerSize({ if (containerHeight === maxHeight) { return { height: containerHeight, - width: clamp(containerHeight * aspectRatio, minHeight, maxHeight), + width: clamp(containerHeight * aspectRatio, minWidth, maxWidth), }; } diff --git a/package/src/components/Attachment/utils/buildGallery/buildGalleryOfTwoImages.ts b/package/src/components/Attachment/utils/buildGallery/buildGalleryOfTwoImages.ts index 57d6014dc5..8ec130fc2b 100644 --- a/package/src/components/Attachment/utils/buildGallery/buildGalleryOfTwoImages.ts +++ b/package/src/components/Attachment/utils/buildGallery/buildGalleryOfTwoImages.ts @@ -46,7 +46,7 @@ export function buildGalleryOfTwoImages({ return buildThumbnailGrid({ grid: [[1, 1]], - images: isLandscape1 ? images : images.reverse(), + images: isLandscape1 ? images : [...images].reverse(), invertedDirections: true, resizableCDNHosts, sizeConfig, diff --git a/package/src/components/Message/MessageItemView/utils/generateMarkdownText.test.ts b/package/src/components/Message/MessageItemView/utils/generateMarkdownText.test.ts index 23195be362..88572233b6 100644 --- a/package/src/components/Message/MessageItemView/utils/generateMarkdownText.test.ts +++ b/package/src/components/Message/MessageItemView/utils/generateMarkdownText.test.ts @@ -19,6 +19,15 @@ describe('generateMarkdownText', () => { ], ['Hi @getstream.io getstream.io', 'Hi @getstream.io [getstream.io](http://getstream.io)'], ['Hi ', 'Hi \\'], + [ + 'getstream.io and https://example.com', + '[getstream.io](http://getstream.io) and [https://example.com](https://example.com)', + ], + [' see getstream.io ', 'see [getstream.io](http://getstream.io)'], + [ + 'getstream.io and getstream.io/chat', + '[getstream.io](http://getstream.io) and [getstream.io/chat](http://getstream.io/chat)', + ], ])('Returns the generated markdown text for %p and %p', (text, expected) => { const result = generateMarkdownText(text); expect(result).toBe(expected); diff --git a/package/src/components/Message/MessageItemView/utils/generateMarkdownText.ts b/package/src/components/Message/MessageItemView/utils/generateMarkdownText.ts index 3158b5646b..67a045ca44 100644 --- a/package/src/components/Message/MessageItemView/utils/generateMarkdownText.ts +++ b/package/src/components/Message/MessageItemView/utils/generateMarkdownText.ts @@ -13,23 +13,36 @@ export const generateMarkdownText = (text?: string) => { let resultText = normalizedText.trim(); - const linkInfos = parseLinksFromText(resultText); - - for (const linkInfo of linkInfos) { - const displayLink = truncate(linkInfo.raw, { + const links = parseLinksFromText(resultText).map((linkInfo) => ({ + ...linkInfo, + displayLink: truncate(linkInfo.raw, { length: 200, omission: '...', - }); - // Convert raw links and emails to markdown links. + }), + })); + + if (links.length > 0) { + // Convert raw links and emails to markdown links in one pass, longest first, so a link that + // contains another one (`getstream.io/chat` and `getstream.io`) is converted only once. // Eg: `getstream.io` -> `[getstream.io](http://getstream.io)`. - const normalRegEx = new RegExp(escapeRegExp(linkInfo.raw), 'g'); - const markdown = `[${displayLink}](${linkInfo.url})`; - resultText = normalizedText.replace(normalRegEx, markdown); + const markdownByRaw = new Map( + links.map(({ displayLink, raw, url }) => [raw, `[${displayLink}](${url})`]), + ); + const linksRegEx = new RegExp( + [...markdownByRaw.keys()] + .sort((a, b) => b.length - a.length) + .map(escapeRegExp) + .join('|'), + 'g', + ); + resultText = resultText.replace(linksRegEx, (raw) => markdownByRaw.get(raw) ?? raw); + } + for (const { displayLink, url } of links) { // A mention whose name looks like a link or email is converted back to raw text. // Eg: `@[test.user@gmail.com](mailto:test.user@gmail.com)` -> `@test.user@gmail.com`. const mentionsRegex = new RegExp( - `@\\[${escapeRegExp(displayLink)}\\]\\(${escapeRegExp(linkInfo.url)}\\)`, + `@\\[${escapeRegExp(displayLink)}\\]\\(${escapeRegExp(url)}\\)`, 'g', ); resultText = resultText.replace(mentionsRegex, `@${displayLink}`); diff --git a/package/src/hooks/__tests__/useRAFCoalescedValue.test.tsx b/package/src/hooks/__tests__/useRAFCoalescedValue.test.tsx new file mode 100644 index 0000000000..eea2598ec7 --- /dev/null +++ b/package/src/hooks/__tests__/useRAFCoalescedValue.test.tsx @@ -0,0 +1,80 @@ +import { act, renderHook } from '@testing-library/react-native'; + +import { useRAFCoalescedValue } from '../useRAFCoalescedValue'; + +describe('useRAFCoalescedValue', () => { + let frames: FrameRequestCallback[]; + + beforeEach(() => { + frames = []; + jest.spyOn(global, 'requestAnimationFrame').mockImplementation((callback) => { + frames.push(callback); + return frames.length; + }); + jest.spyOn(global, 'cancelAnimationFrame').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + const flushFrame = () => + act(() => { + frames.splice(0).forEach((callback) => callback(0)); + }); + + it('publishes the latest value once per frame', () => { + const { rerender, result } = renderHook( + ({ value }: { value: string }) => useRAFCoalescedValue(value, true), + { initialProps: { value: 'a' } }, + ); + + rerender({ value: 'b' }); + rerender({ value: 'c' }); + expect(result.current).toBe('a'); + expect(frames).toHaveLength(1); + + flushFrame(); + expect(result.current).toBe('c'); + }); + + it('returns the value as is while disabled', () => { + const { rerender, result } = renderHook( + ({ value }: { value: string }) => useRAFCoalescedValue(value, false), + { initialProps: { value: 'a' } }, + ); + + rerender({ value: 'b' }); + expect(result.current).toBe('b'); + expect(frames).toHaveLength(0); + }); + + it('shows the last published value when re-enabled with it', () => { + const { rerender, result } = renderHook( + ({ isEnabled, value }: { isEnabled: boolean; value: string }) => + useRAFCoalescedValue(value, isEnabled), + { initialProps: { isEnabled: true, value: 'a' } }, + ); + + rerender({ isEnabled: true, value: 'b' }); + flushFrame(); + rerender({ isEnabled: false, value: 'c' }); + rerender({ isEnabled: true, value: 'b' }); + + expect(result.current).toBe('b'); + }); + + it('publishes a value that changed while disabled once re-enabled', () => { + const { rerender, result } = renderHook( + ({ isEnabled, value }: { isEnabled: boolean; value: string }) => + useRAFCoalescedValue(value, isEnabled), + { initialProps: { isEnabled: true, value: 'a' } }, + ); + + rerender({ isEnabled: false, value: 'b' }); + rerender({ isEnabled: true, value: 'b' }); + flushFrame(); + + expect(result.current).toBe('b'); + }); +}); diff --git a/package/src/hooks/useRAFCoalescedValue.ts b/package/src/hooks/useRAFCoalescedValue.ts index de5b05c42a..170c19a491 100644 --- a/package/src/hooks/useRAFCoalescedValue.ts +++ b/package/src/hooks/useRAFCoalescedValue.ts @@ -20,7 +20,9 @@ export const useRAFCoalescedValue = (value: S, isEnabled: boolean | undefined const pendingRef = useRef(value); const rafIdRef = useRef(null); - // If `value` changes, schedule a single RAF to publish the latest one. + // If `value` changes, schedule a single RAF to publish the latest one. While disabled, + // `pendingRef` keeps the last published value, so a value that changed meanwhile is published + // once re-enabled. useEffect(() => { if (value === pendingRef.current || !isEnabled) return; pendingRef.current = value; diff --git a/package/src/middlewares/__tests__/emojiControl.test.ts b/package/src/middlewares/__tests__/emojiControl.test.ts new file mode 100644 index 0000000000..967e4c65d6 --- /dev/null +++ b/package/src/middlewares/__tests__/emojiControl.test.ts @@ -0,0 +1,65 @@ +import type { Emoji } from '../../types/types'; +import { + createTextComposerEmojiMiddleware, + EmojiMiddleware, + EmojiSuggestion, +} from '../emojiControl'; + +type OnChangeParams = Parameters[0]; + +type EmojiSearchSource = { + query: (searchQuery: string) => Promise<{ items: EmojiSuggestion[] }>; +}; + +const runOnChange = async (emojis: Emoji[], text: string) => { + const middleware = createTextComposerEmojiMiddleware({ + emojiSearchIndex: { search: () => emojis }, + }); + const complete = jest.fn((state: unknown) => state); + + await middleware.handlers.onChange({ + complete, + forward: jest.fn(), + next: jest.fn(), + state: { selection: { end: text.length, start: text.length }, text }, + } as unknown as OnChangeParams); + + return complete.mock.calls[0][0] as { + suggestions?: { searchSource: EmojiSearchSource }; + text: string; + }; +}; + +describe('createTextComposerEmojiMiddleware', () => { + it('skips an emoji that has neither a native glyph nor skins', async () => { + const state = await runOnChange( + [ + { id: 'blank', name: 'Blank', skins: [] }, + { id: 'smile', name: 'Smile', skins: [{ native: '😄' }] }, + ], + ':sm', + ); + + const { items } = await state.suggestions!.searchSource.query('sm'); + + expect(items.map(({ id, native }) => ({ id, native }))).toEqual([ + { id: 'smile', native: '😄' }, + ]); + }); + + it('replaces an emoticon with the native glyph of its first skin', async () => { + const state = await runOnChange( + [ + { + emoticons: [':)'], + id: 'slightly_smiling_face', + name: 'Smile', + skins: [{ native: '🙂' }], + }, + ], + ':)', + ); + + expect(state.text).toBe('🙂'); + }); +}); diff --git a/package/src/middlewares/emojiControl.ts b/package/src/middlewares/emojiControl.ts index b69ae97f36..bc61f53f1e 100644 --- a/package/src/middlewares/emojiControl.ts +++ b/package/src/middlewares/emojiControl.ts @@ -34,21 +34,19 @@ class EmojiSearchSource> extends BaseSea } const emojis = (await this.emojiSearchIndex.search(searchQuery)) ?? []; - // emojiIndex.search sometimes returns undefined values, so filter those out first + // emojiIndex.search sometimes returns undefined values, so filter those out first. An emoji with + // no native glyph, on itself or its first skin, has nothing to insert and is skipped too. return { items: emojis .filter(Boolean) - .slice(0, 7) - .map(({ emoticons = [], id, name, native, skins = [] }) => { - const [firstSkin] = skins; - - return { - emoticons, - id, - name, - native: native ?? firstSkin.native, - }; - }) as T[], + .map(({ emoticons = [], id, name, native, skins = [] }) => ({ + emoticons, + id, + name, + native: native ?? skins[0]?.native, + })) + .filter(({ native }) => !!native) + .slice(0, 7) as T[], next: null, // todo: generate cursor }; } @@ -135,9 +133,7 @@ export const createTextComposerEmojiMiddleware = ({ return null; } - const [firstSkin] = emoji.skins ?? []; - - return emoji.native ?? firstSkin.native; + return emoji.native ?? emoji.skins?.[0]?.native ?? null; }, text: state.text, }); diff --git a/package/src/utils/__tests__/getTrimmedAttachmentTitle.test.ts b/package/src/utils/__tests__/getTrimmedAttachmentTitle.test.ts new file mode 100644 index 0000000000..ffb7a558a8 --- /dev/null +++ b/package/src/utils/__tests__/getTrimmedAttachmentTitle.test.ts @@ -0,0 +1,19 @@ +import { getTrimmedAttachmentTitle } from '../getTrimmedAttachmentTitle'; + +describe('getTrimmedAttachmentTitle', () => { + it('returns an empty string without a title', () => { + expect(getTrimmedAttachmentTitle()).toBe(''); + }); + + it('keeps a title that fits', () => { + expect(getTrimmedAttachmentTitle('short.pdf')).toBe('short.pdf'); + }); + + it('keeps the head and tail of a long title with the default length', () => { + expect(getTrimmedAttachmentTitle('This is a very long title')).toBe('This is a...ong title'); + }); + + it('keeps exactly maxLength characters for an odd maxLength', () => { + expect(getTrimmedAttachmentTitle('abcdefghijklmnopqrst', 7)).toBe('abc...qrst'); + }); +}); diff --git a/package/src/utils/getTrimmedAttachmentTitle.ts b/package/src/utils/getTrimmedAttachmentTitle.ts index ffabc0274b..8ac758c21b 100644 --- a/package/src/utils/getTrimmedAttachmentTitle.ts +++ b/package/src/utils/getTrimmedAttachmentTitle.ts @@ -1,5 +1,6 @@ -// Shortens a title longer than `maxLength` (default 18) to its first and last `maxLength / 2` -// characters joined by an ellipsis: "This is a very long title" => "This is a...ong title". +// Shortens a title longer than `maxLength` (default 18) to `maxLength` characters, its head and tail +// joined by an ellipsis: "This is a very long title" => "This is a...ong title". An odd +// `maxLength` gives the tail the extra character. export const getTrimmedAttachmentTitle = (title?: string, maxLength?: number) => { const maxLengthValue = maxLength || 18; if (!title) return ''; @@ -10,8 +11,10 @@ export const getTrimmedAttachmentTitle = (title?: string, maxLength?: number) => return title; } - const start = title.slice(0, maxLengthValue / 2); - const end = title.slice(title.length - maxLengthValue / 2); + const headLength = Math.floor(maxLengthValue / 2); + const tailLength = maxLengthValue - headLength; + const start = title.slice(0, headLength); + const end = title.slice(title.length - tailLength); return `${start}${ellipsis}${end}`; }; diff --git a/package/src/utils/utils.ts b/package/src/utils/utils.ts index f4ee6bb726..d4576973ae 100644 --- a/package/src/utils/utils.ts +++ b/package/src/utils/utils.ts @@ -340,7 +340,7 @@ export const hashStringToNumber = (str: string) => { hash |= 0; // Convert to 32bit integer } - return Math.abs(hash) ?? 0; + return Math.abs(hash); }; export const getFileSizeDisplayText = (size?: number | string) => { From 0d6815909b38c543a9cf7d80ef77dc764eb208af Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Wed, 21 Oct 2026 08:37:14 +0200 Subject: [PATCH 4/8] fix(ImageGallery): keep the double-tapped point under the finger The image is translated in screen space before it is scaled around its center, so at 2x a point at d from the center lands at translateX + 2d. Zooming in set translateX to +d, which moved the tapped point to 3d, often off screen; it is now -d. Also remove the no-op scaleX: 1 transforms. --- .../components/ImageGallery/ImageGallery.tsx | 1 - .../useImageGalleryGestures.test.tsx | 90 +++++++++++++++++++ .../hooks/useAnimatedGalleryStyle.tsx | 2 - .../hooks/useImageGalleryGestures.tsx | 4 +- 4 files changed, 93 insertions(+), 4 deletions(-) create mode 100644 package/src/components/ImageGallery/hooks/__tests__/useImageGalleryGestures.test.tsx diff --git a/package/src/components/ImageGallery/ImageGallery.tsx b/package/src/components/ImageGallery/ImageGallery.tsx index 6d5ac22802..fa407ea021 100644 --- a/package/src/components/ImageGallery/ImageGallery.tsx +++ b/package/src/components/ImageGallery/ImageGallery.tsx @@ -279,7 +279,6 @@ export const ImageGalleryWithContext = (props: ImageGalleryWithContextProps) => const pagerStyle = useAnimatedStyle( () => ({ transform: [ - { scaleX: 1 }, { translateX: translationX.value, }, diff --git a/package/src/components/ImageGallery/hooks/__tests__/useImageGalleryGestures.test.tsx b/package/src/components/ImageGallery/hooks/__tests__/useImageGalleryGestures.test.tsx new file mode 100644 index 0000000000..fced1be74b --- /dev/null +++ b/package/src/components/ImageGallery/hooks/__tests__/useImageGalleryGestures.test.tsx @@ -0,0 +1,90 @@ +import React, { PropsWithChildren } from 'react'; + +import { renderHook } from '@testing-library/react-native'; + +import { + ImageGalleryContext, + ImageGalleryContextValue, +} from '../../../../contexts/imageGalleryContext/ImageGalleryContext'; +import { ImageGalleryStateStore } from '../../../../state-store/image-gallery-state-store'; +import { useImageGalleryGestures } from '../useImageGalleryGestures'; + +const SCREEN_WIDTH = 400; +const SCREEN_HEIGHT = 800; + +type TapHandlers = { + onEnd: (event: { absoluteX: number; absoluteY: number }) => void; + onStart: (event: { absoluteX: number; absoluteY: number }) => void; +}; + +const setup = () => { + const store = new ImageGalleryStateStore(); + const wrapper = ({ children }: PropsWithChildren) => ( + + {children} + + ); + const values = { + currentImageHeight: { value: 200 }, + headerFooterVisible: { value: 1 }, + offsetScale: { value: 1 }, + overlayOpacity: { value: 1 }, + scale: { value: 1 }, + translateX: { value: 0 }, + translateY: { value: 0 }, + translationX: { value: 0 }, + }; + + const { result } = renderHook( + () => + useImageGalleryGestures({ + ...(values as unknown as Parameters[0]), + halfScreenHeight: SCREEN_HEIGHT / 2, + halfScreenWidth: SCREEN_WIDTH / 2, + screenHeight: SCREEN_HEIGHT, + screenWidth: SCREEN_WIDTH, + }), + { wrapper }, + ); + + const doubleTap = (result.current.doubleTap as unknown as { handlers: TapHandlers }).handlers; + + return { doubleTap, values }; +}; + +describe('useImageGalleryGestures', () => { + // The image is translated in screen space before it is scaled around its center, so a point at + // `d` from the center ends up at `translateX + scale * d`. + it.each([[300], [100]])( + 'keeps the double tapped point at x=%p under the finger when zooming in', + (tapX) => { + const { doubleTap, values } = setup(); + const event = { absoluteX: tapX, absoluteY: SCREEN_HEIGHT / 2 }; + + doubleTap.onStart(event); + doubleTap.onEnd(event); + + const distanceFromCenter = tapX - SCREEN_WIDTH / 2; + expect(values.scale.value).toBe(2); + expect(values.translateX.value + values.scale.value * distanceFromCenter).toBe( + distanceFromCenter, + ); + }, + ); + + it('zooms back out on the next double tap', () => { + const { doubleTap, values } = setup(); + const event = { absoluteX: 300, absoluteY: SCREEN_HEIGHT / 2 }; + + doubleTap.onStart(event); + doubleTap.onEnd(event); + doubleTap.onStart(event); + doubleTap.onEnd(event); + + expect(values.scale.value).toBe(1); + expect(values.translateX.value).toBe(0); + expect(values.translateY.value).toBe(0); + }); +}); diff --git a/package/src/components/ImageGallery/hooks/useAnimatedGalleryStyle.tsx b/package/src/components/ImageGallery/hooks/useAnimatedGalleryStyle.tsx index bf4930c752..bef227b02a 100644 --- a/package/src/components/ImageGallery/hooks/useAnimatedGalleryStyle.tsx +++ b/package/src/components/ImageGallery/hooks/useAnimatedGalleryStyle.tsx @@ -61,7 +61,6 @@ export const useAnimatedGalleryStyle = ({ { scale: selected ? scale.value / 8 : oneEighth, }, - { scaleX: 1 }, ], }; }, []); @@ -71,7 +70,6 @@ export const useAnimatedGalleryStyle = ({ const yScaleOffset = -screenHeight * 3.5; return { transform: [ - { scaleX: 1 }, { translateY: yScaleOffset }, { translateX: -xScaleOffset, diff --git a/package/src/components/ImageGallery/hooks/useImageGalleryGestures.tsx b/package/src/components/ImageGallery/hooks/useImageGalleryGestures.tsx index 3d332003ed..aace6e3bee 100644 --- a/package/src/components/ImageGallery/hooks/useImageGalleryGestures.tsx +++ b/package/src/components/ImageGallery/hooks/useImageGalleryGestures.tsx @@ -544,7 +544,9 @@ export const useImageGalleryGestures = ({ duration: 200, easing: Easing.out(Easing.ease), }); - translateX.value = withTiming(event.absoluteX - halfScreenWidth, { + // Keeps the tapped point under the finger: at 2x it would move from `d` to `2d` from the + // center, so the image shifts by `-d`. + translateX.value = withTiming(halfScreenWidth - event.absoluteX, { duration: 200, easing: Easing.out(Easing.ease), }); From 16bdf983ecf5156107866546c6a87c9a0ba9466d Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Wed, 21 Oct 2026 08:37:40 +0200 Subject: [PATCH 5/8] refactor!: remove props that were never read BREAKING CHANGE: ProgressControl `width`, MessageActionList `dismissOverlay` and MessageFlashList `inverted` are removed. None of them was read. A custom MessageActionList that needs to close the overlay reads useMessageContext().dismissOverlay. Also pass destructive actions their own length in MessageActionList, rename a misleading seconds variable in ImageGalleryVideoControl (the value is in milliseconds), and list the removals in the v10 breaking-changes doc. --- ai-docs/v10-breaking-changes.md | 4 +++ ai-docs/v10-docs-impact.md | 2 +- .../components/ImageGalleryVideoControl.tsx | 5 ++- .../ImageGalleryVideoControl.test.tsx | 9 ++++++ .../MessageList/MessageFlashList.tsx | 2 -- .../MessageMenu/MessageActionList.tsx | 4 +-- .../MessageActionListWithActions.tsx | 2 +- .../__tests__/MessageActionList.test.tsx | 32 +++++++++++++++++++ .../ProgressControl/ProgressControl.tsx | 2 -- 9 files changed, 50 insertions(+), 12 deletions(-) diff --git a/ai-docs/v10-breaking-changes.md b/ai-docs/v10-breaking-changes.md index 34b0b0ab8a..29ac719037 100644 --- a/ai-docs/v10-breaking-changes.md +++ b/ai-docs/v10-breaking-changes.md @@ -191,6 +191,8 @@ Retyped props: - `MessageContent`: `isMessageAIGenerated` removed. — Q.3 - `Giphy`, `GiphyImage`, `URLPreview`, `URLPreviewCompact`: `attachment` is `stream-chat`'s `Attachment` (custom metadata under `attachment.custom`). — §17.1 · §R.4 +- `MessageActionList`: `dismissOverlay` removed; it was never read (each `MessageActionListItem` closes the overlay + itself). A custom list that needs it reads `useMessageContext().dismissOverlay`. ### `` / `` @@ -205,6 +207,7 @@ Retyped props: - Older-end loading component (`FooterComponent` on `MessageList`, `HeaderComponent` on `MessageFlashList`) now receives `{ loadingMore?: boolean }`. The newer-end one gets no props and has no SDK source for `loadingMoreRecent`. — §12.1 +- `MessageFlashList`: `inverted` removed; it was never read (the FlashList is never inverted). - New: `pendingUploadsEnabled`. — §O.3 - `ScrollToBottomButton`: `unreadCount` removed (self-derived from `channel.state`). — §7 - `TypingIndicatorContainer`: `thread` → `threadId`. — §2.1 @@ -239,6 +242,7 @@ Retyped props: ### Indicators, polls, misc - `LoadingErrorIndicator`: `error` `boolean | Error` → `Error`. — §R.4 · #3785 +- `ProgressControl`: `width` removed; it was never read (the control measures its own width). - Poll components: `PollOption` → `PollOptionResponseData`, `PollAnswer` / `PollVote` → `PollVoteResponseData` (`ShowAllVotesButton`, `PollResultsItem`, `PollOptionFullResults(Content)`, `PollAnswerListItem`, `PollAnswersList(Content)`); `CreatePollOption.error` is a `PollComposerValidationError`. — §17.5 · §19 diff --git a/ai-docs/v10-docs-impact.md b/ai-docs/v10-docs-impact.md index 31e70bf3ee..fae211452c 100644 --- a/ai-docs/v10-docs-impact.md +++ b/ai-docs/v10-docs-impact.md @@ -488,7 +488,7 @@ Format: severity · what must change · scanner-detected breaking items with lin #### `04-customize-message-actions.md` — Snippet / prop-table update -- **Change:** Custom label keys `t("Custom Mute User")` (L272) need `asDynamicKey` (or plain strings); `client.userID` (L258) is deprecated -> `client.userId`. `client.muteUser(id)` / `unmuteUser(id)` calls are still valid. Change (stacked PRs): the page already configures everything on ``, which is now the only route (`handleX`, `messageActions` are no longer `` props). Add: (1) the `messageActions` function (L29-55, L207, L248) runs when a message's menu opens, not on every render, so it may do per-message work; (2) pass the same `messageActions` / `handleX` to ``, or thread messages get the defaults; (3) a custom `MessageActionList` (L296-345) receives the built list as its `messageActions` prop (plus `dismissOverlay`), and code that wants the default actions or handlers calls `useMessageActions()` / `useMessageActionHandlers()` with no arguments inside the message (`MessageActionsHookProps` is gone). The intercept list (L430-440) names `handleReaction`, `handleReply`, `handleThreadReply`; `handleReply` does not exist (it is `handleQuotedReply`) and `handleMarkUnread`, `handlePinMessage`, `handleBlockUser` are missing. +- **Change:** Custom label keys `t("Custom Mute User")` (L272) need `asDynamicKey` (or plain strings); `client.userID` (L258) is deprecated -> `client.userId`. `client.muteUser(id)` / `unmuteUser(id)` calls are still valid. Change (stacked PRs): the page already configures everything on ``, which is now the only route (`handleX`, `messageActions` are no longer `` props). Add: (1) the `messageActions` function (L29-55, L207, L248) runs when a message's menu opens, not on every render, so it may do per-message work; (2) pass the same `messageActions` / `handleX` to ``, or thread messages get the defaults; (3) a custom `MessageActionList` (L296-345) receives the built list as its `messageActions` prop (its unused `dismissOverlay` prop is gone; read `useMessageContext().dismissOverlay` if needed), and code that wants the default actions or handlers calls `useMessageActions()` / `useMessageActionHandlers()` with no arguments inside the message (`MessageActionsHookProps` is gone). The intercept list (L430-440) names `handleReaction`, `handleReply`, `handleThreadReply`; `handleReply` does not exist (it is `handleQuotedReply`) and `handleMarkUnread`, `handlePinMessage`, `handleBlockUser` are missing. - **Detected:** `userID` (L258); `msgOps` (L265, L267); `tEnglishKey` (L272) #### `05-livestream-messagelist.md` — Snippet / prop-table update diff --git a/package/src/components/ImageGallery/components/ImageGalleryVideoControl.tsx b/package/src/components/ImageGallery/components/ImageGalleryVideoControl.tsx index 1d6d768500..22a7fc6add 100644 --- a/package/src/components/ImageGallery/components/ImageGalleryVideoControl.tsx +++ b/package/src/components/ImageGallery/components/ImageGalleryVideoControl.tsx @@ -38,9 +38,9 @@ export const ImageGalleryVideoControl = React.memo((props: ImageGalleryVideoCont const videoDuration = getDurationLabelFromDuration(duration); - const progressValueInSeconds = progress * duration; + const progressValueInMilliseconds = progress * duration; - const progressDuration = getDurationLabelFromDuration(progressValueInSeconds); + const progressDuration = getDurationLabelFromDuration(progressValueInMilliseconds); const { icons } = useComponentsContext(); const { @@ -115,7 +115,6 @@ export const ImageGalleryVideoControl = React.memo((props: ImageGalleryVideoCont onStartDrag={handleStartDrag} progress={progress} testID={'progress-control'} - width={180} /> diff --git a/package/src/components/ImageGallery/components/__tests__/ImageGalleryVideoControl.test.tsx b/package/src/components/ImageGallery/components/__tests__/ImageGalleryVideoControl.test.tsx index e1b9064c31..8768ece7ad 100644 --- a/package/src/components/ImageGallery/components/__tests__/ImageGalleryVideoControl.test.tsx +++ b/package/src/components/ImageGallery/components/__tests__/ImageGalleryVideoControl.test.tsx @@ -104,4 +104,13 @@ describe('ImageGalleryVideoControl', () => { expect(seekSpy).toHaveBeenCalledWith(0.5 * DURATION); expect(playSpy).not.toHaveBeenCalled(); }); + + it('labels the progress from the duration in milliseconds', () => { + const { videoPlayer } = setup(); + act(() => { + videoPlayer.progress = 0.5; + }); + + expect(screen.getByText('0:05')).toBeTruthy(); + }); }); diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 6b43130991..2a6fbaa4b8 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -125,8 +125,6 @@ type MessageFlashListPropsWithContext = Pick< * to `ThreadFooterComponent` in a thread, `InlineLoadingMoreIndicator` otherwise. */ HeaderComponent?: React.ComponentType<{ loadingMore?: boolean }>; - /** Ignored: the FlashList is never inverted. */ - inverted?: boolean; /** Turns off grouping of messages by user. */ noGroupByUser?: boolean; onListScroll?: ScrollViewProps['onScroll']; diff --git a/package/src/components/MessageMenu/MessageActionList.tsx b/package/src/components/MessageMenu/MessageActionList.tsx index 5c0387f933..ac570d3414 100644 --- a/package/src/components/MessageMenu/MessageActionList.tsx +++ b/package/src/components/MessageMenu/MessageActionList.tsx @@ -11,8 +11,6 @@ import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { primitives } from '../../theme'; export type MessageActionListProps = { - /** Closes the message actions overlay. */ - dismissOverlay?: () => void; /** The actions to render. */ messageActions?: MessageActionType[]; }; @@ -57,7 +55,7 @@ export const MessageActionList = (props: MessageActionListProps) => { {destructiveActions?.map((messageAction, index) => ( ))} diff --git a/package/src/components/MessageMenu/MessageActionListWithActions.tsx b/package/src/components/MessageMenu/MessageActionListWithActions.tsx index 27e3c8d04b..fd4276a6b7 100644 --- a/package/src/components/MessageMenu/MessageActionListWithActions.tsx +++ b/package/src/components/MessageMenu/MessageActionListWithActions.tsx @@ -65,5 +65,5 @@ export const MessageActionListWithActions = () => { updateMessage, }); - return ; + return ; }; diff --git a/package/src/components/MessageMenu/__tests__/MessageActionList.test.tsx b/package/src/components/MessageMenu/__tests__/MessageActionList.test.tsx index 33aebd3a21..107ef1b3a4 100644 --- a/package/src/components/MessageMenu/__tests__/MessageActionList.test.tsx +++ b/package/src/components/MessageMenu/__tests__/MessageActionList.test.tsx @@ -6,6 +6,7 @@ import { Text } from 'react-native'; import { render } from '@testing-library/react-native'; +import { WithComponents } from '../../../contexts/componentsContext/ComponentsContext'; import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; import { defaultTheme } from '../../../contexts/themeContext/utils/theme'; import { MessageActionList } from '../MessageActionList'; @@ -53,4 +54,35 @@ describe('MessageActionList', () => { expect(getByText('Copy Message')).toBeTruthy(); expect(getByText('Delete Message')).toBeTruthy(); }); + + it('passes each group its own index and length', () => { + const itemProps: Record[] = []; + const RecordingItem = (props: MessageActionListItemProps) => { + itemProps.push(props as unknown as Record); + return {props.title}; + }; + const action = jest.fn(); + + render( + + + + + , + ); + + expect(itemProps.map(({ index, length, title }) => ({ index, length, title }))).toEqual([ + { index: 0, length: 3, title: 'Copy' }, + { index: 1, length: 3, title: 'Reply' }, + { index: 2, length: 3, title: 'Edit' }, + { index: 0, length: 1, title: 'Delete' }, + ]); + }); }); diff --git a/package/src/components/ProgressControl/ProgressControl.tsx b/package/src/components/ProgressControl/ProgressControl.tsx index de45bf9971..0bfc82247f 100644 --- a/package/src/components/ProgressControl/ProgressControl.tsx +++ b/package/src/components/ProgressControl/ProgressControl.tsx @@ -27,8 +27,6 @@ export type ProgressControlProps = { onStartDrag?: (progress: number) => void; /** Expands the touch area vertically by 12px on each side of the 3px track. */ expandedTouchArea?: boolean; - /** Not read; the control measures its own width. */ - width?: number; }; const TRACK_HEIGHT = 3; From f79b4eba7482b908b83dae2e3b335951696b57c6 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Wed, 21 Oct 2026 12:22:54 +0200 Subject: [PATCH 6/8] revert: undo the reaction menu labels and gallery gesture changes Per review: restore the original MessageReactionPicker and MessageUserReactionsItem accessibility labels (and drop their new translation keys), the original double-tap zoom translateX and the scaleX: 1 transforms. --- examples/SampleApp/src/i18n/de.ts | 4 - examples/SampleApp/src/i18n/it.ts | 4 - .../components/ImageGallery/ImageGallery.tsx | 1 + .../useImageGalleryGestures.test.tsx | 90 ------------------- .../hooks/useAnimatedGalleryStyle.tsx | 2 + .../hooks/useImageGalleryGestures.tsx | 4 +- .../MessageMenu/MessageReactionPicker.tsx | 4 +- .../MessageMenu/MessageUserReactionsItem.tsx | 13 +-- .../__tests__/MessageReactionPicker.test.tsx | 34 ------- .../MessageUserReactionsItem.test.tsx | 41 --------- .../src/i18n/__tests__/catalog.fixture.json | 4 - package/src/i18n/keys.ts | 8 -- package/src/i18n/runtimeDefaults.ts | 4 - 13 files changed, 6 insertions(+), 207 deletions(-) delete mode 100644 package/src/components/ImageGallery/hooks/__tests__/useImageGalleryGestures.test.tsx diff --git a/examples/SampleApp/src/i18n/de.ts b/examples/SampleApp/src/i18n/de.ts index d1e833fbc9..e9a0933d30 100644 --- a/examples/SampleApp/src/i18n/de.ts +++ b/examples/SampleApp/src/i18n/de.ts @@ -379,12 +379,8 @@ export const de: TranslationDictionary = { // Message action menu 'messageMenu.actionList.accessibilityLabel': 'Nachrichtenaktionen', - 'messageMenu.reactionPicker.accessibilityLabel': 'Reaktionsauswahl', 'messageMenu.reactionPicker.moreReactions.accessibilityLabel': 'Weitere Reaktionen öffnen', 'messageMenu.userReactions.fetchFailed.error': 'Fehler beim Laden der Reaktionen', - 'messageMenu.userReactions.item.accessibilityLabel': '{{user}} hat mit {{reaction}} reagiert', - 'messageMenu.userReactions.ownItem.accessibilityLabel': 'Du hast mit {{reaction}} reagiert', - 'messageMenu.userReactions.removeHint.accessibilityLabel': 'Zum Entfernen doppeltippen', 'messageMenu.userReactions.tapToRemove.description': 'Zum Entfernen tippen', 'messageMenu.userReactions.title_one': '{{count}} Reaktion', 'messageMenu.userReactions.title_other': '{{count}} Reaktionen', diff --git a/examples/SampleApp/src/i18n/it.ts b/examples/SampleApp/src/i18n/it.ts index 89e4ad6cfb..c36d8f687e 100644 --- a/examples/SampleApp/src/i18n/it.ts +++ b/examples/SampleApp/src/i18n/it.ts @@ -375,12 +375,8 @@ export const it: TranslationDictionary = { // Message action menu 'messageMenu.actionList.accessibilityLabel': 'Azioni del messaggio', - 'messageMenu.reactionPicker.accessibilityLabel': 'Selettore di reazioni', 'messageMenu.reactionPicker.moreReactions.accessibilityLabel': 'Apri altre reazioni', 'messageMenu.userReactions.fetchFailed.error': 'Errore durante il recupero delle reazioni', - 'messageMenu.userReactions.item.accessibilityLabel': '{{user}} ha reagito con {{reaction}}', - 'messageMenu.userReactions.ownItem.accessibilityLabel': 'Hai reagito con {{reaction}}', - 'messageMenu.userReactions.removeHint.accessibilityLabel': 'Tocca due volte per rimuovere', 'messageMenu.userReactions.tapToRemove.description': 'Tocca per rimuovere', 'messageMenu.userReactions.title_one': '{{count}} reazione', 'messageMenu.userReactions.title_other': '{{count}} reazioni', diff --git a/package/src/components/ImageGallery/ImageGallery.tsx b/package/src/components/ImageGallery/ImageGallery.tsx index fa407ea021..6d5ac22802 100644 --- a/package/src/components/ImageGallery/ImageGallery.tsx +++ b/package/src/components/ImageGallery/ImageGallery.tsx @@ -279,6 +279,7 @@ export const ImageGalleryWithContext = (props: ImageGalleryWithContextProps) => const pagerStyle = useAnimatedStyle( () => ({ transform: [ + { scaleX: 1 }, { translateX: translationX.value, }, diff --git a/package/src/components/ImageGallery/hooks/__tests__/useImageGalleryGestures.test.tsx b/package/src/components/ImageGallery/hooks/__tests__/useImageGalleryGestures.test.tsx deleted file mode 100644 index fced1be74b..0000000000 --- a/package/src/components/ImageGallery/hooks/__tests__/useImageGalleryGestures.test.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import React, { PropsWithChildren } from 'react'; - -import { renderHook } from '@testing-library/react-native'; - -import { - ImageGalleryContext, - ImageGalleryContextValue, -} from '../../../../contexts/imageGalleryContext/ImageGalleryContext'; -import { ImageGalleryStateStore } from '../../../../state-store/image-gallery-state-store'; -import { useImageGalleryGestures } from '../useImageGalleryGestures'; - -const SCREEN_WIDTH = 400; -const SCREEN_HEIGHT = 800; - -type TapHandlers = { - onEnd: (event: { absoluteX: number; absoluteY: number }) => void; - onStart: (event: { absoluteX: number; absoluteY: number }) => void; -}; - -const setup = () => { - const store = new ImageGalleryStateStore(); - const wrapper = ({ children }: PropsWithChildren) => ( - - {children} - - ); - const values = { - currentImageHeight: { value: 200 }, - headerFooterVisible: { value: 1 }, - offsetScale: { value: 1 }, - overlayOpacity: { value: 1 }, - scale: { value: 1 }, - translateX: { value: 0 }, - translateY: { value: 0 }, - translationX: { value: 0 }, - }; - - const { result } = renderHook( - () => - useImageGalleryGestures({ - ...(values as unknown as Parameters[0]), - halfScreenHeight: SCREEN_HEIGHT / 2, - halfScreenWidth: SCREEN_WIDTH / 2, - screenHeight: SCREEN_HEIGHT, - screenWidth: SCREEN_WIDTH, - }), - { wrapper }, - ); - - const doubleTap = (result.current.doubleTap as unknown as { handlers: TapHandlers }).handlers; - - return { doubleTap, values }; -}; - -describe('useImageGalleryGestures', () => { - // The image is translated in screen space before it is scaled around its center, so a point at - // `d` from the center ends up at `translateX + scale * d`. - it.each([[300], [100]])( - 'keeps the double tapped point at x=%p under the finger when zooming in', - (tapX) => { - const { doubleTap, values } = setup(); - const event = { absoluteX: tapX, absoluteY: SCREEN_HEIGHT / 2 }; - - doubleTap.onStart(event); - doubleTap.onEnd(event); - - const distanceFromCenter = tapX - SCREEN_WIDTH / 2; - expect(values.scale.value).toBe(2); - expect(values.translateX.value + values.scale.value * distanceFromCenter).toBe( - distanceFromCenter, - ); - }, - ); - - it('zooms back out on the next double tap', () => { - const { doubleTap, values } = setup(); - const event = { absoluteX: 300, absoluteY: SCREEN_HEIGHT / 2 }; - - doubleTap.onStart(event); - doubleTap.onEnd(event); - doubleTap.onStart(event); - doubleTap.onEnd(event); - - expect(values.scale.value).toBe(1); - expect(values.translateX.value).toBe(0); - expect(values.translateY.value).toBe(0); - }); -}); diff --git a/package/src/components/ImageGallery/hooks/useAnimatedGalleryStyle.tsx b/package/src/components/ImageGallery/hooks/useAnimatedGalleryStyle.tsx index bef227b02a..bf4930c752 100644 --- a/package/src/components/ImageGallery/hooks/useAnimatedGalleryStyle.tsx +++ b/package/src/components/ImageGallery/hooks/useAnimatedGalleryStyle.tsx @@ -61,6 +61,7 @@ export const useAnimatedGalleryStyle = ({ { scale: selected ? scale.value / 8 : oneEighth, }, + { scaleX: 1 }, ], }; }, []); @@ -70,6 +71,7 @@ export const useAnimatedGalleryStyle = ({ const yScaleOffset = -screenHeight * 3.5; return { transform: [ + { scaleX: 1 }, { translateY: yScaleOffset }, { translateX: -xScaleOffset, diff --git a/package/src/components/ImageGallery/hooks/useImageGalleryGestures.tsx b/package/src/components/ImageGallery/hooks/useImageGalleryGestures.tsx index aace6e3bee..3d332003ed 100644 --- a/package/src/components/ImageGallery/hooks/useImageGalleryGestures.tsx +++ b/package/src/components/ImageGallery/hooks/useImageGalleryGestures.tsx @@ -544,9 +544,7 @@ export const useImageGalleryGestures = ({ duration: 200, easing: Easing.out(Easing.ease), }); - // Keeps the tapped point under the finger: at 2x it would move from `d` to `2d` from the - // center, so the image shifts by `-d`. - translateX.value = withTiming(halfScreenWidth - event.absoluteX, { + translateX.value = withTiming(event.absoluteX - halfScreenWidth, { duration: 200, easing: Easing.out(Easing.ease), }); diff --git a/package/src/components/MessageMenu/MessageReactionPicker.tsx b/package/src/components/MessageMenu/MessageReactionPicker.tsx index af7ce13eae..d8f3f99898 100644 --- a/package/src/components/MessageMenu/MessageReactionPicker.tsx +++ b/package/src/components/MessageMenu/MessageReactionPicker.tsx @@ -6,7 +6,6 @@ import { EmojiPickerList } from './EmojiPickerList'; import { useMessageOwnReactions } from './hooks/useMessageOwnReactions'; import { ReactionButton } from './ReactionButton'; -import { useA11yLabel } from '../../a11y/hooks/useA11yLabel'; import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; import { MessageContextValue } from '../../contexts/messageContext/MessageContext'; import { @@ -148,7 +147,6 @@ export const MessageReactionPicker = (props: MessageReactionPickerProps) => { } = useTheme(); const styles = useStyles(); const own_capabilities = useOwnCapabilitiesContext(); - const a11yLabel = useA11yLabel('messageMenu.reactionPicker.accessibilityLabel'); const onSelectReaction = useStableCallback((type: string) => { NativeHandlers.triggerHaptic('impactLight'); @@ -164,7 +162,7 @@ export const MessageReactionPicker = (props: MessageReactionPickerProps) => { return ( diff --git a/package/src/components/MessageMenu/MessageUserReactionsItem.tsx b/package/src/components/MessageMenu/MessageUserReactionsItem.tsx index abf14614f2..58a18a501c 100644 --- a/package/src/components/MessageMenu/MessageUserReactionsItem.tsx +++ b/package/src/components/MessageMenu/MessageUserReactionsItem.tsx @@ -2,7 +2,6 @@ import React, { useMemo } from 'react'; import { Pressable, StyleSheet, Text, View } from 'react-native'; -import { useA11yLabel } from '../../a11y/hooks/useA11yLabel'; import { useMessageContext } from '../../contexts'; import { useChatContext } from '../../contexts/chatContext/ChatContext'; import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; @@ -42,15 +41,6 @@ export const MessageUserReactionsItem = ({ const { handleReaction } = useMessageContext(); - // Separate keys for the own reaction, so languages that conjugate "you reacted" can. - const a11yLabel = useA11yLabel( - isOwnReaction - ? 'messageMenu.userReactions.ownItem.accessibilityLabel' - : 'messageMenu.userReactions.item.accessibilityLabel', - { reaction: type, user: name || id }, - ); - const removeHint = useA11yLabel('messageMenu.userReactions.removeHint.accessibilityLabel'); - const onPress = useStableCallback(() => { if (isOwnReaction && handleReaction) { handleReaction(type); @@ -61,8 +51,7 @@ export const MessageUserReactionsItem = ({ return ( { }); }); - it('uses the translated label when accessibility is enabled', async () => { - const { getByLabelText, queryByLabelText } = render( - - - - - - - - - - - - - , - ); - - await waitFor(() => { - expect(getByLabelText('Reaction picker')).toBeTruthy(); - }); - expect(queryByLabelText('Reaction Selector on long pressing message')).toBeNull(); - }); - it('does not render when sendReaction capability is false', () => { const { queryByLabelText } = renderComponent({}, { sendReaction: false }); expect(queryByLabelText('Reaction Selector on long pressing message')).toBeNull(); diff --git a/package/src/components/MessageMenu/__tests__/MessageUserReactionsItem.test.tsx b/package/src/components/MessageMenu/__tests__/MessageUserReactionsItem.test.tsx index 18d34ec49a..11833dee94 100644 --- a/package/src/components/MessageMenu/__tests__/MessageUserReactionsItem.test.tsx +++ b/package/src/components/MessageMenu/__tests__/MessageUserReactionsItem.test.tsx @@ -4,15 +4,12 @@ import { Text } from 'react-native'; import { render } from '@testing-library/react-native'; -import { AccessibilityProvider } from '../../../contexts/accessibilityContext/AccessibilityContext'; import { ChatContextValue, ChatProvider } from '../../../contexts/chatContext/ChatContext'; import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; import { defaultTheme } from '../../../contexts/themeContext/utils/theme'; -import { TranslationProvider } from '../../../contexts/translationContext/TranslationContext'; import { generateUser } from '../../../mock-builders/generator/user'; import { getTestClientWithUser } from '../../../mock-builders/mock'; -import { Streami18n } from '../../../utils/i18n/Streami18n'; import { MessageUserReactionsItem } from '../MessageUserReactionsItem'; jest.mock('../../../icons/Unknown', () => ({ @@ -86,42 +83,4 @@ describe('MessageUserReactionsItem', () => { expect(getByTestId('like-icon')).toBeTruthy(); expect(queryByTestId('unknown-icon')).toBeNull(); }); - - describe('with accessibility enabled', () => { - const renderAccessible = async (clientUserID: string) => { - const translators = await new Streami18n().init(); - return render( - - - - - - - - - , - ); - }; - - it("names the user and the reaction of another user's reaction", async () => { - const { getByLabelText } = await renderAccessible('user2'); - const item = getByLabelText('John Doe reacted with like'); - expect(item.props.accessibilityHint).toBeUndefined(); - }); - - it('hints that an own reaction can be removed', async () => { - const { getByLabelText } = await renderAccessible('user1'); - const item = getByLabelText('You reacted with like'); - expect(item.props.accessibilityHint).toBe('Double tap to remove'); - }); - }); }); diff --git a/package/src/i18n/__tests__/catalog.fixture.json b/package/src/i18n/__tests__/catalog.fixture.json index 2157cb94ed..49ef471ce3 100644 --- a/package/src/i18n/__tests__/catalog.fixture.json +++ b/package/src/i18n/__tests__/catalog.fixture.json @@ -302,12 +302,8 @@ "messageList.unreadMessages.label": "Unread Messages", "messageList.unreadMessages.withCount.label": "{{count}} unread", "messageMenu.actionList.accessibilityLabel": "Message actions", - "messageMenu.reactionPicker.accessibilityLabel": "Reaction picker", "messageMenu.reactionPicker.moreReactions.accessibilityLabel": "Open more reactions", "messageMenu.userReactions.fetchFailed.error": "Error fetching reactions", - "messageMenu.userReactions.item.accessibilityLabel": "{{user}} reacted with {{reaction}}", - "messageMenu.userReactions.ownItem.accessibilityLabel": "You reacted with {{reaction}}", - "messageMenu.userReactions.removeHint.accessibilityLabel": "Double tap to remove", "messageMenu.userReactions.tapToRemove.description": "Tap to remove", "messageMenu.userReactions.title_one": "{{count}} Reaction", "messageMenu.userReactions.title_other": "{{count}} Reactions", diff --git a/package/src/i18n/keys.ts b/package/src/i18n/keys.ts index d06037a9b8..aea51e63cd 100644 --- a/package/src/i18n/keys.ts +++ b/package/src/i18n/keys.ts @@ -313,12 +313,8 @@ export type TranslationCatalog = { 'messageList.unreadMessages.label': 'Unread Messages'; 'messageList.unreadMessages.withCount.label': '{{count}} unread'; 'messageMenu.actionList.accessibilityLabel': 'Message actions'; - 'messageMenu.reactionPicker.accessibilityLabel': 'Reaction picker'; 'messageMenu.reactionPicker.moreReactions.accessibilityLabel': 'Open more reactions'; 'messageMenu.userReactions.fetchFailed.error': 'Error fetching reactions'; - 'messageMenu.userReactions.item.accessibilityLabel': '{{user}} reacted with {{reaction}}'; - 'messageMenu.userReactions.ownItem.accessibilityLabel': 'You reacted with {{reaction}}'; - 'messageMenu.userReactions.removeHint.accessibilityLabel': 'Double tap to remove'; 'messageMenu.userReactions.tapToRemove.description': 'Tap to remove'; 'messageMenu.userReactions.title_one': '{{count}} Reaction'; 'messageMenu.userReactions.title_other': '{{count}} Reactions'; @@ -504,11 +500,7 @@ export type BundledTranslationKey = | 'messageList.dismissUnread.accessibilityLabel' | 'messageList.scrollToBottom.accessibilityLabel' | 'messageMenu.actionList.accessibilityLabel' - | 'messageMenu.reactionPicker.accessibilityLabel' | 'messageMenu.reactionPicker.moreReactions.accessibilityLabel' - | 'messageMenu.userReactions.item.accessibilityLabel' - | 'messageMenu.userReactions.ownItem.accessibilityLabel' - | 'messageMenu.userReactions.removeHint.accessibilityLabel' | 'notifications.attachmentUploadFailed.error' | 'notifications.attachmentUploadFailed.withReason.error' | 'notifications.pollCreateFailed.error' diff --git a/package/src/i18n/runtimeDefaults.ts b/package/src/i18n/runtimeDefaults.ts index 0cb751480c..114b56d297 100644 --- a/package/src/i18n/runtimeDefaults.ts +++ b/package/src/i18n/runtimeDefaults.ts @@ -123,11 +123,7 @@ export const runtimeDefaults = { 'messageList.scrollToBottom.withCount.accessibilityLabel_other': 'Scroll to bottom, {{count}} new messages', 'messageMenu.actionList.accessibilityLabel': 'Message actions', - 'messageMenu.reactionPicker.accessibilityLabel': 'Reaction picker', 'messageMenu.reactionPicker.moreReactions.accessibilityLabel': 'Open more reactions', - 'messageMenu.userReactions.item.accessibilityLabel': '{{user}} reacted with {{reaction}}', - 'messageMenu.userReactions.ownItem.accessibilityLabel': 'You reacted with {{reaction}}', - 'messageMenu.userReactions.removeHint.accessibilityLabel': 'Double tap to remove', 'notifications.attachmentUploadFailed.error': 'Error uploading attachment', 'notifications.attachmentUploadFailed.withReason.error': 'Attachment upload failed due to {{reason}}', From 8991c194fa94fa89d62308f77c6e5fc859fb4711 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 21 Oct 2026 13:35:30 +0200 Subject: [PATCH 7/8] chore: revert unrelated changes --- .../Attachment/utils/buildGallery/buildGalleryOfSingleImage.ts | 2 +- .../Attachment/utils/buildGallery/buildGalleryOfTwoImages.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package/src/components/Attachment/utils/buildGallery/buildGalleryOfSingleImage.ts b/package/src/components/Attachment/utils/buildGallery/buildGalleryOfSingleImage.ts index 632740583c..ec9e08a30e 100644 --- a/package/src/components/Attachment/utils/buildGallery/buildGalleryOfSingleImage.ts +++ b/package/src/components/Attachment/utils/buildGallery/buildGalleryOfSingleImage.ts @@ -50,7 +50,7 @@ function getContainerSize({ if (containerHeight === maxHeight) { return { height: containerHeight, - width: clamp(containerHeight * aspectRatio, minWidth, maxWidth), + width: clamp(containerHeight * aspectRatio, minHeight, maxHeight), }; } diff --git a/package/src/components/Attachment/utils/buildGallery/buildGalleryOfTwoImages.ts b/package/src/components/Attachment/utils/buildGallery/buildGalleryOfTwoImages.ts index 8ec130fc2b..57d6014dc5 100644 --- a/package/src/components/Attachment/utils/buildGallery/buildGalleryOfTwoImages.ts +++ b/package/src/components/Attachment/utils/buildGallery/buildGalleryOfTwoImages.ts @@ -46,7 +46,7 @@ export function buildGalleryOfTwoImages({ return buildThumbnailGrid({ grid: [[1, 1]], - images: isLandscape1 ? images : [...images].reverse(), + images: isLandscape1 ? images : images.reverse(), invertedDirections: true, resizableCDNHosts, sizeConfig, From 7b66d0f0a321f9419473b82da7b1d3656a1df08c Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Wed, 21 Oct 2026 13:58:23 +0200 Subject: [PATCH 8/8] test: drop the gallery tests for the reverted layout fixes --- .../Attachment/__tests__/buildGallery.test.ts | 26 ------------------- 1 file changed, 26 deletions(-) diff --git a/package/src/components/Attachment/__tests__/buildGallery.test.ts b/package/src/components/Attachment/__tests__/buildGallery.test.ts index f8f4c4a268..6506324333 100644 --- a/package/src/components/Attachment/__tests__/buildGallery.test.ts +++ b/package/src/components/Attachment/__tests__/buildGallery.test.ts @@ -81,32 +81,6 @@ describe('buildGallery', () => { }); }); - it('clamps a height-limited landscape image width with the width limits', () => { - // 500x400 fills maxWidth (256), so its height (204.8) is clamped to maxHeight (192). The width - // follows from that height (192 * 1.25 = 240), which is within the width limits. - const image = generateImageAttachment({ original_height: 400, original_width: 500 }); - - const { height, width } = buildGallery({ - images: [image], - sizeConfig: defaultSizeConfig, - }); - - expect(height).toBe(192); - expect(width).toBe(240); - }); - - it('does not reorder the images passed in', () => { - const portrait = generateImageAttachment({ original_height: 400, original_width: 200 }); - const landscape = generateImageAttachment({ original_height: 200, original_width: 400 }); - const images = [portrait, landscape]; - - const { thumbnailGrid } = buildGallery({ images, sizeConfig: defaultSizeConfig }); - - expect(images).toEqual([portrait, landscape]); - // The landscape image still comes first in the grid. - expect(thumbnailGrid[0][0].url).toBe(landscape.image_url); - }); - it('gallery size should default to gridHeight and gridWidth if original image size is unavailable', () => { const attachments: Attachment[] = []; for (let numOfImages = 0; numOfImages < 4; numOfImages++) {