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
Code scanning - action / CodeQL-Build (push) Failing after 1s
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,72 @@
import type * as MessageParser from '@rocket.chat/message-parser';
import ItalicSpan from './ItalicSpan';
import LinkSpan from './LinkSpan';
import PlainSpan from './PlainSpan';
import StrikeSpan from './StrikeSpan';
import CodeElement from '../code/CodeElement';
import EmojiElement from '../emoji/EmojiElement';
import ChannelMentionElement from '../mentions/ChannelMentionElement';
import UserMentionElement from '../mentions/UserMentionElement';
type MessageBlock =
| MessageParser.Emoji
| MessageParser.ChannelMention
| MessageParser.UserMention
| MessageParser.Link
| MessageParser.MarkupExcluding<MessageParser.Bold>
| MessageParser.InlineCode;
export type BoldSpanProps = {
children: MessageBlock[];
};
const BoldSpan = ({ children }: BoldSpanProps) => (
<>
{children.map((block, index) => {
if (
block.type === 'LINK' ||
block.type === 'PLAIN_TEXT' ||
block.type === 'STRIKE' ||
block.type === 'ITALIC' ||
block.type === 'INLINE_CODE'
) {
return <strong key={index}>{renderBlockComponent(block, index)}</strong>;
}
return renderBlockComponent(block, index);
})}
</>
);
const renderBlockComponent = (block: MessageBlock, index: number) => {
switch (block.type) {
case 'EMOJI':
return <EmojiElement key={index} {...block} />;
case 'MENTION_USER':
return <UserMentionElement key={index} mention={block.value.value} />;
case 'MENTION_CHANNEL':
return <ChannelMentionElement key={index} mention={block.value.value} />;
case 'PLAIN_TEXT':
return <PlainSpan key={index} text={block.value} />;
case 'LINK':
return <LinkSpan key={index} href={block.value.src.value} label={block.value.label} />;
case 'STRIKE':
return <StrikeSpan key={index}>{block.value}</StrikeSpan>;
case 'ITALIC':
return <ItalicSpan key={index}>{block.value}</ItalicSpan>;
case 'INLINE_CODE':
return <CodeElement key={index} code={block.value.value} />;
default:
return null;
}
};
export default BoldSpan;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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