Repository: ruma/ruma (rust)

Issue:
Implement MSC3440 (Threading)
[Official MSC3440](https://github.com/matrix-org/matrix-doc/pull/3440)
[Issue in Conduit](https://gitlab.com/famedly/conduit/-/issues/96)

Necessary for forum-like apps such as [Hummingbard](https://hummingbard.com/hummingbard/introducing-hummingbard) ([repo](https://github.com/hummingbard/hummingbard)) to work. Ruma/Conduit is looking to be the easiest and most cost-effective way to self-host a Matrix community. The most advanced reference implementation of a federated, ActivityPub-compatible forum also happens to be made in Rust: [Lemmy](https://github.com/LemmyNet/lemmy).

Personally I see the greatest potential in a more Discourse-like client implementation, primarily designed for structured discussion as seen on Users and Internals. The official Matrix server for Rust should eventually bridge seamlessly with those two forums. I’m working with the Hummingbard developer to envision a prototype for such a chat/forum hybrid application in a soon-to-be-released rewrite of Hb.

A reference implementation exists in Dendrite:
https://github.com/matrix-org/dendrite/pull/1589
https://github.com/matrix-org/dendrite/pull/1596

It’s also been demonstrated in Cerulean:
https://github.com/matrix-org/cerulean

Pull request:
Implement threads
Based on #983.

- Add `m.thread` relation to `m.relates_to`.
- Add `m.thread` bundled relation to `m.relations`.
- Add `related_by_rel_types` and `related_by_senders` to `RoomEventFilter`

All according to [MSC3440](https://github.com/matrix-org/matrix-spec-proposals/pull/3440), behind the feature flag and with the unstable prefixes. Even if we are sure that it will be in v1.3, as long as it's not in the spec I still have doubts about which fields are optional or not for example.

The only thing missing is support for the `/relations` endpoint but that should be handled with #982 and it will probably reuse the types created here.

Closes #622.

Patch:
diff --git a/crates/ruma-client-api/Cargo.toml b/crates/ruma-client-api/Cargo.toml
index 28727702..bc122a56 100644
--- a/crates/ruma-client-api/Cargo.toml
+++ b/crates/ruma-client-api/Cargo.toml
@@ -18,6 +18,7 @@ rustdoc-args = ["--cfg", "docsrs"]
 compat = []
 unstable-exhaustive-types = []
 unstable-msc2448 = []
+unstable-msc3440 = []
 unstable-msc3488 = []
 client = []
 server = []
diff --git a/crates/ruma-client-api/src/filter.rs b/crates/ruma-client-api/src/filter.rs
index 5fbfba5e..760c7b38 100644
--- a/crates/ruma-client-api/src/filter.rs
+++ b/crates/ruma-client-api/src/filter.rs
@@ -48,6 +48,40 @@ impl Default for EventFormat {
     }
 }
 
+/// Relation types as defined in `rel_type` of an `m.relates_to` field.
+///
+/// This type can hold an arbitrary string. To check for formats that are not available as a
+/// documented variant here, use its string representation, obtained through `.as_str()`.
+#[derive(Clone, Debug, PartialEq, Eq, StringEnum)]
+#[cfg(feature = "unstable-msc3440")]
+#[non_exhaustive]
+pub enum RelationType {
+    /// `m.annotation`, an annotation, principally used by reactions.
+    #[cfg(feature = "unstable-msc2677")]
+    #[ruma_enum(rename = "m.annotation")]
+    Annotation,
+
+    /// `m.replace`, a replacement.
+    #[cfg(feature = "unstable-msc2676")]
+    #[ruma_enum(rename = "m.replace")]
+    Replacement,
+
+    /// `m.thread`, a participant to a thread.
+    #[ruma_enum(rename = "io.element.thread")]
+    Thread,
+
+    #[doc(hidden)]
+    _Custom(PrivOwnedStr),
+}
+
+#[cfg(feature = "unstable-msc3440")]
+impl RelationType {
+    /// Creates a string slice from this `RelationType`.
+    pub fn as_str(&self) -> &str {
+        self.as_ref()
+    }
+}
+
 /// Filters to be applied to room events.
 #[derive(Clone, Debug, Default, Outgoing, Serialize)]
 #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
@@ -111,6 +145,30 @@ pub struct RoomEventFilter<'a> {
     /// Defaults to `LazyLoadOptions::Disabled`.
     #[serde(flatten)]
     pub lazy_load_options: LazyLoadOptions,
+
+    /// A list of relation types to include.
+    ///
+    /// An event A is included in the filter only if there exists another event B which relates to
+    /// A with a `rel_type` which is defined in the list.
+    #[cfg(feature = "unstable-msc3440")]
+    #[serde(
+        rename = "io.element.relation_types",
+        default,
+        skip_serializing_if = "<[_]>::is_empty"
+    )]
+    pub related_by_rel_types: &'a [RelationType],
+
+    /// A list of senders to include.
+    ///
+    /// An event A is included in the filter only if there exists another event B which relates to
+    /// A, and which has a sender which is in the list.
+    #[cfg(feature = "unstable-msc3440")]
+    #[serde(
+        rename = "io.element.relation_senders",
+        default,
+        skip_serializing_if = "<[_]>::is_empty"
+    )]
+    pub related_by_senders: &'a [Box<UserId>],
 }
 
 impl<'a> RoomEventFilter<'a> {
@@ -128,7 +186,7 @@ impl<'a> RoomEventFilter<'a> {
 
     /// Returns `true` if all fields are empty.
     pub fn is_empty(&self) -> bool {
-        self.not_types.is_empty()
+        let empty = self.not_types.is_empty()
             && self.not_rooms.is_empty()
             && self.limit.is_none()
             && self.rooms.is_none()
@@ -136,14 +194,20 @@ impl<'a> RoomEventFilter<'a> {
             && self.senders.is_none()
             && self.types.is_none()
             && self.url_filter.is_none()
-            && self.lazy_load_options.is_disabled()
+            && self.lazy_load_options.is_disabled();
+
+        #[cfg(not(feature = "unstable-msc3440"))]
+        return empty;
+
+        #[cfg(feature = "unstable-msc3440")]
+        return empty && self.related_by_rel_types.is_empty() && self.related_by_senders.is_empty();
     }
 }
 
 impl IncomingRoomEventFilter {
     /// Returns `true` if all fields are empty.
     pub fn is_empty(&self) -> bool {
-        self.not_types.is_empty()
+        let empty = self.not_types.is_empty()
             && self.not_rooms.is_empty()
             && self.limit.is_none()
             && self.rooms.is_none()
@@ -151,7 +215,13 @@ impl IncomingRoomEventFilter {
             && self.senders.is_none()
             && self.types.is_none()
             && self.url_filter.is_none()
-            && self.lazy_load_options.is_disabled()
+            && self.lazy_load_options.is_disabled();
+
+        #[cfg(not(feature = "unstable-msc3440"))]
+        return empty;
+
+        #[cfg(feature = "unstable-msc3440")]
+        return empty && self.related_by_rel_types.is_empty() && self.related_by_senders.is_empty();
     }
 }
 
@@ -470,10 +540,26 @@ mod tests {
                 limit: None,
                 url_filter: Some(UrlFilter::EventsWithUrl),
                 lazy_load_options: LazyLoadOptions::Enabled { include_redundant_members: false },
-            } if types == vec!["m.room.message".to_owned()]
+                #[cfg(feature = "unstable-msc3440")]
+                related_by_rel_types,
+                #[cfg(feature = "unstable-msc3440")]
+                related_by_senders
+            } if {
+                let valid = types == vec!["m.room.message".to_owned()]
                 && not_types.is_empty()
                 && not_rooms.is_empty()
-                && not_senders.is_empty()
+                && not_senders.is_empty();
+
+                #[cfg(not(feature = "unstable-msc3440"))]
+                {
+                    valid
+                }
+
+                #[cfg(feature = "unstable-msc3440")]
+                {
+                    valid && related_by_rel_types.is_empty() && related_by_senders.is_empty()
+                }
+            }
         );
 
         Ok(())
diff --git a/crates/ruma-common/Cargo.toml b/crates/ruma-common/Cargo.toml
index 5be5c19c..65987ce5 100644
--- a/crates/ruma-common/Cargo.toml
+++ b/crates/ruma-common/Cargo.toml
@@ -34,6 +34,7 @@ unstable-msc2675 = []
 unstable-msc2676 = []
 unstable-msc2677 = []
 unstable-msc3246 = ["unstable-msc3551", "thiserror"]
+unstable-msc3440 = []
 unstable-msc3488 = ["unstable-msc1767"]
 unstable-msc3551 = ["unstable-msc1767"]
 unstable-msc3552 = ["unstable-msc1767", "unstable-msc3551"]
diff --git a/crates/ruma-common/src/events/relation.rs b/crates/ruma-common/src/events/relation.rs
index 5fa05e95..c401a12f 100644
--- a/crates/ruma-common/src/events/relation.rs
+++ b/crates/ruma-common/src/events/relation.rs
@@ -5,7 +5,8 @@ use std::fmt::Debug;
 use js_int::UInt;
 use serde::{Deserialize, Serialize};
 
-use crate::{EventId, MilliSecondsSinceUnixEpoch, UserId};
+use super::AnySyncMessageLikeEvent;
+use crate::{serde::Raw, EventId, MilliSecondsSinceUnixEpoch, UserId};
 
 /// Summary of all reactions with the given key to an event.
 #[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq, Serialize)]
@@ -91,6 +92,33 @@ impl BundledReplacement {
     }
 }
 
