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,68 @@
<svg
width="768"
height="221"
viewBox="0 0 768 221"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M687.178 68.3765H699.452V85.2808H710.808V96.4023H699.452V126.607H710.193V137.528C708.453 137.935 706.305 138.141 703.849 138.141C692.7 138.141 687.174 132.326 687.174 120.692V68.3765H687.178Z"
fill="#F5455C"
/>
<path
d="M663.995 92.7303V85.2809H676.268V136.708H663.995V129.259C662.255 134.259 656.017 137.83 647.526 137.83C640.265 137.83 634.123 135.28 629.111 130.179C624.2 124.973 621.744 118.65 621.744 110.994C621.744 103.339 624.2 97.0159 629.111 91.9107C634.123 86.7048 640.26 84.1545 647.526 84.1545C656.013 84.1591 662.255 87.7305 663.995 92.7303ZM660.309 122.221C663.38 119.263 664.913 115.486 664.913 110.999C664.913 106.512 663.38 102.735 660.309 99.7722C657.344 96.8145 653.557 95.2806 649.16 95.2806C644.763 95.2806 641.183 96.8099 638.213 99.7722C635.349 102.73 633.917 106.507 633.917 110.999C633.917 115.491 635.349 119.263 638.213 122.221C641.178 125.179 644.758 126.713 649.16 126.713C653.562 126.713 657.344 125.179 660.309 122.221Z"
fill="#F5455C"
/>
<path
d="M209.724 136.708V85.2808H221.896V92.9317C224.352 87.5243 229.979 84.2598 237.24 84.2598C238.672 84.2598 239.903 84.3605 240.922 84.5665V96.4023C239.388 96.0955 237.649 95.8941 235.808 95.8941C227.317 95.8941 221.896 101.201 221.896 109.671V136.713H209.724V136.708Z"
fill="#F5455C"
/>
<path
d="M243.992 110.995C243.992 103.339 246.755 96.8099 252.176 91.604C257.597 86.3981 264.248 83.7471 272.124 83.7471C280 83.7471 286.651 86.3981 292.072 91.604C297.493 96.8099 300.256 103.339 300.256 110.995C300.256 118.645 297.493 125.179 292.072 130.38C286.651 135.586 280 138.237 272.124 138.237C264.248 138.237 257.597 135.586 252.176 130.38C246.755 125.179 243.992 118.645 243.992 110.995ZM283.479 122.524C286.55 119.36 288.083 115.582 288.083 110.995C288.083 106.402 286.55 102.625 283.479 99.5662C280.409 96.4024 276.627 94.8731 272.124 94.8731C267.52 94.8731 263.738 96.4024 260.667 99.5662C257.702 102.629 256.165 106.402 256.165 110.995C256.165 115.587 257.698 119.36 260.667 122.524C263.738 125.587 267.52 127.116 272.124 127.116C276.627 127.116 280.409 125.587 283.479 122.524Z"
fill="#F5455C"
/>
<path
d="M351.3 88.852V101.095C346.903 96.9104 341.583 94.7676 335.446 94.7676C330.641 94.7676 326.647 96.2969 323.48 99.36C320.309 102.423 318.776 106.301 318.776 110.889C318.776 115.481 320.309 119.359 323.48 122.418C326.652 125.481 330.641 127.01 335.446 127.01C341.689 127.01 347.004 124.868 351.3 120.683V132.926C347.004 136.397 341.482 138.132 334.73 138.132C326.753 138.132 320.102 135.582 314.682 130.376C309.261 125.17 306.599 118.741 306.599 110.884C306.599 103.027 309.256 96.5991 314.682 91.3932C320.102 86.1873 326.753 83.637 334.73 83.637C341.377 83.6461 346.903 85.3814 351.3 88.852Z"
fill="#F5455C"
/>
<path
d="M360.407 136.708V68.3765H372.58V107.217L389.971 85.2762H403.883L384.344 109.868L405.517 136.703H391.197L372.58 112.927V136.703H360.407V136.708Z"
fill="#F5455C"
/>
<path
d="M405.824 110.994C405.824 102.931 408.28 96.4023 413.292 91.5031C418.304 86.5033 424.749 84.0537 432.524 84.0537C439.992 84.0537 446.028 86.5033 450.631 91.5031C455.336 96.4023 457.691 102.524 457.691 109.767C457.691 111.297 457.59 112.83 457.484 114.154H417.997C418.405 122.317 424.441 127.523 433.548 127.523C441.63 127.523 447.767 125.586 452.063 121.603V133.031C447.051 136.502 440.708 138.237 433.038 138.237C424.955 138.237 418.309 135.788 413.297 130.994C408.284 126.095 405.829 119.566 405.829 111.503V110.994H405.824ZM445.206 105.486C445.206 102.528 443.98 99.8729 441.525 97.7301C439.069 95.5873 436.104 94.4655 432.524 94.4655C428.737 94.4655 425.465 95.5873 422.706 97.7301C419.943 99.8729 418.511 102.423 418.309 105.486H445.206Z"
fill="#F5455C"
/>
<path
d="M466.944 68.3765H479.218V85.2808H490.573V96.4023H479.218V126.607H489.958V137.528C488.218 137.935 486.07 138.141 483.615 138.141C472.466 138.141 466.939 132.326 466.939 120.692V68.3765H466.944Z"
fill="#F5455C"
/>
<path
d="M502.713 123.215C498.642 123.215 495.342 126.493 495.342 130.536C495.342 134.579 498.642 137.857 502.713 137.857C506.785 137.857 510.085 134.579 510.085 130.536C510.085 126.493 506.785 123.215 502.713 123.215Z"
fill="#F5455C"
/>
<path
d="M558.22 88.852V101.095C553.823 96.9104 548.503 94.7676 542.366 94.7676C537.561 94.7676 533.567 96.2969 530.4 99.36C527.229 102.423 525.696 106.301 525.696 110.889C525.696 115.481 527.229 119.359 530.4 122.418C533.572 125.481 537.561 127.01 542.366 127.01C548.604 127.01 553.924 124.868 558.22 120.683V132.926C553.924 136.397 548.402 138.132 541.65 138.132C533.673 138.132 527.022 135.582 521.601 130.376C516.181 125.17 513.519 118.741 513.519 110.884C513.519 103.027 516.176 96.5991 521.601 91.3932C527.022 86.1873 533.673 83.637 541.65 83.637C548.297 83.6461 553.823 85.3814 558.22 88.852Z"
fill="#F5455C"
/>
<path
d="M567.326 136.708V68.3765H579.499V92.4234C581.853 87.5243 587.476 84.2597 594.535 84.2597C606.4 84.2597 613.359 92.1167 613.359 104.873V136.708H601.186V106.603C601.186 99.5615 597.399 95.1752 590.854 95.1752C584.203 95.1752 579.499 100.074 579.499 107.112V136.703H567.326V136.708Z"
fill="#F5455C"
/>
<path
d="M174.643 91.8055C171.307 86.6179 166.629 82.0255 160.75 78.152C149.39 70.6796 134.463 66.5635 118.72 66.5635C113.46 66.5635 108.277 67.0213 103.247 67.9279C100.126 64.9197 96.4767 62.2138 92.612 60.0755C78.273 52.9191 65.6323 55.5885 59.2477 57.8824C57.1501 58.6378 56.5029 61.298 58.0543 62.896C62.557 67.5479 70.0065 76.7418 68.1751 85.1023C61.0515 92.3824 57.1914 101.155 57.1914 110.289C57.1914 119.598 61.0515 128.37 68.1705 135.646C70.0019 144.006 62.5525 153.205 58.0497 157.857C56.5029 159.455 57.1455 162.11 59.2431 162.866C65.6277 165.164 78.2638 167.834 92.6074 160.677C96.4721 158.539 100.121 155.833 103.242 152.825C108.273 153.731 113.455 154.189 118.715 154.189C134.463 154.189 149.39 150.078 160.745 142.605C166.625 138.732 171.302 134.144 174.639 128.952C178.357 123.174 180.239 116.951 180.239 110.468C180.243 103.801 178.357 97.5883 174.643 91.8055ZM118.077 143.397C111.27 143.397 104.78 142.518 98.8635 140.93L94.5398 145.092C92.1897 147.353 89.4358 149.4 86.5625 151.012C82.7574 152.875 78.9982 153.896 75.2804 154.203C75.4915 153.823 75.6843 153.438 75.8908 153.054C80.2237 145.087 81.3942 137.926 79.3975 131.575C72.3107 126.003 68.0604 118.87 68.0604 111.1C68.0604 93.266 90.4547 78.8067 118.077 78.8067C145.699 78.8067 168.098 93.266 168.098 111.1C168.094 128.943 145.699 143.397 118.077 143.397Z"
fill="#F5455C"
/>
<path
d="M94.1449 103.678C90.0736 103.678 86.7734 106.956 86.7734 110.999C86.7734 115.042 90.0736 118.32 94.1449 118.32C98.2162 118.32 101.516 115.042 101.516 110.999C101.516 106.956 98.2162 103.678 94.1449 103.678Z"
fill="#F5455C"
/>
<path
d="M117.875 103.678C113.804 103.678 110.504 106.956 110.504 110.999C110.504 115.042 113.804 118.32 117.875 118.32C121.947 118.32 125.247 115.042 125.247 110.999C125.247 106.956 121.947 103.678 117.875 103.678Z"
fill="#F5455C"
/>
<path
d="M141.605 103.678C137.534 103.678 134.233 106.956 134.233 110.999C134.233 115.042 137.534 118.32 141.605 118.32C145.676 118.32 148.976 115.042 148.976 110.999C148.976 106.956 145.672 103.678 141.605 103.678Z"
fill="#F5455C"
/>
</svg>

