From f98fb5ef83453df932559628e76651debb2256e1 Mon Sep 17 00:00:00 2001 From: Salastil Date: Sat, 29 Aug 2026 10:57:34 -0400 Subject: [PATCH] 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 --- nobilis | 2 +- src/renderer/src/app.css | 40 ++++++++++++ src/renderer/src/components/MessageRow.tsx | 25 ++++++-- src/renderer/src/lib/richtext.tsx | 75 +++++++++++++++++++++- src/shared/wire.ts | 7 ++ 5 files changed, 140 insertions(+), 9 deletions(-) diff --git a/nobilis b/nobilis index 2821bc9..1e11ce2 160000 --- a/nobilis +++ b/nobilis @@ -1 +1 @@ -Subproject commit 2821bc926a4898b1f42a10a17f92c1e63bf117ab +Subproject commit 1e11ce222cc5f8260892c7d8a8fa9708cab4af04 diff --git a/src/renderer/src/app.css b/src/renderer/src/app.css index ad9a04f..ded64ad 100644 --- a/src/renderer/src/app.css +++ b/src/renderer/src/app.css @@ -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. */ diff --git a/src/renderer/src/components/MessageRow.tsx b/src/renderer/src/components/MessageRow.tsx index c5b747a..9e9bcaa 100644 --- a/src/renderer/src/components/MessageRow.tsx +++ b/src/renderer/src/components/MessageRow.tsx @@ -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() }, diff --git a/src/renderer/src/lib/richtext.tsx b/src/renderer/src/lib/richtext.tsx index 65f871c..4114df2 100644 --- a/src/renderer/src/lib/richtext.tsx +++ b/src/renderer/src/lib/richtext.tsx @@ -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({kids()}) 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(

{kids()}

) + break + case 'CODE': + out.push( + + {kids()} + + ) + break + case 'PRE': + out.push(
{kids()}
) + break + case 'BLOCKQUOTE': + out.push(
{kids()}
) + break + case 'UL': + out.push() + break + case 'OL': + out.push(
    {kids()}
) + break + case 'LI': + out.push(
  • {kids()}
  • ) + 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({kids()}) + break } }) diff --git a/src/shared/wire.ts b/src/shared/wire.ts index 49f77ef..b736ff9 100644 --- a/src/shared/wire.ts +++ b/src/shared/wire.ts @@ -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 }