diff --git a/crates/ruma-common/CHANGELOG.md b/crates/ruma-common/CHANGELOG.md index a2381bbc..d8351cf4 100644 --- a/crates/ruma-common/CHANGELOG.md +++ b/crates/ruma-common/CHANGELOG.md @@ -14,6 +14,7 @@ Improvements: * All push rules are now considered to not apply to events sent by the user themselves * Change `events::relation::BundledAnnotation` to a struct instead of an enum * Remove `BundledReaction` +* Add unstable support for polls (MSC3381) # 0.9.2 diff --git a/crates/ruma-common/Cargo.toml b/crates/ruma-common/Cargo.toml index d7720123..4b4756b0 100644 --- a/crates/ruma-common/Cargo.toml +++ b/crates/ruma-common/Cargo.toml @@ -37,6 +37,7 @@ unstable-msc2676 = [] unstable-msc2677 = [] unstable-msc3245 = ["unstable-msc3246"] unstable-msc3246 = ["unstable-msc3551", "thiserror"] +unstable-msc3381 = ["unstable-msc1767"] unstable-msc3440 = [] unstable-msc3488 = ["unstable-msc1767"] unstable-msc3551 = ["unstable-msc1767"] diff --git a/crates/ruma-common/src/events.rs b/crates/ruma-common/src/events.rs index 736d5da8..3a947141 100644 --- a/crates/ruma-common/src/events.rs +++ b/crates/ruma-common/src/events.rs @@ -146,6 +146,8 @@ pub mod notice; #[cfg(feature = "unstable-pdu")] pub mod pdu; pub mod policy; +#[cfg(feature = "unstable-msc3381")] +pub mod poll; pub mod presence; pub mod push_rules; #[cfg(feature = "unstable-msc2677")] diff --git a/crates/ruma-common/src/events/enums.rs b/crates/ruma-common/src/events/enums.rs index 3d97ede9..f0413a4e 100644 --- a/crates/ruma-common/src/events/enums.rs +++ b/crates/ruma-common/src/events/enums.rs @@ -62,6 +62,15 @@ event_enum! { "m.message" => super::message, #[cfg(feature = "unstable-msc1767")] "m.notice" => super::notice, + #[cfg(feature = "unstable-msc3381")] + #[ruma_enum(alias = "m.poll.start")] + "org.matrix.msc3381.poll.start" => super::poll::start, + #[cfg(feature = "unstable-msc3381")] + #[ruma_enum(alias = "m.poll.response")] + "org.matrix.msc3381.poll.response" => super::poll::response, + #[cfg(feature = "unstable-msc3381")] + #[ruma_enum(alias = "m.poll.end")] + "org.matrix.msc3381.poll.end" => super::poll::end, #[cfg(feature = "unstable-msc2677")] "m.reaction" => super::reaction, "m.room.encrypted" => super::room::encrypted, @@ -280,6 +289,8 @@ impl AnyMessageLikeEventContent { mac::KeyVerificationMacEventContent, ready::KeyVerificationReadyEventContent, start::KeyVerificationStartEventContent, }; + #[cfg(feature = "unstable-msc3381")] + use super::poll::{end::PollEndEventContent, response::PollResponseEventContent}; match self { #[rustfmt::skip] @@ -325,6 +336,16 @@ impl AnyMessageLikeEventContent { Self::Image(ev) => ev.relates_to.clone().map(Into::into), #[cfg(feature = "unstable-msc3553")] Self::Video(ev) => ev.relates_to.clone().map(Into::into), + #[cfg(feature = "unstable-msc3381")] + Self::PollResponse(PollResponseEventContent { relates_to, .. }) + | Self::PollEnd(PollEndEventContent { relates_to, .. }) => { + let super::poll::ReferenceRelation { event_id } = relates_to; + Some(encrypted::Relation::Reference(encrypted::Reference { + event_id: event_id.clone(), + })) + } + #[cfg(feature = "unstable-msc3381")] + Self::PollStart(_) => None, Self::CallAnswer(_) | Self::CallInvite(_) | Self::CallHangup(_) diff --git a/crates/ruma-common/src/events/poll.rs b/crates/ruma-common/src/events/poll.rs new file mode 100644 index 00000000..56db6b49 --- /dev/null +++ b/crates/ruma-common/src/events/poll.rs @@ -0,0 +1,29 @@ +//! Modules for events in the `m.poll` namespace ([MSC3381]). +//! +//! This module also contains types shared by events in its child namespaces. +//! +//! [MSC3381]: https://github.com/matrix-org/matrix-spec-proposals/pull/3381 + +use serde::{Deserialize, Serialize}; + +use crate::OwnedEventId; + +pub mod end; +pub mod response; +pub mod start; + +/// An `m.reference` relation. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] +#[serde(tag = "rel_type", rename = "m.reference")] +pub struct ReferenceRelation { + /// The ID of the event this references. + pub event_id: OwnedEventId, +} + +impl ReferenceRelation { + /// Creates a new `ReferenceRelation` that references the given event ID. + pub fn new(event_id: OwnedEventId) -> Self { + Self { event_id } + } +} diff --git a/crates/ruma-common/src/events/poll/end.rs b/crates/ruma-common/src/events/poll/end.rs new file mode 100644 index 00000000..405013e3 --- /dev/null +++ b/crates/ruma-common/src/events/poll/end.rs @@ -0,0 +1,43 @@ +//! Types for the [`m.poll.end`] event. + +use ruma_macros::EventContent; +use serde::{Deserialize, Serialize}; + +use super::ReferenceRelation; +use crate::OwnedEventId; + +/// The payload for a poll end event. +#[derive(Clone, Debug, Serialize, Deserialize, EventContent)] +#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] +#[ruma_event(type = "org.matrix.msc3381.poll.end", alias = "m.poll.end", kind = MessageLike)] +pub struct PollEndEventContent { + /// The poll end content of the message. + #[serde(rename = "org.matrix.msc3381.poll.end", alias = "m.poll.end")] + pub poll_end: PollEndContent, + + /// Information about the poll start event this responds to. + #[serde(rename = "m.relates_to")] + pub relates_to: ReferenceRelation, +} + +impl PollEndEventContent { + /// Creates a new `PollEndEventContent` that responds to the given poll start event ID, + /// with the given poll end content. + pub fn new(poll_end: PollEndContent, poll_start_id: OwnedEventId) -> Self { + Self { poll_end, relates_to: ReferenceRelation::new(poll_start_id) } + } +} + +/// Poll end content. +/// +/// This is currently empty. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] +pub struct PollEndContent {} + +impl PollEndContent { + /// Creates a new empty `PollEndContent`. + pub fn new() -> Self { + Self {} + } +} diff --git a/crates/ruma-common/src/events/poll/response.rs b/crates/ruma-common/src/events/poll/response.rs new file mode 100644 index 00000000..338a8602 --- /dev/null +++ b/crates/ruma-common/src/events/poll/response.rs @@ -0,0 +1,49 @@ +//! Types for the [`m.poll.response`] event. + +use ruma_macros::EventContent; +use serde::{Deserialize, Serialize}; + +use super::ReferenceRelation; +use crate::OwnedEventId; + +/// The payload for a poll response event. +#[derive(Clone, Debug, Serialize, Deserialize, EventContent)] +#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] +#[ruma_event(type = "org.matrix.msc3381.poll.response", alias = "m.poll.response", kind = MessageLike)] +pub struct PollResponseEventContent { + /// The poll response content of the message. + #[serde(rename = "org.matrix.msc3381.poll.response", alias = "m.poll.response")] + pub poll_response: PollResponseContent, + + /// Information about the poll start event this responds to. + #[serde(rename = "m.relates_to")] + pub relates_to: ReferenceRelation, +} + +impl PollResponseEventContent { + /// Creates a new `PollResponseEventContent` that responds to the given poll start event ID, + /// with the given poll response content. + pub fn new(poll_response: PollResponseContent, poll_start_id: OwnedEventId) -> Self { + Self { poll_response, relates_to: ReferenceRelation::new(poll_start_id) } + } +} + +/// Poll response content. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] +pub struct PollResponseContent { + /// The IDs of the selected answers of the poll. + /// + /// It should be truncated to `max_selections` from the related poll start event. + /// + /// If this is an empty array or includes unknown IDs, this vote should be considered as + /// spoiled. + pub answers: Vec, +} + +impl PollResponseContent { + /// Creates a new `PollResponseContent` with the given answers. + pub fn new(answers: Vec) -> Self { + Self { answers } + } +} diff --git a/crates/ruma-common/src/events/poll/start.rs b/crates/ruma-common/src/events/poll/start.rs new file mode 100644 index 00000000..f5fdc781 --- /dev/null +++ b/crates/ruma-common/src/events/poll/start.rs @@ -0,0 +1,175 @@ +//! Types for the [`m.poll.start`] event. + +use std::convert::TryFrom; + +use js_int::{uint, UInt}; +use ruma_macros::EventContent; +use serde::{Deserialize, Serialize}; + +mod poll_answers_serde; + +use poll_answers_serde::PollAnswersDeHelper; + +use crate::{events::message::MessageContent, serde::StringEnum, PrivOwnedStr}; + +/// The payload for a poll start event. +#[derive(Clone, Debug, Serialize, Deserialize, EventContent)] +#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] +#[ruma_event(type = "org.matrix.msc3381.poll.start", alias = "m.poll.start", kind = MessageLike)] +pub struct PollStartEventContent { + /// The poll start content of the message. + #[serde(rename = "org.matrix.msc3381.poll.start", alias = "m.poll.start")] + pub poll_start: PollStartContent, + + /// Optional fallback text representation of the message, for clients that don't support polls. + #[serde(flatten, skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +impl PollStartEventContent { + /// Creates a new `PollStartEventContent` with the given poll start content. + pub fn new(poll_start: PollStartContent) -> Self { + Self { poll_start, message: None } + } +} + +/// Poll start content. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] +pub struct PollStartContent { + /// The question of the poll. + pub question: MessageContent, + + /// The kind of the poll. + #[serde(default)] + pub kind: PollKind, + + /// The maximum number of responses a user is able to select. + /// + /// Must be greater or equal to `1`. + /// + /// Defaults to `1`. + #[serde( + default = "PollStartContent::default_max_selections", + skip_serializing_if = "PollStartContent::max_selections_is_default" + )] + pub max_selections: UInt, + + /// The possible answers to the poll. + pub answers: PollAnswers, +} + +impl PollStartContent { + /// Creates a new `PollStartContent` with the given question, kind, and answers. + pub fn new(question: MessageContent, kind: PollKind, answers: PollAnswers) -> Self { + Self { question, kind, max_selections: Self::default_max_selections(), answers } + } + + fn default_max_selections() -> UInt { + uint!(1) + } + + fn max_selections_is_default(max_selections: &UInt) -> bool { + max_selections == &Self::default_max_selections() + } +} + +/// The kind of poll. +#[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/string_enum.md"))] +#[derive(Clone, Debug, PartialEq, Eq, StringEnum)] +#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] +pub enum PollKind { + /// The results are revealed once the poll is closed. + #[ruma_enum(rename = "org.matrix.msc3381.poll.undisclosed", alias = "m.poll.undisclosed")] + Undisclosed, + + /// The votes are visible up until and including when the poll is closed. + #[ruma_enum(rename = "org.matrix.msc3381.poll.disclosed", alias = "m.poll.disclosed")] + Disclosed, + + #[doc(hidden)] + _Custom(PrivOwnedStr), +} + +impl Default for PollKind { + fn default() -> Self { + Self::Undisclosed + } +} + +/// The answers to a poll. +/// +/// Must include between 1 and 20 `PollAnswer`s. +/// +/// To build this, use the `TryFrom` implementations. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(try_from = "PollAnswersDeHelper")] +pub struct PollAnswers(Vec); + +impl PollAnswers { + /// The smallest number of values contained in a `PollAnswers`. + pub const MIN_LENGTH: usize = 1; + + /// The largest number of values contained in a `PollAnswers`. + pub const MAX_LENGTH: usize = 20; + + /// The answers of this `PollAnswers`. + pub fn answers(&self) -> &[PollAnswer] { + &self.0 + } +} + +/// An error encountered when trying to convert to a `PollAnswers`. +#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq, thiserror::Error)] +#[non_exhaustive] +pub enum PollAnswersError { + /// There are more than [`PollAnswers::MAX_LENGTH`] values. + #[error("too many values")] + TooManyValues, + /// There are less that [`PollAnswers::MIN_LENGTH`] values. + #[error("not enough values")] + NotEnoughValues, +} + +impl TryFrom> for PollAnswers { + type Error = PollAnswersError; + + fn try_from(value: Vec) -> Result { + if value.len() < Self::MIN_LENGTH { + Err(PollAnswersError::NotEnoughValues) + } else if value.len() > Self::MAX_LENGTH { + Err(PollAnswersError::TooManyValues) + } else { + Ok(Self(value)) + } + } +} + +impl TryFrom<&[PollAnswer]> for PollAnswers { + type Error = PollAnswersError; + + fn try_from(value: &[PollAnswer]) -> Result { + Self::try_from(value.to_owned()) + } +} + +/// Poll answer. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] +pub struct PollAnswer { + /// The ID of the answer. + /// + /// This must be unique among the answers of a poll. + pub id: String, + + /// The text representation of the answer. + #[serde(flatten)] + pub answer: MessageContent, +} + +impl PollAnswer { + /// Creates a new `PollAnswer` with the given id and text representation. + pub fn new(id: String, answer: MessageContent) -> Self { + Self { id, answer } + } +} diff --git a/crates/ruma-common/src/events/poll/start/poll_answers_serde.rs b/crates/ruma-common/src/events/poll/start/poll_answers_serde.rs new file mode 100644 index 00000000..e1e6a765 --- /dev/null +++ b/crates/ruma-common/src/events/poll/start/poll_answers_serde.rs @@ -0,0 +1,20 @@ +//! `Serialize` and `Deserialize` implementations for extensible events (MSC1767). + +use std::convert::TryFrom; + +use serde::Deserialize; + +use super::{PollAnswer, PollAnswers, PollAnswersError}; + +#[derive(Debug, Default, Deserialize)] +pub(crate) struct PollAnswersDeHelper(Vec); + +impl TryFrom for PollAnswers { + type Error = PollAnswersError; + + fn try_from(helper: PollAnswersDeHelper) -> Result { + let mut answers = helper.0; + answers.truncate(PollAnswers::MAX_LENGTH); + PollAnswers::try_from(answers) + } +} diff --git a/crates/ruma-common/src/push/predefined.rs b/crates/ruma-common/src/push/predefined.rs index c93d348b..4eb80fe6 100644 --- a/crates/ruma-common/src/push/predefined.rs +++ b/crates/ruma-common/src/push/predefined.rs @@ -42,6 +42,19 @@ impl Ruleset { ConditionalPushRule::tombstone(), ConditionalPushRule::roomnotif(), ], + #[cfg(feature = "unstable-msc3381")] + underride: indexset![ + ConditionalPushRule::call(), + ConditionalPushRule::encrypted_room_one_to_one(), + ConditionalPushRule::room_one_to_one(), + ConditionalPushRule::message(), + ConditionalPushRule::encrypted(), + ConditionalPushRule::poll_start_one_to_one(), + ConditionalPushRule::poll_start(), + ConditionalPushRule::poll_end_one_to_one(), + ConditionalPushRule::poll_end(), + ], + #[cfg(not(feature = "unstable-msc3381"))] underride: indexset![ ConditionalPushRule::call(), ConditionalPushRule::encrypted_room_one_to_one(), @@ -274,4 +287,66 @@ impl ConditionalPushRule { actions: vec![Notify, SetTweak(Tweak::Highlight(false))], } } + + /// Matches a poll start event sent in a room with exactly two members. + /// + /// This rule should be kept in sync with `.m.rule.room_one_to_one` by the server. + #[cfg(feature = "unstable-msc3381")] + pub fn poll_start_one_to_one() -> Self { + Self { + rule_id: ".m.rule.poll_start_one_to_one".into(), + default: true, + enabled: true, + conditions: vec![ + RoomMemberCount { is: RoomMemberCountIs::from(js_int::uint!(2)) }, + EventMatch { key: "type".into(), pattern: "m.poll.start".into() }, + ], + actions: vec![Notify, SetTweak(Tweak::Sound("default".into()))], + } + } + + /// Matches a poll start event sent in any room. + /// + /// This rule should be kept in sync with `.m.rule.message` by the server. + #[cfg(feature = "unstable-msc3381")] + pub fn poll_start() -> Self { + Self { + rule_id: ".m.rule.poll_start".into(), + default: true, + enabled: true, + conditions: vec![EventMatch { key: "type".into(), pattern: "m.poll.start".into() }], + actions: vec![Notify], + } + } + + /// Matches a poll end event sent in a room with exactly two members. + /// + /// This rule should be kept in sync with `.m.rule.room_one_to_one` by the server. + #[cfg(feature = "unstable-msc3381")] + pub fn poll_end_one_to_one() -> Self { + Self { + rule_id: ".m.rule.poll_end_one_to_one".into(), + default: true, + enabled: true, + conditions: vec![ + RoomMemberCount { is: RoomMemberCountIs::from(js_int::uint!(2)) }, + EventMatch { key: "type".into(), pattern: "m.poll.end".into() }, + ], + actions: vec![Notify, SetTweak(Tweak::Sound("default".into()))], + } + } + + /// Matches a poll end event sent in any room. + /// + /// This rule should be kept in sync with `.m.rule.message` by the server. + #[cfg(feature = "unstable-msc3381")] + pub fn poll_end() -> Self { + Self { + rule_id: ".m.rule.poll_end".into(), + default: true, + enabled: true, + conditions: vec![EventMatch { key: "type".into(), pattern: "m.poll.end".into() }], + actions: vec![Notify], + } + } } diff --git a/crates/ruma-common/tests/events/mod.rs b/crates/ruma-common/tests/events/mod.rs index fc16421c..c9519722 100644 --- a/crates/ruma-common/tests/events/mod.rs +++ b/crates/ruma-common/tests/events/mod.rs @@ -13,6 +13,7 @@ mod location; mod message; mod message_event; mod pdu; +mod poll; mod redacted; mod redaction; mod relations; diff --git a/crates/ruma-common/tests/events/poll.rs b/crates/ruma-common/tests/events/poll.rs new file mode 100644 index 00000000..b4687624 --- /dev/null +++ b/crates/ruma-common/tests/events/poll.rs @@ -0,0 +1,472 @@ +#![cfg(feature = "unstable-msc3381")] + +use std::convert::TryInto; + +use assert_matches::assert_matches; +use assign::assign; +use js_int::uint; +use ruma_common::{ + event_id, + events::{ + message::MessageContent, + poll::{ + end::{PollEndContent, PollEndEventContent}, + response::{PollResponseContent, PollResponseEventContent}, + start::{ + PollAnswer, PollAnswers, PollAnswersError, PollKind, PollStartContent, + PollStartEventContent, + }, + ReferenceRelation, + }, + AnyMessageLikeEvent, MessageLikeEvent, MessageLikeUnsigned, OriginalMessageLikeEvent, + }, + room_id, user_id, MilliSecondsSinceUnixEpoch, +}; +use serde_json::{from_value as from_json_value, json, to_value as to_json_value}; + +#[test] +fn poll_answers_deserialization_valid() { + let json_data = json!([ + { "id": "aaa", "m.text": "First answer" }, + { "id": "bbb", "m.text": "Second answer" }, + ]); + + assert_matches!( + from_json_value::(json_data), + Ok(answers) if answers.answers().len() == 2 + ); +} + +#[test] +fn poll_answers_deserialization_truncate() { + let json_data = json!([ + { "id": "aaa", "m.text": "1st answer" }, + { "id": "bbb", "m.text": "2nd answer" }, + { "id": "ccc", "m.text": "3rd answer" }, + { "id": "ddd", "m.text": "4th answer" }, + { "id": "eee", "m.text": "5th answer" }, + { "id": "fff", "m.text": "6th answer" }, + { "id": "ggg", "m.text": "7th answer" }, + { "id": "hhh", "m.text": "8th answer" }, + { "id": "iii", "m.text": "9th answer" }, + { "id": "jjj", "m.text": "10th answer" }, + { "id": "kkk", "m.text": "11th answer" }, + { "id": "lll", "m.text": "12th answer" }, + { "id": "mmm", "m.text": "13th answer" }, + { "id": "nnn", "m.text": "14th answer" }, + { "id": "ooo", "m.text": "15th answer" }, + { "id": "ppp", "m.text": "16th answer" }, + { "id": "qqq", "m.text": "17th answer" }, + { "id": "rrr", "m.text": "18th answer" }, + { "id": "sss", "m.text": "19th answer" }, + { "id": "ttt", "m.text": "20th answer" }, + { "id": "uuu", "m.text": "21th answer" }, + { "id": "vvv", "m.text": "22th answer" }, + ]); + + assert_matches!( + from_json_value::(json_data), + Ok(answers) if answers.answers().len() == 20 + ); +} + +#[test] +fn poll_answers_deserialization_not_enough() { + let json_data = json!([]); + + let err = from_json_value::(json_data).unwrap_err(); + assert!(err.is_data()); + assert_eq!(err.to_string(), PollAnswersError::NotEnoughValues.to_string()); +} + +#[test] +fn start_content_serialization() { + let event_content = PollStartEventContent::new(PollStartContent::new( + MessageContent::plain("How's the weather?"), + PollKind::Undisclosed, + vec![ + PollAnswer::new("not-bad".to_owned(), MessageContent::plain("Not bad…")), + PollAnswer::new("fine".to_owned(), MessageContent::plain("Fine.")), + PollAnswer::new("amazing".to_owned(), MessageContent::plain("Amazing!")), + ] + .try_into() + .unwrap(), + )); + + assert_eq!( + to_json_value(&event_content).unwrap(), + json!({ + "org.matrix.msc3381.poll.start": { + "question": { "org.matrix.msc1767.text": "How's the weather?" }, + "kind": "org.matrix.msc3381.poll.undisclosed", + "answers": [ + { "id": "not-bad", "org.matrix.msc1767.text": "Not bad…"}, + { "id": "fine", "org.matrix.msc1767.text": "Fine."}, + { "id": "amazing", "org.matrix.msc1767.text": "Amazing!"}, + ], + }, + }) + ); +} + +#[test] +fn start_event_serialization() { + let event = OriginalMessageLikeEvent { + content: PollStartEventContent::new(assign!( + PollStartContent::new( + MessageContent::plain("How's the weather?"), + PollKind::Disclosed, + vec![ + PollAnswer::new("not-bad".to_owned(), MessageContent::plain("Not bad…")), + PollAnswer::new("fine".to_owned(), MessageContent::plain("Fine.")), + PollAnswer::new("amazing".to_owned(), MessageContent::plain("Amazing!")), + ] + .try_into() + .unwrap(), + ), + { max_selections: uint!(2) } + )), + event_id: event_id!("$event:notareal.hs").to_owned(), + sender: user_id!("@user:notareal.hs").to_owned(), + origin_server_ts: MilliSecondsSinceUnixEpoch(uint!(134_829_848)), + room_id: room_id!("!roomid:notareal.hs").to_owned(), + unsigned: MessageLikeUnsigned::default(), + }; + + assert_eq!( + to_json_value(&event).unwrap(), + json!({ + "content": { + "org.matrix.msc3381.poll.start": { + "question": { "org.matrix.msc1767.text": "How's the weather?" }, + "kind": "org.matrix.msc3381.poll.disclosed", + "max_selections": 2, + "answers": [ + { "id": "not-bad", "org.matrix.msc1767.text": "Not bad…"}, + { "id": "fine", "org.matrix.msc1767.text": "Fine."}, + { "id": "amazing", "org.matrix.msc1767.text": "Amazing!"}, + ] + }, + }, + "event_id": "$event:notareal.hs", + "origin_server_ts": 134_829_848, + "room_id": "!roomid:notareal.hs", + "sender": "@user:notareal.hs", + "type": "org.matrix.msc3381.poll.start", + }) + ); +} + +#[test] +fn start_event_unstable_deserialization() { + let json_data = json!({ + "content": { + "org.matrix.msc3381.poll.start": { + "question": { "org.matrix.msc1767.text": "How's the weather?" }, + "kind": "org.matrix.msc3381.poll.undisclosed", + "max_selections": 2, + "answers": [ + { "id": "not-bad", "org.matrix.msc1767.text": "Not bad…"}, + { "id": "fine", "org.matrix.msc1767.text": "Fine."}, + { "id": "amazing", "org.matrix.msc1767.text": "Amazing!"}, + ] + }, + }, + "event_id": "$event:notareal.hs", + "origin_server_ts": 134_829_848, + "room_id": "!roomid:notareal.hs", + "sender": "@user:notareal.hs", + "type": "org.matrix.msc3381.poll.start", + }); + + let event = from_json_value::(json_data).unwrap(); + let message_event = assert_matches!( + event, + AnyMessageLikeEvent::PollStart(MessageLikeEvent::Original(message_event)) => message_event + ); + let poll_start = message_event.content.poll_start; + assert_eq!(poll_start.question[0].body, "How's the weather?"); + assert_eq!(poll_start.kind, PollKind::Undisclosed); + assert_eq!(poll_start.max_selections, uint!(2)); + let answers = poll_start.answers.answers(); + assert_eq!(answers.len(), 3); + assert_eq!(answers[0].id, "not-bad"); + assert_eq!(answers[0].answer[0].body, "Not bad…"); + assert_eq!(answers[1].id, "fine"); + assert_eq!(answers[1].answer[0].body, "Fine."); + assert_eq!(answers[2].id, "amazing"); + assert_eq!(answers[2].answer[0].body, "Amazing!"); +} + +#[test] +fn start_event_stable_deserialization() { + let json_data = json!({ + "content": { + "m.poll.start": { + "question": { "m.text": "How's the weather?" }, + "kind": "m.poll.disclosed", + "answers": [ + { "id": "not-bad", "m.text": "Not bad…"}, + { "id": "fine", "m.text": "Fine."}, + { "id": "amazing", "m.text": "Amazing!"}, + ] + }, + }, + "event_id": "$event:notareal.hs", + "origin_server_ts": 134_829_848, + "room_id": "!roomid:notareal.hs", + "sender": "@user:notareal.hs", + "type": "m.poll.start", + }); + + let event = from_json_value::(json_data).unwrap(); + let message_event = assert_matches!( + event, + AnyMessageLikeEvent::PollStart(MessageLikeEvent::Original(message_event)) => message_event + ); + let poll_start = message_event.content.poll_start; + assert_eq!(poll_start.question[0].body, "How's the weather?"); + assert_eq!(poll_start.kind, PollKind::Disclosed); + assert_eq!(poll_start.max_selections, uint!(1)); + let answers = poll_start.answers.answers(); + assert_eq!(answers.len(), 3); + assert_eq!(answers[0].id, "not-bad"); + assert_eq!(answers[0].answer[0].body, "Not bad…"); + assert_eq!(answers[1].id, "fine"); + assert_eq!(answers[1].answer[0].body, "Fine."); + assert_eq!(answers[2].id, "amazing"); + assert_eq!(answers[2].answer[0].body, "Amazing!"); +} + +#[test] +fn response_content_serialization() { + let event_content = PollResponseEventContent::new( + PollResponseContent::new(vec!["my-answer".to_owned()]), + event_id!("$related_event:notareal.hs").to_owned(), + ); + + assert_eq!( + to_json_value(&event_content).unwrap(), + json!({ + "org.matrix.msc3381.poll.response": { + "answers": ["my-answer"], + }, + "m.relates_to": { + "rel_type": "m.reference", + "event_id": "$related_event:notareal.hs", + } + }) + ); +} + +#[test] +fn response_event_serialization() { + let event = OriginalMessageLikeEvent { + content: PollResponseEventContent::new( + PollResponseContent::new(vec!["first-answer".to_owned(), "second-answer".to_owned()]), + event_id!("$related_event:notareal.hs").to_owned(), + ), + event_id: event_id!("$event:notareal.hs").to_owned(), + sender: user_id!("@user:notareal.hs").to_owned(), + origin_server_ts: MilliSecondsSinceUnixEpoch(uint!(134_829_848)), + room_id: room_id!("!roomid:notareal.hs").to_owned(), + unsigned: MessageLikeUnsigned::default(), + }; + + assert_eq!( + to_json_value(&event).unwrap(), + json!({ + "content": { + "org.matrix.msc3381.poll.response": { + "answers": ["first-answer", "second-answer"], + }, + "m.relates_to": { + "rel_type": "m.reference", + "event_id": "$related_event:notareal.hs", + } + }, + "event_id": "$event:notareal.hs", + "origin_server_ts": 134_829_848, + "room_id": "!roomid:notareal.hs", + "sender": "@user:notareal.hs", + "type": "org.matrix.msc3381.poll.response", + }) + ); +} + +#[test] +fn response_event_unstable_deserialization() { + let json_data = json!({ + "content": { + "org.matrix.msc3381.poll.response": { + "answers": ["my-answer"], + }, + "m.relates_to": { + "rel_type": "m.reference", + "event_id": "$related_event:notareal.hs", + } + }, + "event_id": "$event:notareal.hs", + "origin_server_ts": 134_829_848, + "room_id": "!roomid:notareal.hs", + "sender": "@user:notareal.hs", + "type": "org.matrix.msc3381.poll.response", + }); + + let event = from_json_value::(json_data).unwrap(); + let message_event = assert_matches!( + event, + AnyMessageLikeEvent::PollResponse(MessageLikeEvent::Original(message_event)) + => message_event + ); + let answers = message_event.content.poll_response.answers; + assert_eq!(answers.len(), 1); + assert_eq!(answers[0], "my-answer"); + assert_matches!( + message_event.content.relates_to, + ReferenceRelation { event_id, .. } if event_id == "$related_event:notareal.hs" + ); +} + +#[test] +fn response_event_stable_deserialization() { + let json_data = json!({ + "content": { + "m.poll.response": { + "answers": ["first-answer", "second-answer"], + }, + "m.relates_to": { + "rel_type": "m.reference", + "event_id": "$related_event:notareal.hs", + } + }, + "event_id": "$event:notareal.hs", + "origin_server_ts": 134_829_848, + "room_id": "!roomid:notareal.hs", + "sender": "@user:notareal.hs", + "type": "m.poll.response", + }); + + let event = from_json_value::(json_data).unwrap(); + let message_event = assert_matches!( + event, + AnyMessageLikeEvent::PollResponse(MessageLikeEvent::Original(message_event)) + => message_event + ); + let answers = message_event.content.poll_response.answers; + assert_eq!(answers.len(), 2); + assert_eq!(answers[0], "first-answer"); + assert_eq!(answers[1], "second-answer"); + assert_matches!( + message_event.content.relates_to, + ReferenceRelation { event_id, .. } if event_id == "$related_event:notareal.hs" + ); +} + +#[test] +fn end_content_serialization() { + let event_content = PollEndEventContent::new( + PollEndContent::new(), + event_id!("$related_event:notareal.hs").to_owned(), + ); + + assert_eq!( + to_json_value(&event_content).unwrap(), + json!({ + "org.matrix.msc3381.poll.end": {}, + "m.relates_to": { + "rel_type": "m.reference", + "event_id": "$related_event:notareal.hs", + } + }) + ); +} + +#[test] +fn end_event_serialization() { + let event = OriginalMessageLikeEvent { + content: PollEndEventContent::new( + PollEndContent::new(), + event_id!("$related_event:notareal.hs").to_owned(), + ), + event_id: event_id!("$event:notareal.hs").to_owned(), + sender: user_id!("@user:notareal.hs").to_owned(), + origin_server_ts: MilliSecondsSinceUnixEpoch(uint!(134_829_848)), + room_id: room_id!("!roomid:notareal.hs").to_owned(), + unsigned: MessageLikeUnsigned::default(), + }; + + assert_eq!( + to_json_value(&event).unwrap(), + json!({ + "content": { + "org.matrix.msc3381.poll.end": {}, + "m.relates_to": { + "rel_type": "m.reference", + "event_id": "$related_event:notareal.hs", + } + }, + "event_id": "$event:notareal.hs", + "origin_server_ts": 134_829_848, + "room_id": "!roomid:notareal.hs", + "sender": "@user:notareal.hs", + "type": "org.matrix.msc3381.poll.end", + }) + ); +} + +#[test] +fn end_event_unstable_deserialization() { + let json_data = json!({ + "content": { + "org.matrix.msc3381.poll.end": {}, + "m.relates_to": { + "rel_type": "m.reference", + "event_id": "$related_event:notareal.hs", + } + }, + "event_id": "$event:notareal.hs", + "origin_server_ts": 134_829_848, + "room_id": "!roomid:notareal.hs", + "sender": "@user:notareal.hs", + "type": "org.matrix.msc3381.poll.end", + }); + + let event = from_json_value::(json_data).unwrap(); + let message_event = assert_matches!( + event, + AnyMessageLikeEvent::PollEnd(MessageLikeEvent::Original(message_event)) => message_event + ); + assert_matches!( + message_event.content.relates_to, + ReferenceRelation { event_id, .. } if event_id == "$related_event:notareal.hs" + ); +} + +#[test] +fn end_event_stable_deserialization() { + let json_data = json!({ + "content": { + "m.poll.end": {}, + "m.relates_to": { + "rel_type": "m.reference", + "event_id": "$related_event:notareal.hs", + } + }, + "event_id": "$event:notareal.hs", + "origin_server_ts": 134_829_848, + "room_id": "!roomid:notareal.hs", + "sender": "@user:notareal.hs", + "type": "m.poll.end", + }); + + let event = from_json_value::(json_data).unwrap(); + let message_event = assert_matches!( + event, + AnyMessageLikeEvent::PollEnd(MessageLikeEvent::Original(message_event)) => message_event + ); + assert_matches!( + message_event.content.relates_to, + ReferenceRelation { event_id, .. } if event_id == "$related_event:notareal.hs" + ); +} diff --git a/crates/ruma/Cargo.toml b/crates/ruma/Cargo.toml index b64c3eca..5df8ef49 100644 --- a/crates/ruma/Cargo.toml +++ b/crates/ruma/Cargo.toml @@ -135,6 +135,7 @@ unstable-msc2677 = [ unstable-msc2870 = ["ruma-signatures/unstable-msc2870"] unstable-msc3245 = ["ruma-common/unstable-msc3245"] unstable-msc3246 = ["ruma-common/unstable-msc3246"] +unstable-msc3381 = ["ruma-common/unstable-msc3381"] unstable-msc3440 = [ "ruma-client-api/unstable-msc3440", "ruma-common/unstable-msc3440", @@ -163,6 +164,7 @@ __ci = [ "unstable-msc2870", "unstable-msc3245", "unstable-msc3246", + "unstable-msc3381", "unstable-msc3440", "unstable-msc3488", "unstable-msc3551",