refactor(timeline): push the reply logic down into matrix_sdk (#4842)

This achieves step 2 of #4835.

Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
This commit is contained in:
Johannes Marbach
2025-03-27 13:47:51 +01:00
committed by GitHub
parent f6e223edf6
commit f3baf7efd2
8 changed files with 649 additions and 304 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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")]

View File

@@ -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()`]

View File

@@ -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

View File

@@ -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");

View File

@@ -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)]

View File

@@ -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);
}
}