events: Do not generate reply fallbacks anymore

According to MSC2781.

The helper methods for the relevant relations have been simplified to
only take the necessary data.
This commit is contained in:
Kévin Commaille 2024-12-01 13:00:50 +01:00 committed by strawberry
parent ea0cdc6318
commit 3321286242
6 changed files with 139 additions and 908 deletions

View File

@ -1,5 +1,26 @@
# [unreleased]
- Reply fallbacks are not generated anymore, according to MSC2781 / Matrix 1.13.
As a result, the following methods of `RoomMessageEventContent(WithoutRelation)`
were simplified:
- `make_reply_to` and `make_reply_to_raw` have been merged into
`make_reply_to`. It takes a `ReplyMetadata`, that can be virtually
constructed from any event and includes built-in conversions for room
message events.
- `make_for_thread` also takes a `ReplyMetadata` instead of a room message
event.
- `make_replacement` does not take the replied-to message anymore.
# 0.30.0
Breaking changes:
- Take newly introduced `DirectUserIdentifier(str)` as a key for `DirectEventContent`.
This change allows to have an email or MSISDN phone number as a key for example,
which can be used when issuing invites through third-party systems.
`DirectUserIdentifier` can easily be converted to an `UserId`.
Improvements:
- Add unstable support for MSC4171 for the m.member_hints state event.

View File

@ -1,18 +0,0 @@
<!-- Keep this comment so the content is always included as a new paragraph -->
This function requires an [`OriginalRoomMessageEvent`] since it creates a permalink to
the previous message, for which the room ID is required. If you want to reply to an
[`OriginalSyncRoomMessageEvent`], you have to convert it first by calling [`.into_full_event()`].
If the message was edited, the previous message should be the original message that was edited,
with the content of its replacement, to allow the fallback to be accurate at the time it is added.
It is recommended to enable the `html` feature when using this method as this will
clean up nested [rich reply fallbacks] in chains of replies. This uses [`sanitize_html()`]
internally, with [`RemoveReplyFallback::Yes`].
[`OriginalRoomMessageEvent`]: crate::room::message::OriginalRoomMessageEvent
[`OriginalSyncRoomMessageEvent`]: crate::room::message::OriginalSyncRoomMessageEvent
[`.into_full_event()`]: crate::OriginalSyncMessageLikeEvent::into_full_event
[rich reply fallbacks]: https://spec.matrix.org/latest/client-server-api/#fallbacks-for-rich-replies
[`sanitize_html()`]: ruma_html::sanitize_html
[`RemoveReplyFallback::Yes`]: ruma_html::RemoveReplyFallback::Yes

View File

