Repository: matrix-org/matrix-rust-sdk
Pull request #4842: refactor(timeline): push the reply logic down into matrix_sdk
Author: login: Johennes
Created: 2025-03-25T20:06:40Z | Merged: 2025-03-27T12:47:52Z

This achieves step 2 of #4835.

<!-- description of the changes in this PR -->

- [x] Public API changes documented in changelogs (optional)



Diff:
diff --git a/bindings/matrix-sdk-ffi/src/timeline/mod.rs b/bindings/matrix-sdk-ffi/src/timeline/mod.rs
index 19c2eefe287..66742056d94 100644
--- a/bindings/matrix-sdk-ffi/src/timeline/mod.rs
+++ b/bindings/matrix-sdk-ffi/src/timeline/mod.rs
@@ -27,7 +27,7 @@ use matrix_sdk::{
     },
     deserialized_responses::{ShieldState as SdkShieldState, ShieldStateCode},
     event_cache::RoomPaginationStatus,
-    room::edit::EditedContent as SdkEditedContent,
+    room::{edit::EditedContent as SdkEditedContent, reply::EnforceThread},
 };
 use matrix_sdk_ui::timeline::{
     self, EventItemOrigin, Profile, RepliedToEvent, TimelineDetails,
@@ -473,14 +473,8 @@ impl Timeline {
         event_id: String,
     ) -> Result<(), ClientError> {
         let event_id = EventId::parse(event_id)?;
-        let replied_to_info = self
-            .inner
-            .replied_to_info_from_event_id(&event_id)
-            .await
-            .map_err(|err| anyhow::anyhow!(err))?;
-
         self.inner
-            .send_reply((*msg).clone(), replied_to_info, timeline::EnforceThread::MaybeThreaded)
+            .send_reply((*msg).clone(), event_id, EnforceThread::MaybeThreaded)
             .await
             .map_err(|err| anyhow::anyhow!(err))?;
         Ok(())
@@ -505,17 +499,11 @@ impl Timeline {
         is_reply: bool,
     ) -> Result<(), ClientError> {
         let event_id = EventId::parse(event_id)?;
-        let replied_to_info = self
-            .inner
-            .replied_to_info_from_event_id(&event_id)
-            .await
-            .map_err(|err| anyhow::anyhow!(err))?;
-
         self.inner
             .send_reply(
                 (*msg).clone(),
-                replied_to_info,
-                timeline::EnforceThread::Threaded(if is_reply {
+                event_id,
+                EnforceThread::Threaded(if is_reply {
                     ReplyWithinThread::Yes
                 } else {
                     ReplyWithinThread::No
diff --git a/crates/matrix-sdk-ui/CHANGELOG.md b/crates/matrix-sdk-ui/CHANGELOG.md
index 84f365ec9ed..c7d7f5849ae 100644
--- a/crates/matrix-sdk-ui/CHANGELOG.md
+++ b/crates/matrix-sdk-ui/CHANGELOG.md
@@ -12,6 +12,11 @@ All notable changes to this project will be documented in this file.
 
 - [**breaking**] Optionally allow starting threads with `Timeline::send_reply`.
   ([4819](https://github.com/matrix-org/matrix-rust-sdk/pull/4819))
+- [**breaking**] Push `RepliedToInfo`, `ReplyContent`, `EnforceThread` and
+  `UnsupportedReplyItem` (becoming `ReplyError`) down into matrix_sdk.
+  [`Timeline::send_reply()`] now takes an event ID rather than a `RepliedToInfo`.
+  `Timeline::replied_to_info_from_event_id` has been made private in `matrix_sdk`.
+  ([4842](https://github.com/matrix-org/matrix-rust-sdk/pull/4842))
 
 ### Refactor
 
diff --git a/crates/matrix-sdk-ui/src/timeline/error.rs b/crates/matrix-sdk-ui/src/timeline/error.rs
index 8794b467666..d86cb7ef101 100644
--- a/crates/matrix-sdk-ui/src/timeline/error.rs
+++ b/crates/matrix-sdk-ui/src/timeline/error.rs
@@ -14,6 +14,7 @@
 
 use matrix_sdk::{
     event_cache::{paginator::PaginatorError, EventCacheError},
+    room::reply::ReplyError,
     send_queue::RoomSendQueueError,
     HttpError,
 };
@@ -73,6 +74,10 @@ pub enum Error {
     #[error(transparent)]
     EditError(#[from] EditError),
 
+    /// An error happened while attempting to reply to an event.
+    #[error(transparent)]
+    ReplyError(#[from] ReplyError),
+
     /// An error happened while attempting to redact an event.
     #[error(transparent)]
     RedactError(#[from] RedactError),
@@ -119,20 +124,6 @@ pub enum PaginationError {
     Paginator(#[source] PaginatorError),
 }
 
-#[derive(Debug, Error)]
-pub enum UnsupportedReplyItem {
-    #[error("local messages whose event ID is not known can't be replied to currently")]
-    MissingEventId,
-    #[error("redacted events whose JSON form isn't available can't be replied")]
-    MissingJson,
-    #[error("event to reply to not found")]
-    MissingEvent,
-    #[error("failed to deserialize event to reply to")]
-    FailedToDeserializeEvent,
-    #[error("tried to reply to a state event")]
-    StateEvent,
-}
-
 #[derive(Debug, Error)]
 pub enum UnsupportedEditItem {
     #[error("tried to edit a non-poll event")]
diff --git a/crates/matrix-sdk-ui/src/timeline/mod.rs b/crates/matrix-sdk-ui/src/timeline/mod.rs
index 26371492b1c..4e2821ce088 100644
--- a/crates/matrix-sdk-ui/src/timeline/mod.rs
+++ b/crates/matrix-sdk-ui/src/timeline/mod.rs
@@ -28,7 +28,7 @@ use matrix_sdk::{
     event_cache::{EventCacheDropHandles, RoomEventCache},
     event_handler::EventHandlerHandle,
     executor::JoinHandle,
-    room::{edit::EditedContent, Receipts, Room},
+    room::{edit::EditedContent, reply::EnforceThread, Receipts, Room},
     send_queue::{RoomSendQueueError, SendHandle},
     Client, Result,
 };
@@ -39,25 +39,17 @@ use ruma::{
     events::{
         poll::unstable_start::{NewUnstablePollStartEventContent, UnstablePollStartEventContent},
         receipt::{Receipt, ReceiptThread},
-        relation::Thread,
         room::{
-            encrypted::Relation as EncryptedRelation,
-            message::{
-                AddMentions, ForwardThread, OriginalRoomMessageEvent, Relation, ReplyWithinThread,
-                RoomMessageEventContent, RoomMessageEventContentWithoutRelation,
-            },
+            message::RoomMessageEventContentWithoutRelation,
             pinned_events::RoomPinnedEventsEventContent,
         },
-        AnyMessageLikeEventContent, AnySyncMessageLikeEvent, AnySyncTimelineEvent,
-        SyncMessageLikeEvent,
+        AnyMessageLikeEventContent, AnySyncTimelineEvent,
     },
-    serde::Raw,
-    EventId, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedUserId, RoomVersionId, UserId,
+    EventId, OwnedEventId, RoomVersionId, UserId,
 };
-use serde::Deserialize;
 use subscriber::TimelineWithDropHandle;
 use thiserror::Error;
-use tracing::{error, instrument, trace, warn};
+use tracing::{instrument, trace, warn};
 
 use self::{
     algorithms::rfind_event_by_id, controller::TimelineController, futures::SendAttachment,
@@ -100,57 +92,6 @@ pub use self::{
     virtual_item::VirtualTimelineItem,
 };
 
-/// Information needed to reply to an event.
-#[derive(Debug, Clone)]
-pub struct RepliedToInfo {
-    /// The event ID of the event to reply to.
-    event_id: OwnedEventId,
-    /// The sender of the event to reply to.
-    sender: OwnedUserId,
-    /// The timestamp of the event to reply to.
-    timestamp: MilliSecondsSinceUnixEpoch,
-    /// The content of the event to reply to.
-    content: ReplyContent,
-}
-
-impl RepliedToInfo {
-    /// The event ID of the event to reply to.
-    pub fn event_id(&self) -> &EventId {
-        &self.event_id
-    }
-
-    /// The sender of the event to reply to.
-    pub fn sender(&self) -> &UserId {
-        &self.sender
-    }
-}
-
-/// The content of a reply.
-#[derive(Debug, Clone)]
-pub enum ReplyContent {
-    /// Content of a message event.
-    Message(RoomMessageEventContent),
-    /// Content of any other kind of event stored as raw JSON.
-    Raw(Raw<AnySyncTimelineEvent>),
-}
-
-/// Whether or not to enforce a [`Relation::Thread`] when sending a reply.
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-#[allow(clippy::exhaustive_enums)]
-pub enum EnforceThread {
-    /// A thread relation is enforced. If the original message does not have a
-    /// thread relation itself, a new thread is started.
-    Threaded(ReplyWithinThread),
-
-    /// A thread relation is not enforced. If the original message has a thread
-    /// relation, it is forwarded.
-    MaybeThreaded,
-
-    /// A thread relation is not enforced. If the original message has a thread
-    /// relation, it is *not* forwarded.
-    Unthreaded,
-}
-
 /// A high-level view into a regular¹ room's contents.
 ///
 /// ¹ This type is meant to be used in the context of rooms without a
@@ -331,162 +272,21 @@ impl Timeline {
     ///
     /// * `content` - The content of the reply
     ///
-    /// * `replied_to_info` - A wrapper that contains the event ID, sender,
-    ///   content and timestamp of the event to reply to
+    /// * `event_id` - The ID of the event to reply to
     ///
     /// * `enforce_thread` - Whether to enforce a thread relation on the reply
-    #[instrument(skip(self, content, replied_to_info))]
+    #[instrument(skip(self, content))]
     pub async fn send_reply(
         &self,
         content: RoomMessageEventContentWithoutRelation,
-        replied_to_info: RepliedToInfo,
+        event_id: OwnedEventId,
         enforce_thread: EnforceThread,
-    ) -> Result<(), RoomSendQueueError> {
-        // [The specification](https://spec.matrix.org/v1.10/client-server-api/#user-and-room-mentions) says:
-        //
-        // > Users should not add their own Matrix ID to the `m.mentions` property as
-        // > outgoing messages cannot self-notify.
-        //
-        // If the replied to event has been written by the current user, let's toggle to
-        // `AddMentions::No`.
-        let mention_the_sender = if self.room().own_user_id() == replied_to_info.sender {
-            AddMentions::No
-        } else {
-            AddMentions::Yes
-        };
-
-        let content = match replied_to_info.content {
-            ReplyContent::Message(replied_to_content) => {
-                let event = OriginalRoomMessageEvent {
-                    event_id: replied_to_info.event_id,
-                    sender: replied_to_info.sender,
-                    origin_server_ts: replied_to_info.timestamp,
-                    room_id: self.room().room_id().to_owned(),
-                    content: replied_to_content,
-                    unsigned: Default::default(),
-                };
-
-                match enforce_thread {
-                    EnforceThread::Threaded(is_reply) => {
-                        content.make_for_thread(&event, is_reply, mention_the_sender)
-                    }
-                    EnforceThread::MaybeThreaded => {
-                        content.make_reply_to(&event, ForwardThread::Yes, mention_the_sender)
-                    }
-                    EnforceThread::Unthreaded => {
-                        content.make_reply_to(&event, ForwardThread::No, mention_the_sender)
-                    }
-                }
-            }
-
-            ReplyContent::Raw(raw_event) => {
-                match enforce_thread {
-                    EnforceThread::Threaded(is_reply) => {
-                        // Some of the code below technically belongs into ruma. However,
-                        // reply fallbacks have been removed in Matrix 1.13 which means
-                        // both match arms can use the successor of make_for_thread in
-                        // the next ruma release.
-                        #[derive(Deserialize)]
-                        struct ContentDeHelper {
-                            #[serde(rename = "m.relates_to")]
-                            relates_to: Option<EncryptedRelation>,
-                        }
-
-                        let previous_content =
-                            raw_event.get_field::<ContentDeHelper>("content").ok().flatten();
-
-                        let mut content = if is_reply == ReplyWithinThread::Yes {
-                            content.make_reply_to_raw(
-                                &raw_event,
-                                replied_to_info.event_id.to_owned(),
-                                self.room().room_id(),
-                                ForwardThread::No,
-                                mention_the_sender,
-                            )
-                        } else {
-                            content.into()
-                        };
-
-                        let thread_root = if let Some(EncryptedRelation::Thread(thread)) =
-                            previous_content.as_ref().and_then(|c| c.relates_to.as_ref())
-                        {
-                            thread.event_id.to_owned()
-                        } else {
-                            replied_to_info.event_id.to_owned()
-                        };
-
-                        let thread = if is_reply == ReplyWithinThread::Yes {
-                            Thread::reply(thread_root, replied_to_info.event_id)
-                        } else {
-                            Thread::plain(thread_root, replied_to_info.event_id)
-                        };
-
-                        content.relates_to = Some(Relation::Thread(thread));
-                        content
-                    }
-
-                    EnforceThread::MaybeThreaded => content.make_reply_to_raw(
-                        &raw_event,
-                        replied_to_info.event_id,
-                        self.room().room_id(),
-                        ForwardThread::Yes,
-                        mention_the_sender,
-                    ),
-
-                    EnforceThread::Unthreaded => content.make_reply_to_raw(
-                        &raw_event,
-                        replied_to_info.event_id,
-                        self.room().room_id(),
-                        ForwardThread::No,
-                        mention_the_sender,
-                    ),
-                }
-            }
-        };
-
-        self.send(content.into()).await?;
-
+    ) -> Result<(), Error> {
+        let content = self.room().make_reply_event(content, &event_id, enforce_thread).await?;
+        self.send(content).await?;
         Ok(())
     }
 
-    /// Get the information needed to reply to the event with the given ID.
-    pub async fn replied_to_info_from_event_id(
-        &self,
-        event_id: &EventId,
-    ) -> Result<RepliedToInfo, UnsupportedReplyItem> {
-        let event = self.room().load_or_fetch_event(event_id, None).await.map_err(|error| {
-            error!("Failed to fetch event with ID {event_id} with error: {error}");
-            UnsupportedReplyItem::MissingEvent
-        })?;
-
-        let raw_event = event.into_raw();
-        let event = raw_event.deserialize().map_err(|error| {
-            error!("Failed to deserialize event with ID {event_id} with error: {error}");
-            UnsupportedReplyItem::FailedToDeserializeEvent
-        })?;
-
-        let reply_content = match &event {
-            AnySyncTimelineEvent::MessageLike(event) => {
-                if let AnySyncMessageLikeEvent::RoomMessage(SyncMessageLikeEvent::Original(
-                    original_event,
-                )) = event
-                {
-                    ReplyContent::Message(original_event.content.clone())
-                } else {
-                    ReplyContent::Raw(raw_event)
-                }
-            }
-            AnySyncTimelineEvent::State(_) => return Err(UnsupportedReplyItem::StateEvent),
-        };
-
-        Ok(RepliedToInfo {
-            event_id: event_id.to_owned(),
-            sender: event.sender().to_owned(),
-            timestamp: event.origin_server_ts(),
-            content: reply_content,
-        })
-    }
-
     /// Edit an event given its [`TimelineEventItemId`] and some new content.
     ///
     /// Only supports events for which [`EventTimelineItem::is_editable()`]
diff --git a/crates/matrix-sdk-ui/tests/integration/timeline/replies.rs b/crates/matrix-sdk-ui/tests/integration/timeline/replies.rs
index 9fa1a6dd248..bf024f13105 100644
--- a/crates/matrix-sdk-ui/tests/integration/timeline/replies.rs
+++ b/crates/matrix-sdk-ui/tests/integration/timeline/replies.rs
@@ -4,14 +4,14 @@ use assert_matches::assert_matches;
 use assert_matches2::assert_let;
 use eyeball_im::VectorDiff;
 use futures_util::StreamExt;
-use matrix_sdk::test_utils::mocks::MatrixMockServer;
+use matrix_sdk::{room::reply::EnforceThread, test_utils::mocks::MatrixMockServer};
 use matrix_sdk_base::timeout::timeout;
 use matrix_sdk_test::{
     async_test, event_factory::EventFactory, JoinedRoomBuilder, ALICE, BOB, CAROL,
 };
 use matrix_sdk_ui::timeline::{
-    AggregatedTimelineItemContent, AggregatedTimelineItemContentKind, EnforceThread,
-    Error as TimelineError, EventSendState, RoomExt, TimelineDetails, TimelineItemContent,
+    AggregatedTimelineItemContent, AggregatedTimelineItemContentKind, Error as TimelineError,
+    EventSendState, RoomExt, TimelineDetails, TimelineItemContent,
 };
 use ruma::{
     event_id,
@@ -719,11 +719,10 @@ async fn test_send_reply() {
         .mount()
         .await;
 
-    let replied_to_info = timeline.replied_to_info_from_event_id(event_id_from_bob).await.unwrap();
     timeline
         .send_reply(
             RoomMessageEventContentWithoutRelation::text_plain("Replying to Bob"),
-            replied_to_info,
+            event_id_from_bob.to_owned(),
             EnforceThread::MaybeThreaded,
         )
         .await
@@ -820,11 +819,10 @@ async fn test_send_reply_to_self() {
         .mount()
         .await;
 
-    let replied_to_info = timeline.replied_to_info_from_event_id(event_id_from_self).await.unwrap();
     timeline
         .send_reply(
             RoomMessageEventContentWithoutRelation::text_plain("Replying to self"),
-            replied_to_info,
+            event_id_from_self.to_owned(),
             EnforceThread::MaybeThreaded,
         )
         .await
@@ -887,11 +885,10 @@ async fn test_send_reply_to_threaded() {
 
     server.mock_room_send().ok(event_id!("$reply_event")).mock_once().mount().await;
 
-    let replied_to_info = timeline.replied_to_info_from_event_id(event_id_1).await.unwrap();
     timeline
         .send_reply(
             RoomMessageEventContentWithoutRelation::text_plain("Hello, Bob!"),
-            replied_to_info,
+            event_id_1.to_owned(),
             EnforceThread::MaybeThreaded,
         )
         .await
@@ -991,11 +988,10 @@ async fn test_send_reply_with_event_id() {
         .mount()
         .await;
 
-    let replied_to_info = timeline.replied_to_info_from_event_id(event_id_from_bob).await.unwrap();
     timeline
         .send_reply(
             RoomMessageEventContentWithoutRelation::text_plain("Replying to Bob"),
-            replied_to_info,
+            event_id_from_bob.to_owned(),
             EnforceThread::MaybeThreaded,
         )
         .await
@@ -1078,11 +1074,10 @@ async fn test_send_reply_enforce_thread() {
         .mount()
         .await;
 
-    let replied_to_info = timeline.replied_to_info_from_event_id(event_id_from_bob).await.unwrap();
     timeline
         .send_reply(
             RoomMessageEventContentWithoutRelation::text_plain("Replying to Bob"),
-            replied_to_info,
+            event_id_from_bob.to_owned(),
             EnforceThread::Threaded(ReplyWithinThread::No),
         )
         .await
@@ -1176,11 +1171,10 @@ async fn test_send_reply_enforce_thread_is_reply() {
         .mount()
         .await;
 
-    let replied_to_info = timeline.replied_to_info_from_event_id(event_id_from_bob).await.unwrap();
     timeline
         .send_reply(
             RoomMessageEventContentWithoutRelation::text_plain("Replying to Bob"),
-            replied_to_info,
+            event_id_from_bob.to_owned(),
             EnforceThread::Threaded(ReplyWithinThread::Yes),
         )
         .await
@@ -1270,12 +1264,10 @@ async fn test_send_reply_with_event_id_that_is_redacted() {
         .mount()
         .await;
 
-    let replied_to_info =
-        timeline.replied_to_info_from_event_id(redacted_event_id_from_bob).await.unwrap();
     timeline
         .send_reply(
             RoomMessageEventContentWithoutRelation::text_plain("Replying to Bob"),
-            replied_to_info,
+            redacted_event_id_from_bob.to_owned(),
             EnforceThread::MaybeThreaded,
         )
         .await
diff --git a/crates/matrix-sdk/src/room/edit.rs b/crates/matrix-sdk/src/room/edit.rs
index 0634c028b68..53e58aba685 100644
--- a/crates/matrix-sdk/src/room/edit.rs
+++ b/crates/matrix-sdk/src/room/edit.rs
@@ -14,9 +14,6 @@
 
 //! Facilities to edit existing events.
 
-use std::future::Future;
-
-use matrix_sdk_base::{deserialized_responses::TimelineEvent, SendOutsideWasm};
 use ruma::{
     events::{
         poll::unstable_start::{
@@ -36,6 +33,7 @@ use ruma::{
 use thiserror::Error;
 use tracing::{instrument, warn};
 
+use super::EventSource;
 use crate::Room;
 
 /// The new content that will replace the previous event's content.
@@ -125,21 +123,6 @@ impl Room {
     }
 }
 
-trait EventSource {
-    fn get_event(
-        &self,
-        event_id: &EventId,
-    ) -> impl Future<Output = Result<TimelineEvent, EditError>> + SendOutsideWasm;
-}
-
-impl EventSource for &Room {
-    async fn get_event(&self, event_id: &EventId) -> Result<TimelineEvent, EditError> {
-        self.load_or_fetch_event(event_id, None)
-            .await
-            .map_err(|err| EditError::Fetch(Box::new(err)))
-    }
-}
-
 async fn make_edit_event<S: EventSource>(
     source: S,
     room_id: &RoomId,
@@ -147,7 +130,7 @@ async fn make_edit_event<S: EventSource>(
     event_id: &EventId,
     new_content: EditedContent,
 ) -> Result<AnyMessageLikeEventContent, EditError> {
-    let target = source.get_event(event_id).await?;
+    let target = source.get_event(event_id).await.map_err(|err| EditError::Fetch(Box::new(err)))?;
 
     let event = target.raw().deserialize().map_err(EditError::Deserialize)?;
 
@@ -352,14 +335,11 @@ mod tests {
             room::message::{MessageType, Relation, RoomMessageEventContentWithoutRelation},
             AnyMessageLikeEventContent, AnySyncTimelineEvent, Mentions,
         },
-        owned_mxc_uri, owned_user_id, room_id,
-        serde::Raw,
-        user_id, EventId, OwnedEventId,
+        owned_mxc_uri, owned_user_id, room_id, user_id, EventId, OwnedEventId,
     };
-    use serde_json::json;
 
     use super::{make_edit_event, EditError, EventSource};
-    use crate::room::edit::EditedContent;
+    use crate::{room::edit::EditedContent, Error};
 
     #[derive(Default)]
     struct TestEventCache {
@@ -367,7 +347,7 @@ mod tests {
     }
 
     impl EventSource for TestEventCache {
-        async fn get_event(&self, event_id: &EventId) -> Result<TimelineEvent, EditError> {
+        async fn get_event(&self, event_id: &EventId) -> Result<TimelineEvent, Error> {
             Ok(self.events.get(event_id).unwrap().clone())
         }
     }
@@ -378,26 +358,10 @@ mod tests {
         let own_user_id = user_id!("@me:saucisse.bzh");
 
         let mut cache = TestEventCache::default();
-
+        let f = EventFactory::new();
         cache.events.insert(
             event_id.to_owned(),
-            // TODO: use the EventFactory for state events too.
-            TimelineEvent::new(
-                Raw::<AnySyncTimelineEvent>::from_json_string(
-                    json!({
-                        "content": {
-                            "name": "The room name"
-                        },
-                        "event_id": event_id,
-                        "sender": own_user_id,
-                        "state_key": "",
-                        "origin_server_ts": 1,
-                        "type": "m.room.name",
-                    })
-                    .to_string(),
-                )
-                .unwrap(),
-            ),
+            f.room_name("The room name").event_id(event_id).sender(own_user_id).into(),
         );
 
         let room_id = room_id!("!galette:saucisse.bzh");
diff --git a/crates/matrix-sdk/src/room/mod.rs b/crates/matrix-sdk/src/room/mod.rs
index 7a8bb0cdfc6..133d1608096 100644
--- a/crates/matrix-sdk/src/room/mod.rs
+++ b/crates/matrix-sdk/src/room/mod.rs
@@ -17,6 +17,7 @@
 use std::{
     borrow::Borrow,
     collections::{BTreeMap, HashMap},
+    future::Future,
     ops::Deref,
     sync::Arc,
     time::Duration,
@@ -46,8 +47,8 @@ use matrix_sdk_base::{
     event_cache::store::media::IgnoreMediaRetentionPolicy,
     media::MediaThumbnailSettings,
     store::StateStoreExt,
-    ComposerDraft, EncryptionState, RoomInfoNotableUpdateReasons, RoomMemberships, StateChanges,
-    StateStoreDataKey, StateStoreDataValue,
+    ComposerDraft, EncryptionState, RoomInfoNotableUpdateReasons, RoomMemberships, SendOutsideWasm,
+    StateChanges, StateStoreDataKey, StateStoreDataValue,
 };
 #[cfg(all(feature = "e2e-encryption", not(target_arch = "wasm32")))]
 use matrix_sdk_common::BoxFuture;
@@ -166,6 +167,7 @@ pub mod knock_requests;
 mod member;
 mod messages;
 pub mod power_levels;
+pub mod reply;
 
 /// Contains all the functionality for modifying the privacy settings in a room.
 pub mod privacy_settings;
@@ -3773,6 +3775,19 @@ impl TryFrom<Int> for ReportedContentScore {
     }
 }
 
+trait EventSource {
+    fn get_event(
+        &self,
+        event_id: &EventId,
+    ) -> impl Future<Output = Result<TimelineEvent, Error>> + SendOutsideWasm;
+}
+
+impl EventSource for &Room {
+    async fn get_event(&self, event_id: &EventId) -> Result<TimelineEvent, Error> {
+        self.load_or_fetch_event(event_id, None).await
+    }
+}
+
 /// The error type returned when a checked `ReportedContentScore` conversion
 /// fails.
 #[derive(Debug, Clone, Error)]
diff --git a/crates/matrix-sdk/src/room/reply.rs b/crates/matrix-sdk/src/room/reply.rs
new file mode 100644
index 00000000000..fcb81b5bfdc
--- /dev/null
+++ b/crates/matrix-sdk/src/room/reply.rs
@@ -0,0 +1,590 @@
+// Copyright 2025 The Matrix.org Foundation C.I.C.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Facilities to reply to existing events.
+
+use ruma::{
+    events::{
+        relation::Thread,
+        room::{
+            encrypted::Relation as EncryptedRelation,
+            message::{
+                AddMentions, ForwardThread, OriginalRoomMessageEvent, Relation, ReplyWithinThread,
+                RoomMessageEventContent, RoomMessageEventContentWithoutRelation,
+            },
+        },
+        AnyMessageLikeEventContent, AnySyncMessageLikeEvent, AnySyncTimelineEvent,
+        SyncMessageLikeEvent,
+    },
+    serde::Raw,
+    EventId, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedUserId, RoomId, UserId,
+};
+use serde::Deserialize;
+use thiserror::Error;
+use tracing::{error, instrument};
+
+use super::{EventSource, Room};
+
+/// Information needed to reply to an event.
+#[derive(Debug, Clone)]
+struct RepliedToInfo {
+    /// The event ID of the event to reply to.
+    event_id: OwnedEventId,
+    /// The sender of the event to reply to.
+    sender: OwnedUserId,
+    /// The timestamp of the event to reply to.
+    timestamp: MilliSecondsSinceUnixEpoch,
+    /// The content of the event to reply to.
+    content: ReplyContent,
+}
+
+/// The content of a reply.
+#[derive(Debug, Clone)]
+enum ReplyContent {
+    /// Content of a message event.
+    Message(RoomMessageEventContent),
+    /// Content of any other kind of event stored as raw JSON.
+    Raw(Raw<AnySyncTimelineEvent>),
+}
+
+/// Errors specific to unsupported replies.
+#[derive(Debug, Error)]
+pub enum ReplyError {
+    /// We couldn't fetch the remote event with /room/event.
+    #[error("Couldn't fetch the remote event: {0}")]
+    Fetch(Box<crate::Error>),
+    /// The event to reply to could not be deserialized.
+    #[error("failed to deserialize event to reply to")]
+    Deserialization,
+    /// State events cannot be replied to.
+    #[error("tried to reply to a state event")]
+    StateEvent,
+}
+
+/// Whether or not to enforce a [`Relation::Thread`] when sending a reply.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum EnforceThread {
+    /// A thread relation is enforced. If the original message does not have a
+    /// thread relation itself, a new thread is started.
+    Threaded(ReplyWithinThread),
+
+    /// A thread relation is not enforced. If the original message has a thread
+    /// relation, it is forwarded.
+    MaybeThreaded,
+
+    /// A thread relation is not enforced. If the original message has a thread
+    /// relation, it is *not* forwarded.
+    Unthreaded,
+}
+
+impl Room {
+    /// Create a new reply event for the target event id with the specified
+    /// content.
+    ///
+    /// The event can then be sent with [`Room::send`] or a
+    /// [`crate::send_queue::RoomSendQueue`].
+    #[instrument(skip(self, content), fields(room = %self.room_id()))]
+    pub async fn make_reply_event(
+        &self,
+        content: RoomMessageEventContentWithoutRelation,
+        event_id: &EventId,
+        enforce_thread: EnforceThread,
+    ) -> Result<AnyMessageLikeEventContent, ReplyError> {
+        make_reply_event(
+            self,
+            self.room_id(),
+            self.own_user_id(),
+            content,
+            event_id,
+            enforce_thread,
+        )
+        .await
+    }
+}
+
+async fn make_reply_event<S: EventSource>(
+    source: S,
+    room_id: &RoomId,
+    own_user_id: &UserId,
+    content: RoomMessageEventContentWithoutRelation,
+    event_id: &EventId,
+    enforce_thread: EnforceThread,
+) -> Result<AnyMessageLikeEventContent, ReplyError> {
+    let replied_to_info = replied_to_info_from_event_id(source, event_id).await?;
+
+    // [The specification](https://spec.matrix.org/v1.10/client-server-api/#user-and-room-mentions) says:
+    //
+    // > Users should not add their own Matrix ID to the `m.mentions` property as
+    // > outgoing messages cannot self-notify.
+    //
+    // If the replied to event has been written by the current user, let's toggle to
+    // `AddMentions::No`.
+    let mention_the_sender =
+        if own_user_id == replied_to_info.sender { AddMentions::No } else { AddMentions::Yes };
+
+    let content = match replied_to_info.content {
+        ReplyContent::Message(replied_to_content) => {
+            let event = OriginalRoomMessageEvent {
+                event_id: replied_to_info.event_id,
+                sender: replied_to_info.sender,
+                origin_server_ts: replied_to_info.timestamp,
+                room_id: room_id.to_owned(),
+                content: replied_to_content,
+                unsigned: Default::default(),
+            };
+
+            match enforce_thread {
+                EnforceThread::Threaded(is_reply) => {
+                    content.make_for_thread(&event, is_reply, mention_the_sender)
+                }
+                EnforceThread::MaybeThreaded => {
+                    content.make_reply_to(&event, ForwardThread::Yes, mention_the_sender)
+                }
+                EnforceThread::Unthreaded => {
+                    content.make_reply_to(&event, ForwardThread::No, mention_the_sender)
+                }
+            }
+        }
+
+        ReplyContent::Raw(raw_event) => {
+            match enforce_thread {
+                EnforceThread::Threaded(is_reply) => {
+                    // Some of the code below technically belongs into ruma. However,
+                    // reply fallbacks have been removed in Matrix 1.13 which means
+                    // both match arms can use the successor of make_for_thread in
+                    // the next ruma release.
+                    #[derive(Deserialize)]
+                    struct ContentDeHelper {
+                        #[serde(rename = "m.relates_to")]
+                        relates_to: Option<EncryptedRelation>,
+                    }
+
+                    let previous_content =
+                        raw_event.get_field::<ContentDeHelper>("content").ok().flatten();
+
+                    let mut content = if is_reply == ReplyWithinThread::Yes {
+                        content.make_reply_to_raw(
+                            &raw_event,
+                            replied_to_info.event_id.to_owned(),
+                            room_id,
+                            ForwardThread::No,
+                            mention_the_sender,
+                        )
+                    } else {
+                        content.into()
+                    };
+
+                    let thread_root = if let Some(EncryptedRelation::Thread(thread)) =
+                        previous_content.as_ref().and_then(|c| c.relates_to.as_ref())
+                    {
+                        thread.event_id.to_owned()
+                    } else {
+                        replied_to_info.event_id.to_owned()
+                    };
+
+                    let thread = if is_reply == ReplyWithinThread::Yes {
+                        Thread::reply(thread_root, replied_to_info.event_id)
+                    } else {
+                        Thread::plain(thread_root, replied_to_info.event_id)
+                    };
+
+                    content.relates_to = Some(Relation::Thread(thread));
+                    content
+                }
+
+                EnforceThread::MaybeThreaded => content.make_reply_to_raw(
+                    &raw_event,
+                    replied_to_info.event_id,
+                    room_id,
+                    ForwardThread::Yes,
+                    mention_the_sender,
+                ),
+
+                EnforceThread::Unthreaded => content.make_reply_to_raw(
+                    &raw_event,
+                    replied_to_info.event_id,
+                    room_id,
+                    ForwardThread::No,
+                    mention_the_sender,
+                ),
+            }
+        }
+    };
+
+    Ok(content.into())
+}
+
+async fn replied_to_info_from_event_id<S: EventSource>(
+    source: S,
+    event_id: &EventId,
+) -> Result<RepliedToInfo, ReplyError> {
+    let event = source.get_event(event_id).await.map_err(|err| ReplyError::Fetch(Box::new(err)))?;
+
+    let raw_event = event.into_raw();
+    let event = raw_event.deserialize().map_err(|_| ReplyError::Deserialization)?;
+
+    let reply_content = match &event {
+        AnySyncTimelineEvent::MessageLike(event) => {
+            if let AnySyncMessageLikeEvent::RoomMessage(SyncMessageLikeEvent::Original(
+                original_event,
+            )) = event
+            {
+                ReplyContent::Message(original_event.content.clone())
+            } else {
+                ReplyContent::Raw(raw_event)
+            }
+        }
+        AnySyncTimelineEvent::State(_) => return Err(ReplyError::StateEvent),
+    };
+
+    Ok(RepliedToInfo {
+        event_id: event_id.to_owned(),
+        sender: event.sender().to_owned(),
+        timestamp: event.origin_server_ts(),
+        content: reply_content,
+    })
+}
+
+#[cfg(test)]
+mod tests {
+    use std::collections::BTreeMap;
+
+    use assert_matches2::{assert_let, assert_matches};
+    use matrix_sdk_base::deserialized_responses::TimelineEvent;
+    use matrix_sdk_test::{async_test, event_factory::EventFactory};
+    use ruma::{
+        event_id,
+        events::{
+            room::message::{Relation, ReplyWithinThread, RoomMessageEventContentWithoutRelation},
+            AnyMessageLikeEventContent, AnySyncTimelineEvent,
+        },
+        room_id,
+        serde::Raw,
+        user_id, EventId, OwnedEventId,
+    };
+    use serde_json::json;
+
+    use super::{make_reply_event, EnforceThread, EventSource, ReplyError};
+    use crate::{event_cache::EventCacheError, Error};
+
+    #[derive(Default)]
+    struct TestEventCache {
+        events: BTreeMap<OwnedEventId, TimelineEvent>,
+    }
+
+    impl EventSource for TestEventCache {
+        async fn get_event(&self, event_id: &EventId) -> Result<TimelineEvent, Error> {
+            self.events
+                .get(event_id)
+                .cloned()
+                .ok_or(Error::EventCache(EventCacheError::ClientDropped))
+        }
+    }
+
+    #[async_test]
+    async fn test_cannot_reply_to_unknown_event() {
+        let event_id = event_id!("$1");
+        let own_user_id = user_id!("@me:saucisse.bzh");
+
+        let mut cache = TestEventCache::default();
+        let f = EventFactory::new();
+        cache.events.insert(
+            event_id.to_owned(),
+            f.text_msg("hi").event_id(event_id).sender(own_user_id).into(),
+        );
+
+        let room_id = room_id!("!galette:saucisse.bzh");
+        let content = RoomMessageEventContentWithoutRelation::text_plain("the reply");
+
+        assert_matches!(
+            make_reply_event(
+                cache,
+                room_id,
+                own_user_id,
+                content,
+                event_id!("$2"),
+                EnforceThread::Unthreaded,
+            )
+            .await,
+            Err(ReplyError::Fetch(_))
+        );
+    }
+
+    #[async_test]
+    async fn test_cannot_reply_to_invalid_event() {
+        let event_id = event_id!("$1");
+        let own_user_id = user_id!("@me:saucisse.bzh");
+
+        let mut cache = TestEventCache::default();
+
+        cache.events.insert(
+            event_id.to_owned(),
+            TimelineEvent::new(
+                Raw::<AnySyncTimelineEvent>::from_json_string(
+                    json!({
+                        "content": {
+                            "body": "hi"
+                        },
+                        "event_id": event_id,
+                        "origin_server_ts": 1,
+                        "type": "m.room.message",
+                        // Invalid because sender is missing
+                    })
+                    .to_string(),
+                )
+                .unwrap(),
+            ),
+        );
+
+        let room_id = room_id!("!galette:saucisse.bzh");
+        let content = RoomMessageEventContentWithoutRelation::text_plain("the reply");
+
+        assert_matches!(
+            make_reply_event(
+                cache,
+                room_id,
+                own_user_id,
+                content,
+                event_id,
+                EnforceThread::Unthreaded,
+            )
+            .await,
+            Err(ReplyError::Deserialization)
+        );
+    }
+
+    #[async_test]
+    async fn test_cannot_reply_to_state_event() {
+        let event_id = event_id!("$1");
+        let own_user_id = user_id!("@me:saucisse.bzh");
+
+        let mut cache = TestEventCache::default();
+        let f = EventFactory::new();
+        cache.events.insert(
+            event_id.to_owned(),
+            f.room_name("lobby").event_id(event_id).sender(own_user_id).into(),
+        );
+
+        let room_id = room_id!("!galette:saucisse.bzh");
+        let content = RoomMessageEventContentWithoutRelation::text_plain("the reply");
+
+        assert_matches!(
+            make_reply_event(
+                cache,
+                room_id,
+                own_user_id,
+                content,
+                event_id,
+                EnforceThread::Unthreaded,
+            )
+            .await,
+            Err(ReplyError::StateEvent)
+        );
+    }
+
+    #[async_test]
+    async fn test_reply_unthreaded() {
+        let event_id = event_id!("$1");
+        let own_user_id = user_id!("@me:saucisse.bzh");
+
+        let mut cache = TestEventCache::default();
+        let f = EventFactory::new();
+        cache.events.insert(
+            event_id.to_owned(),
+            f.text_msg("hi").event_id(event_id).sender(own_user_id).into(),
+        );
+
+        let room_id = room_id!("!galette:saucisse.bzh");
+        let content = RoomMessageEventContentWithoutRelation::text_plain("the reply");
+
+        let reply_event = make_reply_event(
+            cache,
+            room_id,
+            own_user_id,
+            content,
+            event_id,
+            EnforceThread::Unthreaded,
+        )
+        .await
+        .unwrap();
+
+        assert_let!(AnyMessageLikeEventContent::RoomMessage(msg) = &reply_event);
+        assert_let!(Some(Relation::Reply { in_reply_to }) = &msg.relates_to);
+
+        assert_eq!(in_reply_to.event_id, event_id);
+    }
+
+    #[async_test]
+    async fn test_start_thread() {
+        let event_id = event_id!("$1");
+        let own_user_id = user_id!("@me:saucisse.bzh");
+
+        let mut cache = TestEventCache::default();
+        let f = EventFactory::new();
+        cache.events.insert(
+            event_id.to_owned(),
+            f.text_msg("hi").event_id(event_id).sender(own_user_id).into(),
+        );
+
+        let room_id = room_id!("!galette:saucisse.bzh");
+        let content = RoomMessageEventContentWithoutRelation::text_plain("the reply");
+
+        let reply_event = make_reply_event(
+            cache,
+            room_id,
+            own_user_id,
+            content,
+            event_id,
+            EnforceThread::Threaded(ReplyWithinThread::No),
+        )
+        .await
+        .unwrap();
+
+        assert_let!(AnyMessageLikeEventContent::RoomMessage(msg) = &reply_event);
+        assert_let!(Some(Relation::Thread(thread)) = &msg.relates_to);
+
+        assert_eq!(thread.event_id, event_id);
+        assert_eq!(thread.in_reply_to.as_ref().unwrap().event_id, event_id);
+        assert!(thread.is_falling_back);
+    }
+
+    #[async_test]
+    async fn test_reply_on_thread() {
+        let thread_root = event_id!("$1");
+        let event_id = event_id!("$2");
+        let own_user_id = user_id!("@me:saucisse.bzh");
+
+        let mut cache = TestEventCache::default();
+        let f = EventFactory::new();
+        cache.events.insert(
+            thread_root.to_owned(),
+            f.text_msg("hi").event_id(thread_root).sender(own_user_id).into(),
+        );
+        cache.events.insert(
+            event_id.to_owned(),
+            f.text_msg("ho")
+                .in_thread(thread_root, thread_root)
+                .event_id(event_id)
+                .sender(own_user_id)
+                .into(),
+        );
+
+        let room_id = room_id!("!galette:saucisse.bzh");
+        let content = RoomMessageEventContentWithoutRelation::text_plain("the reply");
+
+        let reply_event = make_reply_event(
+            cache,
+            room_id,
+            own_user_id,
+            content,
+            event_id,
+            EnforceThread::Threaded(ReplyWithinThread::No),
+        )
+        .await
+        .unwrap();
+
+        assert_let!(AnyMessageLikeEventContent::RoomMessage(msg) = &reply_event);
+        assert_let!(Some(Relation::Thread(thread)) = &msg.relates_to);
+
+        assert_eq!(thread.event_id, thread_root);
+        assert_eq!(thread.in_reply_to.as_ref().unwrap().event_id, event_id);
+        assert!(thread.is_falling_back);
+    }
+
+    #[async_test]
+    async fn test_reply_on_thread_as_reply() {
+        let thread_root = event_id!("$1");
+        let event_id = event_id!("$2");
+        let own_user_id = user_id!("@me:saucisse.bzh");
+
+        let mut cache = TestEventCache::default();
+        let f = EventFactory::new();
+        cache.events.insert(
+            thread_root.to_owned(),
+            f.text_msg("hi").event_id(thread_root).sender(own_user_id).into(),
+        );
+        cache.events.insert(
+            event_id.to_owned(),
+            f.text_msg("ho")
+                .in_thread(thread_root, thread_root)
+                .event_id(event_id)
+                .sender(own_user_id)
+                .into(),
+        );
+
+        let room_id = room_id!("!galette:saucisse.bzh");
+        let content = RoomMessageEventContentWithoutRelation::text_plain("the reply");
+
+        let reply_event = make_reply_event(
+            cache,
+            room_id,
+            own_user_id,
+            content,
+            event_id,
+            EnforceThread::Threaded(ReplyWithinThread::Yes),
+        )
+        .await
+        .unwrap();
+
+        assert_let!(AnyMessageLikeEventContent::RoomMessage(msg) = &reply_event);
+        assert_let!(Some(Relation::Thread(thread)) = &msg.relates_to);
+
+        assert_eq!(thread.event_id, thread_root);
+        assert_eq!(thread.in_reply_to.as_ref().unwrap().event_id, event_id);
+        assert!(!thread.is_falling_back);
+    }
+
+    #[async_test]
+    async fn test_reply_forwarding_thread() {
+        let thread_root = event_id!("$1");
+        let event_id = event_id!("$2");
+        let own_user_id = user_id!("@me:saucisse.bzh");
+
+        let mut cache = TestEventCache::default();
+        let f = EventFactory::new();
+        cache.events.insert(
+            thread_root.to_owned(),
+            f.text_msg("hi").event_id(thread_root).sender(own_user_id).into(),
+        );
+        cache.events.insert(
+            event_id.to_owned(),
+            f.text_msg("ho")
+                .in_thread(thread_root, thread_root)
+                .event_id(event_id)
+                .sender(own_user_id)
+                .into(),
+        );
+
+        let room_id = room_id!("!galette:saucisse.bzh");
+        let content = RoomMessageEventContentWithoutRelation::text_plain("the reply");
+
+        let reply_event = make_reply_event(
+            cache,
+            room_id,
+            own_user_id,
+            content,
+            event_id,
+            EnforceThread::MaybeThreaded,
+        )
+        .await
+        .unwrap();
+
+        assert_let!(AnyMessageLikeEventContent::RoomMessage(msg) = &reply_event);
+        assert_let!(Some(Relation::Thread(thread)) = &msg.relates_to);
+
+        assert_eq!(thread.event_id, thread_root);
+        assert_eq!(thread.in_reply_to.as_ref().unwrap().event_id, event_id);
+        assert!(thread.is_falling_back);
+    }
+}


Reviews:
user: login: Johennes
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T20:10:12Z
user: login: Johennes
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T20:14:08Z
user: login: bnjbvr
body: Thanks, looks like this is on the right track! A few small comments below, but looks close to merging.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T12:24:06Z
user: login: Johennes
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T12:47:35Z
user: login: Johennes
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T13:06:07Z
user: login: Johennes
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T13:13:52Z
user: login: Johennes
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T18:25:29Z
user: login: Johennes
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T18:26:27Z
user: login: Johennes
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T18:28:14Z
user: login: Johennes
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T18:28:31Z
user: login: bnjbvr
body: Sorry I forgot to send the review comment explaining how to share more code yesterday evening. Once it's addressed, I'll press merge. Thanks for the PR!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T09:41:39Z
user: login: bnjbvr
body: Nice, thanks!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T12:47:15Z

Review comments:
diff_hunk: @@ -73,6 +74,10 @@ pub enum Error {
     #[error(transparent)]
     EditError(#[from] EditError),
 
+    /// An error happened while attempting to reply to an event.
+    #[error(transparent)]
+    ReplyError(#[from] ReplyError),
path: crates/matrix-sdk-ui/src/timeline/error.rs
user: login: Johennes
body: I wasn't sure about the wrapping of errors here. Is this allowed to wrap an error from `matrix_sdk`?
created_at: 2025-03-25T20:10:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,404 @@
+// Copyright 2025 The Matrix.org Foundation C.I.C.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Facilities to reply to existing events.
+
+use std::future::Future;
+
+use matrix_sdk_base::{deserialized_responses::TimelineEvent, SendOutsideWasm};
+use ruma::{
+    events::{
+        relation::Thread,
+        room::{
+            encrypted::Relation as EncryptedRelation,
+            message::{
+                AddMentions, ForwardThread, OriginalRoomMessageEvent, Relation, ReplyWithinThread,
+                RoomMessageEventContent, RoomMessageEventContentWithoutRelation,
+            },
+        },
+        AnyMessageLikeEventContent, AnySyncMessageLikeEvent, AnySyncTimelineEvent,
+        SyncMessageLikeEvent,
+    },
+    serde::Raw,
+    EventId, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedUserId, RoomId, UserId,
+};
+use serde::Deserialize;
+use thiserror::Error;
+use tracing::{error, instrument};
+
+use super::Room;
+
+/// Information needed to reply to an event.
+#[derive(Debug, Clone)]
+pub struct RepliedToInfo {
+    /// The event ID of the event to reply to.
+    event_id: OwnedEventId,
+    /// The sender of the event to reply to.
+    sender: OwnedUserId,
+    /// The timestamp of the event to reply to.
+    timestamp: MilliSecondsSinceUnixEpoch,
+    /// The content of the event to reply to.
+    content: ReplyContent,
+}
+
+impl RepliedToInfo {
+    /// The event ID of the event to reply to.
+    pub fn event_id(&self) -> &EventId {
+        &self.event_id
+    }
+
+    /// The sender of the event to reply to.
+    pub fn sender(&self) -> &UserId {
+        &self.sender
+    }
+}
+
+/// The content of a reply.
+#[derive(Debug, Clone)]
+pub enum ReplyContent {
+    /// Content of a message event.
+    Message(RoomMessageEventContent),
+    /// Content of any other kind of event stored as raw JSON.
+    Raw(Raw<AnySyncTimelineEvent>),
+}
+
+/// Errors specific to unsupported replies.
+#[derive(Debug, Error)]
+pub enum ReplyError {
+    /// We couldn't fetch the remote event with /room/event.
+    #[error("Couldn't fetch the remote event: {0}")]
+    Fetch(Box<crate::Error>),
+    /// Redacted events whose JSON form isn't available cannot be replied to.
+    #[error("redacted events whose JSON form isn't available can't be replied")]
+    MissingJson,
+    /// The event to reply to could not be found.
+    #[error("event to reply to not found")]
+    MissingEvent,
+    /// The event to reply to could not be deserialized.
+    #[error("failed to deserialize event to reply to")]
+    FailedToDeserializeEvent,
+    /// State events cannot be replied to.
+    #[error("tried to reply to a state event")]
+    StateEvent,
+}
+
+/// Whether or not to enforce a [`Relation::Thread`] when sending a reply.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[allow(clippy::exhaustive_enums)]
+pub enum EnforceThread {
+    /// A thread relation is enforced. If the original message does not have a
+    /// thread relation itself, a new thread is started.
+    Threaded(ReplyWithinThread),
+
+    /// A thread relation is not enforced. If the original message has a thread
+    /// relation, it is forwarded.
+    MaybeThreaded,
+
+    /// A thread relation is not enforced. If the original message has a thread
+    /// relation, it is *not* forwarded.
+    Unthreaded,
+}
+
+impl Room {
+    /// Create a new reply event for the target event id with the specified
+    /// content.
+    ///
+    /// The event can then be sent with [`Room::send`] or a
+    /// [`crate::send_queue::RoomSendQueue`].
+    #[instrument(skip(self, content), fields(room = %self.room_id()))]
+    pub async fn make_reply_event(
+        &self,
+        content: RoomMessageEventContentWithoutRelation,
+        event_id: &EventId,
+        enforce_thread: EnforceThread,
+    ) -> Result<AnyMessageLikeEventContent, ReplyError> {
+        make_reply_event(
+            self,
+            self.room_id(),
+            self.own_user_id(),
+            content,
+            event_id,
+            enforce_thread,
+        )
+        .await
+    }
+}
+
+trait EventSource {
+    fn get_event(
+        &self,
+        event_id: &EventId,
+    ) -> impl Future<Output = Result<TimelineEvent, ReplyError>> + SendOutsideWasm;
+}
+
+impl EventSource for &Room {
+    async fn get_event(&self, event_id: &EventId) -> Result<TimelineEvent, ReplyError> {
+        self.load_or_fetch_event(event_id, None)
+            .await
+            .map_err(|err| ReplyError::Fetch(Box::new(err)))
+    }
+}
+
+async fn make_reply_event<S: EventSource>(
+    source: S,
+    room_id: &RoomId,
+    own_user_id: &UserId,
+    content: RoomMessageEventContentWithoutRelation,
+    event_id: &EventId,
+    enforce_thread: EnforceThread,
+) -> Result<AnyMessageLikeEventContent, ReplyError> {
+    let replied_to_info = replied_to_info_from_event_id(source, event_id).await?;
+
+    // [The specification](https://spec.matrix.org/v1.10/client-server-api/#user-and-room-mentions) says:
+    //
+    // > Users should not add their own Matrix ID to the `m.mentions` property as
+    // > outgoing messages cannot self-notify.
+    //
+    // If the replied to event has been written by the current user, let's toggle to
+    // `AddMentions::No`.
+    let mention_the_sender =
+        if own_user_id == replied_to_info.sender { AddMentions::No } else { AddMentions::Yes };
+
+    let content = match replied_to_info.content {
+        ReplyContent::Message(replied_to_content) => {
+            let event = OriginalRoomMessageEvent {
+                event_id: replied_to_info.event_id,
+                sender: replied_to_info.sender,
+                origin_server_ts: replied_to_info.timestamp,
+                room_id: room_id.to_owned(),
+                content: replied_to_content,
+                unsigned: Default::default(),
+            };
+
+            match enforce_thread {
+                EnforceThread::Threaded(is_reply) => {
+                    content.make_for_thread(&event, is_reply, mention_the_sender)
+                }
+                EnforceThread::MaybeThreaded => {
+                    content.make_reply_to(&event, ForwardThread::Yes, mention_the_sender)
+                }
+                EnforceThread::Unthreaded => {
+                    content.make_reply_to(&event, ForwardThread::No, mention_the_sender)
+                }
+            }
+        }
+
+        ReplyContent::Raw(raw_event) => {
+            match enforce_thread {
+                EnforceThread::Threaded(is_reply) => {
+                    // Some of the code below technically belongs into ruma. However,
+                    // reply fallbacks have been removed in Matrix 1.13 which means
+                    // both match arms can use the successor of make_for_thread in
+                    // the next ruma release.
+                    #[derive(Deserialize)]
+                    struct ContentDeHelper {
+                        #[serde(rename = "m.relates_to")]
+                        relates_to: Option<EncryptedRelation>,
+                    }
+
+                    let previous_content =
+                        raw_event.get_field::<ContentDeHelper>("content").ok().flatten();
+
+                    let mut content = if is_reply == ReplyWithinThread::Yes {
+                        content.make_reply_to_raw(
+                            &raw_event,
+                            replied_to_info.event_id.to_owned(),
+                            room_id,
+                            ForwardThread::No,
+                            mention_the_sender,
+                        )
+                    } else {
+                        content.into()
+                    };
+
+                    let thread_root = if let Some(EncryptedRelation::Thread(thread)) =
+                        previous_content.as_ref().and_then(|c| c.relates_to.as_ref())
+                    {
+                        thread.event_id.to_owned()
+                    } else {
+                        replied_to_info.event_id.to_owned()
+                    };
+
+                    let thread = if is_reply == ReplyWithinThread::Yes {
+                        Thread::reply(thread_root, replied_to_info.event_id)
+                    } else {
+                        Thread::plain(thread_root, replied_to_info.event_id)
+                    };
+
+                    content.relates_to = Some(Relation::Thread(thread));
+                    content
+                }
+
+                EnforceThread::MaybeThreaded => content.make_reply_to_raw(
+                    &raw_event,
+                    replied_to_info.event_id,
+                    room_id,
+                    ForwardThread::Yes,
+                    mention_the_sender,
+                ),
+
+                EnforceThread::Unthreaded => content.make_reply_to_raw(
+                    &raw_event,
+                    replied_to_info.event_id,
+                    room_id,
+                    ForwardThread::No,
+                    mention_the_sender,
+                ),
+            }
+        }
+    };
+
+    Ok(content.into())
+}
+
+async fn replied_to_info_from_event_id<S: EventSource>(
+    source: S,
+    event_id: &EventId,
+) -> Result<RepliedToInfo, ReplyError> {
+    let event = source.get_event(event_id).await?;
+
+    let raw_event = event.into_raw();
+    let event = raw_event.deserialize().map_err(|_| ReplyError::FailedToDeserializeEvent)?;
+
+    let reply_content = match &event {
+        AnySyncTimelineEvent::MessageLike(event) => {
+            if let AnySyncMessageLikeEvent::RoomMessage(SyncMessageLikeEvent::Original(
+                original_event,
+            )) = event
+            {
+                ReplyContent::Message(original_event.content.clone())
+            } else {
+                ReplyContent::Raw(raw_event)
+            }
+        }
+        AnySyncTimelineEvent::State(_) => return Err(ReplyError::StateEvent),
+    };
+
+    Ok(RepliedToInfo {
+        event_id: event_id.to_owned(),
+        sender: event.sender().to_owned(),
+        timestamp: event.origin_server_ts(),
+        content: reply_content,
+    })
+}
+
+#[cfg(test)]
+mod tests {
path: crates/matrix-sdk/src/room/reply.rs
user: login: Johennes
body: I wasn't sure how many tests to add here since we already have the tests in `replies.rs`.
created_at: 2025-03-25T20:14:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -73,6 +74,10 @@ pub enum Error {
     #[error(transparent)]
     EditError(#[from] EditError),
 
+    /// An error happened while attempting to reply to an event.
+    #[error(transparent)]
+    ReplyError(#[from] ReplyError),
path: crates/matrix-sdk-ui/src/timeline/error.rs
user: login: bnjbvr
body: Yep, it's fine :+1: 
created_at: 2025-03-26T12:08:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -12,6 +12,8 @@ All notable changes to this project will be documented in this file.
 
 - [**breaking**] Optionally allow starting threads with `Timeline::send_reply`.
   ([4819](https://github.com/matrix-org/matrix-rust-sdk/pull/4819))
+- [**breaking**] Push the reply logic down into matrix_sdk.
path: crates/matrix-sdk-ui/CHANGELOG.md
user: login: bnjbvr
body: Agree it's a breaking change, but can you comment to which extent? Ideally we'd include all the names of types that have been changed here, but at least having some steps explaining how to migrate would be sweet :)
created_at: 2025-03-26T12:10:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,392 @@
+// Copyright 2025 The Matrix.org Foundation C.I.C.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Facilities to reply to existing events.
+
+use std::future::Future;
+
+use matrix_sdk_base::{deserialized_responses::TimelineEvent, SendOutsideWasm};
+use ruma::{
+    events::{
+        relation::Thread,
+        room::{
+            encrypted::Relation as EncryptedRelation,
+            message::{
+                AddMentions, ForwardThread, OriginalRoomMessageEvent, Relation, ReplyWithinThread,
+                RoomMessageEventContent, RoomMessageEventContentWithoutRelation,
+            },
+        },
+        AnyMessageLikeEventContent, AnySyncMessageLikeEvent, AnySyncTimelineEvent,
+        SyncMessageLikeEvent,
+    },
+    serde::Raw,
+    EventId, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedUserId, RoomId, UserId,
+};
+use serde::Deserialize;
+use thiserror::Error;
+use tracing::{error, instrument};
+
+use super::Room;
+
+/// Information needed to reply to an event.
+#[derive(Debug, Clone)]
+struct RepliedToInfo {
+    /// The event ID of the event to reply to.
+    event_id: OwnedEventId,
+    /// The sender of the event to reply to.
+    sender: OwnedUserId,
+    /// The timestamp of the event to reply to.
+    timestamp: MilliSecondsSinceUnixEpoch,
+    /// The content of the event to reply to.
+    content: ReplyContent,
+}
+
+/// The content of a reply.
+#[derive(Debug, Clone)]
+enum ReplyContent {
+    /// Content of a message event.
+    Message(RoomMessageEventContent),
+    /// Content of any other kind of event stored as raw JSON.
+    Raw(Raw<AnySyncTimelineEvent>),
+}
+
+/// Errors specific to unsupported replies.
+#[derive(Debug, Error)]
+pub enum ReplyError {
+    /// We couldn't fetch the remote event with /room/event.
+    #[error("Couldn't fetch the remote event: {0}")]
+    Fetch(Box<crate::Error>),
+    /// Redacted events whose JSON form isn't available cannot be replied to.
+    #[error("redacted events whose JSON form isn't available can't be replied")]
+    MissingJson,
+    /// The event to reply to could not be found.
+    #[error("event to reply to not found")]
+    MissingEvent,
+    /// The event to reply to could not be deserialized.
+    #[error("failed to deserialize event to reply to")]
+    FailedToDeserializeEvent,
+    /// State events cannot be replied to.
+    #[error("tried to reply to a state event")]
+    StateEvent,
path: crates/matrix-sdk/src/room/reply.rs
user: login: bnjbvr
body: Let's uniformize the names, since we're touching this error type anyways :)

- FailedToDeserializeEvent -> Deserialization
created_at: 2025-03-26T12:18:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,392 @@
+// Copyright 2025 The Matrix.org Foundation C.I.C.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Facilities to reply to existing events.
+
+use std::future::Future;
+
+use matrix_sdk_base::{deserialized_responses::TimelineEvent, SendOutsideWasm};
+use ruma::{
+    events::{
+        relation::Thread,
+        room::{
+            encrypted::Relation as EncryptedRelation,
+            message::{
+                AddMentions, ForwardThread, OriginalRoomMessageEvent, Relation, ReplyWithinThread,
+                RoomMessageEventContent, RoomMessageEventContentWithoutRelation,
+            },
+        },
+        AnyMessageLikeEventContent, AnySyncMessageLikeEvent, AnySyncTimelineEvent,
+        SyncMessageLikeEvent,
+    },
+    serde::Raw,
+    EventId, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedUserId, RoomId, UserId,
+};
+use serde::Deserialize;
+use thiserror::Error;
+use tracing::{error, instrument};
+
+use super::Room;
+
+/// Information needed to reply to an event.
+#[derive(Debug, Clone)]
+struct RepliedToInfo {
+    /// The event ID of the event to reply to.
+    event_id: OwnedEventId,
+    /// The sender of the event to reply to.
+    sender: OwnedUserId,
+    /// The timestamp of the event to reply to.
+    timestamp: MilliSecondsSinceUnixEpoch,
+    /// The content of the event to reply to.
+    content: ReplyContent,
+}
+
+/// The content of a reply.
+#[derive(Debug, Clone)]
+enum ReplyContent {
+    /// Content of a message event.
+    Message(RoomMessageEventContent),
+    /// Content of any other kind of event stored as raw JSON.
+    Raw(Raw<AnySyncTimelineEvent>),
+}
+
+/// Errors specific to unsupported replies.
+#[derive(Debug, Error)]
+pub enum ReplyError {
+    /// We couldn't fetch the remote event with /room/event.
+    #[error("Couldn't fetch the remote event: {0}")]
+    Fetch(Box<crate::Error>),
+    /// Redacted events whose JSON form isn't available cannot be replied to.
+    #[error("redacted events whose JSON form isn't available can't be replied")]
+    MissingJson,
+    /// The event to reply to could not be found.
+    #[error("event to reply to not found")]
+    MissingEvent,
+    /// The event to reply to could not be deserialized.
+    #[error("failed to deserialize event to reply to")]
+    FailedToDeserializeEvent,
+    /// State events cannot be replied to.
+    #[error("tried to reply to a state event")]
+    StateEvent,
+}
+
+/// Whether or not to enforce a [`Relation::Thread`] when sending a reply.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[allow(clippy::exhaustive_enums)]
path: crates/matrix-sdk/src/room/reply.rs
user: login: bnjbvr
body: Out of curiosity, why is this line useful?
created_at: 2025-03-26T12:18:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,392 @@
+// Copyright 2025 The Matrix.org Foundation C.I.C.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Facilities to reply to existing events.
+
+use std::future::Future;
+
+use matrix_sdk_base::{deserialized_responses::TimelineEvent, SendOutsideWasm};
+use ruma::{
+    events::{
+        relation::Thread,
+        room::{
+            encrypted::Relation as EncryptedRelation,
+            message::{
+                AddMentions, ForwardThread, OriginalRoomMessageEvent, Relation, ReplyWithinThread,
+                RoomMessageEventContent, RoomMessageEventContentWithoutRelation,
+            },
+        },
+        AnyMessageLikeEventContent, AnySyncMessageLikeEvent, AnySyncTimelineEvent,
+        SyncMessageLikeEvent,
+    },
+    serde::Raw,
+    EventId, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedUserId, RoomId, UserId,
+};
+use serde::Deserialize;
+use thiserror::Error;
+use tracing::{error, instrument};
+
+use super::Room;
+
+/// Information needed to reply to an event.
+#[derive(Debug, Clone)]
+struct RepliedToInfo {
+    /// The event ID of the event to reply to.
+    event_id: OwnedEventId,
+    /// The sender of the event to reply to.
+    sender: OwnedUserId,
+    /// The timestamp of the event to reply to.
+    timestamp: MilliSecondsSinceUnixEpoch,
+    /// The content of the event to reply to.
+    content: ReplyContent,
+}
+
+/// The content of a reply.
+#[derive(Debug, Clone)]
+enum ReplyContent {
+    /// Content of a message event.
+    Message(RoomMessageEventContent),
+    /// Content of any other kind of event stored as raw JSON.
+    Raw(Raw<AnySyncTimelineEvent>),
+}
+
+/// Errors specific to unsupported replies.
+#[derive(Debug, Error)]
+pub enum ReplyError {
+    /// We couldn't fetch the remote event with /room/event.
+    #[error("Couldn't fetch the remote event: {0}")]
+    Fetch(Box<crate::Error>),
+    /// Redacted events whose JSON form isn't available cannot be replied to.
+    #[error("redacted events whose JSON form isn't available can't be replied")]
+    MissingJson,
+    /// The event to reply to could not be found.
+    #[error("event to reply to not found")]
+    MissingEvent,
+    /// The event to reply to could not be deserialized.
+    #[error("failed to deserialize event to reply to")]
+    FailedToDeserializeEvent,
+    /// State events cannot be replied to.
+    #[error("tried to reply to a state event")]
+    StateEvent,
+}
+
+/// Whether or not to enforce a [`Relation::Thread`] when sending a reply.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[allow(clippy::exhaustive_enums)]
+pub enum EnforceThread {
+    /// A thread relation is enforced. If the original message does not have a
+    /// thread relation itself, a new thread is started.
+    Threaded(ReplyWithinThread),
+
+    /// A thread relation is not enforced. If the original message has a thread
+    /// relation, it is forwarded.
+    MaybeThreaded,
+
+    /// A thread relation is not enforced. If the original message has a thread
+    /// relation, it is *not* forwarded.
+    Unthreaded,
+}
+
+impl Room {
+    /// Create a new reply event for the target event id with the specified
+    /// content.
+    ///
+    /// The event can then be sent with [`Room::send`] or a
+    /// [`crate::send_queue::RoomSendQueue`].
+    #[instrument(skip(self, content), fields(room = %self.room_id()))]
+    pub async fn make_reply_event(
+        &self,
+        content: RoomMessageEventContentWithoutRelation,
+        event_id: &EventId,
+        enforce_thread: EnforceThread,
+    ) -> Result<AnyMessageLikeEventContent, ReplyError> {
+        make_reply_event(
+            self,
+            self.room_id(),
+            self.own_user_id(),
+            content,
+            event_id,
+            enforce_thread,
+        )
+        .await
+    }
+}
+
+trait EventSource {
+    fn get_event(
+        &self,
+        event_id: &EventId,
+    ) -> impl Future<Output = Result<TimelineEvent, ReplyError>> + SendOutsideWasm;
+}
+
+impl EventSource for &Room {
+    async fn get_event(&self, event_id: &EventId) -> Result<TimelineEvent, ReplyError> {
+        self.load_or_fetch_event(event_id, None)
+            .await
+            .map_err(|err| ReplyError::Fetch(Box::new(err)))
+    }
+}
path: crates/matrix-sdk/src/room/reply.rs
user: login: bnjbvr
body: Consider sharing the trait with `room/edit.rs` :)
created_at: 2025-03-26T12:20:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,392 @@
+// Copyright 2025 The Matrix.org Foundation C.I.C.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Facilities to reply to existing events.
+
+use std::future::Future;
+
+use matrix_sdk_base::{deserialized_responses::TimelineEvent, SendOutsideWasm};
+use ruma::{
+    events::{
+        relation::Thread,
+        room::{
+            encrypted::Relation as EncryptedRelation,
+            message::{
+                AddMentions, ForwardThread, OriginalRoomMessageEvent, Relation, ReplyWithinThread,
+                RoomMessageEventContent, RoomMessageEventContentWithoutRelation,
+            },
+        },
+        AnyMessageLikeEventContent, AnySyncMessageLikeEvent, AnySyncTimelineEvent,
+        SyncMessageLikeEvent,
+    },
+    serde::Raw,
+    EventId, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedUserId, RoomId, UserId,
+};
+use serde::Deserialize;
+use thiserror::Error;
+use tracing::{error, instrument};
+
+use super::Room;
+
+/// Information needed to reply to an event.
+#[derive(Debug, Clone)]
+struct RepliedToInfo {
+    /// The event ID of the event to reply to.
+    event_id: OwnedEventId,
+    /// The sender of the event to reply to.
+    sender: OwnedUserId,
+    /// The timestamp of the event to reply to.
+    timestamp: MilliSecondsSinceUnixEpoch,
+    /// The content of the event to reply to.
+    content: ReplyContent,
+}
+
+/// The content of a reply.
+#[derive(Debug, Clone)]
+enum ReplyContent {
+    /// Content of a message event.
+    Message(RoomMessageEventContent),
+    /// Content of any other kind of event stored as raw JSON.
+    Raw(Raw<AnySyncTimelineEvent>),
+}
+
+/// Errors specific to unsupported replies.
+#[derive(Debug, Error)]
+pub enum ReplyError {
+    /// We couldn't fetch the remote event with /room/event.
+    #[error("Couldn't fetch the remote event: {0}")]
+    Fetch(Box<crate::Error>),
+    /// Redacted events whose JSON form isn't available cannot be replied to.
+    #[error("redacted events whose JSON form isn't available can't be replied")]
+    MissingJson,
+    /// The event to reply to could not be found.
+    #[error("event to reply to not found")]
+    MissingEvent,
+    /// The event to reply to could not be deserialized.
+    #[error("failed to deserialize event to reply to")]
+    FailedToDeserializeEvent,
+    /// State events cannot be replied to.
+    #[error("tried to reply to a state event")]
+    StateEvent,
+}
+
+/// Whether or not to enforce a [`Relation::Thread`] when sending a reply.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[allow(clippy::exhaustive_enums)]
+pub enum EnforceThread {
+    /// A thread relation is enforced. If the original message does not have a
+    /// thread relation itself, a new thread is started.
+    Threaded(ReplyWithinThread),
+
+    /// A thread relation is not enforced. If the original message has a thread
+    /// relation, it is forwarded.
+    MaybeThreaded,
+
+    /// A thread relation is not enforced. If the original message has a thread
+    /// relation, it is *not* forwarded.
+    Unthreaded,
+}
+
+impl Room {
+    /// Create a new reply event for the target event id with the specified
+    /// content.
+    ///
+    /// The event can then be sent with [`Room::send`] or a
+    /// [`crate::send_queue::RoomSendQueue`].
+    #[instrument(skip(self, content), fields(room = %self.room_id()))]
+    pub async fn make_reply_event(
+        &self,
+        content: RoomMessageEventContentWithoutRelation,
+        event_id: &EventId,
+        enforce_thread: EnforceThread,
+    ) -> Result<AnyMessageLikeEventContent, ReplyError> {
+        make_reply_event(
+            self,
+            self.room_id(),
+            self.own_user_id(),
+            content,
+            event_id,
+            enforce_thread,
+        )
+        .await
+    }
+}
+
+trait EventSource {
+    fn get_event(
+        &self,
+        event_id: &EventId,
+    ) -> impl Future<Output = Result<TimelineEvent, ReplyError>> + SendOutsideWasm;
+}
+
+impl EventSource for &Room {
+    async fn get_event(&self, event_id: &EventId) -> Result<TimelineEvent, ReplyError> {
+        self.load_or_fetch_event(event_id, None)
+            .await
+            .map_err(|err| ReplyError::Fetch(Box::new(err)))
+    }
+}
+
+async fn make_reply_event<S: EventSource>(
+    source: S,
+    room_id: &RoomId,
+    own_user_id: &UserId,
+    content: RoomMessageEventContentWithoutRelation,
+    event_id: &EventId,
+    enforce_thread: EnforceThread,
+) -> Result<AnyMessageLikeEventContent, ReplyError> {
+    let replied_to_info = replied_to_info_from_event_id(source, event_id).await?;
+
+    // [The specification](https://spec.matrix.org/v1.10/client-server-api/#user-and-room-mentions) says:
+    //
+    // > Users should not add their own Matrix ID to the `m.mentions` property as
+    // > outgoing messages cannot self-notify.
+    //
+    // If the replied to event has been written by the current user, let's toggle to
+    // `AddMentions::No`.
+    let mention_the_sender =
+        if own_user_id == replied_to_info.sender { AddMentions::No } else { AddMentions::Yes };
+
+    let content = match replied_to_info.content {
+        ReplyContent::Message(replied_to_content) => {
+            let event = OriginalRoomMessageEvent {
+                event_id: replied_to_info.event_id,
+                sender: replied_to_info.sender,
+                origin_server_ts: replied_to_info.timestamp,
+                room_id: room_id.to_owned(),
+                content: replied_to_content,
+                unsigned: Default::default(),
+            };
+
+            match enforce_thread {
+                EnforceThread::Threaded(is_reply) => {
+                    content.make_for_thread(&event, is_reply, mention_the_sender)
+                }
+                EnforceThread::MaybeThreaded => {
+                    content.make_reply_to(&event, ForwardThread::Yes, mention_the_sender)
+                }
+                EnforceThread::Unthreaded => {
+                    content.make_reply_to(&event, ForwardThread::No, mention_the_sender)
+                }
+            }
+        }
+
+        ReplyContent::Raw(raw_event) => {
+            match enforce_thread {
+                EnforceThread::Threaded(is_reply) => {
+                    // Some of the code below technically belongs into ruma. However,
+                    // reply fallbacks have been removed in Matrix 1.13 which means
+                    // both match arms can use the successor of make_for_thread in
+                    // the next ruma release.
+                    #[derive(Deserialize)]
+                    struct ContentDeHelper {
+                        #[serde(rename = "m.relates_to")]
+                        relates_to: Option<EncryptedRelation>,
+                    }
+
+                    let previous_content =
+                        raw_event.get_field::<ContentDeHelper>("content").ok().flatten();
+
+                    let mut content = if is_reply == ReplyWithinThread::Yes {
+                        content.make_reply_to_raw(
+                            &raw_event,
+                            replied_to_info.event_id.to_owned(),
+                            room_id,
+                            ForwardThread::No,
+                            mention_the_sender,
+                        )
+                    } else {
+                        content.into()
+                    };
+
+                    let thread_root = if let Some(EncryptedRelation::Thread(thread)) =
+                        previous_content.as_ref().and_then(|c| c.relates_to.as_ref())
+                    {
+                        thread.event_id.to_owned()
+                    } else {
+                        replied_to_info.event_id.to_owned()
+                    };
+
+                    let thread = if is_reply == ReplyWithinThread::Yes {
+                        Thread::reply(thread_root, replied_to_info.event_id)
+                    } else {
+                        Thread::plain(thread_root, replied_to_info.event_id)
+                    };
+
+                    content.relates_to = Some(Relation::Thread(thread));
+                    content
+                }
+
+                EnforceThread::MaybeThreaded => content.make_reply_to_raw(
+                    &raw_event,
+                    replied_to_info.event_id,
+                    room_id,
+                    ForwardThread::Yes,
+                    mention_the_sender,
+                ),
+
+                EnforceThread::Unthreaded => content.make_reply_to_raw(
+                    &raw_event,
+                    replied_to_info.event_id,
+                    room_id,
+                    ForwardThread::No,
+                    mention_the_sender,
+                ),
+            }
+        }
+    };
+
+    Ok(content.into())
+}
+
+async fn replied_to_info_from_event_id<S: EventSource>(
+    source: S,
+    event_id: &EventId,
+) -> Result<RepliedToInfo, ReplyError> {
+    let event = source.get_event(event_id).await?;
+
+    let raw_event = event.into_raw();
+    let event = raw_event.deserialize().map_err(|_| ReplyError::FailedToDeserializeEvent)?;
+
+    let reply_content = match &event {
+        AnySyncTimelineEvent::MessageLike(event) => {
+            if let AnySyncMessageLikeEvent::RoomMessage(SyncMessageLikeEvent::Original(
+                original_event,
+            )) = event
+            {
+                ReplyContent::Message(original_event.content.clone())
+            } else {
+                ReplyContent::Raw(raw_event)
+            }
+        }
+        AnySyncTimelineEvent::State(_) => return Err(ReplyError::StateEvent),
+    };
+
+    Ok(RepliedToInfo {
+        event_id: event_id.to_owned(),
+        sender: event.sender().to_owned(),
+        timestamp: event.origin_server_ts(),
+        content: reply_content,
+    })
+}
+
+#[cfg(test)]
+mod tests {
+    use std::collections::BTreeMap;
+
+    use assert_matches2::{assert_let, assert_matches};
+    use matrix_sdk_base::deserialized_responses::TimelineEvent;
+    use matrix_sdk_test::{async_test, event_factory::EventFactory};
+    use ruma::{
+        event_id,
+        events::{
+            room::message::{Relation, RoomMessageEventContentWithoutRelation},
+            AnyMessageLikeEventContent, AnySyncTimelineEvent,
+        },
+        room_id,
+        serde::Raw,
+        user_id, EventId, OwnedEventId,
+    };
+    use serde_json::json;
+
+    use super::{make_reply_event, EnforceThread, EventSource, ReplyError};
+
+    #[derive(Default)]
+    struct TestEventCache {
+        events: BTreeMap<OwnedEventId, TimelineEvent>,
+    }
+
+    impl EventSource for TestEventCache {
+        async fn get_event(&self, event_id: &EventId) -> Result<TimelineEvent, ReplyError> {
+            Ok(self.events.get(event_id).unwrap().clone())
+        }
+    }
+
+    #[async_test]
+    async fn test_reply_to_state_event() {
+        let event_id = event_id!("$1");
+        let own_user_id = user_id!("@me:saucisse.bzh");
+
+        let mut cache = TestEventCache::default();
+
+        cache.events.insert(
+            event_id.to_owned(),
+            // TODO: use the EventFactory for state events too.
path: crates/matrix-sdk/src/room/reply.rs
user: login: bnjbvr
body: We can, now! :D
created_at: 2025-03-26T12:22:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,404 @@
+// Copyright 2025 The Matrix.org Foundation C.I.C.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Facilities to reply to existing events.
+
+use std::future::Future;
+
+use matrix_sdk_base::{deserialized_responses::TimelineEvent, SendOutsideWasm};
+use ruma::{
+    events::{
+        relation::Thread,
+        room::{
+            encrypted::Relation as EncryptedRelation,
+            message::{
+                AddMentions, ForwardThread, OriginalRoomMessageEvent, Relation, ReplyWithinThread,
+                RoomMessageEventContent, RoomMessageEventContentWithoutRelation,
+            },
+        },
+        AnyMessageLikeEventContent, AnySyncMessageLikeEvent, AnySyncTimelineEvent,
+        SyncMessageLikeEvent,
+    },
+    serde::Raw,
+    EventId, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedUserId, RoomId, UserId,
+};
+use serde::Deserialize;
+use thiserror::Error;
+use tracing::{error, instrument};
+
+use super::Room;
+
+/// Information needed to reply to an event.
+#[derive(Debug, Clone)]
+pub struct RepliedToInfo {
+    /// The event ID of the event to reply to.
+    event_id: OwnedEventId,
+    /// The sender of the event to reply to.
+    sender: OwnedUserId,
+    /// The timestamp of the event to reply to.
+    timestamp: MilliSecondsSinceUnixEpoch,
+    /// The content of the event to reply to.
+    content: ReplyContent,
+}
+
+impl RepliedToInfo {
+    /// The event ID of the event to reply to.
+    pub fn event_id(&self) -> &EventId {
+        &self.event_id
+    }
+
+    /// The sender of the event to reply to.
+    pub fn sender(&self) -> &UserId {
+        &self.sender
+    }
+}
+
+/// The content of a reply.
+#[derive(Debug, Clone)]
+pub enum ReplyContent {
+    /// Content of a message event.
+    Message(RoomMessageEventContent),
+    /// Content of any other kind of event stored as raw JSON.
+    Raw(Raw<AnySyncTimelineEvent>),
+}
+
+/// Errors specific to unsupported replies.
+#[derive(Debug, Error)]
+pub enum ReplyError {
+    /// We couldn't fetch the remote event with /room/event.
+    #[error("Couldn't fetch the remote event: {0}")]
+    Fetch(Box<crate::Error>),
+    /// Redacted events whose JSON form isn't available cannot be replied to.
+    #[error("redacted events whose JSON form isn't available can't be replied")]
+    MissingJson,
+    /// The event to reply to could not be found.
+    #[error("event to reply to not found")]
+    MissingEvent,
+    /// The event to reply to could not be deserialized.
+    #[error("failed to deserialize event to reply to")]
+    FailedToDeserializeEvent,
+    /// State events cannot be replied to.
+    #[error("tried to reply to a state event")]
+    StateEvent,
+}
+
+/// Whether or not to enforce a [`Relation::Thread`] when sending a reply.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[allow(clippy::exhaustive_enums)]
+pub enum EnforceThread {
+    /// A thread relation is enforced. If the original message does not have a
+    /// thread relation itself, a new thread is started.
+    Threaded(ReplyWithinThread),
+
+    /// A thread relation is not enforced. If the original message has a thread
+    /// relation, it is forwarded.
+    MaybeThreaded,
+
+    /// A thread relation is not enforced. If the original message has a thread
+    /// relation, it is *not* forwarded.
+    Unthreaded,
+}
+
+impl Room {
+    /// Create a new reply event for the target event id with the specified
+    /// content.
+    ///
+    /// The event can then be sent with [`Room::send`] or a
+    /// [`crate::send_queue::RoomSendQueue`].
+    #[instrument(skip(self, content), fields(room = %self.room_id()))]
+    pub async fn make_reply_event(
+        &self,
+        content: RoomMessageEventContentWithoutRelation,
+        event_id: &EventId,
+        enforce_thread: EnforceThread,
+    ) -> Result<AnyMessageLikeEventContent, ReplyError> {
+        make_reply_event(
+            self,
+            self.room_id(),
+            self.own_user_id(),
+            content,
+            event_id,
+            enforce_thread,
+        )
+        .await
+    }
+}
+
+trait EventSource {
+    fn get_event(
+        &self,
+        event_id: &EventId,
+    ) -> impl Future<Output = Result<TimelineEvent, ReplyError>> + SendOutsideWasm;
+}
+
+impl EventSource for &Room {
+    async fn get_event(&self, event_id: &EventId) -> Result<TimelineEvent, ReplyError> {
+        self.load_or_fetch_event(event_id, None)
+            .await
+            .map_err(|err| ReplyError::Fetch(Box::new(err)))
+    }
+}
+
+async fn make_reply_event<S: EventSource>(
+    source: S,
+    room_id: &RoomId,
+    own_user_id: &UserId,
+    content: RoomMessageEventContentWithoutRelation,
+    event_id: &EventId,
+    enforce_thread: EnforceThread,
+) -> Result<AnyMessageLikeEventContent, ReplyError> {
+    let replied_to_info = replied_to_info_from_event_id(source, event_id).await?;
+
+    // [The specification](https://spec.matrix.org/v1.10/client-server-api/#user-and-room-mentions) says:
+    //
+    // > Users should not add their own Matrix ID to the `m.mentions` property as
+    // > outgoing messages cannot self-notify.
+    //
+    // If the replied to event has been written by the current user, let's toggle to
+    // `AddMentions::No`.
+    let mention_the_sender =
+        if own_user_id == replied_to_info.sender { AddMentions::No } else { AddMentions::Yes };
+
+    let content = match replied_to_info.content {
+        ReplyContent::Message(replied_to_content) => {
+            let event = OriginalRoomMessageEvent {
+                event_id: replied_to_info.event_id,
+                sender: replied_to_info.sender,
+                origin_server_ts: replied_to_info.timestamp,
+                room_id: room_id.to_owned(),
+                content: replied_to_content,
+                unsigned: Default::default(),
+            };
+
+            match enforce_thread {
+                EnforceThread::Threaded(is_reply) => {
+                    content.make_for_thread(&event, is_reply, mention_the_sender)
+                }
+                EnforceThread::MaybeThreaded => {
+                    content.make_reply_to(&event, ForwardThread::Yes, mention_the_sender)
+                }
+                EnforceThread::Unthreaded => {
+                    content.make_reply_to(&event, ForwardThread::No, mention_the_sender)
+                }
+            }
+        }
+
+        ReplyContent::Raw(raw_event) => {
+            match enforce_thread {
+                EnforceThread::Threaded(is_reply) => {
+                    // Some of the code below technically belongs into ruma. However,
+                    // reply fallbacks have been removed in Matrix 1.13 which means
+                    // both match arms can use the successor of make_for_thread in
+                    // the next ruma release.
+                    #[derive(Deserialize)]
+                    struct ContentDeHelper {
+                        #[serde(rename = "m.relates_to")]
+                        relates_to: Option<EncryptedRelation>,
+                    }
+
+                    let previous_content =
+                        raw_event.get_field::<ContentDeHelper>("content").ok().flatten();
+
+                    let mut content = if is_reply == ReplyWithinThread::Yes {
+                        content.make_reply_to_raw(
+                            &raw_event,
+                            replied_to_info.event_id.to_owned(),
+                            room_id,
+                            ForwardThread::No,
+                            mention_the_sender,
+                        )
+                    } else {
+                        content.into()
+                    };
+
+                    let thread_root = if let Some(EncryptedRelation::Thread(thread)) =
+                        previous_content.as_ref().and_then(|c| c.relates_to.as_ref())
+                    {
+                        thread.event_id.to_owned()
+                    } else {
+                        replied_to_info.event_id.to_owned()
+                    };
+
+                    let thread = if is_reply == ReplyWithinThread::Yes {
+                        Thread::reply(thread_root, replied_to_info.event_id)
+                    } else {
+                        Thread::plain(thread_root, replied_to_info.event_id)
+                    };
+
+                    content.relates_to = Some(Relation::Thread(thread));
+                    content
+                }
+
+                EnforceThread::MaybeThreaded => content.make_reply_to_raw(
+                    &raw_event,
+                    replied_to_info.event_id,
+                    room_id,
+                    ForwardThread::Yes,
+                    mention_the_sender,
+                ),
+
+                EnforceThread::Unthreaded => content.make_reply_to_raw(
+                    &raw_event,
+                    replied_to_info.event_id,
+                    room_id,
+                    ForwardThread::No,
+                    mention_the_sender,
+                ),
+            }
+        }
+    };
+
+    Ok(content.into())
+}
+
+async fn replied_to_info_from_event_id<S: EventSource>(
+    source: S,
+    event_id: &EventId,
+) -> Result<RepliedToInfo, ReplyError> {
+    let event = source.get_event(event_id).await?;
+
+    let raw_event = event.into_raw();
+    let event = raw_event.deserialize().map_err(|_| ReplyError::FailedToDeserializeEvent)?;
+
+    let reply_content = match &event {
+        AnySyncTimelineEvent::MessageLike(event) => {
+            if let AnySyncMessageLikeEvent::RoomMessage(SyncMessageLikeEvent::Original(
+                original_event,
+            )) = event
+            {
+                ReplyContent::Message(original_event.content.clone())
+            } else {
+                ReplyContent::Raw(raw_event)
+            }
+        }
+        AnySyncTimelineEvent::State(_) => return Err(ReplyError::StateEvent),
+    };
+
+    Ok(RepliedToInfo {
+        event_id: event_id.to_owned(),
+        sender: event.sender().to_owned(),
+        timestamp: event.origin_server_ts(),
+        content: reply_content,
+    })
+}
+
+#[cfg(test)]
+mod tests {
path: crates/matrix-sdk/src/room/reply.rs
user: login: bnjbvr
body: It would be sweet to have a smoke test + have tests that reach each `ReplyError` variant. (We can also *remove* some tests from the timeline's code, if they were only testing the code moved here; otherwise the timeline is now testing this layer, and that's a layer mismatch.)
created_at: 2025-03-26T12:22:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,392 @@
+// Copyright 2025 The Matrix.org Foundation C.I.C.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Facilities to reply to existing events.
+
+use std::future::Future;
+
+use matrix_sdk_base::{deserialized_responses::TimelineEvent, SendOutsideWasm};
+use ruma::{
+    events::{
+        relation::Thread,
+        room::{
+            encrypted::Relation as EncryptedRelation,
+            message::{
+                AddMentions, ForwardThread, OriginalRoomMessageEvent, Relation, ReplyWithinThread,
+                RoomMessageEventContent, RoomMessageEventContentWithoutRelation,
+            },
+        },
+        AnyMessageLikeEventContent, AnySyncMessageLikeEvent, AnySyncTimelineEvent,
+        SyncMessageLikeEvent,
+    },
+    serde::Raw,
+    EventId, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedUserId, RoomId, UserId,
+};
+use serde::Deserialize;
+use thiserror::Error;
+use tracing::{error, instrument};
+
+use super::Room;
+
+/// Information needed to reply to an event.
+#[derive(Debug, Clone)]
+struct RepliedToInfo {
+    /// The event ID of the event to reply to.
+    event_id: OwnedEventId,
+    /// The sender of the event to reply to.
+    sender: OwnedUserId,
+    /// The timestamp of the event to reply to.
+    timestamp: MilliSecondsSinceUnixEpoch,
+    /// The content of the event to reply to.
+    content: ReplyContent,
+}
+
+/// The content of a reply.
+#[derive(Debug, Clone)]
+enum ReplyContent {
+    /// Content of a message event.
+    Message(RoomMessageEventContent),
+    /// Content of any other kind of event stored as raw JSON.
+    Raw(Raw<AnySyncTimelineEvent>),
+}
+
+/// Errors specific to unsupported replies.
+#[derive(Debug, Error)]
+pub enum ReplyError {
+    /// We couldn't fetch the remote event with /room/event.
+    #[error("Couldn't fetch the remote event: {0}")]
+    Fetch(Box<crate::Error>),
+    /// Redacted events whose JSON form isn't available cannot be replied to.
+    #[error("redacted events whose JSON form isn't available can't be replied")]
+    MissingJson,
+    /// The event to reply to could not be found.
+    #[error("event to reply to not found")]
+    MissingEvent,
+    /// The event to reply to could not be deserialized.
+    #[error("failed to deserialize event to reply to")]
+    FailedToDeserializeEvent,
+    /// State events cannot be replied to.
+    #[error("tried to reply to a state event")]
+    StateEvent,
+}
+
+/// Whether or not to enforce a [`Relation::Thread`] when sending a reply.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[allow(clippy::exhaustive_enums)]
path: crates/matrix-sdk/src/room/reply.rs
user: login: Johennes
body: Sorry, this crept in with https://github.com/matrix-org/matrix-rust-sdk/pull/4819/files#diff-7f49982c21dd9af0fcce15d1d1349716f42c41268483df13c1dc8086c6448d57R143, I think because I was initially copying the `ForwardThread` enum from ruma. I'll remove it.
created_at: 2025-03-26T12:47:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,392 @@
+// Copyright 2025 The Matrix.org Foundation C.I.C.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Facilities to reply to existing events.
+
+use std::future::Future;
+
+use matrix_sdk_base::{deserialized_responses::TimelineEvent, SendOutsideWasm};
+use ruma::{
+    events::{
+        relation::Thread,
+        room::{
+            encrypted::Relation as EncryptedRelation,
+            message::{
+                AddMentions, ForwardThread, OriginalRoomMessageEvent, Relation, ReplyWithinThread,
+                RoomMessageEventContent, RoomMessageEventContentWithoutRelation,
+            },
+        },
+        AnyMessageLikeEventContent, AnySyncMessageLikeEvent, AnySyncTimelineEvent,
+        SyncMessageLikeEvent,
+    },
+    serde::Raw,
+    EventId, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedUserId, RoomId, UserId,
+};
+use serde::Deserialize;
+use thiserror::Error;
+use tracing::{error, instrument};
+
+use super::Room;
+
+/// Information needed to reply to an event.
+#[derive(Debug, Clone)]
+struct RepliedToInfo {
+    /// The event ID of the event to reply to.
+    event_id: OwnedEventId,
+    /// The sender of the event to reply to.
+    sender: OwnedUserId,
+    /// The timestamp of the event to reply to.
+    timestamp: MilliSecondsSinceUnixEpoch,
+    /// The content of the event to reply to.
+    content: ReplyContent,
+}
+
+/// The content of a reply.
+#[derive(Debug, Clone)]
+enum ReplyContent {
+    /// Content of a message event.
+    Message(RoomMessageEventContent),
+    /// Content of any other kind of event stored as raw JSON.
+    Raw(Raw<AnySyncTimelineEvent>),
+}
+
+/// Errors specific to unsupported replies.
+#[derive(Debug, Error)]
+pub enum ReplyError {
+    /// We couldn't fetch the remote event with /room/event.
+    #[error("Couldn't fetch the remote event: {0}")]
+    Fetch(Box<crate::Error>),
+    /// Redacted events whose JSON form isn't available cannot be replied to.
+    #[error("redacted events whose JSON form isn't available can't be replied")]
+    MissingJson,
+    /// The event to reply to could not be found.
+    #[error("event to reply to not found")]
+    MissingEvent,
+    /// The event to reply to could not be deserialized.
+    #[error("failed to deserialize event to reply to")]
+    FailedToDeserializeEvent,
+    /// State events cannot be replied to.
+    #[error("tried to reply to a state event")]
+    StateEvent,
+}
+
+/// Whether or not to enforce a [`Relation::Thread`] when sending a reply.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[allow(clippy::exhaustive_enums)]
+pub enum EnforceThread {
+    /// A thread relation is enforced. If the original message does not have a
+    /// thread relation itself, a new thread is started.
+    Threaded(ReplyWithinThread),
+
+    /// A thread relation is not enforced. If the original message has a thread
+    /// relation, it is forwarded.
+    MaybeThreaded,
+
+    /// A thread relation is not enforced. If the original message has a thread
+    /// relation, it is *not* forwarded.
+    Unthreaded,
+}
+
+impl Room {
+    /// Create a new reply event for the target event id with the specified
+    /// content.
+    ///
+    /// The event can then be sent with [`Room::send`] or a
+    /// [`crate::send_queue::RoomSendQueue`].
+    #[instrument(skip(self, content), fields(room = %self.room_id()))]
+    pub async fn make_reply_event(
+        &self,
+        content: RoomMessageEventContentWithoutRelation,
+        event_id: &EventId,
+        enforce_thread: EnforceThread,
+    ) -> Result<AnyMessageLikeEventContent, ReplyError> {
+        make_reply_event(
+            self,
+            self.room_id(),
+            self.own_user_id(),
+            content,
+            event_id,
+            enforce_thread,
+        )
+        .await
+    }
+}
+
+trait EventSource {
+    fn get_event(
+        &self,
+        event_id: &EventId,
+    ) -> impl Future<Output = Result<TimelineEvent, ReplyError>> + SendOutsideWasm;
+}
+
+impl EventSource for &Room {
+    async fn get_event(&self, event_id: &EventId) -> Result<TimelineEvent, ReplyError> {
+        self.load_or_fetch_event(event_id, None)
+            .await
+            .map_err(|err| ReplyError::Fetch(Box::new(err)))
+    }
+}
path: crates/matrix-sdk/src/room/reply.rs
user: login: Johennes
body: How shall we deal with the different error types in the two traits? I could use an associated type but then we'd still be duplicating the implementations. 🤔

```
trait EventSource {
    type Error: std::error::Error + Send + Sync + 'static;

    fn get_event(
        &self,
        event_id: &EventId,
    ) -> impl Future<Output = Result<TimelineEvent, Self::Error>> + SendOutsideWasm;
}

impl EventSource for &Room {
    type Error = ReplyError;

    async fn get_event(&self, event_id: &EventId) -> Result<TimelineEvent, ReplyError> {
        self.load_or_fetch_event(event_id, None)
            .await
            .map_err(|err| ReplyError::Fetch(Box::new(err)))
    }
}

impl EventSource for &Room {
    type Error = EditError;

    async fn get_event(&self, event_id: &EventId) -> Result<TimelineEvent, ReplyError> {
        self.load_or_fetch_event(event_id, None)
            .await
            .map_err(|err| EditError::Fetch(Box::new(err)))
    }
}
```
created_at: 2025-03-26T13:06:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,392 @@
+// Copyright 2025 The Matrix.org Foundation C.I.C.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Facilities to reply to existing events.
+
+use std::future::Future;
+
+use matrix_sdk_base::{deserialized_responses::TimelineEvent, SendOutsideWasm};
+use ruma::{
+    events::{
+        relation::Thread,
+        room::{
+            encrypted::Relation as EncryptedRelation,
+            message::{
+                AddMentions, ForwardThread, OriginalRoomMessageEvent, Relation, ReplyWithinThread,
+                RoomMessageEventContent, RoomMessageEventContentWithoutRelation,
+            },
+        },
+        AnyMessageLikeEventContent, AnySyncMessageLikeEvent, AnySyncTimelineEvent,
+        SyncMessageLikeEvent,
+    },
+    serde::Raw,
+    EventId, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedUserId, RoomId, UserId,
+};
+use serde::Deserialize;
+use thiserror::Error;
+use tracing::{error, instrument};
+
+use super::Room;
+
+/// Information needed to reply to an event.
+#[derive(Debug, Clone)]
+struct RepliedToInfo {
+    /// The event ID of the event to reply to.
+    event_id: OwnedEventId,
+    /// The sender of the event to reply to.
+    sender: OwnedUserId,
+    /// The timestamp of the event to reply to.
+    timestamp: MilliSecondsSinceUnixEpoch,
+    /// The content of the event to reply to.
+    content: ReplyContent,
+}
+
+/// The content of a reply.
+#[derive(Debug, Clone)]
+enum ReplyContent {
+    /// Content of a message event.
+    Message(RoomMessageEventContent),
+    /// Content of any other kind of event stored as raw JSON.
+    Raw(Raw<AnySyncTimelineEvent>),
+}
+
+/// Errors specific to unsupported replies.
+#[derive(Debug, Error)]
+pub enum ReplyError {
+    /// We couldn't fetch the remote event with /room/event.
+    #[error("Couldn't fetch the remote event: {0}")]
+    Fetch(Box<crate::Error>),
+    /// Redacted events whose JSON form isn't available cannot be replied to.
+    #[error("redacted events whose JSON form isn't available can't be replied")]
+    MissingJson,
+    /// The event to reply to could not be found.
+    #[error("event to reply to not found")]
+    MissingEvent,
+    /// The event to reply to could not be deserialized.
+    #[error("failed to deserialize event to reply to")]
+    FailedToDeserializeEvent,
+    /// State events cannot be replied to.
+    #[error("tried to reply to a state event")]
+    StateEvent,
+}
+
+/// Whether or not to enforce a [`Relation::Thread`] when sending a reply.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[allow(clippy::exhaustive_enums)]
+pub enum EnforceThread {
+    /// A thread relation is enforced. If the original message does not have a
+    /// thread relation itself, a new thread is started.
+    Threaded(ReplyWithinThread),
+
+    /// A thread relation is not enforced. If the original message has a thread
+    /// relation, it is forwarded.
+    MaybeThreaded,
+
+    /// A thread relation is not enforced. If the original message has a thread
+    /// relation, it is *not* forwarded.
+    Unthreaded,
+}
+
+impl Room {
+    /// Create a new reply event for the target event id with the specified
+    /// content.
+    ///
+    /// The event can then be sent with [`Room::send`] or a
+    /// [`crate::send_queue::RoomSendQueue`].
+    #[instrument(skip(self, content), fields(room = %self.room_id()))]
+    pub async fn make_reply_event(
+        &self,
+        content: RoomMessageEventContentWithoutRelation,
+        event_id: &EventId,
+        enforce_thread: EnforceThread,
+    ) -> Result<AnyMessageLikeEventContent, ReplyError> {
+        make_reply_event(
+            self,
+            self.room_id(),
+            self.own_user_id(),
+            content,
+            event_id,
+            enforce_thread,
+        )
+        .await
+    }
+}
+
+trait EventSource {
+    fn get_event(
+        &self,
+        event_id: &EventId,
+    ) -> impl Future<Output = Result<TimelineEvent, ReplyError>> + SendOutsideWasm;
+}
+
+impl EventSource for &Room {
+    async fn get_event(&self, event_id: &EventId) -> Result<TimelineEvent, ReplyError> {
+        self.load_or_fetch_event(event_id, None)
+            .await
+            .map_err(|err| ReplyError::Fetch(Box::new(err)))
+    }
+}
+
+async fn make_reply_event<S: EventSource>(
+    source: S,
+    room_id: &RoomId,
+    own_user_id: &UserId,
+    content: RoomMessageEventContentWithoutRelation,
+    event_id: &EventId,
+    enforce_thread: EnforceThread,
+) -> Result<AnyMessageLikeEventContent, ReplyError> {
+    let replied_to_info = replied_to_info_from_event_id(source, event_id).await?;
+
+    // [The specification](https://spec.matrix.org/v1.10/client-server-api/#user-and-room-mentions) says:
+    //
+    // > Users should not add their own Matrix ID to the `m.mentions` property as
+    // > outgoing messages cannot self-notify.
+    //
+    // If the replied to event has been written by the current user, let's toggle to
+    // `AddMentions::No`.
+    let mention_the_sender =
+        if own_user_id == replied_to_info.sender { AddMentions::No } else { AddMentions::Yes };
+
+    let content = match replied_to_info.content {
+        ReplyContent::Message(replied_to_content) => {
+            let event = OriginalRoomMessageEvent {
+                event_id: replied_to_info.event_id,
+                sender: replied_to_info.sender,
+                origin_server_ts: replied_to_info.timestamp,
+                room_id: room_id.to_owned(),
+                content: replied_to_content,
+                unsigned: Default::default(),
+            };
+
+            match enforce_thread {
+                EnforceThread::Threaded(is_reply) => {
+                    content.make_for_thread(&event, is_reply, mention_the_sender)
+                }
+                EnforceThread::MaybeThreaded => {
+                    content.make_reply_to(&event, ForwardThread::Yes, mention_the_sender)
+                }
+                EnforceThread::Unthreaded => {
+                    content.make_reply_to(&event, ForwardThread::No, mention_the_sender)
+                }
+            }
+        }
+
+        ReplyContent::Raw(raw_event) => {
+            match enforce_thread {
+                EnforceThread::Threaded(is_reply) => {
+                    // Some of the code below technically belongs into ruma. However,
+                    // reply fallbacks have been removed in Matrix 1.13 which means
+                    // both match arms can use the successor of make_for_thread in
+                    // the next ruma release.
+                    #[derive(Deserialize)]
+                    struct ContentDeHelper {
+                        #[serde(rename = "m.relates_to")]
+                        relates_to: Option<EncryptedRelation>,
+                    }
+
+                    let previous_content =
+                        raw_event.get_field::<ContentDeHelper>("content").ok().flatten();
+
+                    let mut content = if is_reply == ReplyWithinThread::Yes {
+                        content.make_reply_to_raw(
+                            &raw_event,
+                            replied_to_info.event_id.to_owned(),
+                            room_id,
+                            ForwardThread::No,
+                            mention_the_sender,
+                        )
+                    } else {
+                        content.into()
+                    };
+
+                    let thread_root = if let Some(EncryptedRelation::Thread(thread)) =
+                        previous_content.as_ref().and_then(|c| c.relates_to.as_ref())
+                    {
+                        thread.event_id.to_owned()
+                    } else {
+                        replied_to_info.event_id.to_owned()
+                    };
+
+                    let thread = if is_reply == ReplyWithinThread::Yes {
+                        Thread::reply(thread_root, replied_to_info.event_id)
+                    } else {
+                        Thread::plain(thread_root, replied_to_info.event_id)
+                    };
+
+                    content.relates_to = Some(Relation::Thread(thread));
+                    content
+                }
+
+                EnforceThread::MaybeThreaded => content.make_reply_to_raw(
+                    &raw_event,
+                    replied_to_info.event_id,
+                    room_id,
+                    ForwardThread::Yes,
+                    mention_the_sender,
+                ),
+
+                EnforceThread::Unthreaded => content.make_reply_to_raw(
+                    &raw_event,
+                    replied_to_info.event_id,
+                    room_id,
+                    ForwardThread::No,
+                    mention_the_sender,
+                ),
+            }
+        }
+    };
+
+    Ok(content.into())
+}
+
+async fn replied_to_info_from_event_id<S: EventSource>(
+    source: S,
+    event_id: &EventId,
+) -> Result<RepliedToInfo, ReplyError> {
+    let event = source.get_event(event_id).await?;
+
+    let raw_event = event.into_raw();
+    let event = raw_event.deserialize().map_err(|_| ReplyError::FailedToDeserializeEvent)?;
+
+    let reply_content = match &event {
+        AnySyncTimelineEvent::MessageLike(event) => {
+            if let AnySyncMessageLikeEvent::RoomMessage(SyncMessageLikeEvent::Original(
+                original_event,
+            )) = event
+            {
+                ReplyContent::Message(original_event.content.clone())
+            } else {
+                ReplyContent::Raw(raw_event)
+            }
+        }
+        AnySyncTimelineEvent::State(_) => return Err(ReplyError::StateEvent),
+    };
+
+    Ok(RepliedToInfo {
+        event_id: event_id.to_owned(),
+        sender: event.sender().to_owned(),
+        timestamp: event.origin_server_ts(),
+        content: reply_content,
+    })
+}
+
+#[cfg(test)]
+mod tests {
+    use std::collections::BTreeMap;
+
+    use assert_matches2::{assert_let, assert_matches};
+    use matrix_sdk_base::deserialized_responses::TimelineEvent;
+    use matrix_sdk_test::{async_test, event_factory::EventFactory};
+    use ruma::{
+        event_id,
+        events::{
+            room::message::{Relation, RoomMessageEventContentWithoutRelation},
+            AnyMessageLikeEventContent, AnySyncTimelineEvent,
+        },
+        room_id,
+        serde::Raw,
+        user_id, EventId, OwnedEventId,
+    };
+    use serde_json::json;
+
+    use super::{make_reply_event, EnforceThread, EventSource, ReplyError};
+
+    #[derive(Default)]
+    struct TestEventCache {
+        events: BTreeMap<OwnedEventId, TimelineEvent>,
+    }
+
+    impl EventSource for TestEventCache {
+        async fn get_event(&self, event_id: &EventId) -> Result<TimelineEvent, ReplyError> {
+            Ok(self.events.get(event_id).unwrap().clone())
+        }
+    }
+
+    #[async_test]
+    async fn test_reply_to_state_event() {
+        let event_id = event_id!("$1");
+        let own_user_id = user_id!("@me:saucisse.bzh");
+
+        let mut cache = TestEventCache::default();
+
+        cache.events.insert(
+            event_id.to_owned(),
+            // TODO: use the EventFactory for state events too.
path: crates/matrix-sdk/src/room/reply.rs
user: login: Johennes
body: Why do I always step into these? 🤦‍♂️😅
created_at: 2025-03-26T13:13:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,392 @@
+// Copyright 2025 The Matrix.org Foundation C.I.C.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Facilities to reply to existing events.
+
+use std::future::Future;
+
+use matrix_sdk_base::{deserialized_responses::TimelineEvent, SendOutsideWasm};
+use ruma::{
+    events::{
+        relation::Thread,
+        room::{
+            encrypted::Relation as EncryptedRelation,
+            message::{
+                AddMentions, ForwardThread, OriginalRoomMessageEvent, Relation, ReplyWithinThread,
+                RoomMessageEventContent, RoomMessageEventContentWithoutRelation,
+            },
+        },
+        AnyMessageLikeEventContent, AnySyncMessageLikeEvent, AnySyncTimelineEvent,
+        SyncMessageLikeEvent,
+    },
+    serde::Raw,
+    EventId, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedUserId, RoomId, UserId,
+};
+use serde::Deserialize;
+use thiserror::Error;
+use tracing::{error, instrument};
+
+use super::Room;
+
+/// Information needed to reply to an event.
+#[derive(Debug, Clone)]
+struct RepliedToInfo {
+    /// The event ID of the event to reply to.
+    event_id: OwnedEventId,
+    /// The sender of the event to reply to.
+    sender: OwnedUserId,
+    /// The timestamp of the event to reply to.
+    timestamp: MilliSecondsSinceUnixEpoch,
+    /// The content of the event to reply to.
+    content: ReplyContent,
+}
+
+/// The content of a reply.
+#[derive(Debug, Clone)]
+enum ReplyContent {
+    /// Content of a message event.
+    Message(RoomMessageEventContent),
+    /// Content of any other kind of event stored as raw JSON.
+    Raw(Raw<AnySyncTimelineEvent>),
+}
+
+/// Errors specific to unsupported replies.
+#[derive(Debug, Error)]
+pub enum ReplyError {
+    /// We couldn't fetch the remote event with /room/event.
+    #[error("Couldn't fetch the remote event: {0}")]
+    Fetch(Box<crate::Error>),
+    /// Redacted events whose JSON form isn't available cannot be replied to.
+    #[error("redacted events whose JSON form isn't available can't be replied")]
+    MissingJson,
+    /// The event to reply to could not be found.
+    #[error("event to reply to not found")]
+    MissingEvent,
+    /// The event to reply to could not be deserialized.
+    #[error("failed to deserialize event to reply to")]
+    FailedToDeserializeEvent,
+    /// State events cannot be replied to.
+    #[error("tried to reply to a state event")]
+    StateEvent,
+}
+
+/// Whether or not to enforce a [`Relation::Thread`] when sending a reply.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[allow(clippy::exhaustive_enums)]
+pub enum EnforceThread {
+    /// A thread relation is enforced. If the original message does not have a
+    /// thread relation itself, a new thread is started.
+    Threaded(ReplyWithinThread),
+
+    /// A thread relation is not enforced. If the original message has a thread
+    /// relation, it is forwarded.
+    MaybeThreaded,
+
+    /// A thread relation is not enforced. If the original message has a thread
+    /// relation, it is *not* forwarded.
+    Unthreaded,
+}
+
+impl Room {
+    /// Create a new reply event for the target event id with the specified
+    /// content.
+    ///
+    /// The event can then be sent with [`Room::send`] or a
+    /// [`crate::send_queue::RoomSendQueue`].
+    #[instrument(skip(self, content), fields(room = %self.room_id()))]
+    pub async fn make_reply_event(
+        &self,
+        content: RoomMessageEventContentWithoutRelation,
+        event_id: &EventId,
+        enforce_thread: EnforceThread,
+    ) -> Result<AnyMessageLikeEventContent, ReplyError> {
+        make_reply_event(
+            self,
+            self.room_id(),
+            self.own_user_id(),
+            content,
+            event_id,
+            enforce_thread,
+        )
+        .await
+    }
+}
+
+trait EventSource {
+    fn get_event(
+        &self,
+        event_id: &EventId,
+    ) -> impl Future<Output = Result<TimelineEvent, ReplyError>> + SendOutsideWasm;
+}
+
+impl EventSource for &Room {
+    async fn get_event(&self, event_id: &EventId) -> Result<TimelineEvent, ReplyError> {
+        self.load_or_fetch_event(event_id, None)
+            .await
+            .map_err(|err| ReplyError::Fetch(Box::new(err)))
+    }
+}
path: crates/matrix-sdk/src/room/reply.rs
user: login: bnjbvr
body: Let's have `get_event` return a `Box<crate::Error>`, and the callers can wrap it into either a `ReplyError` or an `EditError` :)
created_at: 2025-03-26T16:20:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -12,6 +12,8 @@ All notable changes to this project will be documented in this file.
 
 - [**breaking**] Optionally allow starting threads with `Timeline::send_reply`.
   ([4819](https://github.com/matrix-org/matrix-rust-sdk/pull/4819))
+- [**breaking**] Push the reply logic down into matrix_sdk.
path: crates/matrix-sdk-ui/CHANGELOG.md
user: login: Johennes
body: Yes, true. Have expanded it.
created_at: 2025-03-26T18:25:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,392 @@
+// Copyright 2025 The Matrix.org Foundation C.I.C.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Facilities to reply to existing events.
+
+use std::future::Future;
+
+use matrix_sdk_base::{deserialized_responses::TimelineEvent, SendOutsideWasm};
+use ruma::{
+    events::{
+        relation::Thread,
+        room::{
+            encrypted::Relation as EncryptedRelation,
+            message::{
+                AddMentions, ForwardThread, OriginalRoomMessageEvent, Relation, ReplyWithinThread,
+                RoomMessageEventContent, RoomMessageEventContentWithoutRelation,
+            },
+        },
+        AnyMessageLikeEventContent, AnySyncMessageLikeEvent, AnySyncTimelineEvent,
+        SyncMessageLikeEvent,
+    },
+    serde::Raw,
+    EventId, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedUserId, RoomId, UserId,
+};
+use serde::Deserialize;
+use thiserror::Error;
+use tracing::{error, instrument};
+
+use super::Room;
+
+/// Information needed to reply to an event.
+#[derive(Debug, Clone)]
+struct RepliedToInfo {
+    /// The event ID of the event to reply to.
+    event_id: OwnedEventId,
+    /// The sender of the event to reply to.
+    sender: OwnedUserId,
+    /// The timestamp of the event to reply to.
+    timestamp: MilliSecondsSinceUnixEpoch,
+    /// The content of the event to reply to.
+    content: ReplyContent,
+}
+
+/// The content of a reply.
+#[derive(Debug, Clone)]
+enum ReplyContent {
+    /// Content of a message event.
+    Message(RoomMessageEventContent),
+    /// Content of any other kind of event stored as raw JSON.
+    Raw(Raw<AnySyncTimelineEvent>),
+}
+
+/// Errors specific to unsupported replies.
+#[derive(Debug, Error)]
+pub enum ReplyError {
+    /// We couldn't fetch the remote event with /room/event.
+    #[error("Couldn't fetch the remote event: {0}")]
+    Fetch(Box<crate::Error>),
+    /// Redacted events whose JSON form isn't available cannot be replied to.
+    #[error("redacted events whose JSON form isn't available can't be replied")]
+    MissingJson,
+    /// The event to reply to could not be found.
+    #[error("event to reply to not found")]
+    MissingEvent,
+    /// The event to reply to could not be deserialized.
+    #[error("failed to deserialize event to reply to")]
+    FailedToDeserializeEvent,
+    /// State events cannot be replied to.
+    #[error("tried to reply to a state event")]
+    StateEvent,
path: crates/matrix-sdk/src/room/reply.rs
user: login: Johennes
body: Done. The two `Missing*` variants also turned out to be unused. So I removed them.
created_at: 2025-03-26T18:26:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,404 @@
+// Copyright 2025 The Matrix.org Foundation C.I.C.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Facilities to reply to existing events.
+
+use std::future::Future;
+
+use matrix_sdk_base::{deserialized_responses::TimelineEvent, SendOutsideWasm};
+use ruma::{
+    events::{
+        relation::Thread,
+        room::{
+            encrypted::Relation as EncryptedRelation,
+            message::{
+                AddMentions, ForwardThread, OriginalRoomMessageEvent, Relation, ReplyWithinThread,
+                RoomMessageEventContent, RoomMessageEventContentWithoutRelation,
+            },
+        },
+        AnyMessageLikeEventContent, AnySyncMessageLikeEvent, AnySyncTimelineEvent,
+        SyncMessageLikeEvent,
+    },
+    serde::Raw,
+    EventId, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedUserId, RoomId, UserId,
+};
+use serde::Deserialize;
+use thiserror::Error;
+use tracing::{error, instrument};
+
+use super::Room;
+
+/// Information needed to reply to an event.
+#[derive(Debug, Clone)]
+pub struct RepliedToInfo {
+    /// The event ID of the event to reply to.
+    event_id: OwnedEventId,
+    /// The sender of the event to reply to.
+    sender: OwnedUserId,
+    /// The timestamp of the event to reply to.
+    timestamp: MilliSecondsSinceUnixEpoch,
+    /// The content of the event to reply to.
+    content: ReplyContent,
+}
+
+impl RepliedToInfo {
+    /// The event ID of the event to reply to.
+    pub fn event_id(&self) -> &EventId {
+        &self.event_id
+    }
+
+    /// The sender of the event to reply to.
+    pub fn sender(&self) -> &UserId {
+        &self.sender
+    }
+}
+
+/// The content of a reply.
+#[derive(Debug, Clone)]
+pub enum ReplyContent {
+    /// Content of a message event.
+    Message(RoomMessageEventContent),
+    /// Content of any other kind of event stored as raw JSON.
+    Raw(Raw<AnySyncTimelineEvent>),
+}
+
+/// Errors specific to unsupported replies.
+#[derive(Debug, Error)]
+pub enum ReplyError {
+    /// We couldn't fetch the remote event with /room/event.
+    #[error("Couldn't fetch the remote event: {0}")]
+    Fetch(Box<crate::Error>),
+    /// Redacted events whose JSON form isn't available cannot be replied to.
+    #[error("redacted events whose JSON form isn't available can't be replied")]
+    MissingJson,
+    /// The event to reply to could not be found.
+    #[error("event to reply to not found")]
+    MissingEvent,
+    /// The event to reply to could not be deserialized.
+    #[error("failed to deserialize event to reply to")]
+    FailedToDeserializeEvent,
+    /// State events cannot be replied to.
+    #[error("tried to reply to a state event")]
+    StateEvent,
+}
+
+/// Whether or not to enforce a [`Relation::Thread`] when sending a reply.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[allow(clippy::exhaustive_enums)]
+pub enum EnforceThread {
+    /// A thread relation is enforced. If the original message does not have a
+    /// thread relation itself, a new thread is started.
+    Threaded(ReplyWithinThread),
+
+    /// A thread relation is not enforced. If the original message has a thread
+    /// relation, it is forwarded.
+    MaybeThreaded,
+
+    /// A thread relation is not enforced. If the original message has a thread
+    /// relation, it is *not* forwarded.
+    Unthreaded,
+}
+
+impl Room {
+    /// Create a new reply event for the target event id with the specified
+    /// content.
+    ///
+    /// The event can then be sent with [`Room::send`] or a
+    /// [`crate::send_queue::RoomSendQueue`].
+    #[instrument(skip(self, content), fields(room = %self.room_id()))]
+    pub async fn make_reply_event(
+        &self,
+        content: RoomMessageEventContentWithoutRelation,
+        event_id: &EventId,
+        enforce_thread: EnforceThread,
+    ) -> Result<AnyMessageLikeEventContent, ReplyError> {
+        make_reply_event(
+            self,
+            self.room_id(),
+            self.own_user_id(),
+            content,
+            event_id,
+            enforce_thread,
+        )
+        .await
+    }
+}
+
+trait EventSource {
+    fn get_event(
+        &self,
+        event_id: &EventId,
+    ) -> impl Future<Output = Result<TimelineEvent, ReplyError>> + SendOutsideWasm;
+}
+
+impl EventSource for &Room {
+    async fn get_event(&self, event_id: &EventId) -> Result<TimelineEvent, ReplyError> {
+        self.load_or_fetch_event(event_id, None)
+            .await
+            .map_err(|err| ReplyError::Fetch(Box::new(err)))
+    }
+}
+
+async fn make_reply_event<S: EventSource>(
+    source: S,
+    room_id: &RoomId,
+    own_user_id: &UserId,
+    content: RoomMessageEventContentWithoutRelation,
+    event_id: &EventId,
+    enforce_thread: EnforceThread,
+) -> Result<AnyMessageLikeEventContent, ReplyError> {
+    let replied_to_info = replied_to_info_from_event_id(source, event_id).await?;
+
+    // [The specification](https://spec.matrix.org/v1.10/client-server-api/#user-and-room-mentions) says:
+    //
+    // > Users should not add their own Matrix ID to the `m.mentions` property as
+    // > outgoing messages cannot self-notify.
+    //
+    // If the replied to event has been written by the current user, let's toggle to
+    // `AddMentions::No`.
+    let mention_the_sender =
+        if own_user_id == replied_to_info.sender { AddMentions::No } else { AddMentions::Yes };
+
+    let content = match replied_to_info.content {
+        ReplyContent::Message(replied_to_content) => {
+            let event = OriginalRoomMessageEvent {
+                event_id: replied_to_info.event_id,
+                sender: replied_to_info.sender,
+                origin_server_ts: replied_to_info.timestamp,
+                room_id: room_id.to_owned(),
+                content: replied_to_content,
+                unsigned: Default::default(),
+            };
+
+            match enforce_thread {
+                EnforceThread::Threaded(is_reply) => {
+                    content.make_for_thread(&event, is_reply, mention_the_sender)
+                }
+                EnforceThread::MaybeThreaded => {
+                    content.make_reply_to(&event, ForwardThread::Yes, mention_the_sender)
+                }
+                EnforceThread::Unthreaded => {
+                    content.make_reply_to(&event, ForwardThread::No, mention_the_sender)
+                }
+            }
+        }
+
+        ReplyContent::Raw(raw_event) => {
+            match enforce_thread {
+                EnforceThread::Threaded(is_reply) => {
+                    // Some of the code below technically belongs into ruma. However,
+                    // reply fallbacks have been removed in Matrix 1.13 which means
+                    // both match arms can use the successor of make_for_thread in
+                    // the next ruma release.
+                    #[derive(Deserialize)]
+                    struct ContentDeHelper {
+                        #[serde(rename = "m.relates_to")]
+                        relates_to: Option<EncryptedRelation>,
+                    }
+
+                    let previous_content =
+                        raw_event.get_field::<ContentDeHelper>("content").ok().flatten();
+
+                    let mut content = if is_reply == ReplyWithinThread::Yes {
+                        content.make_reply_to_raw(
+                            &raw_event,
+                            replied_to_info.event_id.to_owned(),
+                            room_id,
+                            ForwardThread::No,
+                            mention_the_sender,
+                        )
+                    } else {
+                        content.into()
+                    };
+
+                    let thread_root = if let Some(EncryptedRelation::Thread(thread)) =
+                        previous_content.as_ref().and_then(|c| c.relates_to.as_ref())
+                    {
+                        thread.event_id.to_owned()
+                    } else {
+                        replied_to_info.event_id.to_owned()
+                    };
+
+                    let thread = if is_reply == ReplyWithinThread::Yes {
+                        Thread::reply(thread_root, replied_to_info.event_id)
+                    } else {
+                        Thread::plain(thread_root, replied_to_info.event_id)
+                    };
+
+                    content.relates_to = Some(Relation::Thread(thread));
+                    content
+                }
+
+                EnforceThread::MaybeThreaded => content.make_reply_to_raw(
+                    &raw_event,
+                    replied_to_info.event_id,
+                    room_id,
+                    ForwardThread::Yes,
+                    mention_the_sender,
+                ),
+
+                EnforceThread::Unthreaded => content.make_reply_to_raw(
+                    &raw_event,
+                    replied_to_info.event_id,
+                    room_id,
+                    ForwardThread::No,
+                    mention_the_sender,
+                ),
+            }
+        }
+    };
+
+    Ok(content.into())
+}
+
+async fn replied_to_info_from_event_id<S: EventSource>(
+    source: S,
+    event_id: &EventId,
+) -> Result<RepliedToInfo, ReplyError> {
+    let event = source.get_event(event_id).await?;
+
+    let raw_event = event.into_raw();
+    let event = raw_event.deserialize().map_err(|_| ReplyError::FailedToDeserializeEvent)?;
+
+    let reply_content = match &event {
+        AnySyncTimelineEvent::MessageLike(event) => {
+            if let AnySyncMessageLikeEvent::RoomMessage(SyncMessageLikeEvent::Original(
+                original_event,
+            )) = event
+            {
+                ReplyContent::Message(original_event.content.clone())
+            } else {
+                ReplyContent::Raw(raw_event)
+            }
+        }
+        AnySyncTimelineEvent::State(_) => return Err(ReplyError::StateEvent),
+    };
+
+    Ok(RepliedToInfo {
+        event_id: event_id.to_owned(),
+        sender: event.sender().to_owned(),
+        timestamp: event.origin_server_ts(),
+        content: reply_content,
+    })
+}
+
+#[cfg(test)]
+mod tests {
path: crates/matrix-sdk/src/room/reply.rs
user: login: Johennes
body: Have added a bunch more tests. I feel like we could keep the timeline tests around additionally since they also test the remote echo. Therefore, I haven't removed any of them. I don't feel strongly about it though.
created_at: 2025-03-26T18:28:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,392 @@
+// Copyright 2025 The Matrix.org Foundation C.I.C.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Facilities to reply to existing events.
+
+use std::future::Future;
+
+use matrix_sdk_base::{deserialized_responses::TimelineEvent, SendOutsideWasm};
+use ruma::{
+    events::{
+        relation::Thread,
+        room::{
+            encrypted::Relation as EncryptedRelation,
+            message::{
+                AddMentions, ForwardThread, OriginalRoomMessageEvent, Relation, ReplyWithinThread,
+                RoomMessageEventContent, RoomMessageEventContentWithoutRelation,
+            },
+        },
+        AnyMessageLikeEventContent, AnySyncMessageLikeEvent, AnySyncTimelineEvent,
+        SyncMessageLikeEvent,
+    },
+    serde::Raw,
+    EventId, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedUserId, RoomId, UserId,
+};
+use serde::Deserialize;
+use thiserror::Error;
+use tracing::{error, instrument};
+
+use super::Room;
+
+/// Information needed to reply to an event.
+#[derive(Debug, Clone)]
+struct RepliedToInfo {
+    /// The event ID of the event to reply to.
+    event_id: OwnedEventId,
+    /// The sender of the event to reply to.
+    sender: OwnedUserId,
+    /// The timestamp of the event to reply to.
+    timestamp: MilliSecondsSinceUnixEpoch,
+    /// The content of the event to reply to.
+    content: ReplyContent,
+}
+
+/// The content of a reply.
+#[derive(Debug, Clone)]
+enum ReplyContent {
+    /// Content of a message event.
+    Message(RoomMessageEventContent),
+    /// Content of any other kind of event stored as raw JSON.
+    Raw(Raw<AnySyncTimelineEvent>),
+}
+
+/// Errors specific to unsupported replies.
+#[derive(Debug, Error)]
+pub enum ReplyError {
+    /// We couldn't fetch the remote event with /room/event.
+    #[error("Couldn't fetch the remote event: {0}")]
+    Fetch(Box<crate::Error>),
+    /// Redacted events whose JSON form isn't available cannot be replied to.
+    #[error("redacted events whose JSON form isn't available can't be replied")]
+    MissingJson,
+    /// The event to reply to could not be found.
+    #[error("event to reply to not found")]
+    MissingEvent,
+    /// The event to reply to could not be deserialized.
+    #[error("failed to deserialize event to reply to")]
+    FailedToDeserializeEvent,
+    /// State events cannot be replied to.
+    #[error("tried to reply to a state event")]
+    StateEvent,
+}
+
+/// Whether or not to enforce a [`Relation::Thread`] when sending a reply.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[allow(clippy::exhaustive_enums)]
+pub enum EnforceThread {
+    /// A thread relation is enforced. If the original message does not have a
+    /// thread relation itself, a new thread is started.
+    Threaded(ReplyWithinThread),
+
+    /// A thread relation is not enforced. If the original message has a thread
+    /// relation, it is forwarded.
+    MaybeThreaded,
+
+    /// A thread relation is not enforced. If the original message has a thread
+    /// relation, it is *not* forwarded.
+    Unthreaded,
+}
+
+impl Room {
+    /// Create a new reply event for the target event id with the specified
+    /// content.
+    ///
+    /// The event can then be sent with [`Room::send`] or a
+    /// [`crate::send_queue::RoomSendQueue`].
+    #[instrument(skip(self, content), fields(room = %self.room_id()))]
+    pub async fn make_reply_event(
+        &self,
+        content: RoomMessageEventContentWithoutRelation,
+        event_id: &EventId,
+        enforce_thread: EnforceThread,
+    ) -> Result<AnyMessageLikeEventContent, ReplyError> {
+        make_reply_event(
+            self,
+            self.room_id(),
+            self.own_user_id(),
+            content,
+            event_id,
+            enforce_thread,
+        )
+        .await
+    }
+}
+
+trait EventSource {
+    fn get_event(
+        &self,
+        event_id: &EventId,
+    ) -> impl Future<Output = Result<TimelineEvent, ReplyError>> + SendOutsideWasm;
+}
+
+impl EventSource for &Room {
+    async fn get_event(&self, event_id: &EventId) -> Result<TimelineEvent, ReplyError> {
+        self.load_or_fetch_event(event_id, None)
+            .await
+            .map_err(|err| ReplyError::Fetch(Box::new(err)))
+    }
+}
+
+async fn make_reply_event<S: EventSource>(
+    source: S,
+    room_id: &RoomId,
+    own_user_id: &UserId,
+    content: RoomMessageEventContentWithoutRelation,
+    event_id: &EventId,
+    enforce_thread: EnforceThread,
+) -> Result<AnyMessageLikeEventContent, ReplyError> {
+    let replied_to_info = replied_to_info_from_event_id(source, event_id).await?;
+
+    // [The specification](https://spec.matrix.org/v1.10/client-server-api/#user-and-room-mentions) says:
+    //
+    // > Users should not add their own Matrix ID to the `m.mentions` property as
+    // > outgoing messages cannot self-notify.
+    //
+    // If the replied to event has been written by the current user, let's toggle to
+    // `AddMentions::No`.
+    let mention_the_sender =
+        if own_user_id == replied_to_info.sender { AddMentions::No } else { AddMentions::Yes };
+
+    let content = match replied_to_info.content {
+        ReplyContent::Message(replied_to_content) => {
+            let event = OriginalRoomMessageEvent {
+                event_id: replied_to_info.event_id,
+                sender: replied_to_info.sender,
+                origin_server_ts: replied_to_info.timestamp,
+                room_id: room_id.to_owned(),
+                content: replied_to_content,
+                unsigned: Default::default(),
+            };
+
+            match enforce_thread {
+                EnforceThread::Threaded(is_reply) => {
+                    content.make_for_thread(&event, is_reply, mention_the_sender)
+                }
+                EnforceThread::MaybeThreaded => {
+                    content.make_reply_to(&event, ForwardThread::Yes, mention_the_sender)
+                }
+                EnforceThread::Unthreaded => {
+                    content.make_reply_to(&event, ForwardThread::No, mention_the_sender)
+                }
+            }
+        }
+
+        ReplyContent::Raw(raw_event) => {
+            match enforce_thread {
+                EnforceThread::Threaded(is_reply) => {
+                    // Some of the code below technically belongs into ruma. However,
+                    // reply fallbacks have been removed in Matrix 1.13 which means
+                    // both match arms can use the successor of make_for_thread in
+                    // the next ruma release.
+                    #[derive(Deserialize)]
+                    struct ContentDeHelper {
+                        #[serde(rename = "m.relates_to")]
+                        relates_to: Option<EncryptedRelation>,
+                    }
+
+                    let previous_content =
+                        raw_event.get_field::<ContentDeHelper>("content").ok().flatten();
+
+                    let mut content = if is_reply == ReplyWithinThread::Yes {
+                        content.make_reply_to_raw(
+                            &raw_event,
+                            replied_to_info.event_id.to_owned(),
+                            room_id,
+                            ForwardThread::No,
+                            mention_the_sender,
+                        )
+                    } else {
+                        content.into()
+                    };
+
+                    let thread_root = if let Some(EncryptedRelation::Thread(thread)) =
+                        previous_content.as_ref().and_then(|c| c.relates_to.as_ref())
+                    {
+                        thread.event_id.to_owned()
+                    } else {
+                        replied_to_info.event_id.to_owned()
+                    };
+
+                    let thread = if is_reply == ReplyWithinThread::Yes {
+                        Thread::reply(thread_root, replied_to_info.event_id)
+                    } else {
+                        Thread::plain(thread_root, replied_to_info.event_id)
+                    };
+
+                    content.relates_to = Some(Relation::Thread(thread));
+                    content
+                }
+
+                EnforceThread::MaybeThreaded => content.make_reply_to_raw(
+                    &raw_event,
+                    replied_to_info.event_id,
+                    room_id,
+                    ForwardThread::Yes,
+                    mention_the_sender,
+                ),
+
+                EnforceThread::Unthreaded => content.make_reply_to_raw(
+                    &raw_event,
+                    replied_to_info.event_id,
+                    room_id,
+                    ForwardThread::No,
+                    mention_the_sender,
+                ),
+            }
+        }
+    };
+
+    Ok(content.into())
+}
+
+async fn replied_to_info_from_event_id<S: EventSource>(
+    source: S,
+    event_id: &EventId,
+) -> Result<RepliedToInfo, ReplyError> {
+    let event = source.get_event(event_id).await?;
+
+    let raw_event = event.into_raw();
+    let event = raw_event.deserialize().map_err(|_| ReplyError::FailedToDeserializeEvent)?;
+
+    let reply_content = match &event {
+        AnySyncTimelineEvent::MessageLike(event) => {
+            if let AnySyncMessageLikeEvent::RoomMessage(SyncMessageLikeEvent::Original(
+                original_event,
+            )) = event
+            {
+                ReplyContent::Message(original_event.content.clone())
+            } else {
+                ReplyContent::Raw(raw_event)
+            }
+        }
+        AnySyncTimelineEvent::State(_) => return Err(ReplyError::StateEvent),
+    };
+
+    Ok(RepliedToInfo {
+        event_id: event_id.to_owned(),
+        sender: event.sender().to_owned(),
+        timestamp: event.origin_server_ts(),
+        content: reply_content,
+    })
+}
+
+#[cfg(test)]
+mod tests {
+    use std::collections::BTreeMap;
+
+    use assert_matches2::{assert_let, assert_matches};
+    use matrix_sdk_base::deserialized_responses::TimelineEvent;
+    use matrix_sdk_test::{async_test, event_factory::EventFactory};
+    use ruma::{
+        event_id,
+        events::{
+            room::message::{Relation, RoomMessageEventContentWithoutRelation},
+            AnyMessageLikeEventContent, AnySyncTimelineEvent,
+        },
+        room_id,
+        serde::Raw,
+        user_id, EventId, OwnedEventId,
+    };
+    use serde_json::json;
+
+    use super::{make_reply_event, EnforceThread, EventSource, ReplyError};
+
+    #[derive(Default)]
+    struct TestEventCache {
+        events: BTreeMap<OwnedEventId, TimelineEvent>,
+    }
+
+    impl EventSource for TestEventCache {
+        async fn get_event(&self, event_id: &EventId) -> Result<TimelineEvent, ReplyError> {
+            Ok(self.events.get(event_id).unwrap().clone())
+        }
+    }
+
+    #[async_test]
+    async fn test_reply_to_state_event() {
+        let event_id = event_id!("$1");
+        let own_user_id = user_id!("@me:saucisse.bzh");
+
+        let mut cache = TestEventCache::default();
+
+        cache.events.insert(
+            event_id.to_owned(),
+            // TODO: use the EventFactory for state events too.
path: crates/matrix-sdk/src/room/reply.rs
user: login: Johennes
body: Fixed here and in `edit.rs`.
created_at: 2025-03-26T18:28:31Z
author_association: CONTRIBUTOR

PR comments:
user: login: codecov[bot]
created_at: 2025-03-25T20:30:49Z
body: ## [Codecov](https://app.codecov.io/gh/matrix-org/matrix-rust-sdk/pull/4842?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=matrix-org) Report
Attention: Patch coverage is `60.49383%` with `32 lines` in your changes missing coverage. Please review.
> Project coverage is 86.52%. Comparing base [(`4f3b40d`)](https://app.codecov.io/gh/matrix-org/matrix-rust-sdk/commit/4f3b40d6fb0afa78f7eaec308e7207000d6310a8?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=matrix-org) to head [(`9790ed3`)](https://app.codecov.io/gh/matrix-org/matrix-rust-sdk/commit/9790ed31fbe1c65f92190e92f16e30afb09838e4?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=matrix-org).
> Report is 2 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/matrix-org/matrix-rust-sdk/pull/4842?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=matrix-org) | Patch % | Lines |
|---|---|---|
| [crates/matrix-sdk/src/room/reply.rs](https://app.codecov.io/gh/matrix-org/matrix-rust-sdk/pull/4842?src=pr&el=tree&filepath=crates%2Fmatrix-sdk%2Fsrc%2Froom%2Freply.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=matrix-org#diff-Y3JhdGVzL21hdHJpeC1zZGsvc3JjL3Jvb20vcmVwbHkucnM=) | 57.89% | [32 Missing :warning: ](https://app.codecov.io/gh/matrix-org/matrix-rust-sdk/pull/4842?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=matrix-org) |

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##             main    #4842   +/-   ##
=======================================
  Coverage   86.52%   86.52%           
=======================================
  Files         299      300    +1     
  Lines       34719    34729   +10     
=======================================
+ Hits        30042    30051    +9     
- Misses       4677     4678    +1     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/matrix-org/matrix-rust-sdk/pull/4842?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=matrix-org).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=matrix-org).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: Johennes
created_at: 2025-03-27T10:19:57Z
body: No worries at all. Your turnaround time on this is amazing. Thanks a ton! 👌
author_association: CONTRIBUTOR