diff --git a/crates/ruma-common/src/events/enums.rs b/crates/ruma-common/src/events/enums.rs index eae7926c..f8be7ef7 100644 --- a/crates/ruma-common/src/events/enums.rs +++ b/crates/ruma-common/src/events/enums.rs @@ -55,7 +55,8 @@ event_enum! { #[ruma_enum(alias = "m.file")] "org.matrix.msc1767.file" => super::file, #[cfg(feature = "unstable-msc3552")] - "m.image" => super::image, + #[ruma_enum(alias = "m.image")] + "org.matrix.msc1767.image" => super::image, "m.key.verification.ready" => super::key::verification::ready, "m.key.verification.start" => super::key::verification::start, "m.key.verification.cancel" => super::key::verification::cancel, diff --git a/crates/ruma-common/src/events/image.rs b/crates/ruma-common/src/events/image.rs index 59308b3e..68881a69 100644 --- a/crates/ruma-common/src/events/image.rs +++ b/crates/ruma-common/src/events/image.rs @@ -2,12 +2,14 @@ //! //! [MSC3552]: https://github.com/matrix-org/matrix-spec-proposals/pull/3552 +use std::ops::Deref; + use js_int::UInt; use ruma_macros::EventContent; use serde::{Deserialize, Serialize}; use super::{ - file::{EncryptedContent, FileContentBlock}, + file::{CaptionContentBlock, EncryptedContent, FileContentBlock}, message::TextContentBlock, room::message::Relation, }; @@ -16,13 +18,15 @@ use crate::OwnedMxcUri; /// The payload for an extensible image message. /// /// This is the new primary type introduced in [MSC3552] and should only be sent in rooms with a -/// version that supports it. See the documentation of the [`message`] module for more information. +/// version that supports it. This type replaces both the `m.room.message` type with `msgtype: +/// "m.image"` and the `m.sticker` type. To replace the latter, `sticker` must be set to `true` in +/// `image_details`. See the documentation of the [`message`] module for more information. /// /// [MSC3552]: https://github.com/matrix-org/matrix-spec-proposals/pull/3552 /// [`message`]: super::message #[derive(Clone, Debug, Serialize, Deserialize, EventContent)] #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] -#[ruma_event(type = "m.image", kind = MessageLike, without_relation)] +#[ruma_event(type = "org.matrix.msc1767.image", kind = MessageLike, without_relation)] pub struct ImageEventContent { /// The text representation of the message. #[serde(rename = "org.matrix.msc1767.text")] @@ -32,17 +36,36 @@ pub struct ImageEventContent { #[serde(rename = "org.matrix.msc1767.file")] pub file: FileContentBlock, - /// The image content of the message. - #[serde(rename = "m.image")] - pub image: Box, + /// The image details of the message, if any. + #[serde(rename = "org.matrix.msc1767.image_details", skip_serializing_if = "Option::is_none")] + pub image_details: Option, - /// The thumbnails of the message. - #[serde(rename = "m.thumbnail", default, skip_serializing_if = "Vec::is_empty")] - pub thumbnail: Vec, + /// The thumbnails of the message, if any. + /// + /// This is optional and defaults to an empty array. + #[serde( + rename = "org.matrix.msc1767.thumbnail", + default, + skip_serializing_if = "ThumbnailContentBlock::is_empty" + )] + pub thumbnail: ThumbnailContentBlock, - /// The captions of the message. - #[serde(rename = "m.caption", default, skip_serializing_if = "TextContentBlock::is_empty")] - pub caption: TextContentBlock, + /// The caption of the message, if any. + #[serde(rename = "org.matrix.msc1767.caption", skip_serializing_if = "Option::is_none")] + pub caption: Option, + + /// The alternative text of the image, for accessibility considerations, if any. + #[serde(rename = "org.matrix.msc1767.alt_text", skip_serializing_if = "Option::is_none")] + pub alt_text: Option, + + /// Whether this message is automated. + #[cfg(feature = "unstable-msc3955")] + #[serde( + default, + skip_serializing_if = "crate::serde::is_default", + rename = "org.matrix.msc1767.automated" + )] + pub automated: bool, /// Information about related messages. #[serde( @@ -60,87 +83,136 @@ impl ImageEventContent { Self { text, file, - image: Default::default(), + image_details: None, thumbnail: Default::default(), - caption: Default::default(), + caption: None, + alt_text: None, + #[cfg(feature = "unstable-msc3955")] + automated: false, relates_to: None, } } /// Creates a new `ImageEventContent` with the given plain text fallback representation and /// file. - pub fn plain(text_fallback: impl Into, file: FileContentBlock) -> Self { + pub fn with_plain_text(plain_text: impl Into, file: FileContentBlock) -> Self { Self { - text: TextContentBlock::plain(text_fallback), + text: TextContentBlock::plain(plain_text), file, - image: Default::default(), + image_details: None, thumbnail: Default::default(), - caption: Default::default(), + caption: None, + alt_text: None, + #[cfg(feature = "unstable-msc3955")] + automated: false, relates_to: None, } } } -/// Image content. +/// A block for details of image content. #[derive(Default, Clone, Debug, Serialize, Deserialize)] #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] -pub struct ImageContent { +pub struct ImageDetailsContentBlock { /// The height of the image in pixels. - #[serde(skip_serializing_if = "Option::is_none")] - pub height: Option, + pub height: UInt, /// The width of the image in pixels. - #[serde(skip_serializing_if = "Option::is_none")] - pub width: Option, + pub width: UInt, + + /// Whether the image should be presented as sticker. + #[serde( + rename = "org.matrix.msc1767.sticker", + default, + skip_serializing_if = "crate::serde::is_default" + )] + pub sticker: bool, } -impl ImageContent { - /// Creates a new empty `ImageContent`. - pub fn new() -> Self { - Self::default() +impl ImageDetailsContentBlock { + /// Creates a new `ImageDetailsContentBlock` with the given width and height. + pub fn new(width: UInt, height: UInt) -> Self { + Self { height, width, sticker: Default::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) } - } +/// A block for thumbnail content. +/// +/// This is an array of [`Thumbnail`]. +/// +/// To construct a `ThumbnailContentBlock` convert a `Vec` with +/// `ThumbnailContentBlock::from()` / `.into()`. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[allow(clippy::exhaustive_structs)] +pub struct ThumbnailContentBlock(Vec); - /// Whether this `ImageContent` is empty. +impl ThumbnailContentBlock { + /// Whether this content block is empty. pub fn is_empty(&self) -> bool { - self.height.is_none() && self.width.is_none() + self.0.is_empty() + } +} + +impl From> for ThumbnailContentBlock { + fn from(thumbnails: Vec) -> Self { + Self(thumbnails) + } +} + +impl FromIterator for ThumbnailContentBlock { + fn from_iter>(iter: T) -> Self { + Self(iter.into_iter().collect()) + } +} + +impl Deref for ThumbnailContentBlock { + type Target = [Thumbnail]; + + fn deref(&self) -> &Self::Target { + &self.0 } } /// Thumbnail content. #[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] -pub struct ThumbnailContent { +pub struct Thumbnail { /// The file info of the thumbnail. - #[serde(flatten)] - pub file: ThumbnailFileContent, + #[serde(rename = "org.matrix.msc1767.file")] + pub file: ThumbnailFileContentBlock, /// The image info of the thumbnail. - #[serde(flatten, skip_serializing_if = "Option::is_none")] - pub image: Option>, + #[serde(rename = "org.matrix.msc1767.image_details")] + pub image_details: ThumbnailImageDetailsContentBlock, } -impl ThumbnailContent { - /// Creates a `ThumbnailContent` with the given file and image info. - pub fn new(file: ThumbnailFileContent, image: Option>) -> Self { - Self { file, image } +impl Thumbnail { + /// Creates a `Thumbnail` with the given file and image details. + pub fn new( + file: ThumbnailFileContentBlock, + image_details: ThumbnailImageDetailsContentBlock, + ) -> Self { + Self { file, image_details } } } -/// Thumbnail file content. +/// A block for thumbnail file content. #[derive(Clone, Debug, Serialize, Deserialize)] #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] -pub struct ThumbnailFileContent { +pub struct ThumbnailFileContentBlock { /// The URL to the thumbnail. pub url: OwnedMxcUri, - /// Information about the uploaded thumbnail. - #[serde(flatten, skip_serializing_if = "Option::is_none")] - pub info: Option>, + /// The mimetype of the file, e.g. "image/png". + pub mimetype: String, + + /// The original filename of the uploaded file. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + + /// The size of the file in bytes. + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option, /// Information on the encrypted thumbnail. /// @@ -149,20 +221,26 @@ pub struct ThumbnailFileContent { pub encryption_info: Option>, } -impl ThumbnailFileContent { - /// Creates a new non-encrypted `ThumbnailFileContent` with the given url and file info. - pub fn plain(url: OwnedMxcUri, info: Option>) -> Self { - Self { url, info, encryption_info: None } +impl ThumbnailFileContentBlock { + /// Creates a new non-encrypted `ThumbnailFileContentBlock` with the given url and mimetype. + pub fn plain(url: OwnedMxcUri, mimetype: String) -> Self { + Self { url, mimetype, name: None, size: None, encryption_info: None } } - /// Creates a new encrypted `ThumbnailFileContent` with the given url, encryption info and - /// thumbnail file info. + /// Creates a new encrypted `ThumbnailFileContentBlock` with the given url, mimetype and + /// encryption info. pub fn encrypted( url: OwnedMxcUri, + mimetype: String, encryption_info: EncryptedContent, - info: Option>, ) -> Self { - Self { url, info, encryption_info: Some(Box::new(encryption_info)) } + Self { + url, + mimetype, + name: None, + size: None, + encryption_info: Some(Box::new(encryption_info)), + } } /// Whether the thumbnail file is encrypted. @@ -171,22 +249,47 @@ impl ThumbnailFileContent { } } -/// Information about a thumbnail file content. -#[derive(Clone, Debug, Default, Serialize, Deserialize)] +/// A block for details of thumbnail image content. +#[derive(Default, Clone, Debug, 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, +pub struct ThumbnailImageDetailsContentBlock { + /// The height of the image in pixels. + pub height: UInt, - /// The size of the thumbnail in bytes. - #[serde(skip_serializing_if = "Option::is_none")] - pub size: Option, + /// The width of the image in pixels. + pub width: UInt, } -impl ThumbnailFileContentInfo { - /// Creates an empty `ThumbnailFileContentInfo`. - pub fn new() -> Self { - Self::default() +impl ThumbnailImageDetailsContentBlock { + /// Creates a new `ThumbnailImageDetailsContentBlock` with the given width and height. + pub fn new(width: UInt, height: UInt) -> Self { + Self { height, width } + } +} + +/// A block for alternative text content. +/// +/// The content should only contain plain text messages. Non-plain text messages should be ignored. +/// +/// To construct an `AltTextContentBlock` with a custom [`TextContentBlock`], convert it with +/// `AltTextContentBlock::from()` / `.into()`. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] +pub struct AltTextContentBlock { + /// The alternative text. + #[serde(rename = "org.matrix.msc1767.text")] + pub text: TextContentBlock, +} + +impl AltTextContentBlock { + /// A convenience constructor to create a plain text alternative text content block. + pub fn plain(body: impl Into) -> Self { + Self { text: TextContentBlock::plain(body) } + } +} + +impl From for AltTextContentBlock { + fn from(text: TextContentBlock) -> Self { + Self { text } } } diff --git a/crates/ruma-common/src/events/video.rs b/crates/ruma-common/src/events/video.rs index a20a25c8..04ab20dd 100644 --- a/crates/ruma-common/src/events/video.rs +++ b/crates/ruma-common/src/events/video.rs @@ -9,7 +9,7 @@ use ruma_macros::EventContent; use serde::{Deserialize, Serialize}; use super::{ - file::FileContentBlock, image::ThumbnailContent, message::TextContentBlock, + file::FileContentBlock, image::ThumbnailContentBlock, message::TextContentBlock, room::message::Relation, }; @@ -37,8 +37,12 @@ pub struct VideoEventContent { pub video: Box, /// The thumbnails of the message. - #[serde(rename = "m.thumbnail", default, skip_serializing_if = "Vec::is_empty")] - pub thumbnail: Vec, + #[serde( + rename = "org.matrix.msc1767.thumbnail", + default, + skip_serializing_if = "ThumbnailContentBlock::is_empty" + )] + pub thumbnail: ThumbnailContentBlock, /// The captions of the message. #[serde(rename = "m.caption", default, skip_serializing_if = "TextContentBlock::is_empty")] diff --git a/crates/ruma-common/tests/events/image.rs b/crates/ruma-common/tests/events/image.rs index de398a01..d00f2006 100644 --- a/crates/ruma-common/tests/events/image.rs +++ b/crates/ruma-common/tests/events/image.rs @@ -1,15 +1,14 @@ #![cfg(feature = "unstable-msc3552")] use assert_matches::assert_matches; -use assign::assign; use js_int::uint; use ruma_common::{ event_id, events::{ - file::{EncryptedContentInit, FileContentBlock}, + file::{CaptionContentBlock, EncryptedContentInit, FileContentBlock}, image::{ - ImageContent, ImageEventContent, ThumbnailContent, ThumbnailFileContent, - ThumbnailFileContentInfo, + ImageDetailsContentBlock, ImageEventContent, Thumbnail, ThumbnailFileContentBlock, + ThumbnailImageDetailsContentBlock, }, message::TextContentBlock, relation::InReplyTo, @@ -24,7 +23,7 @@ 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( + let event_content = ImageEventContent::with_plain_text( "Upload: my_image.jpg", FileContentBlock::plain( mxc_uri!("mxc://notareal.hs/abcdef").to_owned(), @@ -42,14 +41,13 @@ fn plain_content_serialization() { "url": "mxc://notareal.hs/abcdef", "name": "my_image.jpg", }, - "m.image": {} }) ); } #[test] fn encrypted_content_serialization() { - let event_content = ImageEventContent::plain( + let event_content = ImageEventContent::with_plain_text( "Upload: my_image.jpg", FileContentBlock::encrypted( mxc_uri!("mxc://notareal.hs/abcdef").to_owned(), @@ -97,7 +95,6 @@ fn encrypted_content_serialization() { }, "v": "v2" }, - "m.image": {} }) ); } @@ -114,18 +111,17 @@ fn image_event_serialization() { content.file.mimetype = Some("image/jpeg".to_owned()); content.file.size = Some(uint!(897_774)); - content.image = Box::new(ImageContent::with_size(uint!(1920), uint!(1080))); - content.thumbnail = vec![ThumbnailContent::new( - ThumbnailFileContent::plain( + content.image_details = Some(ImageDetailsContentBlock::new(uint!(1920), uint!(1080))); + let mut thumbnail = Thumbnail::new( + ThumbnailFileContentBlock::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)), - }))), + "image/jpeg".to_owned(), ), - None, - )]; - content.caption = TextContentBlock::plain("This is my house"); + ThumbnailImageDetailsContentBlock::new(uint!(560), uint!(480)), + ); + thumbnail.file.size = Some(uint!(334_593)); + content.thumbnail = vec![thumbnail].into(); + content.caption = Some(CaptionContentBlock::plain("This is my house")); content.relates_to = Some(Relation::Reply { in_reply_to: InReplyTo::new(event_id!("$replyevent:example.com").to_owned()), }); @@ -143,22 +139,28 @@ fn image_event_serialization() { "mimetype": "image/jpeg", "size": 897_774, }, - "m.image": { + "org.matrix.msc1767.image_details": { "width": 1920, "height": 1080, }, - "m.thumbnail": [ + "org.matrix.msc1767.thumbnail": [ { - "url": "mxc://notareal.hs/thumbnail", - "mimetype": "image/jpeg", - "size": 334_593, - } - ], - "m.caption": [ - { - "body": "This is my house", - } + "org.matrix.msc1767.file": { + "url": "mxc://notareal.hs/thumbnail", + "mimetype": "image/jpeg", + "size": 334_593, + }, + "org.matrix.msc1767.image_details": { + "width": 560, + "height": 480, + }, + }, ], + "org.matrix.msc1767.caption": { + "org.matrix.msc1767.text": [ + { "body": "This is my house" }, + ], + }, "m.relates_to": { "m.in_reply_to": { "event_id": "$replyevent:example.com" @@ -178,14 +180,15 @@ fn plain_content_deserialization() { "url": "mxc://notareal.hs/abcdef", "name": "my_cat.png", }, - "m.image": { + "org.matrix.msc1767.image_details": { "width": 668, + "height": 1023, + }, + "org.matrix.msc1767.caption": { + "org.matrix.msc1767.text": [ + { "body": "Look at my cat!" }, + ], }, - "m.caption": [ - { - "body": "Look at my cat!", - } - ] }); let content = from_json_value::(json_data).unwrap(); @@ -194,11 +197,13 @@ fn plain_content_deserialization() { assert_eq!(content.file.url, "mxc://notareal.hs/abcdef"); assert_eq!(content.file.name, "my_cat.png"); assert_matches!(content.file.encryption_info, None); - assert_eq!(content.image.width, Some(uint!(668))); - assert_eq!(content.image.height, None); + let image_details = content.image_details.unwrap(); + assert_eq!(image_details.width, uint!(668)); + assert_eq!(image_details.height, uint!(1023)); assert_eq!(content.thumbnail.len(), 0); - assert_eq!(content.caption.len(), 1); - assert_eq!(content.caption.find_plain(), Some("Look at my cat!")); + let caption = content.caption.unwrap(); + assert_eq!(caption.text.len(), 1); + assert_eq!(caption.text.find_plain(), Some("Look at my cat!")); } #[test] @@ -223,10 +228,16 @@ fn encrypted_content_deserialization() { }, "v": "v2" }, - "m.image": {}, - "m.thumbnail": [ + "org.matrix.msc1767.thumbnail": [ { - "url": "mxc://notareal.hs/thumbnail", + "org.matrix.msc1767.file": { + "url": "mxc://notareal.hs/thumbnail", + "mimetype": "image/png", + }, + "org.matrix.msc1767.image_details": { + "width": 480, + "height": 560, + } } ] }); @@ -237,11 +248,14 @@ fn encrypted_content_deserialization() { assert_eq!(content.file.url, "mxc://notareal.hs/abcdef"); assert_eq!(content.file.name, "my_cat.png"); assert!(content.file.encryption_info.is_some()); - assert_eq!(content.image.width, None); - assert_eq!(content.image.height, None); + assert!(content.image_details.is_none()); assert_eq!(content.thumbnail.len(), 1); - assert_eq!(content.thumbnail[0].file.url, "mxc://notareal.hs/thumbnail"); - assert!(content.caption.is_empty()); + let thumbnail = &content.thumbnail[0]; + assert_eq!(thumbnail.file.url, "mxc://notareal.hs/thumbnail"); + assert_eq!(thumbnail.file.mimetype, "image/png"); + assert_eq!(thumbnail.image_details.width, uint!(480)); + assert_eq!(thumbnail.image_details.height, uint!(560)); + assert!(content.caption.is_none()); } #[test] @@ -257,7 +271,7 @@ fn message_event_deserialization() { "mimetype": "image/webp", "size": 123_774, }, - "m.image": { + "org.matrix.msc1767.image_details": { "width": 1300, "height": 837, } @@ -266,7 +280,7 @@ fn message_event_deserialization() { "origin_server_ts": 134_829_848, "room_id": "!roomid:notareal.hs", "sender": "@user:notareal.hs", - "type": "m.image", + "type": "org.matrix.msc1767.image", }); let message_event = assert_matches!( @@ -286,7 +300,8 @@ fn message_event_deserialization() { assert_eq!(content.file.name, "my_gnome.webp"); assert_eq!(content.file.mimetype.as_deref(), Some("image/webp")); assert_eq!(content.file.size, Some(uint!(123_774))); - assert_eq!(content.image.width, Some(uint!(1300))); - assert_eq!(content.image.height, Some(uint!(837))); + let image_details = content.image_details.unwrap(); + assert_eq!(image_details.width, uint!(1300)); + assert_eq!(image_details.height, uint!(837)); assert_eq!(content.thumbnail.len(), 0); } diff --git a/crates/ruma-common/tests/events/video.rs b/crates/ruma-common/tests/events/video.rs index 46355835..5c0c67a5 100644 --- a/crates/ruma-common/tests/events/video.rs +++ b/crates/ruma-common/tests/events/video.rs @@ -9,7 +9,7 @@ use ruma_common::{ event_id, events::{ file::{EncryptedContentInit, FileContentBlock}, - image::{ThumbnailContent, ThumbnailFileContent, ThumbnailFileContentInfo}, + image::{Thumbnail, ThumbnailFileContentBlock, ThumbnailImageDetailsContentBlock}, message::TextContentBlock, relation::InReplyTo, room::{message::Relation, JsonWebKeyInit}, @@ -125,16 +125,15 @@ fn event_serialization() { duration: Some(Duration::from_secs(15)), } )); - content.thumbnail = vec![ThumbnailContent::new( - ThumbnailFileContent::plain( + let mut thumbnail = Thumbnail::new( + ThumbnailFileContentBlock::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)), - }))), + "image/jpeg".to_owned(), ), - None, - )]; + ThumbnailImageDetailsContentBlock::new(uint!(560), uint!(480)), + ); + thumbnail.file.size = Some(uint!(334_593)); + content.thumbnail = vec![thumbnail].into(); content.caption = TextContentBlock::plain("This is my awesome vintage lava lamp"); content.relates_to = Some(Relation::Reply { in_reply_to: InReplyTo::new(event_id!("$replyevent:example.com").to_owned()), @@ -158,11 +157,17 @@ fn event_serialization() { "height": 1080, "duration": 15_000, }, - "m.thumbnail": [ + "org.matrix.msc1767.thumbnail": [ { - "url": "mxc://notareal.hs/thumbnail", - "mimetype": "image/jpeg", - "size": 334_593, + "org.matrix.msc1767.file": { + "url": "mxc://notareal.hs/thumbnail", + "mimetype": "image/jpeg", + "size": 334_593, + }, + "org.matrix.msc1767.image_details": { + "width": 560, + "height": 480, + }, } ], "m.caption": [ @@ -236,9 +241,16 @@ fn encrypted_content_deserialization() { "v": "v2" }, "m.video": {}, - "m.thumbnail": [ + "org.matrix.msc1767.thumbnail": [ { - "url": "mxc://notareal.hs/thumbnail", + "org.matrix.msc1767.file": { + "url": "mxc://notareal.hs/thumbnail", + "mimetype": "image/png", + }, + "org.matrix.msc1767.image_details": { + "width": 560, + "height": 480, + }, } ] }); @@ -253,7 +265,11 @@ fn encrypted_content_deserialization() { assert_eq!(content.video.height, None); assert_eq!(content.video.duration, None); assert_eq!(content.thumbnail.len(), 1); - assert_eq!(content.thumbnail[0].file.url, "mxc://notareal.hs/thumbnail"); + let thumbnail = &content.thumbnail[0]; + assert_eq!(thumbnail.file.url, "mxc://notareal.hs/thumbnail"); + assert_eq!(thumbnail.file.mimetype, "image/png"); + assert_eq!(thumbnail.image_details.width, uint!(560)); + assert_eq!(thumbnail.image_details.height, uint!(480)); assert!(content.caption.is_empty()); }