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

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
import type { MessageAttachmentBase } from '@rocket.chat/core-typings';
import { isFileAttachment, isQuoteAttachment } from '@rocket.chat/core-typings';
import { memo } from 'react';
import DefaultAttachment from './DefaultAttachment';
import FileAttachment from './FileAttachment';
import { QuoteAttachment } from './QuoteAttachment';
import type { AudioAttachmentSource } from './file/AudioAttachment';
export type AttachmentsItemProps = {
attachment: MessageAttachmentBase;
id: string | undefined;
path: string;
source?: AudioAttachmentSource;
};
const AttachmentsItem = ({ attachment, id, path, source }: AttachmentsItemProps) => {
if (isFileAttachment(attachment)) {
return <FileAttachment id={id} source={source} {...attachment} />;
}
if (isQuoteAttachment(attachment)) {
return <QuoteAttachment attachment={attachment} source={source} path={path} />;
}
return <DefaultAttachment {...attachment} collapseKey={source?.mid ? `${source.mid}-${path}` : undefined} />;
};
export default memo(AttachmentsItem);

View File

@@ -0,0 +1,115 @@
import type { MarkdownFields, MessageAttachmentDefault } from '@rocket.chat/core-typings';
import { isActionAttachment } from '@rocket.chat/core-typings';
import type { ReactNode, ComponentProps } from 'react';
import { ActionAttachment } from './default/ActionAttachtment';
import FieldsAttachment from './default/FieldsAttachment';
import AttachmentAuthor from './structure/AttachmentAuthor';
import AttachmentAuthorAvatar from './structure/AttachmentAuthorAvatar';
import AttachmentAuthorName from './structure/AttachmentAuthorName';
import AttachmentBlock from './structure/AttachmentBlock';
import AttachmentContent from './structure/AttachmentContent';
import AttachmentImage from './structure/AttachmentImage';
import AttachmentRow from './structure/AttachmentRow';
import AttachmentText from './structure/AttachmentText';
import AttachmentThumb from './structure/AttachmentThumb';
import AttachmentTitle from './structure/AttachmentTitle';
import MarkdownText from '../../../MarkdownText';
import { useCollapse } from '../../hooks/useCollapse';
import CollapsibleContent from '../collapsible/CollapsibleContent';
const applyMarkdownIfRequires = (
list: MessageAttachmentDefault['mrkdwn_in'] = ['text', 'pretext'],
key: MarkdownFields,
text: string,
variant: ComponentProps<typeof MarkdownText>['variant'] = 'inline',
): ReactNode => (list?.includes(key) ? <MarkdownText parseEmoji variant={variant} content={text} /> : text);
export type DefaultAttachmentProps = MessageAttachmentDefault & {
collapseKey?: string;
};
const DefaultAttachment = ({ collapseKey, ...attachment }: DefaultAttachmentProps) => {
const [collapsed, toggleCollapse] = useCollapse(!!attachment.collapsed, collapseKey);
return (
<AttachmentBlock
color={attachment.color}
pre={
attachment.pretext && (
<AttachmentText>{applyMarkdownIfRequires(attachment.mrkdwn_in, 'pretext', attachment.pretext)}</AttachmentText>
)
}
>
<AttachmentContent>
{attachment.author_name && (
<AttachmentAuthor>
{attachment.author_icon && <AttachmentAuthorAvatar url={attachment.author_icon} />}
<AttachmentAuthorName
{...(attachment.author_link && {
is: 'a',
href: attachment.author_link,
target: '_blank',
color: undefined,
})}
>
{attachment.author_name}
</AttachmentAuthorName>
</AttachmentAuthor>
)}
{attachment.title && (
<AttachmentRow>
<AttachmentTitle
{...(attachment.title_link && {
is: 'a',
href: attachment.title_link,
target: '_blank',
color: undefined,
})}
>
{attachment.title}
</AttachmentTitle>{' '}
<CollapsibleContent key='collapsible-content-action' collapsed={collapsed} onClick={toggleCollapse} />
</AttachmentRow>
)}
{!collapsed && (
<>
{attachment.text && (
<AttachmentText>{applyMarkdownIfRequires(attachment.mrkdwn_in, 'text', attachment.text, 'document')}</AttachmentText>
)}
{/* {attachment.fields && <FieldsAttachment fields={attachment.mrkdwn_in?.includes('fields') ? attachment.fields.map(({ value, ...rest }) => ({ ...rest, value: <MarkdownText withRichContent={null} content={value} /> })) : attachment.fields} />} */}
{attachment.fields && (
<FieldsAttachment
fields={attachment.fields.map((field) => {
if (!field.value) {
return field;
}
const { value, title, ...rest } = field;
return {
...rest,
title: title ? (
<MarkdownText variant='inline' parseEmoji content={title.replace(/(.*)/g, (line: string) => `${line} `)} />
) : null,
value: value ? (
<MarkdownText variant='inline' parseEmoji content={String(value).replace(/(.*)/g, (line: string) => `${line} `)} />
) : null,
};
})}
/>
)}
{attachment.image_url && (
<AttachmentImage {...(attachment.image_dimensions as any)} src={attachment.image_url} alt={attachment.image_alt || ''} />
)}
{/* DEPRECATED */}
{isActionAttachment(attachment) && <ActionAttachment {...attachment} />}
</>
)}
</AttachmentContent>
{attachment.thumb_url && <AttachmentThumb url={attachment.thumb_url} />}
</AttachmentBlock>
);
};
export default DefaultAttachment;

