Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
77b5b46
add encrypted pdf file blob codification
nazabucciarelli May 13, 2026
339c708
add changeset
nazabucciarelli May 14, 2026
ab04cdf
add use of abort controller
nazabucciarelli May 15, 2026
78225b7
add fetch failure handling
nazabucciarelli May 15, 2026
98015cb
add new setting
nazabucciarelli May 18, 2026
4b6851c
use bytes rather than megabytes for setting
nazabucciarelli May 19, 2026
a082845
address abortController race condition
nazabucciarelli May 19, 2026
45fb0fe
update changeset
nazabucciarelli May 19, 2026
6375d39
fix grammar in translation
nazabucciarelli May 19, 2026
7a51828
extract encrypted pdf opening logic to hook
nazabucciarelli May 19, 2026
68f5ae0
remove workspace setting to use desktop setting
nazabucciarelli May 19, 2026
a7323ab
convert setting value in bytes
nazabucciarelli May 19, 2026
ca906d3
add missing property signatura to IRocketChatDesktop interface
nazabucciarelli May 20, 2026
bff5af9
Merge branch 'develop' into fix/pdf-viewer-encrypted-room
nazabucciarelli May 20, 2026
f27c4cd
Merge branch 'develop' into fix/pdf-viewer-encrypted-room
nazabucciarelli Jun 2, 2026
63f6063
handle undefined size case
nazabucciarelli Jun 5, 2026
8c7c4c2
add useOpenEncryptedPdf test suite
nazabucciarelli Jun 5, 2026
6c4fdd7
remove comment
nazabucciarelli Jun 8, 2026
4500720
add error handling in GenericFileAttachment
nazabucciarelli Jun 8, 2026
4f63e66
add i18n for error
nazabucciarelli Jun 8, 2026
b3504b4
fix test
nazabucciarelli Jun 8, 2026
aadda13
lint
nazabucciarelli Jun 9, 2026
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 @@ -6,7 +6,7 @@ import {
MessageGenericPreviewTitle,
MessageGenericPreviewDescription,
} from '@rocket.chat/fuselage';
import { useMediaUrl } from '@rocket.chat/ui-contexts';
import { useMediaUrl, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useId } from 'react';
import type { UIEvent } from 'react';
import { useTranslation } from 'react-i18next';
Expand All @@ -15,6 +15,7 @@ import { getFileExtension } from '../../../../../../lib/utils/getFileExtension';
import { forAttachmentDownload, registerDownloadForUid } from '../../../../../hooks/useDownloadFromServiceWorker';
import MessageCollapsible from '../../../MessageCollapsible';
import AttachmentSize from '../structure/AttachmentSize';
import { useOpenEncryptedPdf } from './hooks/useOpenEncryptedPdf';

const openDocumentViewer = window.RocketChatDesktop?.openDocumentViewer;

