Repository: ruma/ruma
Pull request #2047: events: add gallery msgtype for MSC4274 (backport into 0.12)
Author: login: Johennes
Created: 2025-03-20T12:19:48Z | Merged: 2025-03-21T15:26:42Z

This backports https://github.com/ruma/ruma/pull/2044 into the 0.12 branch so that it can be used in matrix-rust-sdk. This switches back to custom serialization to avoid introducing a breaking change. I have annotated the two other changes that were needed compared to https://github.com/ruma/ruma/pull/2044.

I tried running `cargo xtask ci` locally. It mostly ran through but produced some warnings and errors of the following form:

```
error: unexpected `cfg` condition value: `unstable-unspecified`
   --> crates/ruma-federation-api/src/membership/create_join_event/v2.rs:145:11
    |
145 |     #[cfg(feature = "unstable-unspecified")]
    |           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    |
    = note: expected values for `feature` are: `client`, `compat-empty-string-null`, `compat-optional-txn-pdus`, `server`, `unstable-msc2448`, `unstable-msc3618`, `unstable-msc3723`, `unstable-msc3843`, and `unstable-msc4125`
    = help: consider adding `unstable-unspecified` as a feature in `Cargo.toml`
    = note: see <https://doc.rust-lang.org/nightly/rustc/check-cfg/cargo-specifics.html> for more information about checking conditional configuration
```

<!--

PR checklist, not strictly necessary but generally useful unless you're just
fixing a typo or something like that:

- Run `cargo xtask ci` locally before posting the PR
- Documented public API changes in CHANGELOG.md files

-->



Diff:
diff --git a/crates/ruma-events/CHANGELOG.md b/crates/ruma-events/CHANGELOG.md
index 97c64a4c1b..fbaf96c969 100644
--- a/crates/ruma-events/CHANGELOG.md
+++ b/crates/ruma-events/CHANGELOG.md
@@ -4,6 +4,7 @@ Improvements:
 
 - Add `RECOMMENDED_STRIPPED_STATE_EVENT_TYPES` constant for servers to filter/get recommended
   stripped state events.
+- Add unstable support for gallery `msgtype` as per MSC4274.
 
 # 0.30.1
 
diff --git a/crates/ruma-events/Cargo.toml b/crates/ruma-events/Cargo.toml
index 08e258b562..ac54a647e1 100644
--- a/crates/ruma-events/Cargo.toml
+++ b/crates/ruma-events/Cargo.toml
@@ -45,6 +45,7 @@ unstable-msc4075 = ["unstable-msc3401"]
 unstable-msc4095 = []
 unstable-msc4171 = []
 unstable-msc4230 = []
+unstable-msc4274 = []
 unstable-pdu = []
 
 # Allow some mandatory fields to be missing, defaulting them to an empty string
diff --git a/crates/ruma-events/src/room/message.rs b/crates/ruma-events/src/room/message.rs
index 7b1fb21bb2..0c519ebe18 100644
--- a/crates/ruma-events/src/room/message.rs
+++ b/crates/ruma-events/src/room/message.rs
@@ -27,6 +27,8 @@ mod audio;
 mod content_serde;
 mod emote;
 mod file;
+#[cfg(feature = "unstable-msc4274")]
+mod gallery;
 mod image;
 mod key_verification_request;
 mod location;
@@ -47,6 +49,8 @@ mod without_relation;
 pub use self::audio::{
     UnstableAmplitude, UnstableAudioDetailsContentBlock, UnstableVoiceContentBlock,
 };
