Render the formatting a message was sent with

A Matrix message carrying its own formatted body is shown with it rather
than being run through the BBCode and markdown pass, which would mangle
markup that is already markup. Media, code and quote extraction still
read the plain body, so link previews keep working on a formatted
message.

The sanitiser gained the structural tags that formatting actually uses -
paragraphs, lists, quotes, code, headings. Each needed a render case and
not just a place on the whitelist: the walk has no default branch, so a
tag allowed through without one renders as nothing at all, children
included. None of them can carry behaviour, and everything outside the
list still degrades to its text.

Headings render as bold. A real heading inside a chat line would tower
over the conversation around it.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-08-29 10:57:34 -04:00
co-authored by Claude Opus 5
parent 0085053f26
commit f98fb5ef83
5 changed files with 140 additions and 9 deletions
+1 -1
Submodule nobilis updated: 2821bc926a...1e11ce222c
+40
View File
@@ -1914,6 +1914,46 @@ img.smilie {
A row is "what it is" against "what you can do about it", with the name
allowed to shrink: a room id is long, and the two buttons must stay
reachable rather than being pushed off the edge by it. */
/* Formatting a sender supplied themselves - Matrix's formatted_body.
Deliberately restrained: a message is a line in a conversation, so lists
and quotes get structure without the vertical space a document would take,
and the first and last blocks lose their margins so a formatted message
sits on the same baseline as a plain one beside it. */
.message-body p {
margin: 0 0 var(--spacing-xxs);
}
.message-body p:last-child {
margin-bottom: 0;
}
.message-body ul,
.message-body ol {
margin: var(--spacing-xxs) 0;
padding-left: 1.4em;
}
.message-body blockquote {
margin: var(--spacing-xxs) 0;
padding-left: var(--spacing-s);
border-left: 2px solid var(--outline-medium);
color: var(--surface-variant-text);
}
.message-body pre {
margin: var(--spacing-xxs) 0;
padding: var(--spacing-s);
border-radius: var(--corner-radius-small);
background: var(--surface-variant);
overflow-x: auto;
white-space: pre;
}
.message-body code {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 0.9em;
}
/* The "X is typing" line. Reserves no height when empty: a row that
appears and disappears under the composer would shove the message list
up and down as people start and stop writing. */
+18 -7
View File
@@ -127,17 +127,28 @@ export function MessageRow({
body = stripQuoteBlocks(body)
return {
html: formatMessage(body, {
revealedSpoilers: revealed,
isSockchat,
smilies: smilieIndex,
channels
}),
// A sender's own formatting, where the protocol carried any: Matrix
// sends a formatted body alongside the plain one. Used instead of the
// BBCode/markdown pass rather than as well as it - it is already
// markup, and running it through a second syntax would mangle it. Not
// trusted for being structured: RichText sanitises it exactly as it
// sanitises everything else.
//
// Media, code and quote extraction still read the plain body above, so
// link previews keep working on a formatted message.
html:
message.html ??
formatMessage(body, {
revealedSpoilers: revealed,
isSockchat,
smilies: smilieIndex,
channels
}),
media,
codeBlocks,
quoteBlocks
}
}, [message.body, contentSniffing, sniffed, revealed, isSockchat, smilieIndex, channels])
}, [message.body, message.html, contentSniffing, sniffed, revealed, isSockchat, smilieIndex, channels])
const entries: MenuEntry[] = [
{ label: 'Reply', icon: 'reply', onClick: () => reply() },
+74 -1
View File
@@ -15,7 +15,38 @@ import { MEDIA_SCHEME, resolveMediaUrl } from './util'
* to its text content.
*/
const ALLOWED_TAGS = new Set(['B', 'STRONG', 'I', 'EM', 'U', 'S', 'STRIKE', 'SPAN', 'A', 'IMG', 'BR'])
const ALLOWED_TAGS = new Set([
'B',
'STRONG',
'I',
'EM',
'U',
'S',
'STRIKE',
'SPAN',
'A',
'IMG',
'BR',
// Structure, for the formatting Matrix senders actually use. None of
// these can carry behaviour - they are containers, and every attribute is
// filtered separately below - so allowing them widens what renders
// without widening what can run. Anything still outside the list keeps
// degrading to its text.
'P',
'CODE',
'PRE',
'BLOCKQUOTE',
'UL',
'OL',
'LI',
'DEL',
'H1',
'H2',
'H3',
'H4',
'H5',
'H6'
])
/**
* Tags whose text content is markup rather than prose. Everything else that
@@ -197,8 +228,50 @@ function walk(node: Node, handlers: Handlers, depth: number): ReactNode[] {
break
case 'STRIKE':
case 'S':
// Matrix's own spelling of the same thing.
case 'DEL':
out.push(<s key={key}>{kids()}</s>)
break
// Structure. These need cases of their own and not just a place on the
// whitelist: the switch has no default, so a tag allowed through
// without one renders as nothing at all - its children included.
case 'P':
out.push(<p key={key}>{kids()}</p>)
break
case 'CODE':
out.push(
<code key={key} className={className}>
{kids()}
</code>
)
break
case 'PRE':
out.push(<pre key={key}>{kids()}</pre>)
break
case 'BLOCKQUOTE':
out.push(<blockquote key={key}>{kids()}</blockquote>)
break
case 'UL':
out.push(<ul key={key}>{kids()}</ul>)
break
case 'OL':
out.push(<ol key={key}>{kids()}</ol>)
break
case 'LI':
out.push(<li key={key}>{kids()}</li>)
break
// A heading inside a chat line is emphasis, not document structure -
// rendering one at heading size would tower over the conversation
// around it.
case 'H1':
case 'H2':
case 'H3':
case 'H4':
case 'H5':
case 'H6':
out.push(<b key={key}>{kids()}</b>)
break
}
})
+7
View File
@@ -229,6 +229,13 @@ export interface Message {
* their media as URLs inside the body, which link unfurling picks up.
*/
attachments?: Attachment[]
/**
* The sender's own formatted version of `body`, where the protocol carries
* one (Matrix's `formatted_body`). Restricted HTML, and still untrusted -
* render it through RichText's whitelist like anything else. `body` stays
* the plain-text fallback and is what search reads.
*/
html?: string
/** Matrix only - the sender's full MXID, for targeting moderation actions. */
senderId?: string
}