snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
15
packages/gazzodown/.storybook/main.ts
Normal file
15
packages/gazzodown/.storybook/main.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
14
packages/gazzodown/.storybook/preview.tsx
Normal file
14
packages/gazzodown/.storybook/preview.tsx
Normal 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;
|
||||
3206
packages/gazzodown/CHANGELOG.md
Normal file
3206
packages/gazzodown/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
11
packages/gazzodown/jest.config.ts
Normal file
11
packages/gazzodown/jest.config.ts
Normal 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;
|
||||
83
packages/gazzodown/package.json
Normal file
83
packages/gazzodown/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
481
packages/gazzodown/src/Markup.spec.tsx
Normal file
481
packages/gazzodown/src/Markup.spec.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
332
packages/gazzodown/src/Markup.stories.tsx
Normal file
332
packages/gazzodown/src/Markup.stories.tsx
Normal 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;
|
||||
\`\`\`
|
||||
`,
|
||||
},
|
||||
};
|
||||
92
packages/gazzodown/src/Markup.tsx
Normal file
92
packages/gazzodown/src/Markup.tsx
Normal 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);
|
||||
29
packages/gazzodown/src/MarkupInteractionContext.ts
Normal file
29
packages/gazzodown/src/MarkupInteractionContext.ts
Normal 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>({});
|
||||
21
packages/gazzodown/src/PreviewMarkup.spec.tsx
Normal file
21
packages/gazzodown/src/PreviewMarkup.spec.tsx
Normal 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();
|
||||
});
|
||||
116
packages/gazzodown/src/PreviewMarkup.tsx
Normal file
116
packages/gazzodown/src/PreviewMarkup.tsx
Normal 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 (
|
||||
<>
|
||||
> <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);
|
||||
20
packages/gazzodown/src/blocks/HeadingBlock.tsx
Normal file
20
packages/gazzodown/src/blocks/HeadingBlock.tsx
Normal 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;
|
||||
19
packages/gazzodown/src/blocks/OrderedListBlock.tsx
Normal file
19
packages/gazzodown/src/blocks/OrderedListBlock.tsx
Normal 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;
|
||||
15
packages/gazzodown/src/blocks/ParagraphBlock.tsx
Normal file
15
packages/gazzodown/src/blocks/ParagraphBlock.tsx
Normal 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;
|
||||
17
packages/gazzodown/src/blocks/QuoteBlock.tsx
Normal file
17
packages/gazzodown/src/blocks/QuoteBlock.tsx
Normal 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;
|
||||
80
packages/gazzodown/src/blocks/SpoilerBlock.tsx
Normal file
80
packages/gazzodown/src/blocks/SpoilerBlock.tsx
Normal 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;
|
||||
51
packages/gazzodown/src/blocks/TableBlock.tsx
Normal file
51
packages/gazzodown/src/blocks/TableBlock.tsx
Normal 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;
|
||||
26
packages/gazzodown/src/blocks/TaskListBlock.tsx
Normal file
26
packages/gazzodown/src/blocks/TaskListBlock.tsx
Normal 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;
|
||||
19
packages/gazzodown/src/blocks/UnorderedListBlock.tsx
Normal file
19
packages/gazzodown/src/blocks/UnorderedListBlock.tsx
Normal 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;
|
||||
112
packages/gazzodown/src/code/CodeBlock.tsx
Normal file
112
packages/gazzodown/src/code/CodeBlock.tsx
Normal 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;
|
||||
13
packages/gazzodown/src/code/CodeElement.tsx
Normal file
13
packages/gazzodown/src/code/CodeElement.tsx
Normal 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;
|
||||
19
packages/gazzodown/src/code/PreviewCodeBlock.tsx
Normal file
19
packages/gazzodown/src/code/PreviewCodeBlock.tsx
Normal 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;
|
||||
7
packages/gazzodown/src/code/PreviewCodeElement.tsx
Normal file
7
packages/gazzodown/src/code/PreviewCodeElement.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export type PreviewCodeElementProps = {
|
||||
code: string;
|
||||
};
|
||||
|
||||
const PreviewCodeElement = ({ code }: PreviewCodeElementProps) => <>{code}</>;
|
||||
|
||||
export default PreviewCodeElement;
|
||||
26
packages/gazzodown/src/colors/ColorElement.tsx
Normal file
26
packages/gazzodown/src/colors/ColorElement.tsx
Normal 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);
|
||||
33
packages/gazzodown/src/colors/PreviewColorElement.tsx
Normal file
33
packages/gazzodown/src/colors/PreviewColorElement.tsx
Normal 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
4
packages/gazzodown/src/css.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
// TS7 errors on side-effect imports of `.css` (TS2882) without a declaration.
|
||||
declare module '*.css' {
|
||||
export = undefined;
|
||||
}
|
||||
72
packages/gazzodown/src/elements/BoldSpan.tsx
Normal file
72
packages/gazzodown/src/elements/BoldSpan.tsx
Normal 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;
|
||||
60
packages/gazzodown/src/elements/ImageElement.tsx
Normal file
60
packages/gazzodown/src/elements/ImageElement.tsx
Normal 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;
|
||||
93
packages/gazzodown/src/elements/InlineElements.tsx
Normal file
93
packages/gazzodown/src/elements/InlineElements.tsx
Normal 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;
|
||||
72
packages/gazzodown/src/elements/ItalicSpan.tsx
Normal file
72
packages/gazzodown/src/elements/ItalicSpan.tsx
Normal 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;
|
||||
62
packages/gazzodown/src/elements/LinkSpan.tsx
Normal file
62
packages/gazzodown/src/elements/LinkSpan.tsx
Normal 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;
|
||||
61
packages/gazzodown/src/elements/PlainSpan.tsx
Normal file
61
packages/gazzodown/src/elements/PlainSpan.tsx
Normal 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);
|
||||
77
packages/gazzodown/src/elements/PreviewInlineElements.tsx
Normal file
77
packages/gazzodown/src/elements/PreviewInlineElements.tsx
Normal 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;
|
||||
137
packages/gazzodown/src/elements/SpoilerSpan.tsx
Normal file
137
packages/gazzodown/src/elements/SpoilerSpan.tsx
Normal 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;
|
||||
73
packages/gazzodown/src/elements/StrikeSpan.tsx
Normal file
73
packages/gazzodown/src/elements/StrikeSpan.tsx
Normal 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;
|
||||
@@ -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);
|
||||
});
|
||||
141
packages/gazzodown/src/elements/Timestamp/index.tsx
Normal file
141
packages/gazzodown/src/elements/Timestamp/index.tsx
Normal 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;
|
||||
109
packages/gazzodown/src/elements/sanitizeUrl.spec.ts
Normal file
109
packages/gazzodown/src/elements/sanitizeUrl.spec.ts
Normal 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¶m2=value2',
|
||||
expected: 'https://example.com/path?param1=value1¶m2=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');
|
||||
});
|
||||
});
|
||||
19
packages/gazzodown/src/elements/sanitizeUrl.ts
Normal file
19
packages/gazzodown/src/elements/sanitizeUrl.ts
Normal 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 '#';
|
||||
}
|
||||
};
|
||||
17
packages/gazzodown/src/emoji/BigEmojiBlock.tsx
Normal file
17
packages/gazzodown/src/emoji/BigEmojiBlock.tsx
Normal 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;
|
||||
9
packages/gazzodown/src/emoji/BigEmojiElement.tsx
Normal file
9
packages/gazzodown/src/emoji/BigEmojiElement.tsx
Normal 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;
|
||||
32
packages/gazzodown/src/emoji/Emoji.tsx
Normal file
32
packages/gazzodown/src/emoji/Emoji.tsx
Normal 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);
|
||||
10
packages/gazzodown/src/emoji/EmojiElement.tsx
Normal file
10
packages/gazzodown/src/emoji/EmojiElement.tsx
Normal 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);
|
||||
47
packages/gazzodown/src/emoji/EmojiRenderer.tsx
Normal file
47
packages/gazzodown/src/emoji/EmojiRenderer.tsx
Normal 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);
|
||||
17
packages/gazzodown/src/emoji/PreviewBigEmojiBlock.tsx
Normal file
17
packages/gazzodown/src/emoji/PreviewBigEmojiBlock.tsx
Normal 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;
|
||||
9
packages/gazzodown/src/emoji/PreviewEmojiElement.tsx
Normal file
9
packages/gazzodown/src/emoji/PreviewEmojiElement.tsx
Normal 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;
|
||||
20
packages/gazzodown/src/emoji/useEmojiDescriptors.ts
Normal file
20
packages/gazzodown/src/emoji/useEmojiDescriptors.ts
Normal 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 };
|
||||
};
|
||||
4
packages/gazzodown/src/index.ts
Normal file
4
packages/gazzodown/src/index.ts
Normal 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';
|
||||
26
packages/gazzodown/src/katex/KatexBlock.tsx
Normal file
26
packages/gazzodown/src/katex/KatexBlock.tsx
Normal 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;
|
||||
26
packages/gazzodown/src/katex/KatexElement.tsx
Normal file
26
packages/gazzodown/src/katex/KatexElement.tsx
Normal 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;
|
||||
23
packages/gazzodown/src/katex/KatexErrorBoundary.tsx
Normal file
23
packages/gazzodown/src/katex/KatexErrorBoundary.tsx
Normal 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;
|
||||
9
packages/gazzodown/src/katex/PreviewKatexBlock.tsx
Normal file
9
packages/gazzodown/src/katex/PreviewKatexBlock.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import 'katex/dist/katex.css';
|
||||
|
||||
export type PreviewKatexBlockProps = {
|
||||
code: string;
|
||||
};
|
||||
|
||||
const PreviewKatexBlock = ({ code }: PreviewKatexBlockProps) => <>{code}</>;
|
||||
|
||||
export default PreviewKatexBlock;
|
||||
9
packages/gazzodown/src/katex/PreviewKatexElement.tsx
Normal file
9
packages/gazzodown/src/katex/PreviewKatexElement.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import 'katex/dist/katex.css';
|
||||
|
||||
export type PreviewKatexElementProps = {
|
||||
code: string;
|
||||
};
|
||||
|
||||
const PreviewKatexElement = ({ code }: PreviewKatexElementProps) => <>{code}</>;
|
||||
|
||||
export default PreviewKatexElement;
|
||||
33
packages/gazzodown/src/mentions/ChannelMentionElement.tsx
Normal file
33
packages/gazzodown/src/mentions/ChannelMentionElement.tsx
Normal 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);
|
||||
@@ -0,0 +1,9 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
export type PreviewChannelMentionElementProps = {
|
||||
mention: string;
|
||||
};
|
||||
|
||||
const PreviewChannelMentionElement = ({ mention }: PreviewChannelMentionElementProps) => <>#{mention}</>;
|
||||
|
||||
export default memo(PreviewChannelMentionElement);
|
||||
@@ -0,0 +1,9 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
export type PreviewUserMentionElementProps = {
|
||||
mention: string;
|
||||
};
|
||||
|
||||
const PreviewUserMentionElement = ({ mention }: PreviewUserMentionElementProps) => <>@{mention}</>;
|
||||
|
||||
export default memo(PreviewUserMentionElement);
|
||||
58
packages/gazzodown/src/mentions/UserMentionElement.tsx
Normal file
58
packages/gazzodown/src/mentions/UserMentionElement.tsx
Normal 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);
|
||||
8
packages/gazzodown/tsconfig.build.json
Normal file
8
packages/gazzodown/tsconfig.build.json
Normal 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"]
|
||||
}
|
||||
7
packages/gazzodown/tsconfig.json
Normal file
7
packages/gazzodown/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "@rocket.chat/tsconfig/client.json",
|
||||
"compilerOptions": {
|
||||
"rootDirs": ["./src", "./"]
|
||||
},
|
||||
"include": ["./src/**/*", "./jest.config.ts", "./.storybook/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user