Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -69,7 +70,7 @@ export const MessageImage = ({ uri, status, encrypted = false, imagePreview, ima
<>
{showImage ? (
<View style={[containerStyle, borderStyle]}>
<Image autoplay={autoplayGifs} style={imageStyle} source={{ uri: encodeURI(uri) }} contentFit='cover' />
<Image autoplay={autoplayGifs} style={imageStyle} source={{ uri: encodeAttachmentUrl(uri) }} contentFit='cover' />
</View>
) : null}
{['loading', 'to-download'].includes(status) || (status === 'downloaded' && !showImage) ? (
Expand Down
101 changes: 21 additions & 80 deletions app/containers/message/hooks/__tests__/useFile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
31 changes: 7 additions & 24 deletions app/containers/message/hooks/useFile.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,13 @@
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) => {
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]);
export const useFile = (file: IAttachment) => {
Comment thread
OtavioStasiak marked this conversation as resolved.
const [overrides, setOverrides] = useState<Partial<IAttachment> | null>(null);

const manageForwardedFile = (f: Partial<IAttachment>) => {
if (isMessagePersisted) {
return;
}
setLocalFile(prev => ({ ...prev, ...f }));
const mergeFile = (f: Partial<IAttachment>) => {
setOverrides(prev => ({ ...prev, ...f }));
};
return [isMessagePersisted ? file : localFile, manageForwardedFile] as const;

return [overrides ? { ...file, ...overrides } : file, mergeFile] as const;
};
2 changes: 1 addition & 1 deletion app/containers/message/hooks/useMediaAutoDownload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,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);
Comment thread
OtavioStasiak marked this conversation as resolved.
const originalUrl = getOriginalURL(file);
const url = formatAttachmentUrl(
file.title_link || getFileProperty(currentFile, fileType, 'url'),
Expand Down
27 changes: 26 additions & 1 deletion app/lib/methods/handleMediaDownload.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -67,6 +83,15 @@ describe('matchDownloadUrl', () => {
matchDownloadUrl({ image_url: '/file-upload/abc/photo.jpg' }, 'https://server.com/file-upload/abc/audio.mp3')
).toBeFalsy();
});

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 Recording.mov'
)
).toBeTruthy();
});
});

describe('Test the getFilename', () => {
Expand Down
61 changes: 61 additions & 0 deletions app/lib/methods/helpers/__tests__/formatAttachmentUrl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
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('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'
);
});

// 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');
});

// 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'
);
});
});
10 changes: 9 additions & 1 deletion app/lib/methods/helpers/formatAttachmentUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ function setParamInUrl({ url, token, userId }: { url: string; token: string; use
return urlObj.toString();
}

export const encodeAttachmentUrl = (url: string): string => {
Comment thread
OtavioStasiak marked this conversation as resolved.
try {
return new URL(url).toString();
} catch {
return url;
}
};

export const formatAttachmentUrl = (
attachmentUrl: string | undefined,
userId: string,
Expand All @@ -28,7 +36,7 @@ export const formatAttachmentUrl = (
}

if (attachmentUrl.includes('rc_token')) {
return encodeURI(attachmentUrl);
return encodeAttachmentUrl(attachmentUrl);
}

if (protectFiles) return setParamInUrl({ url: attachmentUrl, token, userId });
Expand Down
1 change: 1 addition & 0 deletions app/views/AttachmentView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
6 changes: 3 additions & 3 deletions app/views/AttachmentView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -60,7 +60,7 @@ const RenderContent = ({

if (attachment.image_url) {
const url = formatAttachmentUrl(attachment.title_link || attachment.image_url, user.id, user.token, baseUrl);
const uri = encodeURI(url);
const uri = encodeAttachmentUrl(url);
const isAnimated = attachment.image_type === 'image/gif' || /\.gif(\?|$)/i.test(url);
return (
<ImageViewer
Expand All @@ -75,7 +75,7 @@ const RenderContent = ({
}
if (attachment.video_url) {
const url = formatAttachmentUrl(attachment.title_link || attachment.video_url, user.id, user.token, baseUrl);
const uri = encodeURI(url);
const uri = encodeAttachmentUrl(url);
return (
<Video
source={{ uri }}
Expand Down
Loading