events: Add support for intentional mentions

According to MSC3952
This commit is contained in:
Kévin Commaille 2023-07-10 11:01:57 +02:00 committed by Kévin Commaille
parent 07bc06038f
commit f8ac66ca25
6 changed files with 276 additions and 21 deletions

View File

@ -20,8 +20,10 @@ Breaking changes:
- Remove `SessionDescriptionType`, use a `String` instead. A clarification in MSC2746 / Matrix 1.7
explains that the `type` field should not be validated but passed as-is to the WebRTC API. It
also avoids an unnecessary conversion between the WebRTC API and the Ruma type.
- The `reason` field in `CallHangupEventContent` is now required an defaults to `Reason::UserHangup`
- The `reason` field in `CallHangupEventContent` is now required and defaults to `Reason::UserHangup`
(MSC2746 / Matrix 1.7)
- The `Replacement` relation for `RoomMessageEventContent` now takes a
`RoomMessageEventContentWithoutRelation` instead of a `MessageType`
Improvements:
@ -50,6 +52,7 @@ Improvements:
- Stabilize support for VoIP signalling improvements (MSC2746 / Matrix 1.7)
- Make the generated and stripped plain text reply fallback behavior more compatible with most
of the Matrix ecosystem.
- Add support for intentional mentions according to MSC3952 / Matrix 1.7
# 0.11.3

View File

@ -102,9 +102,9 @@
//! ));
//! ```
use serde::{de::IgnoredAny, Deserialize, Serializer};
use serde::{de::IgnoredAny, Deserialize, Serialize, Serializer};
use crate::{EventEncryptionAlgorithm, RoomVersionId};
use crate::{EventEncryptionAlgorithm, OwnedUserId, RoomVersionId};
// Needs to be public for trybuild tests
#[doc(hidden)]
@ -224,3 +224,37 @@ pub fn serialize_custom_event_error<T, S: Serializer>(_: &T, _: S) -> Result<S::
`serde_json::value::to_raw_value` and `Raw::from_json`.",
))
}
/// Describes whether the event mentions other users or the room.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Mentions {
/// The list of mentioned users.
///
/// Defaults to an empty `Vec`.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub user_ids: Vec<OwnedUserId>,
/// Whether the whole room is mentioned.
///
/// Defaults to `false`.
#[serde(default, skip_serializing_if = "crate::serde::is_default")]
pub room: bool,
}
impl Mentions {
/// Create a `Mentions` with the default values.
pub fn new() -> Self {
Self::default()
}
/// Create a `Mentions` for the given user IDs.
pub fn with_user_ids(user_ids: Vec<OwnedUserId>) -> Self {
Self { user_ids, ..Default::default() }
}
/// Create a `Mentions` for a room mention.
pub fn with_room_mention() -> Self {
Self { room: true, ..Default::default() }
}
}

View File