View File

@@ -0,0 +1,29 @@
import { type FileAttachmentProps, isFileAudioAttachment, isFileImageAttachment, isFileVideoAttachment } from '@rocket.chat/core-typings';
import AudioAttachment from './file/AudioAttachment';
import type { AudioAttachmentSource } from './file/AudioAttachment';
import GenericFileAttachment from './file/GenericFileAttachment';
import ImageAttachment from './file/ImageAttachment';
import VideoAttachment from './file/VideoAttachment';
type FileAttachmentComponentProps = FileAttachmentProps & {
source?: AudioAttachmentSource;
};
const FileAttachment = ({ source, ...attachment }: FileAttachmentComponentProps) => {
if (isFileImageAttachment(attachment)) {
return <ImageAttachment {...attachment} />;
}
if (isFileAudioAttachment(attachment)) {
return <AudioAttachment {...attachment} source={source} />;
}
if (isFileVideoAttachment(attachment)) {
return <VideoAttachment {...attachment} />;
}
return <GenericFileAttachment {...attachment} />;
};
export default FileAttachment;

View File

@@ -0,0 +1,86 @@
import type { MessageQuoteAttachment } from '@rocket.chat/core-typings';
import { css } from '@rocket.chat/css-in-js';
import { Palette } from '@rocket.chat/fuselage';
import { useUserPreference } from '@rocket.chat/ui-contexts';
import { useTimeAgo } from '../../../../hooks/useTimeAgo';
import MessageContentBody from '../../MessageContentBody';
import Attachments from '../Attachments';
import type { AudioAttachmentSource } from './file/AudioAttachment';
import AttachmentAuthor from './structure/AttachmentAuthor';
import AttachmentAuthorAvatar from './structure/AttachmentAuthorAvatar';
import AttachmentAuthorName from './structure/AttachmentAuthorName';
import AttachmentAuthorTimestamp from './structure/AttachmentAuthorTimestamp';
import AttachmentContent from './structure/AttachmentContent';
import AttachmentDetails from './structure/AttachmentDetails';
import AttachmentInner from './structure/AttachmentInner';
import AttachmentMessageLink from './structure/AttachmentMessageLink';
import { toPlainTextRoot } from '../../../../lib/toPlainTextRoot';
// TODO: remove this team collaboration
const quoteStyles = css`
.rcx-attachment__details {
.rcx-message-body {
color: ${Palette.text['font-default']};
}
}
&:hover,
&:focus {
.rcx-attachment__details {
background: ${Palette.surface['surface-hover']};
border-color: ${Palette.stroke['stroke-light']};
border-inline-start-color: ${Palette.stroke['stroke-medium']};
}
}
`;
export type QuoteAttachmentProps = {
attachment: MessageQuoteAttachment;
source?: AudioAttachmentSource;
/** This quote's own path, used to prefix its nested attachments' collapse-state keys. */
path?: string;
};
export const QuoteAttachment = ({ attachment, source, path }: QuoteAttachmentProps) => {
const formatTime = useTimeAgo();
const displayAvatarPreference = useUserPreference<boolean>('displayAvatars');
return (
<>
<AttachmentContent className={quoteStyles} width='full'>
<AttachmentDetails
is='blockquote'
borderRadius='x4'
borderWidth='default'
borderStyle='solid'
borderColor='extra-light'
borderInlineStartColor='light'
>
<AttachmentAuthor>
{displayAvatarPreference && <AttachmentAuthorAvatar url={attachment.author_icon} />}
<AttachmentAuthorName
{...(attachment.author_link && { is: 'a', href: attachment.author_link, target: '_blank', color: 'default' })}
>
{attachment.author_name}
</AttachmentAuthorName>
{attachment.ts && (
<AttachmentAuthorTimestamp href={attachment.message_link}>{formatTime(attachment.ts)}</AttachmentAuthorTimestamp>
)}
{attachment.message_link && <AttachmentMessageLink href={attachment.message_link} />}
</AttachmentAuthor>
{attachment.attachments && (
<AttachmentInner>
<Attachments
attachments={attachment.attachments}
id={attachment.attachments[0]?.title_link}
source={source && { rid: source.rid, mid: source.mid, name: attachment.author_name }}
keyPrefix={path}
/>
</AttachmentInner>
)}
<MessageContentBody md={attachment.md ?? toPlainTextRoot(attachment.text)} />
</AttachmentDetails>
</AttachmentContent>
</>
);
};

