events: Allow to build reply to raw events

This commit is contained in:
Kévin Commaille 2023-08-29 17:05:23 +02:00 committed by Kévin Commaille
parent bc48eb2162
commit 5040aa2a93
4 changed files with 421 additions and 68 deletions

View File

@ -63,6 +63,7 @@ Improvements:
- `RedactedRoomRedactionEventContent`, - `RedactedRoomRedactionEventContent`,
- `RedactedRoomPowerLevelsEventContent`, - `RedactedRoomPowerLevelsEventContent`,
- `RedactedRoomMemberEventContent` - `RedactedRoomMemberEventContent`
- Add `RoomMessageEventContent::make_reply_to_raw` to build replies to any event
# 0.26.1 # 0.26.1

View File

@ -2,11 +2,12 @@
//! //!
//! [`m.room.message`]: https://spec.matrix.org/latest/client-server-api/#mroommessage //! [`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::{ use ruma_common::{
serde::{JsonObject, StringEnum}, serde::{JsonObject, Raw, StringEnum},
EventId, OwnedEventId, EventId, OwnedEventId, OwnedUserId, RoomId, UserId,
}; };
#[cfg(feature = "html")] #[cfg(feature = "html")]
use ruma_html::{sanitize_html, HtmlSanitizerMode, RemoveReplyFallback}; use ruma_html::{sanitize_html, HtmlSanitizerMode, RemoveReplyFallback};
@ -16,7 +17,7 @@ use serde_json::Value as JsonValue;
use crate::{ use crate::{
relation::{CustomRelation, InReplyTo, RelationType, Replacement, Thread}, relation::{CustomRelation, InReplyTo, RelationType, Replacement, Thread},
Mentions, PrivOwnedStr, AnySyncTimelineEvent, Mentions, PrivOwnedStr,
}; };
mod audio; mod audio;
@ -50,6 +51,8 @@ pub use server_notice::{LimitType, ServerNoticeMessageEventContent, ServerNotice
pub use text::TextMessageEventContent; pub use text::TextMessageEventContent;
pub use video::{VideoInfo, VideoMessageEventContent}; pub use video::{VideoInfo, VideoMessageEventContent};
use self::reply::OriginalEventData;
/// The content of an `m.room.message` event. /// The content of an `m.room.message` event.
/// ///
/// This event is used when sending messages in a room. /// 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. /// Panics if `self` has a `formatted_body` with a format other than HTML.
#[track_caller] #[track_caller]
pub fn make_reply_to( pub fn make_reply_to(
mut self, self,
original_message: &OriginalRoomMessageEvent, original_message: &OriginalRoomMessageEvent,
forward_thread: ForwardThread, forward_thread: ForwardThread,
add_mentions: AddMentions, add_mentions: AddMentions,
) -> Self { ) -> 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<AnySyncTimelineEvent>,
original_event_id: OwnedEventId,
room_id: &RoomId,
forward_thread: ForwardThread,
add_mentions: AddMentions,
) -> Self {
#[derive(Deserialize)]
struct ContentDeHelper {
body: Option<String>,
#[serde(flatten)]
formatted: Option<FormattedBody>,
#[cfg(feature = "unstable-msc1767")]
#[serde(rename = "org.matrix.msc1767")]
text: Option<String>,
#[serde(rename = "m.relates_to")]
relates_to: Option<crate::room::encrypted::Relation>,
#[serde(rename = "m.mentions")]
mentions: Option<Mentions>,
}
let sender = original_event.get_field::<OwnedUserId>("sender").ok().flatten();
let content = original_event.get_field::<ContentDeHelper>("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 empty_formatted_body = || FormattedBody::html(String::new());
let (body, formatted) = { let (body, formatted) = {
@ -190,32 +314,30 @@ impl RoomMessageEventContent {
(*body, *formatted_body) = reply::plain_and_formatted_reply_body( (*body, *formatted_body) = reply::plain_and_formatted_reply_body(
body.as_str(), body.as_str(),
(!formatted_body.is_empty()).then_some(formatted_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 self
.content
.relates_to
.as_ref()
.filter(|_| forward_thread == ForwardThread::Yes)
{
Relation::Thread(Thread::plain(event_id.clone(), original_message.event_id.clone()))
} else {
Relation::Reply {
in_reply_to: InReplyTo { event_id: original_message.event_id.clone() },
} }
fn make_reply_tweaks(
mut self,
original_event_id: OwnedEventId,
original_thread_id: Option<OwnedEventId>,
original_user_mentions: Option<BTreeSet<OwnedUserId>>,
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_event_id.to_owned() } }
}; };
self.relates_to = Some(relates_to); self.relates_to = Some(relates_to);
if add_mentions == AddMentions::Yes { if let (Some(sender), Some(mut user_ids)) = (original_sender, original_user_mentions) {
// Copy the mentioned users.
let mut user_ids = match &original_message.content.mentions {
Some(m) => m.user_ids.clone(),
None => Default::default(),
};
// Add the sender. // Add the sender.
user_ids.insert(original_message.sender.clone()); user_ids.insert(sender.to_owned());
self.mentions = Some(Mentions { user_ids, ..Default::default() }); self.mentions = Some(Mentions { user_ids, ..Default::default() });
} }

View File

@ -1,5 +1,6 @@
use std::fmt::{self, Write}; use std::fmt::{self, Write};
use ruma_common::{EventId, RoomId, UserId};
#[cfg(feature = "html")] #[cfg(feature = "html")]
use ruma_html::Html; use ruma_html::Html;
@ -8,10 +9,42 @@ use super::{
Relation, Relation,
}; };
fn get_message_quote_fallbacks(original_message: &OriginalRoomMessageEvent) -> (String, String) { pub(super) struct OriginalEventData<'a> {
let get_quotes = |body: &str, formatted: Option<&FormattedBody>, is_emote: bool| { pub(super) body: &'a str,
let OriginalRoomMessageEvent { room_id, event_id, sender, content, .. } = original_message; 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 is_reply = matches!(content.relates_to, Some(Relation::Reply { .. }));
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),
};
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 emote_sign = is_emote.then_some("* ").unwrap_or_default();
let body = is_reply.then(|| remove_plain_reply_fallback(body)).unwrap_or(body); let body = is_reply.then(|| remove_plain_reply_fallback(body)).unwrap_or(body);
#[cfg(feature = "html")] #[cfg(feature = "html")]
@ -32,21 +65,6 @@ fn get_message_quote_fallbacks(original_message: &OriginalRoomMessageEvent) -> (
</mx-reply>" </mx-reply>"
), ),
) )
};
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),
}
} }
struct EscapeHtmlEntities<'a>(&'a str); struct EscapeHtmlEntities<'a>(&'a str);
@ -108,12 +126,12 @@ impl fmt::Display for FormattedOrPlainBody<'_> {
/// ///
/// [HTML tags and attributes]: https://spec.matrix.org/latest/client-server-api/#mroommessage-msgtypes /// [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 /// [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, body: &str,
formatted: Option<impl fmt::Display>, formatted: Option<impl fmt::Display>,
original_message: &OriginalRoomMessageEvent, original_event: OriginalEventData<'_>,
) -> (String, String) { ) -> (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 plain = format!("{quoted}\n\n{body}");
let html = match formatted { let html = match formatted {
@ -133,15 +151,17 @@ mod tests {
#[test] #[test]
fn fallback_multiline() { fn fallback_multiline() {
let (plain_quote, html_quote) = let (plain_quote, html_quote) = super::get_message_quote_fallbacks(
super::get_message_quote_fallbacks(&OriginalRoomMessageEvent { (&OriginalRoomMessageEvent {
content: RoomMessageEventContent::text_plain("multi\nline"), content: RoomMessageEventContent::text_plain("multi\nline"),
event_id: owned_event_id!("$1598361704261elfgc:localhost"), event_id: owned_event_id!("$1598361704261elfgc:localhost"),
sender: owned_user_id!("@alice:example.com"), sender: owned_user_id!("@alice:example.com"),
origin_server_ts: MilliSecondsSinceUnixEpoch::now(), origin_server_ts: MilliSecondsSinceUnixEpoch::now(),
room_id: owned_room_id!("!n8f893n9:example.com"), room_id: owned_room_id!("!n8f893n9:example.com"),
unsigned: MessageLikeUnsigned::new(), unsigned: MessageLikeUnsigned::new(),
}); })
.into(),
);
assert_eq!(plain_quote, "> <@alice:example.com> multi\n> line"); assert_eq!(plain_quote, "> <@alice:example.com> multi\n> line");
assert_eq!( assert_eq!(

View File

@ -1,10 +1,11 @@
use std::borrow::Cow; use std::{borrow::Cow, collections::BTreeSet};
use assert_matches2::assert_matches; use assert_matches2::assert_matches;
use js_int::uint; use js_int::uint;
use ruma_common::{ use ruma_common::{
mxc_uri, owned_event_id, owned_room_id, owned_user_id, serde::Base64, user_id, mxc_uri, owned_event_id, owned_room_id, owned_user_id, room_id,
MilliSecondsSinceUnixEpoch, OwnedDeviceId, serde::{Base64, Raw},
user_id, MilliSecondsSinceUnixEpoch, OwnedDeviceId,
}; };
use ruma_events::{ use ruma_events::{
key::verification::VerificationMethod, key::verification::VerificationMethod,
@ -18,7 +19,7 @@ use ruma_events::{
}, },
EncryptedFileInit, JsonWebKeyInit, MediaSource, EncryptedFileInit, JsonWebKeyInit, MediaSource,
}, },
Mentions, MessageLikeUnsigned, AnySyncTimelineEvent, Mentions, MessageLikeUnsigned,
}; };
use serde_json::{from_value as from_json_value, json, to_value as to_json_value}; 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); 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<AnySyncTimelineEvent> = 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 <strong>my</strong> 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,
"<mx-reply>\
<blockquote>\
<a href=\"https://matrix.to/#/!roomid:notareal.hs/$143273582443PhrSn\">In reply to</a> \
<a href=\"https://matrix.to/#/@user:notareal.hs\">@user:notareal.hs</a>\
<br>\
Hello, World!\
</blockquote>\
</mx-reply>\
This is <strong>my</strong> 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<AnySyncTimelineEvent> = 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 <strong>my</strong> 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 <strong>my</strong> 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<AnySyncTimelineEvent> = 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 <strong>my</strong> 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 <strong>my</strong> 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<AnySyncTimelineEvent> = 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 <strong>my</strong> 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 <strong>my</strong> 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<AnySyncTimelineEvent> = 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 <strong>my</strong> 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 <strong>my</strong> reply");
assert_eq!(reply.mentions.unwrap().user_ids, BTreeSet::from([user_id, other_user_id]));
}
#[test] #[test]
fn make_replacement_no_reply() { fn make_replacement_no_reply() {
let content = RoomMessageEventContent::text_html( let content = RoomMessageEventContent::text_html(