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,15 @@
import { resolve } from 'node:path';
import baseConfig from '@rocket.chat/storybook-config/main';
export default baseConfig({
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')],
};
return config;
},
});

View File

@@ -0,0 +1,14 @@
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';
const preview: Preview = {
parameters: {
...parameters,
},
decorators: [...decorators],
};
export default preview;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
import client from '@rocket.chat/jest-presets/client';
import type { Config } from 'jest';
export default {
preset: client.preset,
setupFilesAfterEnv: [...client.setupFilesAfterEnv],
moduleNameMapper: {
'^react($|/.+)': '<rootDir>/../../node_modules/react$1',
'^react-dom($|/.+)': '<rootDir>/../../node_modules/react-dom$1',
},
} satisfies Config;

View File

@@ -0,0 +1,83 @@
{
"name": "@rocket.chat/gazzodown",
"version": "34.0.0",
"private": true,
"main": "./dist/index.js",
"typings": "./dist/index.d.ts",
"files": [
"/dist"
],
"scripts": {
".:build-preview-move": "mkdir -p ../../.preview && cp -r ./storybook-static ../../.preview/gazzodown",
"build": "rm -rf dist && tsc -p tsconfig.build.json",
"build-preview": "storybook build --quiet",
"build-storybook": "storybook build",
"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": {
"date-fns": "~4.1.0",
"dompurify": "~3.4.14",
"highlight.js": "11.8.0",
"react-error-boundary": "~6.1.3"
},
"devDependencies": {
"@rocket.chat/core-typings": "workspace:^",
"@rocket.chat/css-in-js": "^0.33.1",
"@rocket.chat/emitter": "workspace:~",
"@rocket.chat/fuselage": "^0.89.0",
"@rocket.chat/fuselage-hooks": "^0.43.1",
"@rocket.chat/fuselage-tokens": "^0.34.0",
"@rocket.chat/icons": "^0.49.0",
"@rocket.chat/jest-presets": "workspace:~",
"@rocket.chat/message-parser": "workspace:^",
"@rocket.chat/storybook-config": "workspace:~",
"@rocket.chat/styled": "~0.34.1",
"@rocket.chat/tsconfig": "workspace:*",
"@rocket.chat/ui-client": "workspace:^",
"@rocket.chat/ui-contexts": "workspace:^",
"@storybook/addon-links": "^9.1.20",
"@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/katex": "~0.16.8",
"@types/react": "~19.2.18",
"@types/react-dom": "~19.2.5",
"eslint": "~9.39.5",
"i18next": "~23.4.9",
"identity-obj-proxy": "^3.0.0",
"jest": "~30.2.0",
"katex": "~0.16.47",
"outdent": "^0.8.0",
"react": "~19.2.8",
"react-aria": "~3.37.0",
"react-dom": "~19.2.8",
"react-i18next": "~13.2.2",
"react-virtuoso": "~4.18.12",
"storybook": "^9.1.20",
"typescript": "~5.9.3",
"webpack": "~5.104.1"
},
"peerDependencies": {
"@rocket.chat/core-typings": "workspace:^",
"@rocket.chat/css-in-js": "*",
"@rocket.chat/fuselage": "*",
"@rocket.chat/fuselage-tokens": "*",
"@rocket.chat/message-parser": "0.32.0",
"@rocket.chat/styled": "*",
"@rocket.chat/ui-client": "workspace:^",
"@rocket.chat/ui-contexts": "workspace:^",
"katex": "*",
"react": "*"
},
"volta": {
"extends": "../../package.json"
}
}

View File

@@ -0,0 +1,481 @@
import { render, screen, waitFor } from '@testing-library/react';
import { Suspense } from 'react';
import { MarkupInteractionContext } from '.';
import Markup from './Markup';
jest.mock('highlight.js', () => ({
highlightElement: (): void => undefined,
}));
it('renders empty', () => {
const { container } = render(<Markup tokens={[]} />);
expect(container).toBeEmptyDOMElement();
});
it('renders a big emoji block', () => {
render(
<MarkupInteractionContext.Provider
value={{
convertAsciiToEmoji: true,
useEmoji: true,
}}
>
<Markup
tokens={[
{
type: 'BIG_EMOJI',
value: [
{ type: 'EMOJI', value: { type: 'PLAIN_TEXT', value: 'smile' }, shortCode: 'smile' },
{ type: 'EMOJI', value: undefined, unicode: '😀' },
{ type: 'EMOJI', value: { type: 'PLAIN_TEXT', value: 'smile' }, shortCode: 'smile' },
],
},
]}
/>
</MarkupInteractionContext.Provider>,
);
expect(screen.getByRole('presentation')).toHaveTextContent(':smile:😀:smile:');
expect(screen.getAllByRole('img')).toHaveLength(3);
expect(screen.getAllByRole('img', { name: ':smile:' })).toHaveLength(2);
});
it('renders a big emoji block with ASCII emoji', () => {
render(
<MarkupInteractionContext.Provider
value={{
convertAsciiToEmoji: false,
useEmoji: true,
}}
>
<Markup
tokens={[
{
type: 'BIG_EMOJI',
value: [
{ type: 'EMOJI', value: { type: 'PLAIN_TEXT', value: 'slight_smile' }, shortCode: 'slight_smile' },
{ type: 'EMOJI', value: undefined, unicode: '🙂' },
{ type: 'EMOJI', value: { type: 'PLAIN_TEXT', value: ':)' }, shortCode: 'slight_smile' },
],
},
]}
/>
</MarkupInteractionContext.Provider>,
);
expect(screen.getByRole('presentation')).toHaveTextContent(':slight_smile:🙂:)');
expect(screen.getAllByRole('img')).toHaveLength(2);
expect(screen.getAllByRole('img', { name: ':slight_smile:' })).toHaveLength(1);
});
it('renders a paragraph', () => {
render(
<Markup
tokens={[
{
type: 'PARAGRAPH',
value: [{ type: 'PLAIN_TEXT', value: 'Hello' }],
},
]}
/>,
);
expect(screen.getByText('Hello')).toBeInTheDocument();
});
it('renders a heading', () => {
render(
<Markup
tokens={[
{
type: 'HEADING',
level: 2,
value: [{ type: 'PLAIN_TEXT', value: 'Hello' }],
},
]}
/>,
);
expect(screen.getByRole('heading', { name: 'Hello' })).toBeInTheDocument();
});
it('renders a unordered list', () => {
render(
<Markup
tokens={[
{
type: 'UNORDERED_LIST',
value: [
{ type: 'LIST_ITEM', value: [{ type: 'PLAIN_TEXT', value: 'Hello' }] },
{ type: 'LIST_ITEM', value: [{ type: 'PLAIN_TEXT', value: 'Hola' }] },
{ type: 'LIST_ITEM', value: [{ type: 'PLAIN_TEXT', value: '你好' }] },
],
},
]}
/>,
);
expect(screen.getByRole('list')).toBeInTheDocument();
const items = screen.getAllByRole('listitem');
expect(items).toHaveLength(3);
expect(items[0]).toHaveTextContent('Hello');
expect(items[1]).toHaveTextContent('Hola');
expect(items[2]).toHaveTextContent('你好');
});
it('renders an ordered list', () => {
render(
<Markup
tokens={[
{
type: 'ORDERED_LIST',
value: [
{ type: 'LIST_ITEM', value: [{ type: 'PLAIN_TEXT', value: 'Hello' }] },
{ type: 'LIST_ITEM', value: [{ type: 'PLAIN_TEXT', value: 'Hola' }] },
{ type: 'LIST_ITEM', value: [{ type: 'PLAIN_TEXT', value: '你好' }] },
],
},
]}
/>,
);
expect(screen.getByRole('list')).toBeInTheDocument();
const items = screen.getAllByRole('listitem');
expect(items).toHaveLength(3);
expect(items[0]).toHaveTextContent('Hello');
expect(items[1]).toHaveTextContent('Hola');
expect(items[2]).toHaveTextContent('你好');
});
it('renders a task list', () => {
render(
<Markup
tokens={[
{
type: 'TASKS',
value: [
{ type: 'TASK', status: true, value: [{ type: 'PLAIN_TEXT', value: 'Chores' }] },
{ type: 'TASK', status: false, value: [{ type: 'PLAIN_TEXT', value: 'Dishes' }] },
{ type: 'TASK', status: true, value: [{ type: 'PLAIN_TEXT', value: 'Laundry' }] },
],
},
]}
/>,
);
expect(screen.getByRole('list')).toBeInTheDocument();
const items = screen.getAllByRole('listitem');
expect(items).toHaveLength(3);
expect(items[0]).toHaveTextContent('Chores');
expect(items[1]).toHaveTextContent('Dishes');
expect(items[2]).toHaveTextContent('Laundry');
const checkboxes = screen.getAllByRole('checkbox');
expect(checkboxes).toHaveLength(3);
expect(checkboxes[0]).toBeChecked();
expect(checkboxes[1]).not.toBeChecked();
expect(checkboxes[2]).toBeChecked();
});
it('renders a blockquote', () => {
render(
<Markup
tokens={[
{
type: 'QUOTE',
value: [
{
type: 'PARAGRAPH',
value: [{ type: 'PLAIN_TEXT', value: 'Cogito ergo sum.' }],
},
{
type: 'PARAGRAPH',
value: [{ type: 'PLAIN_TEXT', value: 'Sit amet, consectetur adipiscing elit.' }],
},
{
type: 'PARAGRAPH',
value: [{ type: 'PLAIN_TEXT', value: 'Donec eget ex euismod, euismod nisi euismod, vulputate nisi.' }],
},
],
},
]}
/>,
);
expect(screen.getByText('Cogito ergo sum.')).toBeInTheDocument();
expect(screen.getByText('Sit amet, consectetur adipiscing elit.')).toBeInTheDocument();
expect(screen.getByText('Donec eget ex euismod, euismod nisi euismod, vulputate nisi.')).toBeInTheDocument();
});
it('renders a code block', async () => {
render(
<Suspense fallback={null}>
<Markup
tokens={[
{
type: 'CODE',
value: [{ type: 'CODE_LINE', value: { type: 'PLAIN_TEXT', value: 'const foo = bar;' } }],
language: undefined,
},
]}
/>
</Suspense>,
);
expect(await screen.findByRole('region')).toBeInTheDocument();
expect(screen.getByRole('region')).toHaveTextContent('```const foo = bar;```');
});
it('renders a code block with language', async () => {
render(
<Suspense fallback={null}>
<Markup
tokens={[
{
type: 'CODE',
value: [{ type: 'CODE_LINE', value: { type: 'PLAIN_TEXT', value: 'const foo = bar;' } }],
language: 'javascript',
},
]}
/>
</Suspense>,
);
expect(await screen.findByRole('region')).toBeInTheDocument();
expect(screen.getByRole('region')).toHaveTextContent('```const foo = bar;```');
expect(screen.getByRole('region').querySelector('.language-javascript')).toBeInTheDocument();
});
it('renders a Katex block', async () => {
const { container } = render(
<Suspense fallback={null}>
<Markup
tokens={[
{
type: 'KATEX',
value: 'x^2 + y^2 = z^2',
},
]}
/>
</Suspense>,
);
// workaround for jest-dom's inability to handle MathML
await waitFor(() => expect(container.querySelector('math')).toBeTruthy());
container.querySelector('math')?.remove();
expect(screen.getByRole('math', { name: 'x^2 + y^2 = z^2' })).toBeInTheDocument();
});
it('renders a line break', () => {
const { container } = render(
<Markup
tokens={[
{
type: 'LINE_BREAK',
value: undefined,
},
]}
/>,
);
expect(container).toContainHTML('<br>');
});
it('renders plain text instead of emojis based on preference', () => {
render(
<MarkupInteractionContext.Provider
value={{
convertAsciiToEmoji: false,
useEmoji: false,
}}
>
<Markup
tokens={[
{
type: 'PARAGRAPH',
value: [
{ type: 'PLAIN_TEXT', value: 'Hey! ' },
{ type: 'EMOJI', value: { type: 'PLAIN_TEXT', value: 'smile' }, shortCode: 'smile' },
{ type: 'PLAIN_TEXT', value: ' ' },
{ type: 'EMOJI', value: { type: 'PLAIN_TEXT', value: ':)' }, shortCode: 'slight_smile' },
],
},
]}
/>
</MarkupInteractionContext.Provider>,
);
expect(screen.getByText('Hey! :smile: :)')).toBeInTheDocument();
});
it('renders plain text instead of ASCII emojis based on useEmojis preference', () => {
render(
<MarkupInteractionContext.Provider
value={{
convertAsciiToEmoji: true,
useEmoji: false,
}}
>
<Markup
tokens={[
{
type: 'PARAGRAPH',
value: [
{ type: 'PLAIN_TEXT', value: 'Hey! ' },
{ type: 'EMOJI', value: { type: 'PLAIN_TEXT', value: 'smile' }, shortCode: 'smile' },
{ type: 'PLAIN_TEXT', value: ' ' },
{ type: 'EMOJI', value: { type: 'PLAIN_TEXT', value: ':)' }, shortCode: 'slight_smile' },
],
},
]}
/>
</MarkupInteractionContext.Provider>,
);
expect(screen.getByText('Hey! :smile: :)')).toBeInTheDocument();
});
describe('unicode emojis with the useEmojis preference disabled', () => {
const detectEmoji = (text: string) =>
text === '🐕' ? [{ name: ':dog2:', className: 'emoji', content: '🐕' }] : [{ name: '', className: 'emoji', content: text }];
it('renders the shortcode of an emoji sent as unicode', () => {
render(
<MarkupInteractionContext.Provider
value={{
convertAsciiToEmoji: false,
useEmoji: false,
detectEmoji,
}}
>
<Markup
tokens={[
{
type: 'PARAGRAPH',
value: [
{ type: 'PLAIN_TEXT', value: 'Hey! ' },
{ type: 'EMOJI', value: undefined, unicode: '🐕' },
],
},
]}
/>
</MarkupInteractionContext.Provider>,
);
expect(screen.getByText('Hey! :dog2:')).toBeInTheDocument();
});
it('renders the glyph when no shortcode is known for the emoji', () => {
render(
<MarkupInteractionContext.Provider
value={{
convertAsciiToEmoji: false,
useEmoji: false,
detectEmoji: () => [],
}}
>
<Markup
tokens={[
{
type: 'PARAGRAPH',
value: [{ type: 'EMOJI', value: undefined, unicode: '🫩' }],
},
]}
/>
</MarkupInteractionContext.Provider>,
);
expect(screen.getByText('🫩')).toBeInTheDocument();
});
it('renders a big emoji block as plain text', () => {
render(
<MarkupInteractionContext.Provider
value={{
convertAsciiToEmoji: false,
useEmoji: false,
detectEmoji,
}}
>
<Markup
tokens={[
{
type: 'BIG_EMOJI',
value: [
{ type: 'EMOJI', value: { type: 'PLAIN_TEXT', value: 'dog2' }, shortCode: 'dog2' },
{ type: 'EMOJI', value: undefined, unicode: '🐕' },
],
},
]}
/>
</MarkupInteractionContext.Provider>,
);
expect(screen.getByRole('presentation')).toHaveTextContent(':dog2::dog2:');
expect(screen.queryAllByRole('img')).toHaveLength(0);
});
});
describe('ImageElement sanitization', () => {
it.each([['javascript:alert(1)'], ['JAVASCRIPT:alert(1)'], ['data:text/html,<script>alert(1)</script>'], ['vbscript:msgbox(1)']])(
'replaces dangerous %s scheme with "#" in image href and src',
(dangerousSrc) => {
render(
<Markup
tokens={[
{
type: 'PARAGRAPH',
value: [
{
type: 'IMAGE',
value: {
src: { type: 'PLAIN_TEXT', value: dangerousSrc },
label: { type: 'PLAIN_TEXT', value: 'click me' },
},
},
],
},
]}
/>,
);
expect(screen.getByRole('link')).toHaveAttribute('href', '#');
expect(screen.getByRole('img')).toHaveAttribute('src', '#');
},
);
it('preserves safe http(s) image URLs untouched', () => {
render(
<Markup
tokens={[
{
type: 'PARAGRAPH',
value: [
{
type: 'IMAGE',
value: {
src: { type: 'PLAIN_TEXT', value: 'https://rocket.chat/logo.svg' },
label: { type: 'PLAIN_TEXT', value: 'logo' },
},
},
],
},
]}
/>,
);
expect(screen.getByRole('link')).toHaveAttribute('href', 'https://rocket.chat/logo.svg');
expect(screen.getByRole('img')).toHaveAttribute('src', 'https://rocket.chat/logo.svg');
});
});

View File

@@ -0,0 +1,332 @@
import { css } from '@rocket.chat/css-in-js';
import { Box, MessageBody, MessageContainer } from '@rocket.chat/fuselage';
import colors from '@rocket.chat/fuselage-tokens/dist/colors.json';
import type { Options } from '@rocket.chat/message-parser';
import { parse } from '@rocket.chat/message-parser';
import type { Meta, StoryObj } from '@storybook/react';
import outdent from 'outdent';
import { Suspense } from 'react';
import Markup from './Markup';
import { MarkupInteractionContext } from './MarkupInteractionContext';
export default {
component: Markup,
decorators: [
(Story) => (
<Suspense fallback={null}>
<MarkupInteractionContext.Provider value={{}}>
<MessageContainer>
<MessageBody>
<Box
className={css`
> blockquote {
padding-inline: 8px;
border-radius: 2px;
border-width: 2px;
border-style: solid;
background-color: var(--rcx-color-neutral-100, ${colors.n100});
border-color: var(--rcx-color-neutral-200, ${colors.n200});
border-inline-start-color: var(--rcx-color-neutral-600, ${colors.n600});
&:hover,
&:focus {
background-color: var(--rcx-color-neutral-200, ${colors.n200});
border-color: var(--rcx-color-neutral-300, ${colors.n300});
border-inline-start-color: var(--rcx-color-neutral-600, ${colors.n600});
}
}
> ul.task-list {
> li::before {
display: none;
}
> li > .rcx-check-box > .rcx-check-box__input:focus + .rcx-check-box__fake {
z-index: 1;
}
list-style: none;
margin-inline-start: 0;
padding-inline-start: 0;
}
`}
>
<Story />
</Box>
</MessageBody>
</MessageContainer>
</MarkupInteractionContext.Provider>
{/* workaround? */}
<Box />
</Suspense>
),
],
parameters: {
docs: {
source: {
code: 'Disabled for this story, see https://github.com/storybookjs/storybook/issues/11554',
},
},
},
} satisfies Meta<typeof Markup>;
export const Empty: StoryObj<typeof Markup> = {
args: {
tokens: [],
},
};
export const Timestamp: StoryObj<typeof Markup> = {
args: {
tokens: parse(`Short time: <t:${((): number => Math.floor(Date.now() / 1000))()}:t>
Long time: <t:${((): number => Math.floor(Date.now() / 1000))()}:T>
Short date: <t:${((): number => Math.floor(Date.now() / 1000))()}:d>
Long date: <t:${((): number => Math.floor(Date.now() / 1000))()}:D>
Full date: <t:${((): number => Math.floor(Date.now() / 1000))()}:f>
Full date (long): <t:${((): number => Math.floor(Date.now() / 1000))()}:F>
Relative time from past: <t:${((): number => {
const date = new Date();
date.setHours(date.getHours() - 1);
return Math.floor(date.getTime() / 1000);
})()}:R>
Relative to Future: <t:${((): number => {
const date = new Date();
date.setHours(date.getHours() + 1);
return Math.floor(date.getTime() / 1000);
})()}:R>
Relative Seconds: <t:${((): number => {
const date = new Date();
date.setSeconds(date.getSeconds() - 1);
return Math.floor(date.getTime() / 1000);
})()}:R>
`),
},
};
export const BigEmoji: StoryObj<typeof Markup> = {
args: {
tokens: [
{
type: 'BIG_EMOJI',
value: [
{ type: 'EMOJI', value: { type: 'PLAIN_TEXT', value: 'smile' }, shortCode: 'smile' },
{ type: 'EMOJI', value: undefined, unicode: '😀' },
{ type: 'EMOJI', value: { type: 'PLAIN_TEXT', value: 'smile' }, shortCode: 'smile' },
],
},
],
},
};
export const Paragraph: StoryObj<typeof Markup> = {
args: {
tokens: [
{
type: 'PARAGRAPH',
value: [{ type: 'PLAIN_TEXT', value: 'Hello' }],
},
],
},
};
export const Heading: StoryObj<typeof Markup> = {
args: {
tokens: [
{
type: 'HEADING',
level: 2,
value: [{ type: 'PLAIN_TEXT', value: 'Hello' }],
},
],
},
};
export const UnorderedList: StoryObj<typeof Markup> = {
args: {
tokens: [
{
type: 'UNORDERED_LIST',
value: [
{ type: 'LIST_ITEM', value: [{ type: 'PLAIN_TEXT', value: 'Hello' }] },
{ type: 'LIST_ITEM', value: [{ type: 'PLAIN_TEXT', value: 'Hola' }] },
{ type: 'LIST_ITEM', value: [{ type: 'PLAIN_TEXT', value: '你好' }] },
],
},
],
},
};
export const OrderedList: StoryObj<typeof Markup> = {
args: {
tokens: [
{
type: 'ORDERED_LIST',
value: [
{ type: 'LIST_ITEM', value: [{ type: 'PLAIN_TEXT', value: 'Hello' }] },
{ type: 'LIST_ITEM', value: [{ type: 'PLAIN_TEXT', value: 'Hola' }] },
{ type: 'LIST_ITEM', value: [{ type: 'PLAIN_TEXT', value: '你好' }] },
],
},
],
},
};
export const TaskList: StoryObj<typeof Markup> = {
args: {
tokens: [
{
type: 'TASKS',
value: [
{ type: 'TASK', status: true, value: [{ type: 'PLAIN_TEXT', value: 'Chores' }] },
{ type: 'TASK', status: false, value: [{ type: 'PLAIN_TEXT', value: 'Dishes' }] },
{ type: 'TASK', status: true, value: [{ type: 'PLAIN_TEXT', value: 'Laundry' }] },
],
},
],
},
};
export const Blockquote: StoryObj<typeof Markup> = {
args: {
tokens: [
{
type: 'QUOTE',
value: [
{
type: 'PARAGRAPH',
value: [{ type: 'PLAIN_TEXT', value: 'Cogito ergo sum.' }],
},
{
type: 'PARAGRAPH',
value: [{ type: 'PLAIN_TEXT', value: 'Sit amet, consectetur adipiscing elit.' }],
},
{
type: 'PARAGRAPH',
value: [{ type: 'PLAIN_TEXT', value: 'Donec eget ex euismod, euismod nisi euismod, vulputate nisi.' }],
},
],
},
],
},
};
export const Code: StoryObj<typeof Markup> = {
args: {
tokens: [
{
type: 'CODE',
value: [{ type: 'CODE_LINE', value: { type: 'PLAIN_TEXT', value: 'const foo = bar;' } }],
language: undefined,
},
],
},
};
export const CodeWithLanguage: StoryObj<typeof Markup> = {
args: {
tokens: [
{
type: 'CODE',
value: [{ type: 'CODE_LINE', value: { type: 'PLAIN_TEXT', value: 'const foo = bar;' } }],
language: 'javascript',
},
],
},
};
export const Katex: StoryObj<typeof Markup> = {
args: {
tokens: [
{
type: 'KATEX',
value: 'x^2 + y^2 = z^2',
},
],
},
};
export const LineBreak: StoryObj<typeof Markup> = {
args: {
tokens: [
{
type: 'LINE_BREAK',
value: undefined,
},
],
},
};
export const HorizontalRule: StoryObj<typeof Markup> = {
args: {
tokens: parse('above\n---\nbelow'),
},
};
export const Table: StoryObj<typeof Markup> = {
args: {
tokens: parse(outdent`
| Name | Status | Score |
| :--- | :----: | ----: |
| Alice | **done** | 9.5 |
| Bob | :smile: | 7 |
| Carol | [profile](https://rocket.chat) | 12 |
`),
},
};
export const Example: StoryObj<{ msg: string }> = {
render: ({ msg }) => {
const parseOptions: Options = { katex: { dollarSyntax: true, parenthesisSyntax: true }, colors: true, emoticons: true };
return <Markup tokens={parse(msg, parseOptions)} />;
},
args: {
msg: outdent`
:smile:😀:smile:
Hello, world!
# Heading 1
## Heading 2
### Heading 3
#### Heading 4
* Bullet point 1
* Bullet point 2
* Bullet point 3
1. Numbered point 1
2. Numbered point 2
3. Numbered point 3
- [x] Chores
- [ ] Dishes
- [x] Laundry
---
> Cogito ergo sum.
> Sit amet, consectetur adipiscing elit.
> Donec eget ex euismod, euismod nisi euismod, vulputate nisi.
| Name | Status | Score |
| :--- | :----: | ----: |
| Alice | **done** | 9.5 |
| Bob | :smile: | 7 |
\`\`\`
const x = 1;
\`\`\`
\`\`\`js
const x = 1;
\`\`\`
\`\`\`invalid
const x = 1;
\`\`\`
`,
},
};

View File

@@ -0,0 +1,92 @@
import { Divider } from '@rocket.chat/fuselage';
import type * as MessageParser from '@rocket.chat/message-parser';
import { lazy, memo } from 'react';
import HeadingBlock from './blocks/HeadingBlock';
import OrderedListBlock from './blocks/OrderedListBlock';
import ParagraphBlock from './blocks/ParagraphBlock';
import QuoteBlock from './blocks/QuoteBlock';
import SpoilerBlock from './blocks/SpoilerBlock';
import TableBlock from './blocks/TableBlock';
import TaskList from './blocks/TaskListBlock';
import UnorderedListBlock from './blocks/UnorderedListBlock';
import BigEmojiBlock from './emoji/BigEmojiBlock';
import KatexErrorBoundary from './katex/KatexErrorBoundary';
const CodeBlock = lazy(() => import('./code/CodeBlock'));
const KatexBlock = lazy(() => import('./katex/KatexBlock'));
export type MarkupProps = {
tokens: MessageParser.Root;
/** Original message source, used to render the `fallback` of blocks without a dedicated renderer. */
source?: string;
};
const Markup = ({ tokens, source }: MarkupProps) => (
<>
{tokens.map((block, index) => {
switch (block.type) {
case 'BIG_EMOJI':
return <BigEmojiBlock key={index} emoji={block.value} />;
case 'PARAGRAPH':
return <ParagraphBlock key={index}>{block.value}</ParagraphBlock>;
case 'HEADING':
return (
<HeadingBlock key={index} level={block.level}>
{block.value}
</HeadingBlock>
);
case 'UNORDERED_LIST':
return <UnorderedListBlock key={index} items={block.value} />;
case 'ORDERED_LIST':
return <OrderedListBlock key={index} items={block.value} />;
case 'TASKS':
return <TaskList key={index} tasks={block.value} />;
case 'QUOTE':
return <QuoteBlock key={index}>{block.value}</QuoteBlock>;
case 'SPOILER_BLOCK':
return <SpoilerBlock key={index}>{block.value}</SpoilerBlock>;
case 'CODE':
return <CodeBlock key={index} language={block.language} lines={block.value} />;
case 'KATEX':
return (
<KatexErrorBoundary code={block.value} key={index}>
<KatexBlock code={block.value} />
</KatexErrorBoundary>
);
case 'TABLE':
return <TableBlock key={index} header={block.value.header} rows={block.value.rows} />;
case 'LINE_BREAK':
return <br key={index} />;
case 'HORIZONTAL_RULE':
return <Divider key={index} />;
default: {
// Graceful degradation: blocks may carry a `fallback`. The current form is a
// `[start, end]` offset span into the source (sliced to render the raw markup);
// the union keeps the original form too, which we intentionally ignore.
const { fallback } = block as { fallback?: [number, number] | MessageParser.Plain };
if (Array.isArray(fallback) && source !== undefined) {
const inlines: MessageParser.Inlines[] = [{ type: 'PLAIN_TEXT', value: source.slice(fallback[0], fallback[1]) }];
return <ParagraphBlock key={index}>{inlines}</ParagraphBlock>;
}
return null;
}
}
})}
</>
);
export default memo(Markup);

View File

@@ -0,0 +1,29 @@
import type { AriaButtonProps } from '@react-aria/button';
import type { MessageMention } from '@rocket.chat/core-typings';
import type * as MessageParser from '@rocket.chat/message-parser';
import type { FormEvent, UIEvent } from 'react';
import { createContext } from 'react';
export type UserMention = MessageMention;
export type ChannelMention = MessageMention;
type MarkupInteractionContextValue = {
detectEmoji?: (text: string) => { name: string; className: string; image?: string; content: string }[];
highlightRegex?: () => RegExp;
markRegex?: () => RegExp;
onTaskChecked?: (task: MessageParser.Task) => ((e: FormEvent) => void) | undefined;
resolveUserMention?: (mention: string) => MessageMention | undefined;
onUserMentionClick?: (mentionedUser: MessageMention) => ((e: UIEvent) => void) | undefined;
resolveChannelMention?: (mention: string) => MessageMention | undefined;
onChannelMentionClick?: (mentionedChannel: MessageMention) => ((e: UIEvent) => void) | undefined;
convertAsciiToEmoji?: boolean;
useEmoji?: boolean;
useRealName?: boolean;
isMobile?: boolean;
ownUserId?: string | null;
showMentionSymbol?: boolean;
triggerProps?: AriaButtonProps<'button'>;
language?: string;
};
export const MarkupInteractionContext = createContext<MarkupInteractionContextValue>({});

View File

@@ -0,0 +1,21 @@
import { render, screen } from '@testing-library/react';
import PreviewMarkup from './PreviewMarkup';
it('renders empty', () => {
const { container } = render(<PreviewMarkup tokens={[]} />);
expect(container).toBeEmptyDOMElement();
});
it('renders the first block with content when the message starts with a horizontal rule', () => {
render(
<PreviewMarkup
tokens={[
{ type: 'HORIZONTAL_RULE', value: undefined, fallback: [0, 3] },
{ type: 'PARAGRAPH', value: [{ type: 'PLAIN_TEXT', value: 'Important update' }] },
]}
/>,
);
expect(screen.getByText('Important update')).toBeInTheDocument();
});

View File

@@ -0,0 +1,116 @@
import type * as MessageParser from '@rocket.chat/message-parser';
import { memo } from 'react';
import PreviewCodeBlock from './code/PreviewCodeBlock';
import PreviewInlineElements from './elements/PreviewInlineElements';
import PreviewBigEmojiBlock from './emoji/PreviewBigEmojiBlock';
import KatexErrorBoundary from './katex/KatexErrorBoundary';
import PreviewKatexBlock from './katex/PreviewKatexBlock';
const isOnlyBigEmojiBlock = (tokens: MessageParser.Root): tokens is [MessageParser.BigEmoji] =>
tokens.length === 1 && tokens[0].type === 'BIG_EMOJI';
export type PreviewMarkupProps = {
tokens: MessageParser.Root;
/** Original message source, used to render the `fallback` of blocks without a dedicated renderer. */
source?: string;
};
const PreviewMarkup = ({ tokens, source }: PreviewMarkupProps) => {
if (isOnlyBigEmojiBlock(tokens)) {
return <PreviewBigEmojiBlock emoji={tokens[0].value} />;
}
const firstBlock =
tokens.find((block) => block.type !== 'LINE_BREAK' && block.type !== 'HORIZONTAL_RULE') ??
tokens.find((block) => block.type !== 'LINE_BREAK');
if (!firstBlock) {
return null;
}
switch (firstBlock.type) {
case 'PARAGRAPH':
return <PreviewInlineElements>{firstBlock.value}</PreviewInlineElements>;
case 'HEADING':
return <PreviewInlineElements>{firstBlock.value}</PreviewInlineElements>;
case 'UNORDERED_LIST':
case 'ORDERED_LIST': {
const firstItem = firstBlock.value[0];
return (
<>
{firstItem.number ? `${firstItem.number}.` : '-'} <PreviewInlineElements>{firstItem.value}</PreviewInlineElements>
</>
);
}
case 'TASKS': {
const firstTask = firstBlock.value[0];
return (
<>
{firstTask.status ? '\u2611' : '\u2610'} <PreviewInlineElements>{firstTask.value}</PreviewInlineElements>
</>
);
}
case 'QUOTE': {
const firstParagraph = firstBlock.value[0];
return (
<>
&gt; <PreviewInlineElements>{firstParagraph.value}</PreviewInlineElements>
</>
);
}
case 'SPOILER_BLOCK': {
return (
<>
{firstBlock.value.map((paragraph, index: number) => (
<PreviewInlineElements key={index}>{paragraph.value}</PreviewInlineElements>
))}
</>
);
}
case 'CODE': {
return <PreviewCodeBlock language={firstBlock.language} lines={firstBlock.value} />;
}
case 'KATEX':
return (
<KatexErrorBoundary code={firstBlock.value}>
<PreviewKatexBlock code={firstBlock.value} />
</KatexErrorBoundary>
);
case 'TABLE':
return (
<>
{firstBlock.value.header.map((cell, index) => (
<span key={index}>
{index > 0 ? ' | ' : null}
<PreviewInlineElements>{cell.value}</PreviewInlineElements>
</span>
))}
</>
);
default: {
// Only the `[start, end]` offset form is rendered (sliced from source); the union
// keeps the original fallback form too, which we intentionally ignore.
const { fallback } = firstBlock as { fallback?: [number, number] | MessageParser.Plain };
if (Array.isArray(fallback) && source !== undefined) {
const inlines: MessageParser.Inlines[] = [{ type: 'PLAIN_TEXT', value: source.slice(fallback[0], fallback[1]) }];
return <PreviewInlineElements>{inlines}</PreviewInlineElements>;
}
return null;
}
}
};
export default memo(PreviewMarkup);

View File

@@ -0,0 +1,20 @@
import type * as MessageParser from '@rocket.chat/message-parser';
import InlineElements from '../elements/InlineElements';
export type HeadingBlockProps = {
children?: MessageParser.Inlines[];
level?: 1 | 2 | 3 | 4;
};
const HeadingBlock = ({ children = [], level = 1 }: HeadingBlockProps) => {
const HeadingTag = `h${level}` as const;
return (
<HeadingTag>
<InlineElements>{children}</InlineElements>
</HeadingTag>
);
};
export default HeadingBlock;

View File

@@ -0,0 +1,19 @@
import type * as MessageParser from '@rocket.chat/message-parser';
import InlineElements from '../elements/InlineElements';
export type OrderedListBlockProps = {
items: MessageParser.ListItem[];
};
const OrderedListBlock = ({ items }: OrderedListBlockProps) => (
<ol>
{items.map(({ value, number }, index) => (
<li key={index} value={number}>
<InlineElements>{value}</InlineElements>
</li>
))}
</ol>
);
export default OrderedListBlock;

View File

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

View File

@@ -0,0 +1,17 @@
import type * as MessageParser from '@rocket.chat/message-parser';
import ParagraphBlock from './ParagraphBlock';
export type QuoteBlockProps = {
children: MessageParser.Paragraph[];
};
const QuoteBlock = ({ children }: QuoteBlockProps) => (
<blockquote>
{children.map((paragraph, index) => (
<ParagraphBlock key={index}>{paragraph.value}</ParagraphBlock>
))}
</blockquote>
);
export default QuoteBlock;

View File

@@ -0,0 +1,80 @@
import type * as MessageParser from '@rocket.chat/message-parser';
import type { KeyboardEvent } from 'react';
import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import ParagraphBlock from './ParagraphBlock';
export type SpoilerBlockProps = {
children: MessageParser.Paragraph[];
};
const spoilerStyle = {
cursor: 'pointer',
userSelect: 'none' as const,
borderRadius: 2,
paddingInline: 2,
filter: 'blur(4px)',
transition: 'filter 230ms ease',
} as const;
const revealedStyle = {
filter: 'none',
transition: 'filter 230ms ease',
} as const;
const srOnlyStyle = {
border: 0,
clip: 'rect(0 0 0 0)',
height: 1,
margin: -1,
overflow: 'hidden' as const,
padding: 0,
position: 'absolute' as const,
whiteSpace: 'nowrap' as const,
width: 1,
} as const;
const SpoilerBlock = ({ children }: SpoilerBlockProps) => {
const { t } = useTranslation();
const [revealed, setRevealed] = useState(false);
const reveal = useCallback(() => {
setRevealed(true);
}, []);
const onKeyDown = useCallback(
(e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
reveal();
}
},
[reveal],
);
const srText = t('Spoiler_hidden_activate_to_reveal', { defaultValue: 'Spoiler hidden. Activate to reveal.' });
if (revealed) {
return (
<div style={revealedStyle}>
{children.map((paragraph: MessageParser.Paragraph, index: number) => (
<ParagraphBlock key={index}>{paragraph.value}</ParagraphBlock>
))}
</div>
);
}
return (
<div role='button' tabIndex={0} aria-expanded={false} aria-label={srText} onClick={reveal} onKeyDown={onKeyDown} style={spoilerStyle}>
<span style={srOnlyStyle}>{srText}</span>
<div aria-hidden>
{children.map((paragraph: MessageParser.Paragraph, index: number) => (
<ParagraphBlock key={index}>{paragraph.value}</ParagraphBlock>
))}
</div>
</div>
);
};
export default SpoilerBlock;

