From e336db767ad103ff00024afcc8eaf07e473bb497 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Commaille?= <76261501+zecakeh@users.noreply.github.com> Date: Mon, 14 Mar 2022 11:43:15 +0100 Subject: [PATCH] common: Add support for extensible image events --- crates/ruma-common/Cargo.toml | 1 + crates/ruma-common/src/events.rs | 2 + crates/ruma-common/src/events/enums.rs | 4 + crates/ruma-common/src/events/image.rs | 275 +++++++++++++++++++ crates/ruma-common/tests/events/image.rs | 322 +++++++++++++++++++++++ crates/ruma-common/tests/events/mod.rs | 1 + crates/ruma/Cargo.toml | 2 + 7 files changed, 607 insertions(+) create mode 100644 crates/ruma-common/src/events/image.rs create mode 100644 crates/ruma-common/tests/events/image.rs diff --git a/crates/ruma-common/Cargo.toml b/crates/ruma-common/Cargo.toml index e18a4702..7ca75b59 100644 --- a/crates/ruma-common/Cargo.toml +++ b/crates/ruma-common/Cargo.toml @@ -34,6 +34,7 @@ unstable-msc2675 = [] unstable-msc2676 = [] unstable-msc2677 = [] unstable-msc3551 = ["unstable-msc1767"] +unstable-msc3552 = ["unstable-msc1767", "unstable-msc3551"] [dependencies] base64 = "0.13.0" diff --git a/crates/ruma-common/src/events.rs b/crates/ruma-common/src/events.rs index d47ff8d9..71269d0a 100644 --- a/crates/ruma-common/src/events.rs +++ b/crates/ruma-common/src/events.rs @@ -154,6 +154,8 @@ pub mod file; pub mod forwarded_room_key; pub mod fully_read; pub mod ignored_user_list; +#[cfg(feature = "unstable-msc3552")] +pub mod image; pub mod key; #[cfg(feature = "unstable-msc1767")] pub mod message; diff --git a/crates/ruma-common/src/events/enums.rs b/crates/ruma-common/src/events/enums.rs index 44c152b8..dc83ed7d 100644 --- a/crates/ruma-common/src/events/enums.rs +++ b/crates/ruma-common/src/events/enums.rs @@ -41,6 +41,8 @@ event_enum! { "m.emote", #[cfg(feature = "unstable-msc3551")] "m.file", + #[cfg(feature = "unstable-msc3552")] + "m.image", "m.key.verification.ready", "m.key.verification.start", "m.key.verification.cancel", @@ -368,6 +370,8 @@ impl AnyMessageLikeEventContent { Self::Emote(ev) => ev.relates_to.clone().map(Into::into), #[cfg(feature = "unstable-msc3551")] Self::File(ev) => ev.relates_to.clone().map(Into::into), + #[cfg(feature = "unstable-msc3552")] + Self::Image(ev) => ev.relates_to.clone().map(Into::into), Self::CallAnswer(_) | Self::CallInvite(_) | Self::CallHangup(_) diff --git a/crates/ruma-common/src/events/image.rs b/crates/ruma-common/src/events/image.rs new file mode 100644 index 00000000..505bb79b --- /dev/null +++ b/crates/ruma-common/src/events/image.rs @@ -0,0 +1,275 @@ +//! Types for extensible image message events ([MSC3552]). +//! +//! [MSC3552]: https://github.com/matrix-org/matrix-spec-proposals/pull/3552 + +use js_int::UInt; +use ruma_macros::EventContent; +use serde::{Deserialize, Serialize}; + +use super::{ + file::{EncryptedContent, FileContent}, + message::{MessageContent, Text}, + room::message::Relation, +}; +use crate::MxcUri; + +/// The payload for an extensible image message. +#[derive(Clone, Debug, Serialize, Deserialize, EventContent)] +#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] +#[ruma_event(type = "m.image", kind = MessageLike)] +pub struct ImageEventContent { + /// The text representation of the message. + #[serde(flatten)] + pub message: MessageContent, + + /// The file content of the message. + #[serde(rename = "org.matrix.msc1767.file")] + pub file: FileContent, + + /// The image content of the message. + #[serde(rename = "org.matrix.msc1767.image")] + pub image: Box, + + /// The thumbnails of the message. + #[serde( + rename = "org.matrix.msc1767.thumbnail", + default, + skip_serializing_if = "Thumbnails::is_empty" + )] + pub thumbnail: Thumbnails, + + /// The thumbnails of the message. + #[serde( + rename = "org.matrix.msc1767.caption", + default, + skip_serializing_if = "Captions::is_empty" + )] + pub caption: Captions, + + /// Information about related messages for [rich replies]. + /// + /// [rich replies]: https://spec.matrix.org/v1.2/client-server-api/#rich-replies + #[serde(flatten, skip_serializing_if = "Option::is_none")] + pub relates_to: Option, +} + +impl ImageEventContent { + /// Creates a new `ImageEventContent` with the given plain text message and file. + pub fn plain(message: impl Into, file: FileContent) -> Self { + Self { + message: MessageContent::plain(message), + file, + image: Default::default(), + thumbnail: Default::default(), + caption: Default::default(), + relates_to: None, + } + } + + /// Creates a new non-encrypted `ImageEventContent` with the given message and file. + pub fn with_message(message: MessageContent, file: FileContent) -> Self { + Self { + message, + file, + image: Default::default(), + thumbnail: Default::default(), + caption: Default::default(), + relates_to: None, + } + } +} + +/// Information about a thumbnail file content. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] +pub struct ThumbnailFileContentInfo { + /// The mimetype of the thumbnail, e.g. `image/png`. + #[serde(skip_serializing_if = "Option::is_none")] + pub mimetype: Option, + + /// The size of the thumbnail in bytes. + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option, +} + +impl ThumbnailFileContentInfo { + /// Creates an empty `ThumbnailFileContentInfo`. + pub fn new() -> Self { + Self::default() + } +} + +/// Thumbnail file content. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] +pub struct ThumbnailFileContent { + /// The URL to the thumbnail. + pub url: Box, + + /// Information about the uploaded thumbnail. + #[serde(flatten, skip_serializing_if = "Option::is_none")] + pub info: Option>, + + /// Information on the encrypted thumbnail. + /// + /// Required if the thumbnail is encrypted. + #[serde(flatten, skip_serializing_if = "Option::is_none")] + pub encryption_info: Option>, +} + +impl ThumbnailFileContent { + /// Creates a new non-encrypted `ThumbnailFileContent` with the given url and file info. + pub fn plain(url: Box, info: Option>) -> Self { + Self { url, info, encryption_info: None } + } + + /// Creates a new encrypted `ThumbnailFileContent` with the given url, encryption info and + /// thumbnail file info. + pub fn encrypted( + url: Box, + encryption_info: EncryptedContent, + info: Option>, + ) -> Self { + Self { url, info, encryption_info: Some(Box::new(encryption_info)) } + } + + /// Whether the thumbnail file is encrypted. + pub fn is_encrypted(&self) -> bool { + self.encryption_info.is_some() + } +} + +/// Thumbnail content. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] +pub struct ThumbnailContent { + /// The file info of the thumbnail. + #[serde(flatten)] + pub file: ThumbnailFileContent, + + /// The image info of the thumbnail. + #[serde(flatten, skip_serializing_if = "Option::is_none")] + pub image: Option>, +} + +impl ThumbnailContent { + /// Creates a `ThumbnailContent` with the given file and image info. + pub fn new(file: ThumbnailFileContent, image: Option>) -> Self { + Self { file, image } + } +} + +/// An array of thumbnails. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct Thumbnails(pub(crate) Vec); + +impl Thumbnails { + /// Creates a new `Thumbnails` with the given thumbnails. + /// + /// The thumbnails must be ordered by most preferred first. + pub fn new(thumbnails: &[ThumbnailContent]) -> Self { + Self(thumbnails.to_owned()) + } + + /// Get the thumbnails. + /// + /// The thumbnails are ordered by most preferred first. + pub fn thumbnails(&self) -> &[ThumbnailContent] { + &self.0 + } + + /// Whether this is empty. + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +/// An array of captions. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct Captions(pub(crate) Vec); + +impl Captions { + /// Creates a new `Captions` with the given captions. + /// + /// The captions must be ordered by most preferred first. + pub fn new(captions: &[Text]) -> Self { + Self(captions.to_owned()) + } + + /// A convenience constructor to create a plain text caption. + pub fn plain(body: impl Into) -> Self { + Self(vec![Text::plain(body)]) + } + + /// A convenience constructor to create an HTML caption. + pub fn html(body: impl Into, html_body: impl Into) -> Self { + Self(vec![Text::html(html_body), Text::plain(body)]) + } + + /// A convenience constructor to create a Markdown caption. + /// + /// Returns an HTML caption if some Markdown formatting was detected, otherwise returns a plain + /// text caption. + #[cfg(feature = "markdown")] + pub fn markdown(body: impl AsRef + Into) -> Self { + let mut message = Vec::with_capacity(2); + if let Some(html_body) = Text::markdown(&body) { + message.push(html_body); + } + message.push(Text::plain(body)); + Self(message) + } + + /// Get the captions. + /// + /// The captions are ordered by most preferred first. + pub fn captions(&self) -> &[Text] { + &self.0 + } + + /// Whether this is empty. + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// Get the plain text representation of this caption. + pub fn find_plain(&self) -> Option<&str> { + self.captions() + .iter() + .find(|content| content.mimetype == "text/plain") + .map(|content| content.body.as_ref()) + } + + /// Get the HTML representation of this caption. + pub fn find_html(&self) -> Option<&str> { + self.captions() + .iter() + .find(|content| content.mimetype == "text/html") + .map(|content| content.body.as_ref()) + } +} + +/// Image content. +#[derive(Default, Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] +pub struct ImageContent { + /// The height of the image in pixels. + #[serde(skip_serializing_if = "Option::is_none")] + pub height: Option, + + /// The width of the image in pixels. + #[serde(skip_serializing_if = "Option::is_none")] + pub width: Option, +} + +impl ImageContent { + /// Creates a new empty `ImageContent`. + pub fn new() -> Self { + Self::default() + } + + /// Creates a new `ImageContent` with the given width and height. + pub fn with_size(width: UInt, height: UInt) -> Self { + Self { height: Some(height), width: Some(width) } + } +} diff --git a/crates/ruma-common/tests/events/image.rs b/crates/ruma-common/tests/events/image.rs new file mode 100644 index 00000000..53aef394 --- /dev/null +++ b/crates/ruma-common/tests/events/image.rs @@ -0,0 +1,322 @@ +#![cfg(feature = "unstable-msc3552")] + +use assign::assign; +use js_int::uint; +use matches::assert_matches; +use ruma_common::{ + event_id, + events::{ + file::{EncryptedContentInit, FileContent, FileContentInfo}, + image::{ + Captions, ImageContent, ImageEventContent, ThumbnailContent, ThumbnailFileContent, + ThumbnailFileContentInfo, Thumbnails, + }, + message::MessageContent, + room::{ + message::{InReplyTo, Relation}, + JsonWebKeyInit, + }, + AnyMessageLikeEvent, MessageLikeEvent, Unsigned, + }, + mxc_uri, room_id, + serde::Base64, + user_id, MilliSecondsSinceUnixEpoch, +}; +use serde_json::{from_value as from_json_value, json, to_value as to_json_value}; + +#[test] +fn plain_content_serialization() { + let event_content = ImageEventContent::plain( + "Upload: my_image.jpg", + FileContent::plain(mxc_uri!("mxc://notareal.hs/abcdef").to_owned(), None), + ); + + assert_eq!( + to_json_value(&event_content).unwrap(), + json!({ + "org.matrix.msc1767.text": "Upload: my_image.jpg", + "org.matrix.msc1767.file": { + "url": "mxc://notareal.hs/abcdef", + }, + "org.matrix.msc1767.image": {} + }) + ); +} + +#[test] +fn encrypted_content_serialization() { + let event_content = ImageEventContent::plain( + "Upload: my_image.jpg", + FileContent::encrypted( + mxc_uri!("mxc://notareal.hs/abcdef").to_owned(), + EncryptedContentInit { + key: JsonWebKeyInit { + kty: "oct".to_owned(), + key_ops: vec!["encrypt".to_owned(), "decrypt".to_owned()], + alg: "A256CTR".to_owned(), + k: Base64::parse("TLlG_OpX807zzQuuwv4QZGJ21_u7weemFGYJFszMn9A").unwrap(), + ext: true, + } + .into(), + iv: Base64::parse("S22dq3NAX8wAAAAAAAAAAA").unwrap(), + hashes: [( + "sha256".to_owned(), + Base64::parse("aWOHudBnDkJ9IwaR1Nd8XKoI7DOrqDTwt6xDPfVGN6Q").unwrap(), + )] + .into(), + v: "v2".to_owned(), + } + .into(), + None, + ), + ); + + assert_eq!( + to_json_value(&event_content).unwrap(), + json!({ + "org.matrix.msc1767.text": "Upload: my_image.jpg", + "org.matrix.msc1767.file": { + "url": "mxc://notareal.hs/abcdef", + "key": { + "kty": "oct", + "key_ops": ["encrypt", "decrypt"], + "alg": "A256CTR", + "k": "TLlG_OpX807zzQuuwv4QZGJ21_u7weemFGYJFszMn9A", + "ext": true + }, + "iv": "S22dq3NAX8wAAAAAAAAAAA", + "hashes": { + "sha256": "aWOHudBnDkJ9IwaR1Nd8XKoI7DOrqDTwt6xDPfVGN6Q" + }, + "v": "v2" + }, + "org.matrix.msc1767.image": {} + }) + ); +} + +#[test] +fn image_event_serialization() { + let event = MessageLikeEvent { + content: assign!( + ImageEventContent::with_message( + MessageContent::html( + "Upload: my_house.jpg", + "Upload: my_house.jpg", + ), + FileContent::plain( + mxc_uri!("mxc://notareal.hs/abcdef").to_owned(), + Some(Box::new(assign!( + FileContentInfo::new(), + { + name: Some("my_house.jpg".to_owned()), + mimetype: Some("image/jpeg".to_owned()), + size: Some(uint!(897_774)), + } + ))), + ) + ), + { + image: Box::new(ImageContent::with_size(uint!(1920), uint!(1080))), + thumbnail: Thumbnails::new(&[ThumbnailContent::new( + ThumbnailFileContent::plain( + mxc_uri!("mxc://notareal.hs/thumbnail").to_owned(), + Some(Box::new(assign!(ThumbnailFileContentInfo::new(), { + mimetype: Some("image/jpeg".to_owned()), + size: Some(uint!(334_593)), + }))) + ), + None + )]), + caption: Captions::plain("This is my house"), + relates_to: Some(Relation::Reply { + in_reply_to: InReplyTo::new(event_id!("$replyevent:example.com").to_owned()), + }), + } + ), + event_id: event_id!("$event:notareal.hs").to_owned(), + sender: user_id!("@user:notareal.hs").to_owned(), + origin_server_ts: MilliSecondsSinceUnixEpoch(uint!(134_829_848)), + room_id: room_id!("!roomid:notareal.hs").to_owned(), + unsigned: Unsigned::default(), + }; + + assert_eq!( + to_json_value(&event).unwrap(), + json!({ + "content": { + "org.matrix.msc1767.message": [ + { "body": "Upload: my_house.jpg", "mimetype": "text/html"}, + { "body": "Upload: my_house.jpg", "mimetype": "text/plain"}, + ], + "org.matrix.msc1767.file": { + "url": "mxc://notareal.hs/abcdef", + "name": "my_house.jpg", + "mimetype": "image/jpeg", + "size": 897_774, + }, + "org.matrix.msc1767.image": { + "width": 1920, + "height": 1080, + }, + "org.matrix.msc1767.thumbnail": [ + { + "url": "mxc://notareal.hs/thumbnail", + "mimetype": "image/jpeg", + "size": 334_593, + } + ], + "org.matrix.msc1767.caption": [ + { + "body": "This is my house", + "mimetype": "text/plain", + } + ], + "m.relates_to": { + "m.in_reply_to": { + "event_id": "$replyevent:example.com" + } + } + }, + "event_id": "$event:notareal.hs", + "origin_server_ts": 134_829_848, + "room_id": "!roomid:notareal.hs", + "sender": "@user:notareal.hs", + "type": "m.image", + }) + ); +} + +#[test] +fn plain_content_deserialization() { + let json_data = json!({ + "org.matrix.msc1767.text": "Upload: my_cat.png", + "org.matrix.msc1767.file": { + "url": "mxc://notareal.hs/abcdef", + }, + "org.matrix.msc1767.image": { + "width": 668, + }, + "org.matrix.msc1767.caption": [ + { + "body": "Look at my cat!", + } + ] + }); + + assert_matches!( + from_json_value::(json_data) + .unwrap(), + ImageEventContent { message, file, image, thumbnail, caption, .. } + if message.find_plain() == Some("Upload: my_cat.png") + && message.find_html().is_none() + && file.url == "mxc://notareal.hs/abcdef" + && image.width == Some(uint!(668)) + && image.height.is_none() + && thumbnail.is_empty() + && caption.find_plain() == Some("Look at my cat!") + ); +} + +#[test] +fn encrypted_content_deserialization() { + let json_data = json!({ + "org.matrix.msc1767.text": "Upload: my_file.txt", + "org.matrix.msc1767.file": { + "url": "mxc://notareal.hs/abcdef", + "key": { + "kty": "oct", + "key_ops": ["encrypt", "decrypt"], + "alg": "A256CTR", + "k": "TLlG_OpX807zzQuuwv4QZGJ21_u7weemFGYJFszMn9A", + "ext": true + }, + "iv": "S22dq3NAX8wAAAAAAAAAAA", + "hashes": { + "sha256": "aWOHudBnDkJ9IwaR1Nd8XKoI7DOrqDTwt6xDPfVGN6Q" + }, + "v": "v2" + }, + "org.matrix.msc1767.image": {}, + "org.matrix.msc1767.thumbnail": [ + { + "url": "mxc://notareal.hs/thumbnail", + } + ] + }); + + assert_matches!( + from_json_value::(json_data) + .unwrap(), + ImageEventContent { message, file, image, thumbnail, caption, .. } + if message.find_plain() == Some("Upload: my_file.txt") + && message.find_html().is_none() + && file.url == "mxc://notareal.hs/abcdef" + && file.encryption_info.is_some() + && image.width.is_none() + && image.height.is_none() + && thumbnail.thumbnails()[0].file.url == "mxc://notareal.hs/thumbnail" + && caption.is_empty() + ); +} + +#[test] +fn message_event_deserialization() { + let json_data = json!({ + "content": { + "org.matrix.msc1767.text": "Upload: my_gnome.webp", + "org.matrix.msc1767.file": { + "url": "mxc://notareal.hs/abcdef", + "name": "my_gnome.webp", + "mimetype": "image/webp", + "size": 123_774, + }, + "org.matrix.msc1767.image": { + "width": 1300, + "height": 837, + } + }, + "event_id": "$event:notareal.hs", + "origin_server_ts": 134_829_848, + "room_id": "!roomid:notareal.hs", + "sender": "@user:notareal.hs", + "type": "m.image", + }); + + assert_matches!( + from_json_value::(json_data).unwrap(), + AnyMessageLikeEvent::Image(MessageLikeEvent { + content: ImageEventContent { + message, + file: FileContent { + url, + info: Some(info), + .. + }, + image, + thumbnail, + caption, + .. + }, + event_id, + origin_server_ts, + room_id, + sender, + unsigned + }) if event_id == event_id!("$event:notareal.hs") + && message.find_plain() == Some("Upload: my_gnome.webp") + && message.find_html().is_none() + && url == "mxc://notareal.hs/abcdef" + && info.name.as_deref() == Some("my_gnome.webp") + && info.mimetype.as_deref() == Some("image/webp") + && info.size == Some(uint!(123_774)) + && image.width == Some(uint!(1300)) + && image.height == Some(uint!(837)) + && thumbnail.is_empty() + && caption.is_empty() + && origin_server_ts == MilliSecondsSinceUnixEpoch(uint!(134_829_848)) + && room_id == room_id!("!roomid:notareal.hs") + && sender == user_id!("@user:notareal.hs") + && unsigned.is_empty() + ); +} diff --git a/crates/ruma-common/tests/events/mod.rs b/crates/ruma-common/tests/events/mod.rs index 948cd884..583c0596 100644 --- a/crates/ruma-common/tests/events/mod.rs +++ b/crates/ruma-common/tests/events/mod.rs @@ -8,6 +8,7 @@ mod event_content; mod event_content_enum; mod event_enums; mod file; +mod image; mod initial_state; mod message; mod message_event; diff --git a/crates/ruma/Cargo.toml b/crates/ruma/Cargo.toml index 0338d829..a1688d8b 100644 --- a/crates/ruma/Cargo.toml +++ b/crates/ruma/Cargo.toml @@ -118,6 +118,7 @@ unstable-msc2675 = ["ruma-common/unstable-msc2675"] unstable-msc2676 = ["ruma-common/unstable-msc2676"] unstable-msc2677 = ["ruma-common/unstable-msc2677"] unstable-msc3551 = ["ruma-common/unstable-msc3551"] +unstable-msc3552 = ["ruma-common/unstable-msc3552"] unstable-msc3618 = ["ruma-federation-api/unstable-msc3618"] # Private feature, only used in test / benchmarking code @@ -130,6 +131,7 @@ __ci = [ "unstable-msc2676", "unstable-msc2677", "unstable-msc3551", + "unstable-msc3552", "unstable-msc3618", "ruma-state-res/__ci", ]