Expand All @@ -31,26 +32,38 @@ const GenericFileAttachment = ({
const getURL = useMediaUrl();
const uid = useId();
const { t } = useTranslation();
const openEncryptedPdf = useOpenEncryptedPdf();
const dispatchToastMessage = useToastMessageDispatch();

const handleTitleClick = (event: UIEvent): void => {
const handleTitleClick = async (event: UIEvent): Promise<void> => {
if (!link) {
return;
}

if (openDocumentViewer && format === 'PDF') {
event.preventDefault();
const isEncrypted = link.includes('/file-decrypt/');

const url = new URL(getURL(link), window.location.origin);
url.searchParams.set('contentDisposition', 'inline');
openDocumentViewer(url.toString(), format, '');
return;
}
try {
if (format === 'PDF' && openDocumentViewer) {
event.preventDefault();

if (isEncrypted) {
await openEncryptedPdf(link, title, size, format, openDocumentViewer);
return;
}

if (link.includes('/file-decrypt/')) {
event.preventDefault();
const url = new URL(getURL(link), window.location.origin);
url.searchParams.set('contentDisposition', 'inline');
openDocumentViewer(url.toString(), format, '');
return;
}

registerDownloadForUid(uid, t, title);
forAttachmentDownload(uid, link);
if (isEncrypted) {
event.preventDefault();
registerDownloadForUid(uid, t, title);
forAttachmentDownload(uid, link);
}
} catch (error) {
dispatchToastMessage({ type: 'error', message: t('FileUpload_Error_Trying_To_Open_File') });
}
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,322 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { renderHook, act, waitFor } from '@testing-library/react';

import { useOpenEncryptedPdf } from './useOpenEncryptedPdf';
import { forAttachmentDownload, registerDownloadForUid } from '../../../../../../hooks/useDownloadFromServiceWorker';

jest.mock('../../../../../../hooks/useDownloadFromServiceWorker', () => ({
forAttachmentDownload: jest.fn(),
registerDownloadForUid: jest.fn(),
}));

jest.mock('@rocket.chat/ui-contexts', () => ({
...jest.requireActual('@rocket.chat/ui-contexts'),
useMediaUrl: () => (url: string) => url,
}));

const mockForAttachmentDownload = forAttachmentDownload as jest.MockedFunction<typeof forAttachmentDownload>;
const mockRegisterDownloadForUid = registerDownloadForUid as jest.MockedFunction<typeof registerDownloadForUid>;

const mockAbort = jest.fn();
const mockAbortController = jest.fn(() => {
const signal = { aborted: false };
return {
abort: jest.fn(() => {
mockAbort();
signal.aborted = true;
}),
signal,
};
});

describe('useOpenEncryptedPdf', () => {
const testBlob = new Blob(['content'], { type: 'application/pdf' });
const title = 'My PDF';
const link = '/file-decrypt/encrypted-pdf.pdf';
const format = 'PDF';
const allowedSize = 5 * 1024 * 1024;

let mockOpenDocumentViewer: jest.Mock;
let mockFetch: jest.Mock;
let mockRevokeObjectURL: jest.Mock;
let mockCreateObjectURL: jest.Mock;

const originalFetch = global.fetch;
const originalCreateObjectURL = global.URL.createObjectURL;
const originalRevokeObjectURL = global.URL.revokeObjectURL;
const originalAbortController = global.AbortController;

beforeEach(() => {
jest.clearAllMocks();

window.RocketChatDesktop = {
getE2ePdfPreviewSizeLimit: jest.fn(() => 15),
} as any;

mockOpenDocumentViewer = jest.fn();

// Mock fetch
mockFetch = jest.fn();
global.fetch = mockFetch;

// Mock URL methods
mockCreateObjectURL = jest.fn(() => `blob:mock-url-${Math.random()}`);
mockRevokeObjectURL = jest.fn();
global.URL.createObjectURL = mockCreateObjectURL;
global.URL.revokeObjectURL = mockRevokeObjectURL;

// Mock AbortController
global.AbortController = mockAbortController as any;
});

afterEach(() => {
jest.restoreAllMocks();
global.fetch = originalFetch;
global.URL.createObjectURL = originalCreateObjectURL;
global.URL.revokeObjectURL = originalRevokeObjectURL;
global.AbortController = originalAbortController;
delete (window as any).RocketChatDesktop;
});

describe('file size is not within the limit', () => {
it('should download file if it exceeds the preview size limit', async () => {
const { result } = renderHook(() => useOpenEncryptedPdf(), {
wrapper: mockAppRoot().build(),
});

const exceededSize = 20 * 1024 * 1024; // 20 MB (exceeds 15 MB limit)

await act(async () => {
await result.current(link, title, exceededSize, format, mockOpenDocumentViewer);
});

expect(mockRegisterDownloadForUid).toHaveBeenCalled();
expect(mockForAttachmentDownload).toHaveBeenCalledWith(expect.any(String), link);
expect(mockOpenDocumentViewer).not.toHaveBeenCalled();
});

it('should download file if size is undefined', async () => {
const { result } = renderHook(() => useOpenEncryptedPdf(), {
wrapper: mockAppRoot().build(),
});

await act(async () => {
await result.current(link, title, undefined, format, mockOpenDocumentViewer);
});

expect(mockRegisterDownloadForUid).toHaveBeenCalled();
expect(mockForAttachmentDownload).toHaveBeenCalledWith(expect.any(String), link);
expect(mockOpenDocumentViewer).not.toHaveBeenCalled();
});
});

describe('file size is within the limit', () => {
it('should fetch and open PDF', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
blob: jest.fn().mockResolvedValueOnce(testBlob),
});

const { result } = renderHook(() => useOpenEncryptedPdf(), {
wrapper: mockAppRoot().build(),
});

await act(async () => {
await result.current(link, title, allowedSize, format, mockOpenDocumentViewer);
});

await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith(link, expect.objectContaining({ signal: expect.any(Object) }));
expect(mockCreateObjectURL).toHaveBeenCalledWith(testBlob);
expect(mockOpenDocumentViewer).toHaveBeenCalledWith(expect.stringContaining('blob:'), format, title);
});
});

it('should open the PDF viewer if title is undefined, falling back to empty string', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
blob: jest.fn().mockResolvedValueOnce(testBlob),
});

const { result } = renderHook(() => useOpenEncryptedPdf(), {
wrapper: mockAppRoot().build(),
});

await act(async () => {
await result.current(link, undefined, allowedSize, format, mockOpenDocumentViewer);
});

await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith(link, expect.objectContaining({ signal: expect.any(Object) }));
expect(mockCreateObjectURL).toHaveBeenCalledWith(testBlob);
expect(mockOpenDocumentViewer).toHaveBeenCalledWith(expect.stringContaining('blob:'), format, '');
});
});
});