View File

@@ -0,0 +1,51 @@
import { Table, TableBody, TableCell, TableHead, TableRow } from '@rocket.chat/fuselage';
import type * as MessageParser from '@rocket.chat/message-parser';
import InlineElements from '../elements/InlineElements';
export type TableBlockProps = {
header: MessageParser.TableCell[];
rows: MessageParser.TableRow[];
};
// Explicit mapping (not an object lookup) so a crafted AST align like `__proto__`
// or `toString` can never resolve to an inherited value.
const toAlign = (align: MessageParser.TableCell['align']): 'start' | 'center' | 'end' | undefined => {
switch (align) {
case 'left':
return 'start';
case 'center':
return 'center';
case 'right':
return 'end';
default:
return undefined;
}
};
const TableBlock = ({ header, rows }: TableBlockProps) => (
<Table striped fixed={false}>
<TableHead>
<TableRow>
{header.map((cell, index) => (
<TableCell key={index} align={toAlign(cell.align)}>
<InlineElements>{cell.value}</InlineElements>
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{rows.map((row, rowIndex) => (
<TableRow key={rowIndex}>
{row.value.map((cell, cellIndex) => (
<TableCell key={cellIndex} align={toAlign(cell.align)}>
<InlineElements>{cell.value}</InlineElements>
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
);
export default TableBlock;

View File

@@ -0,0 +1,26 @@
import { CheckBox } from '@rocket.chat/fuselage';
import type * as MessageParser from '@rocket.chat/message-parser';
import { useContext } from 'react';
import { MarkupInteractionContext } from '../MarkupInteractionContext';
import InlineElements from '../elements/InlineElements';
export type TaskListBlockProps = {
tasks: MessageParser.Task[];
};
const TaskListBlock = ({ tasks }: TaskListBlockProps) => {
const { onTaskChecked } = useContext(MarkupInteractionContext);
return (
<ul className='task-list'>
{tasks.map((item, index) => (
<li key={index}>
<CheckBox checked={item.status} onChange={onTaskChecked?.(item)} /> <InlineElements>{item.value}</InlineElements>
</li>
))}
</ul>
);
};
export default TaskListBlock;

View File

@@ -0,0 +1,19 @@
import type * as MessageParser from '@rocket.chat/message-parser';
import InlineElements from '../elements/InlineElements';
export type UnorderedListBlockProps = {
items: MessageParser.ListItem[];
};
const UnorderedListBlock = ({ items }: UnorderedListBlockProps) => (
<ul>
{items.map((item, index) => (
<li key={index}>
<InlineElements>{item.value}</InlineElements>
</li>
))}
</ul>
);
export default UnorderedListBlock;

View File

@@ -0,0 +1,112 @@
import { css } from '@rocket.chat/css-in-js';
import { IconButton, Box } from '@rocket.chat/fuselage';
import type * as MessageParser from '@rocket.chat/message-parser';
import { useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import hljs from 'highlight.js';
import { Fragment, useContext, useLayoutEffect, useMemo, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { MarkupInteractionContext } from '../MarkupInteractionContext';
export type CodeBlockProps = {
language?: string;
lines: MessageParser.CodeLine[];
};
const onHoverStyle = css`
opacity: 0;
user-select: none;
[data-code-block-wrapper]:hover &,
[data-code-block-wrapper]:focus-within & {
opacity: 1;
}
`;
const CodeBlock = ({ lines = [], language }: CodeBlockProps) => {
const ref = useRef<HTMLElement>(null);
const { highlightRegex } = useContext(MarkupInteractionContext);
const { t } = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const code = useMemo(() => lines.map((line) => line.value.value).join('\n'), [lines]);
const content = useMemo(() => {
const regex = highlightRegex?.();
if (regex) {
const chunks = code.split(regex);
const head = chunks.shift() ?? '';
return (
<>
<>{head}</>
{chunks.map((chunk, i) => {
if (i % 2 === 0) {
return (
<mark key={i} className='highlight-text'>
{chunk}
</mark>
);
}
return <Fragment key={i}>{chunk}</Fragment>;
})}
</>
);
}
return code;
}, [code, highlightRegex]);
useLayoutEffect(() => {
const element = ref.current;
if (!element) {
return;
}
hljs.highlightElement(element);
if (!element.classList.contains('hljs')) {
element.classList.add('hljs');
}
}, [language, content]);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(code);
dispatchToastMessage({ type: 'success', message: t('Copied') });
} catch (error) {
dispatchToastMessage({ type: 'error', message: error instanceof Error ? error.message : t('Failed_to_copy') });
}
}, [code, dispatchToastMessage, t]);
return (
<Box is='pre' position='relative' data-code-block-wrapper role='region'>
<IconButton
icon='copy'
small
className={onHoverStyle}
title={t('Copy')}
onClick={() => {
void handleCopy();
}}
position='absolute'
insetInlineEnd={0}
margin={8}
/>
<span className='copyonly'>```</span>
<code
key={language + code}
ref={ref}
className={((!language || language === 'none') && 'code-colors') || `code-colors language-${language}`}
>
{content}
</code>
<span className='copyonly'>```</span>
</Box>
);
};
export default CodeBlock;

View File

@@ -0,0 +1,13 @@
import PlainSpan from '../elements/PlainSpan';
export type CodeElementProps = {
code: string;
};
const CodeElement = ({ code }: CodeElementProps) => (
<code className='code-colors inline'>
<PlainSpan text={code} />
</code>
);
export default CodeElement;

View File

@@ -0,0 +1,19 @@
import type * as MessageParser from '@rocket.chat/message-parser';
import { useMemo } from 'react';
export type PreviewCodeBlockProps = {
language?: string;
lines: MessageParser.CodeLine[];
};
export const PreviewCodeBlock = ({ lines }: PreviewCodeBlockProps) => {
const firstLine = useMemo(() => lines.find((line) => line.value.value.trim())?.value.value.trim(), [lines]);
if (!firstLine) {
return null;
}
return <>{firstLine}</>;
};
export default PreviewCodeBlock;

View File

@@ -0,0 +1,7 @@
export type PreviewCodeElementProps = {
code: string;
};
const PreviewCodeElement = ({ code }: PreviewCodeElementProps) => <>{code}</>;
export default PreviewCodeElement;

View File

@@ -0,0 +1,26 @@
import { memo } from 'react';
export type ColorElementProps = {
r: number;
g: number;
b: number;
a: number;
};
const ColorElement = ({ r, g, b, a }: ColorElementProps) => (
<span>
<span
style={{
backgroundColor: `rgba(${r}, ${g}, ${b}, ${(a / 255) * 100}%)`,
display: 'inline-block',
width: '1em',
height: '1em',
verticalAlign: 'middle',
marginInlineEnd: '0.5em',
}}
/>
rgba({r}, {g}, {b}, {(a / 255) * 100}%)
</span>
);
export default memo(ColorElement);

View File

@@ -0,0 +1,33 @@
import { memo } from 'react';
const toHexByte = (value: number): string => value.toString(16).padStart(2, '0');
export type PreviewColorElementProps = {
r: number;
g: number;
b: number;
a: number;
};
const PreviewColorElement = ({ r, g, b, a }: PreviewColorElementProps) => {
if (a === 255) {
return (
<>
#{toHexByte(r)}
{toHexByte(g)}
{toHexByte(b)}
</>
);
}
return (
<>
#{toHexByte(r)}
{toHexByte(g)}
{toHexByte(b)}
{toHexByte(a)}
</>
);
};
export default memo(PreviewColorElement);

4
packages/gazzodown/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,72 @@
import type * as MessageParser from '@rocket.chat/message-parser';
import ItalicSpan from './ItalicSpan';
import LinkSpan from './LinkSpan';
import PlainSpan from './PlainSpan';
import StrikeSpan from './StrikeSpan';
import CodeElement from '../code/CodeElement';
import EmojiElement from '../emoji/EmojiElement';
import ChannelMentionElement from '../mentions/ChannelMentionElement';
import UserMentionElement from '../mentions/UserMentionElement';
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((block, index) => {
if (
block.type === 'LINK' ||
block.type === 'PLAIN_TEXT' ||
block.type === 'STRIKE' ||
block.type === 'ITALIC' ||
block.type === 'INLINE_CODE'
) {
return <strong key={index}>{renderBlockComponent(block, index)}</strong>;
}
return renderBlockComponent(block, index);
})}
</>
);
const renderBlockComponent = (block: MessageBlock, index: number) => {
switch (block.type) {
case 'EMOJI':
return <EmojiElement key={index} {...block} />;
case 'MENTION_USER':
return <UserMentionElement key={index} mention={block.value.value} />;
case 'MENTION_CHANNEL':
return <ChannelMentionElement key={index} mention={block.value.value} />;
case 'PLAIN_TEXT':
return <PlainSpan key={index} text={block.value} />;
case 'LINK':
return <LinkSpan key={index} href={block.value.src.value} label={block.value.label} />;
case 'STRIKE':
return <StrikeSpan key={index}>{block.value}</StrikeSpan>;
case 'ITALIC':
return <ItalicSpan key={index}>{block.value}</ItalicSpan>;
case 'INLINE_CODE':
return <CodeElement key={index} code={block.value.value} />;
default:
return null;
}
};
export default BoldSpan;

View File

@@ -0,0 +1,60 @@
import type * as MessageParser from '@rocket.chat/message-parser';
import { useMemo } from 'react';
import { sanitizeUrl } from './sanitizeUrl';
const flattenMarkup = (
markup:
| MessageParser.Timestamp
| MessageParser.Markup
| MessageParser.InlineCode
| MessageParser.Link
| MessageParser.Emoji
| MessageParser.ChannelMention
| MessageParser.UserMention,
): string => {
switch (markup.type) {
case 'PLAIN_TEXT':
return markup.value;
case 'ITALIC':
case 'BOLD':
case 'STRIKE':
return markup.value.map(flattenMarkup).join('');
case 'INLINE_CODE':
return flattenMarkup(markup.value);
case 'LINK': {
const label = flattenMarkup(markup.value.label as MessageParser.Markup);
const href = markup.value.src.value;
return label ? `${label} (${href})` : href;
}
default:
return '';
}
};
const style = {
maxWidth: '100%',
};
export type ImageElementProps = {
src: string;
alt: MessageParser.Markup;
};
const ImageElement = ({ src, alt }: ImageElementProps) => {
const plainAlt = useMemo(() => flattenMarkup(alt), [alt]);
const safeSrc = sanitizeUrl(src);
return (
<a href={safeSrc} target='_blank' rel='noopener noreferrer' title={plainAlt}>
<img src={safeSrc} data-title={safeSrc} alt={plainAlt} style={style} />
</a>
);
};
export default ImageElement;

View File

@@ -0,0 +1,93 @@
import type * as MessageParser from '@rocket.chat/message-parser';
import { lazy } from 'react';
import BoldSpan from './BoldSpan';
import ImageElement from './ImageElement';
import ItalicSpan from './ItalicSpan';
import LinkSpan from './LinkSpan';
import PlainSpan from './PlainSpan';
import SpoilerSpan from './SpoilerSpan';
import StrikeSpan from './StrikeSpan';
import Timestamp from './Timestamp';
import CodeElement from '../code/CodeElement';
import ColorElement from '../colors/ColorElement';
import EmojiElement from '../emoji/EmojiElement';
import KatexErrorBoundary from '../katex/KatexErrorBoundary';
import ChannelMentionElement from '../mentions/ChannelMentionElement';
import UserMentionElement from '../mentions/UserMentionElement';
const KatexElement = lazy(() => import('../katex/KatexElement'));
export type InlineElementsProps = {
children: (MessageParser.Inlines | { fallback: MessageParser.Plain; type: undefined })[];
};
const InlineElements = ({ children }: InlineElementsProps) => (
<>
{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 <SpoilerSpan key={index}>{child.value}</SpoilerSpan>;
case 'LINK':
return (
<LinkSpan
key={index}
href={child.value.src.value}
label={Array.isArray(child.value.label) ? child.value.label : [child.value.label]}
/>
);
case 'PLAIN_TEXT':
return <PlainSpan key={index} text={child.value} />;
case 'IMAGE':
return <ImageElement key={index} src={child.value.src.value} alt={child.value.label} />;
case 'MENTION_USER':
return <UserMentionElement key={index} mention={child.value.value} />;
case 'MENTION_CHANNEL':
return <ChannelMentionElement key={index} mention={child.value.value} />;
case 'INLINE_CODE':
return <CodeElement key={index} code={child.value.value} />;
case 'EMOJI':
return <EmojiElement key={index} {...child} />;
case 'COLOR':
return <ColorElement key={index} {...child.value} />;
case 'INLINE_KATEX':
return (
<KatexErrorBoundary key={index} code={child.value}>
<KatexElement code={child.value} />
</KatexErrorBoundary>
);
case 'TIMESTAMP': {
return <Timestamp key={index}>{child}</Timestamp>;
}
default: {
if ('fallback' in child) {
return <InlineElements key={index}>{[child.fallback]}</InlineElements>;
}
return null;
}
}
})}
</>
);
export default InlineElements;

View File

@@ -0,0 +1,72 @@
import type * as MessageParser from '@rocket.chat/message-parser';
import BoldSpan from './BoldSpan';
import LinkSpan from './LinkSpan';
import PlainSpan from './PlainSpan';
import StrikeSpan from './StrikeSpan';
import CodeElement from '../code/CodeElement';
import EmojiElement from '../emoji/EmojiElement';
import ChannelMentionElement from '../mentions/ChannelMentionElement';
import UserMentionElement from '../mentions/UserMentionElement';
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((block, index) => {
if (
block.type === 'LINK' ||
block.type === 'PLAIN_TEXT' ||
block.type === 'STRIKE' ||
block.type === 'BOLD' ||
block.type === 'INLINE_CODE'
) {
return <em key={index}>{renderBlockComponent(block, index)}</em>;
}
return renderBlockComponent(block, index);
})}
</>
);
const renderBlockComponent = (block: MessageBlock, index: number) => {
switch (block.type) {
case 'EMOJI':
return <EmojiElement key={index} {...block} />;
case 'MENTION_USER':
return <UserMentionElement key={index} mention={block.value.value} />;
case 'MENTION_CHANNEL':
return <ChannelMentionElement key={index} mention={block.value.value} />;
case 'PLAIN_TEXT':
return <PlainSpan key={index} text={block.value} />;
case 'LINK':
return <LinkSpan key={index} href={block.value.src.value} label={block.value.label} />;
case 'STRIKE':
return <StrikeSpan key={index}>{block.value}</StrikeSpan>;
case 'BOLD':
return <BoldSpan key={index}>{block.value}</BoldSpan>;
case 'INLINE_CODE':
return <CodeElement key={index} code={block.value.value} />;
default:
return null;
}
};
export default ItalicSpan;

View File

@@ -0,0 +1,62 @@
import type * as MessageParser from '@rocket.chat/message-parser';
import { getBaseURI, isExternal } from '@rocket.chat/ui-client';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import BoldSpan from './BoldSpan';
import ItalicSpan from './ItalicSpan';
import PlainSpan from './PlainSpan';
import StrikeSpan from './StrikeSpan';
import { sanitizeUrl } from './sanitizeUrl';
export type LinkSpanProps = {
href: string;
label: MessageParser.Markup | MessageParser.Markup[];
};
const LinkSpan = ({ href, label }: LinkSpanProps) => {
// Should sanitize 'href' if any of the insecure prefixes are present - see DSK-34 on Jira
const sanitizedHref = sanitizeUrl(href);
const { t } = useTranslation();
const children = useMemo(() => {
const labelArray = Array.isArray(label) ? label : [label];
const labelElements = labelArray.map((child, index) => {
switch (child.type) {
case 'PLAIN_TEXT':
return <PlainSpan key={index} text={child.value} />;
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]);
if (isExternal(sanitizedHref)) {
return (
<a href={sanitizedHref} title={sanitizedHref} rel='noopener noreferrer' target='_blank'>
{children}
</a>
);
}
return (
<a href={sanitizedHref} title={t('Go_to_href', { href: sanitizedHref.replace(getBaseURI(), '') })}>
{children}
</a>
);
};
export default LinkSpan;

View File

@@ -0,0 +1,61 @@
import { Fragment, memo, useContext, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { MarkupInteractionContext } from '../MarkupInteractionContext';
export type PlainSpanProps = {
text: string;
};
const PlainSpan = ({ text }: PlainSpanProps) => {
const { t } = useTranslation();
const { highlightRegex, markRegex } = useContext(MarkupInteractionContext);
const content = useMemo(() => {
if (highlightRegex) {
const chunks = text.split(highlightRegex());
const head = chunks.shift() ?? '';
return (
<>
<>{head}</>
{chunks.map((chunk, i) => {
if (i % 2 === 0) {
return (
<mark title={t('Highlighted_chosen_word')} key={i} className='highlight-text'>
{chunk}
</mark>
);
}
return <Fragment key={i}>{chunk}</Fragment>;
})}
</>
);
}
if (markRegex) {
const chunks = text.split(markRegex());
const head = chunks.shift() ?? '';
return (
<>
<>{head}</>
{chunks.map((chunk, i) => {
if (i % 2 === 0) {
return <mark key={i}>{chunk}</mark>;
}
return <Fragment key={i}>{chunk}</Fragment>;
})}
</>
);
}
return text;
}, [highlightRegex, markRegex, text, t]);
return <>{content}</>;
};
export default memo(PlainSpan);

View File

@@ -0,0 +1,77 @@
import type * as MessageParser from '@rocket.chat/message-parser';
import { Fragment } from 'react';
import BoldSpan from './BoldSpan';
import ItalicSpan from './ItalicSpan';
import StrikeSpan from './StrikeSpan';
import PreviewCodeElement from '../code/PreviewCodeElement';
import PreviewColorElement from '../colors/PreviewColorElement';
import PreviewEmojiElement from '../emoji/PreviewEmojiElement';
import KatexErrorBoundary from '../katex/KatexErrorBoundary';
import PreviewKatexElement from '../katex/PreviewKatexElement';
import PreviewChannelMentionElement from '../mentions/PreviewChannelMentionElement';
import PreviewUserMentionElement from '../mentions/PreviewUserMentionElement';
export type PreviewInlineElementsProps = {
children: MessageParser.Inlines[];
};
const PreviewInlineElements = ({ children }: PreviewInlineElementsProps) => (
<>
{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 <PreviewInlineElements key={index}>{child.value}</PreviewInlineElements>;
case 'LINK':
return (
<PreviewInlineElements key={index}>
{Array.isArray(child.value.label) ? child.value.label : [child.value.label]}
</PreviewInlineElements>
);
case 'PLAIN_TEXT':
return <Fragment key={index}>{child.value}</Fragment>;
case 'IMAGE':
return <PreviewInlineElements key={index}>{[child.value.label]}</PreviewInlineElements>;
case 'MENTION_USER':
return <PreviewUserMentionElement key={index} mention={child.value.value} />;
case 'MENTION_CHANNEL':
return <PreviewChannelMentionElement key={index} mention={child.value.value} />;
case 'INLINE_CODE':
return <PreviewCodeElement key={index} code={child.value.value} />;
case 'EMOJI':
return <PreviewEmojiElement key={index} {...child} />;
case 'COLOR':
return <PreviewColorElement key={index} {...child.value} />;
case 'INLINE_KATEX':
return (
<KatexErrorBoundary key={index} code={child.value}>
<PreviewKatexElement code={child.value} />
</KatexErrorBoundary>
);
default:
return null;
}
})}
</>
);
export default PreviewInlineElements;

View File

@@ -0,0 +1,137 @@
import type * as MessageParser from '@rocket.chat/message-parser';
import type { KeyboardEvent } from 'react';
import { lazy, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import BoldSpan from './BoldSpan';
import ImageElement from './ImageElement';
import ItalicSpan from './ItalicSpan';
import LinkSpan from './LinkSpan';
import PlainSpan from './PlainSpan';
import StrikeSpan from './StrikeSpan';
import Timestamp from './Timestamp';
import CodeElement from '../code/CodeElement';
import ColorElement from '../colors/ColorElement';
import EmojiElement from '../emoji/EmojiElement';
import KatexErrorBoundary from '../katex/KatexErrorBoundary';
import ChannelMentionElement from '../mentions/ChannelMentionElement';
import UserMentionElement from '../mentions/UserMentionElement';
const KatexElement = lazy(() => import('../katex/KatexElement'));
export type SpoilerSpanProps = {
children: MessageParser.Spoiler['value'];
};
const spoilerStyle = {
cursor: 'pointer',
userSelect: 'none',
borderRadius: 2,
paddingInline: 2,
filter: 'blur(4px)',
transition: 'filter 230ms ease',
} as const;
const revealedStyle = {
filter: 'none',
transition: 'filter 230ms ease',
} as const;
const srOnlyStyle = {
border: 0,
clip: 'rect(0 0 0 0)',
height: 1,
margin: -1,
overflow: 'hidden',
padding: 0,
position: 'absolute',
whiteSpace: 'nowrap',
width: 1,
} as const;
const SpoilerSpan = ({ children }: SpoilerSpanProps) => {
const { t } = useTranslation();
const [revealed, setRevealed] = useState(false);
const reveal = useCallback(() => {
setRevealed(true);
}, []);
const onKeyDown = useCallback(
(e: KeyboardEvent<HTMLSpanElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
reveal();
}
},
[reveal],
);
const srText = t('Spoiler_hidden_activate_to_reveal', { defaultValue: 'Spoiler hidden. Activate to reveal.' });
if (revealed) {
return <span style={revealedStyle}>{children.map((block, index) => renderBlockComponent(block, index))}</span>;
}
return (
<span role='button' tabIndex={0} aria-expanded={false} aria-label={srText} onClick={reveal} onKeyDown={onKeyDown} style={spoilerStyle}>
<span style={srOnlyStyle}>{srText}</span>
<span aria-hidden>{children.map((block, index) => renderBlockComponent(block, index))}</span>
</span>
);
};
const renderBlockComponent = (block: MessageParser.Inlines, index: number) => {
switch (block.type) {
case 'EMOJI':
return <EmojiElement key={index} {...block} />;
case 'MENTION_USER':
return <UserMentionElement key={index} mention={block.value.value} />;
case 'MENTION_CHANNEL':
return <ChannelMentionElement key={index} mention={block.value.value} />;
case 'PLAIN_TEXT':
return <PlainSpan key={index} text={block.value} />;
case 'LINK':
return <LinkSpan key={index} href={block.value.src.value} label={block.value.label} />;
case 'STRIKE':
return <StrikeSpan key={index}>{block.value}</StrikeSpan>;
case 'ITALIC':
return <ItalicSpan key={index}>{block.value}</ItalicSpan>;
case 'BOLD':
return <BoldSpan key={index}>{block.value}</BoldSpan>;
case 'INLINE_CODE':
return <CodeElement key={index} code={block.value.value} />;
case 'SPOILER':
return <SpoilerSpan key={index}>{block.value}</SpoilerSpan>;
case 'TIMESTAMP':
return <Timestamp key={index}>{block}</Timestamp>;
case 'COLOR':
return <ColorElement key={index} {...block.value} />;
case 'IMAGE':
return <ImageElement key={index} src={block.value.src.value} alt={block.value.label} />;
case 'INLINE_KATEX':
return (
<KatexErrorBoundary key={index} code={block.value}>
<KatexElement code={block.value} />
</KatexErrorBoundary>
);
default:
return null;
}
};
export default SpoilerSpan;

View File

@@ -0,0 +1,73 @@
import type * as MessageParser from '@rocket.chat/message-parser';
import BoldSpan from './BoldSpan';
import ItalicSpan from './ItalicSpan';
import LinkSpan from './LinkSpan';
import PlainSpan from './PlainSpan';
import CodeElement from '../code/CodeElement';
import EmojiElement from '../emoji/EmojiElement';
import ChannelMentionElement from '../mentions/ChannelMentionElement';
import UserMentionElement from '../mentions/UserMentionElement';
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((block, index) => {
if (
block.type === 'LINK' ||
block.type === 'PLAIN_TEXT' ||
block.type === 'ITALIC' ||
block.type === 'BOLD' ||
block.type === 'INLINE_CODE'
) {
return <del key={index}>{renderBlockComponent(block, index)}</del>;
}
return renderBlockComponent(block, index);
})}
</>
);
const renderBlockComponent = (block: MessageBlock, index: number) => {
switch (block.type) {
case 'EMOJI':
return <EmojiElement key={index} {...block} />;
case 'MENTION_USER':
return <UserMentionElement key={index} mention={block.value.value} />;
case 'MENTION_CHANNEL':
return <ChannelMentionElement key={index} mention={block.value.value} />;
case 'PLAIN_TEXT':
return <PlainSpan key={index} text={block.value} />;
case 'LINK':
return <LinkSpan key={index} href={block.value.src.value} label={block.value.label} />;
case 'ITALIC':
return <ItalicSpan key={index}>{block.value}</ItalicSpan>;
case 'BOLD':
return <BoldSpan key={index}>{block.value}</BoldSpan>;
case 'INLINE_CODE':
return <CodeElement key={index} code={block.value.value} />;
default:
return null;
}
};
export default StrikeSpan;

