diff --git a/crates/ruma-common/src/events/audio.rs b/crates/ruma-common/src/events/audio.rs index 68e1fd56..eb46b626 100644 --- a/crates/ruma-common/src/events/audio.rs +++ b/crates/ruma-common/src/events/audio.rs @@ -13,9 +13,25 @@ mod waveform_serde; use waveform_serde::WaveformSerDeHelper; -use super::{file::FileContent, message::MessageContent, room::message::Relation}; +use super::{ + file::FileContent, + message::MessageContent, + room::message::{AudioInfo, AudioMessageEventContent, Relation}, +}; /// The payload for an extensible audio message. +/// +/// This is the new primary type introduced in [MSC3246] and should not be sent before the end of +/// the transition period. See the documentation of the [`message`] module for more information. +/// +/// `AudioEventContent` can be converted to a [`RoomMessageEventContent`] with a +/// [`MessageType::Audio`]. You can convert it back with +/// [`AudioEventContent::from_audio_room_message()`]. +/// +/// [MSC3246]: https://github.com/matrix-org/matrix-spec-proposals/pull/3246 +/// [`message`]: super::message +/// [`RoomMessageEventContent`]: super::room::message::RoomMessageEventContent +/// [`MessageType::Audio`]: super::room::message::MessageType::Audio #[derive(Clone, Debug, Serialize, Deserialize, EventContent)] #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] #[ruma_event(type = "m.audio", kind = MessageLike)] @@ -52,6 +68,23 @@ impl AudioEventContent { pub fn with_message(message: MessageContent, file: FileContent) -> Self { Self { message, file, audio: Default::default(), relates_to: None } } + + /// Create a new `AudioEventContent` from the given `AudioMessageEventContent` and optional + /// relation. + pub fn from_audio_room_message( + content: AudioMessageEventContent, + relates_to: Option, + ) -> Self { + let AudioMessageEventContent { body, source, info, message, file, audio } = content; + + let message = message.unwrap_or_else(|| MessageContent::plain(body)); + let file = file.unwrap_or_else(|| { + FileContent::from_room_message_content(source, info.as_deref(), None) + }); + let audio = audio.or_else(|| info.as_deref().map(Into::into)).unwrap_or_default(); + + Self { message, file, audio, relates_to } + } } /// Audio content. @@ -76,6 +109,18 @@ impl AudioContent { pub fn new() -> Self { Self::default() } + + /// Whether this `AudioContent` is empty. + pub fn is_empty(&self) -> bool { + self.duration.is_none() && self.waveform.is_none() + } +} + +impl From<&AudioInfo> for AudioContent { + fn from(info: &AudioInfo) -> Self { + let AudioInfo { duration, .. } = info; + Self { duration: duration.to_owned(), ..Default::default() } + } } /// The waveform representation of audio content. diff --git a/crates/ruma-common/src/events/file.rs b/crates/ruma-common/src/events/file.rs index ac3b5a25..5c0654ac 100644 --- a/crates/ruma-common/src/events/file.rs +++ b/crates/ruma-common/src/events/file.rs @@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize}; use super::{ message::MessageContent, room::{ - message::{FileInfo, FileMessageEventContent, Relation, VideoInfo}, + message::{AudioInfo, FileInfo, FileMessageEventContent, Relation, VideoInfo}, EncryptedFile, ImageInfo, JsonWebKey, MediaSource, }, }; @@ -211,6 +211,13 @@ impl FileContentInfo { } } +impl From<&AudioInfo> for FileContentInfo { + fn from(info: &AudioInfo) -> Self { + let AudioInfo { mimetype, size, .. } = info; + Self { mimetype: mimetype.to_owned(), size: size.to_owned(), ..Default::default() } + } +} + impl From<&FileInfo> for FileContentInfo { fn from(info: &FileInfo) -> Self { let FileInfo { mimetype, size, .. } = info; diff --git a/crates/ruma-common/src/events/room/message.rs b/crates/ruma-common/src/events/room/message.rs index 3bda3074..92434d2a 100644 --- a/crates/ruma-common/src/events/room/message.rs +++ b/crates/ruma-common/src/events/room/message.rs @@ -10,6 +10,8 @@ use serde::{de::DeserializeOwned, Deserialize, Serialize}; use serde_json::Value as JsonValue; use super::{EncryptedFile, ImageInfo, MediaSource, ThumbnailInfo}; +#[cfg(feature = "unstable-msc3246")] +use crate::events::audio::{AudioContent, AudioEventContent}; #[cfg(feature = "unstable-msc3551")] use crate::events::file::{FileContent, FileContentInfo, FileEventContent}; #[cfg(feature = "unstable-msc3552")] @@ -194,6 +196,20 @@ impl RoomMessageEventContent { } } +#[cfg(feature = "unstable-msc3246")] +impl From for RoomMessageEventContent { + fn from(content: AudioEventContent) -> Self { + let AudioEventContent { message, file, audio, relates_to } = content; + + Self { + msgtype: MessageType::Audio(AudioMessageEventContent::from_extensible_content( + message, file, audio, + )), + relates_to, + } + } +} + #[cfg(feature = "unstable-msc1767")] impl From for RoomMessageEventContent { fn from(content: EmoteEventContent) -> Self { @@ -518,6 +534,10 @@ impl Thread { #[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] #[serde(tag = "msgtype", rename = "m.audio")] +#[cfg_attr( + feature = "unstable-msc3246", + serde(from = "content_serde::AudioMessageEventContentDeHelper") +)] pub struct AudioMessageEventContent { /// The textual representation of this message. pub body: String, @@ -529,19 +549,81 @@ pub struct AudioMessageEventContent { /// Metadata for the audio clip referred to in `url`. #[serde(skip_serializing_if = "Option::is_none")] pub info: Option>, + + /// Extensible-event text representation of the message. + /// + /// If present, this should be preferred over the `body` field. + #[cfg(feature = "unstable-msc3246")] + #[serde(flatten, skip_serializing_if = "Option::is_none")] + pub message: Option, + + /// Extensible-event file content of the message. + /// + /// If present, this should be preferred over the `source` and `info` fields. + #[cfg(feature = "unstable-msc3246")] + #[serde(rename = "org.matrix.msc1767.file", skip_serializing_if = "Option::is_none")] + pub file: Option, + + /// Extensible-event audio info of the message. + /// + /// If present, this should be preferred over the `info` field. + #[cfg(feature = "unstable-msc3246")] + #[serde(rename = "org.matrix.msc1767.audio", skip_serializing_if = "Option::is_none")] + pub audio: Option, } impl AudioMessageEventContent { /// Creates a new non-encrypted `RoomAudioMessageEventContent` with the given body, url and /// optional extra info. pub fn plain(body: String, url: Box, info: Option>) -> Self { - Self { body, source: MediaSource::Plain(url), info } + Self { + #[cfg(feature = "unstable-msc3246")] + message: Some(MessageContent::plain(body.clone())), + #[cfg(feature = "unstable-msc3246")] + file: Some(FileContent::plain( + url.clone(), + info.as_deref().map(|info| Box::new(info.into())), + )), + #[cfg(feature = "unstable-msc3246")] + audio: Some(info.as_deref().map_or_else(AudioContent::default, Into::into)), + body, + source: MediaSource::Plain(url), + info, + } } /// Creates a new encrypted `RoomAudioMessageEventContent` with the given body and encrypted /// file. pub fn encrypted(body: String, file: EncryptedFile) -> Self { - Self { body, source: MediaSource::Encrypted(Box::new(file)), info: None } + Self { + #[cfg(feature = "unstable-msc3246")] + message: Some(MessageContent::plain(body.clone())), + #[cfg(feature = "unstable-msc3246")] + file: Some(FileContent::encrypted(file.url.clone(), (&file).into(), None)), + #[cfg(feature = "unstable-msc3246")] + audio: Some(AudioContent::default()), + body, + source: MediaSource::Encrypted(Box::new(file)), + info: None, + } + } + + /// Create a new `AudioMessageEventContent` with the given message, file info and audio info. + #[cfg(feature = "unstable-msc3246")] + pub fn from_extensible_content( + message: MessageContent, + file: FileContent, + audio: AudioContent, + ) -> Self { + let body = if let Some(body) = message.find_plain() { + body.to_owned() + } else { + message[0].body.clone() + }; + let source = (&file).into(); + let info = AudioInfo::from_extensible_content(file.info.as_deref(), &audio).map(Box::new); + + Self { message: Some(message), file: Some(file), audio: Some(audio), body, source, info } } } @@ -571,6 +653,24 @@ impl AudioInfo { pub fn new() -> Self { Self::default() } + + /// Create an `AudioInfo` from the given file info and audio info. + #[cfg(feature = "unstable-msc3246")] + pub fn from_extensible_content( + file_info: Option<&FileContentInfo>, + audio: &AudioContent, + ) -> Option { + if file_info.is_none() && audio.is_empty() { + None + } else { + let (mimetype, size) = file_info + .map(|info| (info.mimetype.to_owned(), info.size.to_owned())) + .unwrap_or_default(); + let AudioContent { duration, .. } = audio; + + Some(Self { duration: duration.to_owned(), mimetype, size }) + } + } } /// The payload for an emote message. diff --git a/crates/ruma-common/src/events/room/message/content_serde.rs b/crates/ruma-common/src/events/room/message/content_serde.rs index 8d8a6301..db491dfe 100644 --- a/crates/ruma-common/src/events/room/message/content_serde.rs +++ b/crates/ruma-common/src/events/room/message/content_serde.rs @@ -3,6 +3,8 @@ use serde::{de, Deserialize}; use serde_json::value::RawValue as RawJsonValue; +#[cfg(feature = "unstable-msc3246")] +use super::{AudioContent, AudioInfo, AudioMessageEventContent}; #[cfg(feature = "unstable-msc3551")] use super::{FileContent, FileInfo, FileMessageEventContent, MediaSource, MessageContent}; #[cfg(feature = "unstable-msc3552")] @@ -57,6 +59,65 @@ impl<'de> Deserialize<'de> for MessageType { } } +/// Helper struct for deserializing `AudioMessageEventContent` with stable and unstable field names. +/// +/// It's not possible to use the `alias` attribute of serde because of +/// https://github.com/serde-rs/serde/issues/1504. +#[derive(Clone, Debug, Deserialize)] +#[cfg(feature = "unstable-msc3246")] +pub struct AudioMessageEventContentDeHelper { + /// The textual representation of this message. + pub body: String, + + /// The source of the audio clip. + #[serde(flatten)] + pub source: MediaSource, + + /// Metadata for the audio clip referred to in `source`. + pub info: Option>, + + /// Extensible-event text representation of the message. + #[serde(flatten)] + pub message: Option, + + /// Extensible-event file content of the message, with stable name. + #[serde(rename = "m.file")] + pub file_stable: Option, + + /// Extensible-event file content of the message, with unstable name. + #[serde(rename = "org.matrix.msc1767.file")] + pub file_unstable: Option, + + /// Extensible-event audio info of the message, with stable name. + #[serde(rename = "m.audio")] + pub audio_stable: Option, + + /// Extensible-event audio info of the message, with unstable name. + #[serde(rename = "org.matrix.msc1767.audio")] + pub audio_unstable: Option, +} + +#[cfg(feature = "unstable-msc3246")] +impl From for AudioMessageEventContent { + fn from(helper: AudioMessageEventContentDeHelper) -> Self { + let AudioMessageEventContentDeHelper { + body, + source, + info, + message, + file_stable, + file_unstable, + audio_stable, + audio_unstable, + } = helper; + + let file = file_stable.or(file_unstable); + let audio = audio_stable.or(audio_unstable); + + Self { body, source, info, message, file, audio } + } +} + /// Helper struct for deserializing `FileMessageEventContent` with stable and unstable field names. /// /// It's not possible to use the `alias` attribute of serde because of diff --git a/crates/ruma-common/tests/events/audio.rs b/crates/ruma-common/tests/events/audio.rs index 7c0778a8..c61dba1e 100644 --- a/crates/ruma-common/tests/events/audio.rs +++ b/crates/ruma-common/tests/events/audio.rs @@ -12,8 +12,10 @@ use ruma_common::{ file::{EncryptedContentInit, FileContent, FileContentInfo}, message::MessageContent, room::{ - message::{InReplyTo, Relation}, - JsonWebKeyInit, + message::{ + AudioMessageEventContent, InReplyTo, MessageType, Relation, RoomMessageEventContent, + }, + JsonWebKeyInit, MediaSource, }, AnyMessageLikeEvent, MessageLikeEvent, MessageLikeUnsigned, }, @@ -208,7 +210,7 @@ fn event_serialization() { #[test] fn plain_content_deserialization() { let json_data = json!({ - "org.matrix.msc1767.text": "Upload: my_new_song.webm", + "m.text": "Upload: my_new_song.webm", "m.file": { "url": "mxc://notareal.hs/abcdef", }, @@ -289,7 +291,7 @@ fn plain_content_deserialization() { #[test] fn encrypted_content_deserialization() { let json_data = json!({ - "org.matrix.msc1767.text": "Upload: my_file.txt", + "m.text": "Upload: my_file.txt", "m.file": { "url": "mxc://notareal.hs/abcdef", "key": { @@ -328,7 +330,7 @@ fn encrypted_content_deserialization() { fn message_event_deserialization() { let json_data = json!({ "content": { - "org.matrix.msc1767.text": "Upload: airplane_sound.opus", + "m.text": "Upload: airplane_sound.opus", "m.file": { "url": "mxc://notareal.hs/abcdef", "name": "airplane_sound.opus", @@ -379,3 +381,87 @@ fn message_event_deserialization() { && unsigned.is_empty() ); } + +#[test] +fn room_message_serialization() { + let message_event_content = + RoomMessageEventContent::new(MessageType::Audio(AudioMessageEventContent::plain( + "Upload: my_song.mp3".to_owned(), + mxc_uri!("mxc://notareal.hs/file").to_owned(), + None, + ))); + + assert_eq!( + to_json_value(&message_event_content).unwrap(), + json!({ + "body": "Upload: my_song.mp3", + "url": "mxc://notareal.hs/file", + "msgtype": "m.audio", + "org.matrix.msc1767.text": "Upload: my_song.mp3", + "org.matrix.msc1767.file": { + "url": "mxc://notareal.hs/file", + }, + "org.matrix.msc1767.audio": {}, + }) + ); +} + +#[test] +fn room_message_stable_deserialization() { + let json_data = json!({ + "body": "Upload: my_song.mp3", + "url": "mxc://notareal.hs/file", + "msgtype": "m.audio", + "m.text": "Upload: my_song.mp3", + "m.file": { + "url": "mxc://notareal.hs/file", + }, + "m.audio": {}, + }); + + let event_content = from_json_value::(json_data).unwrap(); + assert_matches!(event_content.msgtype, MessageType::Audio(_)); + if let MessageType::Audio(content) = event_content.msgtype { + assert_eq!(content.body, "Upload: my_song.mp3"); + assert_matches!(content.source, MediaSource::Plain(_)); + if let MediaSource::Plain(url) = content.source { + assert_eq!(url, "mxc://notareal.hs/file"); + } + let message = content.message.unwrap(); + assert_eq!(message.len(), 1); + assert_eq!(message[0].body, "Upload: my_song.mp3"); + let file = content.file.unwrap(); + assert_eq!(file.url, "mxc://notareal.hs/file"); + assert!(!file.is_encrypted()); + } +} + +#[test] +fn room_message_unstable_deserialization() { + let json_data = json!({ + "body": "Upload: my_song.mp3", + "url": "mxc://notareal.hs/file", + "msgtype": "m.audio", + "org.matrix.msc1767.text": "Upload: my_song.mp3", + "org.matrix.msc1767.file": { + "url": "mxc://notareal.hs/file", + }, + "org.matrix.msc1767.audio": {}, + }); + + let event_content = from_json_value::(json_data).unwrap(); + assert_matches!(event_content.msgtype, MessageType::Audio(_)); + if let MessageType::Audio(content) = event_content.msgtype { + assert_eq!(content.body, "Upload: my_song.mp3"); + assert_matches!(content.source, MediaSource::Plain(_)); + if let MediaSource::Plain(url) = content.source { + assert_eq!(url, "mxc://notareal.hs/file"); + } + let message = content.message.unwrap(); + assert_eq!(message.len(), 1); + assert_eq!(message[0].body, "Upload: my_song.mp3"); + let file = content.file.unwrap(); + assert_eq!(file.url, "mxc://notareal.hs/file"); + assert!(!file.is_encrypted()); + } +} diff --git a/crates/ruma-common/tests/events/room_message.rs b/crates/ruma-common/tests/events/room_message.rs index e12163d6..7203366f 100644 --- a/crates/ruma-common/tests/events/room_message.rs +++ b/crates/ruma-common/tests/events/room_message.rs @@ -52,6 +52,7 @@ fn serialization() { unsigned: MessageLikeUnsigned::default(), }; + #[cfg(not(feature = "unstable-msc3246"))] assert_eq!( to_json_value(ev).unwrap(), json!({ @@ -67,6 +68,28 @@ fn serialization() { } }) ); + + #[cfg(feature = "unstable-msc3246")] + assert_eq!( + to_json_value(ev).unwrap(), + json!({ + "type": "m.room.message", + "event_id": "$143273582443PhrSn:example.org", + "origin_server_ts": 10_000, + "room_id": "!testroomid:example.org", + "sender": "@user:example.org", + "content": { + "body": "test", + "msgtype": "m.audio", + "url": "mxc://example.org/ffed755USFFxlgbQYZGtryd", + "org.matrix.msc1767.text": "test", + "org.matrix.msc1767.file": { + "url": "mxc://example.org/ffed755USFFxlgbQYZGtryd", + }, + "org.matrix.msc1767.audio": {}, + } + }) + ); } #[test] @@ -78,6 +101,7 @@ fn content_serialization() { None, ))); + #[cfg(not(feature = "unstable-msc3246"))] assert_eq!( to_json_value(&message_event_content).unwrap(), json!({ @@ -86,6 +110,21 @@ fn content_serialization() { "url": "mxc://example.org/ffed755USFFxlgbQYZGtryd" }) ); + + #[cfg(feature = "unstable-msc3246")] + assert_eq!( + to_json_value(&message_event_content).unwrap(), + json!({ + "body": "test", + "msgtype": "m.audio", + "url": "mxc://example.org/ffed755USFFxlgbQYZGtryd", + "org.matrix.msc1767.text": "test", + "org.matrix.msc1767.file": { + "url": "mxc://example.org/ffed755USFFxlgbQYZGtryd", + }, + "org.matrix.msc1767.audio": {}, + }) + ); } #[test]