View File

@@ -0,0 +1,47 @@
import type { IMessage, MessageAttachmentAction } from '@rocket.chat/core-typings';
import { Button } from '@rocket.chat/fuselage';
import { useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import type { ReactNode } from 'react';
import { usePerformActionMutation } from './hooks/usePerformActionMutation';
type ProcessingType = Exclude<MessageAttachmentAction['actions'][number]['msg_processing_type'], undefined>;
export type ActionAttachmentButtonProps = {
children: ReactNode;
mid?: IMessage['_id'];
msg?: string;
processingType: ProcessingType;
};
const ActionAttachmentButton = ({ children, processingType, msg, mid }: ActionAttachmentButtonProps) => {
const dispatchToastMessage = useToastMessageDispatch();
const performActionMutation = usePerformActionMutation({
onError: (error) => {
dispatchToastMessage({ type: 'error', message: error });
},
});
return (
<Button
small
value={msg}
id={mid}
disabled={performActionMutation.isPending}
onClick={(event): void => {
event.preventDefault();
performActionMutation.mutate({
processingType,
msg,
mid,
});
}}
>
{children}
</Button>
);
};
export default ActionAttachmentButton;

View File

@@ -0,0 +1,38 @@
import type { MessageAttachmentAction } from '@rocket.chat/core-typings';
import { Box, Button, ButtonGroup } from '@rocket.chat/fuselage';
import ActionAttachmentButton from './ActionAttachmentButton';
import { useExternalLink } from '../../../../../hooks/useExternalLink';
export type ActionAttachmentProps = MessageAttachmentAction;
export const ActionAttachment = ({ actions }: ActionAttachmentProps) => {
const handleLinkClick = useExternalLink();
return (
<Box marginBlock={4}>
<ButtonGroup small>
{actions
.filter(
({ type, msg_in_chat_window: msgInChatWindow, url, image_url: image, text }) =>
type === 'button' && (image || text) && (url || msgInChatWindow),
)
.map(({ text, url, msgId, msg, msg_processing_type: processingType = 'sendMessage', image_url: image }, index) => {
const content = image ? <Box is='img' src={image} maxHeight={200} /> : text;
if (url) {
return (
<Button role='link' onClick={() => handleLinkClick(url)} key={index} small>
{content}
</Button>
);
}
return (
<ActionAttachmentButton key={index} processingType={processingType} msg={msg} mid={msgId}>
{content}
</ActionAttachmentButton>
);
})}
</ButtonGroup>
</Box>
);
};

View File

@@ -0,0 +1,18 @@
import { Box } from '@rocket.chat/fuselage';
import type { ComponentProps, ReactNode } from 'react';
export type FieldProps = {
short?: boolean;
title: ReactNode;
value: ReactNode;
} & Omit<ComponentProps<typeof Box>, 'title' | 'value'>;
// TODO: description missing color token
const Field = ({ title, value, ...props }: FieldProps) => (
<Box marginBlock={4} paddingInline={4} width='full' flexBasis={100} flexShrink={0} color='default' {...props}>
<Box fontScale='p2m'>{title}</Box>
{value}
</Box>
);
export default Field;

View File

@@ -0,0 +1,21 @@
import { Box } from '@rocket.chat/fuselage';
import type { ReactNode } from 'react';
import Field from './Field';
import ShortField from './ShortField';
export type FieldsAttachmentProps = {
fields: {
short?: boolean;
title: ReactNode;
value: ReactNode;
}[];
};
const FieldsAttachment = ({ fields }: FieldsAttachmentProps) => (
<Box flexWrap='wrap' display='flex' marginBlock={4} marginInline={-4}>
{fields.map((field, index) => (field.short ? <ShortField key={index} {...field} /> : <Field key={index} {...field} />))}
</Box>
);
export default FieldsAttachment;

View File

@@ -0,0 +1,9 @@
import type { ComponentPropsWithoutRef } from 'react';
import Field from './Field';
export type ShortFieldProps = ComponentPropsWithoutRef<typeof Field>;
const ShortField = (props: ShortFieldProps) => <Field {...props} flexGrow={1} width='50%' flexBasis={1} />;
export default ShortField;

View File

@@ -0,0 +1,46 @@
import type { IMessage, MessageAttachmentAction } from '@rocket.chat/core-typings';
import type { UseMutationOptions, UseMutationResult } from '@tanstack/react-query';
import { useMutation } from '@tanstack/react-query';
import { useChat } from '../../../../../../views/room/contexts/ChatContext';
type ProcessingType = Exclude<MessageAttachmentAction['actions'][number]['msg_processing_type'], undefined>;
type UsePerfomActionMutationParams = {
processingType: ProcessingType;
msg?: string;
mid?: IMessage['_id'];
};
export const usePerformActionMutation = (
options?: Omit<UseMutationOptions<void, Error, UsePerfomActionMutationParams>, 'mutationFn'>,
): UseMutationResult<void, Error, UsePerfomActionMutationParams> => {
const chat = useChat();
return useMutation({
mutationFn: async ({ processingType, msg, mid }) => {
if (!chat) {
return;
}
switch (processingType) {
case 'sendMessage':
if (!msg) return;
await chat.flows.sendMessage({ text: msg });
return;
case 'respondWithMessage':
if (!msg) return;
await chat.composer?.replyWith(msg);
return;
case 'respondWithQuotedMessage':
if (!mid) return;
const message = await chat.data.getMessageByID(mid);
await chat.composer?.quoteMessage(message);
}
},
...options,
});
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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), [])];
};

