snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
import type { AudioAttachmentProps } from '@rocket.chat/core-typings';
|
||||
import { AudioPlayerControls, Box } from '@rocket.chat/fuselage';
|
||||
import { useMediaUrl } from '@rocket.chat/ui-contexts';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { useMediaPlayer } from '../../../../../providers/MediaPlayerProvider';
|
||||
import type { PersistentAudioTrack } from '../../../../../providers/MediaPlayerProvider';
|
||||
import MarkdownText from '../../../../MarkdownText';
|
||||
import MessageCollapsible from '../../../MessageCollapsible';
|
||||
import MessageContentBody from '../../../MessageContentBody';
|
||||
|
||||
/** Extra context about the message that owns this audio, used by the shared player. */
|
||||
export type AudioAttachmentSource = {
|
||||
rid?: string;
|
||||
mid?: string;
|
||||
username?: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
type AudioAttachmentComponentProps = AudioAttachmentProps & {
|
||||
source?: AudioAttachmentSource;
|
||||
};
|
||||
|
||||
const AudioAttachment = ({
|
||||
title,
|
||||
audio_url: url,
|
||||
audio_type: type,
|
||||
audio_size: size,
|
||||
description,
|
||||
descriptionMd,
|
||||
title_link: link,
|
||||
title_link_download: hasDownload,
|
||||
collapsed,
|
||||
source,
|
||||
}: AudioAttachmentComponentProps) => {
|
||||
const getURL = useMediaUrl();
|
||||
const src = useMemo(() => getURL(url), [getURL, url]);
|
||||
|
||||
const { play, toggle, seek, cyclePlaybackRate, isActive, playing, currentTime, duration, playbackRate } = useMediaPlayer();
|
||||
|
||||
const track = useMemo<PersistentAudioTrack>(
|
||||
() => ({
|
||||
id: `${source?.mid ?? ''}:${url}`,
|
||||
url: src,
|
||||
mediaType: type,
|
||||
title: title || url,
|
||||
size,
|
||||
rid: source?.rid,
|
||||
mid: source?.mid,
|
||||
username: source?.username,
|
||||
name: source?.name,
|
||||
}),
|
||||
[source?.mid, source?.rid, source?.username, source?.name, url, src, type, title, size],
|
||||
);
|
||||
|
||||
const active = isActive(track.id);
|
||||
const [previewDuration, setPreviewDuration] = useState(0);
|
||||
|
||||
return (
|
||||
<>
|
||||
{descriptionMd ? <MessageContentBody md={descriptionMd} /> : <MarkdownText parseEmoji content={description} />}
|
||||
<MessageCollapsible title={title} hasDownload={hasDownload} link={getURL(link || url)} size={size} isCollapsed={collapsed}>
|
||||
<Box
|
||||
borderWidth='default'
|
||||
borderStyle='solid'
|
||||
borderColor='extra-light'
|
||||
backgroundColor='tint'
|
||||
paddingBlock={12}
|
||||
paddingInlineEnd={8}
|
||||
paddingInlineStart={16}
|
||||
borderRadius='x4'
|
||||
width='100%'
|
||||
maxWidth='x300'
|
||||
>
|
||||
<AudioPlayerControls
|
||||
isPlaying={active && playing}
|
||||
currentTime={active ? currentTime : 0}
|
||||
durationTime={active && duration ? duration : previewDuration}
|
||||
playbackSpeed={playbackRate}
|
||||
onTogglePlay={() => (active ? toggle() : play(track))}
|
||||
onSeek={(time) => (active ? seek(time) : play(track))}
|
||||
onChangePlaybackSpeed={cyclePlaybackRate}
|
||||
/>
|
||||
{/* Loads only metadata so the controls show the length before this track becomes the shared player's active one. */}
|
||||
<audio
|
||||
hidden
|
||||
preload='metadata'
|
||||
src={src}
|
||||
onLoadedMetadata={(e) => setPreviewDuration(Number.isFinite(e.currentTarget.duration) ? e.currentTarget.duration : 0)}
|
||||
>
|
||||
<track kind='captions' />
|
||||
</audio>
|
||||
</Box>
|
||||
</MessageCollapsible>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioAttachment;
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { MessageAttachmentBase } from '@rocket.chat/core-typings';
|
||||
import {
|
||||
MessageGenericPreview,
|
||||
MessageGenericPreviewContent,
|
||||
MessageGenericPreviewIcon,
|
||||
MessageGenericPreviewTitle,
|
||||
MessageGenericPreviewDescription,
|
||||
} from '@rocket.chat/fuselage';
|
||||
import { useMediaUrl, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
|
||||
import { useId } from 'react';
|
||||
import type { UIEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useOpenEncryptedPdf } from './hooks/useOpenEncryptedPdf';
|
||||
import { getFileExtension } from '../../../../../../lib/utils/getFileExtension';
|
||||
import { forAttachmentDownload, registerDownloadForUid } from '../../../../../hooks/useDownloadFromServiceWorker';
|
||||
import MarkdownText from '../../../../MarkdownText';
|
||||
import MessageCollapsible from '../../../MessageCollapsible';
|
||||
import MessageContentBody from '../../../MessageContentBody';
|
||||
import AttachmentSize from '../structure/AttachmentSize';
|
||||
|
||||
const openDocumentViewer = window.RocketChatDesktop?.openDocumentViewer;
|
||||
|
||||
export type GenericFileAttachmentProps = MessageAttachmentBase;
|
||||
|
||||
const GenericFileAttachment = ({
|
||||
title,
|
||||
description,
|
||||
descriptionMd,
|
||||
title_link: link,
|
||||
title_link_download: hasDownload,
|
||||
size,
|
||||
format,
|
||||
collapsed,
|
||||
}: GenericFileAttachmentProps) => {
|
||||
const getURL = useMediaUrl();
|
||||
const uid = useId();
|
||||
const { t } = useTranslation();
|
||||
const openEncryptedPdf = useOpenEncryptedPdf();
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
|
||||
const handleTitleClick = async (event: UIEvent): Promise<void> => {
|
||||
if (!link) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isEncrypted = link.includes('/file-decrypt/');
|
||||
|
||||
try {
|
||||
if (format === 'PDF' && openDocumentViewer) {
|
||||
event.preventDefault();
|
||||
|
||||
if (isEncrypted) {
|
||||
await openEncryptedPdf(link, title, size, format, openDocumentViewer);
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(getURL(link), window.location.origin);
|
||||
url.searchParams.set('contentDisposition', 'inline');
|
||||
openDocumentViewer(url.toString(), format, '');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEncrypted) {
|
||||
event.preventDefault();
|
||||
registerDownloadForUid(uid, t, title);
|
||||
forAttachmentDownload(uid, link);
|
||||
}
|
||||
} catch (error) {
|
||||
dispatchToastMessage({ type: 'error', message: t('FileUpload_Error_Trying_To_Open_File') });
|
||||
}
|
||||
};
|
||||
|
||||
const getExternalUrl = () => {
|
||||
if (!hasDownload || !link) return undefined;
|
||||
|
||||
if (openDocumentViewer) {
|
||||
const url = new URL(getURL(link), window.location.origin);
|
||||
url.searchParams.set('download', '');
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
return getURL(link);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{descriptionMd ? <MessageContentBody md={descriptionMd} /> : <MarkdownText parseEmoji content={description} />}
|
||||
<MessageCollapsible title={title} hasDownload={hasDownload} link={link} isCollapsed={collapsed}>
|
||||
<MessageGenericPreview style={{ maxWidth: 368, width: '100%' }}>
|
||||
<MessageGenericPreviewContent
|
||||
thumb={<MessageGenericPreviewIcon name='attachment-file' type={format || getFileExtension(title)} />}
|
||||
>
|
||||
<MessageGenericPreviewTitle download={!!openDocumentViewer} externalUrl={getExternalUrl()} onClick={handleTitleClick}>
|
||||
{title}
|
||||
</MessageGenericPreviewTitle>
|
||||
{size && (
|
||||
<MessageGenericPreviewDescription>
|
||||
<AttachmentSize size={size} wrapper={false} />
|
||||
</MessageGenericPreviewDescription>
|
||||
)}
|
||||
</MessageGenericPreviewContent>
|
||||
</MessageGenericPreview>
|
||||
</MessageCollapsible>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GenericFileAttachment;
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { ImageAttachmentProps } from '@rocket.chat/core-typings';
|
||||
import { useMediaUrl } from '@rocket.chat/ui-contexts';
|
||||
|
||||
import { useLoadImage } from './hooks/useLoadImage';
|
||||
import MarkdownText from '../../../../MarkdownText';
|
||||
import MessageCollapsible from '../../../MessageCollapsible';
|
||||
import MessageContentBody from '../../../MessageContentBody';
|
||||
import AttachmentImage from '../structure/AttachmentImage';
|
||||
|
||||
const ImageAttachment = ({
|
||||
id,
|
||||
title,
|
||||
image_url: url,
|
||||
image_preview: imagePreview,
|
||||
image_size: size,
|
||||
image_dimensions: imageDimensions = {
|
||||
width: 368,
|
||||
height: 368,
|
||||
},
|
||||
image_alt: altText,
|
||||
description,
|
||||
descriptionMd,
|
||||
title_link: link,
|
||||
title_link_download: hasDownload,
|
||||
collapsed,
|
||||
}: ImageAttachmentProps) => {
|
||||
const [loadImage, setLoadImage] = useLoadImage();
|
||||
const getURL = useMediaUrl();
|
||||
|
||||
return (
|
||||
<>
|
||||
{descriptionMd ? <MessageContentBody md={descriptionMd} /> : <MarkdownText parseEmoji content={description} />}
|
||||
<MessageCollapsible title={title} hasDownload={hasDownload} link={getURL(link || url)} size={size} isCollapsed={collapsed}>
|
||||
<AttachmentImage
|
||||
{...imageDimensions}
|
||||
loadImage={loadImage}
|
||||
setLoadImage={setLoadImage}
|
||||
dataSrc={getURL(link || url)}
|
||||
src={getURL(url)}
|
||||
previewUrl={`data:image/png;base64,${imagePreview}`}
|
||||
id={id}
|
||||
alt={altText}
|
||||
/>
|
||||
</MessageCollapsible>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageAttachment;
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { VideoAttachmentProps } from '@rocket.chat/core-typings';
|
||||
import { Box, MessageGenericPreview } from '@rocket.chat/fuselage';
|
||||
import { useMediaUrl } from '@rocket.chat/ui-contexts';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useReloadOnError } from './hooks/useReloadOnError';
|
||||
import { userAgentMIMETypeFallback } from '../../../../../lib/utils/userAgentMIMETypeFallback';
|
||||
import MarkdownText from '../../../../MarkdownText';
|
||||
import MessageCollapsible from '../../../MessageCollapsible';
|
||||
import MessageContentBody from '../../../MessageContentBody';
|
||||
|
||||
const VideoAttachment = ({
|
||||
title,
|
||||
video_url: url,
|
||||
video_type: type,
|
||||
video_size: size,
|
||||
description,
|
||||
descriptionMd,
|
||||
title_link: link,
|
||||
title_link_download: hasDownload,
|
||||
collapsed,
|
||||
}: VideoAttachmentProps) => {
|
||||
const getURL = useMediaUrl();
|
||||
const src = useMemo(() => getURL(url), [getURL, url]);
|
||||
const { mediaRef } = useReloadOnError(src, 'video');
|
||||
|
||||
return (
|
||||
<>
|
||||
{descriptionMd ? <MessageContentBody md={descriptionMd} /> : <MarkdownText parseEmoji content={description} />}
|
||||
<MessageCollapsible title={title} hasDownload={hasDownload} link={getURL(link || url)} size={size} isCollapsed={collapsed}>
|
||||
<MessageGenericPreview style={{ maxWidth: 368, width: '100%' }}>
|
||||
<Box is='video' controls preload='metadata' ref={mediaRef} position='relative'>
|
||||
<source src={getURL(url)} type={userAgentMIMETypeFallback(type)} />
|
||||
</Box>
|
||||
</MessageGenericPreview>
|
||||
</MessageCollapsible>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoAttachment;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { useAttachmentAutoLoadEmbedMedia } from '@rocket.chat/ui-contexts';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
export const useLoadImage = (): [boolean, () => void] => {
|
||||
const [loadImage, setLoadImage] = useState(useAttachmentAutoLoadEmbedMedia());
|
||||
return [loadImage, useCallback(() => setLoadImage(true), [])];
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useMediaUrl } from '@rocket.chat/ui-contexts';
|
||||
import { useId, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { forAttachmentDownload, registerDownloadForUid } from '../../../../../../hooks/useDownloadFromServiceWorker';
|
||||
|
||||
export const useOpenEncryptedPdf = () => {
|
||||
const getURL = useMediaUrl();
|
||||
const pdfPreviewSizeLimit = window.RocketChatDesktop?.getE2ePdfPreviewSizeLimit?.() ?? 10;
|
||||
const pdfPreviewSizeLimitInBytes = pdfPreviewSizeLimit * 1024 * 1024;
|
||||
const uid = useId();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const blobUrlRef = useRef<string | undefined>(undefined);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
if (blobUrlRef.current) {
|
||||
URL.revokeObjectURL(blobUrlRef.current);
|
||||
blobUrlRef.current = undefined;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const openEncryptedPdf = async (
|
||||
link: string,
|
||||
title: string | undefined,
|
||||
size: number | undefined,
|
||||
format: string,
|
||||
openDocumentViewer: (url: string, format: string, options: any) => void,
|
||||
) => {
|
||||
if (size === undefined || size > pdfPreviewSizeLimitInBytes) {
|
||||
registerDownloadForUid(uid, t, title);
|
||||
forAttachmentDownload(uid, link);
|
||||
return;
|
||||
}
|
||||
|
||||
if (blobUrlRef.current) {
|
||||
URL.revokeObjectURL(blobUrlRef.current);
|
||||
blobUrlRef.current = undefined;
|
||||
}
|
||||
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
try {
|
||||
const response = await fetch(getURL(link), {
|
||||
signal: abortController.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch encrypted PDF: ${response.status}`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
if (abortController.signal.aborted || abortControllerRef.current !== abortController) {
|
||||
return;
|
||||
}
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
blobUrlRef.current = blobUrl;
|
||||
openDocumentViewer(blobUrl, format, title ?? '');
|
||||
} catch (error: any) {
|
||||
if (error.name !== 'AbortError') {
|
||||
console.error('Error opening preview of encrypted PDF', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return openEncryptedPdf;
|
||||
};
|
||||
@@ -0,0 +1,235 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
|
||||
import { useReloadOnError } from './useReloadOnError';
|
||||
import { FakeResponse } from '../../../../../../../tests/mocks/utils/FakeResponse';
|
||||
|
||||
interface ITestMediaElement extends HTMLAudioElement {
|
||||
_emit: (type: string) => void;
|
||||
}
|
||||
|
||||
function makeMediaEl(): ITestMediaElement {
|
||||
const el = document.createElement('audio') as ITestMediaElement;
|
||||
(el as any).play = jest.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(el, 'paused', { value: false, configurable: true });
|
||||
el._emit = (type: string) => el.dispatchEvent(new Event(type));
|
||||
return el;
|
||||
}
|
||||
|
||||
describe('useReloadOnError', () => {
|
||||
const OLD_FETCH = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.spyOn(console, 'debug').mockImplementation(() => null);
|
||||
jest.spyOn(console, 'warn').mockImplementation(() => null);
|
||||
jest.spyOn(console, 'error').mockImplementation(() => null);
|
||||
|
||||
// default mock: fresh redirect URL + ISO expiry 60s ahead
|
||||
global.fetch = jest.fn().mockResolvedValue(
|
||||
new FakeResponse(
|
||||
JSON.stringify({
|
||||
redirectUrl: '/sampleurl?token=xyz',
|
||||
expires: new Date(Date.now() + 60_000).toISOString(),
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
) as any;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
jest.useRealTimers();
|
||||
global.fetch = OLD_FETCH as any;
|
||||
jest.restoreAllMocks();
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('refreshes media src on error and preserves playback position', async () => {
|
||||
const original = '/sampleurl?token=abc';
|
||||
const { result } = renderHook(() => useReloadOnError(original, 'audio'));
|
||||
|
||||
const media = makeMediaEl();
|
||||
media.currentTime = 12;
|
||||
|
||||
act(() => {
|
||||
result.current.mediaRef(media);
|
||||
});
|
||||
|
||||
const loadSpy = jest.spyOn(media, 'load');
|
||||
|
||||
await act(async () => {
|
||||
media._emit('error');
|
||||
});
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(media.src).toContain('/sampleurl?token=xyz');
|
||||
|
||||
await act(async () => {
|
||||
media._emit('loadedmetadata');
|
||||
media._emit('canplay');
|
||||
});
|
||||
|
||||
expect(loadSpy).toHaveBeenCalled();
|
||||
expect(media.currentTime).toBe(12);
|
||||
expect((media as any).play).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refreshes media src on stalled and preserves playback position', async () => {
|
||||
const original = '/sampleurl?token=abc';
|
||||
const { result } = renderHook(() => useReloadOnError(original, 'audio'));
|
||||
|
||||
const media = makeMediaEl();
|
||||
media.currentTime = 12;
|
||||
|
||||
act(() => {
|
||||
result.current.mediaRef(media);
|
||||
});
|
||||
|
||||
const loadSpy = jest.spyOn(media, 'load');
|
||||
|
||||
await act(async () => {
|
||||
media._emit('stalled');
|
||||
});
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(media.src).toContain('/sampleurl?token=xyz');
|
||||
|
||||
await act(async () => {
|
||||
media._emit('loadedmetadata');
|
||||
media._emit('canplay');
|
||||
});
|
||||
|
||||
expect(loadSpy).toHaveBeenCalled();
|
||||
expect(media.currentTime).toBe(12);
|
||||
expect((media as any).play).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when URL is not expired (second event before expiry)', async () => {
|
||||
// Pin system time so Date.now() is deterministic under fake timers
|
||||
const fixed = new Date('2030-01-01T00:00:00.000Z');
|
||||
jest.setSystemTime(fixed);
|
||||
|
||||
// Backend replies with expiry 60s in the future (relative to pinned time)
|
||||
global.fetch = jest.fn().mockResolvedValue(
|
||||
new FakeResponse(
|
||||
JSON.stringify({
|
||||
redirectUrl: '/new?x=1',
|
||||
expires: new Date(fixed.getTime() + 60_000).toISOString(),
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
) as any;
|
||||
|
||||
const { result } = renderHook(() => useReloadOnError('/sampleurl?token=abc', 'audio'));
|
||||
const media = makeMediaEl();
|
||||
|
||||
act(() => {
|
||||
result.current.mediaRef(media);
|
||||
});
|
||||
|
||||
// First event → fetch + set expires
|
||||
await act(async () => {
|
||||
media._emit('stalled');
|
||||
});
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second event before expiry → early return, no new fetch
|
||||
await act(async () => {
|
||||
media._emit('stalled');
|
||||
});
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('recovers on stalled after expiry and restores seek position', async () => {
|
||||
// Pin time
|
||||
const fixed = new Date('2030-01-01T00:00:00.000Z');
|
||||
jest.setSystemTime(fixed);
|
||||
|
||||
// 1st fetch (first recovery) -> expires in 5s
|
||||
const firstReply = new FakeResponse(
|
||||
JSON.stringify({
|
||||
redirectUrl: '/fresh?token=first',
|
||||
expires: new Date(fixed.getTime() + 5_000).toISOString(),
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
|
||||
// 2nd fetch (after expiry) -> new url, further expiry
|
||||
const secondReply = new FakeResponse(
|
||||
JSON.stringify({
|
||||
redirectUrl: '/fresh?token=second',
|
||||
expires: new Date(fixed.getTime() + 65_000).toISOString(),
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
|
||||
// Mock fetch to return first, then second
|
||||
(global.fetch as unknown as jest.Mock) = jest.fn().mockResolvedValueOnce(firstReply).mockResolvedValueOnce(secondReply);
|
||||
|
||||
const { result } = renderHook(() => useReloadOnError('/sampleurl?token=old', 'audio'));
|
||||
const media = makeMediaEl();
|
||||
act(() => {
|
||||
result.current.mediaRef(media);
|
||||
});
|
||||
|
||||
// Initial recovery to set expiresAt (simulate an error)
|
||||
await act(async () => {
|
||||
media._emit('error');
|
||||
});
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(media.src).toContain('/fresh?token=first');
|
||||
|
||||
// Complete the ready cycle
|
||||
await act(async () => {
|
||||
media._emit('loadedmetadata');
|
||||
media._emit('canplay');
|
||||
});
|
||||
|
||||
// Fast-forward time beyond expiry
|
||||
jest.setSystemTime(new Date(fixed.getTime() + 6_000));
|
||||
|
||||
// User scrubs to a new position just before stall is detected
|
||||
media.currentTime = 42;
|
||||
|
||||
const loadSpy = jest.spyOn(media, 'load');
|
||||
|
||||
// Now we stall after expiry -> should trigger a new fetch
|
||||
await act(async () => {
|
||||
media._emit('stalled');
|
||||
});
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||
expect(media.src).toContain('/fresh?token=second');
|
||||
|
||||
// Complete the ready cycle
|
||||
await act(async () => {
|
||||
media._emit('loadedmetadata');
|
||||
media._emit('canplay');
|
||||
});
|
||||
|
||||
// Ensure we reloaded and restored the seek position + playback
|
||||
expect(loadSpy).toHaveBeenCalled();
|
||||
expect(media.currentTime).toBe(42);
|
||||
expect((media as any).play).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores initial play when expiry is unknown', async () => {
|
||||
// no fetch expected on first play because expiresAt is not known yet
|
||||
(global.fetch as unknown as jest.Mock) = jest.fn();
|
||||
|
||||
const { result } = renderHook(() => useReloadOnError('/foo', 'audio'));
|
||||
const media = makeMediaEl();
|
||||
|
||||
act(() => {
|
||||
result.current.mediaRef(media);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
media._emit('play');
|
||||
});
|
||||
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useStableCallback, useSafeRefCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
const events = ['error', 'stalled', 'play'];
|
||||
|
||||
function toURL(urlString: string): URL {
|
||||
try {
|
||||
return new URL(urlString);
|
||||
} catch {
|
||||
return new URL(urlString, window.location.href);
|
||||
}
|
||||
}
|
||||
|
||||
const getRedirectURLInfo = async (url: string): Promise<{ redirectUrl: string | false; expires: number | null }> => {
|
||||
const _url = toURL(url);
|
||||
_url.searchParams.set('replyWithRedirectUrl', 'true');
|
||||
const response = await fetch(_url, {
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch URL info: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
redirectUrl: data.redirectUrl,
|
||||
expires: data.expires ? new Date(data.expires).getTime() : null,
|
||||
};
|
||||
};
|
||||
|
||||
const renderBufferingUIFallback = (vidEl: HTMLVideoElement) => {
|
||||
const computed = getComputedStyle(vidEl);
|
||||
|
||||
const videoTempStyles = {
|
||||
width: vidEl.style.width,
|
||||
height: vidEl.style.height,
|
||||
};
|
||||
Object.assign(vidEl.style, {
|
||||
width: computed.width,
|
||||
height: computed.height,
|
||||
});
|
||||
|
||||
return () => {
|
||||
Object.assign(vidEl.style, videoTempStyles);
|
||||
};
|
||||
};
|
||||
|
||||
export const useReloadOnError = (url: string, type: 'video' | 'audio') => {
|
||||
const [expiresAt, setExpiresAt] = useState<number | null>(null);
|
||||
const isRecovering = useRef(false);
|
||||
const firstRecoveryAttempted = useRef(false);
|
||||
|
||||
const handleMediaURLRecovery = useStableCallback(async (event: Event) => {
|
||||
if (isRecovering.current) {
|
||||
console.debug(`Media URL recovery already in progress, skipping ${event.type} event`);
|
||||
return;
|
||||
}
|
||||
isRecovering.current = true;
|
||||
|
||||
const node = event.target as HTMLMediaElement | null;
|
||||
if (!node) {
|
||||
isRecovering.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (firstRecoveryAttempted.current) {
|
||||
if (!expiresAt) {
|
||||
console.debug('No expiration time set, skipping recovery');
|
||||
isRecovering.current = false;
|
||||
return;
|
||||
}
|
||||
} else if (event.type === 'play') {
|
||||
// The user has initiated a playback for the first time, probably we should wait for the stalled or error event
|
||||
// the url may still be valid since we dont know the expiration time yet
|
||||
isRecovering.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
firstRecoveryAttempted.current = true;
|
||||
|
||||
if (expiresAt && Date.now() < expiresAt) {
|
||||
console.debug('Media URL is still valid, skipping recovery');
|
||||
isRecovering.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
console.debug('Handling media URL recovery for event:', event.type);
|
||||
|
||||
let cleanup: (() => void) | undefined;
|
||||
if (type === 'video') {
|
||||
cleanup = renderBufferingUIFallback(node as HTMLVideoElement);
|
||||
}
|
||||
|
||||
const wasPlaying = !node.paused;
|
||||
const { currentTime } = node;
|
||||
|
||||
try {
|
||||
const { redirectUrl: newUrl, expires: newExpiresAt } = await getRedirectURLInfo(url);
|
||||
setExpiresAt(newExpiresAt);
|
||||
node.src = newUrl || url;
|
||||
|
||||
const onCanPlay = async () => {
|
||||
node.removeEventListener('canplay', onCanPlay);
|
||||
|
||||
node.currentTime = currentTime;
|
||||
if (wasPlaying) {
|
||||
try {
|
||||
await node.play();
|
||||
} catch (playError) {
|
||||
console.warn('Failed to resume playback after URL recovery:', playError);
|
||||
} finally {
|
||||
isRecovering.current = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onMetaDataLoaded = () => {
|
||||
node.removeEventListener('loadedmetadata', onMetaDataLoaded);
|
||||
isRecovering.current = false;
|
||||
cleanup?.();
|
||||
};
|
||||
|
||||
node.addEventListener('canplay', onCanPlay, { once: true });
|
||||
node.addEventListener('loadedmetadata', onMetaDataLoaded, { once: true });
|
||||
node.load();
|
||||
} catch (err) {
|
||||
console.error('Error during URL recovery:', err);
|
||||
isRecovering.current = false;
|
||||
cleanup?.();
|
||||
}
|
||||
});
|
||||
|
||||
const mediaRefCallback = useSafeRefCallback(
|
||||
useCallback(
|
||||
(node: HTMLAudioElement) => {
|
||||
events.forEach((event) => {
|
||||
node.addEventListener(event, handleMediaURLRecovery);
|
||||
});
|
||||
return () => {
|
||||
events.forEach((event) => {
|
||||
node.removeEventListener(event, handleMediaURLRecovery);
|
||||
});
|
||||
};
|
||||
},
|
||||
[handleMediaURLRecovery],
|
||||
),
|
||||
);
|
||||
|
||||
return { mediaRef: mediaRefCallback };
|
||||
};
|
||||
Reference in New Issue
Block a user