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!( "\
\ In reply to \ {emote_sign}{sender}\
\ {html_body}\
\
" ), ) } 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. // match c { '&' => f.write_str("&")?, '<' => f.write_str("<")?, '>' => f.write_str(">")?, '"' => f.write_str(""")?, '\n' => f.write_str("
")?, _ => 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 mut 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, 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, "\
\ In reply to \ @alice:example.com\
\ multi
line\
\
", ); } }