View File

@@ -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);
});
});
});

View File

@@ -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;
};

View File

@@ -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();
});
});

View File

@@ -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 };
};

View File

@@ -0,0 +1,29 @@
import { css } from '@rocket.chat/css-in-js';
import { Box } from '@rocket.chat/fuselage';
import { useAttachmentDimensions } from '@rocket.chat/ui-contexts';
import type { ComponentPropsWithoutRef } from 'react';
const className = css`
white-space: normal;
`;
export type AttachmentProps = ComponentPropsWithoutRef<typeof Box>;
const Attachment = (props: AttachmentProps) => {
const { width } = useAttachmentDimensions();
return (
<Box
rcx-message-attachment
marginBlock={4}
maxWidth={width}
width='full'
display='flex'
overflow='hidden'
flexDirection='column'
className={className}
{...props}
/>
);
};
export default Attachment;

View File

@@ -0,0 +1,10 @@
import { Box } from '@rocket.chat/fuselage';
import type { ComponentPropsWithoutRef } from 'react';
export type AttachmentAuthorProps = ComponentPropsWithoutRef<typeof Box>;
const AttachmentAuthor = (props: AttachmentAuthorProps) => (
<Box display='flex' flexDirection='row' alignItems='center' marginBlockEnd={4} {...props} />
);
export default AttachmentAuthor;

View File

@@ -0,0 +1,7 @@
import { BaseAvatar } from '@rocket.chat/ui-avatar';
export type AttachmentAuthorAvatarProps = { url: string };
const AttachmentAuthorAvatar = ({ url }: AttachmentAuthorAvatarProps) => <BaseAvatar url={url} size='x24' />;
export default AttachmentAuthorAvatar;

View File

@@ -0,0 +1,8 @@
import { Box } from '@rocket.chat/fuselage';
import type { ComponentPropsWithoutRef } from 'react';
export type AttachmentAuthorNameProps = ComponentPropsWithoutRef<typeof Box>;
const AttachmentAuthorName = (props: AttachmentAuthorNameProps) => <Box withTruncatedText fontScale='p2m' marginInline={8} {...props} />;
export default AttachmentAuthorName;

