mirror of
https://github.com/matrix-org/matrix-rust-sdk.git
synced 2026-05-19 06:04:31 -04:00
ui: Add support for polls in timeline
This commit is contained in:
16
Cargo.lock
generated
16
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
187
crates/matrix-sdk-ui/src/timeline/polls.rs
Normal file
187
crates/matrix-sdk-ui/src/timeline/polls.rs
Normal 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,
|
||||
}
|
||||
@@ -64,6 +64,7 @@ mod edit;
|
||||
mod encryption;
|
||||
mod event_filter;
|
||||
mod invalid;
|
||||
mod polls;
|
||||
mod reaction_group;
|
||||
mod reactions;
|
||||
mod read_receipts;
|
||||
|
||||
308
crates/matrix-sdk-ui/src/timeline/tests/polls.rs
Normal file
308
crates/matrix-sdk-ui/src/timeline/tests/polls.rs
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user