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 }