From f3baf7efd279edb59649fedda3e89ba83ed32b02 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Thu, 27 Mar 2025 13:47:51 +0100 Subject: [PATCH] refactor(timeline): push the reply logic down into matrix_sdk (#4842) This achieves step 2 of #4835. Signed-off-by: Johannes Marbach --- bindings/matrix-sdk-ffi/src/timeline/mod.rs | 20 +- crates/matrix-sdk-ui/CHANGELOG.md | 5 + crates/matrix-sdk-ui/src/timeline/error.rs | 19 +- crates/matrix-sdk-ui/src/timeline/mod.rs | 222 +------ .../tests/integration/timeline/replies.rs | 28 +- crates/matrix-sdk/src/room/edit.rs | 50 +- crates/matrix-sdk/src/room/mod.rs | 19 +- crates/matrix-sdk/src/room/reply.rs | 590 ++++++++++++++++++ 8 files changed, 649 insertions(+), 304 deletions(-) create mode 100644 crates/matrix-sdk/src/room/reply.rs diff --git a/bindings/matrix-sdk-ffi/src/timeline/mod.rs b/bindings/matrix-sdk-ffi/src/timeline/mod.rs index 19c2eefe2..66742056d 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 84f365ec9..c7d7f5849 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 8794b4676..d86cb7ef1 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 26371492b..4e2821ce0 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), -} - -/// 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, - } - - let previous_content = - raw_event.get_field::("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 { - 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 9fa1a6dd2..bf024f131 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 0634c028b..53e58aba6 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> + SendOutsideWasm; -} - -impl EventSource for &Room { - async fn get_event(&self, event_id: &EventId) -> Result { - self.load_or_fetch_event(event_id, None) - .await - .map_err(|err| EditError::Fetch(Box::new(err))) - } -} - async fn make_edit_event( source: S, room_id: &RoomId, @@ -147,7 +130,7 @@ async fn make_edit_event( event_id: &EventId, new_content: EditedContent, ) -> Result { - 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 { + async fn get_event(&self, event_id: &EventId) -> Result { 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::::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 7a8bb0cdf..133d16080 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 for ReportedContentScore { } } +trait EventSource { + fn get_event( + &self, + event_id: &EventId, + ) -> impl Future> + SendOutsideWasm; +} + +impl EventSource for &Room { + async fn get_event(&self, event_id: &EventId) -> Result { + 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 000000000..fcb81b5bf --- /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), +} + +/// 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), + /// 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 { + make_reply_event( + self, + self.room_id(), + self.own_user_id(), + content, + event_id, + enforce_thread, + ) + .await + } +} + +async fn make_reply_event( + source: S, + room_id: &RoomId, + own_user_id: &UserId, + content: RoomMessageEventContentWithoutRelation, + event_id: &EventId, + enforce_thread: EnforceThread, +) -> Result { + 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, + } + + let previous_content = + raw_event.get_field::("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( + source: S, + event_id: &EventId, +) -> Result { + 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, + } + + impl EventSource for TestEventCache { + async fn get_event(&self, event_id: &EventId) -> Result { + 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::::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); + } +}