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(