Send Matrix messages with the formatting they were typed with

Outgoing messages were always plain m.text, so emphasis typed here
arrived everywhere else as literal asterisks - and "/me waves" arrived as
those eight characters, even though inbound emotes were already
understood.

The converter is a deliberately small subset of Markdown and not a
Markdown parser: fenced and inline code, bold, italic, strike, line
breaks, and anything else left exactly as typed. A half-built parser that
silently disagrees with what people expect is worse than none, so the
absences are the point - headings and lists are document structure, and a
chat line that becomes one because it started with a hyphen is a surprise
nobody asked for.

Two rules keep it from firing when nobody meant it to. A marker has to
hug its text, or "2 * 3 * 4" is multiplication rendered as italics; and
an unclosed marker is a character somebody typed rather than the start of
something, which otherwise swallows the rest of the line. Nothing is sent
at all unless the formatting says something the plain text does not.

Text is escaped before any of it. What goes in the box is somebody's
words and what comes out reaches other people's clients, so a tag typed
there arrives as characters.

Edits carry it too, built in one place for encrypted and unencrypted
rooms alike - two copies of that shape is exactly how a formatted body
would have reached one kind of room and not the other.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-08-29 15:59:22 -04:00
co-authored by Claude Opus 5
parent 1e11ce222c
commit 1c16051be1
3 changed files with 273 additions and 15 deletions
+4 -7
View File
@@ -354,14 +354,11 @@ impl CryptoSession {
/// `m.room.message` content under `m.new_content`, with a plain-text
/// fallback body (`* body`) for clients that don't understand edits,
/// matching the wire shape every other Matrix client sends.
pub async fn share_and_encrypt_edit(&self, homeserver_url: &str, access_token: &str, room_id: &RoomId, member_ids: Vec<OwnedUserId>, target_event_id: &str, body: &str) -> Result<Value> {
/// Takes the content ready-made, like share_and_encrypt_content does.
/// An edit's shape is the same encrypted or not, and building it twice
/// meant a formatted body reaching one kind of room and not the other.
pub async fn share_and_encrypt_edit(&self, homeserver_url: &str, access_token: &str, room_id: &RoomId, member_ids: Vec<OwnedUserId>, content: Value) -> Result<Value> {
self.ensure_keys_shared(homeserver_url, access_token, room_id, &member_ids).await?;
let content = serde_json::json!({
"msgtype": "m.text",
"body": format!("* {body}"),
"m.new_content": { "msgtype": "m.text", "body": body },
"m.relates_to": { "rel_type": "m.replace", "event_id": target_event_id },
});
self.encrypt_raw(room_id, "m.room.message", content).await
}
+197
View File
@@ -0,0 +1,197 @@
//! Turning what somebody typed into the HTML Matrix carries alongside it.
//!
//! Matrix messages may carry a `formatted_body` next to the plain one, and
//! every other client sends one - so a message typed here with emphasis in it
//! arrived everywhere else as literal asterisks.
//!
//! This is a deliberately small subset of Markdown, not a Markdown parser.
//! The distinction matters: a half-built parser that silently disagrees with
//! what people expect is worse than none, so the rule here is that anything
//! not listed below is left exactly as typed. What is covered is what people
//! actually reach for in a chat line:
//!
//! - fenced code blocks, and `inline code`
//! - `**bold**`, `*italic*` and `_italic_`, `~~struck through~~`
//! - line breaks
//!
//! Deliberately absent: headings, lists, tables, images, reference links.
//! Those are document structure, and a chat line that turns into one because
//! it happened to start with a hyphen is a surprise nobody asked for.
//!
//! Escaping happens before any of it. The input is somebody's text, not
//! markup, and the output goes to other people's clients - so `<b>` typed
//! into the box has to arrive as those five characters rather than as a tag.
/// The HTML for this message, or `None` if it holds nothing to format.
///
/// `None` is the common case and the point of the return type: a plain line
/// should travel as a plain line, and sending a `formatted_body` that only
/// restates the body would put markup on every message for the benefit of
/// none of them.
pub fn to_html(body: &str) -> Option<String> {
let html = render(body);
// Only worth sending if the formatting says something the plain text
// does not. Comparing against the escaped input rather than the input
// catches the case where the only difference is escaping, which every
// client already does for itself.
if html == escape(body).replace('\n', "<br/>") {
return None;
}
Some(html)
}
fn escape(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for c in text.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
_ => out.push(c),
}
}
out
}
fn render(body: &str) -> String {
let mut out = String::new();
// Fences first and whole: everything between them is text somebody
// wanted shown exactly, so no emphasis rule may reach inside.
let mut fenced = body.split("```");
let mut in_code = false;
while let Some(part) = fenced.next() {
if in_code {
// A fence often carries a language on its opening line; it is not
// part of the code and Matrix has nowhere to put it.
let code = part.strip_prefix(|c: char| c != '\n').unwrap_or(part);
let code = match part.find('\n') {
Some(i) if !part[..i].contains(char::is_whitespace) => &part[i + 1..],
_ => code,
};
out.push_str("<pre><code>");
out.push_str(&escape(code.trim_end_matches('\n')));
out.push_str("</code></pre>");
} else {
out.push_str(&inline(part));
}
in_code = !in_code;
}
out
}
/// Emphasis and inline code, on one stretch of prose.
fn inline(text: &str) -> String {
let mut out = String::new();
let mut rest = text;
while !rest.is_empty() {
// Inline code before emphasis, for the same reason fences come
// before everything: `**` inside backticks is two asterisks.
if let Some(after) = rest.strip_prefix('`') {
if let Some(end) = after.find('`') {
out.push_str("<code>");
out.push_str(&escape(&after[..end]));
out.push_str("</code>");
rest = &after[end + 1..];
continue;
}
}
if let Some(rendered) = span(rest, "**", "strong").or_else(|| span(rest, "~~", "del")) {
let (html, remaining) = rendered;
out.push_str(&html);
rest = remaining;
continue;
}
// Single-character markers last, so ** is never read as two *.
if let Some(rendered) = span(rest, "*", "em").or_else(|| span(rest, "_", "em")) {
let (html, remaining) = rendered;
out.push_str(&html);
rest = remaining;
continue;
}
let take = rest.chars().next().map(char::len_utf8).unwrap_or(1);
out.push_str(&escape(&rest[..take]));
rest = &rest[take..];
}
out.replace('\n', "<br/>")
}
/// One `MARKER…MARKER` run, rendered as `tag`, and whatever follows it.
///
/// An unclosed marker is not emphasis - it is an asterisk somebody typed -
/// and an empty one is not either, so `**` on its own stays as it was.
fn span<'a>(text: &'a str, marker: &str, tag: &str) -> Option<(String, &'a str)> {
let after = text.strip_prefix(marker)?;
let end = after.find(marker)?;
if end == 0 {
return None;
}
let inner = &after[..end];
// No emphasis across a line: an asterisk at the start of one line and
// another paragraphs later is two asterisks, not italics.
if inner.contains('\n') {
return None;
}
// Markers have to hug the text they emphasise. Without this "2 * 3 * 4"
// is multiplication rendered as italics, which is the exact kind of
// surprise this module exists to avoid.
if inner.starts_with(char::is_whitespace) || inner.ends_with(char::is_whitespace) {
return None;
}
Some((format!("<{tag}>{}</{tag}>", inline(inner)), &after[end + marker.len()..]))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plain_text_carries_no_formatting() {
assert_eq!(to_html("just a message"), None);
assert_eq!(to_html("two\nlines"), None);
// Escaping alone is not formatting: every client escapes for itself.
assert_eq!(to_html("a < b & c"), None);
}
#[test]
fn emphasis_becomes_tags() {
assert_eq!(to_html("**bold**").as_deref(), Some("<strong>bold</strong>"));
assert_eq!(to_html("*it*").as_deref(), Some("<em>it</em>"));
assert_eq!(to_html("_it_").as_deref(), Some("<em>it</em>"));
assert_eq!(to_html("~~no~~").as_deref(), Some("<del>no</del>"));
assert_eq!(to_html("a **b** c").as_deref(), Some("a <strong>b</strong> c"));
}
/// The input is somebody's text and the output goes to other people's
/// clients, so a tag typed into the box must arrive as characters.
#[test]
fn typed_markup_is_escaped_not_honoured() {
assert_eq!(to_html("**<b>hi</b>**").as_deref(), Some("<strong>&lt;b&gt;hi&lt;/b&gt;</strong>"));
assert_eq!(to_html("`<script>`").as_deref(), Some("<code>&lt;script&gt;</code>"));
}
#[test]
fn code_is_left_exactly_as_typed() {
assert_eq!(to_html("`a*b*c`").as_deref(), Some("<code>a*b*c</code>"));
assert_eq!(to_html("```\na*b*\n```").as_deref(), Some("<pre><code>a*b*</code></pre>"));
// A language on the fence is not part of the code.
assert_eq!(to_html("```rust\nlet x = 1;\n```").as_deref(), Some("<pre><code>let x = 1;</code></pre>"));
}
/// A stray marker is a character somebody typed, not the start of
/// something. Getting this wrong swallows the rest of the message.
#[test]
fn an_unclosed_marker_stays_as_it_was() {
assert_eq!(to_html("2 * 3 * 4"), None);
assert_eq!(to_html("**"), None);
assert_eq!(to_html("a * b"), None);
assert_eq!(to_html("half **open"), None);
// Nor across lines, where the two are unrelated.
assert_eq!(to_html("*one\ntwo*"), None);
}
#[test]
fn line_breaks_survive_alongside_formatting() {
assert_eq!(to_html("**a**\nb").as_deref(), Some("<strong>a</strong><br/>b"));
}
}
+72 -8
View File
@@ -25,6 +25,7 @@ pub mod auth;
pub mod backup;
pub mod crypto;
pub mod http;
pub mod markup;
pub mod moderation;
pub mod protocol;
pub mod roomstate;
@@ -1255,7 +1256,27 @@ pub async fn send_message(
upload_media_message(base, access_token, path, body).await?
}
} else {
serde_json::json!({ "msgtype": "m.text", "body": body })
// "/me waves" is an action, the same convention IRC uses and the one
// Matrix spells m.emote. Inbound emotes were already understood;
// typing one here sent the literal text. "//" escapes a leading
// slash, matching how the IRC backend reads the same box.
let (msgtype, body) = match body.strip_prefix('/') {
Some(literal) if literal.starts_with('/') => ("m.text", literal),
Some(rest) => match rest.strip_prefix("me ") {
Some(action) => ("m.emote", action),
None => ("m.text", body),
},
None => ("m.text", body),
};
let mut content = serde_json::json!({ "msgtype": msgtype, "body": body });
// The formatting somebody typed, where they typed any. Sent
// alongside the plain text rather than instead of it: body stays the
// fallback for a client that will not render HTML.
if let Some(html) = markup::to_html(body) {
content["format"] = serde_json::json!("org.matrix.custom.html");
content["formatted_body"] = serde_json::json!(html);
}
content
};
if let Some(target) = reply_to_id {
plain_content["m.relates_to"] = serde_json::json!({ "m.in_reply_to": { "event_id": target } });
@@ -1395,28 +1416,47 @@ async fn http_client_post_bytes(url: &str, access_token: &str, content_type: &st
Ok(body)
}
/// The `m.replace` an edit is sent as.
///
/// The replacement carries the sender's formatting the same way a new
/// message does - editing a formatted message should not quietly flatten it
/// - while the outer body keeps the plain "* text" form that clients without
/// edit support fall back to showing.
fn edit_content(target_event_id: &str, body: &str) -> serde_json::Value {
let mut new_content = serde_json::json!({ "msgtype": "m.text", "body": body });
if let Some(html) = markup::to_html(body) {
new_content["format"] = serde_json::json!("org.matrix.custom.html");
new_content["formatted_body"] = serde_json::json!(html);
}
serde_json::json!({
"msgtype": "m.text",
"body": format!("* {body}"),
"m.new_content": new_content,
"m.relates_to": { "rel_type": "m.replace", "event_id": target_event_id },
})
}
/// Applies a real edit. `buffer_id` must already have a known room id.
pub async fn edit_message(state: &AppState, account_id: &str, buffer_id: &str, access_token: &str, msg_id: &str, body: &str) -> Result<()> {
let room_id = state.runtime.get_matrix_room(buffer_id).context("no known room id for this buffer")?;
let account = state.accounts.get_matrix(account_id).context("account not connected")?;
let base = account.homeserver_url.trim_end_matches('/');
// Built once for both kinds of room. An edit reads the same either way,
// and building it twice is how a formatted body would reach one and not
// the other.
let content = edit_content(msg_id, body);
let (event_type, body_json) = if state.runtime.is_matrix_room_encrypted(buffer_id) {
let session = state.runtime.get_matrix_machine(account_id).context("crypto session not ready yet")?;
let member_ids = joined_member_ids(base, access_token, &room_id).await?;
let room_id_ruma = ruma_common::RoomId::parse(&room_id).context("invalid room id")?;
let content = session
.share_and_encrypt_edit(&account.homeserver_url, access_token, &room_id_ruma, member_ids, msg_id, body)
.share_and_encrypt_edit(&account.homeserver_url, access_token, &room_id_ruma, member_ids, content)
.await
.context("encrypting edit")?;
(protocol::EVENT_ROOM_ENCRYPTED, content)
} else {
let content = serde_json::json!({
"msgtype": "m.text",
"body": format!("* {body}"),
"m.new_content": { "msgtype": "m.text", "body": body },
"m.relates_to": { "rel_type": "m.replace", "event_id": msg_id },
});
(protocol::EVENT_ROOM_MESSAGE, content)
};
@@ -1595,6 +1635,30 @@ async fn try_login(state: &AppState, login_id: &str, homeserver_url: &str, usern
#[cfg(test)]
mod tests {
/// An edit carries the sender's formatting the same way a new message
/// does. Building this in two places is how a formatted body reached
/// unencrypted rooms and not encrypted ones.
#[test]
fn an_edit_keeps_the_formatting_it_was_given() {
let c = super::edit_content("$abc", "**bold** now");
assert_eq!(c["m.relates_to"]["rel_type"], "m.replace");
assert_eq!(c["m.relates_to"]["event_id"], "$abc");
// The outer body is the fallback a client without edit support shows.
assert_eq!(c["body"], "* **bold** now");
assert_eq!(c["m.new_content"]["body"], "**bold** now");
assert_eq!(c["m.new_content"]["format"], "org.matrix.custom.html");
assert_eq!(c["m.new_content"]["formatted_body"], "<strong>bold</strong> now");
}
/// A plain edit stays plain - no format keys at all, rather than a
/// formatted body restating the text.
#[test]
fn a_plain_edit_carries_no_format() {
let c = super::edit_content("$abc", "just words");
assert!(c["m.new_content"].get("format").is_none());
assert!(c["m.new_content"].get("formatted_body").is_none());
}
use super::*;
use crate::state::AppState;