View File

@@ -0,0 +1,9 @@
import { Box } from '@rocket.chat/fuselage';
import type { ComponentPropsWithoutRef } from 'react';
type AttachmentAuthorTimestampProps = ComponentPropsWithoutRef<typeof Box>;
const AttachmentAuthorTimestamp = ({ href, ...props }: AttachmentAuthorTimestampProps) =>
href ? <Box is='a' href={href} color='default' fontScale='c1' {...props} /> : <Box fontScale='c1' {...props} />;
export default AttachmentAuthorTimestamp;

View File

@@ -0,0 +1,25 @@
import { Box } from '@rocket.chat/fuselage';
import type { ReactNode } from 'react';
import Attachment from './Attachment';
export type AttachmentBlockProps = { pre?: ReactNode; color?: string | undefined; children?: ReactNode };
const AttachmentBlock = ({ pre, color = 'annotation', children }: AttachmentBlockProps) => (
<Attachment>
{pre}
<Box
display='flex'
flexDirection='row'
paddingInlineStart={16}
borderRadius={2}
borderInlineStartStyle='solid'
borderInlineStartWidth='default'
borderInlineStartColor={color}
>
{children}
</Box>
</Attachment>
);
export default AttachmentBlock;

View File

@@ -0,0 +1,8 @@
import { Box } from '@rocket.chat/fuselage';
import type { ComponentPropsWithoutRef } from 'react';
export type AttachmentContentProps = ComponentPropsWithoutRef<typeof Box>;
const AttachmentContent = (props: AttachmentContentProps) => <Box rcx-attachment__content width='full' marginBlock={4} {...props} />;
export default AttachmentContent;

View File

@@ -0,0 +1,10 @@
import { Box } from '@rocket.chat/fuselage';
import type { ComponentPropsWithoutRef } from 'react';
export type AttachmentDetailsProps = ComponentPropsWithoutRef<typeof Box>;
const AttachmentDetails = (props: AttachmentDetailsProps) => (
<Box rcx-attachment__details fontScale='p2' color='default' backgroundColor='surface-tint' padding={16} {...props} />
);
export default AttachmentDetails;

View File

@@ -0,0 +1,19 @@
import type { ComponentPropsWithoutRef } from 'react';
import AttachmentDownloadBase from './AttachmentDownloadBase';
import AttachmentEncryptedDownload from './AttachmentEncryptedDownload';
import type Action from '../../Action';
export type AttachmentDownloadProps = Omit<ComponentPropsWithoutRef<typeof Action>, 'icon'> & { title?: string | undefined; href: string };
const AttachmentDownload = ({ title, href, ...props }: AttachmentDownloadProps) => {
const isEncrypted = href.includes('/file-decrypt/');
if (isEncrypted) {
return <AttachmentEncryptedDownload title={title} href={href} {...props} />;
}
return <AttachmentDownloadBase title={title} href={href} {...props} />;
};
export default AttachmentDownload;

View File

@@ -0,0 +1,26 @@
import type { ComponentProps } from 'react';
import { useTranslation } from 'react-i18next';
import Action from '../../Action';
export type AttachmentDownloadBaseProps = Omit<ComponentProps<typeof Action>, 'icon'> & { title?: string | undefined; href: string };
const AttachmentDownloadBase = ({ title, href, disabled, ...props }: AttachmentDownloadBaseProps) => {
const { t } = useTranslation();
return (
<Action
icon='cloud-arrow-down'
href={`${href}?download`}
title={disabled ? t('Download_Disabled') : t('Download')}
is='a'
target='_blank'
rel='noopener noreferrer'
download={title}
disabled={disabled}
{...props}
/>
);
};
export default AttachmentDownloadBase;

View File

@@ -0,0 +1,14 @@
import type { ComponentProps } from 'react';
import AttachmentDownloadBase from './AttachmentDownloadBase';
import { useDownloadFromServiceWorker } from '../../../../../hooks/useDownloadFromServiceWorker';
type AttachmentDownloadProps = ComponentProps<typeof AttachmentDownloadBase>;
const AttachmentEncryptedDownload = ({ title, href, ...props }: AttachmentDownloadProps) => {
const encryptedAnchorProps = useDownloadFromServiceWorker(href, title);
return <AttachmentDownloadBase {...props} {...encryptedAnchorProps} title={title} href={href} />;
};
export default AttachmentEncryptedDownload;

