Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/tidy-colts-think.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@rocket.chat/meteor": patch
"@rocket.chat/i18n": patch
---

Introduces PDF file as an export type for room messages
Original file line number Diff line number Diff line change
Expand Up @@ -250,4 +250,5 @@ export const permissions = [
{ _id: 'view-moderation-console', roles: ['admin'] },
{ _id: 'manage-moderation-actions', roles: ['admin'] },
{ _id: 'bypass-time-limit-edit-and-delete', roles: ['bot', 'app'] },
{ _id: 'export-messages-as-pdf', roles: ['admin', 'user'] },
];
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ import {
Callout,
} from '@rocket.chat/fuselage';
import { useAutoFocus } from '@rocket.chat/fuselage-hooks';
import { usePermission } 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 { validateEmail } from '../../../../../lib/emailValidator';
import {
Expand All @@ -41,7 +43,7 @@ export type ExportMessagesFormValues = {
type: 'email' | 'file' | 'download';
dateFrom: string;
dateTo: string;
format: 'html' | 'json';
format: 'html' | 'json' | 'pdf';
toUsers: string[];
additionalEmails: string;
messagesCount: number;
Expand All @@ -51,6 +53,7 @@ export type ExportMessagesFormValues = {
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;
Expand Down Expand Up @@ -92,13 +95,21 @@ const ExportMessages = () => {
[t],
);

const outputOptions = useMemo<SelectOption[]>(
() => [
const outputOptions = useMemo<SelectOption[]>(() => {
const options: SelectOption[] = [
['html', t('HTML')],
['json', t('JSON')],
],
[t],
);
];

if (pfdExportPermission) {
options.push(['pdf', t('PDF')]);
}

return options;
}, [t, pfdExportPermission]);

// Remove HTML from download options
const downloadOutputOptions = outputOptions.slice(1);

const roomExportMutation = useRoomExportMutation();
const downloadExportMutation = useDownloadExportMutation();
Expand All @@ -108,6 +119,12 @@ const ExportMessages = () => {

const { type, toUsers } = watch();

useEffect(() => {
if (type === 'email') {
setValue('format', 'html');
}
}, [type, setValue]);

useEffect(() => {
if (type !== 'file') {
selectedMessageStore.setIsSelecting(true);
Expand All @@ -119,24 +136,24 @@ const ExportMessages = () => {
}, [type, selectedMessageStore]);

useEffect(() => {
if (type === 'email') {
setValue('format', 'html');
}

if (type === 'download') {
setValue('format', 'json');
}

setValue('messagesCount', messageCount, { shouldDirty: true });
}, [type, setValue, messageCount]);
}, [messageCount, setValue]);

const { mutate: exportAsPDF } = useExportMessagesAsPDFMutation();

const handleExport = async ({ type, toUsers, dateFrom, dateTo, format, subject, additionalEmails }: ExportMessagesFormValues) => {
const messages = selectedMessageStore.getSelectedMessages();

if (type === 'download') {
return downloadExportMutation.mutateAsync({
mids: messages,
});
if (format === 'pdf') {
return exportAsPDF(messages);
}

if (format === 'json') {
return downloadExportMutation.mutateAsync({
mids: messages,
});
}
}

if (type === 'file') {
Expand All @@ -145,7 +162,7 @@ const ExportMessages = () => {
type: 'file',
...(dateFrom && { dateFrom }),
...(dateTo && { dateTo }),
format,
format: format as 'html' | 'json',
});
}

Expand Down Expand Up @@ -207,9 +224,9 @@ const ExportMessages = () => {
<Select
{...field}
id={formatField}
disabled={type === 'email' || type === 'download'}
disabled={type === 'email'}
placeholder={t('Format')}
options={outputOptions}
options={type === 'download' ? downloadOutputOptions : outputOptions}
/>
)}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { Document, Page, pdf, Text, View } from '@react-pdf/renderer';
import type { IMessage } from '@rocket.chat/core-typings';
import { escapeHTML } from '@rocket.chat/string-helpers';
import { useSetting } from '@rocket.chat/ui-contexts';
import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';

import { Messages } from '../../../../../app/models/client';
import { MessageTypes } from '../../../../../app/ui-utils/lib/MessageTypes';
import { useFormatDateAndTime } from '../../../../hooks/useFormatDateAndTime';

export const useExportMessagesAsPDFMutation = () => {
const { t } = useTranslation();
const chatopsUsername = useSetting('Chatops_Username');
const formatDateAndTime = useFormatDateAndTime();

return useMutation({
mutationFn: async (messageIds: IMessage['_id'][]) => {
const parseMessage = (msg: IMessage) => {
const messageType = MessageTypes.getType(msg);
if (messageType) {
if (messageType.template) {
// Render message
return;
}
if (messageType.message) {
const data = (typeof messageType.data === 'function' && messageType.data(msg)) || {};
return t(messageType.message, data);
}
}
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={{ margin: 10 }}>
{messages.map((message) => {
const dateTime = formatDateAndTime(message.ts);
return (
<Text key={message._id} style={{ marginBottom: 5 }}>
<Text style={{ color: '#555', fontSize: 14 }}>{message.u.username}</Text>{' '}
<Text style={{ color: '#aaa', fontSize: 12 }}>{dateTime}</Text>
<Text>{'\n'}</Text>
{parseMessage(message)}
</Text>
);
})}
</View>
</Page>
</Document>
);

const instance = pdf();

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

try {
instance.on('change', callback);
instance.updateContainer(jsx);
} finally {
instance.removeListener('change', callback);
}
},
});
};
47 changes: 26 additions & 21 deletions apps/meteor/server/services/calendar/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,18 +252,19 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
nextProcessTime = nextEndTime;
} else {
// This should never happen due to the earlier check, but just in case
logger.error(`Unexpected state: nextStartEvent=${nextStartEvent}, nextEndTime=${nextEndTime}`);
return;
}

await cronJobs.addAtTimestamp(schedulerJobId, nextProcessTime, async () => this.processStatusChangesAtTime());
logger.debug(`Next status change scheduled for ${nextProcessTime}`);
await cronJobs.addAtTimestamp(schedulerJobId, nextProcessTime, async () => this.processStatusChangesAtTime(nextProcessTime));
}

private async processStatusChangesAtTime(): Promise<void> {
const processTime = new Date();

private async processStatusChangesAtTime(processTime: Date): Promise<void> {
const eventsStartingNow = await CalendarEvent.findEventsStartingNow({ now: processTime, offset: 5000 }).toArray();
for await (const event of eventsStartingNow) {
if (event.busy === false) {
logger.debug(`Not processing event start for user ${event.uid}`, event);
continue;
}
await this.processEventStart(event);
Expand All @@ -272,6 +273,7 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
const eventsEndingNow = await CalendarEvent.findEventsEndingNow({ now: processTime, offset: 5000 }).toArray();
for await (const event of eventsEndingNow) {
if (event.busy === false) {
logger.debug(`Not processing event end for user ${event.uid}`, event);
continue;
}
await this.processEventEnd(event);
Expand All @@ -290,20 +292,13 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
return;
}

const overlappingEvents = await CalendarEvent.findOverlappingEvents(event._id, event.uid, event.startTime, event.endTime)
.sort({ startTime: -1 })
.toArray();
const previousStatus = overlappingEvents.at(0)?.previousStatus ?? user.status;

if (previousStatus) {
await CalendarEvent.updateEvent(event._id, { previousStatus });
if (user.status) {
await CalendarEvent.updateEvent(event._id, { previousStatus: user.status });
}

logger.debug(`Setting status for user ${event.uid} to BUSY for event ${event._id}`);
await applyStatusChange({
eventId: event._id,
uid: event.uid,
startTime: event.startTime,
endTime: event.endTime,
status: UserStatus.BUSY,
});
}
Expand All @@ -318,20 +313,30 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
return;
}

const overlappingEvents = await CalendarEvent.findOverlappingEvents(event._id, event.uid, event.startTime, event.endTime).toArray();
const earliestOverlappingEvent = overlappingEvents.reduce(
(earliest, current) => {
if (!current.endTime) {
return earliest;
}
return current.startTime.getTime() < earliest.startTime.getTime() ? current : earliest;
},
overlappingEvents.at(0) ?? event,
);

const { previousStatus } = earliestOverlappingEvent;

// Only restore status if:
// 1. The current status is BUSY (meaning it was set by our system, not manually changed by user)
// 2. We have a previousStatus stored from before the event started

if (user.status === UserStatus.BUSY && event.previousStatus && event.previousStatus !== user.status) {
if (user.status !== previousStatus) {
logger.debug(`Restoring status for user ${event.uid} from ${user.status} to ${previousStatus} after event ${event._id}`);
await applyStatusChange({
eventId: event._id,
uid: event.uid,
startTime: event.startTime,
endTime: event.endTime,
status: event.previousStatus,
status: previousStatus,
});
} else {
logger.debug(`Not restoring status for user ${event.uid}: current=${user.status}, stored=${event.previousStatus}`);
logger.debug(`Not restoring status for user ${event.uid} after event ${event._id}, current status is already ${user.status}`);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,27 +1,9 @@
import { api } from '@rocket.chat/core-services';
import { UserStatus } from '@rocket.chat/core-typings';
import type { ICalendarEvent, IUser } from '@rocket.chat/core-typings';
import { Logger } from '@rocket.chat/logger';
import type { IUser } from '@rocket.chat/core-typings';
import { Users } from '@rocket.chat/models';

const logger = new Logger('Calendar');

export async function applyStatusChange({
eventId,
uid,
startTime,
endTime,
status,
}: {
eventId: ICalendarEvent['_id'];
uid: IUser['_id'];
startTime: Date;
endTime?: Date;
status?: UserStatus;
shouldScheduleRemoval?: boolean;
}): Promise<void> {
logger.debug(`Applying status change for event ${eventId} at ${startTime} ${endTime ? `to ${endTime}` : ''} to ${status}`);

export async function applyStatusChange({ uid, status }: { uid: IUser['_id']; status?: UserStatus }): Promise<void> {
const user = await Users.findOneById(uid, { projection: { roles: 1, username: 1, name: 1, status: 1 } });
if (!user || user.status === UserStatus.OFFLINE) {
return;
Expand Down
1 change: 1 addition & 0 deletions apps/meteor/server/startup/migrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,6 @@ import './v315';
import './v316';
import './v317';
import './v318';
import './v319';

export * from './xrun';
9 changes: 9 additions & 0 deletions apps/meteor/server/startup/migrations/v319.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { upsertPermissions } from '../../../app/authorization/server/functions/upsertPermissions';
import { addMigration } from '../../lib/migrations';

addMigration({
version: 319,
async up() {
await upsertPermissions();
},
});
Loading
Loading