+/// A bundled thread.
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[cfg(feature = "unstable-msc3440")]
+#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
+pub struct BundledThread {
+    /// The latest event in the thread.
+    pub latest_event: Box<Raw<AnySyncMessageLikeEvent>>,
+
+    /// The number of events in the thread.
+    pub count: UInt,
+
+    /// Whether the current logged in user has participated in the thread.
+    pub current_user_participated: bool,
+}
+
+#[cfg(feature = "unstable-msc3440")]
+impl BundledThread {
+    /// Creates a new `BundledThread` with the given event, count and user participated flag.
+    pub fn new(
+        latest_event: Box<Raw<AnySyncMessageLikeEvent>>,
+        count: UInt,
+        current_user_participated: bool,
+    ) -> Self {
+        Self { latest_event, count, current_user_participated }
+    }
+}
+
 /// Precompiled list of relations to this event grouped by relation type.
 #[derive(Clone, Debug, Default, Deserialize, Serialize)]
 #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
@@ -104,6 +132,11 @@ pub struct Relations {
     #[cfg(feature = "unstable-msc2676")]
     #[serde(rename = "m.replace")]
     pub replace: Option<BundledReplacement>,
+
+    /// Thread relation.
+    #[cfg(feature = "unstable-msc3440")]
+    #[serde(rename = "io.element.thread")]
+    pub thread: Option<BundledThread>,
 }
 
 impl Relations {
diff --git a/crates/ruma-common/src/events/room/encrypted.rs b/crates/ruma-common/src/events/room/encrypted.rs
index 5b71148a..d225cc25 100644
--- a/crates/ruma-common/src/events/room/encrypted.rs
+++ b/crates/ruma-common/src/events/room/encrypted.rs
@@ -22,9 +22,7 @@ pub struct RoomEncryptedEventContent {
     #[serde(flatten)]
     pub scheme: EncryptedEventScheme,
 
-    /// Information about related messages for [rich replies].
-    ///
-    /// [rich replies]: https://spec.matrix.org/v1.2/client-server-api/#rich-replies
+    /// Information about related events.
     #[serde(flatten, skip_serializing_if = "Option::is_none")]
     pub relates_to: Option<Relation>,
 }
@@ -103,6 +101,10 @@ pub enum Relation {
     #[cfg(feature = "unstable-msc2677")]
     Annotation(Annotation),
 
+    /// An event that belongs to a thread.
+    #[cfg(feature = "unstable-msc3440")]
+    Thread(Thread),
+
     #[doc(hidden)]
     _Custom,
 }
@@ -115,6 +117,12 @@ impl From<message::Relation> for Relation {
             message::Relation::Replacement(re) => {
                 Self::Replacement(Replacement { event_id: re.event_id })
             }
+            #[cfg(feature = "unstable-msc3440")]
+            message::Relation::Thread(t) => Self::Thread(Thread {
+                event_id: t.event_id,
+                in_reply_to: t.in_reply_to,
+                is_falling_back: t.is_falling_back,
+            }),
             message::Relation::_Custom => Self::_Custom,
         }
     }