describe('when RocketChatDesktop is undefined', () => {
beforeEach(() => {
delete (window as any).RocketChatDesktop;
});

it('should fall back to 10MB limit and download if file exceeds it', async () => {
const { result } = renderHook(() => useOpenEncryptedPdf(), {
wrapper: mockAppRoot().build(),
});

const exceededSize = 11 * 1024 * 1024; // 11 MB

await act(async () => {
await result.current(link, title, exceededSize, format, mockOpenDocumentViewer);
});

expect(mockRegisterDownloadForUid).toHaveBeenCalled();
expect(mockForAttachmentDownload).toHaveBeenCalledWith(expect.any(String), link);
expect(mockOpenDocumentViewer).not.toHaveBeenCalled();
});

it('should fall back to 10MB limit and open PDF if size is within limit', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
blob: jest.fn().mockResolvedValueOnce(testBlob),
});

const { result } = renderHook(() => useOpenEncryptedPdf(), {
wrapper: mockAppRoot().build(),
});

await act(async () => {
await result.current(link, title, allowedSize, format, mockOpenDocumentViewer);
});

await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith(link, expect.objectContaining({ signal: expect.any(Object) }));
expect(mockCreateObjectURL).toHaveBeenCalledWith(testBlob);
expect(mockOpenDocumentViewer).toHaveBeenCalledWith(expect.stringContaining('blob:'), format, title);
});
});
});

describe('blob URL management', () => {
it('should revoke previous blob URL before creating a new one', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
blob: jest.fn().mockResolvedValueOnce(testBlob),
})
.mockResolvedValueOnce({
ok: true,
blob: jest.fn().mockResolvedValueOnce(testBlob),
});

const { result } = renderHook(() => useOpenEncryptedPdf(), {
wrapper: mockAppRoot().build(),
});

// First call
await act(async () => {
await result.current(link, title, allowedSize, format, mockOpenDocumentViewer);
});

await waitFor(() => {
expect(mockCreateObjectURL).toHaveBeenCalledTimes(1);
});

// Second call
await act(async () => {
await result.current(link, title, allowedSize, format, mockOpenDocumentViewer);
});

await waitFor(() => {
expect(mockRevokeObjectURL).toHaveBeenCalled();
expect(mockCreateObjectURL).toHaveBeenCalledTimes(2);
});
});

it('should revoke blob URL on component unmount', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
blob: jest.fn().mockResolvedValueOnce(testBlob),
});

const { result, unmount } = renderHook(() => useOpenEncryptedPdf(), {
wrapper: mockAppRoot().build(),
});

await act(async () => {
await result.current(link, title, allowedSize, format, mockOpenDocumentViewer);
});

await waitFor(() => {
expect(mockCreateObjectURL).toHaveBeenCalled();
});

unmount();

expect(mockRevokeObjectURL).toHaveBeenCalled();
});
});

describe('fetch failure handling', () => {
let consoleErrorSpy: jest.SpyInstance;

beforeEach(() => {
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
});

afterEach(() => {
consoleErrorSpy.mockRestore();
});

it('should throw error if fetch response is not ok', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
});

const { result } = renderHook(() => useOpenEncryptedPdf(), {
wrapper: mockAppRoot().build(),
});

await expect(async () => {
await result.current(link, title, allowedSize, format, mockOpenDocumentViewer);
}).rejects.toThrow('Failed to fetch encrypted PDF: 404');

await waitFor(() => {
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error opening preview of encrypted PDF',
expect.objectContaining({ message: expect.stringContaining('Failed to fetch encrypted PDF: 404') }),
);
});
});
});

describe('concurrent requests', () => {
it('should ignore blob from cancelled request', async () => {
let resolveFirstFetch: any;

mockFetch
.mockImplementationOnce(() => {
return new Promise((resolve) => {
resolveFirstFetch = resolve;
});
})
.mockImplementationOnce(() => {
return Promise.resolve({ ok: true, blob: jest.fn().mockResolvedValueOnce(testBlob) });
});

const { result } = renderHook(() => useOpenEncryptedPdf(), { wrapper: mockAppRoot().build() });

const promise1 = result.current(link, title, allowedSize, format, mockOpenDocumentViewer);
const promise2 = result.current(link, title, allowedSize, format, mockOpenDocumentViewer);

await act(async () => {
resolveFirstFetch({ ok: true, blob: jest.fn().mockResolvedValue(testBlob) });
await Promise.all([promise1, promise2]);
});

expect(mockOpenDocumentViewer).toHaveBeenCalledTimes(1);
expect(mockAbort).toHaveBeenCalledTimes(1);
});
});
});
Loading
Loading