diff --git a/crates/ruma-common/Cargo.toml b/crates/ruma-common/Cargo.toml index 7ca75b59..66453bdc 100644 --- a/crates/ruma-common/Cargo.toml +++ b/crates/ruma-common/Cargo.toml @@ -35,6 +35,7 @@ unstable-msc2676 = [] unstable-msc2677 = [] unstable-msc3551 = ["unstable-msc1767"] unstable-msc3552 = ["unstable-msc1767", "unstable-msc3551"] +unstable-msc3553 = ["unstable-msc3552"] [dependencies] base64 = "0.13.0" diff --git a/crates/ruma-common/src/events.rs b/crates/ruma-common/src/events.rs index 71269d0a..56c71190 100644 --- a/crates/ruma-common/src/events.rs +++ b/crates/ruma-common/src/events.rs @@ -179,6 +179,8 @@ pub mod space; pub mod sticker; pub mod tag; pub mod typing; +#[cfg(feature = "unstable-msc3553")] +pub mod video; #[cfg(feature = "unstable-msc2675")] pub use self::relation::Relations; diff --git a/crates/ruma-common/src/events/enums.rs b/crates/ruma-common/src/events/enums.rs index 67cdfb92..3c354684 100644 --- a/crates/ruma-common/src/events/enums.rs +++ b/crates/ruma-common/src/events/enums.rs @@ -61,6 +61,8 @@ event_enum! { "m.room.message.feedback", "m.room.redaction", "m.sticker", + #[cfg(feature = "unstable-msc3553")] + "m.video", } /// Any state event. @@ -372,6 +374,8 @@ impl AnyMessageLikeEventContent { Self::File(ev) => ev.relates_to.clone().map(Into::into), #[cfg(feature = "unstable-msc3552")] Self::Image(ev) => ev.relates_to.clone().map(Into::into), + #[cfg(feature = "unstable-msc3553")] + Self::Video(ev) => ev.relates_to.clone().map(Into::into), Self::CallAnswer(_) | Self::CallInvite(_) | Self::CallHangup(_) diff --git a/crates/ruma-common/src/events/video.rs b/crates/ruma-common/src/events/video.rs new file mode 100644 index 00000000..2ba5dd3b --- /dev/null +++ b/crates/ruma-common/src/events/video.rs @@ -0,0 +1,108 @@ +//! Types for extensible video message events ([MSC3553]). +//! +//! [MSC3553]: https://github.com/matrix-org/matrix-spec-proposals/pull/3553 + +use std::time::Duration; + +use js_int::UInt; +use ruma_macros::EventContent; +use serde::{Deserialize, Serialize}; + +use super::{ + file::FileContent, + image::{Captions, Thumbnails}, + message::MessageContent, + room::message::Relation, +}; + +/// The payload for an extensible video message. +#[derive(Clone, Debug, Serialize, Deserialize, EventContent)] +#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] +#[ruma_event(type = "m.video", kind = MessageLike)] +pub struct VideoEventContent { + /// 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 video content of the message. + #[serde(rename = "org.matrix.msc1767.video")] + pub video: Box, + + /// The thumbnails of the message. + #[serde( + rename = "org.matrix.msc1767.thumbnail", + default, + skip_serializing_if = "Thumbnails::is_empty" + )] + pub thumbnail: Thumbnails, + + /// The captions of the message. + #[serde( + rename = "org.matrix.msc1767.caption", + default, + skip_serializing_if = "Captions::is_empty" + )] + pub caption: Captions, + + /// Information about related messages. + #[serde(flatten, skip_serializing_if = "Option::is_none")] + pub relates_to: Option, +} + +impl VideoEventContent { + /// Creates a new `VideoEventContent` with the given plain text message and file. + pub fn plain(message: impl Into, file: FileContent) -> Self { + Self { + message: MessageContent::plain(message), + file, + video: Default::default(), + thumbnail: Default::default(), + caption: Default::default(), + relates_to: None, + } + } + + /// Creates a new `VideoEventContent` with the given message and file. + pub fn with_message(message: MessageContent, file: FileContent) -> Self { + Self { + message, + file, + video: Default::default(), + thumbnail: Default::default(), + caption: Default::default(), + relates_to: None, + } + } +} + +/// Video content. +#[derive(Default, Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] +pub struct VideoContent { + /// The height of the video in pixels. + #[serde(skip_serializing_if = "Option::is_none")] + pub height: Option, + + /// The width of the video in pixels. + #[serde(skip_serializing_if = "Option::is_none")] + pub width: Option, + + /// The duration of the video in milliseconds. + #[serde( + with = "ruma_common::serde::duration::opt_ms", + default, + skip_serializing_if = "Option::is_none" + )] + pub duration: Option, +} + +impl VideoContent { + /// Creates a new empty `VideoContent`. + pub fn new() -> Self { + Self::default() + } +} diff --git a/crates/ruma-common/tests/events/mod.rs b/crates/ruma-common/tests/events/mod.rs index 583c0596..d44036e9 100644 --- a/crates/ruma-common/tests/events/mod.rs +++ b/crates/ruma-common/tests/events/mod.rs @@ -19,3 +19,4 @@ mod room_message; mod state_event; mod stripped; mod to_device; +mod video; diff --git a/crates/ruma-common/tests/events/video.rs b/crates/ruma-common/tests/events/video.rs new file mode 100644 index 00000000..2f1f9526 --- /dev/null +++ b/crates/ruma-common/tests/events/video.rs @@ -0,0 +1,335 @@ +#![cfg(feature = "unstable-msc3553")] + +use std::time::Duration; + +use assign::assign; +use js_int::uint; +use matches::assert_matches; +use ruma_common::{ + event_id, + events::{ + file::{EncryptedContentInit, FileContent, FileContentInfo}, + image::{ + Captions, ThumbnailContent, ThumbnailFileContent, ThumbnailFileContentInfo, Thumbnails, + }, + message::MessageContent, + room::{ + message::{InReplyTo, Relation}, + JsonWebKeyInit, + }, + video::{VideoContent, VideoEventContent}, + 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 = VideoEventContent::plain( + "Upload: my_video.webm", + 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_video.webm", + "org.matrix.msc1767.file": { + "url": "mxc://notareal.hs/abcdef", + }, + "org.matrix.msc1767.video": {} + }) + ); +} + +#[test] +fn encrypted_content_serialization() { + let event_content = VideoEventContent::plain( + "Upload: my_video.webm", + 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_video.webm", + "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.video": {} + }) + ); +} + +#[test] +fn event_serialization() { + let event = MessageLikeEvent { + content: assign!( + VideoEventContent::with_message( + MessageContent::html( + "Upload: my_lava_lamp.webm", + "Upload: my_lava_lamp.webm", + ), + FileContent::plain( + mxc_uri!("mxc://notareal.hs/abcdef").to_owned(), + Some(Box::new(assign!( + FileContentInfo::new(), + { + name: Some("my_lava_lamp.webm".to_owned()), + mimetype: Some("video/webm".to_owned()), + size: Some(uint!(1_897_774)), + } + ))), + ) + ), + { + video: Box::new(assign!( + VideoContent::new(), + { + width: Some(uint!(1920)), + height: Some(uint!(1080)), + duration: Some(Duration::from_secs(15)), + } + )), + 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 awesome vintage lava lamp"), + 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_lava_lamp.webm", "mimetype": "text/html"}, + { "body": "Upload: my_lava_lamp.webm", "mimetype": "text/plain"}, + ], + "org.matrix.msc1767.file": { + "url": "mxc://notareal.hs/abcdef", + "name": "my_lava_lamp.webm", + "mimetype": "video/webm", + "size": 1_897_774, + }, + "org.matrix.msc1767.video": { + "width": 1920, + "height": 1080, + "duration": 15_000, + }, + "org.matrix.msc1767.thumbnail": [ + { + "url": "mxc://notareal.hs/thumbnail", + "mimetype": "image/jpeg", + "size": 334_593, + } + ], + "org.matrix.msc1767.caption": [ + { + "body": "This is my awesome vintage lava lamp", + "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.video", + }) + ); +} + +#[test] +fn plain_content_deserialization() { + let json_data = json!({ + "org.matrix.msc1767.text": "Video: my_cat.mp4", + "org.matrix.msc1767.file": { + "url": "mxc://notareal.hs/abcdef", + }, + "org.matrix.msc1767.video": { + "duration": 5_668, + }, + "org.matrix.msc1767.caption": [ + { + "body": "Look at my cat!", + } + ] + }); + + assert_matches!( + from_json_value::(json_data) + .unwrap(), + VideoEventContent { message, file, video, thumbnail, caption, .. } + if message.find_plain() == Some("Video: my_cat.mp4") + && message.find_html().is_none() + && file.url == "mxc://notareal.hs/abcdef" + && video.width.is_none() + && video.height.is_none() + && video.duration == Some(Duration::from_millis(5_668)) + && 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_cat.mp4", + "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.video": {}, + "org.matrix.msc1767.thumbnail": [ + { + "url": "mxc://notareal.hs/thumbnail", + } + ] + }); + + assert_matches!( + from_json_value::(json_data) + .unwrap(), + VideoEventContent { message, file, video, thumbnail, caption, .. } + if message.find_plain() == Some("Upload: my_cat.mp4") + && message.find_html().is_none() + && file.url == "mxc://notareal.hs/abcdef" + && file.encryption_info.is_some() + && video.width.is_none() + && video.height.is_none() + && video.duration.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.webm", + "org.matrix.msc1767.file": { + "url": "mxc://notareal.hs/abcdef", + "name": "my_gnome.webm", + "mimetype": "video/webm", + "size": 123_774, + }, + "org.matrix.msc1767.video": { + "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.video", + }); + + assert_matches!( + from_json_value::(json_data).unwrap(), + AnyMessageLikeEvent::Video(MessageLikeEvent { + content: VideoEventContent { + message, + file: FileContent { + url, + info: Some(info), + .. + }, + video, + 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.webm") + && message.find_html().is_none() + && url == "mxc://notareal.hs/abcdef" + && info.name.as_deref() == Some("my_gnome.webm") + && info.mimetype.as_deref() == Some("video/webm") + && info.size == Some(uint!(123_774)) + && video.width == Some(uint!(1300)) + && video.height == Some(uint!(837)) + && video.duration.is_none() + && 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/Cargo.toml b/crates/ruma/Cargo.toml index bfb63955..a2eb56fe 100644 --- a/crates/ruma/Cargo.toml +++ b/crates/ruma/Cargo.toml @@ -119,6 +119,7 @@ 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-msc3553 = ["ruma-common/unstable-msc3553"] unstable-msc3618 = ["ruma-federation-api/unstable-msc3618"] unstable-msc3723 = ["ruma-federation-api/unstable-msc3723"] @@ -133,6 +134,7 @@ __ci = [ "unstable-msc2677", "unstable-msc3551", "unstable-msc3552", + "unstable-msc3553", "unstable-msc3618", "unstable-msc3723", "ruma-state-res/__ci",