@@ -168,6 +176,44 @@ impl Annotation {
     }
 }
 
+/// A thread relation for an event.
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[cfg(feature = "unstable-msc3440")]
+#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
+pub struct Thread {
+    /// The ID of the root message in the thread.
+    pub event_id: Box<EventId>,
+
+    /// A reply relation.
+    ///
+    /// If this event is a reply and belongs to a thread, this points to the message that is being
+    /// replied to, and `is_falling_back` must be set to `false`.
+    ///
+    /// If this event is not a reply, this is used as a fallback mechanism for clients that do not
+    /// support threads. This should point to the latest message-like event in the thread and
+    /// `is_falling_back` must be set to `true`.
+    pub in_reply_to: InReplyTo,
+
+    /// Whether the `m.in_reply_to` field is a fallback for older clients or a real reply in a
+    /// thread.
+    pub is_falling_back: bool,
+}
+
+#[cfg(feature = "unstable-msc3440")]
+impl Thread {
+    /// Convenience method to create a regular `Thread` with the given event ID and latest
+    /// message-like event ID.
+    pub fn plain(event_id: Box<EventId>, latest_event_id: Box<EventId>) -> Self {
+        Self { event_id, in_reply_to: InReplyTo::new(latest_event_id), is_falling_back: false }
+    }
+
+    /// Convenience method to create a reply `Thread` with the given event ID and replied-to event
+    /// ID.
+    pub fn reply(event_id: Box<EventId>, reply_to_event_id: Box<EventId>) -> Self {
+        Self { event_id, in_reply_to: InReplyTo::new(reply_to_event_id), is_falling_back: true }
+    }
+}
+
 /// The content of an `m.room.encrypted` event using the `m.olm.v1.curve25519-aes-sha2` algorithm.
 #[derive(Clone, Debug, Serialize, Deserialize)]
 #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