@ -4,9 +4,10 @@
use std::borrow::Cow;
use as_variant::as_variant;
use ruma_common::{
serde::{JsonObject, Raw, StringEnum},
OwnedEventId, RoomId,
serde::{JsonObject, StringEnum},
EventId, OwnedEventId, UserId,
};
#[cfg(feature = "html")]
use ruma_html::{sanitize_html, HtmlSanitizerMode, RemoveReplyFallback};
@ -15,12 +16,11 @@ use serde::{de::DeserializeOwned, Deserialize, Serialize};
use serde_json::Value as JsonValue;
use tracing::warn;
use self::reply::OriginalEventData;
#[cfg(feature = "html")]
use self::sanitize::remove_plain_reply_fallback;
use crate::{
relation::{InReplyTo, Replacement, Thread},
AnySyncTimelineEvent, Mentions, PrivOwnedStr,
Mentions, PrivOwnedStr,
};
mod audio;
@ -34,7 +34,6 @@ mod media_caption;
mod notice;
mod relation;
pub(crate) mod relation_serde;
mod reply;
pub mod sanitize;
mod server_notice;
mod text;
@ -151,84 +150,45 @@ impl RoomMessageEventContent {
Self::new(MessageType::emote_markdown(body))
}
/// Turns `self` into a reply to the given message.
/// Turns `self` into a [rich reply] to the message using the given metadata.
///
/// Takes the `body` / `formatted_body` (if any) in `self` for the main text and prepends a
/// quoted version of `original_message`. 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.
#[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/rich_reply.md"))]
/// Sets the `in_reply_to` field inside `relates_to`, and optionally the `rel_type` to
/// `m.thread` if the metadata has a `thread` and `ForwardThread::Yes` is used.
///
/// # Panics
/// If `AddMentions::Yes` is used, the `sender` in the metadata is added as a user mention.
///
/// Panics if `self` has a `formatted_body` with a format other than HTML.
/// [rich reply]: https://spec.matrix.org/latest/client-server-api/#rich-replies
#[track_caller]
pub fn make_reply_to(
pub fn make_reply_to<'a>(
self,
original_message: &OriginalRoomMessageEvent,
metadata: impl Into<ReplyMetadata<'a>>,
forward_thread: ForwardThread,
add_mentions: AddMentions,
) -> Self {
self.without_relation().make_reply_to(original_message, forward_thread, add_mentions)
self.without_relation().make_reply_to(metadata, forward_thread, add_mentions)
}
/// Turns `self` into a reply to the given raw event.
/// Turns `self` into a new message for a [thread], that is optionally a reply.
///
/// 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.
/// Looks for the `thread` in the given metadata. If it exists, this message will be in the same
/// thread. If it doesn't, a new thread is created with the `event_id` in the metadata as the
/// root.
///
/// 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.
/// It also sets the `in_reply_to` field inside `relates_to` to point the `event_id`
/// in the metadata. If `ReplyWithinThread::Yes` is used, the metadata should be constructed
/// from the event to make a reply to, otherwise it should be constructed from the latest
/// event in the thread.
///
/// 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.
/// If `AddMentions::Yes` is used, the `sender` in the metadata is added as a user mention.
///
/// # Panics
///
/// Panics if `self` has a `formatted_body` with a format other than HTML.
#[track_caller]
pub fn make_reply_to_raw(
/// [thread]: https://spec.matrix.org/latest/client-server-api/#threading
pub fn make_for_thread<'a>(
self,
original_event: &Raw<AnySyncTimelineEvent>,
original_event_id: OwnedEventId,
room_id: &RoomId,
forward_thread: ForwardThread,
add_mentions: AddMentions,
) -> Self {
self.without_relation().make_reply_to_raw(
original_event,
original_event_id,
room_id,
forward_thread,
add_mentions,
)
}
/// Turns `self` into a new message for a thread, that is optionally a reply.
///
/// Looks for a [`Relation::Thread`] in `previous_message`. If it exists, this message will be
/// in the same thread. If it doesn't, a new thread with `previous_message` as the root is
/// created.
///
/// If this is a reply within the thread, takes the `body` / `formatted_body` (if any) in `self`
/// for the main text and prepends a quoted version of `previous_message`. Also sets the
/// `in_reply_to` field inside `relates_to`.
#[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/rich_reply.md"))]
///
/// # Panics
///
/// Panics if this is a reply within the thread and `self` has a `formatted_body` with a format
/// other than HTML.
pub fn make_for_thread(
self,
previous_message: &OriginalRoomMessageEvent,
metadata: impl Into<ReplyMetadata<'a>>,
is_reply: ReplyWithinThread,
add_mentions: AddMentions,
) -> Self {
self.without_relation().make_for_thread(previous_message, is_reply, add_mentions)
self.without_relation().make_for_thread(metadata, is_reply, add_mentions)
}
/// Turns `self` into a [replacement] (or edit) for a given message.
@ -240,12 +200,6 @@ impl RoomMessageEventContent {
/// This takes the content and sets it in `m.new_content`, and modifies the `content` to include
/// a fallback.
///
/// If the message that is replaced is a reply to another message, the latter should also be
/// provided to be able to generate a rich reply fallback that takes the `body` /
/// `formatted_body` (if any) in `self` for the main text and prepends a quoted version of
/// `original_message`.
#[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/rich_reply.md"))]
///
/// If this message contains [`Mentions`], they are copied into `m.new_content` to keep the same
/// mentions, but the ones in `content` are filtered with the ones in the
/// [`ReplacementMetadata`] so only new mentions will trigger a notification.
@ -256,12 +210,8 @@ impl RoomMessageEventContent {
///
/// [replacement]: https://spec.matrix.org/latest/client-server-api/#event-replacements
#[track_caller]
pub fn make_replacement(
self,
metadata: impl Into<ReplacementMetadata>,
replied_to_message: Option<&OriginalRoomMessageEvent>,
) -> Self {
self.without_relation().make_replacement(metadata, replied_to_message)
pub fn make_replacement(self, metadata: impl Into<ReplacementMetadata>) -> Self {
self.without_relation().make_replacement(metadata)
}
/// Set the [mentions] of this event.
@ -375,6 +325,11 @@ impl RoomMessageEventContent {
self.into()
}
/// Get the thread relation from this content, if any.
fn thread(&self) -> Option<&Thread> {
self.relates_to.as_ref().and_then(|relates_to| as_variant!(relates_to, Relation::Thread))
}
}
/// Whether or not to forward a [`Relation::Thread`] when sending a reply.
@ -661,49 +616,6 @@ impl MessageType {
}
}
#[track_caller]
fn add_reply_fallback(&mut self, original_event: OriginalEventData<'_>) {
let empty_formatted_body = || FormattedBody::html(String::new());
let (body, formatted) = {
match self {
MessageType::Emote(m) => {
(&mut m.body, Some(m.formatted.get_or_insert_with(empty_formatted_body)))
}
MessageType::Notice(m) => {
(&mut m.body, Some(m.formatted.get_or_insert_with(empty_formatted_body)))
}
MessageType::Text(m) => {
(&mut m.body, Some(m.formatted.get_or_insert_with(empty_formatted_body)))
}
MessageType::Audio(m) => (&mut m.body, None),
MessageType::File(m) => (&mut m.body, None),
MessageType::Image(m) => (&mut m.body, None),
MessageType::Location(m) => (&mut m.body, None),
MessageType::ServerNotice(m) => (&mut m.body, None),
MessageType::Video(m) => (&mut m.body, None),
MessageType::VerificationRequest(m) => (&mut m.body, None),
MessageType::_Custom(m) => (&mut m.body, None),
}
};
if let Some(f) = formatted {
assert_eq!(
f.format,
MessageFormat::Html,
"can't add reply fallback to non-HTML formatted messages"
);
let formatted_body = &mut f.body;
(*body, *formatted_body) = reply::plain_and_formatted_reply_body(
body.as_str(),
(!formatted_body.is_empty()).then_some(formatted_body.as_str()),
original_event,
);
}
}
fn make_replacement_body(&mut self) {
let empty_formatted_body = || FormattedBody::html(String::new());
@ -784,6 +696,39 @@ impl From<&OriginalSyncRoomMessageEvent> for ReplacementMetadata {
}
}
/// Metadata about an event to reply to or to add to a thread.
///
/// To be used with [`RoomMessageEventContent::make_reply_to`] or
/// [`RoomMessageEventContent::make_for_thread`].
#[derive(Clone, Copy, Debug)]
pub struct ReplyMetadata<'a> {
/// The event ID of the event to reply to.
event_id: &'a EventId,
/// The sender of the event to reply to.
sender: &'a UserId,
/// The `m.thread` relation of the event to reply to, if any.
thread: Option<&'a Thread>,
}
impl<'a> ReplyMetadata<'a> {
/// Creates a new `ReplyMetadata` with the given event ID, sender and thread relation.
pub fn new(event_id: &'a EventId, sender: &'a UserId, thread: Option<&'a Thread>) -> Self {
Self { event_id, sender, thread }
}
}
impl<'a> From<&'a OriginalRoomMessageEvent> for ReplyMetadata<'a> {
fn from(value: &'a OriginalRoomMessageEvent) -> Self {
ReplyMetadata::new(&value.event_id, &value.sender, value.content.thread())
}
}
impl<'a> From<&'a OriginalSyncRoomMessageEvent> for ReplyMetadata<'a> {
fn from(value: &'a OriginalSyncRoomMessageEvent) -> Self {
ReplyMetadata::new(&value.event_id, &value.sender, value.content.thread())
}
}
/// The format for the formatted representation of a message body.
#[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/string_enum.md"))]
#[derive(Clone, PartialEq, Eq, StringEnum)]

