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/examples/SampleApp/src/i18n/de.ts b/examples/SampleApp/src/i18n/de.ts index 619e6b4695..e9a0933d30 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', diff --git a/examples/SampleApp/src/i18n/it.ts b/examples/SampleApp/src/i18n/it.ts index 7949c4c73a..c36d8f687e 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', 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/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/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/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/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; 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/__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/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/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/i18n/__tests__/catalog.fixture.json b/package/src/i18n/__tests__/catalog.fixture.json index cdadf0884a..49ef471ce3 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", diff --git a/package/src/i18n/keys.ts b/package/src/i18n/keys.ts index 976bfed276..aea51e63cd 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'; 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/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) { 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) { 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) => {