diff --git a/crates/ruma-common/src/events/enums.rs b/crates/ruma-common/src/events/enums.rs index 07c3f3eb..12eab3d5 100644 --- a/crates/ruma-common/src/events/enums.rs +++ b/crates/ruma-common/src/events/enums.rs @@ -70,9 +70,18 @@ event_enum! { #[cfg(feature = "unstable-msc3381")] "m.poll.start" => super::poll::start, #[cfg(feature = "unstable-msc3381")] + #[ruma_enum(ident = UnstablePollStart)] + "org.matrix.msc3381.poll.start" => super::poll::unstable_start, + #[cfg(feature = "unstable-msc3381")] "m.poll.response" => super::poll::response, #[cfg(feature = "unstable-msc3381")] + #[ruma_enum(ident = UnstablePollResponse)] + "org.matrix.msc3381.poll.response" => super::poll::unstable_response, + #[cfg(feature = "unstable-msc3381")] "m.poll.end" => super::poll::end, + #[cfg(feature = "unstable-msc3381")] + #[ruma_enum(ident = UnstablePollEnd)] + "org.matrix.msc3381.poll.end" => super::poll::unstable_end, "m.reaction" => super::reaction, "m.room.encrypted" => super::room::encrypted, "m.room.message" => super::room::message, @@ -293,7 +302,11 @@ impl AnyMessageLikeEventContent { start::KeyVerificationStartEventContent, }; #[cfg(feature = "unstable-msc3381")] - use super::poll::{end::PollEndEventContent, response::PollResponseEventContent}; + use super::poll::{ + end::PollEndEventContent, response::PollResponseEventContent, + unstable_end::UnstablePollEndEventContent, + unstable_response::UnstablePollResponseEventContent, + }; match self { #[rustfmt::skip] @@ -329,11 +342,13 @@ impl AnyMessageLikeEventContent { Self::Video(ev) => ev.relates_to.clone().map(Into::into), #[cfg(feature = "unstable-msc3381")] Self::PollResponse(PollResponseEventContent { relates_to, .. }) - | Self::PollEnd(PollEndEventContent { relates_to, .. }) => { + | Self::UnstablePollResponse(UnstablePollResponseEventContent { relates_to, .. }) + | Self::PollEnd(PollEndEventContent { relates_to, .. }) + | Self::UnstablePollEnd(UnstablePollEndEventContent { relates_to, .. }) => { Some(encrypted::Relation::Reference(relates_to.clone())) } #[cfg(feature = "unstable-msc3381")] - Self::PollStart(_) => None, + Self::PollStart(_) | Self::UnstablePollStart(_) => None, Self::CallNegotiate(_) | Self::CallReject(_) | Self::CallSelectAnswer(_) diff --git a/crates/ruma-common/src/events/poll.rs b/crates/ruma-common/src/events/poll.rs index 5da9a503..0386ecd7 100644 --- a/crates/ruma-common/src/events/poll.rs +++ b/crates/ruma-common/src/events/poll.rs @@ -11,11 +11,18 @@ use js_int::uint; use crate::{MilliSecondsSinceUnixEpoch, UserId}; -use self::{response::OriginalSyncPollResponseEvent, start::PollContentBlock}; +use self::{ + response::OriginalSyncPollResponseEvent, start::PollContentBlock, + unstable_response::OriginalSyncUnstablePollResponseEvent, + unstable_start::UnstablePollStartContentBlock, +}; pub mod end; pub mod response; pub mod start; +pub mod unstable_end; +pub mod unstable_response; +pub mod unstable_start; /// Generate the current results with the given poll and responses. /// @@ -53,9 +60,57 @@ pub fn compile_poll_results<'a>( acc }); - // Aggregate the selections by answer. - let mut results = - IndexMap::from_iter(poll.answers.iter().map(|a| (a.id.as_str(), BTreeSet::new()))); + aggregate_results(poll.answers.iter().map(|a| a.id.as_str()), users_selections) +} + +/// Generate the current results with the given unstable poll and responses. +/// +/// If the `end_timestamp` is provided, any response with an `origin_server_ts` after that timestamp +/// is ignored. If it is not provided, `MilliSecondsSinceUnixEpoch::now()` will be used instead. +/// +/// This method will handle invalid responses, or several response from the same user so all +/// responses to the poll should be provided. +/// +/// Returns a map of answer ID to a set of user IDs that voted for them. When using `.iter()` or +/// `.into_iter()` on the map, the results are sorted from the highest number of votes to the +/// lowest. +pub fn compile_unstable_poll_results<'a>( + poll: &'a UnstablePollStartContentBlock, + responses: impl IntoIterator, + end_timestamp: Option, +) -> IndexMap<&'a str, BTreeSet<&'a UserId>> { + let end_ts = end_timestamp.unwrap_or_else(MilliSecondsSinceUnixEpoch::now); + + let users_selections = responses + .into_iter() + .filter(|ev| { + // Filter out responses after the end_timestamp. + ev.origin_server_ts <= end_ts + }) + .fold(BTreeMap::new(), |mut acc, ev| { + let response = + acc.entry(&*ev.sender).or_insert((MilliSecondsSinceUnixEpoch(uint!(0)), None)); + + // Only keep the latest selections for each user. + if response.0 < ev.origin_server_ts { + *response = (ev.origin_server_ts, ev.content.poll_response.validate(poll)); + } + + acc + }); + + aggregate_results(poll.answers.iter().map(|a| a.id.as_str()), users_selections) +} + +// Aggregate the given selections by answer. +fn aggregate_results<'a>( + answers: impl Iterator, + users_selections: BTreeMap< + &'a UserId, + (MilliSecondsSinceUnixEpoch, Option>), + >, +) -> IndexMap<&'a str, BTreeSet<&'a UserId>> { + let mut results = IndexMap::from_iter(answers.into_iter().map(|a| (a, BTreeSet::new()))); for (user, (_, selections)) in users_selections { if let Some(selections) = selections { @@ -72,3 +127,51 @@ pub fn compile_poll_results<'a>( results } + +/// Generate the fallback text representation of a poll end event. +/// +/// This is a sentence that lists the top answers for the given results, in english. It is used to +/// generate a valid poll end event when using +/// `OriginalSync(Unstable)PollStartEvent::compile_results()`. +/// +/// `answers` is an iterator of `(answer ID, answer plain text representation)` and `results` is an +/// iterator of `(answer ID, count)` ordered in descending order. +fn generate_poll_end_fallback_text<'a>( + answers: &[(&'a str, &'a str)], + results: impl Iterator, +) -> String { + let mut top_answers = Vec::new(); + let mut top_count = 0; + + for (id, count) in results { + if count >= top_count { + top_answers.push(id); + top_count = count; + } else { + break; + } + } + + let top_answers_text = top_answers + .into_iter() + .map(|id| { + answers + .iter() + .find(|(a_id, _)| *a_id == id) + .expect("top answer ID should be a valid answer ID") + .1 + }) + .collect::>(); + + // Construct the plain text representation. + match top_answers_text.len() { + l if l > 1 => { + let answers = top_answers_text.join(", "); + format!("The poll has closed. Top answers: {answers}") + } + l if l == 1 => { + format!("The poll has closed. Top answer: {}", top_answers_text[0]) + } + _ => "The poll has closed with no top answer".to_owned(), + } +} diff --git a/crates/ruma-common/src/events/poll/end.rs b/crates/ruma-common/src/events/poll/end.rs index 84da6600..4465d2ed 100644 --- a/crates/ruma-common/src/events/poll/end.rs +++ b/crates/ruma-common/src/events/poll/end.rs @@ -19,7 +19,14 @@ use crate::{ /// This type can be generated from the poll start and poll response events with /// [`OriginalSyncPollStartEvent::compile_results()`]. /// +/// This is the event content that should be sent for room versions that support extensible events. +/// As of Matrix 1.7, none of the stable room versions (1 through 10) support extensible events. +/// +/// To send a poll end event for a room version that does not support extensible events, use +/// [`UnstablePollEndEventContent`]. +/// /// [`OriginalSyncPollStartEvent::compile_results()`]: super::start::OriginalSyncPollStartEvent::compile_results +/// [`UnstablePollEndEventContent`]: super::unstable_end::UnstablePollEndEventContent #[derive(Clone, Debug, Serialize, Deserialize, EventContent)] #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] #[ruma_event(type = "m.poll.end", kind = MessageLike)] diff --git a/crates/ruma-common/src/events/poll/response.rs b/crates/ruma-common/src/events/poll/response.rs index 61766479..7f775ab5 100644 --- a/crates/ruma-common/src/events/poll/response.rs +++ b/crates/ruma-common/src/events/poll/response.rs @@ -10,6 +10,14 @@ use crate::{events::relation::Reference, OwnedEventId}; use super::start::PollContentBlock; /// The payload for a poll response event. +/// +/// This is the event content that should be sent for room versions that support extensible events. +/// As of Matrix 1.7, none of the stable room versions (1 through 10) support extensible events. +/// +/// To send a poll response event for a room version that does not support extensible events, use +/// [`UnstablePollResponseEventContent`]. +/// +/// [`UnstablePollResponseEventContent`]: super::unstable_response::UnstablePollResponseEventContent #[derive(Clone, Debug, Serialize, Deserialize, EventContent)] #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] #[ruma_event(type = "m.poll.response", kind = MessageLike)] diff --git a/crates/ruma-common/src/events/poll/start.rs b/crates/ruma-common/src/events/poll/start.rs index 8732040d..d82f696f 100644 --- a/crates/ruma-common/src/events/poll/start.rs +++ b/crates/ruma-common/src/events/poll/start.rs @@ -15,10 +15,19 @@ use crate::{events::message::TextContentBlock, serde::StringEnum, PrivOwnedStr}; use super::{ compile_poll_results, end::{PollEndEventContent, PollResultsContentBlock}, + generate_poll_end_fallback_text, response::OriginalSyncPollResponseEvent, }; /// The payload for a poll start event. +/// +/// This is the event content that should be sent for room versions that support extensible events. +/// As of Matrix 1.7, none of the stable room versions (1 through 10) support extensible events. +/// +/// To send a poll start event for a room version that does not support extensible events, use +/// [`UnstablePollStartEventContent`]. +/// +/// [`UnstablePollStartEventContent`]: super::unstable_start::UnstablePollStartEventContent #[derive(Clone, Debug, Serialize, Deserialize, EventContent)] #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] #[ruma_event(type = "m.poll.start", kind = MessageLike)] @@ -75,59 +84,32 @@ impl OriginalSyncPollStartEvent { &'a self, responses: impl IntoIterator, ) -> PollEndEventContent { - let computed = compile_poll_results(&self.content.poll, responses, None); + let full_results = compile_poll_results(&self.content.poll, responses, None); + let results = + full_results.into_iter().map(|(id, users)| (id, users.len())).collect::>(); // Construct the results and get the top answer(s). - let mut top_answers = Vec::new(); - let mut top_count = uint!(0); - - let results = - PollResultsContentBlock::from_iter(computed.into_iter().map(|(id, users)| { - let count = users.len().try_into().unwrap_or(UInt::MAX); - - if count >= top_count { - top_answers.push(id); - top_count = count; - } - - (id.to_owned(), count) - })); + let poll_results = PollResultsContentBlock::from_iter( + results + .iter() + .map(|(id, count)| ((*id).to_owned(), (*count).try_into().unwrap_or(UInt::MAX))), + ); // Get the text representation of the best answers. - let top_answers_text = top_answers - .into_iter() - .map(|id| { - let text = &self - .content - .poll - .answers - .iter() - .find(|a| a.id == id) - .expect("top answer ID should be a valid answer ID") - .text; - // Use the plain text representation, fallback to the first text representation or - // to the answer ID. - text.find_plain() - .or_else(|| text.get(0).map(|t| t.body.as_ref())) - .unwrap_or(id) - .to_owned() + let answers = self + .content + .poll + .answers + .iter() + .map(|a| { + let text = a.text.find_plain().unwrap_or(&a.id); + (a.id.as_str(), text) }) .collect::>(); - - // Construct the plain text representation. - let plain_text = match top_answers_text.len() { - l if l > 1 => { - let answers = top_answers_text.join(", "); - format!("The poll has closed. Top answers: {answers}") - } - l if l == 1 => { - format!("The poll has closed. Top answer: {}", top_answers_text[0]) - } - _ => "The poll has closed with no top answer".to_owned(), - }; + let plain_text = generate_poll_end_fallback_text(&answers, results.into_iter()); let mut end = PollEndEventContent::with_plain_text(plain_text, self.event_id.clone()); - end.poll_results = Some(results); + end.poll_results = Some(poll_results); end } @@ -170,7 +152,7 @@ impl PollContentBlock { } } - fn default_max_selections() -> UInt { + pub(super) fn default_max_selections() -> UInt { uint!(1) } diff --git a/crates/ruma-common/src/events/poll/unstable_end.rs b/crates/ruma-common/src/events/poll/unstable_end.rs new file mode 100644 index 00000000..b83e6b49 --- /dev/null +++ b/crates/ruma-common/src/events/poll/unstable_end.rs @@ -0,0 +1,56 @@ +//! Types for the `org.matrix.msc3381.poll.end` event, the unstable version of `m.poll.end`. + +use ruma_macros::EventContent; +use serde::{Deserialize, Serialize}; + +use crate::{events::relation::Reference, OwnedEventId}; + +/// The payload for an unstable poll end event. +/// +/// This type can be generated from the unstable poll start and poll response events with +/// [`OriginalSyncUnstablePollStartEvent::compile_results()`]. +/// +/// This is the event content that should be sent for room versions that don't support extensible +/// events. As of Matrix 1.7, none of the stable room versions (1 through 10) support extensible +/// events. +/// +/// To send a poll end event for a room version that supports extensible events, use +/// [`PollEndEventContent`]. +/// +/// [`OriginalSyncUnstablePollStartEvent::compile_results()`]: super::unstable_start::OriginalSyncUnstablePollStartEvent::compile_results +/// [`PollEndEventContent`]: super::end::PollEndEventContent +#[derive(Clone, Debug, Serialize, Deserialize, EventContent)] +#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] +#[ruma_event(type = "org.matrix.msc3381.poll.end", kind = MessageLike)] +pub struct UnstablePollEndEventContent { + /// The text representation of the results. + #[serde(rename = "org.matrix.msc1767.text")] + pub text: String, + + /// The poll end content. + #[serde(default, rename = "org.matrix.msc3381.poll.end")] + pub poll_end: UnstablePollEndContentBlock, + + /// Information about the poll start event this responds to. + #[serde(rename = "m.relates_to")] + pub relates_to: Reference, +} + +impl UnstablePollEndEventContent { + /// Creates a new `PollEndEventContent` with the given fallback representation and + /// that responds to the given poll start event ID. + pub fn new(text: impl Into, poll_start_id: OwnedEventId) -> Self { + Self { + text: text.into(), + poll_end: UnstablePollEndContentBlock {}, + relates_to: Reference::new(poll_start_id), + } + } +} + +/// A block for the results of a poll. +/// +/// This is currently an empty struct. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] +pub struct UnstablePollEndContentBlock {} diff --git a/crates/ruma-common/src/events/poll/unstable_response.rs b/crates/ruma-common/src/events/poll/unstable_response.rs new file mode 100644 index 00000000..ce367ea6 --- /dev/null +++ b/crates/ruma-common/src/events/poll/unstable_response.rs @@ -0,0 +1,86 @@ +//! Types for the `org.matrix.msc3381.poll.response` event, the unstable version of +//! `m.poll.response`. + +use std::ops::Deref; + +use ruma_macros::EventContent; +use serde::{Deserialize, Serialize}; + +use crate::{events::relation::Reference, OwnedEventId}; + +use super::unstable_start::UnstablePollStartContentBlock; + +/// The payload for an unstable poll response event. +/// +/// This is the event content that should be sent for room versions that don't support extensible +/// events. As of Matrix 1.7, none of the stable room versions (1 through 10) support extensible +/// events. +/// +/// To send a poll response event for a room version that supports extensible events, use +/// [`PollResponseEventContent`]. +/// +/// [`PollResponseEventContent`]: super::response::PollResponseEventContent +#[derive(Clone, Debug, Serialize, Deserialize, EventContent)] +#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] +#[ruma_event(type = "org.matrix.msc3381.poll.response", kind = MessageLike)] +pub struct UnstablePollResponseEventContent { + /// The response's content. + #[serde(rename = "org.matrix.msc3381.poll.response")] + pub poll_response: UnstablePollResponseContentBlock, + + /// Information about the poll start event this responds to. + #[serde(rename = "m.relates_to")] + pub relates_to: Reference, +} + +impl UnstablePollResponseEventContent { + /// Creates a new `UnstablePollResponseEventContent` that responds to the given poll start event + /// ID, with the given answers. + pub fn new(answers: Vec, poll_start_id: OwnedEventId) -> Self { + Self { + poll_response: UnstablePollResponseContentBlock::new(answers), + relates_to: Reference::new(poll_start_id), + } + } +} + +/// An unstable block for poll response content. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] +pub struct UnstablePollResponseContentBlock { + /// The selected answers for the response. + pub answers: Vec, +} + +impl UnstablePollResponseContentBlock { + /// Creates a new `UnstablePollResponseContentBlock` with the given answers. + pub fn new(answers: Vec) -> Self { + Self { answers } + } + + /// Validate these selections against the given `UnstablePollStartContentBlock`. + /// + /// Returns the list of valid selections in this `UnstablePollResponseContentBlock`, or `None` + /// if there is no valid selection. + pub fn validate( + &self, + poll: &UnstablePollStartContentBlock, + ) -> Option> { + // Vote is spoiled if any answer is unknown. + if self.answers.iter().any(|s| !poll.answers.iter().any(|a| a.id == *s)) { + return None; + } + + // Fallback to the maximum value for usize because we can't have more selections than that + // in memory. + let max_selections: usize = poll.max_selections.try_into().unwrap_or(usize::MAX); + + Some(self.answers.iter().take(max_selections).map(Deref::deref)) + } +} + +impl From> for UnstablePollResponseContentBlock { + fn from(value: Vec) -> Self { + Self::new(value) + } +} diff --git a/crates/ruma-common/src/events/poll/unstable_start.rs b/crates/ruma-common/src/events/poll/unstable_start.rs new file mode 100644 index 00000000..50e17507 --- /dev/null +++ b/crates/ruma-common/src/events/poll/unstable_start.rs @@ -0,0 +1,194 @@ +//! Types for the `org.matrix.msc3381.poll.start` event, the unstable version of `m.poll.start`. + +use std::ops::Deref; + +use js_int::UInt; +use ruma_macros::EventContent; +use serde::{Deserialize, Serialize}; + +mod unstable_poll_answers_serde; +mod unstable_poll_kind_serde; + +use self::unstable_poll_answers_serde::UnstablePollAnswersDeHelper; +use super::{ + compile_unstable_poll_results, generate_poll_end_fallback_text, + start::{PollAnswers, PollAnswersError, PollContentBlock, PollKind}, + unstable_end::UnstablePollEndEventContent, + unstable_response::OriginalSyncUnstablePollResponseEvent, +}; + +/// The payload for an unstable poll start event. +/// +/// This is the event content that should be sent for room versions that don't support extensible +/// events. As of Matrix 1.7, none of the stable room versions (1 through 10) support extensible +/// events. +/// +/// To send a poll start event for a room version that supports extensible events, use +/// [`PollStartEventContent`]. +/// +/// [`PollStartEventContent`]: super::start::PollStartEventContent +#[derive(Clone, Debug, Serialize, Deserialize, EventContent)] +#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] +#[ruma_event(type = "org.matrix.msc3381.poll.start", kind = MessageLike)] +pub struct UnstablePollStartEventContent { + /// The poll content of the message. + #[serde(rename = "org.matrix.msc3381.poll.start")] + pub poll_start: UnstablePollStartContentBlock, + + /// Text representation of the message, for clients that don't support polls. + #[serde(rename = "org.matrix.msc1767.text")] + pub text: Option, +} + +impl UnstablePollStartEventContent { + /// Creates a new `PollStartEventContent` with the given poll content. + pub fn new(poll_start: UnstablePollStartContentBlock) -> Self { + Self { poll_start, text: None } + } + + /// Creates a new `PollStartEventContent` with the given plain text fallback + /// representation and poll content. + pub fn plain_text(text: impl Into, poll_start: UnstablePollStartContentBlock) -> Self { + Self { poll_start, text: Some(text.into()) } + } +} + +impl OriginalSyncUnstablePollStartEvent { + /// Compile the results for this poll with the given response into an + /// `UnstablePollEndEventContent`. + /// + /// It generates a default text representation of the results in English. + /// + /// This uses [`compile_unstable_poll_results()`] internally. + pub fn compile_results<'a>( + &'a self, + responses: impl IntoIterator, + ) -> UnstablePollEndEventContent { + let full_results = compile_unstable_poll_results(&self.content.poll_start, responses, None); + let results = + full_results.into_iter().map(|(id, users)| (id, users.len())).collect::>(); + + // Get the text representation of the best answers. + let answers = self + .content + .poll_start + .answers + .iter() + .map(|a| (a.id.as_str(), a.text.as_str())) + .collect::>(); + let plain_text = generate_poll_end_fallback_text(&answers, results.into_iter()); + + UnstablePollEndEventContent::new(plain_text, self.event_id.clone()) + } +} + +/// An unstable block for poll start content. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] +pub struct UnstablePollStartContentBlock { + /// The question of the poll. + pub question: UnstablePollQuestion, + + /// The kind of the poll. + #[serde(default, with = "unstable_poll_kind_serde")] + 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 = "PollContentBlock::default_max_selections")] + pub max_selections: UInt, + + /// The possible answers to the poll. + pub answers: UnstablePollAnswers, +} + +impl UnstablePollStartContentBlock { + /// Creates a new `PollStartContent` with the given question and answers. + pub fn new(question: impl Into, answers: UnstablePollAnswers) -> Self { + Self { + question: UnstablePollQuestion::new(question), + kind: Default::default(), + max_selections: PollContentBlock::default_max_selections(), + answers, + } + } +} + +/// An unstable poll question. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] +pub struct UnstablePollQuestion { + /// The text representation of the question. + #[serde(rename = "org.matrix.msc1767.text")] + pub text: String, +} + +impl UnstablePollQuestion { + /// Creates a new `UnstablePollQuestion` with the given plain text. + pub fn new(text: impl Into) -> Self { + Self { text: text.into() } + } +} + +/// The unstable answers to a poll. +/// +/// Must include between 1 and 20 `UnstablePollAnswer`s. +/// +/// To build this, use one of the `TryFrom` implementations. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(try_from = "UnstablePollAnswersDeHelper")] +pub struct UnstablePollAnswers(Vec); + +impl TryFrom> for UnstablePollAnswers { + type Error = PollAnswersError; + + fn try_from(value: Vec) -> Result { + if value.len() < PollAnswers::MIN_LENGTH { + Err(PollAnswersError::NotEnoughValues) + } else if value.len() > PollAnswers::MAX_LENGTH { + Err(PollAnswersError::TooManyValues) + } else { + Ok(Self(value)) + } + } +} + +impl TryFrom<&[UnstablePollAnswer]> for UnstablePollAnswers { + type Error = PollAnswersError; + + fn try_from(value: &[UnstablePollAnswer]) -> Result { + Self::try_from(value.to_owned()) + } +} + +impl Deref for UnstablePollAnswers { + type Target = [UnstablePollAnswer]; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +/// Unstable poll answer. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] +pub struct UnstablePollAnswer { + /// 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(rename = "org.matrix.msc1767.text")] + pub text: String, +} + +impl UnstablePollAnswer { + /// Creates a new `PollAnswer` with the given id and text representation. + pub fn new(id: impl Into, text: impl Into) -> Self { + Self { id: id.into(), text: text.into() } + } +} diff --git a/crates/ruma-common/src/events/poll/unstable_start/unstable_poll_answers_serde.rs b/crates/ruma-common/src/events/poll/unstable_start/unstable_poll_answers_serde.rs new file mode 100644 index 00000000..ee4d2360 --- /dev/null +++ b/crates/ruma-common/src/events/poll/unstable_start/unstable_poll_answers_serde.rs @@ -0,0 +1,20 @@ +//! `Deserialize` helpers for unstable poll answers (MSC3381). + +use serde::Deserialize; + +use crate::events::poll::start::{PollAnswers, PollAnswersError}; + +use super::{UnstablePollAnswer, UnstablePollAnswers}; + +#[derive(Debug, Default, Deserialize)] +pub(crate) struct UnstablePollAnswersDeHelper(Vec); + +impl TryFrom for UnstablePollAnswers { + type Error = PollAnswersError; + + fn try_from(helper: UnstablePollAnswersDeHelper) -> Result { + let mut answers = helper.0; + answers.truncate(PollAnswers::MAX_LENGTH); + UnstablePollAnswers::try_from(answers) + } +} diff --git a/crates/ruma-common/src/events/poll/unstable_start/unstable_poll_kind_serde.rs b/crates/ruma-common/src/events/poll/unstable_start/unstable_poll_kind_serde.rs new file mode 100644 index 00000000..57cfc3eb --- /dev/null +++ b/crates/ruma-common/src/events/poll/unstable_start/unstable_poll_kind_serde.rs @@ -0,0 +1,37 @@ +//! `Serialize` and `Deserialize` helpers for unstable poll kind (MSC3381). + +use std::borrow::Cow; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use crate::{events::poll::start::PollKind, PrivOwnedStr}; + +/// Serializes a PollKind using the unstable prefixes. +pub(super) fn serialize(kind: &PollKind, serializer: S) -> Result +where + S: Serializer, +{ + let s = match kind { + PollKind::Undisclosed => "org.matrix.msc3381.poll.undisclosed", + PollKind::Disclosed => "org.matrix.msc3381.poll.disclosed", + PollKind::_Custom(s) => &s.0, + }; + + s.serialize(serializer) +} + +/// Deserializes a PollKind using the unstable prefixes. +pub(super) fn deserialize<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let s = Cow::<'_, str>::deserialize(deserializer)?; + + let kind = match &*s { + "org.matrix.msc3381.poll.undisclosed" => PollKind::Undisclosed, + "org.matrix.msc3381.poll.disclosed" => PollKind::Disclosed, + _ => PollKind::_Custom(PrivOwnedStr(s.into())), + }; + + Ok(kind) +} diff --git a/crates/ruma-common/tests/events/poll.rs b/crates/ruma-common/tests/events/poll.rs index b6e5a5e5..6bb13d89 100644 --- a/crates/ruma-common/tests/events/poll.rs +++ b/crates/ruma-common/tests/events/poll.rs @@ -8,13 +8,21 @@ use ruma_common::{ events::{ message::TextContentBlock, poll::{ - compile_poll_results, + compile_poll_results, compile_unstable_poll_results, end::PollEndEventContent, response::{OriginalSyncPollResponseEvent, PollResponseEventContent}, start::{ OriginalSyncPollStartEvent, PollAnswer, PollAnswers, PollAnswersError, PollContentBlock, PollKind, PollStartEventContent, }, + unstable_end::UnstablePollEndEventContent, + unstable_response::{ + OriginalSyncUnstablePollResponseEvent, UnstablePollResponseEventContent, + }, + unstable_start::{ + OriginalSyncUnstablePollStartEvent, UnstablePollAnswer, + UnstablePollStartContentBlock, UnstablePollStartEventContent, + }, }, relation::Reference, AnyMessageLikeEvent, MessageLikeEvent, @@ -348,6 +356,194 @@ fn end_event_deserialization() { assert_eq!(event_id, "$related_event:notareal.hs"); } +#[test] +fn unstable_start_content_serialization() { + let event_content = UnstablePollStartEventContent::plain_text( + "How's the weather?\n1. Not bad…\n2. Fine.\n3. Amazing!", + UnstablePollStartContentBlock::new( + "How's the weather?", + vec![ + UnstablePollAnswer::new("not-bad", "Not bad…"), + UnstablePollAnswer::new("fine", "Fine."), + UnstablePollAnswer::new("amazing", "Amazing!"), + ] + .try_into() + .unwrap(), + ), + ); + + assert_eq!( + to_json_value(&event_content).unwrap(), + json!({ + "org.matrix.msc1767.text": "How's the weather?\n1. Not bad…\n2. Fine.\n3. Amazing!", + "org.matrix.msc3381.poll.start": { + "kind": "org.matrix.msc3381.poll.undisclosed", + "max_selections": 1, + "question": { "org.matrix.msc1767.text": "How's the weather?" }, + "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 unstable_start_event_deserialization() { + let json_data = json!({ + "content": { + "org.matrix.msc1767.text": "How's the weather?\n1. Not bad…\n2. Fine.\n3. Amazing!", + "org.matrix.msc3381.poll.start": { + "question": { "org.matrix.msc1767.text": "How's the weather?" }, + "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(); + assert_matches!( + event, + AnyMessageLikeEvent::UnstablePollStart(MessageLikeEvent::Original(message_event)) + ); + assert_eq!( + message_event.content.text.unwrap(), + "How's the weather?\n1. Not bad…\n2. Fine.\n3. Amazing!" + ); + let poll = message_event.content.poll_start; + assert_eq!(poll.question.text, "How's the weather?"); + assert_eq!(poll.kind, PollKind::Undisclosed); + assert_eq!(poll.max_selections, uint!(2)); + let answers = poll.answers; + assert_eq!(answers.len(), 3); + assert_eq!(answers[0].id, "not-bad"); + assert_eq!(answers[0].text, "Not bad…"); + assert_eq!(answers[1].id, "fine"); + assert_eq!(answers[1].text, "Fine."); + assert_eq!(answers[2].id, "amazing"); + assert_eq!(answers[2].text, "Amazing!"); +} + +#[test] +fn unstable_response_content_serialization() { + let event_content = UnstablePollResponseEventContent::new( + vec!["my-answer".to_owned()], + owned_event_id!("$related_event:notareal.hs"), + ); + + 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 unstable_response_event_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(); + assert_matches!( + event, + AnyMessageLikeEvent::UnstablePollResponse(MessageLikeEvent::Original(message_event)) + ); + let selections = message_event.content.poll_response.answers; + assert_eq!(selections.len(), 1); + assert_eq!(selections[0], "my-answer"); + assert_matches!(message_event.content.relates_to, Reference { event_id, .. }); + assert_eq!(event_id, "$related_event:notareal.hs"); +} + +#[test] +fn unstable_end_content_serialization() { + let event_content = UnstablePollEndEventContent::new( + "The poll has closed. Top answer: Amazing!", + owned_event_id!("$related_event:notareal.hs"), + ); + + assert_eq!( + to_json_value(&event_content).unwrap(), + json!({ + "org.matrix.msc1767.text": "The poll has closed. Top answer: Amazing!", + "org.matrix.msc3381.poll.end": {}, + "m.relates_to": { + "rel_type": "m.reference", + "event_id": "$related_event:notareal.hs", + } + }) + ); +} + +#[test] +fn unstable_end_event_deserialization() { + let json_data = json!({ + "content": { + "org.matrix.msc1767.text": "The poll has closed. Top answer: Amazing!", + "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(); + assert_matches!( + event, + AnyMessageLikeEvent::UnstablePollEnd(MessageLikeEvent::Original(message_event)) + ); + assert_eq!(message_event.content.text, "The poll has closed. Top answer: Amazing!"); + assert_matches!(message_event.content.relates_to, Reference { event_id, .. }); + assert_eq!(event_id, "$related_event:notareal.hs"); +} + fn new_poll_response( event_id: &str, user_id: &str, @@ -644,3 +840,91 @@ fn compute_results() { assert_eq!(*results.get("italian").unwrap(), uint!(6)); assert_eq!(*results.get("wings").unwrap(), uint!(7)); } + +fn new_unstable_poll_response( + event_id: &str, + user_id: &str, + ts: UInt, + selections: &[&str], +) -> OriginalSyncUnstablePollResponseEvent { + from_json_value(json!({ + "type": "org.matrix.msc3381.poll.response", + "sender": user_id, + "origin_server_ts": ts, + "event_id": event_id, + "content": { + "m.relates_to": { + "rel_type": "m.reference", + "event_id": "$poll_start_event_id" + }, + "org.matrix.msc3381.poll.response": { + "answers": selections, + }, + } + })) + .unwrap() +} + +fn generate_unstable_poll_responses( + range: Range, + selections: &[&str], +) -> Vec { + let mut responses = Vec::with_capacity(range.len()); + + for i in range { + let event_id = format!("$valid_event_{i}"); + let user_id = format!("@valid_user_{i}:localhost"); + let ts = 1000 + i as u16; + + responses.push(new_unstable_poll_response(&event_id, &user_id, ts.into(), selections)); + } + + responses +} + +#[test] +fn compute_unstable_results() { + let poll: OriginalSyncUnstablePollStartEvent = from_json_value(json!({ + "type": "org.matrix.msc3381.poll.start", + "sender": "@alice:localhost", + "event_id": "$poll_start_event_id", + "origin_server_ts": 1, + "content": { + "org.matrix.msc1767.text": "What should we order for the party?\n1. Pizza 🍕\n2. Poutine 🍟\n3. Italian 🍝\n4. Wings 🔥", + "org.matrix.msc3381.poll.start": { + "kind": "org.matrix.msc3381.poll.disclosed", + "max_selections": 2, + "question": { + "org.matrix.msc1767.text": "What should we order for the party?", + }, + "answers": [ + { "id": "pizza", "org.matrix.msc1767.text": "Pizza 🍕" }, + { "id": "poutine", "org.matrix.msc1767.text": "Poutine 🍟" }, + { "id": "italian", "org.matrix.msc1767.text": "Italian 🍝" }, + { "id": "wings", "org.matrix.msc1767.text": "Wings 🔥" }, + ] + }, + } + })).unwrap(); + + // Populate responses. + let mut responses = generate_unstable_poll_responses(0..5, &["pizza"]); + responses.extend(generate_unstable_poll_responses(5..6, &["poutine"])); + responses.extend(generate_unstable_poll_responses(6..8, &["italian"])); + responses.extend(generate_unstable_poll_responses(8..11, &["wings"])); + + let counted = compile_unstable_poll_results(&poll.content.poll_start, &responses, None); + assert_eq!(counted.get("pizza").unwrap().len(), 5); + assert_eq!(counted.get("poutine").unwrap().len(), 1); + assert_eq!(counted.get("italian").unwrap().len(), 2); + assert_eq!(counted.get("wings").unwrap().len(), 3); + let mut iter = counted.keys(); + assert_eq!(iter.next(), Some(&"pizza")); + assert_eq!(iter.next(), Some(&"wings")); + assert_eq!(iter.next(), Some(&"italian")); + assert_eq!(iter.next(), Some(&"poutine")); + assert_eq!(iter.next(), None); + + let poll_end = poll.compile_results(&responses); + assert_eq!(poll_end.text, "The poll has closed. Top answer: Pizza 🍕"); +}