Matrix: a threaded message says which conversation it is in

Partial work on #80, and deliberately not the whole of it.

A threaded message carries `rel_type: m.thread` naming the message the
thread grew from, and usually *also* an `m.in_reply_to` that is a fallback
for clients which cannot read threads. That fallback points at the
previous message in the thread rather than at the thread, so a client
reading only it - which this one was - shows a chain of one-line replies
where a conversation was. The thread relation is preferred where there is
one.

Threads are still not buffers, and replying still sends an ordinary reply
rather than continuing the thread. What this buys is that a threaded
message arrives saying which conversation it belongs to instead of landing
in the timeline with no context at all.

Also moves a doc comment that had come adrift: the note about `redacts`
was sitting on top of `formatted_body`, describing a function two below
it.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-02 08:35:13 -04:00
co-authored by Claude Opus 5
parent 27ce6a112a
commit 5ce6d50735
2 changed files with 75 additions and 5 deletions
+10 -1
View File
@@ -667,7 +667,16 @@ async fn handle_timeline_event(
return;
}
let reply_to = match protocol::reply_target(&content) {
// What this message is answering. A threaded message names its thread
// rather than the message before it: Matrix sends both, and the second is
// a fallback for clients that cannot read threads, so following it gives a
// chain of one-line replies where a conversation was.
//
// Threads are not their own buffers here, and this does not make them one.
// It is the difference between a threaded message arriving with no context
// at all and one that says which conversation it belongs to.
let relation = protocol::thread_root(&content).or_else(|| protocol::reply_target(&content));
let reply_to = match relation {
Some(target_event) => match state.store.get_message(buffer_id, target_event) {
Ok(Some(m)) => Some(crate::model::ReplyPreview { id: target_event.to_string(), from: m.from, body: m.body }),
// Not (or no longer) in local scrollback - still record that
+65 -4
View File
@@ -56,10 +56,6 @@ pub fn message_body(content: &Value) -> (String, bool) {
(body, is_action)
}
/// `redacts`, the target event id of an `m.room.redaction` - used for
/// both real message deletes and reaction removal (Matrix has no
/// dedicated "remove reaction" event; un-reacting is redacting the
/// `m.reaction` event you sent - see Runtime::take_matrix_reaction_target).
/// The sender's formatted version of the body, if there is one.
///
/// Only `org.matrix.custom.html` counts: the spec allows other formats and a
@@ -73,6 +69,10 @@ pub fn formatted_body(content: &Value) -> Option<&str> {
content["formatted_body"].as_str().filter(|h| !h.is_empty())
}
/// `redacts`, the target event id of an `m.room.redaction` - used for both
/// real message deletes and reaction removal (Matrix has no dedicated "remove
/// reaction" event; un-reacting is redacting the `m.reaction` event you sent -
/// see Runtime::take_matrix_reaction_target).
pub fn redaction_target(event: &Value) -> Option<&str> {
event["redacts"].as_str().or_else(|| event["content"]["redacts"].as_str())
}
@@ -97,6 +97,26 @@ pub fn reply_target(content: &Value) -> Option<&str> {
content["m.relates_to"]["m.in_reply_to"]["event_id"].as_str()
}
/// The thread a message belongs to, if it is in one.
///
/// A threaded message carries `rel_type: m.thread` and the id of the message
/// the thread grew from. It usually *also* carries an `m.in_reply_to`, which
/// is a fallback for clients that do not understand threads and which points
/// at the previous message in the thread rather than at the thread itself -
/// so reading only that answers a different question and gets a chain of
/// one-line replies where a conversation was.
///
/// Threads are not buffers here. What this buys is that a threaded message
/// says which conversation it belongs to instead of arriving in the timeline
/// with no context at all, which is what a flat client shows today.
pub fn thread_root(content: &Value) -> Option<&str> {
let relates_to = &content["m.relates_to"];
if relates_to["rel_type"].as_str() != Some("m.thread") {
return None;
}
relates_to["event_id"].as_str()
}
/// The target event id + emoji key of an `m.reaction` event
/// (`m.relates_to.rel_type == "m.annotation"`).
pub fn reaction_target(content: &Value) -> Option<(&str, &str)> {
@@ -107,6 +127,47 @@ pub fn reaction_target(content: &Value) -> Option<(&str, &str)> {
Some((relates_to["event_id"].as_str()?, relates_to["key"].as_str()?))
}
#[cfg(test)]
mod thread_tests {
use super::*;
#[test]
fn finds_the_thread_a_message_belongs_to() {
let threaded = serde_json::json!({
"m.relates_to": {
"rel_type": "m.thread",
"event_id": "$root",
// The fallback for clients that do not understand threads.
// It names the previous message in the thread, not the thread,
// which is why reading it instead would give a chain of
// one-line replies where a conversation was.
"m.in_reply_to": { "event_id": "$previous" },
"is_falling_back": true
}
});
assert_eq!(thread_root(&threaded), Some("$root"));
assert_eq!(reply_target(&threaded), Some("$previous"));
}
#[test]
fn an_ordinary_reply_is_not_a_thread() {
let reply = serde_json::json!({ "m.relates_to": { "m.in_reply_to": { "event_id": "$x" } } });
assert_eq!(thread_root(&reply), None);
assert_eq!(reply_target(&reply), Some("$x"));
}
#[test]
fn an_edit_is_not_a_thread_either() {
let edit = serde_json::json!({ "m.relates_to": { "rel_type": "m.replace", "event_id": "$x" } });
assert_eq!(thread_root(&edit), None);
}
#[test]
fn a_plain_message_relates_to_nothing() {
assert_eq!(thread_root(&serde_json::json!({ "body": "hi" })), None);
}
}
const MEDIA_MSGTYPES: &[&str] = &["m.image", "m.video", "m.audio", "m.file"];
/// The `mxc://` URI of an *unencrypted* media message (`m.image`/