ui: Add support for polls in timeline

This commit is contained in:
Marco Romano
2023-08-23 14:57:52 +02:00
committed by GitHub
parent 4dbb0a7cc7
commit 3bc7b9136e
10 changed files with 690 additions and 25 deletions

16
Cargo.lock generated
View File

@@ -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",

View File

@@ -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"

View File

@@ -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<String, Vec<String>>,
end_time: Option<u64>,
},
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<PollKind> for RumaPollKind {
}
}
#[derive(Clone, uniffi::Record)]
impl From<RumaPollKind> 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<PollResult> 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,
}
}
}

View File

@@ -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<UnstablePollStartEventContentWithoutRelation>,
) {
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<Arc<TimelineItem>>,
items_updated: &mut u16,
event_id: &EventId,
action: &str,
update: impl FnOnce(&EventTimelineItem) -> Option<EventTimelineItem>,
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()
}
}

View File

@@ -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<serde_json::Error>,
},
/// 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()),

View File

@@ -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<Arc<TimelineItem>>,
next_internal_id: u64,
pub reactions: Reactions,
pub poll_pending_events: PollPendingEvents,
pub fully_read_event: Option<OwnedEventId>,
/// 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(),

View File

@@ -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();

View File

@@ -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<ResponseData>,
pub(super) end_event_timestamp: Option<MilliSecondsSinceUnixEpoch>,
}
#[derive(Clone, Debug)]
pub(super) struct ResponseData {
pub(super) sender: OwnedUserId,
pub(super) timestamp: MilliSecondsSinceUnixEpoch,
pub(super) answers: Vec<String>,
}
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<Self, ()> {
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<Self, ()> {
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<String> {
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<PollState> 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<OwnedEventId, Vec<ResponseData>>,
pub(super) pending_poll_ends: HashMap<OwnedEventId, MilliSecondsSinceUnixEpoch>,
}
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<PollResultAnswer>,
pub votes: HashMap<String, Vec<String>>,
pub end_time: Option<u64>,
}
#[derive(Debug)]
pub struct PollResultAnswer {
pub id: String,
pub text: String,
}

View File

@@ -64,6 +64,7 @@ mod edit;
mod encryption;
mod event_filter;
mod invalid;
mod polls;
mod reaction_group;
mod reactions;
mod read_receipts;

View File

@@ -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<EventTimelineItem> {
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
}
}