View File

@ -1,179 +0,0 @@
use std::fmt::{self, Write};
use ruma_common::{EventId, RoomId, UserId};
#[cfg(feature = "html")]
use ruma_html::Html;
use super::{
sanitize::remove_plain_reply_fallback, FormattedBody, MessageType, OriginalRoomMessageEvent,
Relation,
};
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 (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 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!(
"<mx-reply>\
<blockquote>\
<a href=\"https://matrix.to/#/{room_id}/{event_id}\">In reply to</a> \
{emote_sign}<a href=\"https://matrix.to/#/{sender}\">{sender}</a>\
<br>\
{html_body}\
</blockquote>\
</mx-reply>"
),
)
}
struct EscapeHtmlEntities<'a>(&'a str);
impl fmt::Display for EscapeHtmlEntities<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for c in self.0.chars() {
// Escape reserved HTML entities and new lines.
// <https://developer.mozilla.org/en-US/docs/Glossary/Entity#reserved_characters>
match c {
'&' => f.write_str("&amp;")?,
'<' => f.write_str("&lt;")?,
'>' => f.write_str("&gt;")?,
'"' => f.write_str("&quot;")?,
'\n' => f.write_str("<br>")?,
_ => f.write_char(c)?,
}
}
Ok(())
}
}
struct FormattedOrPlainBody<'a> {
formatted: Option<&'a FormattedBody>,
body: &'a str,
#[cfg(feature = "html")]
is_reply: bool,
}
impl fmt::Display for FormattedOrPlainBody<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if let Some(formatted_body) = self.formatted {
#[cfg(feature = "html")]
if self.is_reply {
let html = Html::parse(&formatted_body.body);
html.sanitize();
write!(f, "{html}")
} else {
f.write_str(&formatted_body.body)
}
#[cfg(not(feature = "html"))]
f.write_str(&formatted_body.body)
} else {
write!(f, "{}", EscapeHtmlEntities(self.body))
}
}
}
/// Get the plain and formatted body for a rich reply.
///
/// Returns a `(plain, html)` tuple.
///
/// With the `sanitize` feature, [HTML tags and attributes] that are not allowed in the Matrix
/// spec and previous [rich reply fallbacks] are removed from the previous message in the new rich
/// reply fallback.
///
/// [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(super) fn plain_and_formatted_reply_body(
body: &str,
formatted: Option<impl fmt::Display>,
original_event: OriginalEventData<'_>,
) -> (String, String) {
let (quoted, quoted_html) = get_message_quote_fallbacks(original_event);
let plain = format!("{quoted}\n\n{body}");
let html = match formatted {
Some(formatted) => format!("{quoted_html}{formatted}"),
None => format!("{quoted_html}{}", EscapeHtmlEntities(body)),
};
(plain, html)
}
#[cfg(test)]
mod tests {
use ruma_common::{owned_event_id, owned_room_id, owned_user_id, MilliSecondsSinceUnixEpoch};
use super::OriginalRoomMessageEvent;
use crate::{room::message::RoomMessageEventContent, MessageLikeUnsigned};
#[test]
fn fallback_multiline() {
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!(
html_quote,
"<mx-reply>\
<blockquote>\
<a href=\"https://matrix.to/#/!n8f893n9:example.com/$1598361704261elfgc:localhost\">In reply to</a> \
<a href=\"https://matrix.to/#/@alice:example.com\">@alice:example.com</a>\
<br>\
multi<br>line\
</blockquote>\
</mx-reply>",
);
}
}