diff --git a/crates/ruma-common/src/events/room/encrypted/relation_serde.rs b/crates/ruma-common/src/events/room/encrypted/relation_serde.rs
index 257d9892..c213bd70 100644
--- a/crates/ruma-common/src/events/room/encrypted/relation_serde.rs
+++ b/crates/ruma-common/src/events/room/encrypted/relation_serde.rs
@@ -4,37 +4,52 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
 use super::Annotation;
 #[cfg(feature = "unstable-msc2676")]
 use super::Replacement;
+#[cfg(feature = "unstable-msc3440")]
+use super::Thread;
 use super::{InReplyTo, Reference, Relation};
+#[cfg(feature = "unstable-msc3440")]
+use crate::EventId;
 
 impl<'de> Deserialize<'de> for Relation {
     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
     where
         D: Deserializer<'de>,
     {
-        fn convert_relation(ev: EventWithRelatesToJsonRepr) -> Relation {
-            if let Some(in_reply_to) = ev.relates_to.in_reply_to {
-                return Relation::Reply { in_reply_to };
-            }
+        let ev = EventWithRelatesToJsonRepr::deserialize(deserializer)?;
 
-            if let Some(relation) = ev.relates_to.relation {
-                return match relation {
-                    #[cfg(feature = "unstable-msc2677")]
-                    RelationJsonRepr::Annotation(a) => Relation::Annotation(a),
-                    RelationJsonRepr::Reference(r) => Relation::Reference(r),
-                    #[cfg(feature = "unstable-msc2676")]
-                    RelationJsonRepr::Replacement(Replacement { event_id }) => {
-                        Relation::Replacement(Replacement { event_id })
-                    }
-                    // FIXME: Maybe we should log this, though at this point we don't even have
-                    // access to the rel_type of the unknown relation.
-                    RelationJsonRepr::Unknown => Relation::_Custom,
-                };
-            }
+        #[cfg(feature = "unstable-msc3440")]
+        if let Some(RelationJsonRepr::Thread(ThreadJsonRepr { event_id, is_falling_back })) =
+            ev.relates_to.relation
+        {
+            let in_reply_to = ev
+                .relates_to
+                .in_reply_to
+                .ok_or_else(|| serde::de::Error::missing_field("m.in_reply_to"))?;
+            return Ok(Relation::Thread(Thread { event_id, in_reply_to, is_falling_back }));
+        }
 
+        let rel = if let Some(in_reply_to) = ev.relates_to.in_reply_to {
+            Relation::Reply { in_reply_to }
+        } else if let Some(relation) = ev.relates_to.relation {
+            match relation {
+                #[cfg(feature = "unstable-msc2677")]
+                RelationJsonRepr::Annotation(a) => Relation::Annotation(a),
+                RelationJsonRepr::Reference(r) => Relation::Reference(r),
+                #[cfg(feature = "unstable-msc2676")]
+                RelationJsonRepr::Replacement(Replacement { event_id }) => {
+                    Relation::Replacement(Replacement { event_id })
+                }
+                #[cfg(feature = "unstable-msc3440")]
+                RelationJsonRepr::Thread(_) => unreachable!(),
+                // FIXME: Maybe we should log this, though at this point we don't even have
+                // access to the rel_type of the unknown relation.
+                RelationJsonRepr::Unknown => Relation::_Custom,
+            }
+        } else {
             Relation::_Custom
-        }
+        };
 
-        EventWithRelatesToJsonRepr::deserialize(deserializer).map(convert_relation)
+        Ok(rel)
     }
 }
 
@@ -62,6 +77,17 @@ impl Serialize for Relation {
             Relation::Reply { in_reply_to } => {
                 RelatesToJsonRepr { in_reply_to: Some(in_reply_to.clone()), ..Default::default() }
             }
+            #[cfg(feature = "unstable-msc3440")]
+            Relation::Thread(Thread { event_id, in_reply_to, is_falling_back }) => {
+                RelatesToJsonRepr {
+                    in_reply_to: Some(in_reply_to.clone()),
+                    relation: Some(RelationJsonRepr::Thread(ThreadJsonRepr {
+                        event_id: event_id.clone(),
+                        is_falling_back: *is_falling_back,
+                    })),
+                    ..Default::default()
+                }
+            }
             Relation::_Custom => RelatesToJsonRepr::default(),
         };
 
@@ -75,8 +101,8 @@ struct EventWithRelatesToJsonRepr {
     relates_to: RelatesToJsonRepr,
 }
 
-/// Enum modeling the different ways relationships can be expressed in a `m.relates_to` field of an
-/// event.
+/// Struct modeling the different ways relationships can be expressed in a `m.relates_to` field of
+/// an event.
 #[derive(Default, Deserialize, Serialize)]
 struct RelatesToJsonRepr {
     #[serde(rename = "m.in_reply_to", skip_serializing_if = "Option::is_none")]
@@ -92,6 +118,23 @@ impl RelatesToJsonRepr {
     }
 }
 