After

Width:  |  Height:  |  Size: 7.2 KiB

View File

@@ -0,0 +1,3 @@
declare const path: string;
export = path;

View File

@@ -0,0 +1,32 @@
import { resolve } from 'node:path';
import baseConfig from '@rocket.chat/storybook-config/main';
import webpack from 'webpack';
export default baseConfig({
staticDirs: ['../src/public'],
webpackFinal: (config) => {
// This is only needed because of Rocket.Chat's icon font.
config.resolve = {
...config.resolve,
roots: [...(config.resolve?.roots ?? []), resolve(__dirname, '../../../../apps/meteor/public')],
fallback: {
...config.resolve?.fallback,
buffer: require.resolve('buffer/'),
path: require.resolve('path-browserify'),
},
};
// Webpack 5 does not handle `node:` URI imports out of the box. Strip the
// prefix so the corresponding `resolve.fallback` entries (above) kick in.
config.plugins = [
...(config.plugins ?? []),
new webpack.NormalModuleReplacementPlugin(/^node:/, (resource: { request: string }) => {
resource.request = resource.request.replace(/^node:/, '');
}),
];
return config;
},
});

View File

@@ -0,0 +1,27 @@
import { parameters, decorators } from '@rocket.chat/storybook-config/preview';
import type { Preview } from '@storybook/react';
import '../../../../apps/meteor/app/theme/client/main.css';
import 'highlight.js/styles/github.css';
import logo from './logo.svg';
const preview: Preview = {
parameters: {
...parameters,
darkMode: {
...parameters.darkMode,
dark: {
...parameters.darkMode?.dark,
brandImage: logo,
},
light: {
...parameters.darkMode?.light,
brandImage: logo,
},
},
},
decorators: [...decorators],
tags: ['autodocs'],
};
export default preview;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,56 @@
# @rocket.chat/pdf-worker
This package is a PDF worker for Rocket.Chat. It allows for the rendering of PDFs within the Rocket.Chat application. `@react-pdf/renderer` is used as the PDF renderer, as it provides a React-based solution for rendering PDFs, making it easy to integrate into the existing React codebase of Rocket.Chat.
## Installation
To install this package, you can use yarn:
```
yarn add @rocket.chat/pdf-worker
yarn install
```
## Usage
To use this package, you will need to import it in your project and use the provided PDF renderer.
```
import { PdfWorker } from '@rocket.chat/pdf-worker';
const PdfWorker = new PdfWorker();
PdfWorker.render('template-mode');
```
## Development
If you wish to contribute to the development of this package, you can clone the repository and run the following commands:
```
yarn dev
```
This will start a development server and allow you to make changes to the code.
## Testing
You can run the tests for this package with the following command:
```
yarn test
```
## Storybook
You can also run Storybook to see the components in action and debug during development:
```
yarn storybook
```
This will start a development server and allow you to see the different components and their states. It also provides a visual representation of the components and how they will look in the final application, making it easy to debug and develop the templates.
## Additional Note
Please refer to the [official documentation](https://docs.rocket.chat/) of @rocket.chat/pdf-worker for more information about this package.

View File

@@ -0,0 +1,23 @@
import client from '@rocket.chat/jest-presets/client';
import type { Config } from 'jest';
export default {
projects: [
{
displayName: 'client',
preset: client.preset,
setupFilesAfterEnv: [...client.setupFilesAfterEnv],
modulePathIgnorePatterns: ['<rootDir>/src/worker.spec.ts'],
},
{
displayName: 'worker',
preset: client.preset,
setupFilesAfterEnv: [...client.setupFilesAfterEnv],
moduleNameMapper: {
'^fontkit($|/.+)': '<rootDir>/../../../node_modules/fontkit$1', // needed to a weird bug related to module resolution in SWC
},
transformIgnorePatterns: ['/node_modules/(?!(yoga-layout|@react-pdf|color-string|color-name)/)'],
modulePathIgnorePatterns: ['<rootDir>/src/strategies/', '<rootDir>/src/templates/'],
},
],
} satisfies Config;

View File

@@ -0,0 +1,55 @@
{
"name": "@rocket.chat/pdf-worker",
"version": "0.3.36",
"private": true,
"main": "./dist/index.js",
"typings": "./dist/index.d.ts",
"files": [
"/dist"
],
"scripts": {
"build": "tsc -p tsconfig.build.json && cp -r src/public dist/public",
"dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"storybook": "storybook dev -p 6006",
"test": "jest",
"testunit": "jest",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@react-pdf/renderer": "~4.5.1",
"@rocket.chat/core-typings": "workspace:^",
"@rocket.chat/fuselage-tokens": "^0.34.0",
"emojibase-data": "^16.0.3",
"moment": "^2.30.1",
"moment-timezone": "^0.5.48",
"react": "~19.2.8",
"react-i18next": "~13.2.2"
},
"devDependencies": {
"@rocket.chat/jest-presets": "workspace:~",
"@rocket.chat/message-parser": "workspace:~",
"@rocket.chat/storybook-config": "workspace:~",
"@rocket.chat/tsconfig": "workspace:*",
"@storybook/react": "^9.1.20",
"@storybook/react-webpack5": "^9.1.20",
"@testing-library/dom": "~10.4.1",
"@testing-library/react": "~16.3.3",
"@types/jest": "~30.0.0",
"@types/react": "~19.2.18",
"@types/react-dom": "~19.2.5",
"buffer": "~6.0.3",
"emojibase": "^16.0.0",
"eslint": "~9.39.5",
"i18next": "~23.4.9",
"jest": "~30.2.0",
"path-browserify": "^1.0.1",
"react-dom": "~19.2.8",
"storybook": "^9.1.20",
"typescript": "~5.9.3"
},
"volta": {
"extends": "../../../package.json"
}
}

4
ee/packages/pdf-worker/src/css.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
// TS7 errors on side-effect imports of `.css` (TS2882) without a declaration.
declare module '*.css' {
export = undefined;
}

View File

@@ -0,0 +1,45 @@
import type { i18n } from 'i18next';
import { ChatTranscript } from './strategies/ChatTranscript';
import type { IStrategy } from './types/IStrategy';
import type { WorkerData } from './types/WorkerData';
export type Templates = 'chat-transcript';
export type { Quote, MessageData, WorkerData } from './types/WorkerData';
export class PdfWorker {
protected validMimeTypes = ['image/jpeg', 'image/png', 'image/jpg'];
readonly worker: IStrategy;
constructor(public readonly mode: Templates) {
if (!mode) {
throw new Error('Invalid mode');
}
this.mode = mode;
this.worker = this.getWorkerClass();
}
private getWorkerClass(): IStrategy {
switch (this.mode) {
case 'chat-transcript':
return new ChatTranscript();
default:
throw new Error('Invalid mode');
}
}
isMimeTypeValid(mimeType?: string): boolean {
if (!mimeType) {
return false;
}
return this.validMimeTypes.includes(mimeType?.toLowerCase());
}
async renderToStream({ data, i18n }: { data: WorkerData; i18n: i18n }): Promise<NodeJS.ReadableStream> {
const parsedData = this.worker.parseTemplateData(data, i18n);
return this.worker.renderTemplate(parsedData);
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,76 @@
import i18next from 'i18next';
import moment from 'moment-timezone';
import { ChatTranscript } from './ChatTranscript';
import { invalidData, validData, newDayData, sameDayData } from '../templates/ChatTranscript/ChatTranscript.fixtures';
jest.mock('../templates/ChatTranscript', () => {
return {
exportTranscript: jest.fn(() => Promise.resolve()),
};
});
beforeAll(() => {
i18next.init({
lng: 'en',
resources: {
en: {
translation: {
transcript: 'Transcript',
visitor: 'Visitor',
agent: 'Agent',
date: 'Date',
time: 'Time',
},
},
},
});
});
describe('Strategies/ChatTranscript', () => {
let chatTranscript: ChatTranscript;
beforeEach(() => {
chatTranscript = new ChatTranscript();
});
it('should throws an error if data is not valid', () => {
expect(() => {
chatTranscript.renderTemplate(invalidData);
}).toThrow('Invalid data');
});
it('should creates a divider for a first message', () => {
const result = chatTranscript.parseTemplateData(validData, i18next);
expect(result.messages[0]).toHaveProperty('divider');
});
it('should creates a divider if message is from a new day', () => {
const result = chatTranscript.parseTemplateData(newDayData, i18next);
expect(result.messages[0]).toHaveProperty('divider');
expect(result.messages[1]).toHaveProperty(
'divider',
moment(newDayData.messages[1].ts).tz(newDayData.timezone).format(newDayData.dateFormat),
);
});
it('should not create a divider if message is from the same day', () => {
const result = chatTranscript.parseTemplateData(sameDayData, i18next);
expect(result.messages[0]).toHaveProperty('divider');
expect(result.messages[1]).not.toHaveProperty('divider');
});
it('should returns the correct translation value for a given key', () => {
const result = chatTranscript.parseTemplateData(validData, i18next);
expect(result.i18n.t('transcript')).toEqual('Transcript');
expect(result.i18n.t('visitor')).toEqual('Visitor');
expect(result.i18n.t('agent')).toEqual('Agent');
expect(result.i18n.t('date')).toEqual('Date');
expect(result.i18n.t('time')).toEqual('Time');
});
it('should parse the system message', () => {
const result = chatTranscript.parseTemplateData(validData, i18next);
expect(result.messages[2]).toHaveProperty('t', 'livechat-started');
});
});

View File

@@ -0,0 +1,108 @@
import type { i18n } from 'i18next';
import moment from 'moment-timezone';
import exportChatTranscript from '../templates/ChatTranscript';
import type { ChatTranscriptData, PDFMessage } from '../types/ChatTranscriptData';
import type { IStrategy } from '../types/IStrategy';
import type { MessageData, WorkerData } from '../types/WorkerData';
export class ChatTranscript implements IStrategy {
private isNewDay(current: MessageData, previous: MessageData | undefined, timezone: string): boolean {
return !previous || !moment(current.ts).tz(timezone).isSame(previous.ts, 'day');
}
private parserMessages(messages: MessageData[], dateFormat: string, timeAndDateFormat: string, timezone: string): PDFMessage[] {
return messages.map((message, index, arr): PDFMessage => {
const previousMessage = arr[index - 1];
const { ts, quotes, requestData, webRtcCallEndTs, ...rest } = message;
const isDivider = this.isNewDay(message, previousMessage, timezone);
const formattedTs = moment(ts).tz(timezone).format(timeAndDateFormat);
const formattedQuotes = quotes?.map((quote) => ({
...quote,
ts: moment(quote.ts).tz(timezone).format(timeAndDateFormat),
}));
const formattedRequestData = requestData
? {
...requestData,
visitor: requestData.visitor
? {
...requestData.visitor,
ts: moment(requestData.visitor.ts).tz(timezone).format(timeAndDateFormat),
lastChat: requestData.visitor.lastChat
? {
...requestData.visitor.lastChat,
ts: moment(requestData.visitor.lastChat.ts).tz(timezone).format(timeAndDateFormat),
}
: undefined,
lastAgent: requestData.visitor.lastAgent
? {
...requestData.visitor.lastAgent,
ts: moment(requestData.visitor.lastAgent.ts).tz(timezone).format(timeAndDateFormat),
}
: undefined,
externalIds: requestData.visitor.externalIds?.map((ext) => ({
...ext,
metadata: ext.metadata ? Object.fromEntries(Object.entries(ext.metadata).map(([key]) => [key, null])) : undefined,
})),
livechatData: requestData.visitor.livechatData
? Object.fromEntries(Object.entries(requestData.visitor.livechatData).map(([key]) => [key, null]))
: undefined,
_updatedAt: requestData.visitor._updatedAt
? moment(requestData.visitor._updatedAt).tz(timezone).format(timeAndDateFormat)
: '',
}
: undefined,
}
: undefined;
const formattedWebRtcCallEndTs = webRtcCallEndTs ? moment(webRtcCallEndTs).tz(timezone).format(timeAndDateFormat) : undefined;
return {
...rest,
ts: formattedTs,
quotes: formattedQuotes,
requestData: formattedRequestData,
webRtcCallEndTs: formattedWebRtcCallEndTs,
...(isDivider && { divider: moment(ts).tz(timezone).format(dateFormat) }),
};
});
}
private isChatTranscriptData = (data: unknown): data is ChatTranscriptData => {
return (
typeof data === 'object' &&
data !== null &&
'header' in data &&
'messages' in data &&
'i18n' in data &&
typeof data.header === 'object' &&
data.header !== null &&
'agent' in data.header &&
'visitor' in data.header &&
'siteName' in data.header &&
'date' in data.header &&
'time' in data.header
);
};
renderTemplate(data: ChatTranscriptData): Promise<NodeJS.ReadableStream> {
if (!this.isChatTranscriptData(data)) {
throw new Error('Invalid data');
}
return exportChatTranscript(data);
}
parseTemplateData(data: WorkerData, i18n: i18n): ChatTranscriptData {
return {
header: {
visitor: data.visitor,
agent: data.agent,
siteName: data.siteName,
date: `${moment(data.closedAt).tz(data.timezone).format(String(data.dateFormat))}`,
time: `${moment(data.closedAt).tz(data.timezone).format('H:mm:ss')} ${data.timezone}`,
},
messages: this.parserMessages(data.messages, data.dateFormat, data.timeAndDateFormat, data.timezone),
i18n,
};
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,40 @@
import { Font, PDFViewer } from '@react-pdf/renderer';
import type { Meta, StoryObj } from '@storybook/react';
import i18next from 'i18next';
import { ChatTranscriptPDF } from '.';
import { exampleData } from './ChatTranscript.fixtures';
import { ChatTranscript } from '../../strategies/ChatTranscript';
Font.register({
family: 'Inter',
fonts: [
{ src: '/inter400.ttf' },
{ src: '/inter400-italic.ttf', fontStyle: 'italic' },
{ src: '/inter500.ttf', fontWeight: 500 },
{ src: '/inter500-italic.ttf', fontWeight: 500, fontStyle: 'italic' },
{ src: '/inter700.ttf', fontWeight: 700 },
{ src: '/inter700-italic.ttf', fontWeight: 700, fontStyle: 'italic' },
],
});
Font.register({
family: 'FiraCode',
fonts: [{ src: '/fira-code700.ttf', fontWeight: 700 }],
});
Font.registerHyphenationCallback((word) => [word]);
export default {
component: ChatTranscriptPDF,
} satisfies Meta<typeof ChatTranscriptPDF>;
const data = new ChatTranscript().parseTemplateData(exampleData, i18next);
export const ChatTranscriptPDFTemplate: StoryObj<typeof ChatTranscriptPDF> = {
render: () => (
<PDFViewer width='100%' height='800'>
<ChatTranscriptPDF {...data} />
</PDFViewer>
),
};

View File

@@ -0,0 +1,30 @@
import { Text, View, StyleSheet } from '@react-pdf/renderer';
import colors from '@rocket.chat/fuselage-tokens/dist/colors.json';
const styles = StyleSheet.create({
wrapper: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
fontSize: 12,
fontWeight: 700,
},
line: {
flex: 1,
height: 2,
backgroundColor: colors.n200,
},
text: {
paddingHorizontal: 8,
},
});
export type DividerProps = { divider: string };
export const Divider = ({ divider }: DividerProps) => (
<View style={styles.wrapper} wrap={false}>
<View style={styles.line} />
<Text style={styles.text}>{divider}</Text>
<View style={styles.line} />
</View>
);

View File

@@ -0,0 +1,32 @@
import { render } from '@testing-library/react';
import type { ReactNode } from 'react';
import { invalidFile, validFile } from '../ChatTranscript.fixtures';
import { Files } from './Files';
jest.mock('@react-pdf/renderer', () => ({
StyleSheet: { create: () => ({ style: '' }) },
Image: () => <img src='' alt='' />,
Text: ({ children }: { children: ReactNode }) => <div>{children}</div>,
View: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
describe('components/Files', () => {
it('should renders file name and invalid message when buffer is null', () => {
const { getByText } = render(<Files files={[invalidFile]} invalidMessage='invalid' />);
const invalidText = getByText('invalid');
const fileName = getByText(invalidFile.name);
expect(invalidText).toBeInTheDocument();
expect(fileName).toBeInTheDocument();
});
it('should renders file name and image when buffer is not null', () => {
const { getByRole, getByText } = render(<Files files={[validFile]} invalidMessage='' />);
const image = getByRole('presentation');
const fileName = getByText(validFile.name);
expect(image).toBeInTheDocument();
expect(fileName).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,59 @@
import { Buffer } from 'node:buffer';
import { View, StyleSheet, Text, Image } from '@react-pdf/renderer';
import colors from '@rocket.chat/fuselage-tokens/dist/colors.json';
import { fontScale } from '@rocket.chat/fuselage-tokens/dist/typography.json';
import type { PDFFile } from '../../../types/ChatTranscriptData';
const styles = StyleSheet.create({
container: {
marginTop: 8,
width: '100%',
},
file: {
color: colors.n700,
marginBottom: 16,
flexDirection: 'column',
fontSize: fontScale.c1.fontSize,
width: '100%',
},
fileName: {
marginBottom: 6,
},
image: {
width: '100%',
maxHeight: 240,
objectFit: 'contain',
objectPosition: '0',
},
invalidMessage: {
backgroundColor: colors.n100,
textAlign: 'center',
borderColor: colors.n250,
borderWidth: 1,
borderRadius: 4,
paddingVertical: 8,
marginTop: 4,
},
});
export type FilesProps = { files: PDFFile[]; invalidMessage: string };
export const Files = ({ files, invalidMessage }: FilesProps) => (
<View style={styles.container}>
{files?.map((file, index) => (
<View style={styles.file} key={index} wrap>
<Text style={styles.fileName}>{file.name}</Text>
{file.buffer ? (
// Cache = false is required to avoid a bug in react-pdf
// Which causes the image to be duplicated when using buffers because of bad caching
// https://github.com/diegomura/react-pdf/issues/1805
<Image style={styles.image} src={Buffer.from(file.buffer)} cache={false} />
) : (
<Text style={styles.invalidMessage}>{invalidMessage}</Text>
)}
</View>
))}
</View>
);

View File

@@ -0,0 +1,70 @@
import { Text, View, StyleSheet } from '@react-pdf/renderer';
import colors from '@rocket.chat/fuselage-tokens/dist/colors.json';
import { fontScale } from '@rocket.chat/fuselage-tokens/dist/typography.json';
const styles = StyleSheet.create({
header: {
padding: 32,
borderRadius: 4,
backgroundColor: colors.n100,
marginBottom: 16,
},
headerText: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
borderBottomColor: colors.n200,
borderBottomWidth: 2,
marginBottom: 16,
paddingBottom: 16,
color: colors.n900,
},
pagination: {
fontSize: fontScale.c1.fontSize,
color: colors.n700,
},
title: {
fontSize: fontScale.h4.fontSize,
fontWeight: fontScale.h4.fontWeight,
},
subtitle: {
fontSize: fontScale.p2m.fontSize,
fontWeight: fontScale.p2m.fontWeight,
color: colors.n900,
},
container: {
fontSize: fontScale.c1.fontSize,
flexDirection: 'row',
flexWrap: 'wrap',
},
item: {
paddingTop: 8,
paddingRight: 10,
flexGrow: 1,
flexShrink: 1,
width: '50%',
},
key: {
fontWeight: fontScale.c2.fontWeight,
},
});
export type HeaderProps = { title: string; subtitle: string; values: { key: string; value: string }[] };
export const Header = ({ title, subtitle, values }: HeaderProps) => (
<View style={styles.header} fixed>
<View style={styles.headerText}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.pagination} render={({ pageNumber, totalPages }) => `${pageNumber}/${totalPages}`} />
</View>
<Text style={styles.subtitle}>{subtitle}</Text>
<View style={styles.container}>
{values.map((value, index) => (
<Text style={styles.item} key={index}>
<Text style={styles.key}>{value.key}: </Text>
<Text>{value.value}</Text>
</Text>
))}
</View>
</View>
);

View File

@@ -0,0 +1,68 @@
import { Text, View, StyleSheet } from '@react-pdf/renderer';
import { fontScale } from '@rocket.chat/fuselage-tokens/dist/typography.json';
import type { PDFMessage } from '../../../types/ChatTranscriptData';
import { Markup } from '../markup';
import { Divider } from './Divider';
import { Files } from './Files';
import { MessageHeader } from './MessageHeader';
import Quotes from './Quotes';
import { isSystemMessage, markupEntriesGreaterThan10, messageLongerThanPage, splitByTens } from './utils';
const styles = StyleSheet.create({
wrapper: {
marginBottom: 16,
paddingBottom: 16,
paddingHorizontal: 32,
},
message: {
marginTop: 1,
fontSize: fontScale.p2.fontSize,
},
systemMessage: {
fontStyle: 'italic',
},
});
const processMd = (message: PDFMessage) =>
splitByTens(message.md).map((chunk, index) => (
<View style={{ ...styles.message, ...(isSystemMessage(message) && styles.systemMessage) }} key={index}>
<Markup tokens={chunk as NonNullable<PDFMessage['md']>} />
</View>
));
const processMessage = (message: PDFMessage) => {
if (message.md) {
if (markupEntriesGreaterThan10(message.md)) {
return processMd(message);
}
return (
<View style={{ ...styles.message, ...(isSystemMessage(message) && styles.systemMessage) }}>
<Markup tokens={message.md}></Markup>
</View>
);
}
return (
<View style={{ ...styles.message, ...(isSystemMessage(message) && styles.systemMessage) }}>
<Text>{message.msg}</Text>
</View>
);
};
export type MessageProps = { message: PDFMessage; invalidFileMessage: string };
const Message = ({ message, invalidFileMessage }: MessageProps) => (
<View style={styles.wrapper} wrap={!!message.quotes || messageLongerThanPage(message.msg) || (message.files && message.files.length > 0)}>
{message.divider && <Divider divider={message.divider} />}
<MessageHeader name={message.u.name || message.u.username} time={message.ts} />
{processMessage(message)}
{message.quotes && <Quotes quotes={message.quotes} />}
{message.files && <Files files={message.files} invalidMessage={invalidFileMessage} />}
</View>
);
export default Message;

View File

@@ -0,0 +1,28 @@
import { Text, View, StyleSheet } from '@react-pdf/renderer';
import colors from '@rocket.chat/fuselage-tokens/dist/colors.json';
import { fontScale } from '@rocket.chat/fuselage-tokens/dist/typography.json';
const styles = StyleSheet.create({
header: {
flexDirection: 'row',
alignItems: 'center',
},
name: {
fontSize: fontScale.p2b.fontSize,
fontWeight: fontScale.p2b.fontWeight,
color: colors.n900,
},
time: {
fontSize: fontScale.c1.fontSize,
marginLeft: 4,
},
});
export type MessageHeaderProps = { name: string; time: string; light?: boolean };
export const MessageHeader = ({ name, time, light }: MessageHeaderProps) => (
<View style={styles.header} wrap={false}>
<Text style={styles.name}>{name}</Text>
<Text style={{ ...styles.time, color: light ? colors.n600 : colors.n700 }}>{time}</Text>
</View>
);

View File

@@ -0,0 +1,44 @@
import { render } from '@testing-library/react';
import { invalidFile, validFile, validMessage, validSystemMessage } from '../ChatTranscript.fixtures';
import { MessageList } from './MessageList';
jest.mock('@react-pdf/renderer', () => ({
Image: () => <div>Image</div>,
StyleSheet: { create: () => ({ style: '' }) },
Text: ({ children }: { children: string }) => <div>{children}</div>,
View: ({ children }: { children: string }) => <div>{children}</div>,
Files: ({ children }: { children: string }) => <div>{children}</div>,
}));
describe('components/MessageList', () => {
it('should render correctly', () => {
const { getByText } = render(<MessageList messages={[validMessage]} invalidFileMessage='' />);
expect(getByText(validMessage.msg)).toBeInTheDocument();
});
it('should render divider', () => {
const { getByText } = render(<MessageList messages={[{ ...validMessage, divider: 'divider' }]} invalidFileMessage='' />);
expect(getByText(validMessage.msg)).toBeInTheDocument();
expect(getByText('divider')).toBeInTheDocument();
});
it('should render file', () => {
const { getByText } = render(<MessageList messages={[{ ...validMessage, files: [validFile] }]} invalidFileMessage='' />);
expect(getByText(validMessage.msg)).toBeInTheDocument();
expect(getByText(validFile.name)).toBeInTheDocument();
});
it('should render invalid file message', () => {
const { getByText } = render(
<MessageList messages={[{ ...validMessage, files: [invalidFile] }]} invalidFileMessage='invalid message' />,
);
expect(getByText(validMessage.msg)).toBeInTheDocument();
expect(getByText('invalid message')).toBeInTheDocument();
});
it('should render valid system message', () => {
const { getByText } = render(<MessageList messages={[{ ...validSystemMessage, files: [] }]} invalidFileMessage='' />);
expect(getByText(validSystemMessage.t)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,12 @@
import Message from './Message';
import type { ChatTranscriptData } from '../../../types/ChatTranscriptData';
export type MessageListProps = { messages: ChatTranscriptData['messages']; invalidFileMessage: string };
export const MessageList = ({ messages, invalidFileMessage }: MessageListProps) => (
<>
{messages.map((message, index) => (
<Message invalidFileMessage={invalidFileMessage} message={message} key={index} />
))}
</>
);

View File

@@ -0,0 +1,49 @@
import { View, StyleSheet } from '@react-pdf/renderer';
import colors from '@rocket.chat/fuselage-tokens/dist/colors.json';
import { fontScale } from '@rocket.chat/fuselage-tokens/dist/typography.json';
import type { ReactNode } from 'react';
import type { PDFQuote } from '../../../types/ChatTranscriptData';
import { Markup } from '../markup';
import { MessageHeader } from './MessageHeader';
const styles = StyleSheet.create({
wrapper: {
backgroundColor: colors.n100,
borderWidth: 1,
borderColor: colors.n250,
borderLeftColor: colors.n600,
borderTopWidth: 1,
paddingLeft: 16,
paddingRight: 16,
},
quoteMessage: {
paddingTop: 6,
paddingBottom: 6,
fontSize: fontScale.p2.fontSize,
},
});
export type QuoteProps = {
quote: PDFQuote;
children: ReactNode;
index: number;
};
const Quote = ({ quote, children, index }: QuoteProps) => (
<View
style={{
...styles.wrapper,
marginTop: !index ? 4 : 16,
}}
>
<MessageHeader name={quote.name} time={quote.ts} light />
<View style={styles.quoteMessage}>
<Markup tokens={quote.md} />
</View>
{children}
</View>
);
export default Quote;

View File

@@ -0,0 +1,20 @@
import type { ReactNode } from 'react';
import Quote from './Quote';
import type { PDFQuote } from '../../../types/ChatTranscriptData';
export type QuotesProps = {
quotes: PDFQuote[];
};
const Quotes = ({ quotes }: QuotesProps) =>
quotes.reduceRight<ReactNode>(
(lastQuote, quote, index) => (
<Quote quote={quote} index={index}>
{lastQuote}
</Quote>
),
null,
);
export default Quotes;

View File

@@ -0,0 +1,49 @@
import { messageLongerThanPage, splitByTens, isSystemMessage, markupEntriesGreaterThan10 } from './utils';
import type { PDFMessage } from '../../../types/ChatTranscriptData';
describe('utils', () => {
it('should return true if message is longer than MAX_MSG_SIZE', () => {
const message = 'f'.repeat(10000);
expect(messageLongerThanPage(message)).toBe(true);
});
it('should return false if message is shorter than MAX_MSG_SIZE', () => {
const message = 'f'.repeat(1000);
expect(messageLongerThanPage(message)).toBe(false);
});
it('should return false if message is exactly MAX_MSG_SIZE', () => {
const message = 'f'.repeat(1200);
expect(messageLongerThanPage(message)).toBe(false);
});
it('should return true if message has more markdown elements than MAX_MD_ELEMENTS_PER_VIEW', () => {
const message = Array(11).fill({});
expect(markupEntriesGreaterThan10(message)).toBe(true);
});
it('should return false if message has less markdown elements than MAX_MD_ELEMENTS_PER_VIEW', () => {
const message = Array(10).fill({});
expect(markupEntriesGreaterThan10(message)).toBe(false);
});
it('should return false if message has exactly MAX_MD_ELEMENTS_PER_VIEW markdown elements', () => {
const message = Array(10).fill({});
expect(markupEntriesGreaterThan10(message)).toBe(false);
});
it('should split an array by groups of 10', () => {
const message = Array(11).fill({});
expect(splitByTens(message)).toEqual([Array(10).fill({}), Array(1).fill({})]);
});
it('should split an array by groups of 10', () => {
const message = Array(21).fill({});
expect(splitByTens(message)).toEqual([Array(10).fill({}), Array(10).fill({}), Array(1).fill({})]);
});
it('should split an array by groups of 10', () => {
const message = Array(8).fill({});
expect(splitByTens(message)).toEqual([Array(8).fill({})]);
});
it('should return true if message is a system message', () => {
const message = { t: 'livechat-started' } as PDFMessage;
expect(isSystemMessage(message)).toBe(true);
});
it('should return false if message is not a system message', () => {
const message = { msg: 'text' } as PDFMessage;
expect(isSystemMessage(message)).toBe(false);
});
});

View File

@@ -0,0 +1,19 @@
import type { PDFMessage } from '../../../types/ChatTranscriptData';
const MAX_MD_ELEMENTS_PER_VIEW = 10;
const MAX_MSG_SIZE = 1200;
export const messageLongerThanPage = (message: string | undefined) => (message?.length ?? 0) > MAX_MSG_SIZE;
// When a markup list is greater than 10 (magic number, but a reasonable small/big number) we're gonna split the markdown into multiple <View> element
// So react-pdf can split them evenly across pages
export const markupEntriesGreaterThan10 = (messageMd: unknown[] = []) => messageMd.length > MAX_MD_ELEMENTS_PER_VIEW;
export const splitByTens = (array: unknown[] = []): unknown[][] => {
const result = [];
for (let i = 0; i < array.length; i += 10) {
result.push(array.slice(i, i + 10));
}
return result;
};
export const isSystemMessage = (message: PDFMessage) => !!message.t;

View File

@@ -0,0 +1,83 @@
import * as path from 'node:path';
import ReactPDF, { Font, Document, Page, StyleSheet } from '@react-pdf/renderer';
import colors from '@rocket.chat/fuselage-tokens/dist/colors.json';
import { I18nextProvider, useTranslation } from 'react-i18next';
import { Header } from './components/Header';
import { MessageList } from './components/MessageList';
import type { ChatTranscriptData } from '../../types/ChatTranscriptData';
const FONT_PATH = path.resolve(__dirname, '../../public');
const styles = StyleSheet.create({
page: {
fontFamily: 'Inter',
lineHeight: 1.25,
color: colors.n800,
// ugh https://github.com/diegomura/react-pdf/issues/684
paddingBottom: 32,
},
wrapper: {
paddingHorizontal: 32,
},
message: {
wordWrap: 'break-word',
fontSize: 12,
marginBottom: 20,
textAlign: 'justify',
},
});
export type ChatTranscriptPDFProps = Omit<ChatTranscriptData, 'i18n'>;
export const ChatTranscriptPDF = ({ header, messages }: ChatTranscriptPDFProps) => {
const { t } = useTranslation();
const agentValue = header.agent?.name || header.agent?.username || t('Not_assigned');
const customerValue = header.visitor?.name || header.visitor?.username || t('Guest');
const dateValue = header.date;
const timeValue = header.time;
return (
<Document>
<Page size='A4' style={styles.page} wrap>
<Header
title={header.siteName}
subtitle={t('Chat_transcript')}
values={[
{ key: t('Agent'), value: agentValue },
{ key: t('Date'), value: dateValue },
{ key: t('Customer'), value: customerValue },
{ key: t('Time'), value: timeValue },
]}
/>
<MessageList messages={messages} invalidFileMessage={t('This_attachment_is_not_supported')} />
</Page>
</Document>
);
};
export default async ({ i18n, ...data }: ChatTranscriptData): Promise<NodeJS.ReadableStream> => {
Font.register({
family: 'Inter',
fonts: [
{ src: `${FONT_PATH}/inter400.ttf` },
{ src: `${FONT_PATH}/inter400-italic.ttf`, fontStyle: 'italic' },
{ src: `${FONT_PATH}/inter500.ttf`, fontWeight: 500 },
{ src: `${FONT_PATH}/inter500-italic.ttf`, fontWeight: 500, fontStyle: 'italic' },
{ src: `${FONT_PATH}/inter700.ttf`, fontWeight: 700 },
{ src: `${FONT_PATH}/inter700-italic.ttf`, fontWeight: 700, fontStyle: 'italic' },
],
});
Font.register({
family: 'FiraCode',
fonts: [{ src: `${FONT_PATH}/fira-code700.ttf`, fontWeight: 700 }],
});
Font.registerHyphenationCallback((word) => [word]);
return ReactPDF.renderToStream(
<I18nextProvider i18n={i18n}>
<ChatTranscriptPDF {...data} />
</I18nextProvider>,
);
};

View File

@@ -0,0 +1,19 @@
import { Text } from '@react-pdf/renderer';
import type * as MessageParser from '@rocket.chat/message-parser';
import type { ReactElement } from 'react';
import { unicodeToShortname } from '../emoji';
export type BigEmojiBlockProps = {
emoji: MessageParser.Emoji[];
};
const BigEmojiBlock = ({ emoji }: BigEmojiBlockProps): ReactElement => (
<Text>
{emoji.map((emoji, index) => (
<Text key={index}>{emoji.value ? `:${emoji.value?.value}:` : unicodeToShortname(emoji.unicode)}</Text>
))}
</Text>
);
export default BigEmojiBlock;

View File

@@ -0,0 +1,20 @@
import { Text, View } from '@react-pdf/renderer';
import type * as MessageParser from '@rocket.chat/message-parser';
import { codeStyles } from '../elements/CodeSpan';
export type CodeBlockProps = {
lines: MessageParser.CodeLine[];
};
const CodeBlock = ({ lines }: CodeBlockProps) => (
<View style={{ ...codeStyles.wrapper, padding: 8 }} wrap>
{lines.map((line, index) => (
<Text key={index} style={codeStyles.code}>
{line.value?.value || ' '}
</Text>
))}
</View>
);
export default CodeBlock;

View File

@@ -0,0 +1,18 @@
import { View } from '@react-pdf/renderer';
import { fontScale } from '@rocket.chat/fuselage-tokens/dist/typography.json';
import type * as MessageParser from '@rocket.chat/message-parser';
import InlineElements from '../elements/InlineElements';
type HeadingBlockProps = {
items?: MessageParser.Inlines[];
level?: 1 | 2 | 3 | 4;
};
const Header = ({ items = [], level = 1 }: HeadingBlockProps) => (
<View style={{ fontSize: fontScale[`h${level}`].fontSize, fontWeight: fontScale[`h${level}`].fontWeight }}>
<InlineElements>{items}</InlineElements>
</View>
);
export default Header;

View File

@@ -0,0 +1,33 @@
import { StyleSheet, Text, View } from '@react-pdf/renderer';
import type * as MessageParser from '@rocket.chat/message-parser';
import InlineElements from '../elements/InlineElements';
const styles = StyleSheet.create({
wrapper: {
marginTop: 4,
},
list: {
flexDirection: 'row',
},
number: {
fontWeight: 700,
marginHorizontal: 4,
},
});
export type OrderedListBlockProps = {
items: MessageParser.ListItem[];
};
const OrderedListBlock = ({ items }: OrderedListBlockProps) => (
<View style={styles.wrapper} wrap>
{items.map(({ value, number }, index) => (
<Text style={styles.list} key={index}>
<Text style={styles.number}>{number}.</Text> <InlineElements>{value}</InlineElements>
</Text>
))}
</View>
);
export default OrderedListBlock;

View File

@@ -0,0 +1,11 @@
import type * as MessageParser from '@rocket.chat/message-parser';
import InlineElements from '../elements/InlineElements';
export type ParagraphBlockProps = {
items: MessageParser.Inlines[];
};
const ParagraphBlock = ({ items }: ParagraphBlockProps) => <InlineElements>{items}</InlineElements>;
export default ParagraphBlock;

View File

@@ -0,0 +1,33 @@
import { StyleSheet, Text, View } from '@react-pdf/renderer';
import type * as MessageParser from '@rocket.chat/message-parser';
import InlineElements from '../elements/InlineElements';
const styles = StyleSheet.create({
wrapper: {
marginTop: 4,
},
list: {
display: 'flex',
flexDirection: 'row',
},
bullet: {
marginHorizontal: 4,
},
});
export type UnorderedListBlockProps = {
items: MessageParser.ListItem[];
};
const UnorderedListBlock = ({ items }: UnorderedListBlockProps) => (
<View style={styles.wrapper} wrap>
{items.map(({ value }, index) => (
<View style={styles.list} key={index}>
<Text style={styles.bullet}>•</Text>
<InlineElements>{value}</InlineElements>
</View>
))}
</View>
);
export default UnorderedListBlock;

View File

@@ -0,0 +1,64 @@
import { StyleSheet, View, Text } from '@react-pdf/renderer';
import type * as MessageParser from '@rocket.chat/message-parser';
import EmojiSpan from './EmojiSpan';
import ItalicSpan from './ItalicSpan';
import LinkSpan from './LinkSpan';
import StrikeSpan from './StrikeSpan';
const styles = StyleSheet.create({
bold: {
fontWeight: 700,
},
});
type MessageBlock =
| MessageParser.Emoji
| MessageParser.ChannelMention
| MessageParser.UserMention
| MessageParser.Link
| MessageParser.MarkupExcluding<MessageParser.Bold>
| MessageParser.InlineCode;
export type BoldSpanProps = {
children: MessageBlock[];
};
const BoldSpan = ({ children }: BoldSpanProps) => (
<>
{children.map((child, index) => {
if (child.type === 'LINK' || child.type === 'PLAIN_TEXT' || child.type === 'ITALIC' || child.type === 'STRIKE') {
return (
<View style={styles.bold} key={index}>
{renderBlockComponent(child, index)}
</View>
);
}
return renderBlockComponent(child, index);
})}
</>
);
const renderBlockComponent = (child: MessageBlock, index: number) => {
switch (child.type) {
case 'LINK':
return <LinkSpan key={index} label={Array.isArray(child.value.label) ? child.value.label : [child.value.label]} />;
case 'PLAIN_TEXT':
return <Text key={index}>{child.value}</Text>;
case 'STRIKE':
return <StrikeSpan key={index}>{child.value}</StrikeSpan>;
case 'ITALIC':
return <ItalicSpan key={index}>{child.value}</ItalicSpan>;
case 'EMOJI':
return <EmojiSpan key={index} {...child} />;
default:
return null;
}
};
export default BoldSpan;

View File

@@ -0,0 +1,37 @@
import { StyleSheet, Text } from '@react-pdf/renderer';
import colors from '@rocket.chat/fuselage-tokens/dist/colors.json';
export const codeStyles = StyleSheet.create({
wrapper: {
backgroundColor: colors.n100,
borderColor: colors.n250,
borderWidth: 1,
borderRadius: 4,
paddingHorizontal: 3,
paddingVertical: 1,
},
space: {
backgroundColor: colors.n100,
},
code: {
backgroundColor: colors.n100,
color: colors.n800,
fontSize: 13,
fontWeight: 700,
fontFamily: 'FiraCode',
},
});
export type CodeSpanProps = {
code: string;
};
const CodeSpan = ({ code }: CodeSpanProps) => (
<Text style={codeStyles.wrapper}>
<Text style={codeStyles.space}> </Text>
<Text style={codeStyles.code}>{code}</Text>
<Text style={codeStyles.space}> </Text>
</Text>
);
export default CodeSpan;

View File

@@ -0,0 +1,10 @@
import { Text } from '@react-pdf/renderer';
import type * as MessageParser from '@rocket.chat/message-parser';
import { unicodeToShortname } from '../emoji';
export type EmojiSpanProps = MessageParser.Emoji;
const EmojiSpan = (emoji: EmojiSpanProps) => <Text>{emoji.value ? `:${emoji.value?.value}:` : unicodeToShortname(emoji.unicode)}</Text>;
export default EmojiSpan;

View File

@@ -0,0 +1,54 @@
import { Text } from '@react-pdf/renderer';
import type * as MessageParser from '@rocket.chat/message-parser';
import BoldSpan from './BoldSpan';
import CodeSpan from './CodeSpan';
import EmojiSpan from './EmojiSpan';
import ItalicSpan from './ItalicSpan';
import LinkSpan from './LinkSpan';
import StrikeSpan from './StrikeSpan';
export type InlineElementsProps = {
children: MessageParser.Inlines[];
};
const InlineElements = ({ children }: InlineElementsProps) => (
<Text>
{children.map((child, index) => {
switch (child.type) {
case 'BOLD':
return <BoldSpan key={index}>{child.value}</BoldSpan>;
case 'STRIKE':
return <StrikeSpan key={index}>{child.value}</StrikeSpan>;
case 'ITALIC':
return <ItalicSpan key={index}>{child.value}</ItalicSpan>;
case 'SPOILER':
return <InlineElements key={index}>{child.value}</InlineElements>;
case 'LINK':
return <LinkSpan key={index} label={Array.isArray(child.value.label) ? child.value.label : [child.value.label]} />;
case 'PLAIN_TEXT':
return <Text key={index}>{child.value}</Text>;
case 'EMOJI':
return <EmojiSpan key={index} {...child} />;
case 'INLINE_CODE':
return <CodeSpan key={index} code={child.value.value} />;
case 'MENTION_USER':
case 'MENTION_CHANNEL':
return <Text key={index}>{child.value?.value}</Text>;
default:
return null;
}
})}
</Text>
);
export default InlineElements;

View File

@@ -0,0 +1,64 @@
import { StyleSheet, Text, View } from '@react-pdf/renderer';
import type * as MessageParser from '@rocket.chat/message-parser';
import BoldSpan from './BoldSpan';
import EmojiSpan from './EmojiSpan';
import LinkSpan from './LinkSpan';
import StrikeSpan from './StrikeSpan';
const styles = StyleSheet.create({
italic: {
fontStyle: 'italic',
},
});
type MessageBlock =
| MessageParser.Emoji
| MessageParser.ChannelMention
| MessageParser.UserMention
| MessageParser.Link
| MessageParser.MarkupExcluding<MessageParser.Italic>
| MessageParser.InlineCode;
export type ItalicSpanProps = {
children: MessageBlock[];
};
const ItalicSpan = ({ children }: ItalicSpanProps) => (
<>
{children.map((child, index) => {
if (child.type === 'LINK' || child.type === 'PLAIN_TEXT' || child.type === 'STRIKE' || child.type === 'BOLD') {
return (
<View style={styles.italic} key={index}>
{renderBlockComponent(child, index)}
</View>
);
}
return renderBlockComponent(child, index);
})}
</>
);
const renderBlockComponent = (child: MessageBlock, index: number) => {
switch (child.type) {
case 'LINK':
return <LinkSpan key={index} label={Array.isArray(child.value.label) ? child.value.label : [child.value.label]} />;
case 'PLAIN_TEXT':
return <Text key={index}>{child.value}</Text>;
case 'STRIKE':
return <StrikeSpan key={index}>{child.value}</StrikeSpan>;
case 'BOLD':
return <BoldSpan key={index}>{child.value}</BoldSpan>;
case 'EMOJI':
return <EmojiSpan key={index} {...child} />;
default:
return null;
}
};
export default ItalicSpan;

View File

@@ -0,0 +1,39 @@
import { View, Text } from '@react-pdf/renderer';
import type * as MessageParser from '@rocket.chat/message-parser';
import { useMemo } from 'react';
import BoldSpan from './BoldSpan';
import ItalicSpan from './ItalicSpan';
import StrikeSpan from './StrikeSpan';
export type LinkSpanProps = {
label: MessageParser.Markup | MessageParser.Markup[];
};
const LinkSpan = ({ label }: LinkSpanProps) => {
const children = useMemo(() => {
const labelArray = Array.isArray(label) ? label : [label];
const labelElements = labelArray.map((child, index) => {
switch (child.type) {
case 'PLAIN_TEXT':
return <Text key={index}>{child.value.trim()}</Text>;
case 'STRIKE':
return <StrikeSpan key={index}>{child.value}</StrikeSpan>;
case 'ITALIC':
return <ItalicSpan key={index}>{child.value}</ItalicSpan>;
case 'BOLD':
return <BoldSpan key={index}>{child.value}</BoldSpan>;
default:
return null;
}
});
return labelElements;
}, [label]);
return <View>{children}</View>;
};
export default LinkSpan;

View File

@@ -0,0 +1,65 @@
import { StyleSheet, View, Text } from '@react-pdf/renderer';
import type * as MessageParser from '@rocket.chat/message-parser';
import BoldSpan from './BoldSpan';
import EmojiSpan from './EmojiSpan';
import ItalicSpan from './ItalicSpan';
import LinkSpan from './LinkSpan';
const styles = StyleSheet.create({
strike: {
textDecoration: 'line-through',
},
});
type MessageBlock =
| MessageParser.Timestamp
| MessageParser.Emoji
| MessageParser.ChannelMention
| MessageParser.UserMention
| MessageParser.Link
| MessageParser.MarkupExcluding<MessageParser.Strike>
| MessageParser.InlineCode;
export type StrikeSpanProps = {
children: MessageBlock[];
};
const StrikeSpan = ({ children }: StrikeSpanProps) => (
<>
{children.map((child, index) => {
if (child.type === 'LINK' || child.type === 'PLAIN_TEXT' || child.type === 'ITALIC' || child.type === 'BOLD') {
return (
<View style={styles.strike} key={index}>
{renderBlockComponent(child, index)}
</View>
);
}
return renderBlockComponent(child, index);
})}
</>
);
const renderBlockComponent = (child: MessageBlock, index: number) => {
switch (child.type) {
case 'LINK':
return <LinkSpan key={index} label={Array.isArray(child.value.label) ? child.value.label : [child.value.label]} />;
case 'PLAIN_TEXT':
return <Text key={index}>{child.value}</Text>;
case 'ITALIC':
return <ItalicSpan key={index}>{child.value}</ItalicSpan>;
case 'BOLD':
return <BoldSpan key={index}>{child.value}</BoldSpan>;
case 'EMOJI':
return <EmojiSpan key={index} {...child} />;
default:
return null;
}
};
export default StrikeSpan;

View File

@@ -0,0 +1,19 @@
import { unicodeToShortname } from './emoji';
describe('unicodeToShortname', () => {
it('converts emoji without variation selector to shortname', () => {
expect(unicodeToShortname('\u{1F44D}')).toBe(':+1:');
});
it('converts emoji with variation selector to shortname', () => {
expect(unicodeToShortname('\u{1F44D}\u{FE0F}')).toBe(':+1:');
});
it('converts skin tone emoji to shortname', () => {
expect(unicodeToShortname('\u{1F44D}\u{1F3FD}')).toBe(':+1_tone3:');
});
it('returns the input unchanged when no shortname exists', () => {
expect(unicodeToShortname('not an emoji')).toBe('not an emoji');
});
});

View File

@@ -0,0 +1,40 @@
import data from 'emojibase-data/en/data.json';
import shortcodes from 'emojibase-data/en/shortcodes/emojibase.json';
type EmojibaseEntry = { emoji: string; hexcode: string; skins?: { emoji: string; hexcode: string }[] };
let unicodeToShort: Map<string, string> | null = null;
const firstShortcode = (hexcode: string): string | undefined => {
const entry = (shortcodes as Record<string, string | string[]>)[hexcode];
if (!entry) {
return undefined;
}
return Array.isArray(entry) ? entry[0] : entry;
};
const stripVariationSelectors = (unicode: string): string => unicode.replace(/\p{Variation_Selector}/gu, '');
const buildMap = (): Map<string, string> => {
const map = new Map<string, string>();
for (const emoji of data as EmojibaseEntry[]) {
const code = firstShortcode(emoji.hexcode);
if (code) {
map.set(stripVariationSelectors(emoji.emoji), `:${code}:`);
}
for (const skin of emoji.skins ?? []) {
const skinCode = firstShortcode(skin.hexcode);
if (skinCode) {
map.set(stripVariationSelectors(skin.emoji), `:${skinCode}:`);
}
}
}
return map;
};
export const unicodeToShortname = (unicode: string): string => {
if (!unicodeToShort) {
unicodeToShort = buildMap();
}
return unicodeToShort.get(stripVariationSelectors(unicode)) ?? unicode;
};

View File

@@ -0,0 +1,43 @@
import type * as MessageParser from '@rocket.chat/message-parser';
import BigEmojiBlock from './blocks/BigEmojiBlock';
import CodeBlock from './blocks/CodeBlock';
import HeadingBlock from './blocks/HeadingBlock';
import OrderedListBlock from './blocks/OrderedListBlock';
import ParagraphBlock from './blocks/ParagraphBlock';
import UnorderedListBlock from './blocks/UnorderedListBlock';
export type MarkupProps = {
tokens: MessageParser.Root;
};
export const Markup = ({ tokens }: MarkupProps) => (
<>
{tokens
.map((child, index) => {
switch (child.type) {
case 'PARAGRAPH':
return <ParagraphBlock key={index} items={child.value} />;
case 'HEADING':
return <HeadingBlock key={index} level={child.level} items={child.value} />;
case 'UNORDERED_LIST':
return <UnorderedListBlock key={index} items={child.value} />;
case 'ORDERED_LIST':
return <OrderedListBlock key={index} items={child.value} />;
case 'BIG_EMOJI':
return <BigEmojiBlock key={index} emoji={child.value} />;
case 'CODE':
return <CodeBlock key={index} lines={child.value} />;
default:
return null;
}
})
.filter(Boolean)}
</>
);

View File

@@ -0,0 +1,30 @@
import type { Serialized, IOmnichannelSystemMessage, ILivechatAgent, ILivechatVisitor } from '@rocket.chat/core-typings';
import type { Root } from '@rocket.chat/message-parser';
import type { i18n } from 'i18next';
export type PDFFile = { name?: string; buffer?: Buffer | null; extension?: string };
export type PDFQuote = { md: Root; name: string; ts: string };
export type PDFMessage = Serialized<
Pick<
IOmnichannelSystemMessage,
'msg' | 'u' | 'ts' | 'md' | 't' | 'navigation' | 'transferData' | 'requestData' | 'webRtcCallEndTs' | 'comment'
>
> & {
files?: PDFFile[];
quotes?: PDFQuote[];
divider?: string;
};
export type ChatTranscriptData = {
header: {
agent: Pick<ILivechatAgent, 'name' | 'username'> | null;
visitor: Pick<ILivechatVisitor, 'name' | 'username'> | null;
siteName: string;
date: string;
time: string;
};
messages: PDFMessage[];
i18n: i18n;
};

View File

@@ -0,0 +1,9 @@
import type { i18n } from 'i18next';
import type { ChatTranscriptData } from './ChatTranscriptData';
import type { WorkerData } from './WorkerData';
export interface IStrategy {
renderTemplate(data: ChatTranscriptData): Promise<NodeJS.ReadableStream>;
parseTemplateData(data: WorkerData, i18n: i18n): ChatTranscriptData;
}

View File

@@ -0,0 +1,34 @@
import type { ILivechatAgent, ILivechatVisitor, IOmnichannelSystemMessage } from '@rocket.chat/core-typings';
import type { Root } from '@rocket.chat/message-parser';
export type Quote = { name: string; ts?: Date; md: Root };
export type MessageData = Pick<
IOmnichannelSystemMessage,
| 'msg'
| 'u'
| 'ts'
| 'md'
| 't'
| 'navigation'
| 'transferData'
| 'requestData'
| 'webRtcCallEndTs'
| 'comment'
| 'slaData'
| 'priorityData'
> & {
files?: { name?: string; buffer?: Buffer | null; extension?: string }[];
quotes?: Quote[];
};
export type WorkerData = {
siteName: string;
visitor: Pick<ILivechatVisitor, 'username' | 'name' | 'visitorEmails'> | null;
agent: Pick<ILivechatAgent, 'name' | 'username' | 'utcOffset'> | null;
closedAt?: Date;
messages: MessageData[];
timezone: string;
dateFormat: string;
timeAndDateFormat: string;
};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,110 @@
/* eslint-disable testing-library/render-result-naming-convention */
import { mkdtemp, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import i18next from 'i18next';
import { PdfWorker } from './index';
import {
bigConversationData,
dataWithASingleMessageButAReallyLongMessage,
dataWithMultipleMessagesAndABigMessage,
dataWithASingleMessageAndAnImage,
dataWithASingleSystemMessage,
dataWith2ReallyBigMessages,
} from './worker.fixtures';
beforeAll(() => {
i18next.init({
lng: 'en',
resources: {
en: {},
},
});
});
const streamToBuffer = async (stream: NodeJS.ReadableStream) => {
const chunks: (string | Buffer)[] = [];
for await (const chunk of stream) {
chunks.push(chunk);
}
return Buffer.concat(chunks as Buffer[]);
};
const pdfWorker = new PdfWorker('chat-transcript');
it('should fail to instantiate if no mode is provided', () => {
// @ts-expect-error - testing
expect(() => new PdfWorker('')).toThrow();
});
it('should fail to instantiate if mode is invalid', () => {
// @ts-expect-error - testing
expect(() => new PdfWorker('invalid')).toThrow();
});
it('should properly instantiate', () => {
const newWorker = new PdfWorker('chat-transcript');
expect(newWorker).toBeInstanceOf(PdfWorker);
expect(newWorker.mode).toBe('chat-transcript');
});
it('should generate a pdf transcript for a big bunch of messages', async () => {
const stream = await pdfWorker.renderToStream({ data: bigConversationData, i18n: i18next });
const buffer = await streamToBuffer(stream);
expect(buffer).toBeTruthy();
}, 10000);
it('should generate a pdf transcript for a single message, but a really long message', async () => {
const stream = await pdfWorker.renderToStream({ data: dataWithASingleMessageButAReallyLongMessage, i18n: i18next });
const buffer = await streamToBuffer(stream);
expect(buffer).toBeTruthy();
}, 10000);
it('should generate a pdf transcript of a single message with an image', async () => {
const stream = await pdfWorker.renderToStream({ data: dataWithASingleMessageAndAnImage, i18n: i18next });
const buffer = await streamToBuffer(stream);
expect(buffer).toBeTruthy();
const tempDir = await mkdtemp(join(tmpdir(), 'pdf-worker'));
const tempFile = join(tempDir, 'test.pdf');
// console.log(tempFile);
await writeFile(tempFile, buffer);
}, 10000);
it('should generate a pdf transcript for multiple messages, one big message and 2 small messages', async () => {
const stream = await pdfWorker.renderToStream({ data: dataWithMultipleMessagesAndABigMessage, i18n: i18next });
const buffer = await streamToBuffer(stream);
expect(buffer).toBeTruthy();
}, 10000);
it('should generate a pdf transcript for a single system message', async () => {
const stream = await pdfWorker.renderToStream({ data: dataWithASingleSystemMessage, i18n: i18next });
const buffer = await streamToBuffer(stream);
expect(buffer).toBeTruthy();
});
it('should generate a pdf transcript for rooms with messages consisting of tons of markdown elements', async () => {
const stream = await pdfWorker.renderToStream({ data: dataWith2ReallyBigMessages, i18n: i18next });
const buffer = await streamToBuffer(stream);
expect(buffer).toBeTruthy();
});
describe('isMimeTypeValid', () => {
it('should return true if mimeType is valid', () => {
expect(pdfWorker.isMimeTypeValid('image/png')).toBe(true);
});
it('should return false if mimeType is not valid', () => {
expect(pdfWorker.isMimeTypeValid('image/svg')).toBe(false);
});
});

View File

@@ -0,0 +1,11 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"declaration": true,
"declarationMap": true
},
"include": ["./src/**/*"],
"exclude": ["./src/**/*.spec.ts", "./src/**/*.spec.tsx", "./src/**/*.fixtures.ts", "./src/**/*.stories.tsx", "./.storybook/**/*", "./jest.config.ts"]
}

View File

@@ -0,0 +1,8 @@
{
"extends": "@rocket.chat/tsconfig/server.json",
"compilerOptions": {
"jsx": "react-jsx"
},
"include": ["./src/**/*", "./**/*.spec.ts", "./jest.config.ts", "./.storybook/**/*"],
"exclude": []
}