View File

@@ -0,0 +1,97 @@
import { Box } from '@rocket.chat/fuselage';
import { useAttachmentDimensions } from '@rocket.chat/ui-contexts';
import { memo, useState, useMemo } from 'react';
import ImageBox from './image/ImageBox';
import Load from './image/Load';
import Retry from './image/Retry';
export type AttachmentImageProps = {
previewUrl?: string;
dataSrc?: string;
src: string;
loadImage?: boolean;
setLoadImage: () => void;
id: string | undefined;
width: number;
height: number;
alt?: string;
} & ({ loadImage: true } | { loadImage: false; setLoadImage: () => void });
const getDimensions = (
originalWidth: number,
originalHeight: number,
limits: { width: number; height: number },
): { width: number; height: number; ratio: number } => {
const widthRatio = originalWidth / limits.width;
const heightRatio = originalHeight / limits.height;
if (widthRatio > heightRatio) {
const width = Math.min(originalWidth, limits.width);
const height = (width / originalWidth) * originalHeight;
return { width, height, ratio: (height / width) * 100 };
}
const height = Math.min(originalHeight, limits.height);
const width = (height / originalHeight) * originalWidth;
return { width, height, ratio: (height / width) * 100 };
};
const AttachmentImage = ({ id, previewUrl, dataSrc, loadImage = true, setLoadImage, src, alt = '', ...size }: AttachmentImageProps) => {
const limits = useAttachmentDimensions();
const [error, setError] = useState(false);
const { width = limits.width, height = limits.height } = size;
const { setHasNoError } = useMemo(
() => ({
setHasNoError: (): void => setError(false),
}),
[],
);
const dimensions = getDimensions(width, height, limits);
const background = previewUrl && `url(${previewUrl}) center center / cover no-repeat fixed`;
if (!loadImage) {
return <Load width={dimensions.width || limits.width} height={dimensions.height || limits.height} load={setLoadImage} />;
}
if (error) {
return <Retry retry={setHasNoError} />;
}
return (
<Box width={dimensions.width} maxWidth='full' position='relative'>
<Box paddingBlockStart={`${dimensions.ratio}%`} position='relative'>
<ImageBox
is='picture'
position='absolute'
onError={() => setError(true)}
style={{
...(previewUrl && { background, boxSizing: 'content-box' }),
top: 0,
left: 0,
bottom: 0,
right: 0,
}}
>
<img
data-id={id}
className='gallery-item'
data-src={dataSrc || src}
src={src}
alt={alt}
width={dimensions.width}
height={dimensions.height}
loading='lazy'
/>
</ImageBox>
</Box>
</Box>
);
};
export default memo(AttachmentImage);

View File

@@ -0,0 +1,8 @@
import { Box } from '@rocket.chat/fuselage';
import type { ComponentPropsWithoutRef } from 'react';
export type AttachmentInnerProps = ComponentPropsWithoutRef<typeof Box>;
const AttachmentInner = (props: AttachmentInnerProps) => <Box {...props} />;
export default AttachmentInner;

View File

@@ -0,0 +1,14 @@
import type { Box } from '@rocket.chat/fuselage';
import { IconButton } from '@rocket.chat/fuselage';
import type { ComponentPropsWithoutRef } from 'react';
import { useTranslation } from 'react-i18next';
type AttachmentAuthorMessageLinkProps = ComponentPropsWithoutRef<typeof Box>;
const AttachmentAuthorMessageLink = ({ href, ...props }: AttachmentAuthorMessageLinkProps) => {
const { t } = useTranslation();
return <IconButton marginInlineStart={4} mini is='a' href={href} icon='jump' title={t('Jump_to_message')} {...props} />;
};
export default AttachmentAuthorMessageLink;

View File

@@ -0,0 +1,10 @@
import { Box } from '@rocket.chat/fuselage';
import type { ComponentPropsWithoutRef } from 'react';
export type AttachmentRowProps = ComponentPropsWithoutRef<typeof Box>;
const AttachmentRow = (props: AttachmentRowProps) => (
<Box marginInline={-2} marginBlockEnd={2} rcx-message-attachment display='flex' alignItems='center' {...props} />
);
export default AttachmentRow;

View File