View File

@ -1,15 +1,12 @@
use as_variant::as_variant;
use ruma_common::{serde::Raw, OwnedEventId, OwnedUserId, RoomId, UserId};
use serde::{Deserialize, Serialize};
use serde::Serialize;
use super::{
AddMentions, ForwardThread, MessageType, OriginalRoomMessageEvent, Relation,
ReplacementMetadata, ReplyWithinThread, RoomMessageEventContent,
AddMentions, ForwardThread, MessageType, Relation, ReplacementMetadata, ReplyMetadata,
ReplyWithinThread, RoomMessageEventContent,
};
use crate::{
relation::{InReplyTo, Replacement, Thread},
room::message::{reply::OriginalEventData, FormattedBody},
AnySyncTimelineEvent, Mentions,
Mentions,
};
/// Form of [`RoomMessageEventContent`] without relation.
@ -92,161 +89,81 @@ impl RoomMessageEventContentWithoutRelation {
RoomMessageEventContent { msgtype, relates_to, mentions }
}
/// Turns `self` into a reply to the given message.
/// Turns `self` into a [rich reply] to the message using the given metadata.
///
/// Takes the `body` / `formatted_body` (if any) in `self` for the main text and prepends a
/// quoted version of `original_message`. 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.
#[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/rich_reply.md"))]
/// Sets the `in_reply_to` field inside `relates_to`, and optionally the `rel_type` to
/// `m.thread` if the metadata has a `thread` and `ForwardThread::Yes` is used.
///
/// # Panics
/// If `AddMentions::Yes` is used, the `sender` in the metadata is added as a user mention.
///
/// Panics if `self` has a `formatted_body` with a format other than HTML.
/// [rich reply]: https://spec.matrix.org/latest/client-server-api/#rich-replies
#[track_caller]
pub fn make_reply_to(
pub fn make_reply_to<'a>(
mut self,
original_message: &OriginalRoomMessageEvent,
metadata: impl Into<ReplyMetadata<'a>>,
forward_thread: ForwardThread,
add_mentions: AddMentions,
) -> RoomMessageEventContent {
self.msgtype.add_reply_fallback(original_message.into());
let original_event_id = original_message.event_id.clone();
let metadata = metadata.into();
let original_event_id = metadata.event_id.to_owned();
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())
let original_thread_id = metadata
.thread
.filter(|_| forward_thread == ForwardThread::Yes)
.map(|thread| thread.event_id.clone());
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 {
None
Relation::Reply { in_reply_to: InReplyTo { event_id: original_event_id.to_owned() } }
};
let sender_for_mentions =
(add_mentions == AddMentions::Yes).then_some(&*original_message.sender);
self.make_reply_tweaks(original_event_id, original_thread_id, sender_for_mentions)
if add_mentions == AddMentions::Yes {
self.mentions
.get_or_insert_with(Mentions::new)
.user_ids
.insert(metadata.sender.to_owned());
}
/// 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(
mut self,
original_event: &Raw<AnySyncTimelineEvent>,
original_event_id: OwnedEventId,
room_id: &RoomId,
forward_thread: ForwardThread,
add_mentions: AddMentions,
) -> RoomMessageEventContent {
#[derive(Deserialize)]
struct ContentDeHelper {
body: Option<String>,
#[serde(flatten)]
formatted: Option<FormattedBody>,
#[cfg(feature = "unstable-msc1767")]
#[serde(rename = "org.matrix.msc1767.text")]
text: Option<String>,
#[serde(rename = "m.relates_to")]
relates_to: Option<crate::room::encrypted::Relation>,
self.with_relation(Some(relates_to))
}
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.
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.msgtype.add_reply_fallback(data);
}
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 sender_for_mentions = sender.as_deref().filter(|_| add_mentions == AddMentions::Yes);
self.make_reply_tweaks(original_event_id, original_thread_id, sender_for_mentions)
}
/// Turns `self` into a new message for a thread, that is optionally a reply.
/// Turns `self` into a new message for a [thread], that is optionally a reply.
///
/// Looks for a [`Relation::Thread`] in `previous_message`. If it exists, this message will be
/// in the same thread. If it doesn't, a new thread with `previous_message` as the root is
/// created.
/// Looks for the `thread` in the given metadata. If it exists, this message will be in the same
/// thread. If it doesn't, a new thread is created with the `event_id` in the metadata as the
/// root.
///
/// If this is a reply within the thread, takes the `body` / `formatted_body` (if any) in `self`
/// for the main text and prepends a quoted version of `previous_message`. Also sets the
/// `in_reply_to` field inside `relates_to`.
#[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/rich_reply.md"))]
/// It also sets the `in_reply_to` field inside `relates_to` to point the `event_id`
/// in the metadata. If `ReplyWithinThread::Yes` is used, the metadata should be constructed
/// from the event to make a reply to, otherwise it should be constructed from the latest
/// event in the thread.
///
/// # Panics
/// If `AddMentions::Yes` is used, the `sender` in the metadata is added as a user mention.
///
/// Panics if this is a reply within the thread and `self` has a `formatted_body` with a format
/// other than HTML.
pub fn make_for_thread(
/// [thread]: https://spec.matrix.org/latest/client-server-api/#threading
pub fn make_for_thread<'a>(
self,
previous_message: &OriginalRoomMessageEvent,
metadata: impl Into<ReplyMetadata<'a>>,
is_reply: ReplyWithinThread,
add_mentions: AddMentions,
) -> RoomMessageEventContent {
let metadata = metadata.into();
let mut content = if is_reply == ReplyWithinThread::Yes {
self.make_reply_to(previous_message, ForwardThread::No, add_mentions)
self.make_reply_to(metadata, ForwardThread::No, add_mentions)
} else {
self.into()
};
let thread_root = if let Some(Relation::Thread(Thread { event_id, .. })) =
&previous_message.content.relates_to
{
event_id.clone()
let thread_root = if let Some(Thread { event_id, .. }) = &metadata.thread {
event_id.to_owned()
} else {
previous_message.event_id.clone()
metadata.event_id.to_owned()
};
content.relates_to = Some(Relation::Thread(Thread {
event_id: thread_root,
in_reply_to: Some(InReplyTo { event_id: previous_message.event_id.clone() }),
in_reply_to: Some(InReplyTo { event_id: metadata.event_id.to_owned() }),
is_falling_back: is_reply == ReplyWithinThread::No,
}));
@ -262,12 +179,6 @@ impl RoomMessageEventContentWithoutRelation {
/// This takes the content and sets it in `m.new_content`, and modifies the `content` to include
/// a fallback.
///
/// If the message that is replaced is a reply to another message, the latter should also be
/// provided to be able to generate a rich reply fallback that takes the `body` /
/// `formatted_body` (if any) in `self` for the main text and prepends a quoted version of
/// `original_message`.
#[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/rich_reply.md"))]
///
/// If this message contains [`Mentions`], they are copied into `m.new_content` to keep the same
/// mentions, but the ones in `content` are filtered with the ones in the
/// [`ReplacementMetadata`] so only new mentions will trigger a notification.
@ -281,7 +192,6 @@ impl RoomMessageEventContentWithoutRelation {
pub fn make_replacement(
mut self,
metadata: impl Into<ReplacementMetadata>,
replied_to_message: Option<&OriginalRoomMessageEvent>,
) -> RoomMessageEventContent {
let metadata = metadata.into();
@ -318,13 +228,7 @@ impl RoomMessageEventContentWithoutRelation {
self.msgtype.make_replacement_body();
// Add reply fallback if needed.
let mut content = if let Some(original_message) = replied_to_message {
self.make_reply_to(original_message, ForwardThread::No, AddMentions::No)
} else {
self.into()
};
let mut content = RoomMessageEventContent::from(self);
content.relates_to = Some(relates_to);
content
@ -341,25 +245,6 @@ impl RoomMessageEventContentWithoutRelation {
self.mentions.get_or_insert_with(Mentions::new).add(mentions);
self
}
fn make_reply_tweaks(
mut self,
original_event_id: OwnedEventId,
original_thread_id: Option<OwnedEventId>,
sender_for_mentions: Option<&UserId>,
) -> RoomMessageEventContent {
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() } }
};
if let Some(sender) = sender_for_mentions {
self.mentions.get_or_insert_with(Mentions::new).user_ids.insert(sender.to_owned());
}
self.with_relation(Some(relates_to))
}
}
impl From<MessageType> for RoomMessageEventContentWithoutRelation {

View File

@ -1,9 +1,9 @@
use std::{borrow::Cow, collections::BTreeSet};
use std::borrow::Cow;
use assert_matches2::assert_matches;
use js_int::uint;
use ruma_common::{
mxc_uri, owned_event_id, owned_room_id, owned_user_id, room_id,
mxc_uri, owned_event_id, owned_room_id, owned_user_id,
serde::{Base64, Raw},
user_id, MilliSecondsSinceUnixEpoch, OwnedDeviceId,
};
@ -19,7 +19,7 @@ use ruma_events::{
},
EncryptedFileInit, JsonWebKeyInit, MediaSource,
},
AnySyncTimelineEvent, EventContent, Mentions, MessageLikeUnsigned, RawExt,
EventContent, Mentions, MessageLikeUnsigned, RawExt,
};
use serde_json::{
from_value as from_json_value, json, to_value as to_json_value, Value as JsonValue,
@ -302,155 +302,6 @@ fn content_deserialization_failure() {
assert_matches!(from_json_value::<RoomMessageEventContent>(json_data), Err(_));
}
#[test]
fn escape_tags_in_plain_reply_body() {
let first_message = OriginalRoomMessageEvent {
content: RoomMessageEventContent::text_plain("Usage: cp <source> <destination>"),
event_id: owned_event_id!("$143273582443PhrSn:example.org"),
origin_server_ts: MilliSecondsSinceUnixEpoch(uint!(10_000)),
room_id: owned_room_id!("!testroomid:example.org"),
sender: owned_user_id!("@user:example.org"),
unsigned: MessageLikeUnsigned::default(),
};
let second_message = RoomMessageEventContent::text_plain("Usage: rm <path>").make_reply_to(
&first_message,
ForwardThread::Yes,
AddMentions::No,
);
assert_matches!(second_message.mentions, None);
assert_matches!(
first_message.content.msgtype,
MessageType::Text(TextMessageEventContent { body, formatted: None, .. })
);
assert_eq!(body, "Usage: cp <source> <destination>");
assert_matches!(
second_message.msgtype,
MessageType::Text(TextMessageEventContent { body, formatted, .. })
);
assert_eq!(
body,
"\
> <@user:example.org> Usage: cp <source> <destination>\n\
\n\
Usage: rm <path>\
"
);
let formatted = formatted.unwrap();
assert_eq!(
formatted.body,
"\
<mx-reply>\
<blockquote>\
<a href=\"https://matrix.to/#/!testroomid:example.org/$143273582443PhrSn:example.org\">In reply to</a> \
<a href=\"https://matrix.to/#/@user:example.org\">@user:example.org</a>\
<br>\
Usage: cp &lt;source&gt; &lt;destination&gt;\
</blockquote>\
</mx-reply>\
Usage: rm &lt;path&gt;\
"
);
}
#[test]
#[cfg(feature = "html")]
fn reply_sanitize() {
let first_message = OriginalRoomMessageEvent {
content: RoomMessageEventContent::text_html(
"# This is the first message",
"<h1>This is the first message</h1>",
),
event_id: owned_event_id!("$143273582443PhrSn:example.org"),
origin_server_ts: MilliSecondsSinceUnixEpoch(uint!(10_000)),
room_id: owned_room_id!("!testroomid:example.org"),
sender: owned_user_id!("@user:example.org"),
unsigned: MessageLikeUnsigned::default(),
};
let second_message = OriginalRoomMessageEvent {
content: RoomMessageEventContent::text_html(
"This is the _second_ message",
"This is the <em>second</em> message",
)
.make_reply_to(&first_message, ForwardThread::Yes, AddMentions::No),
event_id: owned_event_id!("$143273582443PhrSn:example.org"),
origin_server_ts: MilliSecondsSinceUnixEpoch(uint!(10_000)),
room_id: owned_room_id!("!testroomid:example.org"),
sender: owned_user_id!("@user:example.org"),
unsigned: MessageLikeUnsigned::default(),
};
let final_reply = RoomMessageEventContent::text_html(
"This is **my** reply",
"This is <strong>my</strong> reply",
)
.make_reply_to(&second_message, ForwardThread::Yes, AddMentions::No);
assert_matches!(
first_message.content.msgtype,
MessageType::Text(TextMessageEventContent { body, formatted, .. })
);
assert_eq!(body, "# This is the first message");
let formatted = formatted.unwrap();
assert_eq!(formatted.body, "<h1>This is the first message</h1>");
assert_matches!(
second_message.content.msgtype,
MessageType::Text(TextMessageEventContent { body, formatted, .. })
);
assert_eq!(
body,
"\
> <@user:example.org> # This is the first message\n\
\n\
This is the _second_ message\
"
);
let formatted = formatted.unwrap();
assert_eq!(
formatted.body,
"\
<mx-reply>\
<blockquote>\
<a href=\"https://matrix.to/#/!testroomid:example.org/$143273582443PhrSn:example.org\">In reply to</a> \
<a href=\"https://matrix.to/#/@user:example.org\">@user:example.org</a>\
<br>\
<h1>This is the first message</h1>\
</blockquote>\
</mx-reply>\
This is the <em>second</em> message\
"
);
assert_matches!(
final_reply.msgtype,
MessageType::Text(TextMessageEventContent { body, formatted, .. })
);
assert_eq!(
body,
"\
> <@user:example.org> This is the _second_ message\n\
\n\
This is **my** reply\
"
);
let formatted = formatted.unwrap();
assert_eq!(
formatted.body,
"\
<mx-reply>\
<blockquote>\
<a href=\"https://matrix.to/#/!testroomid:example.org/$143273582443PhrSn:example.org\">In reply to</a> \
<a href=\"https://matrix.to/#/@user:example.org\">@user:example.org</a>\
<br>\
This is the <em>second</em> message\
</blockquote>\
</mx-reply>\
This is <strong>my</strong> reply\
"
);
}
#[test]
fn reply_thread_fallback() {
let thread_root = OriginalRoomMessageEvent {
@ -569,216 +420,7 @@ fn reply_add_mentions() {
}
#[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]));
}
#[test]
fn make_replacement_no_reply() {
fn make_replacement() {
let content = RoomMessageEventContent::text_html(
"This is _an edited_ message.",
"This is <em>an edited</em> message.",
@ -798,7 +440,7 @@ fn make_replacement_no_reply() {
let original_message: OriginalSyncRoomMessageEvent =
from_json_value(original_message_json).unwrap();
let content = content.make_replacement(&original_message, None);
let content = content.make_replacement(&original_message);
assert_matches!(
content.msgtype,
@ -810,71 +452,6 @@ fn make_replacement_no_reply() {
assert_matches!(content.mentions, None);
}
#[test]
fn make_replacement_with_reply() {
let replied_to_message = OriginalRoomMessageEvent {
content: RoomMessageEventContent::text_html(
"# This is the first message",
"<h1>This is the first message</h1>",
),
event_id: owned_event_id!("$143273582443PhrSn:example.org"),
origin_server_ts: MilliSecondsSinceUnixEpoch(uint!(10_000)),
room_id: owned_room_id!("!testroomid:example.org"),
sender: owned_user_id!("@user:example.org"),
unsigned: MessageLikeUnsigned::default(),
};
let content = RoomMessageEventContent::text_html(
"This is _an edited_ reply.",
"This is <em>an edited</em> reply.",
);
let original_message_json = json!({
"content": {
"body": "Hello, World!",
"msgtype": "m.text",
},
"event_id": "$143273582443PhrSn",
"origin_server_ts": 134_829_848,
"room_id": "!roomid:notareal.hs",
"sender": "@user:notareal.hs",
"type": "m.room.message",
});
let original_message: OriginalSyncRoomMessageEvent =
from_json_value(original_message_json).unwrap();
let content = content.make_replacement(&original_message, Some(&replied_to_message));
assert_matches!(
content.msgtype,
MessageType::Text(TextMessageEventContent { body, formatted, .. })
);
assert_eq!(
body,
"\
> <@user:example.org> # This is the first message\n\
\n\
* This is _an edited_ reply.\
"
);
let formatted = formatted.unwrap();
assert_eq!(
formatted.body,
"\
<mx-reply>\
<blockquote>\
<a href=\"https://matrix.to/#/!testroomid:example.org/$143273582443PhrSn:example.org\">In reply to</a> \
<a href=\"https://matrix.to/#/@user:example.org\">@user:example.org</a>\
<br>\
<h1>This is the first message</h1>\
</blockquote>\
</mx-reply>\
* This is <em>an edited</em> reply.\
"
);
assert_matches!(content.mentions, None);
}
#[test]
fn audio_msgtype_serialization() {
let message_event_content =
@ -1253,7 +830,7 @@ fn add_mentions_then_make_replacement() {
"This is <em>an edited</em> message.",
);
content = content.add_mentions(Mentions::with_user_ids(vec![alice.clone(), bob.clone()]));
content = content.make_replacement(&original_message, None);
content = content.make_replacement(&original_message);
let mentions = content.mentions.unwrap();
assert_eq!(mentions.user_ids, [bob.clone()].into());
@ -1287,7 +864,7 @@ fn make_replacement_then_add_mentions() {
"This is _an edited_ message.",
"This is <em>an edited</em> message.",
);
content = content.make_replacement(&original_message, None);
content = content.make_replacement(&original_message);
content = content.add_mentions(Mentions::with_user_ids(vec![alice.clone(), bob.clone()]));
let mentions = content.mentions.unwrap();