events: Add support for intentional mentions
According to MSC3952
This commit is contained in:
parent
07bc06038f
commit
f8ac66ca25
@ -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
|
||||
|
||||
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
@ -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)]
|
||||
|
@ -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 {
|
||||
|
@ -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");
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user