View File

@@ -0,0 +1,89 @@
import { render, screen, act } from '@testing-library/react';
import TimestampWrapper from './index';
import { MarkupInteractionContext } from '../../MarkupInteractionContext';
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
const makeTimestampNode = (epochSeconds: number) =>
({
type: 'TIMESTAMP' as const,
value: {
timestamp: String(epochSeconds),
format: 'R' as const,
},
}) satisfies Parameters<typeof TimestampWrapper>[0]['children'];
it('should update displayed text immediately when the value prop changes', () => {
const now = new Date('2026-05-12T12:00:00.000Z');
jest.setSystemTime(now);
const futureA = Math.floor(now.getTime() / 1000) + 7200;
const futureB = Math.floor(now.getTime() / 1000) + 86400;
const { rerender } = render(<TimestampWrapper>{makeTimestampNode(futureA)}</TimestampWrapper>);
const tagA = screen.getByText(/2 hours/i);
expect(tagA).toBeInTheDocument();
rerender(<TimestampWrapper>{makeTimestampNode(futureB)}</TimestampWrapper>);
const tagB = screen.getByText(/tomorrow/i);
expect(tagB).toBeInTheDocument();
});
it('should preserve the locale from context after interval refresh', () => {
const now = new Date('2026-05-12T12:00:00.000Z');
jest.setSystemTime(now);
const futureEpoch = Math.floor(now.getTime() / 1000) + 30;
render(
<MarkupInteractionContext.Provider value={{ language: 'de' }}>
<TimestampWrapper>{makeTimestampNode(futureEpoch)}</TimestampWrapper>
</MarkupInteractionContext.Provider>,
);
const initial = screen.getByRole('time');
const initialText = initial.textContent;
expect(initialText).toBeTruthy();
act(() => {
jest.advanceTimersByTime(1500);
});
const afterTick = screen.getByRole('time');
const afterTickText = afterTick.textContent;
expect(afterTickText).toBeTruthy();
expect(afterTickText).not.toMatch(/seconds/i);
});
it('should update displayed text immediately when locale changes via context', () => {
const now = new Date('2026-05-12T12:00:00.000Z');
jest.setSystemTime(now);
const futureEpoch = Math.floor(now.getTime() / 1000) + 30;
const { rerender } = render(
<MarkupInteractionContext.Provider value={{ language: 'en' }}>
<TimestampWrapper>{makeTimestampNode(futureEpoch)}</TimestampWrapper>
</MarkupInteractionContext.Provider>,
);
expect(screen.getByRole('time').textContent).toMatch(/seconds/i);
rerender(
<MarkupInteractionContext.Provider value={{ language: 'de' }}>
<TimestampWrapper>{makeTimestampNode(futureEpoch)}</TimestampWrapper>
</MarkupInteractionContext.Provider>,
);
expect(screen.getByRole('time').textContent).not.toMatch(/seconds/i);
});