+/// A thread relation without the reply fallback.
+#[derive(Clone, Deserialize, Serialize)]
+#[cfg(feature = "unstable-msc3440")]
+struct ThreadJsonRepr {
+    /// The ID of the root message in the thread.
+    pub event_id: Box<EventId>,
+
+    /// Whether the `m.in_reply_to` field is a fallback for older clients or a real reply in a
+    /// thread.
+    #[serde(
+        rename = "io.element.show_reply",
+        default,
+        skip_serializing_if = "ruma_common::serde::is_default"
+    )]
+    pub is_falling_back: bool,
+}
+
 /// A relation, which associates new information to an existing event.
 #[derive(Clone, Deserialize, Serialize)]
 #[serde(tag = "rel_type")]
@@ -110,6 +153,11 @@ enum RelationJsonRepr {
     #[serde(rename = "m.replace")]
     Replacement(Replacement),
 
+    /// An event that belongs to a thread.
+    #[cfg(feature = "unstable-msc3440")]
+    #[serde(rename = "io.element.thread")]
+    Thread(ThreadJsonRepr),
+
     /// An unknown relation type.
     ///
     /// Not available in the public API, but exists here so deserialization
diff --git a/crates/ruma-common/src/events/room/message.rs b/crates/ruma-common/src/events/room/message.rs
index c5591792..2b4586d1 100644
--- a/crates/ruma-common/src/events/room/message.rs
+++ b/crates/ruma-common/src/events/room/message.rs
@@ -338,8 +338,6 @@ impl From<MessageType> for RoomMessageEventContent {
 }
 
 /// Message event relationship.
-///
-/// Currently used for replies and editing (message replacement).
 #[derive(Clone, Debug)]
 #[allow(clippy::manual_non_exhaustive)]
 #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
@@ -354,6 +352,10 @@ pub enum Relation {
     #[cfg(feature = "unstable-msc2676")]
     Replacement(Replacement),
 
+    /// An event that belongs to a thread.
+    #[cfg(feature = "unstable-msc3440")]
+    Thread(Thread),
+
     #[doc(hidden)]
     _Custom,
 }
@@ -393,6 +395,44 @@ impl Replacement {
     }
 }
 
+/// The content of a thread relation.
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[cfg(feature = "unstable-msc3440")]
+#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
+pub struct Thread {
+    /// The ID of the root message in the thread.
+    pub event_id: Box<EventId>,
+
+    /// A reply relation.
+    ///
+    /// If this event is a reply and belongs to a thread, this points to the message that is being
+    /// replied to, and `is_falling_back` must be set to `false`.
+    ///
+    /// If this event is not a reply, this is used as a fallback mechanism for clients that do not
+    /// support threads. This should point to the latest message-like event in the thread and
+    /// `is_falling_back` must be set to `true`.
+    pub in_reply_to: InReplyTo,
+
+    /// Whether the `m.in_reply_to` field is a fallback for older clients or a genuine reply in a
+    /// thread.
+    pub is_falling_back: bool,
+}
+
+#[cfg(feature = "unstable-msc3440")]
+impl Thread {
+    /// Convenience method to create a regular `Thread` with the given event ID and latest
+    /// message-like event ID.
+    pub fn plain(event_id: Box<EventId>, latest_event_id: Box<EventId>) -> Self {
+        Self { event_id, in_reply_to: InReplyTo::new(latest_event_id), is_falling_back: false }
+    }
+
+    /// Convenience method to create a reply `Thread` with the given event ID and replied-to event
+    /// ID.
+    pub fn reply(event_id: Box<EventId>, reply_to_event_id: Box<EventId>) -> Self {
+        Self { event_id, in_reply_to: InReplyTo::new(reply_to_event_id), is_falling_back: true }
+    }
+}
+
 /// The payload for an audio message.
 #[derive(Clone, Debug, Deserialize, Serialize)]
 #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
@@ -1006,35 +1046,3 @@ pub struct CustomEventContent {
     #[serde(flatten)]
     data: JsonObject,
 }
-
-#[cfg(test)]
-mod tests {
-    use crate::event_id;
-    use matches::assert_matches;
-    use serde_json::{from_value as from_json_value, json};
-
-    use super::{InReplyTo, MessageType, Relation, RoomMessageEventContent};
-
-    #[test]
-    fn deserialize_reply() {
-        let ev_id = event_id!("$1598361704261elfgc:localhost");
-
-        let json = json!({
-            "msgtype": "m.text",
-            "body": "<text msg>",
-            "m.relates_to": {
-                "m.in_reply_to": {
-                    "event_id": ev_id,
-                },
-            },
-        });
-
-        assert_matches!(
-            from_json_value::<RoomMessageEventContent>(json).unwrap(),
-            RoomMessageEventContent {
-                msgtype: MessageType::Text(_),
-                relates_to: Some(Relation::Reply { in_reply_to: InReplyTo { event_id } }),
-            } if event_id == ev_id
-        );
-    }
-}
diff --git a/crates/ruma-common/src/events/room/message/relation_serde.rs b/crates/ruma-common/src/events/room/message/relation_serde.rs
index 1ad8a5b6..9c66aab3 100644
--- a/crates/ruma-common/src/events/room/message/relation_serde.rs
+++ b/crates/ruma-common/src/events/room/message/relation_serde.rs
@@ -4,8 +4,10 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
 use super::Replacement;
 #[cfg(feature = "unstable-msc2676")]
 use super::RoomMessageEventContent;
