From 3bc7b9136ea7521dcba13b29be010dc5333d3d30 Mon Sep 17 00:00:00 2001 From: Marco Romano Date: Wed, 23 Aug 2023 14:57:52 +0200 Subject: [PATCH] ui: Add support for polls in timeline --- Cargo.lock | 16 +- Cargo.toml | 4 +- bindings/matrix-sdk-ffi/src/timeline.rs | 42 ++- .../src/timeline/event_handler.rs | 141 +++++++- .../src/timeline/event_item/content.rs | 8 +- .../matrix-sdk-ui/src/timeline/inner/state.rs | 3 + crates/matrix-sdk-ui/src/timeline/mod.rs | 5 + crates/matrix-sdk-ui/src/timeline/polls.rs | 187 +++++++++++ .../matrix-sdk-ui/src/timeline/tests/mod.rs | 1 + .../matrix-sdk-ui/src/timeline/tests/polls.rs | 308 ++++++++++++++++++ 10 files changed, 690 insertions(+), 25 deletions(-) create mode 100644 crates/matrix-sdk-ui/src/timeline/polls.rs create mode 100644 crates/matrix-sdk-ui/src/timeline/tests/polls.rs diff --git a/Cargo.lock b/Cargo.lock index db4b4c29e..0d806fd53 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4870,7 +4870,7 @@ dependencies = [ [[package]] name = "ruma" version = "0.8.2" -source = "git+https://github.com/ruma/ruma?rev=f59652b94086a5733cc741cf8e21d90bd56e05b1#f59652b94086a5733cc741cf8e21d90bd56e05b1" +source = "git+https://github.com/ruma/ruma?rev=eeef5555238c6ee38bee5711930b256d0255e627#eeef5555238c6ee38bee5711930b256d0255e627" dependencies = [ "assign", "js_int", @@ -4885,7 +4885,7 @@ dependencies = [ [[package]] name = "ruma-appservice-api" version = "0.8.1" -source = "git+https://github.com/ruma/ruma?rev=f59652b94086a5733cc741cf8e21d90bd56e05b1#f59652b94086a5733cc741cf8e21d90bd56e05b1" +source = "git+https://github.com/ruma/ruma?rev=eeef5555238c6ee38bee5711930b256d0255e627#eeef5555238c6ee38bee5711930b256d0255e627" dependencies = [ "js_int", "ruma-common", @@ -4896,7 +4896,7 @@ dependencies = [ [[package]] name = "ruma-client-api" version = "0.16.2" -source = "git+https://github.com/ruma/ruma?rev=f59652b94086a5733cc741cf8e21d90bd56e05b1#f59652b94086a5733cc741cf8e21d90bd56e05b1" +source = "git+https://github.com/ruma/ruma?rev=eeef5555238c6ee38bee5711930b256d0255e627#eeef5555238c6ee38bee5711930b256d0255e627" dependencies = [ "assign", "bytes", @@ -4913,7 +4913,7 @@ dependencies = [ [[package]] name = "ruma-common" version = "0.11.3" -source = "git+https://github.com/ruma/ruma?rev=f59652b94086a5733cc741cf8e21d90bd56e05b1#f59652b94086a5733cc741cf8e21d90bd56e05b1" +source = "git+https://github.com/ruma/ruma?rev=eeef5555238c6ee38bee5711930b256d0255e627#eeef5555238c6ee38bee5711930b256d0255e627" dependencies = [ "base64 0.21.2", "bytes", @@ -4946,7 +4946,7 @@ dependencies = [ [[package]] name = "ruma-federation-api" version = "0.7.1" -source = "git+https://github.com/ruma/ruma?rev=f59652b94086a5733cc741cf8e21d90bd56e05b1#f59652b94086a5733cc741cf8e21d90bd56e05b1" +source = "git+https://github.com/ruma/ruma?rev=eeef5555238c6ee38bee5711930b256d0255e627#eeef5555238c6ee38bee5711930b256d0255e627" dependencies = [ "js_int", "ruma-common", @@ -4957,7 +4957,7 @@ dependencies = [ [[package]] name = "ruma-identifiers-validation" version = "0.9.1" -source = "git+https://github.com/ruma/ruma?rev=f59652b94086a5733cc741cf8e21d90bd56e05b1#f59652b94086a5733cc741cf8e21d90bd56e05b1" +source = "git+https://github.com/ruma/ruma?rev=eeef5555238c6ee38bee5711930b256d0255e627#eeef5555238c6ee38bee5711930b256d0255e627" dependencies = [ "js_int", "thiserror", @@ -4966,7 +4966,7 @@ dependencies = [ [[package]] name = "ruma-macros" version = "0.11.3" -source = "git+https://github.com/ruma/ruma?rev=f59652b94086a5733cc741cf8e21d90bd56e05b1#f59652b94086a5733cc741cf8e21d90bd56e05b1" +source = "git+https://github.com/ruma/ruma?rev=eeef5555238c6ee38bee5711930b256d0255e627#eeef5555238c6ee38bee5711930b256d0255e627" dependencies = [ "once_cell", "proc-macro-crate", @@ -4981,7 +4981,7 @@ dependencies = [ [[package]] name = "ruma-push-gateway-api" version = "0.7.1" -source = "git+https://github.com/ruma/ruma?rev=f59652b94086a5733cc741cf8e21d90bd56e05b1#f59652b94086a5733cc741cf8e21d90bd56e05b1" +source = "git+https://github.com/ruma/ruma?rev=eeef5555238c6ee38bee5711930b256d0255e627#eeef5555238c6ee38bee5711930b256d0255e627" dependencies = [ "js_int", "ruma-common", diff --git a/Cargo.toml b/Cargo.toml index 261086f23..ac2f659c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,8 +34,8 @@ futures-executor = "0.3.21" futures-util = { version = "0.3.26", default-features = false, features = ["alloc"] } http = "0.2.6" itertools = "0.11.0" -ruma = { git = "https://github.com/ruma/ruma", rev = "f59652b94086a5733cc741cf8e21d90bd56e05b1", features = ["client-api-c", "compat-upload-signatures", "compat-user-id"] } -ruma-common = { git = "https://github.com/ruma/ruma", rev = "f59652b94086a5733cc741cf8e21d90bd56e05b1" } +ruma = { git = "https://github.com/ruma/ruma", rev = "eeef5555238c6ee38bee5711930b256d0255e627", features = ["client-api-c", "compat-upload-signatures", "compat-user-id"] } +ruma-common = { git = "https://github.com/ruma/ruma", rev = "eeef5555238c6ee38bee5711930b256d0255e627" } once_cell = "1.16.0" serde = "1.0.151" serde_html_form = "0.2.0" diff --git a/bindings/matrix-sdk-ffi/src/timeline.rs b/bindings/matrix-sdk-ffi/src/timeline.rs index 2b2c6b829..f3a8def4c 100644 --- a/bindings/matrix-sdk-ffi/src/timeline.rs +++ b/bindings/matrix-sdk-ffi/src/timeline.rs @@ -27,9 +27,9 @@ use matrix_sdk::{ }, }, }; -use matrix_sdk_ui::timeline::{EventItemOrigin, Profile, TimelineDetails}; +use matrix_sdk_ui::timeline::{EventItemOrigin, PollResult, Profile, TimelineDetails}; use ruma::{assign, UInt}; -use tracing::warn; +use tracing::{info, warn}; use crate::{ error::{ClientError, TimelineError}, @@ -420,6 +420,7 @@ impl TimelineItemContent { url: content.url.to_string(), } } + Content::Poll(poll_state) => TimelineItemContentKind::from(poll_state.results()), Content::UnableToDecrypt(msg) => { TimelineItemContentKind::UnableToDecrypt { msg: EncryptedMessage::new(msg) } } @@ -491,9 +492,6 @@ pub enum TimelineItemContentKind { votes: HashMap>, end_time: Option, }, - PollEnd { - start_event_id: String, - }, UnableToDecrypt { msg: EncryptedMessage, }, @@ -1273,7 +1271,7 @@ impl From<&matrix_sdk_ui::timeline::AnyOtherFullStateEventContent> for OtherStat } } -#[derive(Clone, uniffi::Enum)] +#[derive(uniffi::Enum)] pub enum PollKind { Disclosed, Undisclosed, @@ -1288,8 +1286,38 @@ impl From for RumaPollKind { } } -#[derive(Clone, uniffi::Record)] +impl From for PollKind { + fn from(value: RumaPollKind) -> Self { + match value { + RumaPollKind::Disclosed => Self::Disclosed, + RumaPollKind::Undisclosed => Self::Undisclosed, + _ => { + info!("Unknown poll kind, defaulting to undisclosed"); + Self::Undisclosed + } + } + } +} + +#[derive(uniffi::Record)] pub struct PollAnswer { pub id: String, pub text: String, } + +impl From for TimelineItemContentKind { + fn from(value: PollResult) -> Self { + TimelineItemContentKind::Poll { + question: value.question, + kind: PollKind::from(value.kind), + max_selections: value.max_selections, + answers: value + .answers + .into_iter() + .map(|i| PollAnswer { id: i.id, text: i.text }) + .collect(), + votes: value.votes, + end_time: value.end_time, + } + } +} diff --git a/crates/matrix-sdk-ui/src/timeline/event_handler.rs b/crates/matrix-sdk-ui/src/timeline/event_handler.rs index efd52eb6d..b93696470 100644 --- a/crates/matrix-sdk-ui/src/timeline/event_handler.rs +++ b/crates/matrix-sdk-ui/src/timeline/event_handler.rs @@ -19,6 +19,13 @@ use indexmap::{map::Entry, IndexMap}; use matrix_sdk::deserialized_responses::EncryptionInfo; use ruma::{ events::{ + poll::{ + unstable_end::UnstablePollEndEventContent, + unstable_response::UnstablePollResponseEventContent, + unstable_start::{ + UnstablePollStartEventContent, UnstablePollStartEventContentWithoutRelation, + }, + }, reaction::ReactionEventContent, receipt::Receipt, relation::Replacement, @@ -54,7 +61,7 @@ use super::{ Sticker, TimelineDetails, TimelineInnerState, TimelineItem, TimelineItemContent, VirtualTimelineItem, DEFAULT_SANITIZER_MODE, }; -use crate::events::SyncTimelineEventWithoutContent; +use crate::{events::SyncTimelineEventWithoutContent, timeline::polls::PollState}; #[derive(Clone)] pub(super) enum Flow { @@ -223,13 +230,18 @@ pub(super) struct TimelineEventHandler<'a> { // "see through" it, allowing borrows of TimelineEventHandler fields other than // `timeline_items` and `items_updated` in the update closure. macro_rules! update_timeline_item { - ($this:ident, $event_id:expr, $action:expr, $update:expr) => { + ($this:ident, $event_id:expr, $action:expr, $found:expr) => { + update_timeline_item!($this, $event_id, found: $found, not_found: || { + debug!("Timeline item not found, discarding {}", $action); + }); + }; + ($this:ident, $event_id:expr, found: $found:expr, not_found: $not_found:expr) => { _update_timeline_item( &mut $this.state.items, &mut $this.result.items_updated, $event_id, - $action, - $update, + $found, + $not_found, ) }; } @@ -292,6 +304,15 @@ impl<'a> TimelineEventHandler<'a> { AnyMessageLikeEventContent::Sticker(content) => { self.add(should_add, TimelineItemContent::Sticker(Sticker { content })); } + AnyMessageLikeEventContent::UnstablePollStart(UnstablePollStartEventContent { + relates_to: Some(message::Relation::Replacement(re)), + .. + }) => self.handle_poll_start_edit(re), + AnyMessageLikeEventContent::UnstablePollStart(c) => { + self.handle_poll_start(c, should_add) + } + AnyMessageLikeEventContent::UnstablePollResponse(c) => self.handle_poll_response(c), + AnyMessageLikeEventContent::UnstablePollEnd(c) => self.handle_poll_end(c), // TODO _ => { debug!( @@ -383,6 +404,10 @@ impl<'a> TimelineEventHandler<'a> { info!("Edit event applies to a sticker, discarding"); return None; } + TimelineItemContent::Poll(_) => { + info!("Edit event applies to a poll, discarding"); + return None; + } TimelineItemContent::UnableToDecrypt(_) => { info!("Edit event applies to event that couldn't be decrypted, discarding"); return None; @@ -502,6 +527,107 @@ impl<'a> TimelineEventHandler<'a> { self.state.reactions.map.insert(reaction_id, (reaction_sender_data, c.relates_to)); } + #[instrument(skip_all, fields(replacement_event_id = ?replacement.event_id))] + fn handle_poll_start_edit( + &mut self, + replacement: Replacement, + ) { + update_timeline_item!(self, &replacement.event_id, "poll edit", |event_item| { + if self.ctx.sender != event_item.sender() { + info!( + original_sender = ?event_item.sender(), edit_sender = ?self.ctx.sender, + "Edit event applies to another user's timeline item, discarding" + ); + return None; + } + + let TimelineItemContent::Poll(poll_state) = &event_item.content() else { + info!( + original_sender = ?event_item.sender(), edit_sender = ?self.ctx.sender, + "Can't edit a poll that is not of type TimelineItemContent::Poll, discarding" + ); + return None; + }; + + let new_content = match poll_state.edit(&replacement.new_content) { + Ok(edited_poll_state) => TimelineItemContent::Poll(edited_poll_state), + Err(e) => { + info!( + original_sender = ?event_item.sender(), edit_sender = ?self.ctx.sender, + "Failed to apply poll edit: {e:?}" + ); + return None; + } + }; + + let edit_json = match &self.ctx.flow { + Flow::Local { .. } => None, + Flow::Remote { raw_event, .. } => Some(raw_event.clone()), + }; + + trace!("Applying edit"); + Some(event_item.with_content(new_content, edit_json)) + }); + } + + fn handle_poll_start(&mut self, c: UnstablePollStartEventContent, should_add: bool) { + let mut poll_state = PollState::new(c); + if let Flow::Remote { event_id, .. } = self.ctx.flow.clone() { + // Applying the cache to remote events only because local echoes + // don't have an event ID that could be referenced by responses yet. + self.state.poll_pending_events.apply(&event_id, &mut poll_state); + } + self.add(should_add, TimelineItemContent::Poll(poll_state)); + } + + fn handle_poll_response(&mut self, c: UnstablePollResponseEventContent) { + update_timeline_item!( + self, + &c.relates_to.event_id, + found: |event_item| match event_item.content() { + TimelineItemContent::Poll(poll_state) => Some(event_item.with_content( + TimelineItemContent::Poll(poll_state.add_response( + &self.ctx.sender, + self.ctx.timestamp, + &c, + )), + None, + )), + _ => None, + }, + not_found: || { + self.state.poll_pending_events.add_response( + &c.relates_to.event_id, + &self.ctx.sender, + self.ctx.timestamp, + &c, + ); + } + ); + } + + fn handle_poll_end(&mut self, c: UnstablePollEndEventContent) { + update_timeline_item!( + self, + &c.relates_to.event_id, + found: |event_item| match event_item.content() { + TimelineItemContent::Poll(poll_state) => { + match poll_state.end(self.ctx.timestamp) { + Ok(poll_state) => Some(event_item.with_content(TimelineItemContent::Poll(poll_state), None)), + Err(_) => { + info!("Got multiple poll end events, discarding"); + None + }, + } + }, + _ => None, + }, + not_found: || { + self.state.poll_pending_events.add_end(&c.relates_to.event_id, self.ctx.timestamp); + } + ); + } + #[instrument(skip_all)] fn handle_room_encrypted(&mut self, c: RoomEncryptedEventContent) { // TODO: Handle replacements if the replaced event is also UTD @@ -511,6 +637,9 @@ impl<'a> TimelineEventHandler<'a> { // Redacted redactions are no-ops (unfortunately) #[instrument(skip_all, fields(redacts_event_id = ?redacts))] fn handle_redaction(&mut self, redacts: OwnedEventId, _content: RoomRedactionEventContent) { + // TODO: Apply local redaction of PollResponse and PollEnd events. + // https://github.com/matrix-org/matrix-rust-sdk/pull/2381#issuecomment-1689647825 + let id = EventItemIdentifier::EventId(redacts.clone()); if let Some((_, rel)) = self.state.reactions.map.remove(&id) { update_timeline_item!(self, &rel.event_id, "redaction", |event_item| { @@ -1069,8 +1198,8 @@ fn _update_timeline_item( items: &mut ObservableVector>, items_updated: &mut u16, event_id: &EventId, - action: &str, update: impl FnOnce(&EventTimelineItem) -> Option, + not_found: impl FnOnce(), ) { if let Some((idx, item)) = rfind_event_by_id(items, event_id) { trace!("Found timeline item to update"); @@ -1080,6 +1209,6 @@ fn _update_timeline_item( *items_updated += 1; } } else { - debug!("Timeline item not found, discarding {action}"); + not_found() } } diff --git a/crates/matrix-sdk-ui/src/timeline/event_item/content.rs b/crates/matrix-sdk-ui/src/timeline/event_item/content.rs index 6eacd532a..bf4340811 100644 --- a/crates/matrix-sdk-ui/src/timeline/event_item/content.rs +++ b/crates/matrix-sdk-ui/src/timeline/event_item/content.rs @@ -63,8 +63,8 @@ use tracing::{error, warn}; use super::{EventItemIdentifier, EventTimelineItem, Profile, TimelineDetails}; use crate::timeline::{ - traits::RoomDataProvider, Error as TimelineError, ReactionSenderData, TimelineItem, - DEFAULT_SANITIZER_MODE, + polls::PollState, traits::RoomDataProvider, Error as TimelineError, ReactionSenderData, + TimelineItem, DEFAULT_SANITIZER_MODE, }; /// The content of an [`EventTimelineItem`][super::EventTimelineItem]. @@ -111,6 +111,9 @@ pub enum TimelineItemContent { /// The deserialization error. error: Arc, }, + + /// An `m.poll.start` event. + Poll(PollState), } impl TimelineItemContent { @@ -283,6 +286,7 @@ impl TimelineItemContent { Self::Message(_) | Self::RedactedMessage | Self::Sticker(_) + | Self::Poll(_) | Self::UnableToDecrypt(_) => Self::RedactedMessage, Self::MembershipChange(ev) => Self::MembershipChange(ev.redact(room_version)), Self::ProfileChange(ev) => Self::ProfileChange(ev.redact()), diff --git a/crates/matrix-sdk-ui/src/timeline/inner/state.rs b/crates/matrix-sdk-ui/src/timeline/inner/state.rs index 7bbe62969..41a930ee8 100644 --- a/crates/matrix-sdk-ui/src/timeline/inner/state.rs +++ b/crates/matrix-sdk-ui/src/timeline/inner/state.rs @@ -47,6 +47,7 @@ use crate::{ }, event_item::EventItemIdentifier, item::timeline_item, + polls::PollPendingEvents, reactions::{ReactionToggleResult, Reactions}, traits::RoomDataProvider, util::{rfind_event_item, timestamp_to_date}, @@ -97,6 +98,7 @@ pub(in crate::timeline) struct TimelineInnerState { pub items: ObservableVector>, next_internal_id: u64, pub reactions: Reactions, + pub poll_pending_events: PollPendingEvents, pub fully_read_event: Option, /// Whether the fully-read marker item should try to be updated when an /// event is added. @@ -123,6 +125,7 @@ impl TimelineInnerState { items: ObservableVector::with_capacity(32), next_internal_id: Default::default(), reactions: Default::default(), + poll_pending_events: Default::default(), fully_read_event: Default::default(), event_should_update_fully_read_marker: Default::default(), users_read_receipts: Default::default(), diff --git a/crates/matrix-sdk-ui/src/timeline/mod.rs b/crates/matrix-sdk-ui/src/timeline/mod.rs index ef55316d2..ad899f3fd 100644 --- a/crates/matrix-sdk-ui/src/timeline/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/mod.rs @@ -56,6 +56,7 @@ mod futures; mod inner; mod item; mod pagination; +mod polls; mod queue; mod reactions; mod read_receipts; @@ -79,6 +80,7 @@ pub use self::{ futures::SendAttachment, item::{TimelineItem, TimelineItemKind}, pagination::{PaginationOptions, PaginationOutcome}, + polls::PollResult, reactions::ReactionSenderData, sliding_sync_ext::SlidingSyncRoomExt, traits::RoomExt, @@ -493,6 +495,9 @@ impl Timeline { | TimelineItemContent::FailedToParseState { .. } => { error_return!("Invalid state: attempting to retry a failed-to-parse item"); } + TimelineItemContent::Poll(poll_state) => { + AnyMessageLikeEventContent::UnstablePollStart(poll_state.into()) + } }; let txn_id = txn_id.to_owned(); diff --git a/crates/matrix-sdk-ui/src/timeline/polls.rs b/crates/matrix-sdk-ui/src/timeline/polls.rs new file mode 100644 index 000000000..23ad1ac4b --- /dev/null +++ b/crates/matrix-sdk-ui/src/timeline/polls.rs @@ -0,0 +1,187 @@ +//! This module handles rendering of MSC3381 polls in the timeline. + +use std::collections::HashMap; + +use ruma::{ + events::poll::{ + compile_unstable_poll_results, + start::PollKind, + unstable_response::UnstablePollResponseEventContent, + unstable_start::{ + UnstablePollStartContentBlock, UnstablePollStartEventContent, + UnstablePollStartEventContentWithoutRelation, + }, + PollResponseData, + }, + EventId, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedUserId, UserId, +}; + +/// Holds the state of a poll. +/// +/// This struct should be created for each poll start event handled and then +/// updated whenever handling any poll response or poll end event that relates +/// to the same poll start event. +#[derive(Clone, Debug)] +pub struct PollState { + pub(super) start_event_content: UnstablePollStartEventContent, + pub(super) response_data: Vec, + pub(super) end_event_timestamp: Option, +} + +#[derive(Clone, Debug)] +pub(super) struct ResponseData { + pub(super) sender: OwnedUserId, + pub(super) timestamp: MilliSecondsSinceUnixEpoch, + pub(super) answers: Vec, +} + +impl PollState { + pub(super) fn new(content: UnstablePollStartEventContent) -> Self { + Self { start_event_content: content, response_data: vec![], end_event_timestamp: None } + } + + pub(super) fn edit( + &self, + replacement: &UnstablePollStartEventContentWithoutRelation, + ) -> Result { + if self.response_data.is_empty() && self.end_event_timestamp.is_none() { + let mut clone = self.clone(); + clone.start_event_content.poll_start = replacement.poll_start.clone(); + clone.start_event_content.text = replacement.text.clone(); + Ok(clone) + } else { + Err(()) + } + } + + pub(super) fn add_response( + &self, + sender: &UserId, + timestamp: MilliSecondsSinceUnixEpoch, + content: &UnstablePollResponseEventContent, + ) -> Self { + let mut clone = self.clone(); + clone.response_data.push(ResponseData { + sender: sender.to_owned(), + timestamp, + answers: content.poll_response.answers.clone(), + }); + clone + } + + /// Marks the poll as ended. + /// + /// If the poll has already ended, returns `Err(())`. + pub(super) fn end(&self, timestamp: MilliSecondsSinceUnixEpoch) -> Result { + if self.end_event_timestamp.is_none() { + let mut clone = self.clone(); + clone.end_event_timestamp = Some(timestamp); + Ok(clone) + } else { + Err(()) + } + } + + pub fn fallback_text(&self) -> Option { + self.start_event_content.text.clone() + } + + pub fn results(&self) -> PollResult { + let results = compile_unstable_poll_results( + &self.start_event_content.poll_start, + self.response_data.iter().map(|response_data| PollResponseData { + sender: &response_data.sender, + origin_server_ts: response_data.timestamp, + selections: &response_data.answers, + }), + self.end_event_timestamp, + ); + + PollResult { + question: self.start_event_content.poll_start.question.text.clone(), + kind: self.start_event_content.poll_start.kind.clone(), + max_selections: self.start_event_content.poll_start.max_selections.into(), + answers: self + .start_event_content + .poll_start + .answers + .iter() + .map(|i| PollResultAnswer { id: i.id.clone(), text: i.text.clone() }) + .collect(), + votes: results + .iter() + .map(|i| ((*i.0).to_owned(), i.1.iter().map(|i| i.to_string()).collect())) + .collect(), + end_time: self.end_event_timestamp.map(|millis| millis.0.into()), + } + } +} + +impl From for UnstablePollStartEventContent { + fn from(value: PollState) -> Self { + let content = UnstablePollStartContentBlock::new( + value.start_event_content.poll_start.question.text.clone(), + value.start_event_content.poll_start.answers.clone(), + ); + if let Some(text) = value.fallback_text() { + UnstablePollStartEventContent::plain_text(text, content) + } else { + UnstablePollStartEventContent::new(content) + } + } +} + +/// Acts as a cache for poll response and poll end events handled before their +/// start event has been handled. +#[derive(Debug, Default)] +pub(super) struct PollPendingEvents { + pub(super) pending_poll_responses: HashMap>, + pub(super) pending_poll_ends: HashMap, +} + +impl PollPendingEvents { + pub(super) fn add_response( + &mut self, + start_id: &EventId, + sender: &UserId, + timestamp: MilliSecondsSinceUnixEpoch, + content: &UnstablePollResponseEventContent, + ) { + self.pending_poll_responses.entry(start_id.to_owned()).or_default().push(ResponseData { + sender: sender.to_owned(), + timestamp, + answers: content.poll_response.answers.clone(), + }); + } + + pub(super) fn add_end(&mut self, start_id: &EventId, timestamp: MilliSecondsSinceUnixEpoch) { + self.pending_poll_ends.insert(start_id.to_owned(), timestamp); + } + + /// Dumps all response and end events present in the cache that belong to + /// the given start_event_id into the given poll_state. + pub(super) fn apply(&mut self, start_event_id: &EventId, poll_state: &mut PollState) { + if let Some(pending_responses) = self.pending_poll_responses.get_mut(start_event_id) { + poll_state.response_data.append(pending_responses); + } + if let Some(pending_end) = self.pending_poll_ends.get(start_event_id) { + poll_state.end_event_timestamp = Some(*pending_end) + } + } +} + +#[derive(Debug)] +pub struct PollResult { + pub question: String, + pub kind: PollKind, + pub max_selections: u64, + pub answers: Vec, + pub votes: HashMap>, + pub end_time: Option, +} + +#[derive(Debug)] +pub struct PollResultAnswer { + pub id: String, + pub text: String, +} diff --git a/crates/matrix-sdk-ui/src/timeline/tests/mod.rs b/crates/matrix-sdk-ui/src/timeline/tests/mod.rs index ccb0f78de..93efdaee1 100644 --- a/crates/matrix-sdk-ui/src/timeline/tests/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/tests/mod.rs @@ -64,6 +64,7 @@ mod edit; mod encryption; mod event_filter; mod invalid; +mod polls; mod reaction_group; mod reactions; mod read_receipts; diff --git a/crates/matrix-sdk-ui/src/timeline/tests/polls.rs b/crates/matrix-sdk-ui/src/timeline/tests/polls.rs new file mode 100644 index 000000000..ea9586141 --- /dev/null +++ b/crates/matrix-sdk-ui/src/timeline/tests/polls.rs @@ -0,0 +1,308 @@ +use matrix_sdk_test::async_test; +use ruma::{ + events::{ + poll::{ + unstable_end::UnstablePollEndEventContent, + unstable_response::UnstablePollResponseEventContent, + unstable_start::{UnstablePollStartContentBlock, UnstablePollStartEventContent}, + }, + relation::Replacement, + room::message::Relation, + AnyMessageLikeEventContent, + }, + serde::Raw, + server_name, EventId, OwnedEventId, UserId, +}; + +use crate::timeline::{ + polls::PollState, + tests::{TestTimeline, ALICE, BOB}, + EventTimelineItem, TimelineItemContent, +}; + +#[async_test] +async fn poll_is_displayed() { + let timeline = TestTimeline::new(); + + timeline.send_poll_start(&ALICE, fakes::poll_a()).await; + let poll_state = timeline.poll_state().await; + + assert_poll_start_eq(&poll_state.start_event_content.poll_start, &fakes::poll_a()); + assert!(poll_state.response_data.is_empty()); +} + +#[async_test] +async fn edited_poll_is_displayed() { + let timeline = TestTimeline::new(); + + timeline.send_poll_start(&ALICE, fakes::poll_a()).await; + let event = timeline.poll_event().await; + let event_id = event.event_id().unwrap(); + timeline.send_poll_edit(&ALICE, event_id, fakes::poll_b()).await; + let edited_poll_state = timeline.poll_state().await; + + assert_poll_start_eq(&event.poll_state().start_event_content.poll_start, &fakes::poll_a()); + assert_poll_start_eq(&edited_poll_state.start_event_content.poll_start, &fakes::poll_b()); +} + +#[async_test] +async fn voting_adds_the_vote_to_the_results() { + let timeline = TestTimeline::new(); + timeline.send_poll_start(&ALICE, fakes::poll_a()).await; + let poll_id = timeline.poll_event().await.event_id().unwrap().to_owned(); + + // Alice votes + timeline.send_poll_response(&ALICE, vec!["id_up"], &poll_id).await; + let results = timeline.poll_state().await.results(); + + assert_eq!(results.votes["id_up"], vec![ALICE.to_string()]); + assert!(results.end_time.is_none()); +} + +#[async_test] +async fn ending_a_poll_sets_end_time_to_results() { + let timeline = TestTimeline::new(); + timeline.send_poll_start(&ALICE, fakes::poll_a()).await; + let poll_id = timeline.poll_event().await.event_id().unwrap().to_owned(); + + // Poll finishes + timeline.send_poll_end(&ALICE, "ENDED", &poll_id).await; + let results = timeline.poll_state().await.results(); + + assert!(results.end_time.is_some()); +} + +#[async_test] +async fn only_the_last_vote_from_a_user_is_counted() { + let timeline = TestTimeline::new(); + timeline.send_poll_start(&ALICE, fakes::poll_a()).await; + let poll_id = timeline.poll_event().await.event_id().unwrap().to_owned(); + + // Alice votes + timeline.send_poll_response(&ALICE, vec!["id_up"], &poll_id).await; + let results = timeline.poll_state().await.results(); + assert_eq!(results.votes["id_up"], vec![ALICE.to_string()]); + + // Alice changes her mind and votes again + timeline.send_poll_response(&ALICE, vec!["id_down"], &poll_id).await; + let results = timeline.poll_state().await.results(); + assert_eq!(results.votes["id_down"], vec![ALICE.to_string()]); +} + +#[async_test] +async fn votes_after_end_are_discarded() { + let timeline = TestTimeline::new(); + timeline.send_poll_start(&ALICE, fakes::poll_a()).await; + let poll_id = timeline.poll_event().await.event_id().unwrap().to_owned(); + + // Poll finishes + timeline.send_poll_end(&ALICE, "ENDED", &poll_id).await; + + // Alice votes but it's too late, her vote won't count + timeline.send_poll_response(&ALICE, vec!["id_up"], &poll_id).await; + let results = timeline.poll_state().await.results(); + for (_, votes) in results.votes.iter() { + assert!(votes.is_empty()); + } +} + +#[async_test] +async fn multiple_end_events_are_discarded() { + let timeline = TestTimeline::new(); + timeline.send_poll_start(&ALICE, fakes::poll_a()).await; + let poll_id = timeline.poll_event().await.event_id().unwrap().to_owned(); + + // Poll finishes + timeline.send_poll_end(&ALICE, "ENDED", &poll_id).await; + let results = timeline.poll_state().await.results(); + assert!(results.end_time.is_some()); + + let first_end_time = results.end_time.unwrap(); + + // Another poll end event arrives, but it should be discarded + // and therefore the poll's end time should not change + timeline.send_poll_end(&ALICE, "ENDED", &poll_id).await; + let results = timeline.poll_state().await.results(); + assert_eq!(results.end_time.unwrap(), first_end_time); +} + +#[async_test] +async fn a_somewhat_complex_voting_session_yields_the_expected_outcome() { + let timeline = TestTimeline::new(); + timeline.send_poll_start(&ALICE, fakes::poll_a()).await; + let poll_id = timeline.poll_event().await.event_id().unwrap().to_owned(); + + // Alice votes + timeline.send_poll_response(&ALICE, vec!["id_up"], &poll_id).await; + let results = timeline.poll_state().await.results(); + assert_eq!(results.votes["id_up"], vec![ALICE.to_string()]); + + // Now Bob also votes + timeline.send_poll_response(&BOB, vec!["id_up"], &poll_id).await; + let results = timeline.poll_state().await.results(); + assert_eq!(results.votes["id_up"], vec![ALICE.to_string(), BOB.to_string()]); + + // Alice changes her mind and votes again + timeline.send_poll_response(&ALICE, vec!["id_down"], &poll_id).await; + let results = timeline.poll_state().await.results(); + assert_eq!(results.votes["id_up"], vec![BOB.to_string()]); + assert_eq!(results.votes["id_down"], vec![ALICE.to_string()]); + + // Poll finishes + timeline.send_poll_end(&ALICE, "ENDED", &poll_id).await; + + // Now Bob also changes his mind but it's too late, his vote won't count + timeline.send_poll_response(&BOB, vec!["id_down"], &poll_id).await; + let results = timeline.poll_state().await.results(); + assert_eq!(results.votes["id_up"], vec![BOB.to_string()]); + assert_eq!(results.votes["id_down"], vec![ALICE.to_string()]); +} + +#[async_test] +async fn events_received_before_start_are_not_lost() { + let timeline = TestTimeline::new(); + let poll_id: OwnedEventId = EventId::new(server_name!("dummy.server")); + + // Alice votes + timeline.send_poll_response(&ALICE, vec!["id_up"], &poll_id).await; + + // Now Bob also votes + timeline.send_poll_response(&BOB, vec!["id_up"], &poll_id).await; + + // Alice changes her mind and votes again + timeline.send_poll_response(&ALICE, vec!["id_down"], &poll_id).await; + + // Poll finishes + timeline.send_poll_end(&ALICE, "ENDED", &poll_id).await; + + // Now the start event arrives + timeline.send_poll_start_with_id(&ALICE, &poll_id, fakes::poll_a()).await; + + // Now Bob votes again but his vote won't count + timeline.send_poll_response(&BOB, vec!["id_down"], &poll_id).await; + + let results = timeline.poll_state().await.results(); + assert_eq!(results.votes["id_up"], vec![BOB.to_string()]); + assert_eq!(results.votes["id_down"], vec![ALICE.to_string()]); +} + +impl TestTimeline { + async fn event_items(&self) -> Vec { + self.inner.items().await.iter().filter_map(|item| item.as_event().cloned()).collect() + } + + async fn poll_event(&self) -> EventTimelineItem { + self.event_items().await.first().unwrap().clone() + } + + async fn poll_state(&self) -> PollState { + self.event_items().await.first().unwrap().clone().poll_state() + } + + async fn send_poll_start(&self, sender: &UserId, content: UnstablePollStartContentBlock) { + let event_content = AnyMessageLikeEventContent::UnstablePollStart( + UnstablePollStartEventContent::new(content), + ); + self.handle_live_message_event(sender, event_content).await; + } + + async fn send_poll_start_with_id( + &self, + sender: &UserId, + event_id: &EventId, + content: UnstablePollStartContentBlock, + ) { + let event_content = AnyMessageLikeEventContent::UnstablePollStart( + UnstablePollStartEventContent::new(content), + ); + let event = self.make_message_event_with_id(sender, event_content, event_id.to_owned()); + let raw = Raw::new(&event).unwrap().cast(); + self.handle_live_event(raw).await; + } + + async fn send_poll_response(&self, sender: &UserId, answers: Vec<&str>, poll_id: &EventId) { + let event_content = AnyMessageLikeEventContent::UnstablePollResponse( + UnstablePollResponseEventContent::new( + answers.into_iter().map(|i| i.to_owned()).collect(), + poll_id.to_owned(), + ), + ); + self.handle_live_message_event(sender, event_content).await + } + + async fn send_poll_end(&self, sender: &UserId, text: &str, poll_id: &EventId) { + let event_content = AnyMessageLikeEventContent::UnstablePollEnd( + UnstablePollEndEventContent::new(text, poll_id.to_owned()), + ); + self.handle_live_message_event(sender, event_content).await + } + + async fn send_poll_edit( + &self, + sender: &UserId, + original_id: &EventId, + content: UnstablePollStartContentBlock, + ) { + let mut content = UnstablePollStartEventContent::new(content); + content.relates_to = Some(Relation::Replacement(Replacement::new( + original_id.to_owned(), + content.clone().into(), + ))); + let event_content = AnyMessageLikeEventContent::UnstablePollStart(content); + self.handle_live_message_event(sender, event_content).await + } +} + +impl EventTimelineItem { + fn poll_state(self) -> PollState { + match self.content() { + TimelineItemContent::Poll(poll_state) => poll_state.clone(), + _ => panic!("Not a poll state"), + } + } +} + +fn assert_poll_start_eq(a: &UnstablePollStartContentBlock, b: &UnstablePollStartContentBlock) { + assert_eq!(a.question.text, b.question.text); + assert_eq!(a.kind, b.kind); + assert_eq!(a.max_selections, b.max_selections); + assert_eq!(a.answers.len(), a.answers.len()); + a.answers.iter().zip(b.answers.iter()).all(|(a, b)| { + assert_eq!(a.id, b.id); + assert_eq!(a.text, b.text); + true + }); +} + +mod fakes { + use ruma::events::poll::{ + start::PollKind, + unstable_start::{UnstablePollAnswer, UnstablePollAnswers, UnstablePollStartContentBlock}, + }; + + pub fn poll_a() -> UnstablePollStartContentBlock { + let mut content = UnstablePollStartContentBlock::new( + "Up or down?", + UnstablePollAnswers::try_from(vec![ + UnstablePollAnswer::new("id_up", "Up"), + UnstablePollAnswer::new("id_down", "Down"), + ]) + .unwrap(), + ); + content.kind = PollKind::Disclosed; + content + } + + pub fn poll_b() -> UnstablePollStartContentBlock { + let mut content = UnstablePollStartContentBlock::new( + "Left or right?", + UnstablePollAnswers::try_from(vec![ + UnstablePollAnswer::new("id_left", "Left"), + UnstablePollAnswer::new("id_right", "Right"), + ]) + .unwrap(), + ); + content.kind = PollKind::Disclosed; + content + } +}