diff --git a/crates/ruma-events/CHANGELOG.md b/crates/ruma-events/CHANGELOG.md index f197efbf..8caaa949 100644 --- a/crates/ruma-events/CHANGELOG.md +++ b/crates/ruma-events/CHANGELOG.md @@ -63,6 +63,7 @@ Improvements: - `RedactedRoomRedactionEventContent`, - `RedactedRoomPowerLevelsEventContent`, - `RedactedRoomMemberEventContent` +- Add `RoomMessageEventContent::make_reply_to_raw` to build replies to any event # 0.26.1 diff --git a/crates/ruma-events/src/room/message.rs b/crates/ruma-events/src/room/message.rs index 56f93240..41fe8c61 100644 --- a/crates/ruma-events/src/room/message.rs +++ b/crates/ruma-events/src/room/message.rs @@ -2,11 +2,12 @@ //! //! [`m.room.message`]: https://spec.matrix.org/latest/client-server-api/#mroommessage -use std::borrow::Cow; +use std::{borrow::Cow, collections::BTreeSet}; +use as_variant::as_variant; use ruma_common::{ - serde::{JsonObject, StringEnum}, - EventId, OwnedEventId, + serde::{JsonObject, Raw, StringEnum}, + EventId, OwnedEventId, OwnedUserId, RoomId, UserId, }; #[cfg(feature = "html")] use ruma_html::{sanitize_html, HtmlSanitizerMode, RemoveReplyFallback}; @@ -16,7 +17,7 @@ use serde_json::Value as JsonValue; use crate::{ relation::{CustomRelation, InReplyTo, RelationType, Replacement, Thread}, - Mentions, PrivOwnedStr, + AnySyncTimelineEvent, Mentions, PrivOwnedStr, }; mod audio; @@ -50,6 +51,8 @@ pub use server_notice::{LimitType, ServerNoticeMessageEventContent, ServerNotice pub use text::TextMessageEventContent; pub use video::{VideoInfo, VideoMessageEventContent}; +use self::reply::OriginalEventData; + /// The content of an `m.room.message` event. /// /// This event is used when sending messages in a room. @@ -149,11 +152,132 @@ impl RoomMessageEventContent { /// Panics if `self` has a `formatted_body` with a format other than HTML. #[track_caller] pub fn make_reply_to( - mut self, + self, original_message: &OriginalRoomMessageEvent, forward_thread: ForwardThread, add_mentions: AddMentions, ) -> Self { + let reply = self.make_reply_fallback(original_message.into()); + + let original_thread_id = if forward_thread == ForwardThread::Yes { + original_message + .content + .relates_to + .as_ref() + .and_then(as_variant!(Relation::Thread)) + .map(|thread| thread.event_id.clone()) + } else { + None + }; + + let original_user_mentions = (add_mentions == AddMentions::Yes).then(|| { + original_message + .content + .mentions + .as_ref() + .map(|m| m.user_ids.clone()) + .unwrap_or_default() + }); + + reply.make_reply_tweaks( + original_message.event_id.clone(), + original_thread_id, + original_user_mentions, + Some(&original_message.sender), + ) + } + + /// Turns `self` into a reply to the given raw event. + /// + /// Takes the `body` / `formatted_body` (if any) in `self` for the main text and prepends a + /// quoted version of the `body` of `original_event` (if any). Also sets the `in_reply_to` field + /// inside `relates_to`, and optionally the `rel_type` to `m.thread` if the + /// `original_message is in a thread and thread forwarding is enabled. + /// + /// It is recommended to use [`Self::make_reply_to()`] for replies to `m.room.message` events, + /// as the generated fallback is better for some `msgtype`s. + /// + /// Note that except for the panic below, this is infallible. Which means that if a field is + /// missing when deserializing the data, the changes that require it will not be applied. It + /// will still at least apply the `m.in_reply_to` relation to this content. + /// + /// # Panics + /// + /// Panics if `self` has a `formatted_body` with a format other than HTML. + #[track_caller] + pub fn make_reply_to_raw( + self, + original_event: &Raw, + original_event_id: OwnedEventId, + room_id: &RoomId, + forward_thread: ForwardThread, + add_mentions: AddMentions, + ) -> Self { + #[derive(Deserialize)] + struct ContentDeHelper { + body: Option, + #[serde(flatten)] + formatted: Option, + #[cfg(feature = "unstable-msc1767")] + #[serde(rename = "org.matrix.msc1767")] + text: Option, + #[serde(rename = "m.relates_to")] + relates_to: Option, + #[serde(rename = "m.mentions")] + mentions: Option, + } + + let sender = original_event.get_field::("sender").ok().flatten(); + let content = original_event.get_field::("content").ok().flatten(); + let relates_to = content.as_ref().and_then(|c| c.relates_to.as_ref()); + + let content_body = content.as_ref().and_then(|c| { + let body = c.body.as_deref(); + #[cfg(feature = "unstable-msc1767")] + let body = body.or(c.text.as_deref()); + + Some((c, body?)) + }); + + // Only apply fallback if we managed to deserialize raw event. + let reply = if let (Some(sender), Some((content, body))) = (&sender, content_body) { + let is_reply = + matches!(content.relates_to, Some(crate::room::encrypted::Relation::Reply { .. })); + let data = OriginalEventData { + body, + formatted: content.formatted.as_ref(), + is_emote: false, + is_reply, + room_id, + event_id: &original_event_id, + sender, + }; + + self.make_reply_fallback(data) + } else { + self + }; + + let original_thread_id = if forward_thread == ForwardThread::Yes { + relates_to + .and_then(as_variant!(crate::room::encrypted::Relation::Thread)) + .map(|thread| thread.event_id.clone()) + } else { + None + }; + + let original_user_mentions = (add_mentions == AddMentions::Yes) + .then(|| content.and_then(|c| c.mentions).map(|m| m.user_ids).unwrap_or_default()); + + reply.make_reply_tweaks( + original_event_id, + original_thread_id, + original_user_mentions, + sender.as_deref(), + ) + } + + fn make_reply_fallback(mut self, original_event: OriginalEventData<'_>) -> Self { let empty_formatted_body = || FormattedBody::html(String::new()); let (body, formatted) = { @@ -190,32 +314,30 @@ impl RoomMessageEventContent { (*body, *formatted_body) = reply::plain_and_formatted_reply_body( body.as_str(), (!formatted_body.is_empty()).then_some(formatted_body.as_str()), - original_message, + original_event, ); } - let relates_to = if let Some(Relation::Thread(Thread { event_id, .. })) = original_message - .content - .relates_to - .as_ref() - .filter(|_| forward_thread == ForwardThread::Yes) - { - Relation::Thread(Thread::plain(event_id.clone(), original_message.event_id.clone())) + self + } + + fn make_reply_tweaks( + mut self, + original_event_id: OwnedEventId, + original_thread_id: Option, + original_user_mentions: Option>, + original_sender: Option<&UserId>, + ) -> Self { + let relates_to = if let Some(event_id) = original_thread_id { + Relation::Thread(Thread::plain(event_id.to_owned(), original_event_id.to_owned())) } else { - Relation::Reply { - in_reply_to: InReplyTo { event_id: original_message.event_id.clone() }, - } + Relation::Reply { in_reply_to: InReplyTo { event_id: original_event_id.to_owned() } } }; self.relates_to = Some(relates_to); - if add_mentions == AddMentions::Yes { - // Copy the mentioned users. - let mut user_ids = match &original_message.content.mentions { - Some(m) => m.user_ids.clone(), - None => Default::default(), - }; + if let (Some(sender), Some(mut user_ids)) = (original_sender, original_user_mentions) { // Add the sender. - user_ids.insert(original_message.sender.clone()); + user_ids.insert(sender.to_owned()); self.mentions = Some(Mentions { user_ids, ..Default::default() }); } diff --git a/crates/ruma-events/src/room/message/reply.rs b/crates/ruma-events/src/room/message/reply.rs index e1ca4f2e..0f08e034 100644 --- a/crates/ruma-events/src/room/message/reply.rs +++ b/crates/ruma-events/src/room/message/reply.rs @@ -1,5 +1,6 @@ use std::fmt::{self, Write}; +use ruma_common::{EventId, RoomId, UserId}; #[cfg(feature = "html")] use ruma_html::Html; @@ -8,47 +9,64 @@ use super::{ Relation, }; -fn get_message_quote_fallbacks(original_message: &OriginalRoomMessageEvent) -> (String, String) { - let get_quotes = |body: &str, formatted: Option<&FormattedBody>, is_emote: bool| { - let OriginalRoomMessageEvent { room_id, event_id, sender, content, .. } = original_message; +pub(super) struct OriginalEventData<'a> { + pub(super) body: &'a str, + pub(super) formatted: Option<&'a FormattedBody>, + pub(super) is_emote: bool, + pub(super) is_reply: bool, + pub(super) room_id: &'a RoomId, + pub(super) event_id: &'a EventId, + pub(super) sender: &'a UserId, +} + +impl<'a> From<&'a OriginalRoomMessageEvent> for OriginalEventData<'a> { + fn from(message: &'a OriginalRoomMessageEvent) -> Self { + let OriginalRoomMessageEvent { room_id, event_id, sender, content, .. } = message; let is_reply = matches!(content.relates_to, Some(Relation::Reply { .. })); - let emote_sign = is_emote.then_some("* ").unwrap_or_default(); - let body = is_reply.then(|| remove_plain_reply_fallback(body)).unwrap_or(body); - #[cfg(feature = "html")] - let html_body = FormattedOrPlainBody { formatted, body, is_reply }; - #[cfg(not(feature = "html"))] - let html_body = FormattedOrPlainBody { formatted, body }; - ( - format!("> {emote_sign}<{sender}> {body}").replace('\n', "\n> "), - format!( - "\ -
\ - In reply to \ - {emote_sign}{sender}\ -
\ - {html_body}\ -
\ -
" - ), - ) - }; + let (body, formatted, is_emote) = match &content.msgtype { + MessageType::Audio(_) => ("sent an audio file.", None, false), + MessageType::Emote(c) => (&*c.body, c.formatted.as_ref(), true), + MessageType::File(_) => ("sent a file.", None, false), + MessageType::Image(_) => ("sent an image.", None, false), + MessageType::Location(_) => ("sent a location.", None, false), + MessageType::Notice(c) => (&*c.body, c.formatted.as_ref(), false), + MessageType::ServerNotice(c) => (&*c.body, None, false), + MessageType::Text(c) => (&*c.body, c.formatted.as_ref(), false), + MessageType::Video(_) => ("sent a video.", None, false), + MessageType::VerificationRequest(c) => (&*c.body, None, false), + MessageType::_Custom(c) => (&*c.body, None, false), + }; - match &original_message.content.msgtype { - MessageType::Audio(_) => get_quotes("sent an audio file.", None, false), - MessageType::Emote(c) => get_quotes(&c.body, c.formatted.as_ref(), true), - MessageType::File(_) => get_quotes("sent a file.", None, false), - MessageType::Image(_) => get_quotes("sent an image.", None, false), - MessageType::Location(_) => get_quotes("sent a location.", None, false), - MessageType::Notice(c) => get_quotes(&c.body, c.formatted.as_ref(), false), - MessageType::ServerNotice(c) => get_quotes(&c.body, None, false), - MessageType::Text(c) => get_quotes(&c.body, c.formatted.as_ref(), false), - MessageType::Video(_) => get_quotes("sent a video.", None, false), - MessageType::VerificationRequest(content) => get_quotes(&content.body, None, false), - MessageType::_Custom(content) => get_quotes(&content.body, None, false), + Self { body, formatted, is_emote, is_reply, room_id, event_id, sender } } } +fn get_message_quote_fallbacks(original_event: OriginalEventData<'_>) -> (String, String) { + let OriginalEventData { body, formatted, is_emote, is_reply, room_id, event_id, sender } = + original_event; + let emote_sign = is_emote.then_some("* ").unwrap_or_default(); + let body = is_reply.then(|| remove_plain_reply_fallback(body)).unwrap_or(body); + #[cfg(feature = "html")] + let html_body = FormattedOrPlainBody { formatted, body, is_reply }; + #[cfg(not(feature = "html"))] + let html_body = FormattedOrPlainBody { formatted, body }; + + ( + format!("> {emote_sign}<{sender}> {body}").replace('\n', "\n> "), + format!( + "\ +
\ + In reply to \ + {emote_sign}{sender}\ +
\ + {html_body}\ +
\ +
" + ), + ) +} + struct EscapeHtmlEntities<'a>(&'a str); impl fmt::Display for EscapeHtmlEntities<'_> { @@ -108,12 +126,12 @@ impl fmt::Display for FormattedOrPlainBody<'_> { /// /// [HTML tags and attributes]: https://spec.matrix.org/latest/client-server-api/#mroommessage-msgtypes /// [rich reply fallbacks]: https://spec.matrix.org/latest/client-server-api/#fallbacks-for-rich-replies -pub(crate) fn plain_and_formatted_reply_body( +pub(super) fn plain_and_formatted_reply_body( body: &str, formatted: Option, - original_message: &OriginalRoomMessageEvent, + original_event: OriginalEventData<'_>, ) -> (String, String) { - let (quoted, quoted_html) = get_message_quote_fallbacks(original_message); + let (quoted, quoted_html) = get_message_quote_fallbacks(original_event); let plain = format!("{quoted}\n\n{body}"); let html = match formatted { @@ -133,15 +151,17 @@ mod tests { #[test] fn fallback_multiline() { - let (plain_quote, html_quote) = - super::get_message_quote_fallbacks(&OriginalRoomMessageEvent { + let (plain_quote, html_quote) = super::get_message_quote_fallbacks( + (&OriginalRoomMessageEvent { content: RoomMessageEventContent::text_plain("multi\nline"), event_id: owned_event_id!("$1598361704261elfgc:localhost"), sender: owned_user_id!("@alice:example.com"), origin_server_ts: MilliSecondsSinceUnixEpoch::now(), room_id: owned_room_id!("!n8f893n9:example.com"), unsigned: MessageLikeUnsigned::new(), - }); + }) + .into(), + ); assert_eq!(plain_quote, "> <@alice:example.com> multi\n> line"); assert_eq!( diff --git a/crates/ruma-events/tests/it/room_message.rs b/crates/ruma-events/tests/it/room_message.rs index eda6b6bc..9f5d3aeb 100644 --- a/crates/ruma-events/tests/it/room_message.rs +++ b/crates/ruma-events/tests/it/room_message.rs @@ -1,10 +1,11 @@ -use std::borrow::Cow; +use std::{borrow::Cow, collections::BTreeSet}; use assert_matches2::assert_matches; use js_int::uint; use ruma_common::{ - mxc_uri, owned_event_id, owned_room_id, owned_user_id, serde::Base64, user_id, - MilliSecondsSinceUnixEpoch, OwnedDeviceId, + mxc_uri, owned_event_id, owned_room_id, owned_user_id, room_id, + serde::{Base64, Raw}, + user_id, MilliSecondsSinceUnixEpoch, OwnedDeviceId, }; use ruma_events::{ key::verification::VerificationMethod, @@ -18,7 +19,7 @@ use ruma_events::{ }, EncryptedFileInit, JsonWebKeyInit, MediaSource, }, - Mentions, MessageLikeUnsigned, + AnySyncTimelineEvent, Mentions, MessageLikeUnsigned, }; use serde_json::{from_value as from_json_value, json, to_value as to_json_value}; @@ -483,6 +484,215 @@ fn reply_add_mentions() { assert!(mentions.room); } +#[test] +fn reply_to_raw() { + let room_id = room_id!("!roomid:notareal.hs"); + let event_id = owned_event_id!("$143273582443PhrSn"); + + let original_message: Raw = from_json_value(json!({ + "content": { + "body": "Hello, World!", + "msgtype": "m.text", + }, + "event_id": event_id, + "origin_server_ts": 134_829_848, + "sender": "@user:notareal.hs", + "type": "m.room.message", + })) + .unwrap(); + + let reply = RoomMessageEventContent::text_html( + "This is **my** reply", + "This is my reply", + ) + .make_reply_to_raw( + &original_message, + event_id.clone(), + room_id, + ForwardThread::Yes, + AddMentions::No, + ); + + assert_matches!(reply.relates_to, Some(Relation::Reply { in_reply_to })); + assert_eq!(in_reply_to.event_id, event_id); + + assert_matches!(reply.msgtype, MessageType::Text(text_msg)); + assert_eq!( + text_msg.body, + "> <@user:notareal.hs> Hello, World!\n\ + \n\ + This is **my** reply" + ); + assert_eq!( + text_msg.formatted.unwrap().body, + "\ +
\ + In reply to \ + @user:notareal.hs\ +
\ + Hello, World!\ +
\ +
\ + This is my reply" + ); +} + +#[test] +fn reply_to_raw_no_body() { + let room_id = room_id!("!roomid:notareal.hs"); + let event_id = owned_event_id!("$143273582443PhrSn"); + + let original_message: Raw = from_json_value(json!({ + "content": {}, + "event_id": event_id, + "origin_server_ts": 134_829_848, + "sender": "@user:notareal.hs", + "type": "m.room.message", + })) + .unwrap(); + + let reply = RoomMessageEventContent::text_html( + "This is **my** reply", + "This is my reply", + ) + .make_reply_to_raw( + &original_message, + event_id.clone(), + room_id, + ForwardThread::Yes, + AddMentions::No, + ); + + assert_matches!(reply.relates_to, Some(Relation::Reply { in_reply_to })); + assert_eq!(in_reply_to.event_id, event_id); + + assert_matches!(reply.msgtype, MessageType::Text(text_msg)); + assert_eq!(text_msg.body, "This is **my** reply"); + assert_eq!(text_msg.formatted.unwrap().body, "This is my reply"); +} + +#[test] +fn reply_to_raw_no_sender() { + let room_id = room_id!("!roomid:notareal.hs"); + let event_id = owned_event_id!("$143273582443PhrSn"); + + let original_message: Raw = from_json_value(json!({ + "content": { + "body": "Hello, World!", + "msgtype": "m.text", + }, + "event_id": event_id, + "origin_server_ts": 134_829_848, + "type": "m.room.message", + })) + .unwrap(); + + let reply = RoomMessageEventContent::text_html( + "This is **my** reply", + "This is my reply", + ) + .make_reply_to_raw( + &original_message, + event_id.clone(), + room_id, + ForwardThread::Yes, + AddMentions::No, + ); + + assert_matches!(reply.relates_to, Some(Relation::Reply { in_reply_to })); + assert_eq!(in_reply_to.event_id, event_id); + + assert_matches!(reply.msgtype, MessageType::Text(text_msg)); + assert_eq!(text_msg.body, "This is **my** reply"); + assert_eq!(text_msg.formatted.unwrap().body, "This is my reply"); +} + +#[test] +fn reply_to_raw_forward_thread() { + let room_id = room_id!("!roomid:notareal.hs"); + let event_id = owned_event_id!("$143273582443PhrSn"); + + let original_message: Raw = from_json_value(json!({ + "content": { + "m.relates_to": { + "rel_type": "m.thread", + "event_id": "$threadroot", + "m.in_reply_to": { + "event_id": "$repliedto", + }, + }, + }, + "event_id": event_id, + "origin_server_ts": 134_829_848, + "sender": "@user:notareal.hs", + "type": "m.room.message", + })) + .unwrap(); + + let reply = RoomMessageEventContent::text_html( + "This is **my** reply", + "This is my reply", + ) + .make_reply_to_raw( + &original_message, + event_id.clone(), + room_id, + ForwardThread::Yes, + AddMentions::No, + ); + + assert_matches!(reply.relates_to, Some(Relation::Thread(thread))); + assert_eq!(thread.event_id, "$threadroot"); + assert_eq!(thread.in_reply_to.unwrap().event_id, event_id); + + assert_matches!(reply.msgtype, MessageType::Text(text_msg)); + assert_eq!(text_msg.body, "This is **my** reply"); + assert_eq!(text_msg.formatted.unwrap().body, "This is my reply"); +} + +#[test] +fn reply_to_raw_add_mentions() { + let room_id = room_id!("!roomid:notareal.hs"); + let event_id = owned_event_id!("$143273582443PhrSn"); + + let user_id = owned_user_id!("@user:notareal.hs"); + let other_user_id = owned_user_id!("@other_user:notareal.hs"); + + let original_message: Raw = from_json_value(json!({ + "content": { + "m.mentions": { + "user_ids": [other_user_id], + }, + }, + "event_id": event_id, + "origin_server_ts": 134_829_848, + "sender": user_id, + "type": "m.room.message", + })) + .unwrap(); + + let reply = RoomMessageEventContent::text_html( + "This is **my** reply", + "This is my reply", + ) + .make_reply_to_raw( + &original_message, + event_id.clone(), + room_id, + ForwardThread::Yes, + AddMentions::Yes, + ); + + assert_matches!(reply.relates_to, Some(Relation::Reply { in_reply_to })); + assert_eq!(in_reply_to.event_id, event_id); + + assert_matches!(reply.msgtype, MessageType::Text(text_msg)); + assert_eq!(text_msg.body, "This is **my** reply"); + assert_eq!(text_msg.formatted.unwrap().body, "This is my reply"); + + assert_eq!(reply.mentions.unwrap().user_ids, BTreeSet::from([user_id, other_user_id])); +} + #[test] fn make_replacement_no_reply() { let content = RoomMessageEventContent::text_html(