+#[cfg(feature = "unstable-msc3440")]
+use super::Thread;
 use super::{InReplyTo, Relation};
-#[cfg(feature = "unstable-msc2676")]
+#[cfg(any(feature = "unstable-msc2676", feature = "unstable-msc3440"))]
 use crate::EventId;
 
 impl<'de> Deserialize<'de> for Relation {
@@ -15,13 +17,22 @@ impl<'de> Deserialize<'de> for Relation {
     {
         let ev = EventWithRelatesToJsonRepr::deserialize(deserializer)?;
 
-        if let Some(in_reply_to) = ev.relates_to.in_reply_to {
-            return Ok(Relation::Reply { in_reply_to });
+        #[cfg(feature = "unstable-msc3440")]
+        if let Some(RelationJsonRepr::Thread(ThreadJsonRepr { event_id, is_falling_back })) =
+            ev.relates_to.relation
+        {
+            let in_reply_to = ev
+                .relates_to
+                .in_reply_to
+                .ok_or_else(|| serde::de::Error::missing_field("m.in_reply_to"))?;
+            return Ok(Relation::Thread(Thread { event_id, in_reply_to, is_falling_back }));
         }
 
-        #[cfg(feature = "unstable-msc2676")]
-        if let Some(relation) = ev.relates_to.relation {
-            return Ok(match relation {
+        let rel = if let Some(in_reply_to) = ev.relates_to.in_reply_to {
+            Relation::Reply { in_reply_to }
+        } else if let Some(relation) = ev.relates_to.relation {
+            match relation {
+                #[cfg(feature = "unstable-msc2676")]
                 RelationJsonRepr::Replacement(ReplacementJsonRepr { event_id }) => {
                     let new_content = ev
                         .new_content
@@ -31,10 +42,14 @@ impl<'de> Deserialize<'de> for Relation {
                 // FIXME: Maybe we should log this, though at this point we don't even have
                 // access to the rel_type of the unknown relation.
                 RelationJsonRepr::Unknown => Relation::_Custom,
-            });
-        }
+                #[cfg(feature = "unstable-msc3440")]
+                RelationJsonRepr::Thread(_) => unreachable!(),
+            }
+        } else {
+            Relation::_Custom
+        };
 
-        Ok(Relation::_Custom)
+        Ok(rel)
     }
 }
 
@@ -61,6 +76,17 @@ impl Serialize for Relation {
                     new_content: Some(new_content.clone()),
                 }
             }
+            #[cfg(feature = "unstable-msc3440")]
+            Relation::Thread(Thread { event_id, in_reply_to, is_falling_back }) => {
+                EventWithRelatesToJsonRepr::new(RelatesToJsonRepr {
+                    in_reply_to: Some(in_reply_to.clone()),
+                    relation: Some(RelationJsonRepr::Thread(ThreadJsonRepr {
+                        event_id: event_id.clone(),
+                        is_falling_back: *is_falling_back,
+                    })),
+                    ..Default::default()
+                })
+            }
             Relation::_Custom => EventWithRelatesToJsonRepr::default(),
         };
 
@@ -88,41 +114,37 @@ impl EventWithRelatesToJsonRepr {
     }
 }
 
-/// Enum modeling the different ways relationships can be expressed in a `m.relates_to` field of an
-/// event.
+/// Struct modeling the different ways relationships can be expressed in a `m.relates_to` field of
+/// an event.
 #[derive(Default, Deserialize, Serialize)]
 struct RelatesToJsonRepr {
     #[serde(rename = "m.in_reply_to", skip_serializing_if = "Option::is_none")]
     in_reply_to: Option<InReplyTo>,
 
-    #[cfg(feature = "unstable-msc2676")]
     #[serde(flatten, skip_serializing_if = "Option::is_none")]
     relation: Option<RelationJsonRepr>,
 }
 
 impl RelatesToJsonRepr {
     fn is_empty(&self) -> bool {
-        #[cfg(not(feature = "unstable-msc2676"))]
-        {
-            self.in_reply_to.is_none()
-        }
-
-        #[cfg(feature = "unstable-msc2676")]
-        {
-            self.in_reply_to.is_none() && self.relation.is_none()
-        }
+        self.in_reply_to.is_none() && self.relation.is_none()
     }
 }
 
 /// A relation, which associates new information to an existing event.
 #[derive(Clone, Deserialize, Serialize)]
-#[cfg(feature = "unstable-msc2676")]
 #[serde(tag = "rel_type")]
 enum RelationJsonRepr {
     /// An event that replaces another event.
+    #[cfg(feature = "unstable-msc2676")]
     #[serde(rename = "m.replace")]
     Replacement(ReplacementJsonRepr),
 
+    /// An event that belongs to a thread.
+    #[cfg(feature = "unstable-msc3440")]
+    #[serde(rename = "io.element.thread")]
+    Thread(ThreadJsonRepr),
+
     /// An unknown relation type.
     ///
     /// Not available in the public API, but exists here so deserialization
@@ -136,3 +158,20 @@ enum RelationJsonRepr {
 struct ReplacementJsonRepr {
     event_id: Box<EventId>,
 }
+
+/// A thread relation without the reply fallback.
+#[derive(Clone, Deserialize, Serialize)]
+#[cfg(feature = "unstable-msc3440")]
+struct ThreadJsonRepr {
+    /// The ID of the root message in the thread.
+    event_id: Box<EventId>,
+
+    /// Whether the `m.in_reply_to` field is a fallback for older clients or a real reply in a
+    /// thread.
+    #[serde(
+        rename = "io.element.show_reply",
+        default,
+        skip_serializing_if = "ruma_common::serde::is_default"
+    )]
+    is_falling_back: bool,
+}
diff --git a/crates/ruma/Cargo.toml b/crates/ruma/Cargo.toml
index db30612f..1f53acc2 100644
--- a/crates/ruma/Cargo.toml
+++ b/crates/ruma/Cargo.toml
@@ -118,6 +118,10 @@ unstable-msc2675 = ["ruma-common/unstable-msc2675"]
 unstable-msc2676 = ["ruma-common/unstable-msc2676"]
 unstable-msc2677 = ["ruma-common/unstable-msc2677"]
 unstable-msc3246 = ["ruma-common/unstable-msc3246"]
+unstable-msc3440 = [
+    "ruma-client-api/unstable-msc3440",
+    "ruma-common/unstable-msc3440",
+]
 unstable-msc3488 = [
     "ruma-client-api/unstable-msc3488",
     "ruma-common/unstable-msc3488",
@@ -138,6 +142,7 @@ __ci = [
     "unstable-msc2676",
     "unstable-msc2677",
     "unstable-msc3246",
+    "unstable-msc3440",
     "unstable-msc3488",
     "unstable-msc3551",
     "unstable-msc3552",


Test patch:
diff --git a/crates/ruma-common/tests/events/mod.rs b/crates/ruma-common/tests/events/mod.rs
index b71baaa8..6e93c028 100644
--- a/crates/ruma-common/tests/events/mod.rs
+++ b/crates/ruma-common/tests/events/mod.rs
@@ -17,6 +17,7 @@ mod message_event;
 mod pdu;
 mod redacted;
 mod redaction;
+mod relations;
 mod room_message;
 mod state_event;
 mod stripped;
diff --git a/crates/ruma-common/tests/events/relations.rs b/crates/ruma-common/tests/events/relations.rs
new file mode 100644
index 00000000..13f85d61
--- /dev/null
+++ b/crates/ruma-common/tests/events/relations.rs
@@ -0,0 +1,217 @@
+use assign::assign;
+use matches::assert_matches;
+use ruma_common::{
+    event_id,
+    events::room::message::{InReplyTo, MessageType, Relation, RoomMessageEventContent},
+};
+use serde_json::{from_value as from_json_value, json, to_value as to_json_value};
+
+#[test]
+fn reply_deserialize() {
+    let ev_id = event_id!("$1598361704261elfgc:localhost");
+
+    let json = json!({
+        "msgtype": "m.text",
+        "body": "<text msg>",
+        "m.relates_to": {
+            "m.in_reply_to": {
+                "event_id": ev_id,
+            },
+        },
+    });
+
+    assert_matches!(
+        from_json_value::<RoomMessageEventContent>(json).unwrap(),
+        RoomMessageEventContent {
+            msgtype: MessageType::Text(_),
+            relates_to: Some(Relation::Reply { in_reply_to: InReplyTo { event_id, .. }, .. }),
+            ..
+        } if event_id == ev_id
+    );
+}
+
+#[test]
+fn reply_serialize() {
+    let content = assign!(RoomMessageEventContent::text_plain("This is a reply"), {
+        relates_to: Some(Relation::Reply { in_reply_to: InReplyTo::new(event_id!("$1598361704261elfgc").to_owned()) }),
+    });
+
+    assert_eq!(
+        to_json_value(content).unwrap(),
+        json!({
+            "msgtype": "m.text",
+            "body": "This is a reply",
+            "m.relates_to": {
+                "m.in_reply_to": {
+                    "event_id": "$1598361704261elfgc",
+                },
+            },
+        })
+    );
+}
+
+#[test]
+#[cfg(feature = "unstable-msc2676")]
+fn replacement_serialize() {
+    use ruma_common::events::room::message::Replacement;
+
+    let content = assign!(
+        RoomMessageEventContent::text_plain("<text msg>"),
+        {
+            relates_to: Some(Relation::Replacement(
+                Replacement::new(
+                    event_id!("$1598361704261elfgc").to_owned(),
+                    Box::new(RoomMessageEventContent::text_plain("This is the new content.")),
+                )
+            ))
+        }
+    );
+
+    assert_eq!(
+        to_json_value(content).unwrap(),
+        json!({
+            "msgtype": "m.text",
+            "body": "<text msg>",
+            "m.new_content": {
+                "body": "This is the new content.",
+                "msgtype": "m.text",
+            },
+            "m.relates_to": {
+                "rel_type": "m.replace",
+                "event_id": "$1598361704261elfgc",
+            },
+        })
+    );
+}
+
+#[test]
+#[cfg(feature = "unstable-msc2676")]
+fn replacement_deserialize() {
+    use ruma_common::events::room::message::Replacement;
+
+    let json = json!({
+        "msgtype": "m.text",
+        "body": "<text msg>",
+        "m.new_content": {
+            "body": "Hello! My name is bar",
+            "msgtype": "m.text",
+        },
+        "m.relates_to": {
+            "rel_type": "m.replace",
+            "event_id": "$1598361704261elfgc",
+        },
+    });
+
+    assert_matches!(
+        from_json_value::<RoomMessageEventContent>(json).unwrap(),
+        RoomMessageEventContent {
+            msgtype: MessageType::Text(_),
+            relates_to: Some(Relation::Replacement(Replacement { event_id, new_content, .. })),
+            ..
+        } if event_id == "$1598361704261elfgc"
+          && matches!(&new_content.msgtype, MessageType::Text(text) if text.body == "Hello! My name is bar")
+    );
+}
+
+#[test]
+#[cfg(feature = "unstable-msc3440")]
+fn thread_plain_serialize() {
+    use ruma_common::events::room::message::Thread;
+
+    let content = assign!(
+        RoomMessageEventContent::text_plain("<text msg>"),
+        {
+            relates_to: Some(Relation::Thread(
+                Thread::plain(
+                    event_id!("$1598361704261elfgc").to_owned(),
+                    event_id!("$latesteventid").to_owned(),
+                ),
+            )),
+        }
+    );
+
+    assert_eq!(
+        to_json_value(content).unwrap(),
+        json!({
+            "msgtype": "m.text",
+            "body": "<text msg>",
+            "m.relates_to": {
+                "rel_type": "io.element.thread",
+                "event_id": "$1598361704261elfgc",
+                "m.in_reply_to": {
+                    "event_id": "$latesteventid",
+                },
+            },
+        })
+    );
+}
+
+#[test]
+#[cfg(feature = "unstable-msc3440")]
+fn thread_reply_serialize() {
+    use ruma_common::events::room::message::Thread;
+
+    let content = assign!(
+        RoomMessageEventContent::text_plain("<text msg>"),
+        {
+            relates_to: Some(Relation::Thread(
+                Thread::reply(
+                    event_id!("$1598361704261elfgc").to_owned(),
+                    event_id!("$repliedtoeventid").to_owned(),
+                ),
+            )),
+        }
+    );
+
+    assert_eq!(
+        to_json_value(content).unwrap(),
+        json!({
+            "msgtype": "m.text",
+            "body": "<text msg>",
+            "m.relates_to": {
+                "rel_type": "io.element.thread",
+                "event_id": "$1598361704261elfgc",
+                "m.in_reply_to": {
+                    "event_id": "$repliedtoeventid",
+                },
+                "io.element.show_reply": true,
+            },
+        })
+    );
+}
+
+#[test]
+#[cfg(feature = "unstable-msc3440")]
+fn thread_deserialize() {
+    use ruma_common::events::room::message::Thread;
+
+    let json = json!({
+        "msgtype": "m.text",
+        "body": "<text msg>",
+        "m.relates_to": {
+            "rel_type": "io.element.thread",
+            "event_id": "$1598361704261elfgc",
+            "m.in_reply_to": {
+                "event_id": "$latesteventid",
+            },
+        },
+    });
+
+    assert_matches!(
+        from_json_value::<RoomMessageEventContent>(json).unwrap(),
+        RoomMessageEventContent {
+            msgtype: MessageType::Text(_),
+            relates_to: Some(Relation::Thread(
+                Thread {
+                    event_id,
+                    in_reply_to: InReplyTo { event_id: reply_to_event_id, .. },
+                    is_falling_back,
+                    ..
+                },
+            )),
+            ..
+        } if event_id == "$1598361704261elfgc"
+          && reply_to_event_id == "$latesteventid"
+          && !is_falling_back
+    );
+}