diff --git a/crates/ruma-events/CHANGELOG.md b/crates/ruma-events/CHANGELOG.md index 683bbc6d..48879d4a 100644 --- a/crates/ruma-events/CHANGELOG.md +++ b/crates/ruma-events/CHANGELOG.md @@ -23,6 +23,7 @@ The new format (Session) is required to reliably display the call member count ( `CallMemberEventContent` is now an enum to model the two different formats. - `CallMemberStateKey` (instead of `OwnedUserId`) is now used as the state key type for `CallMemberEventContent`. This guarantees correct formatting of the event key. +- Add helpers for captions on audio, file, image and video messages. Breaking changes: diff --git a/crates/ruma-events/src/room/message.rs b/crates/ruma-events/src/room/message.rs index 39f7475a..2f1d016e 100644 --- a/crates/ruma-events/src/room/message.rs +++ b/crates/ruma-events/src/room/message.rs @@ -30,6 +30,7 @@ mod file; mod image; mod key_verification_request; mod location; +mod media_caption; mod notice; mod relation; pub(crate) mod relation_serde; diff --git a/crates/ruma-events/src/room/message/audio.rs b/crates/ruma-events/src/room/message/audio.rs index edd59f7e..438e3f5d 100644 --- a/crates/ruma-events/src/room/message/audio.rs +++ b/crates/ruma-events/src/room/message/audio.rs @@ -5,7 +5,10 @@ use ruma_common::OwnedMxcUri; use serde::{Deserialize, Serialize}; use super::FormattedBody; -use crate::room::{EncryptedFile, MediaSource}; +use crate::room::{ + message::media_caption::{caption, formatted_caption}, + EncryptedFile, MediaSource, +}; /// The payload for an audio message. #[derive(Clone, Debug, Deserialize, Serialize)] @@ -88,6 +91,21 @@ impl AudioMessageEventContent { pub fn info(self, info: impl Into>>) -> Self { Self { info: info.into(), ..self } } + + /// Returns the caption for the audio as defined by the [spec](https://spec.matrix.org/latest/client-server-api/#media-captions). + /// + /// In short, this is the `body` field if the `filename` field exists and has a different value, + /// otherwise the media file does not have a caption. + pub fn caption(&self) -> Option<&str> { + caption(&self.body, self.filename.as_deref()) + } + + /// Returns the formatted caption for the audio as defined by the [spec](https://spec.matrix.org/latest/client-server-api/#media-captions). + /// + /// This is the same as `caption`, but returns the formatted body instead of the plain body. + pub fn formatted_caption(&self) -> Option<&FormattedBody> { + formatted_caption(&self.body, self.formatted.as_ref(), self.filename.as_deref()) + } } /// Metadata about an audio clip. diff --git a/crates/ruma-events/src/room/message/file.rs b/crates/ruma-events/src/room/message/file.rs index 3f7abb51..0edbc634 100644 --- a/crates/ruma-events/src/room/message/file.rs +++ b/crates/ruma-events/src/room/message/file.rs @@ -3,7 +3,10 @@ use ruma_common::OwnedMxcUri; use serde::{Deserialize, Serialize}; use super::FormattedBody; -use crate::room::{EncryptedFile, MediaSource, ThumbnailInfo}; +use crate::room::{ + message::media_caption::{caption, formatted_caption}, + EncryptedFile, MediaSource, ThumbnailInfo, +}; /// The payload for a file message. #[derive(Clone, Debug, Deserialize, Serialize)] @@ -60,6 +63,21 @@ impl FileMessageEventContent { pub fn info(self, info: impl Into>>) -> Self { Self { info: info.into(), ..self } } + + /// Returns the caption of the media file as defined by the [spec](https://spec.matrix.org/latest/client-server-api/#media-captions). + /// + /// In short, this is the `body` field if the `filename` field exists and has a different value, + /// otherwise the media file does not have a caption. + pub fn caption(&self) -> Option<&str> { + caption(&self.body, self.filename.as_deref()) + } + + /// Returns the formatted caption of the media file as defined by the [spec](https://spec.matrix.org/latest/client-server-api/#media-captions). + /// + /// This is the same as `caption`, but returns the formatted body instead of the plain body. + pub fn formatted_caption(&self) -> Option<&FormattedBody> { + formatted_caption(&self.body, self.formatted.as_ref(), self.filename.as_deref()) + } } /// Metadata about a file. diff --git a/crates/ruma-events/src/room/message/image.rs b/crates/ruma-events/src/room/message/image.rs index 674be973..29e1c60c 100644 --- a/crates/ruma-events/src/room/message/image.rs +++ b/crates/ruma-events/src/room/message/image.rs @@ -2,7 +2,10 @@ use ruma_common::OwnedMxcUri; use serde::{Deserialize, Serialize}; use super::FormattedBody; -use crate::room::{EncryptedFile, ImageInfo, MediaSource}; +use crate::room::{ + message::media_caption::{caption, formatted_caption}, + EncryptedFile, ImageInfo, MediaSource, +}; /// The payload for an image message. #[derive(Clone, Debug, Deserialize, Serialize)] @@ -59,4 +62,19 @@ impl ImageMessageEventContent { pub fn info(self, info: impl Into>>) -> Self { Self { info: info.into(), ..self } } + + /// Returns the caption for the image as defined by the [spec](https://spec.matrix.org/latest/client-server-api/#media-captions). + /// + /// In short, this is the `body` field if the `filename` field exists and has a different value, + /// otherwise the media file does not have a caption. + pub fn caption(&self) -> Option<&str> { + caption(&self.body, self.filename.as_deref()) + } + + /// Returns the formatted caption for the image as defined by the [spec](https://spec.matrix.org/latest/client-server-api/#media-captions). + /// + /// This is the same as `caption`, but returns the formatted body instead of the plain body. + pub fn formatted_caption(&self) -> Option<&FormattedBody> { + formatted_caption(&self.body, self.formatted.as_ref(), self.filename.as_deref()) + } } diff --git a/crates/ruma-events/src/room/message/media_caption.rs b/crates/ruma-events/src/room/message/media_caption.rs new file mode 100644 index 00000000..a24a688e --- /dev/null +++ b/crates/ruma-events/src/room/message/media_caption.rs @@ -0,0 +1,22 @@ +//! Reusable methods for captioning media files. + +use crate::room::message::FormattedBody; + +/// Computes the caption of a media file as defined by the [spec](https://spec.matrix.org/latest/client-server-api/#media-captions). +/// +/// In short, this is the `body` field if the `filename` field exists and has a different value, +/// otherwise the media file does not have a caption. +pub(crate) fn caption<'a>(body: &'a str, filename: Option<&str>) -> Option<&'a str> { + filename.is_some_and(|filename| body != filename).then_some(body) +} + +/// Computes the formatted caption of a media file as defined by the [spec](https://spec.matrix.org/latest/client-server-api/#media-captions). +/// +/// This is the same as `caption`, but returns the formatted body instead of the plain body. +pub(crate) fn formatted_caption<'a>( + body: &str, + formatted: Option<&'a FormattedBody>, + filename: Option<&str>, +) -> Option<&'a FormattedBody> { + filename.is_some_and(|filename| body != filename).then_some(formatted).flatten() +} diff --git a/crates/ruma-events/src/room/message/video.rs b/crates/ruma-events/src/room/message/video.rs index 00d9dd00..df8ac0c1 100644 --- a/crates/ruma-events/src/room/message/video.rs +++ b/crates/ruma-events/src/room/message/video.rs @@ -5,7 +5,10 @@ use ruma_common::OwnedMxcUri; use serde::{Deserialize, Serialize}; use super::FormattedBody; -use crate::room::{EncryptedFile, MediaSource, ThumbnailInfo}; +use crate::room::{ + message::media_caption::{caption, formatted_caption}, + EncryptedFile, MediaSource, ThumbnailInfo, +}; /// The payload for a video message. #[derive(Clone, Debug, Deserialize, Serialize)] @@ -62,6 +65,21 @@ impl VideoMessageEventContent { pub fn info(self, info: impl Into>>) -> Self { Self { info: info.into(), ..self } } + + /// Returns the caption of the video as defined by the [spec](https://spec.matrix.org/latest/client-server-api/#media-captions). + /// + /// In short, this is the `body` field if the `filename` field exists and has a different value, + /// otherwise the media file does not have a caption. + pub fn caption(&self) -> Option<&str> { + caption(&self.body, self.filename.as_deref()) + } + + /// Returns the formatted caption of the video as defined by the [spec](https://spec.matrix.org/latest/client-server-api/#media-captions). + /// + /// This is the same as `caption`, but returns the formatted body instead of the plain body. + pub fn formatted_caption(&self) -> Option<&FormattedBody> { + formatted_caption(&self.body, self.formatted.as_ref(), self.filename.as_deref()) + } } /// Metadata about a video. diff --git a/crates/ruma-events/tests/it/room_message.rs b/crates/ruma-events/tests/it/room_message.rs index acc2422b..717f5685 100644 --- a/crates/ruma-events/tests/it/room_message.rs +++ b/crates/ruma-events/tests/it/room_message.rs @@ -12,7 +12,7 @@ use ruma_events::{ room::{ message::{ AddMentions, AudioMessageEventContent, EmoteMessageEventContent, - FileMessageEventContent, ForwardThread, ImageMessageEventContent, + FileMessageEventContent, FormattedBody, ForwardThread, ImageMessageEventContent, KeyVerificationRequestEventContent, MessageType, OriginalRoomMessageEvent, OriginalSyncRoomMessageEvent, Relation, ReplyWithinThread, RoomMessageEventContent, TextMessageEventContent, VideoMessageEventContent, @@ -900,8 +900,9 @@ fn audio_msgtype_deserialization() { let event_content = from_json_value::(json_data).unwrap(); assert_matches!(event_content.msgtype, MessageType::Audio(content)); assert_eq!(content.body, "Upload: my_song.mp3"); - assert_matches!(content.source, MediaSource::Plain(url)); + assert_matches!(&content.source, MediaSource::Plain(url)); assert_eq!(url, "mxc://notareal.hs/file"); + assert!(content.caption().is_none()); } #[test] @@ -983,8 +984,9 @@ fn file_msgtype_plain_content_deserialization() { let event_content = from_json_value::(json_data).unwrap(); assert_matches!(event_content.msgtype, MessageType::File(content)); assert_eq!(content.body, "Upload: my_file.txt"); - assert_matches!(content.source, MediaSource::Plain(url)); + assert_matches!(&content.source, MediaSource::Plain(url)); assert_eq!(url, "mxc://notareal.hs/file"); + assert!(content.caption().is_none()); } #[test] @@ -1045,8 +1047,9 @@ fn image_msgtype_deserialization() { let event_content = from_json_value::(json_data).unwrap(); assert_matches!(event_content.msgtype, MessageType::Image(content)); assert_eq!(content.body, "Upload: my_image.jpg"); - assert_matches!(content.source, MediaSource::Plain(url)); + assert_matches!(&content.source, MediaSource::Plain(url)); assert_eq!(url, "mxc://notareal.hs/file"); + assert!(content.caption().is_none()); } #[cfg(not(feature = "unstable-msc3488"))] @@ -1202,8 +1205,9 @@ fn video_msgtype_deserialization() { let event_content = from_json_value::(json_data).unwrap(); assert_matches!(event_content.msgtype, MessageType::Video(content)); assert_eq!(content.body, "Upload: my_video.mp4"); - assert_matches!(content.source, MediaSource::Plain(url)); + assert_matches!(&content.source, MediaSource::Plain(url)); assert_eq!(url, "mxc://notareal.hs/file"); + assert!(content.caption().is_none()); } #[test] @@ -1330,3 +1334,106 @@ fn invalid_replacement() { assert_matches!(&data, Cow::Borrowed(_)); // data is stored in JSON form because it's invalid assert_eq!(JsonValue::Object(data.into_owned()), relation); } + +#[test] +fn test_audio_caption() { + let mut content = AudioMessageEventContent::plain( + "my_sound.ogg".to_owned(), + mxc_uri!("mxc://notareal.hs/abcdef").to_owned(), + ); + assert!(content.caption().is_none()); + assert!(content.formatted_caption().is_none()); + + content.filename = Some("my_sound.ogg".to_owned()); + assert!(content.caption().is_none()); + assert!(content.formatted_caption().is_none()); + + content.body = "This was a great podcast episode".to_owned(); + assert_eq!(content.caption(), Some("This was a great podcast episode")); + assert!(content.formatted_caption().is_none()); + + content.formatted = + Some(FormattedBody::html("This was a great podcast episode".to_owned())); + assert_eq!(content.caption(), Some("This was a great podcast episode")); + assert_eq!( + content.formatted_caption().map(|f| f.body.clone()), + Some("This was a great podcast episode".to_owned()) + ); +} + +#[test] +fn test_file_caption() { + let mut content = FileMessageEventContent::plain( + "my_file.txt".to_owned(), + mxc_uri!("mxc://notareal.hs/abcdef").to_owned(), + ); + assert!(content.caption().is_none()); + assert!(content.formatted_caption().is_none()); + + content.filename = Some("my_file.txt".to_owned()); + assert!(content.caption().is_none()); + assert!(content.formatted_caption().is_none()); + + content.body = "Please check these notes".to_owned(); + assert_eq!(content.caption(), Some("Please check these notes")); + assert!(content.formatted_caption().is_none()); + + content.formatted = + Some(FormattedBody::html("Please check these notes".to_owned())); + assert_eq!(content.caption(), Some("Please check these notes")); + assert_eq!( + content.formatted_caption().map(|f| f.body.clone()), + Some("Please check these notes".to_owned()) + ); +} + +#[test] +fn test_image_caption() { + let mut content = ImageMessageEventContent::plain( + "my_image.jpg".to_owned(), + mxc_uri!("mxc://notareal.hs/abcdef").to_owned(), + ); + assert!(content.caption().is_none()); + assert!(content.formatted_caption().is_none()); + + content.filename = Some("my_image.jpg".to_owned()); + assert!(content.caption().is_none()); + assert!(content.formatted_caption().is_none()); + + content.body = "Check it out 😎".to_owned(); + assert_eq!(content.caption(), Some("Check it out 😎")); + assert!(content.formatted_caption().is_none()); + + content.formatted = Some(FormattedBody::html("

Check it out 😎

".to_owned())); + assert_eq!(content.caption(), Some("Check it out 😎")); + assert_eq!( + content.formatted_caption().map(|f| f.body.clone()), + Some("

Check it out 😎

".to_owned()) + ); +} + +#[test] +fn test_video_caption() { + let mut content = VideoMessageEventContent::plain( + "my_video.mp4".to_owned(), + mxc_uri!("mxc://notareal.hs/abcdef").to_owned(), + ); + assert!(content.caption().is_none()); + assert!(content.formatted_caption().is_none()); + + content.filename = Some("my_video.mp4".to_owned()); + assert!(content.caption().is_none()); + assert!(content.formatted_caption().is_none()); + + content.body = "You missed a great evening".to_owned(); + assert_eq!(content.caption(), Some("You missed a great evening")); + assert!(content.formatted_caption().is_none()); + + content.formatted = + Some(FormattedBody::html("You missed a great evening".to_owned())); + assert_eq!(content.caption(), Some("You missed a great evening")); + assert_eq!( + content.formatted_caption().map(|f| f.body.clone()), + Some("You missed a great evening".to_owned()) + ); +}