@@ -0,0 +1,21 @@
import type { Box } from '@rocket.chat/fuselage';
import type { ComponentPropsWithoutRef } from 'react';
import Title from './AttachmentTitle';
import { useFormatMemorySize } from '../../../../../hooks/useFormatMemorySize';
export type AttachmentSizeProps = ComponentPropsWithoutRef<typeof Box> & { size: number; wrapper?: boolean };
const AttachmentSize = ({ size, wrapper = true, ...props }: AttachmentSizeProps) => {
const format = useFormatMemorySize();
const formattedSize = wrapper ? `(${format(size)})` : format(size);
return (
<Title flexShrink={0} {...props}>
{formattedSize}
</Title>
);
};
export default AttachmentSize;

View File

@@ -0,0 +1,22 @@
import { Box } from '@rocket.chat/fuselage';
import type { ComponentPropsWithoutRef } from 'react';
import { useTranslation } from 'react-i18next';
export type AttachmentTextProps = ComponentPropsWithoutRef<typeof Box>;
const AttachmentText = (props: AttachmentTextProps) => {
const { t } = useTranslation();
return (
<Box
role='document'
aria-roledescription={t('message_attachment')}
marginBlockEnd={4}
marginInline={2}
fontScale='p2'
color='default'
{...props}
/>
);
};
export default AttachmentText;

View File

@@ -0,0 +1,12 @@
import { Box, Avatar } from '@rocket.chat/fuselage';
import { memo } from 'react';
export type AttachmentThumbProps = { url: string };
const AttachmentThumb = ({ url }: AttachmentThumbProps) => (
<Box marginInlineStart={8}>
<Avatar {...({ url, size: 'x48' } as any)} />
</Box>
);
export default memo(AttachmentThumb);

View File

@@ -0,0 +1,8 @@
import { Box } from '@rocket.chat/fuselage';
import type { ComponentPropsWithoutRef } from 'react';
export type AttachmentTitleProps = ComponentPropsWithoutRef<typeof Box>;
const AttachmentTitle = (props: AttachmentTitleProps) => <Box withTruncatedText marginInline={2} fontScale='c1' color='hint' {...props} />;
export default AttachmentTitle;

View File

@@ -0,0 +1,23 @@
import { Box } from '@rocket.chat/fuselage';
import type { ComponentPropsWithoutRef } from 'react';
export type ImageBoxProps = ComponentPropsWithoutRef<typeof Box>;
const ImageBox = (props: ImageBoxProps) => (
<Box
display='flex'
maxWidth='full'
flexDirection='column'
justifyContent='center'
alignItems='center'
alignContent='center'
borderRadius={2}
borderWidth='default'
borderStyle='solid'
borderColor='extra-light'
position='relative'
{...props}
/>
);
export default ImageBox;

View File

@@ -0,0 +1,31 @@
import { css } from '@rocket.chat/css-in-js';
import { Box, Icon, Palette } from '@rocket.chat/fuselage';
import type { ComponentPropsWithoutRef } from 'react';
import { useTranslation } from 'react-i18next';
import ImageBox from './ImageBox';
export type LoadProps = ComponentPropsWithoutRef<typeof Box> & { load: () => void };
const Load = ({ load, ...props }: LoadProps) => {
const { t } = useTranslation();
const clickable = css`
cursor: pointer;
background: ${Palette.surface['surface-tint']};
&:hover,
&:focus {
background: ${Palette.surface['surface-hover']};
}
`;
return (
<ImageBox className={clickable} {...props} onClick={load}>
<Icon name='image' color='hint' size='x64' />
<Box fontScale='h2' color='default'>
{t('Click_to_load')}
</Box>
</ImageBox>
);
};
export default Load;

View File

@@ -0,0 +1,30 @@
import { css } from '@rocket.chat/css-in-js';
import { Box, Icon, Palette } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
import ImageBox from './ImageBox';
export type RetryProps = { retry: () => void };
const Retry = ({ retry }: RetryProps) => {
const { t } = useTranslation();
const clickable = css`
cursor: pointer;
background: ${Palette.surface['surface-tint']};
&:hover,
&:focus {
background: ${Palette.surface['surface-hover']};
}
`;
return (
<ImageBox className={clickable} onClick={retry} size='x160'>
<Icon name='refresh' color='hint' size='x64' />
<Box fontScale='h3' color='default' textAlign='center'>
{t('Retry')}
</Box>
</ImageBox>
);
};
export default Retry;