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,17 @@
|
||||
import { Contextualbar } from '@rocket.chat/ui-client';
|
||||
import type { StoryObj, Meta } from '@storybook/react';
|
||||
|
||||
import ExportMessages from './index';
|
||||
|
||||
export default {
|
||||
component: ExportMessages,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
decorators: [(fn) => <Contextualbar height='100vh'>{fn()}</Contextualbar>],
|
||||
} satisfies Meta<typeof ExportMessages>;
|
||||
|
||||
export const Default: StoryObj<typeof ExportMessages> = {
|
||||
render: () => <ExportMessages />,
|
||||
name: 'ExportMessages',
|
||||
};
|
||||
@@ -0,0 +1,397 @@
|
||||
import type { SelectOption } from '@rocket.chat/fuselage';
|
||||
import { ButtonGroup, Button, Icon, InputBox, Callout } from '@rocket.chat/fuselage';
|
||||
import { FieldError, Field, FieldLabel, FieldRow, TextAreaInput, FieldGroup, Select, TextInput } from '@rocket.chat/fuselage-forms';
|
||||
import { useAutoFocus } from '@rocket.chat/fuselage-hooks';
|
||||
import { validateEmail } from '@rocket.chat/tools';
|
||||
import {
|
||||
ContextualbarHeader,
|
||||
ContextualbarScrollableContent,
|
||||
ContextualbarIcon,
|
||||
ContextualbarTitle,
|
||||
ContextualbarClose,
|
||||
ContextualbarFooter,
|
||||
ContextualbarDialog,
|
||||
} from '@rocket.chat/ui-client';
|
||||
import { usePermission, useRoomToolbox } from '@rocket.chat/ui-contexts';
|
||||
import { useContext, useEffect, useId, useMemo } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useDownloadExportMutation } from './useDownloadExportMutation';
|
||||
import { useExportMessagesAsPDFMutation } from './useExportMessagesAsPDFMutation';
|
||||
import { useRoomExportMutation } from './useRoomExportMutation';
|
||||
import UserAutoCompleteMultiple from '../../../../components/UserAutoCompleteMultiple';
|
||||
import { roomCoordinator } from '../../../../lib/rooms/roomCoordinator';
|
||||
import { SelectedMessageContext, useCountSelected } from '../../MessageList/contexts/SelectedMessagesContext';
|
||||
import { useRoom } from '../../contexts/RoomContext';
|
||||
|
||||
export type ExportMessagesFormValues = {
|
||||
type: 'email' | 'file' | 'download';
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
format: 'html' | 'json' | 'pdf';
|
||||
toUsers: string[];
|
||||
additionalEmails: string;
|
||||
messagesCount: number;
|
||||
subject: string;
|
||||
};
|
||||
|
||||
const ExportMessages = () => {
|
||||
const { t } = useTranslation();
|
||||
const { closeTab } = useRoomToolbox();
|
||||
const pfdExportPermission = usePermission('export-messages-as-pdf');
|
||||
const formFocus = useAutoFocus<HTMLFormElement>();
|
||||
const room = useRoom();
|
||||
const isE2ERoom = room.encrypted;
|
||||
|
||||
const roomName = room?.t && roomCoordinator.getRoomName(room.t, room);
|
||||
|
||||
const {
|
||||
control,
|
||||
formState: { errors, isSubmitting, isDirty, isSubmitted },
|
||||
watch,
|
||||
register,
|
||||
setValue,
|
||||
handleSubmit,
|
||||
clearErrors,
|
||||
reset,
|
||||
} = useForm<ExportMessagesFormValues>({
|
||||
defaultValues: {
|
||||
type: isE2ERoom ? 'download' : 'email',
|
||||
dateFrom: '',
|
||||
dateTo: '',
|
||||
toUsers: [],
|
||||
additionalEmails: '',
|
||||
messagesCount: 0,
|
||||
subject: t('Mail_Messages_Subject', { roomName }),
|
||||
format: isE2ERoom ? 'json' : 'html',
|
||||
},
|
||||
});
|
||||
|
||||
const exportOptions = useMemo<SelectOption[]>(
|
||||
() => [
|
||||
['email', t('Send_email')],
|
||||
['file', t('Send_file_via_email')],
|
||||
['download', t('Download_file')],
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const outputOptions = useMemo<SelectOption[]>(() => {
|
||||
const options: SelectOption[] = [
|
||||
['html', t('HTML')],
|
||||
['json', t('JSON')],
|
||||
];
|
||||
|
||||
if (pfdExportPermission) {
|
||||
options.push(['pdf', t('PDF')]);
|
||||
}
|
||||
|
||||
return options;
|
||||
}, [t, pfdExportPermission]);
|
||||
|
||||
// Remove HTML from download options
|
||||
const downloadOutputOptions = useMemo<SelectOption[]>(() => {
|
||||
return outputOptions.filter((option) => option[0] !== 'html');
|
||||
}, [outputOptions]);
|
||||
|
||||
// Remove PDF from file options
|
||||
const fileOutputOptions = useMemo<SelectOption[]>(() => {
|
||||
return outputOptions.filter((option) => option[0] !== 'pdf');
|
||||
}, [outputOptions]);
|
||||
|
||||
const { mutateAsync: exportRoom } = useRoomExportMutation();
|
||||
const { mutateAsync: exportAndDownload } = useDownloadExportMutation();
|
||||
|
||||
const { selectedMessageStore } = useContext(SelectedMessageContext);
|
||||
const messageCount = useCountSelected();
|
||||
|
||||
const { type, toUsers } = watch();
|
||||
|
||||
useEffect(() => {
|
||||
if (type === 'email') {
|
||||
setValue('format', 'html');
|
||||
}
|
||||
|
||||
if (type === 'download') {
|
||||
setValue('format', 'json');
|
||||
}
|
||||
}, [type, setValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (type !== 'file') {
|
||||
selectedMessageStore.setIsSelecting(true);
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
selectedMessageStore.reset();
|
||||
};
|
||||
}, [type, selectedMessageStore]);
|
||||
|
||||
useEffect(() => {
|
||||
setValue('messagesCount', messageCount, { shouldDirty: true, shouldValidate: isSubmitted });
|
||||
}, [messageCount, setValue, isSubmitted]);
|
||||
|
||||
const { mutateAsync: exportAsPDF } = useExportMessagesAsPDFMutation();
|
||||
|
||||
const handleExport = async ({
|
||||
type,
|
||||
toUsers,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
format,
|
||||
subject,
|
||||
additionalEmails,
|
||||
}: ExportMessagesFormValues): Promise<void> => {
|
||||
const messages = selectedMessageStore.getSelectedMessages();
|
||||
|
||||
if (type === 'download') {
|
||||
if (format === 'pdf') {
|
||||
await exportAsPDF(messages);
|
||||
return;
|
||||
}
|
||||
|
||||
if (format === 'json') {
|
||||
await exportAndDownload({
|
||||
mids: messages,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'file') {
|
||||
await exportRoom({
|
||||
rid: room._id,
|
||||
type: 'file',
|
||||
...(dateFrom && { dateFrom }),
|
||||
...(dateTo && { dateTo }),
|
||||
format: format as 'html' | 'json',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await exportRoom({
|
||||
rid: room._id,
|
||||
type: 'email',
|
||||
toUsers,
|
||||
toEmails: additionalEmails?.split(','),
|
||||
subject,
|
||||
messages,
|
||||
});
|
||||
};
|
||||
|
||||
const formId = useId();
|
||||
const dateFromField = useId();
|
||||
const dateToField = useId();
|
||||
|
||||
return (
|
||||
<ContextualbarDialog aria-labelledby={`${formId}-title`}>
|
||||
<ContextualbarHeader>
|
||||
<ContextualbarIcon name='mail' />
|
||||
<ContextualbarTitle id={`${formId}-title`}>{t('Export_Messages')}</ContextualbarTitle>
|
||||
<ContextualbarClose onClick={closeTab} />
|
||||
</ContextualbarHeader>
|
||||
<ContextualbarScrollableContent>
|
||||
<form ref={formFocus} tabIndex={-1} aria-labelledby={`${formId}-title`} id={formId} onSubmit={handleSubmit(handleExport)}>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel>{t('Method')}</FieldLabel>
|
||||
<FieldRow>
|
||||
<Controller
|
||||
name='type'
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
data-testid='export-messages-method'
|
||||
{...field}
|
||||
placeholder={t('Type')}
|
||||
disabled={isE2ERoom}
|
||||
options={exportOptions}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>{t('Output_format')}</FieldLabel>
|
||||
<FieldRow>
|
||||
<Controller
|
||||
name='format'
|
||||
control={control}
|
||||
render={({ field }) => {
|
||||
let options: SelectOption[];
|
||||
|
||||
if (type === 'download') {
|
||||
options = downloadOutputOptions;
|
||||
} else if (type === 'file') {
|
||||
options = fileOutputOptions;
|
||||
} else {
|
||||
options = outputOptions;
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
{...field}
|
||||
data-testid='export-messages-output-format'
|
||||
disabled={type === 'email'}
|
||||
placeholder={t('Format')}
|
||||
options={options}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FieldRow>
|
||||
</Field>
|
||||
{type === 'file' && (
|
||||
<>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={dateFromField}>{t('Date_From')}</FieldLabel>
|
||||
<FieldRow>
|
||||
<Controller
|
||||
name='dateFrom'
|
||||
control={control}
|
||||
render={({ field }) => <InputBox id={dateFromField} type='date' {...field} />}
|
||||
/>
|
||||
</FieldRow>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={dateToField}>{t('Date_to')}</FieldLabel>
|
||||
<FieldRow>
|
||||
<Controller
|
||||
name='dateTo'
|
||||
control={control}
|
||||
render={({ field }) => <InputBox id={dateToField} {...field} type='date' />}
|
||||
/>
|
||||
</FieldRow>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
{type === 'email' && (
|
||||
<>
|
||||
<Field>
|
||||
<FieldLabel>{t('To_users')}</FieldLabel>
|
||||
<FieldRow>
|
||||
<Controller
|
||||
name='toUsers'
|
||||
control={control}
|
||||
rules={{
|
||||
validate: {
|
||||
validateRecipient: (toUsers) => {
|
||||
const additionalEmails = watch('additionalEmails');
|
||||
if (toUsers?.length > 0 || additionalEmails !== '') {
|
||||
return undefined;
|
||||
}
|
||||
return t('Mail_Message_Missing_to');
|
||||
},
|
||||
},
|
||||
}}
|
||||
render={({ field: { value, onChange, onBlur, name, ...field } }) => (
|
||||
<UserAutoCompleteMultiple
|
||||
{...field}
|
||||
value={value}
|
||||
onChange={(value) => {
|
||||
onChange(value);
|
||||
clearErrors('additionalEmails');
|
||||
}}
|
||||
onBlur={onBlur}
|
||||
name={name}
|
||||
error={errors?.toUsers?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
{errors?.toUsers && <FieldError>{errors.toUsers.message}</FieldError>}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>{t('To_additional_emails')}</FieldLabel>
|
||||
<FieldRow>
|
||||
<Controller
|
||||
name='additionalEmails'
|
||||
control={control}
|
||||
rules={{
|
||||
validate: {
|
||||
validateEmail: (additionalEmails) => {
|
||||
if (additionalEmails === '') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const emails = additionalEmails?.split(',').map((email) => email.trim());
|
||||
if (Array.isArray(emails) && emails.every((email) => validateEmail(email.trim()))) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return t('Mail_Message_Invalid_emails', { emails: additionalEmails });
|
||||
},
|
||||
validateRecipient: (additionalEmails) => {
|
||||
if (additionalEmails !== '' || toUsers?.length > 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return t('Mail_Message_Missing_to');
|
||||
},
|
||||
},
|
||||
}}
|
||||
render={({ field: { onChange, onBlur, ...field } }) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
onChange(e);
|
||||
clearErrors('toUsers');
|
||||
}}
|
||||
onBlur={onBlur}
|
||||
placeholder={t('Email_Placeholder_any')}
|
||||
endAddon={<Icon name='mail' size='x20' />}
|
||||
error={errors?.additionalEmails?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
{errors?.additionalEmails && <FieldError>{errors.additionalEmails.message}</FieldError>}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>{t('Subject')}</FieldLabel>
|
||||
<FieldRow>
|
||||
<Controller
|
||||
name='subject'
|
||||
control={control}
|
||||
render={({ field }) => <TextAreaInput rows={3} {...field} endAddon={<Icon name='edit' size='x20' />} />}
|
||||
/>
|
||||
</FieldRow>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
{type !== 'file' && (
|
||||
<>
|
||||
<input
|
||||
type='hidden'
|
||||
{...register('messagesCount', {
|
||||
validate: (messagesCount) => (messagesCount > 0 ? undefined : t('Mail_Message_No_messages_selected_select_all')),
|
||||
})}
|
||||
/>
|
||||
{errors.messagesCount && (
|
||||
<Field>
|
||||
<Callout role='alert' type='danger'>
|
||||
{errors.messagesCount.message}
|
||||
</Callout>
|
||||
</Field>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</ContextualbarScrollableContent>
|
||||
<ContextualbarFooter>
|
||||
<ButtonGroup stretch>
|
||||
<Button type='reset' disabled={!isDirty || isSubmitting} onClick={() => reset()}>
|
||||
{t('Reset')}
|
||||
</Button>
|
||||
<Button loading={isSubmitting} form={formId} primary type='submit'>
|
||||
{type === 'download' ? t('Download') : t('Send')}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</ContextualbarFooter>
|
||||
</ContextualbarDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExportMessages;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './ExportMessages';
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { IMessage } from '@rocket.chat/core-typings';
|
||||
import { useToastMessageDispatch, useUser } from '@rocket.chat/ui-contexts';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { downloadJsonAs } from '../../../../lib/download';
|
||||
import { Messages } from '../../../../stores';
|
||||
import { useRoom } from '../../contexts/RoomContext';
|
||||
|
||||
export const useDownloadExportMutation = () => {
|
||||
const { t } = useTranslation();
|
||||
const room = useRoom();
|
||||
const user = useUser();
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
|
||||
const filterMessages = Messages.use((state) => state.filter);
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ mids }: { mids: IMessage['_id'][] }) => {
|
||||
const messages = filterMessages((record) => mids.includes(record._id) || (!!record.tmid && mids.includes(record.tmid))).map(
|
||||
({ _id, ts, u, msg, _updatedAt, tlm, replies, tmid, attachments }) => ({
|
||||
_id,
|
||||
ts,
|
||||
u,
|
||||
msg,
|
||||
_updatedAt,
|
||||
tlm,
|
||||
replies,
|
||||
tmid,
|
||||
attachments:
|
||||
attachments?.map((attachment) => ({
|
||||
ts: attachment.ts,
|
||||
title: attachment.title,
|
||||
title_link: attachment.title_link,
|
||||
title_link_download: attachment.title_link_download,
|
||||
...('image_dimensions' in attachment && { image_dimensions: attachment.image_dimensions }),
|
||||
...('image_preview' in attachment && { image_preview: attachment.image_preview }),
|
||||
...('image_url' in attachment && { image_url: attachment.image_url }),
|
||||
...('image_type' in attachment && { image_type: attachment.image_type }),
|
||||
...('image_size' in attachment && { image_size: attachment.image_size }),
|
||||
...('type' in attachment && { type: attachment.type }),
|
||||
description: attachment.description,
|
||||
})) ?? [],
|
||||
}),
|
||||
);
|
||||
|
||||
const fileData = {
|
||||
roomId: room._id,
|
||||
roomName: room.fname || room.name,
|
||||
userExport: {
|
||||
id: user?._id,
|
||||
username: user?.username,
|
||||
name: user?.name,
|
||||
roles: user?.roles,
|
||||
},
|
||||
exportDate: new Date().toISOString(),
|
||||
messages,
|
||||
};
|
||||
|
||||
return downloadJsonAs(fileData, `exportedMessages-${new Date().toISOString()}`);
|
||||
},
|
||||
onError: (error) => {
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
},
|
||||
onSuccess: () => {
|
||||
dispatchToastMessage({ type: 'success', message: t('Messages_exported_successfully') });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,196 @@
|
||||
import { Document, Font, Image, Page, pdf, StyleSheet, Text, View } from '@react-pdf/renderer';
|
||||
import type { IMessage, MessageAttachmentDefault } from '@rocket.chat/core-typings';
|
||||
import { MessageTypes } from '@rocket.chat/message-types';
|
||||
import { escapeHTML } from '@rocket.chat/tools';
|
||||
import { useSetting, useToastMessageDispatch, useAbsoluteUrl } from '@rocket.chat/ui-contexts';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useFormatDateAndTime } from '../../../../hooks/useFormatDateAndTime';
|
||||
import { Messages } from '../../../../stores';
|
||||
|
||||
const leftTab = {
|
||||
marginLeft: 20,
|
||||
};
|
||||
const NOTO_SANS_FONTS: { name: string; fontSrc: string }[] = [
|
||||
{ name: 'Noto Sans Hebrew', fontSrc: '/fonts/NotoSansHebrew-Regular.ttf' },
|
||||
{ name: 'Noto Sans', fontSrc: '/fonts/NotoSans-Regular.ttf' },
|
||||
{ name: 'Noto Sans Arabic', fontSrc: '/fonts/NotoSansArabic-Regular.ttf' },
|
||||
{ name: 'Noto Sans Devanagari', fontSrc: '/fonts/NotoSansDevanagari-Regular.ttf' },
|
||||
{ name: 'Noto Sans Bengali', fontSrc: '/fonts/NotoSansBengali-Regular.ttf' },
|
||||
{ name: 'Noto Sans Tamil', fontSrc: '/fonts/NotoSansTamil-Regular.ttf' },
|
||||
{ name: 'Noto Sans Sinhala', fontSrc: '/fonts/NotoSansSinhala-Regular.ttf' },
|
||||
{ name: 'Noto Sans Thai', fontSrc: '/fonts/NotoSansThai-Regular.ttf' },
|
||||
{ name: 'Noto Sans Lao', fontSrc: '/fonts/NotoSansLao-Regular.ttf' },
|
||||
{ name: 'Noto Sans Georgian', fontSrc: '/fonts/NotoSansGeorgian-Regular.ttf' },
|
||||
{ name: 'Noto Sans JP', fontSrc: '/fonts/NotoSansJP-Regular.ttf' },
|
||||
{ name: 'Noto Sans KR', fontSrc: '/fonts/NotoSansKR-Regular.ttf' },
|
||||
{ name: 'Noto Sans SC', fontSrc: '/fonts/NotoSansSC-Regular.ttf' },
|
||||
{ name: 'Noto Sans TC', fontSrc: '/fonts/NotoSansTC-Regular.ttf' },
|
||||
{ name: 'Noto Sans HK', fontSrc: '/fonts/NotoSansHK-Regular.ttf' },
|
||||
];
|
||||
|
||||
const EMOJI_RENDER_SIZE = 72;
|
||||
|
||||
const renderEmojiToDataURL = (codePoints: string): string => {
|
||||
const points = codePoints.split('-').map((codePoint) => parseInt(codePoint, 16));
|
||||
if (points.some((point) => !Number.isInteger(point) || point < 0 || point > 0x10ffff)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const emoji = String.fromCodePoint(...points);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = EMOJI_RENDER_SIZE;
|
||||
canvas.height = EMOJI_RENDER_SIZE;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
return '';
|
||||
}
|
||||
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.font = `${Math.floor(EMOJI_RENDER_SIZE * 0.8)}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", "Noto Emoji", sans-serif`;
|
||||
ctx.fillText(emoji, EMOJI_RENDER_SIZE / 2, EMOJI_RENDER_SIZE / 2);
|
||||
|
||||
return canvas.toDataURL('image/png');
|
||||
};
|
||||
|
||||
const pdfStyles = StyleSheet.create({
|
||||
page: {
|
||||
fontFamily: NOTO_SANS_FONTS.map((font) => font.name),
|
||||
},
|
||||
messageHeader: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-end',
|
||||
gap: 10,
|
||||
},
|
||||
username: {
|
||||
color: '#000',
|
||||
fontSize: 14,
|
||||
},
|
||||
dateTime: {
|
||||
color: '#aaa',
|
||||
fontSize: 12,
|
||||
},
|
||||
threadMessagesCount: {
|
||||
color: '#000',
|
||||
fontSize: 14,
|
||||
},
|
||||
threadMessage: {
|
||||
color: '#555',
|
||||
fontSize: 12,
|
||||
...leftTab,
|
||||
},
|
||||
message: {
|
||||
color: '#555',
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
|
||||
export const useExportMessagesAsPDFMutation = () => {
|
||||
const { t } = useTranslation();
|
||||
const chatopsUsername = useSetting('Chatops_Username');
|
||||
const formatDateAndTime = useFormatDateAndTime();
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
const absoluteUrl = useAbsoluteUrl();
|
||||
|
||||
useEffect(() => {
|
||||
for (const font of NOTO_SANS_FONTS) {
|
||||
Font.register({
|
||||
family: font.name,
|
||||
fonts: [{ src: absoluteUrl(font.fontSrc) }],
|
||||
});
|
||||
}
|
||||
// Manually build the emoji data to avoid relying on a cdn
|
||||
// https://react-pdf.org/fonts#registeremojisource
|
||||
Font.registerEmojiSource({
|
||||
withVariationSelectors: true,
|
||||
builder: renderEmojiToDataURL,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (messageIds: IMessage['_id'][]) => {
|
||||
const parseMessage = (msg: IMessage) => {
|
||||
const messageType = MessageTypes.getType(msg);
|
||||
if (messageType) {
|
||||
return messageType.text(t, msg);
|
||||
}
|
||||
if (msg.u && msg.u.username === chatopsUsername) {
|
||||
msg.html = msg.msg;
|
||||
return msg.html;
|
||||
}
|
||||
msg.html = msg.msg;
|
||||
if (msg.html.trim() !== '') {
|
||||
msg.html = escapeHTML(msg.html);
|
||||
}
|
||||
return msg.html;
|
||||
};
|
||||
|
||||
const messages = Messages.state.filter((record) => messageIds.includes(record._id)).sort((a, b) => a.ts.getTime() - b.ts.getTime());
|
||||
|
||||
const jsx = (
|
||||
<Document>
|
||||
<Page size='A4'>
|
||||
<View style={{ ...pdfStyles.page, margin: 10 }}>
|
||||
{messages.map((message) => {
|
||||
const dateTime = formatDateAndTime(message.ts);
|
||||
return (
|
||||
<View key={message._id} style={{ marginBottom: 10 }}>
|
||||
<View style={pdfStyles.messageHeader}>
|
||||
<Text style={{ ...(message.tmid ? leftTab : {}), ...pdfStyles.username }}>{message.u.username}</Text>
|
||||
<Text style={pdfStyles.dateTime}>{dateTime}</Text>
|
||||
{message.tcount && <Text style={pdfStyles.threadMessagesCount}>{`${message.tcount} ${t('thread_messages')}`}</Text>}
|
||||
</View>
|
||||
<Text style={message.tmid ? pdfStyles.threadMessage : pdfStyles.message}>{parseMessage(message)}</Text>
|
||||
{message.attachments?.map((attachment: MessageAttachmentDefault, index) => (
|
||||
<View key={index}>
|
||||
{attachment.description && <Text style={pdfStyles.message}>{attachment.description}</Text>}
|
||||
{attachment.image_url && <Image src={attachment.title_link} style={attachment.image_dimensions} />}
|
||||
<Text style={pdfStyles.message}>{attachment.title}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
|
||||
const instance = pdf();
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const callback = async () => {
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(await instance.toBlob());
|
||||
link.download = `exportedMessages-${new Date().toISOString()}.pdf`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(link.href);
|
||||
resolve();
|
||||
};
|
||||
|
||||
try {
|
||||
instance.on('change', callback);
|
||||
instance.updateContainer(jsx);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
} finally {
|
||||
instance.removeListener('change', callback);
|
||||
}
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
},
|
||||
onSuccess: () => {
|
||||
dispatchToastMessage({ type: 'success', message: t('Messages_exported_successfully') });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const useRoomExportMutation = () => {
|
||||
const { t } = useTranslation();
|
||||
const roomsExport = useEndpoint('POST', '/v1/rooms.export');
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: roomsExport,
|
||||
onSuccess: () => {
|
||||
dispatchToastMessage({
|
||||
type: 'success',
|
||||
message: t('Your_email_has_been_queued_for_sending'),
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
dispatchToastMessage({
|
||||
type: 'error',
|
||||
message: error,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user