Skip to content
5 changes: 5 additions & 0 deletions .changeset/block-fallback-rendering.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@rocket.chat/gazzodown": patch
---

Degrades blocks without a dedicated renderer to their raw markup instead of dropping them. When a block carries a `fallback` `[start, end]` offset span, `Markup`/`PreviewMarkup` slice the original message source (passed via the new optional `source` prop) and render that text. This avoids duplicating the markup into the AST while keeping unsupported blocks visible.
5 changes: 5 additions & 0 deletions .changeset/message-content-body-source.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@rocket.chat/meteor": patch
---

Passes the original message text to the message renderer so blocks without a dedicated renderer (e.g. tables on clients that don't render them yet) can degrade to their raw markup via the parser's `fallback` source offsets, instead of disappearing.
5 changes: 5 additions & 0 deletions .changeset/timestamp-fallback-offsets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@rocket.chat/message-parser": minor
---

Normalizes the `Timestamp` node's `fallback` to the same `[start, end]` source-offset span used by other blocks, instead of a reconstructed plain-text node. The type still allows the previous `Plain` form so already-persisted data keeps type-checking and is safely ignored at render time.
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,19 @@ import type { MessageWithMdEnforced } from '../../lib/parseMessageTextToAstMarkd
import GazzodownText from '../GazzodownText';

type MessageContentBodyProps = Pick<MessageWithMdEnforced, 'mentions' | 'channels' | 'md'> & {
/** Original source text the `md` was parsed from; used to render the fallback of unsupported blocks. */
msg?: string;
searchText?: string;
} & ComponentProps<typeof MessageBody>;

const MessageContentBody = ({ mentions, channels, md, searchText, ...props }: MessageContentBodyProps) => {
const MessageContentBody = ({ mentions, channels, md, msg, searchText, ...props }: MessageContentBodyProps) => {
const { t } = useTranslation();

return (
<MessageBody role='document' aria-roledescription={t('message_body')} dir='auto' {...props}>
<Suspense fallback={<Skeleton />}>
<GazzodownText channels={channels} mentions={mentions} searchText={searchText}>
<Markup tokens={md} />
<Markup tokens={md} source={msg} />
</GazzodownText>
</Suspense>
</MessageBody>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ const RoomMessageContent = ({ message, unread, all, mention, searchText }: RoomM
<MessageContentBody
id={`${normalizedMessage._id}-content`}
md={normalizedMessage.md}
msg={normalizedMessage.mdSource}
mentions={normalizedMessage.mentions}
channels={normalizedMessage.channels}
searchText={searchText}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,12 @@ const ThreadMessageContent = ({ message }: ThreadMessageContentProps) => {
{!normalizedMessage.blocks?.length && !!normalizedMessage.md?.length && (
<>
{(!encrypted || normalizedMessage.e2e === 'done') && (
<MessageContentBody md={normalizedMessage.md} mentions={normalizedMessage.mentions} channels={normalizedMessage.channels} />
<MessageContentBody
md={normalizedMessage.md}
msg={normalizedMessage.mdSource}
mentions={normalizedMessage.mentions}
channels={normalizedMessage.channels}
/>
)}
</>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ describe('parseMessageTextToAstMarkdown', () => {
const attachmentTranslatedMessageParsed = {
...translatedMessage,
md: translatedMessageParsed,
mdSource: 'message translated',
attachments: [
{
description: 'description',
Expand Down Expand Up @@ -233,6 +234,7 @@ describe('parseMessageTextToAstMarkdown', () => {
const attachmentTranslatedMessageParsed = {
...translatedMessage,
md: translatedMessageParsed,
mdSource: 'message translated',
attachments: [
{
text: 'text',
Expand Down Expand Up @@ -275,6 +277,7 @@ describe('parseMessageTextToAstMarkdown', () => {
const attachmentTranslatedMessageParsed = {
...translatedMessage,
md: translatedMessageParsed,
mdSource: 'message translated',
attachments: [
{
text: 'text',
Expand Down
8 changes: 7 additions & 1 deletion apps/meteor/client/lib/parseMessageTextToAstMarkdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ type WithRequiredProperty<Type, Key extends keyof Type> = Omit<Type, Key> & {
};

export type MessageWithMdEnforced<TMessage extends IMessage & Partial<ITranslatedMessage> = IMessage & Partial<ITranslatedMessage>> =
WithRequiredProperty<TMessage, 'md'>;
WithRequiredProperty<TMessage, 'md'> & {
/** The exact source text `md` was parsed from (translation-aware), so its `fallback` offsets can be sliced. */
mdSource?: string;
};
/**
* Removes null values for known properties values.
* Adds a property `md` to the message with the parsed message if is not provided.
Expand Down Expand Up @@ -46,6 +49,9 @@ export const parseMessageTextToAstMarkdown = <
return {
...msg,
md: isE2EEMessage(message) || translated ? textToMessageToken(text, parseOptions) : (msg.md ?? textToMessageToken(text, parseOptions)),
// `text` is the exact string `md` was parsed from (translation/E2EE-aware, and equal to
// `msg.msg` otherwise), so block `fallback` offsets slice against the right source.
mdSource: text,
...(msg.attachments && {
attachments: parseMessageAttachments(msg.attachments, parseOptions, { autoTranslateLanguage, translated }),
}),
Expand Down
15 changes: 13 additions & 2 deletions packages/gazzodown/src/Markup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@ const KatexBlock = lazy(() => import('./katex/KatexBlock'));

type MarkupProps = {
tokens: MessageParser.Root;
/** Original message source, used to render the `fallback` of blocks without a dedicated renderer. */
source?: string;
};

const Markup = ({ tokens }: MarkupProps) => (
const Markup = ({ tokens, source }: MarkupProps) => (
<>
{tokens.map((block, index) => {
switch (block.type) {
Expand Down Expand Up @@ -63,8 +65,17 @@ const Markup = ({ tokens }: MarkupProps) => (
case 'LINE_BREAK':
return <br key={index} />;

default:
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;
}
}
})}
</>
Expand Down
14 changes: 12 additions & 2 deletions packages/gazzodown/src/PreviewMarkup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ const isOnlyBigEmojiBlock = (tokens: MessageParser.Root): tokens is [MessagePars

type PreviewMarkupProps = {
tokens: MessageParser.Root;
/** Original message source, used to render the `fallback` of blocks without a dedicated renderer. */
source?: string;
};

const PreviewMarkup = ({ tokens }: PreviewMarkupProps) => {
const PreviewMarkup = ({ tokens, source }: PreviewMarkupProps) => {
if (isOnlyBigEmojiBlock(tokens)) {
return <PreviewBigEmojiBlock emoji={tokens[0].value} />;
}
Expand Down Expand Up @@ -84,8 +86,16 @@ const PreviewMarkup = ({ tokens }: PreviewMarkupProps) => {
</KatexErrorBoundary>
);

default:
default: {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Fallback extraction and rendering logic is duplicated from Markup.tsx. Extract a shared helper for converting (fallback, source) to a MessageParser.Inlines[] array so that fixes to the fallback contract or rendering behavior are applied consistently in both full and preview renderers.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/gazzodown/src/PreviewMarkup.tsx, line 89:

<comment>Fallback extraction and rendering logic is duplicated from `Markup.tsx`. Extract a shared helper for converting `(fallback, source)` to a `MessageParser.Inlines[]` array so that fixes to the fallback contract or rendering behavior are applied consistently in both full and preview renderers.</comment>

<file context>
@@ -84,8 +86,16 @@ const PreviewMarkup = ({ tokens }: PreviewMarkupProps) => {
 			);
 
-		default:
+		default: {
+			// Only the `[start, end]` offset form is rendered (sliced from source); the union
+			// keeps the original fallback form too, which we intentionally ignore.
</file context>

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

Expand Down
9 changes: 8 additions & 1 deletion packages/message-parser/src/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,11 @@ export type Plain = {
value: string;
};

// [start, end] offsets into the original parsed source. Consumers that don't
// implement a renderer for a node can slice the source to show the raw markup,
// without duplicating the text into the AST.
export type SourceRange = [number, number];

export type LineBreak = {
type: 'LINE_BREAK';
value: undefined;
Expand Down Expand Up @@ -170,7 +175,9 @@ export type Timestamp = {
timestamp: string;
format: 't' | 'T' | 'd' | 'D' | 'f' | 'F' | 'R';
};
fallback?: Plain;
// New form is a `[start, end]` offset span; the `Plain` form is kept in the
// type only to tolerate previously-persisted data at runtime.
fallback?: SourceRange | Plain;
};

export type Types = {
Expand Down
2 changes: 1 addition & 1 deletion packages/message-parser/src/grammar.pegjs
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ ISO8601Date = year:$(Digit |4|) "-" month:$(Digit |2|) "-" day:$(Digit |2|) "T"
ISO8601DateWithoutMilliseconds = year:$(Digit |4|) "-" month:$(Digit |2|) "-" day:$(Digit |2|) "T" hours:$(Digit |2|) ":" minutes:$(Digit |2|) ":" seconds:$(Digit |2|) tz:Timezone? { return timestampFromIsoTime({ year, month, day, hours, minutes, seconds, timezone: tz }); }


TimestampRules = "<t:" date:(Unixtime / ISO8601Date / ISO8601DateWithoutMilliseconds / Timestamp) ":" format:TimestampType ">" { return timestamp(date, format); } / "<t:" date:(Unixtime / ISO8601Date / ISO8601DateWithoutMilliseconds / Timestamp) ">" { return timestamp(date); }
TimestampRules = "<t:" date:(Unixtime / ISO8601Date / ISO8601DateWithoutMilliseconds / Timestamp) ":" format:TimestampType ">" { return timestamp(date, format, [range().start, range().end]); } / "<t:" date:(Unixtime / ISO8601Date / ISO8601DateWithoutMilliseconds / Timestamp) ">" { return timestamp(date, undefined, [range().start, range().end]); }

/**
*
Expand Down
5 changes: 3 additions & 2 deletions packages/message-parser/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,13 @@
InlineKaTeX,
Link,
Timestamp,
SourceRange,
} from './definitions';

const generate =
<Type extends keyof Types>(type: Type) =>
(value: Types[Type]['value']): Types[Type] =>
({ type, value }) as any;

Check warning on line 26 in packages/message-parser/src/utils.ts

View workflow job for this annotation

GitHub Actions / 🔎 Code Check / Code Lint

Unsafe return of a value of type `any`

export const paragraph = generate('PARAGRAPH');

Expand Down Expand Up @@ -191,7 +192,7 @@
let needsSlowPath = false;
for (let i = 0; i < flattenableValues.length; i++) {
const v = flattenableValues[i];
if (Array.isArray(v) || (v as Inlines).type === 'EMOJI') {

Check warning on line 195 in packages/message-parser/src/utils.ts

View workflow job for this annotation

GitHub Actions / 🔎 Code Check / Code Lint

This assertion is unnecessary since it does not change the type of the expression
needsSlowPath = true;
break;
}
Expand Down Expand Up @@ -287,14 +288,14 @@
return link(`tel:${number}`, [plain(text)]);
};

export const timestamp = (value: string, type?: 't' | 'T' | 'd' | 'D' | 'f' | 'F' | 'R'): Timestamp => {
export const timestamp = (value: string, type?: 't' | 'T' | 'd' | 'D' | 'f' | 'F' | 'R', fallback?: SourceRange): Timestamp => {
return {
type: 'TIMESTAMP',
value: {
timestamp: value,
format: type || 't',
},
fallback: plain(`<t:${value}:${type || 't'}>`),
...(fallback !== undefined && { fallback }),
};
};

Expand Down
58 changes: 34 additions & 24 deletions packages/message-parser/tests/timestamp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,29 @@ const bold = (value: Array<Record<string, unknown>>) => ({ type: 'BOLD' as const

const strike = (value: Array<Record<string, unknown>>) => ({ type: 'STRIKE' as const, value });

const timestampNode = (value: string, format: 't' | 'T' | 'd' | 'D' | 'f' | 'F' | 'R' = 't') => ({
// `fallback` is the [start, end] offset span of the raw `<t:...>` in the source.
const timestampNode = (value: string, format: 't' | 'T' | 'd' | 'D' | 'f' | 'F' | 'R' = 't', fallback?: [number, number]) => ({
type: 'TIMESTAMP' as const,
value: {
timestamp: value,
format,
},
fallback: plain(`<t:${value}:${format}>`),
...(fallback !== undefined ? { fallback } : {}),
});

const spanOf = (input: string, raw: string): [number, number] => {
const start = input.indexOf(raw);
return [start, start + raw.length];
};

test.each([
[`<t:1708551317>`, [paragraph([timestampNode('1708551317')])]],
[`<t:1708551317:R>`, [paragraph([timestampNode('1708551317', 'R')])]],
['hello <t:1708551317>', [paragraph([plain('hello '), timestampNode('1708551317')])]],
])('parses %p', (input, output) => {
expect(parse(input)).toEqual(output);
['<t:1708551317>', '<t:1708551317>', '1708551317', 't' as const],
['<t:1708551317:R>', '<t:1708551317:R>', '1708551317', 'R' as const],
['hello <t:1708551317>', '<t:1708551317>', '1708551317', 't' as const],
])('parses %p', (input, raw, value, format) => {
const node = timestampNode(value, format, spanOf(input, raw));
const prefix = input.slice(0, input.indexOf(raw));
expect(parse(input)).toEqual([paragraph(prefix ? [plain(prefix), node] : [node])]);
});

test.each([
Expand All @@ -33,20 +41,21 @@ test.each([
});

test.each([
['~<t:1708551317>~', [paragraph([strike([timestampNode('1708551317')])])]],
['~<t:1708551317:R>~', [paragraph([strike([timestampNode('1708551317', 'R')])])]],
['*<t:1708551317>*', [paragraph([bold([timestampNode('1708551317')])])]],
])('parses %p', (input, output) => {
expect(parse(input)).toEqual(output);
['~<t:1708551317>~', '<t:1708551317>', '1708551317', 't' as const, strike],
['~<t:1708551317:R>~', '<t:1708551317:R>', '1708551317', 'R' as const, strike],
['*<t:1708551317>*', '<t:1708551317>', '1708551317', 't' as const, bold],
])('parses %p', (input, raw, value, format, wrapper) => {
const node = timestampNode(value, format, spanOf(input, raw));
expect(parse(input)).toEqual([paragraph([wrapper([node])])]);
});

test.each([
['<t:2025-07-22T10:00:00.000+00:00:R>', [paragraph([timestampNode('1753178400', 'R')])]],
['<t:2025-07-22T10:00:00+00:00:R>', [paragraph([timestampNode('1753178400', 'R')])]],

['<t:2025-07-24T20:19:58.154+00:00:R>', [paragraph([timestampNode('1753388398', 'R')])]],
])('parses %p', (input, output) => {
expect(parse(input)).toEqual(output);
['<t:2025-07-22T10:00:00.000+00:00:R>', '1753178400', 'R' as const],
['<t:2025-07-22T10:00:00+00:00:R>', '1753178400', 'R' as const],
['<t:2025-07-24T20:19:58.154+00:00:R>', '1753388398', 'R' as const],
])('parses %p', (input, value, format) => {
const node = timestampNode(value, format, [0, input.length]);
expect(parse(input)).toEqual([paragraph([node])]);
});

describe('relative hour timestamp parsing', () => {
Expand All @@ -60,11 +69,12 @@ describe('relative hour timestamp parsing', () => {
});

test.each([
['<t:10:00:00+00:00:R>', [paragraph([timestampNode('1753178400', 'R')])]],
['<t:10:00+00:00:R>', [paragraph([timestampNode('1753178400', 'R')])]],
['<t:10:00:05+00:00>', [paragraph([timestampNode('1753178405')])]],
['<t:10:00+00:00>', [paragraph([timestampNode('1753178400')])]],
])('parses %p', (input, output) => {
expect(parse(input)).toEqual(output);
['<t:10:00:00+00:00:R>', '1753178400', 'R' as const],
['<t:10:00+00:00:R>', '1753178400', 'R' as const],
['<t:10:00:05+00:00>', '1753178405', 't' as const],
['<t:10:00+00:00>', '1753178400', 't' as const],
])('parses %p', (input, value, format) => {
const node = timestampNode(value, format, [0, input.length]);
expect(parse(input)).toEqual([paragraph([node])]);
});
});
Loading