+#[cfg(feature = "unstable-msc4274")]
+pub use self::gallery::{GalleryItemType, GalleryMessageEventContent};
 #[cfg(feature = "unstable-msc4095")]
 pub use self::url_preview::{PreviewImage, PreviewImageSource, UrlPreview};
 pub use self::{
@@ -445,6 +449,10 @@ pub enum MessageType {
     /// A file message.
     File(FileMessageEventContent),
 
+    /// A media gallery message.
+    #[cfg(feature = "unstable-msc4274")]
+    Gallery(GalleryMessageEventContent),
+
     /// An image message.
     Image(ImageMessageEventContent),
 
@@ -499,6 +507,8 @@ impl MessageType {
             "m.audio" => Self::Audio(deserialize_variant(body, data)?),
             "m.emote" => Self::Emote(deserialize_variant(body, data)?),
             "m.file" => Self::File(deserialize_variant(body, data)?),
+            #[cfg(feature = "unstable-msc4274")]
+            "dm.filament.gallery" => Self::Gallery(deserialize_variant(body, data)?),
             "m.image" => Self::Image(deserialize_variant(body, data)?),
             "m.location" => Self::Location(deserialize_variant(body, data)?),
             "m.notice" => Self::Notice(deserialize_variant(body, data)?),
@@ -566,6 +576,8 @@ impl MessageType {
             Self::Audio(_) => "m.audio",
             Self::Emote(_) => "m.emote",
             Self::File(_) => "m.file",
+            #[cfg(feature = "unstable-msc4274")]
+            Self::Gallery(_) => "dm.filament.gallery",
             Self::Image(_) => "m.image",
             Self::Location(_) => "m.location",
             Self::Notice(_) => "m.notice",
@@ -583,6 +595,8 @@ impl MessageType {
             MessageType::Audio(m) => &m.body,
             MessageType::Emote(m) => &m.body,
             MessageType::File(m) => &m.body,
+            #[cfg(feature = "unstable-msc4274")]
+            MessageType::Gallery(m) => &m.body,
             MessageType::Image(m) => &m.body,
             MessageType::Location(m) => &m.body,
             MessageType::Notice(m) => &m.body,
@@ -616,6 +630,8 @@ impl MessageType {
             Self::Audio(d) => Cow::Owned(serialize(d)),
             Self::Emote(d) => Cow::Owned(serialize(d)),
             Self::File(d) => Cow::Owned(serialize(d)),
+            #[cfg(feature = "unstable-msc4274")]
+            Self::Gallery(d) => Cow::Owned(serialize(d)),
             Self::Image(d) => Cow::Owned(serialize(d)),
             Self::Location(d) => Cow::Owned(serialize(d)),
             Self::Notice(d) => Cow::Owned(serialize(d)),
@@ -676,6 +692,8 @@ impl MessageType {
                 }
                 MessageType::Audio(m) => (&mut m.body, None),
                 MessageType::File(m) => (&mut m.body, None),
+                #[cfg(feature = "unstable-msc4274")]
+                MessageType::Gallery(m) => (&mut m.body, None),
                 MessageType::Image(m) => (&mut m.body, None),
                 MessageType::Location(m) => (&mut m.body, None),
                 MessageType::ServerNotice(m) => (&mut m.body, None),
@@ -718,6 +736,8 @@ impl MessageType {
                 }
                 MessageType::Audio(m) => (&mut m.body, None),
                 MessageType::File(m) => (&mut m.body, None),
+                #[cfg(feature = "unstable-msc4274")]
+                MessageType::Gallery(m) => (&mut m.body, None),
                 MessageType::Image(m) => (&mut m.body, None),
                 MessageType::Location(m) => (&mut m.body, None),
                 MessageType::ServerNotice(m) => (&mut m.body, None),
diff --git a/crates/ruma-events/src/room/message/content_serde.rs b/crates/ruma-events/src/room/message/content_serde.rs
index cd762fbd41..b1e1d4f32e 100644
--- a/crates/ruma-events/src/room/message/content_serde.rs
+++ b/crates/ruma-events/src/room/message/content_serde.rs
@@ -1,9 +1,15 @@
 //! `Deserialize` implementation for RoomMessageEventContent and MessageType.
 
 use ruma_common::serde::from_raw_json_value;
+#[cfg(feature = "unstable-msc4274")]
+use serde::Serialize;
 use serde::{de, Deserialize};
 use serde_json::value::RawValue as RawJsonValue;
+#[cfg(feature = "unstable-msc4274")]
+use serde_json::Value as JsonValue;
 
+#[cfg(feature = "unstable-msc4274")]
+use super::gallery::GalleryItemType;
 use super::{
     relation_serde::deserialize_relation, MessageType, RoomMessageEventContent,
     RoomMessageEventContentWithoutRelation,
@@ -64,6 +70,8 @@ impl<'de> Deserialize<'de> for MessageType {
             "m.audio" => Self::Audio(from_raw_json_value(&json)?),
             "m.emote" => Self::Emote(from_raw_json_value(&json)?),
             "m.file" => Self::File(from_raw_json_value(&json)?),
+            #[cfg(feature = "unstable-msc4274")]
+            "dm.filament.gallery" => Self::Gallery(from_raw_json_value(&json)?),
             "m.image" => Self::Image(from_raw_json_value(&json)?),
             "m.location" => Self::Location(from_raw_json_value(&json)?),
             "m.notice" => Self::Notice(from_raw_json_value(&json)?),
@@ -76,6 +84,67 @@ impl<'de> Deserialize<'de> for MessageType {
     }
 }
 
+/// Helper struct to determine the itemtype from a `serde_json::value::RawValue`
+#[derive(Debug, Deserialize)]
+#[cfg(feature = "unstable-msc4274")]
+struct ItemTypeDeHelper {
+    /// The item type field
+    itemtype: String,
+}
+
+#[cfg(feature = "unstable-msc4274")]
+impl<'de> Deserialize<'de> for GalleryItemType {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: de::Deserializer<'de>,
+    {
+        let json = Box::<RawJsonValue>::deserialize(deserializer)?;
+        let ItemTypeDeHelper { itemtype } = from_raw_json_value(&json)?;
+
+        Ok(match itemtype.as_ref() {
+            "m.audio" => Self::Audio(from_raw_json_value(&json)?),
+            "m.file" => Self::File(from_raw_json_value(&json)?),
+            "m.image" => Self::Image(from_raw_json_value(&json)?),
+            "m.video" => Self::Video(from_raw_json_value(&json)?),
+            _ => Self::_Custom(from_raw_json_value(&json)?),
+        })
+    }
+}
+
+#[cfg(feature = "unstable-msc4274")]
+impl Serialize for GalleryItemType {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        let mut map = match self {
+            GalleryItemType::Audio(content) => {
+                serde_json::to_value(content).map_err(serde::ser::Error::custom)?
+            }
+            GalleryItemType::File(content) => {
+                serde_json::to_value(content).map_err(serde::ser::Error::custom)?
+            }
+            GalleryItemType::Image(content) => {
+                serde_json::to_value(content).map_err(serde::ser::Error::custom)?
+            }
+            GalleryItemType::Video(content) => {
+                serde_json::to_value(content).map_err(serde::ser::Error::custom)?
+            }
+            GalleryItemType::_Custom(content) => {
+                serde_json::to_value(content).map_err(serde::ser::Error::custom)?
+            }
+        }
+        .as_object()
+        .cloned()
+        .unwrap_or_default();
+
+        map.insert("itemtype".to_owned(), JsonValue::String(self.itemtype().to_owned()));
+        map.remove("msgtype");
+
+        map.serialize(serializer)
+    }
+}
+
 #[allow(unreachable_pub)] // https://github.com/rust-lang/rust/issues/112615
 #[cfg(feature = "unstable-msc3488")]
 pub(in super::super) mod msc3488 {
diff --git a/crates/ruma-events/src/room/message/gallery.rs b/crates/ruma-events/src/room/message/gallery.rs
new file mode 100644
index 0000000000..cc5b9cabb2
--- /dev/null
+++ b/crates/ruma-events/src/room/message/gallery.rs
@@ -0,0 +1,156 @@
+use std::borrow::Cow;
+
+use ruma_common::serde::JsonObject;
+use serde::{de::DeserializeOwned, Deserialize, Serialize};
+use serde_json::Value as JsonValue;
+
+use super::{
+    AudioMessageEventContent, FileMessageEventContent, FormattedBody, ImageMessageEventContent,
+    VideoMessageEventContent,
+};
+
+/// The payload for a gallery message.
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
+#[serde(tag = "msgtype", rename = "dm.filament.gallery")]
+pub struct GalleryMessageEventContent {
+    /// A human-readable description of the gallery.
+    pub body: String,
+
+    /// Formatted form of the message `body`.
+    #[serde(flatten)]
+    pub formatted: Option<FormattedBody>,
+
+    /// Item types for the media in the gallery.
+    pub itemtypes: Vec<GalleryItemType>,
+}
+
+impl GalleryMessageEventContent {
+    /// Creates a new `GalleryMessageEventContent`.
+    pub fn new(
+        body: String,
+        formatted: Option<FormattedBody>,
+        itemtypes: Vec<GalleryItemType>,
+    ) -> Self {
+        Self { body, formatted, itemtypes }
+    }
+}
+
+/// The content that is specific to each gallery item type variant.
+#[derive(Clone, Debug)]
+#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
+pub enum GalleryItemType {
+    /// An audio item.
+    Audio(AudioMessageEventContent),
+
+    /// A file item.
+    File(FileMessageEventContent),
+
+    /// An image item.
+    Image(ImageMessageEventContent),
+
+    /// A video item.
+    Video(VideoMessageEventContent),
+
+    /// A custom item.
+    #[doc(hidden)]
+    _Custom(CustomEventContent),
+}
+
+impl GalleryItemType {
+    /// Creates a new `GalleryItemType`.
+    ///
+    /// The `itemtype` and `body` are required fields.
+    /// Additionally it's possible to add arbitrary key/value pairs to the event content for custom
+    /// item types through the `data` map.
+    ///
+    /// Prefer to use the public variants of `GalleryItemType` where possible; this constructor is
+    /// meant be used for unsupported item types only and does not allow setting arbitrary data
+    /// for supported ones.
+    ///
+    /// # Errors
+    ///
+    /// Returns an error if the `itemtype` is known and serialization of `data` to the corresponding
+    /// `GalleryItemType` variant fails.
+    pub fn new(itemtype: &str, body: String, data: JsonObject) -> serde_json::Result<Self> {
+        fn deserialize_variant<T: DeserializeOwned>(
+            body: String,
+            mut obj: JsonObject,
+        ) -> serde_json::Result<T> {
+            obj.insert("body".into(), body.into());
+            serde_json::from_value(JsonValue::Object(obj))
+        }
+
+        Ok(match itemtype {
+            "m.audio" => Self::Audio(deserialize_variant(body, data)?),
+            "m.file" => Self::File(deserialize_variant(body, data)?),
+            "m.image" => Self::Image(deserialize_variant(body, data)?),
+            "m.video" => Self::Video(deserialize_variant(body, data)?),
+            _ => Self::_Custom(CustomEventContent { itemtype: itemtype.to_owned(), body, data }),
+        })
+    }
+
+    /// Returns a reference to the `itemtype` string.
+    pub fn itemtype(&self) -> &str {
+        match self {
+            Self::Audio(_) => "m.audio",
+            Self::File(_) => "m.file",
+            Self::Image(_) => "m.image",
+            Self::Video(_) => "m.video",
+            Self::_Custom(c) => &c.itemtype,
+        }
+    }
+
+    /// Return a reference to the itemtype body.
+    pub fn body(&self) -> &str {
+        match self {
+            GalleryItemType::Audio(m) => &m.body,
+            GalleryItemType::File(m) => &m.body,
+            GalleryItemType::Image(m) => &m.body,
+            GalleryItemType::Video(m) => &m.body,
+            GalleryItemType::_Custom(m) => &m.body,
+        }
+    }
+
+    /// Returns the associated data.
+    ///
+    /// The returned JSON object won't contain the `itemtype` and `body` fields, use
+    /// [`.itemtype()`][Self::itemtype] / [`.body()`](Self::body) to access those.
+    ///
+    /// Prefer to use the public variants of `GalleryItemType` where possible; this method is meant
+    /// to be used for custom message types only.
+    pub fn data(&self) -> Cow<'_, JsonObject> {
+        fn serialize<T: Serialize>(obj: &T) -> JsonObject {
+            match serde_json::to_value(obj).expect("item type serialization to succeed") {
+                JsonValue::Object(mut obj) => {
+                    obj.remove("body");
+                    obj
+                }
+                _ => panic!("all item types must serialize to objects"),
+            }
+        }
+
+        match self {
+            Self::Audio(d) => Cow::Owned(serialize(d)),
+            Self::File(d) => Cow::Owned(serialize(d)),
+            Self::Image(d) => Cow::Owned(serialize(d)),
+            Self::Video(d) => Cow::Owned(serialize(d)),
+            Self::_Custom(c) => Cow::Borrowed(&c.data),
+        }
+    }
+}
+
+/// The payload for a custom item type.
+#[doc(hidden)]
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct CustomEventContent {
+    /// A custom itemtype.
+    itemtype: String,
+
+    /// The message body.
+    body: String,
+
+    /// Remaining event content.
+    #[serde(flatten)]
+    data: JsonObject,
+}
diff --git a/crates/ruma-events/src/room/message/reply.rs b/crates/ruma-events/src/room/message/reply.rs
index 8c447bd830..f7aa7d6ff7 100644
--- a/crates/ruma-events/src/room/message/reply.rs
+++ b/crates/ruma-events/src/room/message/reply.rs
@@ -28,6 +28,8 @@ impl<'a> From<&'a OriginalRoomMessageEvent> for OriginalEventData<'a> {
             MessageType::Audio(_) => ("sent an audio file.", None, false),
             MessageType::Emote(c) => (&*c.body, c.formatted.as_ref(), true),
             MessageType::File(_) => ("sent a file.", None, false),
+            #[cfg(feature = "unstable-msc4274")]
+            MessageType::Gallery(_) => ("sent a gallery.", None, false),
             MessageType::Image(_) => ("sent an image.", None, false),
             MessageType::Location(_) => ("sent a location.", None, false),
             MessageType::Notice(c) => (&*c.body, c.formatted.as_ref(), false),
diff --git a/crates/ruma-events/tests/it/room_message.rs b/crates/ruma-events/tests/it/room_message.rs
index 09727e01dd..6ce646b516 100644
--- a/crates/ruma-events/tests/it/room_message.rs
+++ b/crates/ruma-events/tests/it/room_message.rs
@@ -7,6 +7,8 @@ use ruma_common::{
     serde::{Base64, Raw},
     user_id, MilliSecondsSinceUnixEpoch, OwnedDeviceId,
 };
+#[cfg(feature = "unstable-msc4274")]
+use ruma_events::room::message::{GalleryItemType, GalleryMessageEventContent};
 use ruma_events::{
     key::verification::VerificationMethod,
     room::{
@@ -1022,6 +1024,139 @@ fn file_msgtype_encrypted_content_deserialization() {
     assert_eq!(encrypted_file.url, "mxc://notareal.hs/file");
 }
 
+#[test]
+#[cfg(feature = "unstable-msc4274")]
+fn gallery_msgtype_serialization_with_image() {
+    let message_event_content =
+        RoomMessageEventContent::new(MessageType::Gallery(GalleryMessageEventContent::new(
+            "My photos from [FOSDEM 2025](https://fosdem.org/2025/)".to_owned(),
+            Some(FormattedBody::html(
+                "My photos from <a href=\"https://fosdem.org/2025/\">FOSDEM 2025</a>",
+            )),
+            vec![GalleryItemType::Image(ImageMessageEventContent::plain(
+                "my_image.jpg".to_owned(),
+                mxc_uri!("mxc://notareal.hs/file").to_owned(),
+            ))],
+        )));
+
+    assert_eq!(
+        to_json_value(&message_event_content).unwrap(),
+        json!({
+            "body": "My photos from [FOSDEM 2025](https://fosdem.org/2025/)",
+            "formatted_body": "My photos from <a href=\"https://fosdem.org/2025/\">FOSDEM 2025</a>",
+            "format": "org.matrix.custom.html",
+            "itemtypes": [{
+                "body": "my_image.jpg",
+                "url": "mxc://notareal.hs/file",
+                "itemtype": "m.image",
+            }],
+            "msgtype": "dm.filament.gallery",
+        })
+    );
+}
+
+#[test]
+#[cfg(feature = "unstable-msc4274")]
+fn gallery_msgtype_deserialization_with_image() {
+    let json_data = json!({
+        "body": "My photos from [FOSDEM 2025](https://fosdem.org/2025/)",
+        "formatted_body": "My photos from <a href=\"https://fosdem.org/2025/\">FOSDEM 2025</a>",
+        "format": "org.matrix.custom.html",
+        "itemtypes": [{
+            "body": "my_image.jpg",
+            "url": "mxc://notareal.hs/file",
+            "itemtype": "m.image",
+        }],
+        "msgtype": "dm.filament.gallery",
+    });
+
+    let event_content = from_json_value::<RoomMessageEventContent>(json_data).unwrap();
+    assert_matches!(event_content.msgtype, MessageType::Gallery(content));
+    assert_eq!(content.body, "My photos from [FOSDEM 2025](https://fosdem.org/2025/)");
+    assert_eq!(
+        content.formatted.unwrap().body,
+        "My photos from <a href=\"https://fosdem.org/2025/\">FOSDEM 2025</a>"
+    );
+    assert_matches!(&content.itemtypes.len(), 1);
+    assert_matches!(&content.itemtypes.first().unwrap(), GalleryItemType::Image(content));
+    assert_eq!(content.body, "my_image.jpg");
+    assert_matches!(&content.source, MediaSource::Plain(url));
+    assert_eq!(url, "mxc://notareal.hs/file");
+    assert!(content.caption().is_none());
+}
+
+#[test]
+#[cfg(feature = "unstable-msc4274")]
+fn gallery_msgtype_serialization_with_custom_itemtype() {
+    let message_event_content =
+        RoomMessageEventContent::new(MessageType::Gallery(GalleryMessageEventContent::new(
+            "My photos from [FOSDEM 2025](https://fosdem.org/2025/)".to_owned(),
+            Some(FormattedBody::html(
+                "My photos from <a href=\"https://fosdem.org/2025/\">FOSDEM 2025</a>",
+            )),
+            vec![GalleryItemType::new(
+                "my_custom_itemtype",
+                "my message body".into(),
+                json_object! {
+                    "custom_field": "baba",
+                    "another_one": "abab",
+                },
+            )
+            .unwrap()],
+        )));
+
+    assert_eq!(
+        to_json_value(&message_event_content).unwrap(),
+        json!({
+            "body": "My photos from [FOSDEM 2025](https://fosdem.org/2025/)",
+            "formatted_body": "My photos from <a href=\"https://fosdem.org/2025/\">FOSDEM 2025</a>",
+            "format": "org.matrix.custom.html",
+            "itemtypes": [{
+                "body": "my message body",
+                "custom_field": "baba",
+                "another_one": "abab",
+                "itemtype": "my_custom_itemtype",
+            }],
+            "msgtype": "dm.filament.gallery",
+        })
+    );
+}
+
+#[test]
+#[cfg(feature = "unstable-msc4274")]
+fn gallery_msgtype_deserialization_with_custom_itemtype() {
+    let json_data = json!({
+        "body": "My photos from [FOSDEM 2025](https://fosdem.org/2025/)",
+        "formatted_body": "My photos from <a href=\"https://fosdem.org/2025/\">FOSDEM 2025</a>",
+        "format": "org.matrix.custom.html",
+        "itemtypes": [{
+            "body": "my message body",
+            "custom_field": "baba",
+            "another_one": "abab",
+            "itemtype": "my_custom_itemtype",
+        }],
+        "msgtype": "dm.filament.gallery",
+    });
+
+    let expected_json_data = json_object! {
+        "custom_field": "baba",
+        "another_one": "abab",
+    };
+
+    let event_content = from_json_value::<RoomMessageEventContent>(json_data).unwrap();
+    assert_matches!(event_content.msgtype, MessageType::Gallery(content));
+    assert_eq!(content.body, "My photos from [FOSDEM 2025](https://fosdem.org/2025/)");
+    assert_eq!(
+        content.formatted.unwrap().body,
+        "My photos from <a href=\"https://fosdem.org/2025/\">FOSDEM 2025</a>"
+    );
+    assert_matches!(&content.itemtypes.len(), 1);
+    let itemtype = content.itemtypes.first().unwrap();
+    assert_eq!(itemtype.itemtype(), "my_custom_itemtype");
+    assert_eq!(itemtype.body(), "my message body");
+    assert_eq!(itemtype.data(), Cow::Owned(expected_json_data));
+}
+
 #[test]
 fn image_msgtype_serialization() {
     let message_event_content =
diff --git a/crates/ruma/Cargo.toml b/crates/ruma/Cargo.toml
index 694d2bbae9..02b8af8ff5 100644
--- a/crates/ruma/Cargo.toml
+++ b/crates/ruma/Cargo.toml
@@ -228,6 +228,7 @@ unstable-msc4171 = ["ruma-events?/unstable-msc4171"]
 unstable-msc4186 = ["ruma-client-api?/unstable-msc4186"]
 unstable-msc4203 = ["ruma-appservice-api?/unstable-msc4203"]
 unstable-msc4230 = ["ruma-events?/unstable-msc4230"]
+unstable-msc4274 = ["ruma-events?/unstable-msc4274"]
 unstable-pdu = ["ruma-events?/unstable-pdu"]
 unstable-unspecified = [
     "ruma-common/unstable-unspecified",
@@ -283,6 +284,7 @@ __unstable-mscs = [
     "unstable-msc4186",
     "unstable-msc4203",
     "unstable-msc4230",
+    "unstable-msc4274",
 ]
 __ci = [
     "full",


Reviews:
user: login: Johennes
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T12:20:20Z
user: login: Johennes
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T12:21:05Z
user: login: zecakeh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T15:25:33Z
user: login: zecakeh
body: Thanks for adapting your code for the backport
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T15:26:13Z

Review comments:
diff_hunk: @@ -28,6 +28,8 @@ impl<'a> From<&'a OriginalRoomMessageEvent> for OriginalEventData<'a> {
             MessageType::Audio(_) => ("sent an audio file.", None, false),
             MessageType::Emote(c) => (&*c.body, c.formatted.as_ref(), true),
             MessageType::File(_) => ("sent a file.", None, false),
+            #[cfg(feature = "unstable-msc4274")]
+            MessageType::Gallery(_) => ("sent a gallery.", None, false),
path: crates/ruma-events/src/room/message/reply.rs
user: login: Johennes
body: This is new compared to https://github.com/ruma/ruma/pull/2044.
created_at: 2025-03-20T12:20:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -676,6 +704,8 @@ impl MessageType {
                 }
                 MessageType::Audio(m) => (&mut m.body, None),
                 MessageType::File(m) => (&mut m.body, None),
+                #[cfg(feature = "unstable-msc4274")]
+                MessageType::Gallery(m) => (&mut m.body, None),
path: crates/ruma-events/src/room/message.rs
user: login: Johennes
body: This is new compared to https://github.com/ruma/ruma/pull/2044.
created_at: 2025-03-20T12:21:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -76,6 +84,67 @@ impl<'de> Deserialize<'de> for MessageType {
     }
 }
 
+/// Helper struct to determine the itemtype from a `serde_json::value::RawValue`
+#[derive(Debug, Deserialize)]
+#[cfg(feature = "unstable-msc4274")]
+struct ItemTypeDeHelper {
+    /// The item type field
+    itemtype: String,
+}
+
+#[cfg(feature = "unstable-msc4274")]
+impl<'de> Deserialize<'de> for GalleryItemType {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: de::Deserializer<'de>,
+    {
+        let json = Box::<RawJsonValue>::deserialize(deserializer)?;
+        let ItemTypeDeHelper { itemtype } = from_raw_json_value(&json)?;
+
+        Ok(match itemtype.as_ref() {
+            "m.audio" => Self::Audio(from_raw_json_value(&json)?),
+            "m.file" => Self::File(from_raw_json_value(&json)?),
+            "m.image" => Self::Image(from_raw_json_value(&json)?),
+            "m.video" => Self::Video(from_raw_json_value(&json)?),
path: crates/ruma-events/src/room/message/content_serde.rs
user: login: zecakeh
body: I find it weird that those don't complain about a missing `msgtype`, but that's good for us.
created_at: 2025-03-21T15:25:33Z
author_association: CONTRIBUTOR

PR comments:
user: login: zecakeh
created_at: 2025-03-20T12:51:13Z
body: The thing is, we are not supposed to include breaking changes in backports…
author_association: CONTRIBUTOR
user: login: Johennes
created_at: 2025-03-20T12:53:34Z
body: Yeah, I had a feeling this might be problematic. I guess then I'll have to switch back to the custom `Serialize` implementation for 0.12?
author_association: CONTRIBUTOR
user: login: zecakeh
created_at: 2025-03-20T13:04:59Z
body: I think it's the simplest, yes.
author_association: CONTRIBUTOR
user: login: Johennes
created_at: 2025-03-20T18:45:03Z
body: Ok, this is now using the custom serialization approach to avoid breaking changes. I have verified it works by running the tests locally.
author_association: CONTRIBUTOR