@ -9,9 +9,12 @@ use serde::{de::DeserializeOwned, Deserialize, Serialize};
use serde_json::Value as JsonValue;
use crate::{
events::relation::{CustomRelation, InReplyTo, RelationType, Replacement, Thread},
events::{
relation::{CustomRelation, InReplyTo, RelationType, Replacement, Thread},
Mentions,
},
serde::{JsonObject, StringEnum},
EventId, OwnedEventId, PrivOwnedStr,
EventId, PrivOwnedStr,
};
mod audio;
@ -64,13 +67,23 @@ pub struct RoomMessageEventContent {
///
/// [related messages]: https://spec.matrix.org/latest/client-server-api/#forming-relationships-between-events
#[serde(flatten, skip_serializing_if = "Option::is_none")]
pub relates_to: Option<Relation<MessageType>>,
pub relates_to: Option<Relation<RoomMessageEventContentWithoutRelation>>,
/// The [mentions] of this event.
///
/// This should always be set to avoid triggering the legacy mention push rules. It is
/// recommended to use [`Self::set_mentions()`] to set this field, that will take care of
/// populating the fields correctly if this is a replacement.
///
/// [mentions]: https://spec.matrix.org/latest/client-server-api/#user-and-room-mentions
#[serde(rename = "m.mentions", skip_serializing_if = "Option::is_none")]
pub mentions: Option<Mentions>,
}
impl RoomMessageEventContent {
/// Create a `RoomMessageEventContent` with the given `MessageType`.
pub fn new(msgtype: MessageType) -> Self {
Self { msgtype, relates_to: None }
Self { msgtype, relates_to: None, mentions: None }
}
/// A constructor to create a plain text message.
@ -247,6 +260,10 @@ impl RoomMessageEventContent {
/// `original_message`.
#[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/rich_reply.md"))]
///
/// If the message that is replaced contains [`Mentions`], they are copied into
/// `m.new_content` to keep the same mentions, but not into `content` to avoid repeated
/// notifications.
///
/// # Panics
///
/// Panics if `self` has a `formatted_body` with a format other than HTML.
@ -255,13 +272,16 @@ impl RoomMessageEventContent {
#[track_caller]
pub fn make_replacement(
mut self,
original_message_id: OwnedEventId,
original_message: &OriginalSyncRoomMessageEvent,
replied_to_message: Option<&OriginalRoomMessageEvent>,
) -> Self {
// Prepare relates_to with the untouched msgtype.
let relates_to = Relation::Replacement(Replacement {
event_id: original_message_id,
new_content: self.msgtype.clone(),
event_id: original_message.event_id.clone(),
new_content: RoomMessageEventContentWithoutRelation {
msgtype: self.msgtype.clone(),
mentions: original_message.content.mentions.clone(),
},
});
let empty_formatted_body = || FormattedBody::html(String::new());
@ -311,6 +331,43 @@ impl RoomMessageEventContent {
self
}
/// Set the [mentions] of this event.
///
/// If this event is a replacement, it will update the mentions both in the `content` and the
/// `m.new_content` so only new mentions will trigger a notification. As such, this needs to be
/// called after [`Self::make_replacement()`].
///
/// [mentions]: https://spec.matrix.org/latest/client-server-api/#user-and-room-mentions
pub fn set_mentions(mut self, mentions: Mentions) -> Self {
if let Some(Relation::Replacement(replacement)) = &mut self.relates_to {
let old_mentions = &replacement.new_content.mentions;
let new_mentions = if let Some(old_mentions) = old_mentions {
let mut new_mentions = Mentions::new();
new_mentions.user_ids = mentions
.user_ids
.iter()
.filter(|u| !old_mentions.user_ids.contains(*u))
.cloned()
.collect();
new_mentions.room = mentions.room && !old_mentions.room;
new_mentions
} else {
mentions.clone()
};
replacement.new_content.mentions = Some(mentions);
self.mentions = Some(new_mentions);
} else {
self.mentions = Some(mentions);
}
self
}
/// Returns a reference to the `msgtype` string.
///
/// If you want to access the message type-specific data rather than the message type itself,
@ -352,6 +409,44 @@ impl RoomMessageEventContent {
}
}
/// Form of [`RoomMessageEventContent`] without relation.
///
/// To construct this type, construct a [`RoomMessageEventContent`] and then use one of its ::from()
/// / .into() methods.
#[derive(Clone, Debug, Serialize)]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
pub struct RoomMessageEventContentWithoutRelation {
/// A key which identifies the type of message being sent.
///
/// This also holds the specific content of each message.
#[serde(flatten)]
pub msgtype: MessageType,
/// The [mentions] of this event.
///
/// [mentions]: https://spec.matrix.org/latest/client-server-api/#user-and-room-mentions
#[serde(rename = "m.mentions", skip_serializing_if = "Option::is_none")]
pub mentions: Option<Mentions>,
}
impl RoomMessageEventContentWithoutRelation {
/// Transform `self` into a `RoomMessageEventContent` with the given relation.
pub fn with_relation(
self,
relates_to: Option<Relation<RoomMessageEventContentWithoutRelation>>,
) -> RoomMessageEventContent {
let Self { msgtype, mentions } = self;
RoomMessageEventContent { msgtype, relates_to, mentions }
}
}
impl From<RoomMessageEventContent> for RoomMessageEventContentWithoutRelation {
fn from(value: RoomMessageEventContent) -> Self {
let RoomMessageEventContent { msgtype, mentions, .. } = value;
Self { msgtype, mentions }
}
}
/// Whether or not to forward a [`Relation::Thread`] when sending a reply.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[allow(clippy::exhaustive_enums)]

View File

@ -3,8 +3,11 @@
use serde::{de, Deserialize};
use serde_json::value::RawValue as RawJsonValue;
use super::{relation_serde::deserialize_relation, MessageType, RoomMessageEventContent};
use crate::serde::from_raw_json_value;
use super::{
relation_serde::deserialize_relation, MessageType, RoomMessageEventContent,
RoomMessageEventContentWithoutRelation,
};
use crate::{events::Mentions, serde::from_raw_json_value};
impl<'de> Deserialize<'de> for RoomMessageEventContent {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
@ -12,13 +15,35 @@ impl<'de> Deserialize<'de> for RoomMessageEventContent {
D: de::Deserializer<'de>,
{
let json = Box::<RawJsonValue>::deserialize(deserializer)?;
let mut deserializer = serde_json::Deserializer::from_str(json.get());
let relates_to = deserialize_relation(&mut deserializer).map_err(de::Error::custom)?;
Ok(Self { msgtype: from_raw_json_value(&json)?, relates_to })
let MentionsDeHelper { mentions } = from_raw_json_value(&json)?;
Ok(Self { msgtype: from_raw_json_value(&json)?, relates_to, mentions })
}
}
impl<'de> Deserialize<'de> for RoomMessageEventContentWithoutRelation {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: de::Deserializer<'de>,
{
let json = Box::<RawJsonValue>::deserialize(deserializer)?;
let MentionsDeHelper { mentions } = from_raw_json_value(&json)?;
Ok(Self { msgtype: from_raw_json_value(&json)?, mentions })
}
}
#[derive(Deserialize)]
struct MentionsDeHelper {
#[serde(rename = "m.mentions")]
mentions: Option<Mentions>,
}
/// Helper struct to determine the msgtype from a `serde_json::value::RawValue`
#[derive(Debug, Deserialize)]
struct MessageTypeDeHelper {

View File

@ -107,7 +107,7 @@ fn replacement_deserialize() {
})
);
assert_eq!(replacement.event_id, "$1598361704261elfgc");
assert_matches!(replacement.new_content, MessageType::Text(text));
assert_matches!(replacement.new_content.msgtype, MessageType::Text(text));
assert_eq!(text.body, "Hello! My name is bar");
}

View File

@ -9,12 +9,12 @@ use ruma_common::{
message::{
AudioMessageEventContent, EmoteMessageEventContent, FileMessageEventContent,
ForwardThread, ImageMessageEventContent, KeyVerificationRequestEventContent,
MessageType, OriginalRoomMessageEvent, RoomMessageEventContent,
TextMessageEventContent, VideoMessageEventContent,
MessageType, OriginalRoomMessageEvent, OriginalSyncRoomMessageEvent, Relation,
RoomMessageEventContent, TextMessageEventContent, VideoMessageEventContent,
},
EncryptedFileInit, JsonWebKeyInit, MediaSource,
},
MessageLikeUnsigned,
Mentions, MessageLikeUnsigned,
},
mxc_uri, owned_event_id, owned_room_id, owned_user_id,
serde::Base64,
@ -408,9 +408,22 @@ fn make_replacement_no_reply() {
"This is _an edited_ message.",
"This is <em>an edited</em> message.",
);
let event_id = owned_event_id!("$143273582443PhrSn:example.org");
let content = content.make_replacement(event_id, None);
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, None);
assert_matches!(
content.msgtype,
@ -419,6 +432,7 @@ fn make_replacement_no_reply() {
assert_eq!(body, "* This is _an edited_ message.");
let formatted = formatted.unwrap();
assert_eq!(formatted.body, "* This is <em>an edited</em> message.");
assert_matches!(content.mentions, None);
}
#[test]
@ -439,9 +453,22 @@ fn make_replacement_with_reply() {
"This is _an edited_ reply.",
"This is <em>an edited</em> reply.",
);
let event_id = owned_event_id!("$143273582443PhrSn:example.org");
let content = content.make_replacement(event_id, Some(&replied_to_message));
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,
@ -470,6 +497,7 @@ fn make_replacement_with_reply() {
* This is <em>an edited</em> reply.\
"
);
assert_matches!(content.mentions, None);
}
#[test]
@ -806,3 +834,73 @@ fn video_msgtype_deserialization() {
assert_matches!(content.source, MediaSource::Plain(url));
assert_eq!(url, "mxc://notareal.hs/file");
}
#[test]
fn set_mentions() {
let mut content = RoomMessageEventContent::text_plain("you!");
let mentions = content.mentions.take();
assert_matches!(mentions, None);
let user_id = owned_user_id!("@you:localhost");
content = content.set_mentions(Mentions::with_user_ids(vec![user_id.clone()]));
let mentions = content.mentions.unwrap();
assert_eq!(mentions.user_ids.as_slice(), &[user_id]);
}
#[test]
fn make_replacement_set_mentions() {
let alice = owned_user_id!("@alice:localhost");
let bob = owned_user_id!("@bob:localhost");
let original_message_json = json!({
"content": {
"body": "Hello, World!",
"msgtype": "m.text",
"m.mentions": {
"user_ids": [alice],
}
},
"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 mut content = RoomMessageEventContent::text_html(
"This is _an edited_ message.",
"This is <em>an edited</em> message.",
);
content = content.make_replacement(&original_message, None);
let content_clone = content.clone();
assert_matches!(content.mentions, None);
assert_matches!(content.relates_to, Some(Relation::Replacement(replacement)));
let mentions = replacement.new_content.mentions.unwrap();
assert_eq!(mentions.user_ids.as_slice(), &[alice.clone()]);
content = content_clone.set_mentions(Mentions::with_user_ids(vec![alice.clone(), bob.clone()]));
let mentions = content.mentions.unwrap();
assert_eq!(mentions.user_ids.as_slice(), &[bob.clone()]);
assert_matches!(content.relates_to, Some(Relation::Replacement(replacement)));
let mentions = replacement.new_content.mentions.unwrap();
assert_eq!(mentions.user_ids.as_slice(), &[alice, bob]);
}
#[test]
fn mentions_room_deserialization() {
let json_data = json!({
"body": "room!",
"msgtype": "m.text",
"m.mentions": {
"room": true,
},
});
let content = from_json_value::<RoomMessageEventContent>(json_data).unwrap();
assert_matches!(content.msgtype, MessageType::Text(text));
assert_eq!(text.body, "room!");
let mentions = content.mentions.unwrap();
assert!(mentions.room);
}