diff --git a/crates/ruma-common/Cargo.toml b/crates/ruma-common/Cargo.toml index 5be5c19c..65987ce5 100644 --- a/crates/ruma-common/Cargo.toml +++ b/crates/ruma-common/Cargo.toml @@ -34,6 +34,7 @@ unstable-msc2675 = [] unstable-msc2676 = [] unstable-msc2677 = [] unstable-msc3246 = ["unstable-msc3551", "thiserror"] +unstable-msc3440 = [] unstable-msc3488 = ["unstable-msc1767"] unstable-msc3551 = ["unstable-msc1767"] unstable-msc3552 = ["unstable-msc1767", "unstable-msc3551"] diff --git a/crates/ruma-common/src/events/room/encrypted.rs b/crates/ruma-common/src/events/room/encrypted.rs index 5b71148a..d225cc25 100644 --- a/crates/ruma-common/src/events/room/encrypted.rs +++ b/crates/ruma-common/src/events/room/encrypted.rs @@ -22,9 +22,7 @@ pub struct RoomEncryptedEventContent { #[serde(flatten)] pub scheme: EncryptedEventScheme, - /// Information about related messages for [rich replies]. - /// - /// [rich replies]: https://spec.matrix.org/v1.2/client-server-api/#rich-replies + /// Information about related events. #[serde(flatten, skip_serializing_if = "Option::is_none")] pub relates_to: Option, } @@ -103,6 +101,10 @@ pub enum Relation { #[cfg(feature = "unstable-msc2677")] Annotation(Annotation), + /// An event that belongs to a thread. + #[cfg(feature = "unstable-msc3440")] + Thread(Thread), + #[doc(hidden)] _Custom, } @@ -115,6 +117,12 @@ impl From for Relation { message::Relation::Replacement(re) => { Self::Replacement(Replacement { event_id: re.event_id }) } + #[cfg(feature = "unstable-msc3440")] + message::Relation::Thread(t) => Self::Thread(Thread { + event_id: t.event_id, + in_reply_to: t.in_reply_to, + is_falling_back: t.is_falling_back, + }), message::Relation::_Custom => Self::_Custom, } } @@ -168,6 +176,44 @@ impl Annotation { } } +/// A thread relation for an event. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[cfg(feature = "unstable-msc3440")] +#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] +pub struct Thread { + /// The ID of the root message in the thread. + pub event_id: Box, + + /// A reply relation. + /// + /// If this event is a reply and belongs to a thread, this points to the message that is being + /// replied to, and `is_falling_back` must be set to `false`. + /// + /// If this event is not a reply, this is used as a fallback mechanism for clients that do not + /// support threads. This should point to the latest message-like event in the thread and + /// `is_falling_back` must be set to `true`. + pub in_reply_to: InReplyTo, + + /// Whether the `m.in_reply_to` field is a fallback for older clients or a real reply in a + /// thread. + pub is_falling_back: bool, +} + +#[cfg(feature = "unstable-msc3440")] +impl Thread { + /// Convenience method to create a regular `Thread` with the given event ID and latest + /// message-like event ID. + pub fn plain(event_id: Box, latest_event_id: Box) -> Self { + Self { event_id, in_reply_to: InReplyTo::new(latest_event_id), is_falling_back: false } + } + + /// Convenience method to create a reply `Thread` with the given event ID and replied-to event + /// ID. + pub fn reply(event_id: Box, reply_to_event_id: Box) -> Self { + Self { event_id, in_reply_to: InReplyTo::new(reply_to_event_id), is_falling_back: true } + } +} + /// The content of an `m.room.encrypted` event using the `m.olm.v1.curve25519-aes-sha2` algorithm. #[derive(Clone, Debug, Serialize, Deserialize)] #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] diff --git a/crates/ruma-common/src/events/room/encrypted/relation_serde.rs b/crates/ruma-common/src/events/room/encrypted/relation_serde.rs index 257d9892..c213bd70 100644 --- a/crates/ruma-common/src/events/room/encrypted/relation_serde.rs +++ b/crates/ruma-common/src/events/room/encrypted/relation_serde.rs @@ -4,37 +4,52 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; use super::Annotation; #[cfg(feature = "unstable-msc2676")] use super::Replacement; +#[cfg(feature = "unstable-msc3440")] +use super::Thread; use super::{InReplyTo, Reference, Relation}; +#[cfg(feature = "unstable-msc3440")] +use crate::EventId; impl<'de> Deserialize<'de> for Relation { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { - fn convert_relation(ev: EventWithRelatesToJsonRepr) -> Relation { - if let Some(in_reply_to) = ev.relates_to.in_reply_to { - return Relation::Reply { in_reply_to }; - } + let ev = EventWithRelatesToJsonRepr::deserialize(deserializer)?; - if let Some(relation) = ev.relates_to.relation { - return match relation { - #[cfg(feature = "unstable-msc2677")] - RelationJsonRepr::Annotation(a) => Relation::Annotation(a), - RelationJsonRepr::Reference(r) => Relation::Reference(r), - #[cfg(feature = "unstable-msc2676")] - RelationJsonRepr::Replacement(Replacement { event_id }) => { - Relation::Replacement(Replacement { event_id }) - } - // FIXME: Maybe we should log this, though at this point we don't even have - // access to the rel_type of the unknown relation. - RelationJsonRepr::Unknown => Relation::_Custom, - }; - } - - Relation::_Custom + #[cfg(feature = "unstable-msc3440")] + if let Some(RelationJsonRepr::Thread(ThreadJsonRepr { event_id, is_falling_back })) = + ev.relates_to.relation + { + let in_reply_to = ev + .relates_to + .in_reply_to + .ok_or_else(|| serde::de::Error::missing_field("m.in_reply_to"))?; + return Ok(Relation::Thread(Thread { event_id, in_reply_to, is_falling_back })); } - EventWithRelatesToJsonRepr::deserialize(deserializer).map(convert_relation) + let rel = if let Some(in_reply_to) = ev.relates_to.in_reply_to { + Relation::Reply { in_reply_to } + } else if let Some(relation) = ev.relates_to.relation { + match relation { + #[cfg(feature = "unstable-msc2677")] + RelationJsonRepr::Annotation(a) => Relation::Annotation(a), + RelationJsonRepr::Reference(r) => Relation::Reference(r), + #[cfg(feature = "unstable-msc2676")] + RelationJsonRepr::Replacement(Replacement { event_id }) => { + Relation::Replacement(Replacement { event_id }) + } + #[cfg(feature = "unstable-msc3440")] + RelationJsonRepr::Thread(_) => unreachable!(), + // FIXME: Maybe we should log this, though at this point we don't even have + // access to the rel_type of the unknown relation. + RelationJsonRepr::Unknown => Relation::_Custom, + } + } else { + Relation::_Custom + }; + + Ok(rel) } } @@ -62,6 +77,17 @@ impl Serialize for Relation { Relation::Reply { in_reply_to } => { RelatesToJsonRepr { in_reply_to: Some(in_reply_to.clone()), ..Default::default() } } + #[cfg(feature = "unstable-msc3440")] + Relation::Thread(Thread { event_id, in_reply_to, is_falling_back }) => { + RelatesToJsonRepr { + in_reply_to: Some(in_reply_to.clone()), + relation: Some(RelationJsonRepr::Thread(ThreadJsonRepr { + event_id: event_id.clone(), + is_falling_back: *is_falling_back, + })), + ..Default::default() + } + } Relation::_Custom => RelatesToJsonRepr::default(), }; @@ -75,8 +101,8 @@ struct EventWithRelatesToJsonRepr { relates_to: RelatesToJsonRepr, } -/// Enum modeling the different ways relationships can be expressed in a `m.relates_to` field of an -/// event. +/// Struct modeling the different ways relationships can be expressed in a `m.relates_to` field of +/// an event. #[derive(Default, Deserialize, Serialize)] struct RelatesToJsonRepr { #[serde(rename = "m.in_reply_to", skip_serializing_if = "Option::is_none")] @@ -92,6 +118,23 @@ impl RelatesToJsonRepr { } } +/// A thread relation without the reply fallback. +#[derive(Clone, Deserialize, Serialize)] +#[cfg(feature = "unstable-msc3440")] +struct ThreadJsonRepr { + /// The ID of the root message in the thread. + pub event_id: Box, + + /// Whether the `m.in_reply_to` field is a fallback for older clients or a real reply in a + /// thread. + #[serde( + rename = "io.element.show_reply", + default, + skip_serializing_if = "ruma_common::serde::is_default" + )] + pub is_falling_back: bool, +} + /// A relation, which associates new information to an existing event. #[derive(Clone, Deserialize, Serialize)] #[serde(tag = "rel_type")] @@ -110,6 +153,11 @@ enum RelationJsonRepr { #[serde(rename = "m.replace")] Replacement(Replacement), + /// An event that belongs to a thread. + #[cfg(feature = "unstable-msc3440")] + #[serde(rename = "io.element.thread")] + Thread(ThreadJsonRepr), + /// An unknown relation type. /// /// Not available in the public API, but exists here so deserialization diff --git a/crates/ruma-common/src/events/room/message.rs b/crates/ruma-common/src/events/room/message.rs index c5591792..2b4586d1 100644 --- a/crates/ruma-common/src/events/room/message.rs +++ b/crates/ruma-common/src/events/room/message.rs @@ -338,8 +338,6 @@ impl From for RoomMessageEventContent { } /// Message event relationship. -/// -/// Currently used for replies and editing (message replacement). #[derive(Clone, Debug)] #[allow(clippy::manual_non_exhaustive)] #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] @@ -354,6 +352,10 @@ pub enum Relation { #[cfg(feature = "unstable-msc2676")] Replacement(Replacement), + /// An event that belongs to a thread. + #[cfg(feature = "unstable-msc3440")] + Thread(Thread), + #[doc(hidden)] _Custom, } @@ -393,6 +395,44 @@ impl Replacement { } } +/// The content of a thread relation. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[cfg(feature = "unstable-msc3440")] +#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] +pub struct Thread { + /// The ID of the root message in the thread. + pub event_id: Box, + + /// A reply relation. + /// + /// If this event is a reply and belongs to a thread, this points to the message that is being + /// replied to, and `is_falling_back` must be set to `false`. + /// + /// If this event is not a reply, this is used as a fallback mechanism for clients that do not + /// support threads. This should point to the latest message-like event in the thread and + /// `is_falling_back` must be set to `true`. + pub in_reply_to: InReplyTo, + + /// Whether the `m.in_reply_to` field is a fallback for older clients or a genuine reply in a + /// thread. + pub is_falling_back: bool, +} + +#[cfg(feature = "unstable-msc3440")] +impl Thread { + /// Convenience method to create a regular `Thread` with the given event ID and latest + /// message-like event ID. + pub fn plain(event_id: Box, latest_event_id: Box) -> Self { + Self { event_id, in_reply_to: InReplyTo::new(latest_event_id), is_falling_back: false } + } + + /// Convenience method to create a reply `Thread` with the given event ID and replied-to event + /// ID. + pub fn reply(event_id: Box, reply_to_event_id: Box) -> Self { + Self { event_id, in_reply_to: InReplyTo::new(reply_to_event_id), is_falling_back: true } + } +} + /// The payload for an audio message. #[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] @@ -1006,35 +1046,3 @@ pub struct CustomEventContent { #[serde(flatten)] data: JsonObject, } - -#[cfg(test)] -mod tests { - use crate::event_id; - use matches::assert_matches; - use serde_json::{from_value as from_json_value, json}; - - use super::{InReplyTo, MessageType, Relation, RoomMessageEventContent}; - - #[test] - fn deserialize_reply() { - let ev_id = event_id!("$1598361704261elfgc:localhost"); - - let json = json!({ - "msgtype": "m.text", - "body": "", - "m.relates_to": { - "m.in_reply_to": { - "event_id": ev_id, - }, - }, - }); - - assert_matches!( - from_json_value::(json).unwrap(), - RoomMessageEventContent { - msgtype: MessageType::Text(_), - relates_to: Some(Relation::Reply { in_reply_to: InReplyTo { event_id } }), - } if event_id == ev_id - ); - } -} diff --git a/crates/ruma-common/src/events/room/message/relation_serde.rs b/crates/ruma-common/src/events/room/message/relation_serde.rs index 1ad8a5b6..9c66aab3 100644 --- a/crates/ruma-common/src/events/room/message/relation_serde.rs +++ b/crates/ruma-common/src/events/room/message/relation_serde.rs @@ -4,8 +4,10 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; use super::Replacement; #[cfg(feature = "unstable-msc2676")] use super::RoomMessageEventContent; +#[cfg(feature = "unstable-msc3440")] +use super::Thread; use super::{InReplyTo, Relation}; -#[cfg(feature = "unstable-msc2676")] +#[cfg(any(feature = "unstable-msc2676", feature = "unstable-msc3440"))] use crate::EventId; impl<'de> Deserialize<'de> for Relation { @@ -15,13 +17,22 @@ impl<'de> Deserialize<'de> for Relation { { let ev = EventWithRelatesToJsonRepr::deserialize(deserializer)?; - if let Some(in_reply_to) = ev.relates_to.in_reply_to { - return Ok(Relation::Reply { in_reply_to }); + #[cfg(feature = "unstable-msc3440")] + if let Some(RelationJsonRepr::Thread(ThreadJsonRepr { event_id, is_falling_back })) = + ev.relates_to.relation + { + let in_reply_to = ev + .relates_to + .in_reply_to + .ok_or_else(|| serde::de::Error::missing_field("m.in_reply_to"))?; + return Ok(Relation::Thread(Thread { event_id, in_reply_to, is_falling_back })); } - #[cfg(feature = "unstable-msc2676")] - if let Some(relation) = ev.relates_to.relation { - return Ok(match relation { + let rel = if let Some(in_reply_to) = ev.relates_to.in_reply_to { + Relation::Reply { in_reply_to } + } else if let Some(relation) = ev.relates_to.relation { + match relation { + #[cfg(feature = "unstable-msc2676")] RelationJsonRepr::Replacement(ReplacementJsonRepr { event_id }) => { let new_content = ev .new_content @@ -31,10 +42,14 @@ impl<'de> Deserialize<'de> for Relation { // FIXME: Maybe we should log this, though at this point we don't even have // access to the rel_type of the unknown relation. RelationJsonRepr::Unknown => Relation::_Custom, - }); - } + #[cfg(feature = "unstable-msc3440")] + RelationJsonRepr::Thread(_) => unreachable!(), + } + } else { + Relation::_Custom + }; - Ok(Relation::_Custom) + Ok(rel) } } @@ -61,6 +76,17 @@ impl Serialize for Relation { new_content: Some(new_content.clone()), } } + #[cfg(feature = "unstable-msc3440")] + Relation::Thread(Thread { event_id, in_reply_to, is_falling_back }) => { + EventWithRelatesToJsonRepr::new(RelatesToJsonRepr { + in_reply_to: Some(in_reply_to.clone()), + relation: Some(RelationJsonRepr::Thread(ThreadJsonRepr { + event_id: event_id.clone(), + is_falling_back: *is_falling_back, + })), + ..Default::default() + }) + } Relation::_Custom => EventWithRelatesToJsonRepr::default(), }; @@ -88,41 +114,37 @@ impl EventWithRelatesToJsonRepr { } } -/// Enum modeling the different ways relationships can be expressed in a `m.relates_to` field of an -/// event. +/// Struct modeling the different ways relationships can be expressed in a `m.relates_to` field of +/// an event. #[derive(Default, Deserialize, Serialize)] struct RelatesToJsonRepr { #[serde(rename = "m.in_reply_to", skip_serializing_if = "Option::is_none")] in_reply_to: Option, - #[cfg(feature = "unstable-msc2676")] #[serde(flatten, skip_serializing_if = "Option::is_none")] relation: Option, } impl RelatesToJsonRepr { fn is_empty(&self) -> bool { - #[cfg(not(feature = "unstable-msc2676"))] - { - self.in_reply_to.is_none() - } - - #[cfg(feature = "unstable-msc2676")] - { - self.in_reply_to.is_none() && self.relation.is_none() - } + self.in_reply_to.is_none() && self.relation.is_none() } } /// A relation, which associates new information to an existing event. #[derive(Clone, Deserialize, Serialize)] -#[cfg(feature = "unstable-msc2676")] #[serde(tag = "rel_type")] enum RelationJsonRepr { /// An event that replaces another event. + #[cfg(feature = "unstable-msc2676")] #[serde(rename = "m.replace")] Replacement(ReplacementJsonRepr), + /// An event that belongs to a thread. + #[cfg(feature = "unstable-msc3440")] + #[serde(rename = "io.element.thread")] + Thread(ThreadJsonRepr), + /// An unknown relation type. /// /// Not available in the public API, but exists here so deserialization @@ -136,3 +158,20 @@ enum RelationJsonRepr { struct ReplacementJsonRepr { event_id: Box, } + +/// A thread relation without the reply fallback. +#[derive(Clone, Deserialize, Serialize)] +#[cfg(feature = "unstable-msc3440")] +struct ThreadJsonRepr { + /// The ID of the root message in the thread. + event_id: Box, + + /// Whether the `m.in_reply_to` field is a fallback for older clients or a real reply in a + /// thread. + #[serde( + rename = "io.element.show_reply", + default, + skip_serializing_if = "ruma_common::serde::is_default" + )] + is_falling_back: bool, +} diff --git a/crates/ruma-common/tests/events/mod.rs b/crates/ruma-common/tests/events/mod.rs index b71baaa8..6e93c028 100644 --- a/crates/ruma-common/tests/events/mod.rs +++ b/crates/ruma-common/tests/events/mod.rs @@ -17,6 +17,7 @@ mod message_event; mod pdu; mod redacted; mod redaction; +mod relations; mod room_message; mod state_event; mod stripped; diff --git a/crates/ruma-common/tests/events/relations.rs b/crates/ruma-common/tests/events/relations.rs new file mode 100644 index 00000000..13f85d61 --- /dev/null +++ b/crates/ruma-common/tests/events/relations.rs @@ -0,0 +1,217 @@ +use assign::assign; +use matches::assert_matches; +use ruma_common::{ + event_id, + events::room::message::{InReplyTo, MessageType, Relation, RoomMessageEventContent}, +}; +use serde_json::{from_value as from_json_value, json, to_value as to_json_value}; + +#[test] +fn reply_deserialize() { + let ev_id = event_id!("$1598361704261elfgc:localhost"); + + let json = json!({ + "msgtype": "m.text", + "body": "", + "m.relates_to": { + "m.in_reply_to": { + "event_id": ev_id, + }, + }, + }); + + assert_matches!( + from_json_value::(json).unwrap(), + RoomMessageEventContent { + msgtype: MessageType::Text(_), + relates_to: Some(Relation::Reply { in_reply_to: InReplyTo { event_id, .. }, .. }), + .. + } if event_id == ev_id + ); +} + +#[test] +fn reply_serialize() { + let content = assign!(RoomMessageEventContent::text_plain("This is a reply"), { + relates_to: Some(Relation::Reply { in_reply_to: InReplyTo::new(event_id!("$1598361704261elfgc").to_owned()) }), + }); + + assert_eq!( + to_json_value(content).unwrap(), + json!({ + "msgtype": "m.text", + "body": "This is a reply", + "m.relates_to": { + "m.in_reply_to": { + "event_id": "$1598361704261elfgc", + }, + }, + }) + ); +} + +#[test] +#[cfg(feature = "unstable-msc2676")] +fn replacement_serialize() { + use ruma_common::events::room::message::Replacement; + + let content = assign!( + RoomMessageEventContent::text_plain(""), + { + relates_to: Some(Relation::Replacement( + Replacement::new( + event_id!("$1598361704261elfgc").to_owned(), + Box::new(RoomMessageEventContent::text_plain("This is the new content.")), + ) + )) + } + ); + + assert_eq!( + to_json_value(content).unwrap(), + json!({ + "msgtype": "m.text", + "body": "", + "m.new_content": { + "body": "This is the new content.", + "msgtype": "m.text", + }, + "m.relates_to": { + "rel_type": "m.replace", + "event_id": "$1598361704261elfgc", + }, + }) + ); +} + +#[test] +#[cfg(feature = "unstable-msc2676")] +fn replacement_deserialize() { + use ruma_common::events::room::message::Replacement; + + let json = json!({ + "msgtype": "m.text", + "body": "", + "m.new_content": { + "body": "Hello! My name is bar", + "msgtype": "m.text", + }, + "m.relates_to": { + "rel_type": "m.replace", + "event_id": "$1598361704261elfgc", + }, + }); + + assert_matches!( + from_json_value::(json).unwrap(), + RoomMessageEventContent { + msgtype: MessageType::Text(_), + relates_to: Some(Relation::Replacement(Replacement { event_id, new_content, .. })), + .. + } if event_id == "$1598361704261elfgc" + && matches!(&new_content.msgtype, MessageType::Text(text) if text.body == "Hello! My name is bar") + ); +} + +#[test] +#[cfg(feature = "unstable-msc3440")] +fn thread_plain_serialize() { + use ruma_common::events::room::message::Thread; + + let content = assign!( + RoomMessageEventContent::text_plain(""), + { + relates_to: Some(Relation::Thread( + Thread::plain( + event_id!("$1598361704261elfgc").to_owned(), + event_id!("$latesteventid").to_owned(), + ), + )), + } + ); + + assert_eq!( + to_json_value(content).unwrap(), + json!({ + "msgtype": "m.text", + "body": "", + "m.relates_to": { + "rel_type": "io.element.thread", + "event_id": "$1598361704261elfgc", + "m.in_reply_to": { + "event_id": "$latesteventid", + }, + }, + }) + ); +} + +#[test] +#[cfg(feature = "unstable-msc3440")] +fn thread_reply_serialize() { + use ruma_common::events::room::message::Thread; + + let content = assign!( + RoomMessageEventContent::text_plain(""), + { + relates_to: Some(Relation::Thread( + Thread::reply( + event_id!("$1598361704261elfgc").to_owned(), + event_id!("$repliedtoeventid").to_owned(), + ), + )), + } + ); + + assert_eq!( + to_json_value(content).unwrap(), + json!({ + "msgtype": "m.text", + "body": "", + "m.relates_to": { + "rel_type": "io.element.thread", + "event_id": "$1598361704261elfgc", + "m.in_reply_to": { + "event_id": "$repliedtoeventid", + }, + "io.element.show_reply": true, + }, + }) + ); +} + +#[test] +#[cfg(feature = "unstable-msc3440")] +fn thread_deserialize() { + use ruma_common::events::room::message::Thread; + + let json = json!({ + "msgtype": "m.text", + "body": "", + "m.relates_to": { + "rel_type": "io.element.thread", + "event_id": "$1598361704261elfgc", + "m.in_reply_to": { + "event_id": "$latesteventid", + }, + }, + }); + + assert_matches!( + from_json_value::(json).unwrap(), + RoomMessageEventContent { + msgtype: MessageType::Text(_), + relates_to: Some(Relation::Thread( + Thread { + event_id, + in_reply_to: InReplyTo { event_id: reply_to_event_id, .. }, + is_falling_back, + .. + }, + )), + .. + } if event_id == "$1598361704261elfgc" + && reply_to_event_id == "$latesteventid" + && !is_falling_back + ); +} diff --git a/crates/ruma/Cargo.toml b/crates/ruma/Cargo.toml index db30612f..cd082161 100644 --- a/crates/ruma/Cargo.toml +++ b/crates/ruma/Cargo.toml @@ -118,6 +118,7 @@ unstable-msc2675 = ["ruma-common/unstable-msc2675"] unstable-msc2676 = ["ruma-common/unstable-msc2676"] unstable-msc2677 = ["ruma-common/unstable-msc2677"] unstable-msc3246 = ["ruma-common/unstable-msc3246"] +unstable-msc3440 = ["ruma-common/unstable-msc3440"] unstable-msc3488 = [ "ruma-client-api/unstable-msc3488", "ruma-common/unstable-msc3488", @@ -138,6 +139,7 @@ __ci = [ "unstable-msc2676", "unstable-msc2677", "unstable-msc3246", + "unstable-msc3440", "unstable-msc3488", "unstable-msc3551", "unstable-msc3552",