View File

@@ -0,0 +1,141 @@
import { Tag } from '@rocket.chat/fuselage';
import type * as MessageParser from '@rocket.chat/message-parser';
import { format, intlFormatDistance } from 'date-fns';
import { useContext, useEffect, useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { MarkupInteractionContext } from '../../MarkupInteractionContext';
type BoldSpanProps = {
children: MessageParser.Timestamp;
};
export type TimestampProps = { format: 't' | 'T' | 'd' | 'D' | 'f' | 'F' | 'R'; value: Date };
const Timestamp = ({ format, value }: TimestampProps) => {
switch (format) {
case 't': // Short time format
return <ShortTime value={value} />;
case 'T': // Long time format
return <LongTime value={value} />;
case 'd': // Short date format
return <ShortDate value={value} />;
case 'D': // Long date format
return <LongDate value={value} />;
case 'f': // Full date and time format
return <FullDate value={value} />;
case 'F': // Full date and time (long) format
return <FullDateLong value={value} />;
case 'R': // Relative time format
return <RelativeTime value={value} />;
default:
return <time dateTime={value.toISOString()}> {JSON.stringify(value.getTime())}</time>;
}
};
// eslint-disable-next-line react/no-multi-comp
const TimestampWrapper = ({ children }: BoldSpanProps) => (
<ErrorBoundary fallback={<>{new Date(parseInt(children.value.timestamp) * 1000).toUTCString()}</>}>
<Timestamp format={children.value.format} value={new Date(parseInt(children.value.timestamp) * 1000)} />
</ErrorBoundary>
);
export type ShortTimeProps = { value: Date };
// eslint-disable-next-line react/no-multi-comp
const ShortTime = ({ value }: ShortTimeProps) => <Time value={format(value, 'p')} dateTime={value.toISOString()} />;
export type LongTimeProps = { value: Date };
// eslint-disable-next-line react/no-multi-comp
const LongTime = ({ value }: LongTimeProps) => <Time value={format(value, 'pp')} dateTime={value.toISOString()} />;
export type ShortDateProps = { value: Date };
// eslint-disable-next-line react/no-multi-comp
const ShortDate = ({ value }: ShortDateProps) => <Time value={format(value, 'P')} dateTime={value.toISOString()} />;
export type LongDateProps = { value: Date };
// eslint-disable-next-line react/no-multi-comp
const LongDate = ({ value }: LongDateProps) => <Time value={format(value, 'Pp')} dateTime={value.toISOString()} />;
export type FullDateProps = { value: Date };
// eslint-disable-next-line react/no-multi-comp
const FullDate = ({ value }: FullDateProps) => <Time value={format(value, 'PPPP p')} dateTime={value.toISOString()} />;
export type FullDateLongProps = { value: Date };
// eslint-disable-next-line react/no-multi-comp
const FullDateLong = ({ value }: FullDateLongProps) => <Time value={format(value, 'PPPP pp')} dateTime={value.toISOString()} />;
export type TimeProps = { value: string; dateTime: string };
// eslint-disable-next-line react/no-multi-comp
const Time = ({ value, dateTime }: TimeProps) => (
<time
title={new Date(dateTime).toLocaleString()}
dateTime={dateTime}
style={{
display: 'inline-block',
}}
>
<Tag>{value}</Tag>
</time>
);
export type RelativeTimeProps = { value: Date };
// eslint-disable-next-line react/no-multi-comp
const RelativeTime = ({ value }: RelativeTimeProps) => {
const time = value.getTime();
const { language } = useContext(MarkupInteractionContext);
const locale = language ?? 'en';
const [text, setText] = useState(() => intlFormatDistance(time, Date.now(), { locale }));
useEffect(() => {
setText(intlFormatDistance(time, Date.now(), { locale }));
let timeoutId: ReturnType<typeof setTimeout>;
const refresh = () => {
setText(intlFormatDistance(time, Date.now(), { locale }));
timeoutId = setTimeout(refresh, getTimeToRefresh(time));
};
timeoutId = setTimeout(refresh, getTimeToRefresh(time));
return () => clearTimeout(timeoutId);
}, [time, locale]);
return <Time value={text} dateTime={value.toISOString()} />;
};
const getTimeToRefresh = (time: number): number => {
const timeToRefresh = time - Date.now();
// less than 1 minute
if (timeToRefresh < 60000) {
return 1000;
}
// if the difference is in the minutes range, we should refresh the time in 1 minute / 2
if (timeToRefresh < 3600000) {
return 60000 / 2;
}
// if the difference is in the hours range, we should refresh the time in 5 minutes
if (timeToRefresh < 86400000) {
return 60000 * 5;
}
// refresh the time in 1 hour
return 3600000;
};
export default TimestampWrapper;

View File

@@ -0,0 +1,109 @@
import { sanitizeUrl } from './sanitizeUrl';
describe('sanitizeUrl', () => {
it('allows safe HTTP URLs', () => {
expect(sanitizeUrl('http://example.com')).toBe('http://example.com/');
});
it('allows safe HTTPS URLs', () => {
expect(sanitizeUrl('https://secure.com')).toBe('https://secure.com/');
});
it('allows safe URLs with query parameters', () => {
const testCases = [
{
input: 'https://example.com/?q=test',
expected: 'https://example.com/?q=test',
},
{
input: 'http://example.com/search?query=hello+world',
expected: 'http://example.com/search?query=hello+world',
},
{
input: 'https://example.com/path?param1=value1&param2=value2',
expected: 'https://example.com/path?param1=value1&param2=value2',
},
{
input: 'https://example.com/?redirect=http://safe.com',
expected: 'https://example.com/?redirect=http://safe.com',
},
{
input: 'https://example.com/?xss=%3Cscript%3Ealert(1)%3C%2Fscript%3E',
expected: 'https://example.com/?xss=%3Cscript%3Ealert(1)%3C%2Fscript%3E',
},
{
input: 'https://example.com/search?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E',
expected: 'https://example.com/search?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E',
},
{
input: 'https://example.com/?param1=%22%3E%3Cscript%3Ealert(1)%3C%2Fscript%3E',
expected: 'https://example.com/?param1=%22%3E%3Cscript%3Ealert(1)%3C%2Fscript%3E',
},
{
input: 'https://example.com/?q=%3Cimg+src%3D%22javascript%3Aalert(1)%22%3E',
expected: 'https://example.com/?q=%3Cimg+src%3D%22javascript%3Aalert(1)%22%3E',
},
];
testCases.forEach(({ input, expected }) => {
expect(sanitizeUrl(input)).toBe(expected);
});
});
describe('sanitizeUrl - XSS Payloads', () => {
it('sanitizes javascript: URLs', () => {
const payloads = [
'javascript:alert(1)',
'javascript:confirm("XSS")',
'javascript:/*XSS*/alert(1)',
'javascript:eval("alert(1)")',
'javascript:window.location="http://evil.com"',
];
payloads.forEach((payload) => {
expect(sanitizeUrl(payload)).toBe('#');
});
});
it('sanitizes data: URLs', () => {
const payloads = [
'data:text/html,<script>alert("XSS")</script>',
'data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==',
'data:image/svg+xml,<svg onload=alert(1)>',
'data:text/html,<iframe src="javascript:alert(1)"></iframe>',
'data:text/html;charset=utf-8,<script>confirm("XSS")</script>',
];
payloads.forEach((payload) => {
expect(sanitizeUrl(payload)).toBe('#');
});
});
it('sanitizes vbscript: URLs', () => {
const payloads = [
'vbscript:msgbox("XSS")',
'vbscript:alert("Hello")',
'vbscript:Execute("msgbox(123)")',
'VBScript:CreateObject("WScript.Shell").Run("calc")',
'VBSCRIPT:MsgBox("payload")',
];
payloads.forEach((payload) => {
expect(sanitizeUrl(payload)).toBe('#');
});
});
});
it('sanitizes empty string', () => {
expect(sanitizeUrl('')).toBe('#');
});
it('is case-insensitive with protocols', () => {
expect(sanitizeUrl('JAVASCRIPT:alert(1)')).toBe('#');
});
it('allows bare domain names', () => {
expect(sanitizeUrl('example.com/page')).toBe('//example.com/page');
});
});

View File

@@ -0,0 +1,19 @@
export const sanitizeUrl = (href: string) => {
if (!href) {
return '#';
}
try {
const hasProtocol = /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(href);
if (hasProtocol) {
const url = new URL(href);
const dangerousProtocols = ['javascript:', 'data:', 'vbscript:'];
return dangerousProtocols.includes(url.protocol.toLowerCase()) ? '#' : url.href;
}
return `//${href}`;
} catch {
return '#';
}
};

View File

@@ -0,0 +1,17 @@
import type * as MessageParser from '@rocket.chat/message-parser';
import BigEmojiElement from './BigEmojiElement';
export type BigEmojiBlockProps = {
emoji: MessageParser.Emoji[];
};
const BigEmojiBlock = ({ emoji }: BigEmojiBlockProps) => (
<div role='presentation'>
{emoji.map((emoji, index) => (
<BigEmojiElement key={index} {...emoji} />
))}
</div>
);
export default BigEmojiBlock;

View File

@@ -0,0 +1,9 @@
import type * as MessageParser from '@rocket.chat/message-parser';
import Emoji from './Emoji';
export type BigEmojiElementProps = MessageParser.Emoji;
const BigEmojiElement = (emoji: BigEmojiElementProps) => <Emoji big {...emoji} />;
export default BigEmojiElement;

View File

@@ -0,0 +1,32 @@
import type * as MessageParser from '@rocket.chat/message-parser';
import { useMemo, useContext, memo } from 'react';
import { MarkupInteractionContext } from '../MarkupInteractionContext';
import EmojiRenderer from './EmojiRenderer';
import PlainSpan from '../elements/PlainSpan';
export type EmojiProps = MessageParser.Emoji & {
big?: boolean;
preview?: boolean;
};
const Emoji = ({ big = false, preview = false, ...emoji }: EmojiProps) => {
const { convertAsciiToEmoji, useEmoji } = useContext(MarkupInteractionContext);
const asciiEmoji = useMemo(
() => ('shortCode' in emoji && emoji.value.value !== emoji.shortCode ? emoji.value.value : undefined),
[emoji],
);
if (!useEmoji && 'shortCode' in emoji) {
return <PlainSpan text={emoji.shortCode === emoji.value.value ? `:${emoji.shortCode}:` : emoji.value.value} />;
}
if (!convertAsciiToEmoji && asciiEmoji) {
return <PlainSpan text={asciiEmoji} />;
}
return <EmojiRenderer big={big} preview={preview} {...emoji} />;
};
export default memo(Emoji);

View File

@@ -0,0 +1,10 @@
import type * as MessageParser from '@rocket.chat/message-parser';
import { memo } from 'react';
import Emoji from './Emoji';
export type EmojiElementProps = MessageParser.Emoji;
const EmojiElement = (emoji: EmojiElementProps) => <Emoji {...emoji} />;
export default memo(EmojiElement);

View File

@@ -0,0 +1,47 @@
import { MessageEmoji, ThreadMessageEmoji } from '@rocket.chat/fuselage';
import type * as MessageParser from '@rocket.chat/message-parser';
import { useContext, memo } from 'react';
import { MarkupInteractionContext } from '../MarkupInteractionContext';
import { useEmojiDescriptors } from './useEmojiDescriptors';
import PlainSpan from '../elements/PlainSpan';
type EmojiProps = MessageParser.Emoji & {
big?: boolean;
preview?: boolean;
};
const EmojiRenderer = ({ big = false, preview = false, ...emoji }: EmojiProps) => {
const { useEmoji } = useContext(MarkupInteractionContext);
const { descriptors, sanitizedFallback } = useEmojiDescriptors(emoji);
//handles unicode emojis
if (!useEmoji) {
return <PlainSpan text={descriptors?.map(({ name, content }) => name || content).join('') ?? sanitizedFallback} />;
}
return (
<>
{descriptors?.map(({ name, className, image, content }, i) => (
<span key={i} title={name}>
{preview ? (
<ThreadMessageEmoji className={className} name={name} image={image}>
{content}
</ThreadMessageEmoji>
) : (
<MessageEmoji big={big} className={className} name={name} image={image}>
{content}
</MessageEmoji>
)}
</span>
)) ?? (
<span title={sanitizedFallback} role='img' aria-label={sanitizedFallback.charAt(0) === ':' ? sanitizedFallback : undefined}>
{sanitizedFallback}
</span>
)}
</>
);
};
export default memo(EmojiRenderer);

View File

@@ -0,0 +1,17 @@
import type * as MessageParser from '@rocket.chat/message-parser';
import PreviewEmojiElement from './PreviewEmojiElement';
export type PreviewBigEmojiBlockProps = {
emoji: MessageParser.Emoji[];
};
const PreviewBigEmojiBlock = ({ emoji }: PreviewBigEmojiBlockProps) => (
<>
{emoji.map((emoji, index) => (
<PreviewEmojiElement key={index} {...emoji} />
))}
</>
);
export default PreviewBigEmojiBlock;

View File

@@ -0,0 +1,9 @@
import type * as MessageParser from '@rocket.chat/message-parser';
import Emoji from './Emoji';
export type PreviewEmojiElementProps = MessageParser.Emoji;
const PreviewEmojiElement = (emoji: PreviewEmojiElementProps) => <Emoji preview {...emoji} />;
export default PreviewEmojiElement;

View File

@@ -0,0 +1,20 @@
import type * as MessageParser from '@rocket.chat/message-parser';
import DOMPurify from 'dompurify';
import { useContext, useMemo } from 'react';
import { MarkupInteractionContext } from '../MarkupInteractionContext';
export const useEmojiDescriptors = (emoji: MessageParser.Emoji) => {
const { detectEmoji } = useContext(MarkupInteractionContext);
const fallback = useMemo(() => ('unicode' in emoji ? emoji.unicode : `:${emoji.shortCode ?? emoji.value.value}:`), [emoji]);
const sanitizedFallback = useMemo(() => DOMPurify.sanitize(fallback), [fallback]);
const descriptors = useMemo(() => {
const detected = detectEmoji?.(sanitizedFallback);
return detected?.length !== 0 ? detected : undefined;
}, [detectEmoji, sanitizedFallback]);
return { descriptors, sanitizedFallback };
};

View File

@@ -0,0 +1,4 @@
export type { UserMention, ChannelMention } from './MarkupInteractionContext';
export { MarkupInteractionContext } from './MarkupInteractionContext';
export { default as Markup } from './Markup';
export { default as PreviewMarkup } from './PreviewMarkup';

View File

@@ -0,0 +1,26 @@
import katex from 'katex';
import { useMemo } from 'react';
import 'katex/dist/katex.css';
export type KatexBlockProps = {
code: string;
};
const KatexBlock = ({ code }: KatexBlockProps) => {
const html = useMemo(
() =>
katex.renderToString(code, {
displayMode: true,
macros: {
'\\href': '\\@secondoftwo',
},
maxSize: 100,
}),
[code],
);
return <div role='math' style={{ overflowX: 'auto' }} aria-label={code} dangerouslySetInnerHTML={{ __html: html }} />;
};
export default KatexBlock;

View File

@@ -0,0 +1,26 @@
import katex from 'katex';
import { useMemo } from 'react';
import 'katex/dist/katex.css';
export type KatexElementProps = {
code: string;
};
const KatexElement = ({ code }: KatexElementProps) => {
const html = useMemo(
() =>
katex.renderToString(code, {
displayMode: false,
macros: {
'\\href': '\\@secondoftwo',
},
maxSize: 100,
}),
[code],
);
return <span dangerouslySetInnerHTML={{ __html: html }} />;
};
export default KatexElement;

View File

@@ -0,0 +1,23 @@
import colors from '@rocket.chat/fuselage-tokens/dist/colors.json';
import styled from '@rocket.chat/styled';
import type { PropsWithChildren } from 'react';
import { useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
export type KatexErrorBoundaryProps = PropsWithChildren<{ code: string }>;
const Fallback = styled('span')`
text-decoration: underline;
text-decoration-color: ${colors.r400};
`;
const KatexErrorBoundary = ({ children, code }: KatexErrorBoundaryProps) => {
const [error, setError] = useState<unknown>(null);
return (
<ErrorBoundary onError={setError} fallback={<Fallback title={(error as Error)?.message}>{code}</Fallback>}>
{children}
</ErrorBoundary>
);
};
export default KatexErrorBoundary;

View File

@@ -0,0 +1,9 @@
import 'katex/dist/katex.css';
export type PreviewKatexBlockProps = {
code: string;
};
const PreviewKatexBlock = ({ code }: PreviewKatexBlockProps) => <>{code}</>;
export default PreviewKatexBlock;

View File

@@ -0,0 +1,9 @@
import 'katex/dist/katex.css';
export type PreviewKatexElementProps = {
code: string;
};
const PreviewKatexElement = ({ code }: PreviewKatexElementProps) => <>{code}</>;
export default PreviewKatexElement;

View File

@@ -0,0 +1,33 @@
import { MessageHighlight } from '@rocket.chat/fuselage';
import { useButtonPattern } from '@rocket.chat/fuselage-hooks';
import { memo, useContext, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { MarkupInteractionContext } from '../MarkupInteractionContext';
export type ChannelMentionElementProps = {
mention: string;
};
const handleChannelMention = (mention: string, withSymbol: boolean | undefined): string => (withSymbol ? `#${mention}` : mention);
const ChannelMentionElement = ({ mention }: ChannelMentionElementProps) => {
const { t } = useTranslation();
const { resolveChannelMention, onChannelMentionClick, showMentionSymbol } = useContext(MarkupInteractionContext);
const resolved = useMemo(() => resolveChannelMention?.(mention), [mention, resolveChannelMention]);
const handleClick = useMemo(() => (resolved ? onChannelMentionClick?.(resolved) : undefined), [resolved, onChannelMentionClick]);
const buttonProps = useButtonPattern((e) => handleClick?.(e));
if (!resolved) {
return <>#{mention}</>;
}
return (
<MessageHighlight title={t('Mentions_channel')} variant='link' clickable {...buttonProps}>
{handleChannelMention(resolved.fname ?? mention, showMentionSymbol)}
</MessageHighlight>
);
};
export default memo(ChannelMentionElement);

View File

@@ -0,0 +1,9 @@
import { memo } from 'react';
export type PreviewChannelMentionElementProps = {
mention: string;
};
const PreviewChannelMentionElement = ({ mention }: PreviewChannelMentionElementProps) => <>#{mention}</>;
export default memo(PreviewChannelMentionElement);

View File

@@ -0,0 +1,9 @@
import { memo } from 'react';
export type PreviewUserMentionElementProps = {
mention: string;
};
const PreviewUserMentionElement = ({ mention }: PreviewUserMentionElementProps) => <>@{mention}</>;
export default memo(PreviewUserMentionElement);

View File

@@ -0,0 +1,58 @@
import { MessageHighlight } from '@rocket.chat/fuselage';
import { useButtonPattern } from '@rocket.chat/fuselage-hooks';
import { memo, useContext, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { MarkupInteractionContext } from '../MarkupInteractionContext';
export type UserMentionElementProps = {
mention: string;
};
const handleUserMention = (mention: string | undefined, withSymbol: boolean | undefined): string | undefined =>
withSymbol ? `@${mention}` : mention;
const UserMentionElement = ({ mention }: UserMentionElementProps) => {
const { t } = useTranslation();
const { resolveUserMention, onUserMentionClick, ownUserId, useRealName, showMentionSymbol, triggerProps } =
useContext(MarkupInteractionContext);
const resolved = useMemo(() => resolveUserMention?.(mention), [mention, resolveUserMention]);
const handleClick = useMemo(() => (resolved ? onUserMentionClick?.(resolved) : undefined), [resolved, onUserMentionClick]);
const buttonProps = useButtonPattern((e) => handleClick?.(e));
if (mention === 'all') {
return (
<MessageHighlight title={t('Mentions_all_room_members')} variant='relevant'>
{handleUserMention('all', showMentionSymbol)}
</MessageHighlight>
);
}
if (mention === 'here') {
return (
<MessageHighlight title={t('Mentions_online_room_members')} variant='relevant'>
{handleUserMention('here', showMentionSymbol)}
</MessageHighlight>
);
}
if (!resolved) {
return <>@{mention}</>;
}
return (
<MessageHighlight
variant={resolved._id === ownUserId ? 'critical' : 'other'}
title={resolved._id === ownUserId ? t('Mentions_you') : t('Mentions_user')}
clickable
{...buttonProps}
{...triggerProps}
data-uid={resolved._id}
>
{handleUserMention((useRealName ? resolved.name : resolved.username) ?? mention, showMentionSymbol)}
</MessageHighlight>
);
};
export default memo(UserMentionElement);

View File

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

View File

@@ -0,0 +1,7 @@
{
"extends": "@rocket.chat/tsconfig/client.json",
"compilerOptions": {
"rootDirs": ["./src", "./"]
},
"include": ["./src/**/*", "./jest.config.ts", "./.storybook/**/*"]
}