From 653bbd5d1124223dc365963569c50961b2710340 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 29 Jul 2026 11:07:46 -0300 Subject: [PATCH 01/14] fix: video attachments stuck loading forever on iOS --- .../message/hooks/__tests__/useFile.test.ts | 101 ++++-------------- app/containers/message/hooks/useFile.tsx | 42 ++++---- .../message/hooks/useMediaAutoDownload.tsx | 2 +- app/lib/methods/handleMediaDownload.ts | 14 ++- .../methods/helpers/formatAttachmentUrl.ts | 25 ++++- app/views/AttachmentView.tsx | 9 +- 6 files changed, 74 insertions(+), 119 deletions(-) diff --git a/app/containers/message/hooks/__tests__/useFile.test.ts b/app/containers/message/hooks/__tests__/useFile.test.ts index 373fffe3315..4cd27daff8e 100644 --- a/app/containers/message/hooks/__tests__/useFile.test.ts +++ b/app/containers/message/hooks/__tests__/useFile.test.ts @@ -2,106 +2,47 @@ import { act, renderHook } from '@testing-library/react-native'; import { useFile } from '../useFile'; import { type IAttachment } from '../../../../definitions'; -import { getMessageById } from '../../../../lib/database/services/Message'; -import { getThreadMessageById } from '../../../../lib/database/services/ThreadMessage'; - -jest.mock('../../../../lib/database/services/Message', () => ({ - getMessageById: jest.fn() -})); - -jest.mock('../../../../lib/database/services/ThreadMessage', () => ({ - getThreadMessageById: jest.fn() -})); - -const mockGetMessageById = getMessageById as jest.Mock; -const mockGetThreadMessageById = getThreadMessageById as jest.Mock; const file = { title: 'original.png', title_link: '/original' } as IAttachment; -// Flushes the async checkMessage effect (two awaited DB reads) inside act so any -// setIsMessagePersisted update settles before assertions run. -const flushEffect = () => - act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - describe('useFile', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockGetThreadMessageById.mockResolvedValue(undefined); - mockGetMessageById.mockResolvedValue(undefined); - }); - - it('returns the file prop and treats forwarded merges as a no-op while the thread message persists', async () => { - mockGetThreadMessageById.mockResolvedValue({ id: 'thread-message' }); - const { result } = renderHook(() => useFile(file, 'msg-id')); - - await flushEffect(); - expect(mockGetThreadMessageById).toHaveBeenCalledWith('msg-id'); - expect(mockGetMessageById).not.toHaveBeenCalled(); - expect(result.current[0]).toBe(file); - - act(() => result.current[1]({ title_link: '/forwarded' })); - expect(result.current[0]).toBe(file); - }); - - it('stays persisted when no thread message exists but the message is found', async () => { - mockGetThreadMessageById.mockResolvedValue(undefined); - mockGetMessageById.mockResolvedValue({ id: 'message' }); - const { result } = renderHook(() => useFile(file, 'msg-id')); - - await flushEffect(); - expect(mockGetMessageById).toHaveBeenCalledWith('msg-id'); - expect(result.current[0]).toBe(file); + it('returns the file prop untouched until something is merged', () => { + const { result } = renderHook(() => useFile(file)); - act(() => result.current[1]({ title_link: '/forwarded' })); expect(result.current[0]).toBe(file); }); - it('becomes not-persisted and returns the merged localFile when neither message is found', async () => { - const { result } = renderHook(() => useFile(file, 'msg-id')); + it('merges an override on top of the file prop', () => { + const { result } = renderHook(() => useFile(file)); - await flushEffect(); - expect(mockGetMessageById).toHaveBeenCalledWith('msg-id'); + act(() => result.current[1]({ title_link: 'file:///local/original.png' })); - act(() => result.current[1]({ title_link: '/forwarded' })); - expect(result.current[0].title_link).toBe('/forwarded'); expect(result.current[0]).not.toBe(file); + expect(result.current[0].title_link).toBe('file:///local/original.png'); expect(result.current[0].title).toBe('original.png'); }); - it('starts not-persisted when messageId is empty and merges forwarded files into localFile', async () => { - const { result } = renderHook(() => useFile(file, '')); + it('accumulates successive overrides', () => { + const { result } = renderHook(() => useFile(file)); - await flushEffect(); - expect(result.current[0]).toBe(file); + act(() => result.current[1]({ title_link: 'file:///local/original.png' })); + act(() => result.current[1]({ e2e: 'done' })); - act(() => result.current[1]({ title_link: '/forwarded' })); - expect(result.current[0]).not.toBe(file); - expect(result.current[0].title_link).toBe('/forwarded'); + expect(result.current[0].title_link).toBe('file:///local/original.png'); + expect(result.current[0].e2e).toBe('done'); }); - // Documents the current contract: isMessagePersisted is seeded once and the effect only ever - // flips it to false, so it can't recover to true on a later messageId change. Not fixed: the - // only caller keys its list by that same id, so a real id change always remounts the hook fresh. - it('stays stuck not-persisted after rerendering with a real persisted messageId (documents current contract)', async () => { - mockGetThreadMessageById.mockResolvedValue(undefined); - mockGetMessageById.mockResolvedValue(undefined); - const { result, rerender } = renderHook(({ messageId }: { messageId: string }) => useFile(file, messageId), { - initialProps: { messageId: '' } + // The override is what the download resolved to locally, so it has to win over a later `file` + // prop that still carries the remote url — that stale prop is exactly the bug this hook fixes. + it('keeps overrides applied when the file prop changes', () => { + const { result, rerender } = renderHook(({ f }: { f: IAttachment }) => useFile(f), { + initialProps: { f: file } }); - await flushEffect(); - expect(result.current[0]).toBe(file); + act(() => result.current[1]({ title_link: 'file:///local/original.png' })); + rerender({ f: { ...file, title: 'renamed.png' } as IAttachment }); - mockGetMessageById.mockResolvedValue({ id: 'msg-id' }); - rerender({ messageId: 'msg-id' }); - await flushEffect(); - - expect(mockGetMessageById).toHaveBeenCalledWith('msg-id'); - act(() => result.current[1]({ title_link: '/forwarded' })); - expect(result.current[0]).not.toBe(file); - expect(result.current[0].title_link).toBe('/forwarded'); + expect(result.current[0].title).toBe('renamed.png'); + expect(result.current[0].title_link).toBe('file:///local/original.png'); }); }); diff --git a/app/containers/message/hooks/useFile.tsx b/app/containers/message/hooks/useFile.tsx index 6506d5b8e7c..61daa0a682c 100644 --- a/app/containers/message/hooks/useFile.tsx +++ b/app/containers/message/hooks/useFile.tsx @@ -1,32 +1,26 @@ -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { type IAttachment } from '../../../definitions'; -import { getMessageById } from '../../../lib/database/services/Message'; -import { getThreadMessageById } from '../../../lib/database/services/ThreadMessage'; -export const useFile = (file: IAttachment, messageId: string) => { +/** + * Keeps local overrides for an attachment — currently the local uri a download resolved to — + * merged on top of the `file` prop. + * + * Overrides are applied unconditionally rather than only for non-persisted messages. Waiting on the + * database round trip is not reliable: `persistMessage` silently no-ops whenever it can't find a + * row for the message id, which happens for forwarded messages and for the Files/Mentions/Starred/ + * Pinned lists, whose attachments are built from REST payloads that carry no message id. Those + * attachments were left pointing at the remote url even after the file was cached on disk, so + * opening them streamed instead of playing the local file. + */ +export const useFile = (file: IAttachment) => { 'use memo'; - const [localFile, setLocalFile] = useState(file); - const [isMessagePersisted, setIsMessagePersisted] = useState(!!messageId); - useEffect(() => { - const checkMessage = async () => { - const threadMessage = await getThreadMessageById(messageId); - if (!threadMessage) { - const message = await getMessageById(messageId); - if (!message) { - setIsMessagePersisted(false); - } - } - }; - checkMessage(); - }, [messageId]); + const [overrides, setOverrides] = useState | null>(null); - const manageForwardedFile = (f: Partial) => { - if (isMessagePersisted) { - return; - } - setLocalFile(prev => ({ ...prev, ...f })); + const mergeFile = (f: Partial) => { + setOverrides(prev => ({ ...prev, ...f })); }; - return [isMessagePersisted ? file : localFile, manageForwardedFile] as const; + + return [overrides ? { ...file, ...overrides } : file, mergeFile] as const; }; diff --git a/app/containers/message/hooks/useMediaAutoDownload.tsx b/app/containers/message/hooks/useMediaAutoDownload.tsx index c3deec8da0f..4534f244742 100644 --- a/app/containers/message/hooks/useMediaAutoDownload.tsx +++ b/app/containers/message/hooks/useMediaAutoDownload.tsx @@ -82,7 +82,7 @@ export const useMediaAutoDownload = ({ const baseUrl = useBaseUrl(); const user = useMessageUser(); const [status, dispatchDownloadEvent] = useReducer(downloadStatusReducer, 'to-download'); - const [currentFile, setCurrentFile] = useFile(file, id ?? ''); + const [currentFile, setCurrentFile] = useFile(file); const originalUrl = getOriginalURL(file); const url = formatAttachmentUrl( file.title_link || getFileProperty(currentFile, fileType, 'url'), diff --git a/app/lib/methods/handleMediaDownload.ts b/app/lib/methods/handleMediaDownload.ts index 8f42fe26224..2006bd961ce 100644 --- a/app/lib/methods/handleMediaDownload.ts +++ b/app/lib/methods/handleMediaDownload.ts @@ -249,11 +249,17 @@ const persistMessage = async (messageId: string, uri: string, encryption: boolea }) ); } - if (batch.length) { - await db.write(async () => { - await db.batch(batch); - }); + if (!batch.length) { + // No row to point at the cached file. Expected for forwarded messages and for the + // Files/Mentions/Starred/Pinned lists, whose attachments come from REST payloads that carry no + // message id. useFile keeps the local uri in component state so playback still works, but the + // database keeps the remote url — worth knowing about rather than failing silently. + console.log(`[handleMediaDownload] no message found for id "${messageId}", cached uri not persisted`); + return; } + await db.write(async () => { + await db.batch(batch); + }); }; export function downloadMediaFile({ diff --git a/app/lib/methods/helpers/formatAttachmentUrl.ts b/app/lib/methods/helpers/formatAttachmentUrl.ts index a4f47b0cc35..08cab83dd2e 100644 --- a/app/lib/methods/helpers/formatAttachmentUrl.ts +++ b/app/lib/methods/helpers/formatAttachmentUrl.ts @@ -10,6 +10,21 @@ function setParamInUrl({ url, token, userId }: { url: string; token: string; use return urlObj.toString(); } +/** + * Percent-encodes a url without double-encoding one that already is. The server hands us attachment + * paths already encoded (`/file-upload//Screen%20Recording.mov`), and a plain `encodeURI` turns + * every `%` into `%25`, producing a path the server can't resolve. Decoding first makes the result + * the same whichever form we were given. `decodeURI` leaves escapes for reserved characters alone, + * so query string values survive the round trip; it throws on a malformed escape, hence the guard. + */ +export const encodeAttachmentUrl = (url: string): string => { + try { + return encodeURI(decodeURI(url)); + } catch { + return url; + } +}; + export const formatAttachmentUrl = ( attachmentUrl: string | undefined, userId: string, @@ -28,17 +43,17 @@ export const formatAttachmentUrl = ( } if (attachmentUrl.includes('rc_token')) { - return encodeURI(attachmentUrl); + return encodeAttachmentUrl(attachmentUrl); } - if (protectFiles) return setParamInUrl({ url: attachmentUrl, token, userId }); - return attachmentUrl; + if (protectFiles) return encodeAttachmentUrl(setParamInUrl({ url: attachmentUrl, token, userId })); + return encodeAttachmentUrl(attachmentUrl); } let cdnPrefix = store?.getState().settings.CDN_PREFIX as string; cdnPrefix = cdnPrefix?.trim(); if (cdnPrefix && cdnPrefix.startsWith('http')) { server = cdnPrefix.replace(/\/+$/, ''); } - if (protectFiles) return setParamInUrl({ url: `${server}${attachmentUrl}`, token, userId }); - return `${server}${attachmentUrl}`; + if (protectFiles) return encodeAttachmentUrl(setParamInUrl({ url: `${server}${attachmentUrl}`, token, userId })); + return encodeAttachmentUrl(`${server}${attachmentUrl}`); }; diff --git a/app/views/AttachmentView.tsx b/app/views/AttachmentView.tsx index 322b659eada..a7801b35497 100644 --- a/app/views/AttachmentView.tsx +++ b/app/views/AttachmentView.tsx @@ -59,9 +59,9 @@ const RenderContent = ({ }, [navigation]); if (attachment.image_url) { - const url = formatAttachmentUrl(attachment.title_link || attachment.image_url, user.id, user.token, baseUrl); - const uri = encodeURI(url); - const isAnimated = attachment.image_type === 'image/gif' || /\.gif(\?|$)/i.test(url); + // formatAttachmentUrl already encodes the url; encoding again would turn `%20` into `%2520`. + const uri = formatAttachmentUrl(attachment.title_link || attachment.image_url, user.id, user.token, baseUrl); + const isAnimated = attachment.image_type === 'image/gif' || /\.gif(\?|$)/i.test(uri); return ( Date: Wed, 29 Jul 2026 16:24:41 -0300 Subject: [PATCH 02/14] chore: code improvements --- app/lib/methods/handleMediaDownload.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/app/lib/methods/handleMediaDownload.ts b/app/lib/methods/handleMediaDownload.ts index 2006bd961ce..0790d4b9393 100644 --- a/app/lib/methods/handleMediaDownload.ts +++ b/app/lib/methods/handleMediaDownload.ts @@ -250,11 +250,8 @@ const persistMessage = async (messageId: string, uri: string, encryption: boolea ); } if (!batch.length) { - // No row to point at the cached file. Expected for forwarded messages and for the - // Files/Mentions/Starred/Pinned lists, whose attachments come from REST payloads that carry no - // message id. useFile keeps the local uri in component state so playback still works, but the - // database keeps the remote url — worth knowing about rather than failing silently. - console.log(`[handleMediaDownload] no message found for id "${messageId}", cached uri not persisted`); + // Expected when the attachment has no message row (forwarded, attachment lists); useFile keeps the local uri. + console.log('[handleMediaDownload] no message row for attachment, cached uri not persisted'); return; } await db.write(async () => { From 2fc4f687898ca84d215cfa69644b0edc24e58376 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 29 Jul 2026 16:40:03 -0300 Subject: [PATCH 03/14] chore: code improvements --- app/containers/message/hooks/useFile.tsx | 12 +----------- app/lib/methods/helpers/formatAttachmentUrl.ts | 8 +------- 2 files changed, 2 insertions(+), 18 deletions(-) diff --git a/app/containers/message/hooks/useFile.tsx b/app/containers/message/hooks/useFile.tsx index 61daa0a682c..5c0820d82da 100644 --- a/app/containers/message/hooks/useFile.tsx +++ b/app/containers/message/hooks/useFile.tsx @@ -2,17 +2,7 @@ import { useState } from 'react'; import { type IAttachment } from '../../../definitions'; -/** - * Keeps local overrides for an attachment — currently the local uri a download resolved to — - * merged on top of the `file` prop. - * - * Overrides are applied unconditionally rather than only for non-persisted messages. Waiting on the - * database round trip is not reliable: `persistMessage` silently no-ops whenever it can't find a - * row for the message id, which happens for forwarded messages and for the Files/Mentions/Starred/ - * Pinned lists, whose attachments are built from REST payloads that carry no message id. Those - * attachments were left pointing at the remote url even after the file was cached on disk, so - * opening them streamed instead of playing the local file. - */ +// Merges local overrides (the downloaded uri) over the `file` prop, unconditionally: persistMessage no-ops without a message row. export const useFile = (file: IAttachment) => { 'use memo'; diff --git a/app/lib/methods/helpers/formatAttachmentUrl.ts b/app/lib/methods/helpers/formatAttachmentUrl.ts index 08cab83dd2e..9898067638d 100644 --- a/app/lib/methods/helpers/formatAttachmentUrl.ts +++ b/app/lib/methods/helpers/formatAttachmentUrl.ts @@ -10,13 +10,7 @@ function setParamInUrl({ url, token, userId }: { url: string; token: string; use return urlObj.toString(); } -/** - * Percent-encodes a url without double-encoding one that already is. The server hands us attachment - * paths already encoded (`/file-upload//Screen%20Recording.mov`), and a plain `encodeURI` turns - * every `%` into `%25`, producing a path the server can't resolve. Decoding first makes the result - * the same whichever form we were given. `decodeURI` leaves escapes for reserved characters alone, - * so query string values survive the round trip; it throws on a malformed escape, hence the guard. - */ +// Idempotent encode: the server already sends encoded paths, and a plain encodeURI would turn `%20` into `%2520`. export const encodeAttachmentUrl = (url: string): string => { try { return encodeURI(decodeURI(url)); From 1b5bf97e775ebc096e76b139864606c021f20af5 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Mon, 3 Aug 2026 14:23:06 -0300 Subject: [PATCH 04/14] test: add coverage for encodeAttachmentUrl --- .../__tests__/formatAttachmentUrl.test.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 app/lib/methods/helpers/__tests__/formatAttachmentUrl.test.ts diff --git a/app/lib/methods/helpers/__tests__/formatAttachmentUrl.test.ts b/app/lib/methods/helpers/__tests__/formatAttachmentUrl.test.ts new file mode 100644 index 00000000000..b5dbc851dad --- /dev/null +++ b/app/lib/methods/helpers/__tests__/formatAttachmentUrl.test.ts @@ -0,0 +1,21 @@ +import { encodeAttachmentUrl } from '../formatAttachmentUrl'; + +describe('encodeAttachmentUrl', () => { + it('encodes an unencoded path', () => { + expect(encodeAttachmentUrl('https://open.rocket.chat/file-upload/1/Screen Recording.mov')).toBe( + 'https://open.rocket.chat/file-upload/1/Screen%20Recording.mov' + ); + }); + + it('leaves an already-encoded path untouched', () => { + expect(encodeAttachmentUrl('https://open.rocket.chat/file-upload/1/Screen%20Recording.mov')).toBe( + 'https://open.rocket.chat/file-upload/1/Screen%20Recording.mov' + ); + }); + + it('returns the raw url when it has a malformed escape', () => { + expect(encodeAttachmentUrl('https://open.rocket.chat/file-upload/1/%ZZ.mov')).toBe( + 'https://open.rocket.chat/file-upload/1/%ZZ.mov' + ); + }); +}); From c9abe98d1c3d1239ff9712d29095e247e044b175 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 5 Aug 2026 17:11:06 -0300 Subject: [PATCH 05/14] refactor: encode attachment urls via the URL parser --- .../helpers/__tests__/formatAttachmentUrl.test.ts | 12 ++++++++++++ app/lib/methods/helpers/formatAttachmentUrl.ts | 3 +-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/app/lib/methods/helpers/__tests__/formatAttachmentUrl.test.ts b/app/lib/methods/helpers/__tests__/formatAttachmentUrl.test.ts index b5dbc851dad..45a86f47bce 100644 --- a/app/lib/methods/helpers/__tests__/formatAttachmentUrl.test.ts +++ b/app/lib/methods/helpers/__tests__/formatAttachmentUrl.test.ts @@ -13,6 +13,18 @@ describe('encodeAttachmentUrl', () => { ); }); + it('leaves an escaped reserved character untouched', () => { + expect(encodeAttachmentUrl('https://open.rocket.chat/file-upload/1/a%20video%20%232.mov')).toBe( + 'https://open.rocket.chat/file-upload/1/a%20video%20%232.mov' + ); + }); + + it('preserves the query string', () => { + expect(encodeAttachmentUrl('https://open.rocket.chat/file-upload/1/Screen Recording.mov?rc_token=abc&rc_uid=123')).toBe( + 'https://open.rocket.chat/file-upload/1/Screen%20Recording.mov?rc_token=abc&rc_uid=123' + ); + }); + it('returns the raw url when it has a malformed escape', () => { expect(encodeAttachmentUrl('https://open.rocket.chat/file-upload/1/%ZZ.mov')).toBe( 'https://open.rocket.chat/file-upload/1/%ZZ.mov' diff --git a/app/lib/methods/helpers/formatAttachmentUrl.ts b/app/lib/methods/helpers/formatAttachmentUrl.ts index 9898067638d..4d1e8ca74d4 100644 --- a/app/lib/methods/helpers/formatAttachmentUrl.ts +++ b/app/lib/methods/helpers/formatAttachmentUrl.ts @@ -9,11 +9,10 @@ function setParamInUrl({ url, token, userId }: { url: string; token: string; use urlObj.searchParams.set('rc_uid', userId); return urlObj.toString(); } - // Idempotent encode: the server already sends encoded paths, and a plain encodeURI would turn `%20` into `%2520`. export const encodeAttachmentUrl = (url: string): string => { try { - return encodeURI(decodeURI(url)); + return new URL(url).toString(); } catch { return url; } From 1f1cc90bc8096d0018d6b088c043a5a9cda80bd9 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Thu, 6 Aug 2026 14:57:03 -0300 Subject: [PATCH 06/14] fix: double-encoded attachment urls in inline media --- .../message/components/Attachments/Image/Image.tsx | 2 +- app/containers/message/hooks/useFile.tsx | 2 -- .../methods/helpers/__tests__/formatAttachmentUrl.test.ts | 6 ++++++ app/lib/methods/helpers/formatAttachmentUrl.ts | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/app/containers/message/components/Attachments/Image/Image.tsx b/app/containers/message/components/Attachments/Image/Image.tsx index 381ac09e07a..179a23f56f7 100644 --- a/app/containers/message/components/Attachments/Image/Image.tsx +++ b/app/containers/message/components/Attachments/Image/Image.tsx @@ -69,7 +69,7 @@ export const MessageImage = ({ uri, status, encrypted = false, imagePreview, ima <> {showImage ? ( - + ) : null} {['loading', 'to-download'].includes(status) || (status === 'downloaded' && !showImage) ? ( diff --git a/app/containers/message/hooks/useFile.tsx b/app/containers/message/hooks/useFile.tsx index 5c0820d82da..be567cc0eb3 100644 --- a/app/containers/message/hooks/useFile.tsx +++ b/app/containers/message/hooks/useFile.tsx @@ -4,8 +4,6 @@ import { type IAttachment } from '../../../definitions'; // Merges local overrides (the downloaded uri) over the `file` prop, unconditionally: persistMessage no-ops without a message row. export const useFile = (file: IAttachment) => { - 'use memo'; - const [overrides, setOverrides] = useState | null>(null); const mergeFile = (f: Partial) => { diff --git a/app/lib/methods/helpers/__tests__/formatAttachmentUrl.test.ts b/app/lib/methods/helpers/__tests__/formatAttachmentUrl.test.ts index 45a86f47bce..8b937388943 100644 --- a/app/lib/methods/helpers/__tests__/formatAttachmentUrl.test.ts +++ b/app/lib/methods/helpers/__tests__/formatAttachmentUrl.test.ts @@ -25,9 +25,15 @@ describe('encodeAttachmentUrl', () => { ); }); + // WHATWG URL passes a malformed escape through rather than throwing, so this exercises the try branch. it('returns the raw url when it has a malformed escape', () => { expect(encodeAttachmentUrl('https://open.rocket.chat/file-upload/1/%ZZ.mov')).toBe( 'https://open.rocket.chat/file-upload/1/%ZZ.mov' ); }); + + // A non-absolute url is what actually throws — reachable when the server/CDN prefix is empty. + it('returns the raw url when it is not absolute', () => { + expect(encodeAttachmentUrl('/file-upload/1/Screen Recording.mov')).toBe('/file-upload/1/Screen Recording.mov'); + }); }); diff --git a/app/lib/methods/helpers/formatAttachmentUrl.ts b/app/lib/methods/helpers/formatAttachmentUrl.ts index 4d1e8ca74d4..6d9dafe2163 100644 --- a/app/lib/methods/helpers/formatAttachmentUrl.ts +++ b/app/lib/methods/helpers/formatAttachmentUrl.ts @@ -32,7 +32,7 @@ export const formatAttachmentUrl = ( } if (attachmentUrl && attachmentUrl.startsWith('http')) { if (_originalUrl && !_originalUrl.startsWith(server)) { - return _originalUrl; + return encodeAttachmentUrl(_originalUrl); } if (attachmentUrl.includes('rc_token')) { From 8d6a891581047b745f1ed8deccf4be713c24f038 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Thu, 6 Aug 2026 18:16:01 -0300 Subject: [PATCH 07/14] fix: double-encoded attachment urls in inline media --- app/lib/methods/handleMediaDownload.test.ts | 24 +++++++++++++++++++ app/lib/methods/handleMediaDownload.ts | 20 ++++++++++++---- .../__tests__/formatAttachmentUrl.test.ts | 11 ++++++++- 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/app/lib/methods/handleMediaDownload.test.ts b/app/lib/methods/handleMediaDownload.test.ts index bfab148f84f..42969d28dd9 100644 --- a/app/lib/methods/handleMediaDownload.test.ts +++ b/app/lib/methods/handleMediaDownload.test.ts @@ -34,6 +34,30 @@ describe('matchDownloadUrl', () => { matchDownloadUrl({ image_url: '/file-upload/abc/photo.jpg' }, 'https://server.com/file-upload/abc/audio.mp3') ).toBeFalsy(); }); + + it('matches a raw attachment url against an encoded downloadUrl', () => { + expect( + matchDownloadUrl( + { video_url: '/file-upload/abc/Screen Recording.mov' }, + 'https://server.com/file-upload/abc/Screen%20Recording.mov' + ) + ).toBeTruthy(); + }); + + it('matches an encoded attachment url against an encoded downloadUrl', () => { + expect( + matchDownloadUrl( + { video_url: '/file-upload/abc/Screen%20Recording.mov' }, + 'https://server.com/file-upload/abc/Screen%20Recording.mov' + ) + ).toBeTruthy(); + }); + + it('still compares when the attachment url has a malformed escape', () => { + expect( + matchDownloadUrl({ image_url: '/file-upload/abc/%ZZ.jpg' }, 'https://server.com/file-upload/abc/%ZZ.jpg') + ).toBeTruthy(); + }); }); describe('Test the getFilename', () => { diff --git a/app/lib/methods/handleMediaDownload.ts b/app/lib/methods/handleMediaDownload.ts index 0790d4b9393..a4a197cf58e 100644 --- a/app/lib/methods/handleMediaDownload.ts +++ b/app/lib/methods/handleMediaDownload.ts @@ -200,10 +200,22 @@ export async function cancelDownload(messageUrl: string): Promise { } } -export const matchDownloadUrl = (att: IAttachment, downloadUrl: string) => - (att.image_url && downloadUrl.includes(att.image_url)) || - (att.audio_url && downloadUrl.includes(att.audio_url)) || - (att.video_url && downloadUrl.includes(att.video_url)); +// decodeURIComponent throws on a malformed escape (`%ZZ`); fall back to the raw value so the comparison still runs. +const decodeUrl = (url: string) => { + try { + return decodeURIComponent(url); + } catch { + return url; + } +}; + +// `downloadUrl` has been through formatAttachmentUrl (so spaces are `%20`) while the attachment rows carry the +// server value verbatim. Compare decoded so both forms line up regardless of which one the server sent. +export const matchDownloadUrl = (att: IAttachment, downloadUrl: string) => { + const target = decodeUrl(downloadUrl); + const matches = (attachmentUrl?: string) => !!attachmentUrl && target.includes(decodeUrl(attachmentUrl)); + return matches(att.image_url) || matches(att.audio_url) || matches(att.video_url); +}; const mapAttachments = ({ attachments, diff --git a/app/lib/methods/helpers/__tests__/formatAttachmentUrl.test.ts b/app/lib/methods/helpers/__tests__/formatAttachmentUrl.test.ts index 8b937388943..b8e4c8febce 100644 --- a/app/lib/methods/helpers/__tests__/formatAttachmentUrl.test.ts +++ b/app/lib/methods/helpers/__tests__/formatAttachmentUrl.test.ts @@ -13,12 +13,21 @@ describe('encodeAttachmentUrl', () => { ); }); - it('leaves an escaped reserved character untouched', () => { + it('leaves an already-escaped reserved character in the path', () => { expect(encodeAttachmentUrl('https://open.rocket.chat/file-upload/1/a%20video%20%232.mov')).toBe( 'https://open.rocket.chat/file-upload/1/a%20video%20%232.mov' ); }); + // Known limitation, not a regression: `#` is the fragment delimiter per the URL spec, so a raw one ends the + // path and the rest becomes the fragment — which HTTP drops, so the server sees a truncated path. The previous + // encodeURI behaved identically (it leaves `#` unescaped too). Only reachable if the server sends a raw `#`. + it('treats a raw reserved `#` in the path as a fragment', () => { + expect(encodeAttachmentUrl('https://open.rocket.chat/file-upload/1/a video #2.mov')).toBe( + 'https://open.rocket.chat/file-upload/1/a%20video%20#2.mov' + ); + }); + it('preserves the query string', () => { expect(encodeAttachmentUrl('https://open.rocket.chat/file-upload/1/Screen Recording.mov?rc_token=abc&rc_uid=123')).toBe( 'https://open.rocket.chat/file-upload/1/Screen%20Recording.mov?rc_token=abc&rc_uid=123' From 04149da1719f3081e37dd753a93a7f3245157ae6 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Fri, 7 Aug 2026 15:45:36 -0300 Subject: [PATCH 08/14] fix: encode local file uris for non-ascii filenames --- .../helpers/__tests__/formatAttachmentUrl.test.ts | 13 +++++++++++++ app/lib/methods/helpers/formatAttachmentUrl.ts | 8 +++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/app/lib/methods/helpers/__tests__/formatAttachmentUrl.test.ts b/app/lib/methods/helpers/__tests__/formatAttachmentUrl.test.ts index b8e4c8febce..10e08b1836c 100644 --- a/app/lib/methods/helpers/__tests__/formatAttachmentUrl.test.ts +++ b/app/lib/methods/helpers/__tests__/formatAttachmentUrl.test.ts @@ -45,4 +45,17 @@ describe('encodeAttachmentUrl', () => { it('returns the raw url when it is not absolute', () => { expect(encodeAttachmentUrl('/file-upload/1/Screen Recording.mov')).toBe('/file-upload/1/Screen Recording.mov'); }); + + // Cache filenames keep unicode letters, so local uris need encoding before they reach the native players. + it('encodes unicode letters in a local file uri', () => { + expect(encodeAttachmentUrl('file:///var/app/Documents/server/msg1/vídeo.mov')).toBe( + 'file:///var/app/Documents/server/msg1/v%C3%ADdeo.mov' + ); + }); + + it('leaves an already-encoded local file uri untouched', () => { + expect(encodeAttachmentUrl('file:///var/app/Documents/server/msg1/v%C3%ADdeo.mov')).toBe( + 'file:///var/app/Documents/server/msg1/v%C3%ADdeo.mov' + ); + }); }); diff --git a/app/lib/methods/helpers/formatAttachmentUrl.ts b/app/lib/methods/helpers/formatAttachmentUrl.ts index 6d9dafe2163..6a8d5455337 100644 --- a/app/lib/methods/helpers/formatAttachmentUrl.ts +++ b/app/lib/methods/helpers/formatAttachmentUrl.ts @@ -27,9 +27,15 @@ export const formatAttachmentUrl = ( ): string => { const protectFiles = store.getState().settings.FileUpload_ProtectFiles; - if ((attachmentUrl && isImageBase64(attachmentUrl)) || attachmentUrl?.startsWith('file://')) { + // A data: uri is already its own encoding — running it through the parser would corrupt the payload. + if (attachmentUrl && isImageBase64(attachmentUrl)) { return attachmentUrl; } + // Cache filenames keep unicode letters (sanitizeLikeString only strips `[^\p{L}\p{Nd}]`), so `vídeo.mov` reaches + // here verbatim and the native players reject the unescaped path. Encode local uris too. + if (attachmentUrl?.startsWith('file://')) { + return encodeAttachmentUrl(attachmentUrl); + } if (attachmentUrl && attachmentUrl.startsWith('http')) { if (_originalUrl && !_originalUrl.startsWith(server)) { return encodeAttachmentUrl(_originalUrl); From e5d20d9f134523a91496fa92762ea8b64341b560 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Thu, 13 Aug 2026 13:03:09 -0300 Subject: [PATCH 09/14] remove comments --- app/lib/methods/handleMediaDownload.ts | 1 - app/lib/methods/helpers/formatAttachmentUrl.ts | 2 +- app/views/AttachmentView.tsx | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/app/lib/methods/handleMediaDownload.ts b/app/lib/methods/handleMediaDownload.ts index a4a197cf58e..80960214e49 100644 --- a/app/lib/methods/handleMediaDownload.ts +++ b/app/lib/methods/handleMediaDownload.ts @@ -262,7 +262,6 @@ const persistMessage = async (messageId: string, uri: string, encryption: boolea ); } if (!batch.length) { - // Expected when the attachment has no message row (forwarded, attachment lists); useFile keeps the local uri. console.log('[handleMediaDownload] no message row for attachment, cached uri not persisted'); return; } diff --git a/app/lib/methods/helpers/formatAttachmentUrl.ts b/app/lib/methods/helpers/formatAttachmentUrl.ts index 6a8d5455337..85726d01049 100644 --- a/app/lib/methods/helpers/formatAttachmentUrl.ts +++ b/app/lib/methods/helpers/formatAttachmentUrl.ts @@ -9,7 +9,7 @@ function setParamInUrl({ url, token, userId }: { url: string; token: string; use urlObj.searchParams.set('rc_uid', userId); return urlObj.toString(); } -// Idempotent encode: the server already sends encoded paths, and a plain encodeURI would turn `%20` into `%2520`. + export const encodeAttachmentUrl = (url: string): string => { try { return new URL(url).toString(); diff --git a/app/views/AttachmentView.tsx b/app/views/AttachmentView.tsx index a7801b35497..b9a0856aa3e 100644 --- a/app/views/AttachmentView.tsx +++ b/app/views/AttachmentView.tsx @@ -59,7 +59,6 @@ const RenderContent = ({ }, [navigation]); if (attachment.image_url) { - // formatAttachmentUrl already encodes the url; encoding again would turn `%20` into `%2520`. const uri = formatAttachmentUrl(attachment.title_link || attachment.image_url, user.id, user.token, baseUrl); const isAnimated = attachment.image_type === 'image/gif' || /\.gif(\?|$)/i.test(uri); return ( From c2671d3346bb48912b22c746fcab704fbf3a170b Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Fri, 14 Aug 2026 12:39:32 -0300 Subject: [PATCH 10/14] fix: merge conflicts --- app/lib/methods/handleMediaDownload.ts | 59 +++++++++++++------------- 1 file changed, 30 insertions(+), 29 deletions(-) diff --git a/app/lib/methods/handleMediaDownload.ts b/app/lib/methods/handleMediaDownload.ts index 23936343187..63f0908d846 100644 --- a/app/lib/methods/handleMediaDownload.ts +++ b/app/lib/methods/handleMediaDownload.ts @@ -236,36 +236,37 @@ const mapAttachments = ({ export const persistMessage = async (messageId: string, uri: string, encryption: boolean, downloadUrl: string) => { const db = database.active; - const batch: Model[] = []; - const messageRecord = await getMessageById(messageId); - if (messageRecord) { - batch.push( - messageRecord.prepareUpdate(m => { - m.attachments = mapAttachments({ attachments: m.attachments, uri, encryption, downloadUrl }); - }) - ); - } - const threadRecord = await getThreadById(messageId); - if (threadRecord) { - batch.push( - threadRecord.prepareUpdate(m => { - m.attachments = mapAttachments({ attachments: m.attachments, uri, encryption, downloadUrl }); - }) - ); - } - const threadMessageRecord = await getThreadMessageById(messageId); - if (threadMessageRecord) { - batch.push( - threadMessageRecord.prepareUpdate(m => { - m.attachments = mapAttachments({ attachments: m.attachments, uri, encryption, downloadUrl }); - }) - ); - } - if (!batch.length) { - console.log('[handleMediaDownload] no message row for attachment, cached uri not persisted'); - return; - } + await db.write(async () => { + const batch: Model[] = []; + const messageRecord = await getMessageById(messageId); + if (messageRecord) { + batch.push( + messageRecord.prepareUpdate(m => { + m.attachments = mapAttachments({ attachments: m.attachments, uri, encryption, downloadUrl }); + }) + ); + } + const threadRecord = await getThreadById(messageId); + if (threadRecord) { + batch.push( + threadRecord.prepareUpdate(m => { + m.attachments = mapAttachments({ attachments: m.attachments, uri, encryption, downloadUrl }); + }) + ); + } + const threadMessageRecord = await getThreadMessageById(messageId); + if (threadMessageRecord) { + batch.push( + threadMessageRecord.prepareUpdate(m => { + m.attachments = mapAttachments({ attachments: m.attachments, uri, encryption, downloadUrl }); + }) + ); + } + if (!batch.length) { + console.log('[handleMediaDownload] no message row for attachment, cached uri not persisted'); + return; + } await db.batch(batch); }); }; From dffddf666507e422a840b506eafbdf3352a2b7cd Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Fri, 14 Aug 2026 15:47:43 -0300 Subject: [PATCH 11/14] chore: remove console.log from persistMessage empty-batch path --- app/lib/methods/handleMediaDownload.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/app/lib/methods/handleMediaDownload.ts b/app/lib/methods/handleMediaDownload.ts index 63f0908d846..feb668453af 100644 --- a/app/lib/methods/handleMediaDownload.ts +++ b/app/lib/methods/handleMediaDownload.ts @@ -263,11 +263,9 @@ export const persistMessage = async (messageId: string, uri: string, encryption: }) ); } - if (!batch.length) { - console.log('[handleMediaDownload] no message row for attachment, cached uri not persisted'); - return; + if (batch.length) { + await db.batch(batch); } - await db.batch(batch); }); }; From 6b5b2a2d1907fc958afd06630986e6916953def6 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Fri, 14 Aug 2026 15:49:02 -0300 Subject: [PATCH 12/14] remove unused comment --- app/containers/message/hooks/useFile.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/app/containers/message/hooks/useFile.tsx b/app/containers/message/hooks/useFile.tsx index be567cc0eb3..6dcb1438e9b 100644 --- a/app/containers/message/hooks/useFile.tsx +++ b/app/containers/message/hooks/useFile.tsx @@ -2,7 +2,6 @@ import { useState } from 'react'; import { type IAttachment } from '../../../definitions'; -// Merges local overrides (the downloaded uri) over the `file` prop, unconditionally: persistMessage no-ops without a message row. export const useFile = (file: IAttachment) => { const [overrides, setOverrides] = useState | null>(null); From 84196a4324bd17d9e4b432cdcfdbf5d7b9b1cde6 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Fri, 14 Aug 2026 16:12:10 -0300 Subject: [PATCH 13/14] fix: encode attachment urls at the player sinks instead of formatAttachmentUrl --- .../components/Attachments/Image/Image.tsx | 3 +- app/lib/methods/handleMediaDownload.test.ts | 37 ++++++++++--------- app/lib/methods/handleMediaDownload.ts | 20 ++-------- .../methods/helpers/formatAttachmentUrl.ts | 18 +++------ app/views/AttachmentView.tsx | 10 +++-- 5 files changed, 37 insertions(+), 51 deletions(-) diff --git a/app/containers/message/components/Attachments/Image/Image.tsx b/app/containers/message/components/Attachments/Image/Image.tsx index 179a23f56f7..3795e41448c 100644 --- a/app/containers/message/components/Attachments/Image/Image.tsx +++ b/app/containers/message/components/Attachments/Image/Image.tsx @@ -12,6 +12,7 @@ import { useUserPreferences } from '../../../../../lib/methods/userPreferences'; import { AUTOPLAY_GIFS_PREFERENCES_KEY } from '../../../../../lib/constants/keys'; import ImageBadge from './ImageBadge'; import log from '../../../../../lib/methods/helpers/log'; +import { encodeAttachmentUrl } from '../../../../../lib/methods/helpers/formatAttachmentUrl'; export const MessageImage = ({ uri, status, encrypted = false, imagePreview, imageType }: IMessageImage) => { const { colors } = useTheme(); @@ -69,7 +70,7 @@ export const MessageImage = ({ uri, status, encrypted = false, imagePreview, ima <> {showImage ? ( - + ) : null} {['loading', 'to-download'].includes(status) || (status === 'downloaded' && !showImage) ? ( diff --git a/app/lib/methods/handleMediaDownload.test.ts b/app/lib/methods/handleMediaDownload.test.ts index 8f350891f4d..0035df697a6 100644 --- a/app/lib/methods/handleMediaDownload.test.ts +++ b/app/lib/methods/handleMediaDownload.test.ts @@ -1,4 +1,4 @@ -import { getFilename, matchDownloadUrl, persistMessage } from './handleMediaDownload'; +import { getFilePath, getFilename, matchDownloadUrl, persistMessage } from './handleMediaDownload'; import database from '../database'; import { getMessageById } from '../database/services/Message'; import { getThreadById } from '../database/services/Thread'; @@ -33,6 +33,22 @@ jest.mock('../database/services/ThreadMessage', () => ({ getThreadMessageById: jest.fn() })); +jest.mock('../store/auxStore', () => ({ + store: { getState: () => ({ server: { server: 'https://server.com' } }) } +})); + +describe('getFilePath', () => { + it('derives the cache filename from the unencoded url', () => { + expect( + getFilePath({ + type: 'video', + mimeType: 'video/quicktime', + urlToCache: 'https://server.com/file-upload/abc/Screen Recording.mov' + }) + ).toContain('/Screen_Recording.mov'); + }); +}); + describe('matchDownloadUrl', () => { it('matches when downloadUrl contains image_url', () => { expect( @@ -68,29 +84,14 @@ describe('matchDownloadUrl', () => { ).toBeFalsy(); }); - it('matches a raw attachment url against an encoded downloadUrl', () => { + it('matches an attachment url with a space against the download url', () => { expect( matchDownloadUrl( { video_url: '/file-upload/abc/Screen Recording.mov' }, - 'https://server.com/file-upload/abc/Screen%20Recording.mov' - ) - ).toBeTruthy(); - }); - - it('matches an encoded attachment url against an encoded downloadUrl', () => { - expect( - matchDownloadUrl( - { video_url: '/file-upload/abc/Screen%20Recording.mov' }, - 'https://server.com/file-upload/abc/Screen%20Recording.mov' + 'https://server.com/file-upload/abc/Screen Recording.mov' ) ).toBeTruthy(); }); - - it('still compares when the attachment url has a malformed escape', () => { - expect( - matchDownloadUrl({ image_url: '/file-upload/abc/%ZZ.jpg' }, 'https://server.com/file-upload/abc/%ZZ.jpg') - ).toBeTruthy(); - }); }); describe('Test the getFilename', () => { diff --git a/app/lib/methods/handleMediaDownload.ts b/app/lib/methods/handleMediaDownload.ts index feb668453af..33b0dcbd2d6 100644 --- a/app/lib/methods/handleMediaDownload.ts +++ b/app/lib/methods/handleMediaDownload.ts @@ -200,22 +200,10 @@ export async function cancelDownload(messageUrl: string): Promise { } } -// decodeURIComponent throws on a malformed escape (`%ZZ`); fall back to the raw value so the comparison still runs. -const decodeUrl = (url: string) => { - try { - return decodeURIComponent(url); - } catch { - return url; - } -}; - -// `downloadUrl` has been through formatAttachmentUrl (so spaces are `%20`) while the attachment rows carry the -// server value verbatim. Compare decoded so both forms line up regardless of which one the server sent. -export const matchDownloadUrl = (att: IAttachment, downloadUrl: string) => { - const target = decodeUrl(downloadUrl); - const matches = (attachmentUrl?: string) => !!attachmentUrl && target.includes(decodeUrl(attachmentUrl)); - return matches(att.image_url) || matches(att.audio_url) || matches(att.video_url); -}; +export const matchDownloadUrl = (att: IAttachment, downloadUrl: string) => + (att.image_url && downloadUrl.includes(att.image_url)) || + (att.audio_url && downloadUrl.includes(att.audio_url)) || + (att.video_url && downloadUrl.includes(att.video_url)); const mapAttachments = ({ attachments, diff --git a/app/lib/methods/helpers/formatAttachmentUrl.ts b/app/lib/methods/helpers/formatAttachmentUrl.ts index 85726d01049..217fdd1bb42 100644 --- a/app/lib/methods/helpers/formatAttachmentUrl.ts +++ b/app/lib/methods/helpers/formatAttachmentUrl.ts @@ -27,32 +27,26 @@ export const formatAttachmentUrl = ( ): string => { const protectFiles = store.getState().settings.FileUpload_ProtectFiles; - // A data: uri is already its own encoding — running it through the parser would corrupt the payload. - if (attachmentUrl && isImageBase64(attachmentUrl)) { + if ((attachmentUrl && isImageBase64(attachmentUrl)) || attachmentUrl?.startsWith('file://')) { return attachmentUrl; } - // Cache filenames keep unicode letters (sanitizeLikeString only strips `[^\p{L}\p{Nd}]`), so `vídeo.mov` reaches - // here verbatim and the native players reject the unescaped path. Encode local uris too. - if (attachmentUrl?.startsWith('file://')) { - return encodeAttachmentUrl(attachmentUrl); - } if (attachmentUrl && attachmentUrl.startsWith('http')) { if (_originalUrl && !_originalUrl.startsWith(server)) { - return encodeAttachmentUrl(_originalUrl); + return _originalUrl; } if (attachmentUrl.includes('rc_token')) { return encodeAttachmentUrl(attachmentUrl); } - if (protectFiles) return encodeAttachmentUrl(setParamInUrl({ url: attachmentUrl, token, userId })); - return encodeAttachmentUrl(attachmentUrl); + if (protectFiles) return setParamInUrl({ url: attachmentUrl, token, userId }); + return attachmentUrl; } let cdnPrefix = store?.getState().settings.CDN_PREFIX as string; cdnPrefix = cdnPrefix?.trim(); if (cdnPrefix && cdnPrefix.startsWith('http')) { server = cdnPrefix.replace(/\/+$/, ''); } - if (protectFiles) return encodeAttachmentUrl(setParamInUrl({ url: `${server}${attachmentUrl}`, token, userId })); - return encodeAttachmentUrl(`${server}${attachmentUrl}`); + if (protectFiles) return setParamInUrl({ url: `${server}${attachmentUrl}`, token, userId }); + return `${server}${attachmentUrl}`; }; diff --git a/app/views/AttachmentView.tsx b/app/views/AttachmentView.tsx index b9a0856aa3e..b698b52048d 100644 --- a/app/views/AttachmentView.tsx +++ b/app/views/AttachmentView.tsx @@ -18,7 +18,7 @@ import I18n from '../i18n'; import { useAltTextSupported } from '../lib/hooks/useAltTextSupported'; import { useAppSelector } from '../lib/hooks/useAppSelector'; import { useAppNavigation, useAppRoute } from '../lib/hooks/navigation'; -import { formatAttachmentUrl, isAndroid, fileDownload, showErrorAlert } from '../lib/methods/helpers'; +import { encodeAttachmentUrl, formatAttachmentUrl, isAndroid, fileDownload, showErrorAlert } from '../lib/methods/helpers'; import EventEmitter from '../lib/methods/helpers/events'; import { getUserSelector } from '../selectors/login'; import { type TNavigation } from '../stacks/stackType'; @@ -59,8 +59,9 @@ const RenderContent = ({ }, [navigation]); if (attachment.image_url) { - const uri = formatAttachmentUrl(attachment.title_link || attachment.image_url, user.id, user.token, baseUrl); - const isAnimated = attachment.image_type === 'image/gif' || /\.gif(\?|$)/i.test(uri); + const url = formatAttachmentUrl(attachment.title_link || attachment.image_url, user.id, user.token, baseUrl); + const uri = encodeAttachmentUrl(url); + const isAnimated = attachment.image_type === 'image/gif' || /\.gif(\?|$)/i.test(url); return ( Date: Fri, 14 Aug 2026 16:25:18 -0300 Subject: [PATCH 14/14] fix: test --- app/views/AttachmentView.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app/views/AttachmentView.test.tsx b/app/views/AttachmentView.test.tsx index 0376332a134..04654b45ac1 100644 --- a/app/views/AttachmentView.test.tsx +++ b/app/views/AttachmentView.test.tsx @@ -96,6 +96,7 @@ jest.mock('../lib/hooks/useAppSelector', () => ({ jest.mock('../lib/methods/helpers', () => ({ formatAttachmentUrl: (url: string) => url, + encodeAttachmentUrl: (url: string) => url, isAndroid: false, fileDownload: jest.fn(), showErrorAlert: jest.fn()