Matrix polls, on the card polls already had
A poll arrives as three kinds of event and no running total: the start carries the question and the answers, every vote is its own event, and an end says the counting is over. Only the most recent vote from each person counts, which is why the tally is kept by sender rather than as a number - a changed vote must replace, not double. Both spellings are read. The stable names arrived with Matrix 1.10 and the `org.matrix.msc3381` ones are what clients wrote for years before that; a room with a mix of clients has both, and a poll that renders in Element and not here is the whole complaint. A test covers each, and caught a real bug while being written: `?` inside the loop over spellings gave up on the whole question the moment the first key was missing, so only the stable form was ever read. It lands on the card Kick's polls already use, which is not a coincidence being exploited - it is one question with a set of answers and a tally, whoever is asking. The runtime's store is renamed to say so. What that card could not express is a poll with no clock: Matrix polls run until somebody ends them, so the card is now told plainly whether it is open rather than working it out from a countdown that would never start. Voting sends the stable `m.poll.response`, encrypted where the room is - a vote is an event like any other, and a room that hides its messages hides its votes. Measured in a real room: started, shown, voted in from the client, tallied, ended, and kept in the history the panel reads. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -1125,7 +1125,7 @@ pub fn announce_prediction(state: &AppState, buffer_id: &str, payload: &serde_js
|
||||
// bet, not your points. Both were known a moment ago if this is the same
|
||||
// prediction moving, so they are carried across rather than blanked -
|
||||
// otherwise somebody else's bet would wipe yours off the card.
|
||||
let known = state.runtime.kick_poll(buffer_id, "prediction");
|
||||
let known = state.runtime.live_card(buffer_id, "prediction");
|
||||
let same = known
|
||||
.as_ref()
|
||||
.and_then(|card| card["id"].as_str().map(|id| id == format!("prediction:{}", prediction.id)))
|
||||
@@ -1287,9 +1287,9 @@ fn publish_card(state: &AppState, buffer_id: &str, kind: &str, card: Option<serd
|
||||
card
|
||||
});
|
||||
match &card {
|
||||
None => state.runtime.forget_kick_poll(buffer_id, kind),
|
||||
None => state.runtime.forget_live_card(buffer_id, kind),
|
||||
Some(card) => {
|
||||
state.runtime.set_kick_poll(buffer_id, kind, card.clone());
|
||||
state.runtime.set_live_card(buffer_id, kind, card.clone());
|
||||
// Written down as it changes, so what is read back later is how
|
||||
// it finished rather than how it opened.
|
||||
let id = card["id"].as_str().unwrap_or_default().to_string();
|
||||
@@ -1360,7 +1360,7 @@ fn now_secs() -> i64 {
|
||||
/// title matches, since a second of drift in `remaining` must not split one
|
||||
/// poll into two.
|
||||
fn card_id(state: &AppState, buffer_id: &str, kind: &str, title: &str, duration: u32, remaining: u32) -> String {
|
||||
if let Some(open) = state.runtime.kick_poll(buffer_id, kind) {
|
||||
if let Some(open) = state.runtime.live_card(buffer_id, kind) {
|
||||
if open["title"].as_str() == Some(title) {
|
||||
if let Some(id) = open["id"].as_str() {
|
||||
return id.to_string();
|
||||
@@ -1986,7 +1986,7 @@ mod tests {
|
||||
"winning_outcome_id": "OUT2"
|
||||
}
|
||||
}));
|
||||
let card = state.runtime.kick_poll(&buffer_id, "prediction").expect("a card");
|
||||
let card = state.runtime.live_card(&buffer_id, "prediction").expect("a card");
|
||||
assert_eq!(card["kind"], "prediction");
|
||||
assert_eq!(card["id"], "prediction:01M1PQXD465ZFG2RJJ2FE4166R");
|
||||
assert_eq!(card["title"], "who will win?");
|
||||
@@ -2027,7 +2027,7 @@ mod tests {
|
||||
};
|
||||
let vote = api::PredictionVote { outcome_id: "OUT1".into(), total_vote_amount: 250.0 };
|
||||
announce_prediction_card(&state, &buffer_id, &prediction, Some(&vote), Some(9_000));
|
||||
let card = state.runtime.kick_poll(&buffer_id, "prediction").expect("a card");
|
||||
let card = state.runtime.live_card(&buffer_id, "prediction").expect("a card");
|
||||
assert_eq!(card["hasVoted"], true);
|
||||
assert_eq!(card["votedOptionId"], "OUT1");
|
||||
assert_eq!(card["stake"], 250.0);
|
||||
|
||||
@@ -27,6 +27,7 @@ pub mod crypto;
|
||||
pub mod http;
|
||||
pub mod markup;
|
||||
pub mod moderation;
|
||||
pub mod polls;
|
||||
pub mod protocol;
|
||||
pub mod roomstate;
|
||||
pub mod rooms;
|
||||
@@ -684,7 +685,12 @@ async fn handle_timeline_event(
|
||||
return;
|
||||
}
|
||||
|
||||
if outer_type != protocol::EVENT_ROOM_MESSAGE && outer_type != protocol::EVENT_ROOM_ENCRYPTED && outer_type != protocol::EVENT_REACTION {
|
||||
if outer_type != protocol::EVENT_ROOM_MESSAGE
|
||||
&& outer_type != protocol::EVENT_ROOM_ENCRYPTED
|
||||
&& outer_type != protocol::EVENT_REACTION
|
||||
// A poll is three kinds of event and none of them is a message.
|
||||
&& !polls::is_poll_event(outer_type)
|
||||
{
|
||||
// Membership/name changes were already folded into naming in
|
||||
// process_sync_response; anything else (typing, receipts, other
|
||||
// state events) isn't rendered.
|
||||
@@ -742,6 +748,15 @@ async fn handle_timeline_event(
|
||||
return;
|
||||
}
|
||||
|
||||
// A poll: the question, a vote, or the end of the counting. Handled after
|
||||
// decryption like everything else, because a poll in an encrypted room
|
||||
// arrives as ciphertext the same way a message does.
|
||||
if polls::is_poll_event(&effective_type)
|
||||
&& polls::handle(state, account_id, buffer_id, own_user_id, &effective_type, event_id, sender, &content)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if effective_type != protocol::EVENT_ROOM_MESSAGE {
|
||||
return;
|
||||
}
|
||||
@@ -2944,6 +2959,55 @@ pub async fn edit_message(state: &AppState, account_id: &str, buffer_id: &str, a
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Answers a poll.
|
||||
///
|
||||
/// The stable spelling is sent, which is what a current Element writes;
|
||||
/// everything on the way in is read either way, because a room with older
|
||||
/// clients in it has both. Encrypted where the room is - a vote is an event
|
||||
/// like any other, and a room that hides its messages hides its votes.
|
||||
///
|
||||
/// The answer is recorded here as well as sent: the echo arrives on the next
|
||||
/// sync, and a card that does not move when pressed reads as a card that did
|
||||
/// not take the press.
|
||||
pub async fn vote_in_poll(state: &AppState, account_id: &str, buffer_id: &str, poll_id: &str, answer: &str) -> Result<()> {
|
||||
let account = state.accounts.get_matrix(account_id).context("account not connected")?;
|
||||
let room_id = state.runtime.get_matrix_room(buffer_id).context("no known Matrix room for this buffer")?;
|
||||
let base = account.homeserver_url.trim_end_matches('/');
|
||||
let access_token = account.access_token.clone();
|
||||
|
||||
let content = serde_json::json!({
|
||||
"m.relates_to": { "rel_type": "m.reference", "event_id": poll_id },
|
||||
"m.selections": [answer],
|
||||
});
|
||||
|
||||
let (event_type, body_json) = if state.runtime.is_matrix_room_encrypted(buffer_id) {
|
||||
let session = state.runtime.get_matrix_machine(account_id).context("crypto session not ready yet")?;
|
||||
let member_ids = joined_member_ids(base, &access_token, &room_id).await?;
|
||||
let room_id_ruma = ruma_common::RoomId::parse(&room_id).context("invalid room id")?;
|
||||
let encrypted = session
|
||||
.share_and_encrypt_content(&account.homeserver_url, &access_token, &room_id_ruma, member_ids, polls::POLL_RESPONSE[0], content)
|
||||
.await
|
||||
.context("encrypting the vote")?;
|
||||
(protocol::EVENT_ROOM_ENCRYPTED.to_string(), encrypted)
|
||||
} else {
|
||||
(polls::POLL_RESPONSE[0].to_string(), content)
|
||||
};
|
||||
|
||||
let txn_id = model::next_message_id();
|
||||
let url = format!(
|
||||
"{base}/_matrix/client/v3/rooms/{}/send/{event_type}/{}",
|
||||
url::form_urlencoded::byte_serialize(room_id.as_bytes()).collect::<String>(),
|
||||
url::form_urlencoded::byte_serialize(txn_id.as_bytes()).collect::<String>(),
|
||||
);
|
||||
http::put_json(&url, &access_token, body_json).await.context("sending your vote")?;
|
||||
|
||||
if !state.runtime.matrix_poll_ended(buffer_id, poll_id) {
|
||||
state.runtime.set_matrix_poll_vote(buffer_id, poll_id, &account.user_id, answer);
|
||||
polls::republish(state, account_id, buffer_id, poll_id, &account.user_id);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Redacts (deletes) a message. Redactions are always sent in cleartext,
|
||||
/// even in an encrypted room (per the C-S API spec) - no crypto involved.
|
||||
pub async fn delete_message(state: &AppState, account_id: &str, buffer_id: &str, access_token: &str, msg_id: &str) -> Result<()> {
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
//! Matrix polls - `m.poll.start`, `m.poll.response`, `m.poll.end`.
|
||||
//!
|
||||
//! A poll arrives as three kinds of event and no running total: the start
|
||||
//! carries the question and the answers, every vote is its own event, and the
|
||||
//! end is a fourth party saying the counting is over. Only the most recent
|
||||
//! vote from each person counts, which is why the tally is kept by sender
|
||||
//! rather than as a number - a changed vote must replace, not double.
|
||||
//!
|
||||
//! Two spellings of everything, and both are read. The stable names arrived
|
||||
//! with Matrix 1.10 and the `org.matrix.msc3381` ones are what clients sent
|
||||
//! for years before that; a room with a mix of clients in it has both, and a
|
||||
//! poll that renders in Element and not here is exactly the complaint this
|
||||
//! answers.
|
||||
//!
|
||||
//! The card this produces is the same card a Kick poll or prediction lands
|
||||
//! on. That is not a coincidence being exploited - it is one question with a
|
||||
//! set of answers and a tally, whoever is asking.
|
||||
|
||||
use crate::state::AppState;
|
||||
use serde_json::Value;
|
||||
|
||||
/// Event types, stable first and unstable second, in the order a `starts_with`
|
||||
/// test should try them.
|
||||
pub const POLL_START: [&str; 2] = ["m.poll.start", "org.matrix.msc3381.poll.start"];
|
||||
pub const POLL_RESPONSE: [&str; 2] = ["m.poll.response", "org.matrix.msc3381.poll.response"];
|
||||
pub const POLL_END: [&str; 2] = ["m.poll.end", "org.matrix.msc3381.poll.end"];
|
||||
|
||||
/// Whether an event type is any of the poll ones.
|
||||
pub fn is_poll_event(event_type: &str) -> bool {
|
||||
POLL_START.contains(&event_type) || POLL_RESPONSE.contains(&event_type) || POLL_END.contains(&event_type)
|
||||
}
|
||||
|
||||
/// Reads whichever spelling of a text block this event used.
|
||||
///
|
||||
/// MSC1767 wrapped text in an object, the stable form kept it, and plenty of
|
||||
/// events carry a plain `body` beside it as the fallback for clients that
|
||||
/// understand none of this.
|
||||
fn text_of(value: &Value) -> Option<String> {
|
||||
for key in ["m.text", "org.matrix.msc1767.text", "body"] {
|
||||
if let Some(text) = value.get(key).and_then(|v| v.as_str()) {
|
||||
return Some(text.to_string());
|
||||
}
|
||||
}
|
||||
// The stable form allows a list of representations; the first one that is
|
||||
// text is the one to show.
|
||||
value
|
||||
.get("m.text")
|
||||
.and_then(|v| v.as_array())
|
||||
.and_then(|items| items.iter().find_map(|i| i.get("body").and_then(|b| b.as_str())))
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// The question and answers, out of whichever shape the sender used.
|
||||
pub fn parse_start(content: &Value) -> Option<(String, Vec<(String, String)>)> {
|
||||
let poll = ["m.poll", "org.matrix.msc3381.poll.start"]
|
||||
.iter()
|
||||
.find_map(|key| content.get(*key))
|
||||
.unwrap_or(content);
|
||||
let question = poll.get("question").and_then(text_of).or_else(|| text_of(content))?;
|
||||
let answers = poll.get("answers")?.as_array()?;
|
||||
let options: Vec<(String, String)> = answers
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, answer)| {
|
||||
let id = answer
|
||||
.get("m.id")
|
||||
.or_else(|| answer.get("id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
// An answer with no id of its own is still an answer; its
|
||||
// position is the only handle anybody has on it.
|
||||
.unwrap_or_else(|| index.to_string());
|
||||
let label = text_of(answer).unwrap_or_else(|| format!("Option {}", index + 1));
|
||||
Some((id, label))
|
||||
})
|
||||
.collect();
|
||||
if options.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((question, options))
|
||||
}
|
||||
|
||||
/// Which poll a vote or an end belongs to.
|
||||
pub fn related_poll(content: &Value) -> Option<String> {
|
||||
content["m.relates_to"]["event_id"].as_str().map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// The answer somebody picked. One answer, because this client offers one:
|
||||
/// a multi-select poll is read correctly and answered singly.
|
||||
pub fn parse_response(content: &Value) -> Option<String> {
|
||||
for key in ["m.selections", "org.matrix.msc3381.poll.response"] {
|
||||
// `continue`, not `?`: the whole point of the loop is that an event
|
||||
// carries one spelling or the other, and giving up on the first
|
||||
// missing key would only ever read the stable one.
|
||||
let Some(block) = content.get(key) else { continue };
|
||||
// Stable puts the ids in a list at the key; unstable wraps them in an
|
||||
// object under `answers`.
|
||||
let list = block.as_array().cloned().or_else(|| block.get("answers").and_then(|a| a.as_array()).cloned());
|
||||
if let Some(list) = list {
|
||||
if let Some(first) = list.first().and_then(|v| v.as_str()) {
|
||||
return Some(first.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Builds the card for a poll, from its question, its answers and every vote
|
||||
/// this client has seen.
|
||||
pub fn card(
|
||||
buffer_id: &str,
|
||||
poll_id: &str,
|
||||
question: &str,
|
||||
answers: &[(String, String)],
|
||||
votes: &std::collections::HashMap<String, String>,
|
||||
own_user_id: &str,
|
||||
ended: bool,
|
||||
) -> Value {
|
||||
let options: Vec<Value> = answers
|
||||
.iter()
|
||||
.map(|(id, label)| {
|
||||
serde_json::json!({
|
||||
"id": id,
|
||||
"label": label,
|
||||
"votes": votes.values().filter(|answer| *answer == id).count(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
serde_json::json!({
|
||||
"bufferId": buffer_id,
|
||||
"kind": "poll",
|
||||
"id": poll_id,
|
||||
"title": question,
|
||||
"options": options,
|
||||
// Matrix polls have no clock: they run until somebody ends them, so
|
||||
// the card is told plainly rather than left to work it out from a
|
||||
// countdown that would never start.
|
||||
"open": !ended,
|
||||
"duration": 0,
|
||||
"remaining": 0,
|
||||
"resultDisplayDuration": 0,
|
||||
"hasVoted": votes.contains_key(own_user_id),
|
||||
"votedOptionId": votes.get(own_user_id),
|
||||
})
|
||||
}
|
||||
|
||||
/// Handles one poll event, updating the tally and telling the client.
|
||||
///
|
||||
/// Returns whether the event was one of ours, so the caller can stop.
|
||||
pub fn handle(
|
||||
state: &AppState,
|
||||
account_id: &str,
|
||||
buffer_id: &str,
|
||||
own_user_id: &str,
|
||||
event_type: &str,
|
||||
event_id: &str,
|
||||
sender: &str,
|
||||
content: &Value,
|
||||
) -> bool {
|
||||
if POLL_START.contains(&event_type) {
|
||||
let Some((question, answers)) = parse_start(content) else { return true };
|
||||
state.runtime.set_matrix_poll(buffer_id, event_id, &question, &answers);
|
||||
announce(state, account_id, buffer_id, event_id, own_user_id);
|
||||
return true;
|
||||
}
|
||||
|
||||
if POLL_RESPONSE.contains(&event_type) {
|
||||
let (Some(poll_id), Some(answer)) = (related_poll(content), parse_response(content)) else {
|
||||
return true;
|
||||
};
|
||||
// A vote after the poll ended does not count, which is the server's
|
||||
// rule and this client's too.
|
||||
if !state.runtime.matrix_poll_ended(buffer_id, &poll_id) {
|
||||
state.runtime.set_matrix_poll_vote(buffer_id, &poll_id, sender, &answer);
|
||||
announce(state, account_id, buffer_id, &poll_id, own_user_id);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if POLL_END.contains(&event_type) {
|
||||
if let Some(poll_id) = related_poll(content) {
|
||||
state.runtime.end_matrix_poll(buffer_id, &poll_id);
|
||||
announce(state, account_id, buffer_id, &poll_id, own_user_id);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Draws the poll again after a change made here rather than received.
|
||||
pub fn republish(state: &AppState, account_id: &str, buffer_id: &str, poll_id: &str, own_user_id: &str) {
|
||||
announce(state, account_id, buffer_id, poll_id, own_user_id);
|
||||
}
|
||||
|
||||
/// Draws the current state of one poll and sends it to the client.
|
||||
fn announce(state: &AppState, _account_id: &str, buffer_id: &str, poll_id: &str, own_user_id: &str) {
|
||||
let Some((question, answers, ended)) = state.runtime.matrix_poll(buffer_id, poll_id) else { return };
|
||||
let votes = state.runtime.matrix_poll_votes(buffer_id, poll_id);
|
||||
let card = card(buffer_id, poll_id, &question, &answers, &votes, own_user_id, ended);
|
||||
state.runtime.set_live_card(buffer_id, "poll", card.clone());
|
||||
// Written down as well as shown, so the room's past polls are readable
|
||||
// from the history panel the same way a Kick channel's are - and so a
|
||||
// poll answered last week survives a restart.
|
||||
let ts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
if let Err(e) = state.store.record_live_card(buffer_id, "poll", poll_id, &question, &card.to_string(), ts) {
|
||||
tracing::debug!("matrix: keeping the poll: {e:#}");
|
||||
}
|
||||
state.events.emit(
|
||||
"pollCard",
|
||||
serde_json::json!({ "bufferId": buffer_id, "kind": "poll", "poll": card }),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Both spellings, because a room with a mix of clients in it has both.
|
||||
#[test]
|
||||
fn reads_a_poll_however_it_was_written() {
|
||||
let stable = serde_json::json!({
|
||||
"m.poll": {
|
||||
"question": { "m.text": "Where for lunch?" },
|
||||
"kind": "m.poll.disclosed",
|
||||
"answers": [
|
||||
{ "m.id": "a", "m.text": "Pub" },
|
||||
{ "m.id": "b", "m.text": "Chippy" }
|
||||
]
|
||||
}
|
||||
});
|
||||
let (question, answers) = parse_start(&stable).expect("stable poll");
|
||||
assert_eq!(question, "Where for lunch?");
|
||||
assert_eq!(answers, vec![("a".into(), "Pub".into()), ("b".into(), "Chippy".into())]);
|
||||
|
||||
let unstable = serde_json::json!({
|
||||
"org.matrix.msc3381.poll.start": {
|
||||
"question": { "org.matrix.msc1767.text": "Where for lunch?" },
|
||||
"answers": [
|
||||
{ "id": "a", "org.matrix.msc1767.text": "Pub" },
|
||||
{ "id": "b", "org.matrix.msc1767.text": "Chippy" }
|
||||
]
|
||||
}
|
||||
});
|
||||
let (question, answers) = parse_start(&unstable).expect("unstable poll");
|
||||
assert_eq!(question, "Where for lunch?");
|
||||
assert_eq!(answers.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_a_vote_however_it_was_written() {
|
||||
let stable = serde_json::json!({ "m.selections": ["b"], "m.relates_to": { "event_id": "$poll" } });
|
||||
assert_eq!(parse_response(&stable).as_deref(), Some("b"));
|
||||
assert_eq!(related_poll(&stable).as_deref(), Some("$poll"));
|
||||
|
||||
let unstable = serde_json::json!({ "org.matrix.msc3381.poll.response": { "answers": ["a"] } });
|
||||
assert_eq!(parse_response(&unstable).as_deref(), Some("a"));
|
||||
|
||||
// Nothing to read is not an answer of "".
|
||||
assert_eq!(parse_response(&serde_json::json!({})), None);
|
||||
}
|
||||
|
||||
/// Only the last vote from each person counts, and the card says whether
|
||||
/// this account has voted at all.
|
||||
#[test]
|
||||
fn counts_one_vote_each() {
|
||||
let answers = vec![("a".to_string(), "Pub".to_string()), ("b".to_string(), "Chippy".to_string())];
|
||||
let mut votes = std::collections::HashMap::new();
|
||||
votes.insert("@alice:example.org".to_string(), "a".to_string());
|
||||
votes.insert("@bob:example.org".to_string(), "a".to_string());
|
||||
// Bob changes his mind, which replaces rather than adds.
|
||||
votes.insert("@bob:example.org".to_string(), "b".to_string());
|
||||
let card = card("buf", "$poll", "Where for lunch?", &answers, &votes, "@alice:example.org", false);
|
||||
assert_eq!(card["options"][0]["votes"], 1);
|
||||
assert_eq!(card["options"][1]["votes"], 1);
|
||||
assert_eq!(card["hasVoted"], true);
|
||||
assert_eq!(card["votedOptionId"], "a");
|
||||
assert_eq!(card["open"], true);
|
||||
|
||||
let ended = card_ended(&answers, &votes);
|
||||
assert_eq!(ended["open"], false);
|
||||
}
|
||||
|
||||
fn card_ended(answers: &[(String, String)], votes: &std::collections::HashMap<String, String>) -> Value {
|
||||
card("buf", "$poll", "Where for lunch?", answers, votes, "@nobody:example.org", true)
|
||||
}
|
||||
}
|
||||
+22
-4
@@ -275,7 +275,7 @@ pub async fn dispatch(
|
||||
// A poll or prediction already running when you opened the
|
||||
// channel: the events only carry the ones that change while
|
||||
// you are watching.
|
||||
for card in state.runtime.kick_polls_for(buffer_id) {
|
||||
for card in state.runtime.live_cards_for(buffer_id) {
|
||||
// Only while it still means something. A card kept from
|
||||
// an hour ago is not news to a window opening now, and
|
||||
// one whose clock ran out before you arrived would open
|
||||
@@ -2362,9 +2362,27 @@ pub async fn dispatch(
|
||||
let Some(buffer_id) = p_str_opt(params, "bufferId") else {
|
||||
return (None, Some("votePoll requires \"bufferId\"".to_string()));
|
||||
};
|
||||
let Some(option_id) = params.get("optionId").and_then(|v| v.as_i64()) else {
|
||||
return (None, Some("votePoll requires \"optionId\"".to_string()));
|
||||
};
|
||||
// Kick numbers its options and Matrix gives them ids of their
|
||||
// own, so this is read as whichever it is and handed back to
|
||||
// whichever service the conversation belongs to.
|
||||
let option_id = params.get("optionId").and_then(|v| v.as_i64()).unwrap_or(-1);
|
||||
// Matrix polls answer the same question through a different
|
||||
// door: a vote is an event in the room, not a call to an API.
|
||||
if let Some(buffer) = state.runtime.get_buffer(buffer_id) {
|
||||
if buffer.account_id.starts_with("matrix:") {
|
||||
let Some(poll_id) = state.runtime.live_card(buffer_id, "poll").and_then(|c| c["id"].as_str().map(|s| s.to_string()))
|
||||
else {
|
||||
return (None, Some("there is no poll open here".to_string()));
|
||||
};
|
||||
let answer = p_str_opt(params, "answerId")
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| option_id.to_string());
|
||||
return match backend::matrix::vote_in_poll(state, &buffer.account_id, buffer_id, &poll_id, &answer).await {
|
||||
Ok(()) => (Some(ok_node()), None),
|
||||
Err(e) => (None, Some(format!("{e:#}"))),
|
||||
};
|
||||
}
|
||||
}
|
||||
let Some(channel) = state.runtime.kick_channel(buffer_id) else {
|
||||
return (None, Some("that is not a Kick channel".to_string()));
|
||||
};
|
||||
|
||||
+82
-12
@@ -403,9 +403,21 @@ pub struct Runtime {
|
||||
/// When each channel was last asked about directly, so opening twenty
|
||||
/// buffers at once does not become twenty channel fetches at once.
|
||||
kick_stream_fetched: Mutex<HashMap<String, std::time::Instant>>,
|
||||
/// The poll running in each channel, so a window opened while one is
|
||||
/// running is told about it rather than waiting for the next vote.
|
||||
kick_polls: Mutex<HashMap<String, serde_json::Value>>,
|
||||
/// The polls and predictions on screen, by conversation and kind.
|
||||
///
|
||||
/// Not Kick's alone any more: a Matrix poll is the same idea - a
|
||||
/// question, a set of answers, a tally - and lands on the same card.
|
||||
live_cards: Mutex<HashMap<String, serde_json::Value>>,
|
||||
/// The question and answers of each Matrix poll, and whether it has been
|
||||
/// ended, by conversation and poll.
|
||||
matrix_polls: Mutex<HashMap<(String, String), (String, Vec<(String, String)>, bool)>>,
|
||||
/// Who voted for what in each Matrix poll, by conversation and poll.
|
||||
///
|
||||
/// Matrix sends the votes as individual events rather than a running
|
||||
/// total, and only the last one from each person counts - so the tally is
|
||||
/// this client's to keep, and keeping it by sender is what makes a
|
||||
/// changed vote replace rather than double.
|
||||
matrix_poll_votes: Mutex<HashMap<(String, String), HashMap<String, String>>>,
|
||||
/// A `/list` in progress, by account. Gathered rather than announced a
|
||||
/// line at a time - a network answers with tens of thousands of channels.
|
||||
irc_channel_lists: Mutex<HashMap<String, Vec<IrcChannelListing>>>,
|
||||
@@ -754,7 +766,9 @@ impl Runtime {
|
||||
kick_channels: Mutex::new(HashMap::new()),
|
||||
kick_streams: Mutex::new(HashMap::new()),
|
||||
kick_stream_fetched: Mutex::new(HashMap::new()),
|
||||
kick_polls: Mutex::new(HashMap::new()),
|
||||
live_cards: Mutex::new(HashMap::new()),
|
||||
matrix_polls: Mutex::new(HashMap::new()),
|
||||
matrix_poll_votes: Mutex::new(HashMap::new()),
|
||||
irc_channel_lists: Mutex::new(HashMap::new()),
|
||||
irc_caps: Mutex::new(HashMap::new()),
|
||||
matrix_rooms: Mutex::new(HashMap::new()),
|
||||
@@ -2545,19 +2559,75 @@ impl Runtime {
|
||||
|
||||
/// Keyed by kind as well as conversation: a channel can have a poll and
|
||||
/// a prediction running at once, and they are two cards.
|
||||
pub fn set_kick_poll(&self, buffer_id: &str, kind: &str, poll: serde_json::Value) {
|
||||
self.kick_polls.lock().unwrap().insert(format!("{buffer_id}|{kind}"), poll);
|
||||
pub fn set_live_card(&self, buffer_id: &str, kind: &str, poll: serde_json::Value) {
|
||||
self.live_cards.lock().unwrap().insert(format!("{buffer_id}|{kind}"), poll);
|
||||
}
|
||||
|
||||
pub fn kick_poll(&self, buffer_id: &str, kind: &str) -> Option<serde_json::Value> {
|
||||
self.kick_polls.lock().unwrap().get(&format!("{buffer_id}|{kind}")).cloned()
|
||||
/// Remembers a poll's question and answers, which arrive once, in the
|
||||
/// event that started it - every vote afterwards names only an answer id.
|
||||
pub fn set_matrix_poll(&self, buffer_id: &str, poll_id: &str, question: &str, answers: &[(String, String)]) {
|
||||
let key = (buffer_id.to_string(), poll_id.to_string());
|
||||
let mut polls = self.matrix_polls.lock().unwrap();
|
||||
// A poll seen twice - a backfill after a live one, say - keeps
|
||||
// whether it had already been ended.
|
||||
let ended = polls.get(&key).map(|(_, _, ended)| *ended).unwrap_or(false);
|
||||
polls.insert(key, (question.to_string(), answers.to_vec(), ended));
|
||||
}
|
||||
|
||||
pub fn matrix_poll(&self, buffer_id: &str, poll_id: &str) -> Option<(String, Vec<(String, String)>, bool)> {
|
||||
self.matrix_polls.lock().unwrap().get(&(buffer_id.to_string(), poll_id.to_string())).cloned()
|
||||
}
|
||||
|
||||
pub fn matrix_poll_ended(&self, buffer_id: &str, poll_id: &str) -> bool {
|
||||
self.matrix_polls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&(buffer_id.to_string(), poll_id.to_string()))
|
||||
.map(|(_, _, ended)| *ended)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The counting is over. Recorded even for a poll this client never saw
|
||||
/// start, so a late-arriving start cannot reopen it.
|
||||
pub fn end_matrix_poll(&self, buffer_id: &str, poll_id: &str) {
|
||||
let key = (buffer_id.to_string(), poll_id.to_string());
|
||||
let mut polls = self.matrix_polls.lock().unwrap();
|
||||
match polls.get_mut(&key) {
|
||||
Some(entry) => entry.2 = true,
|
||||
None => {
|
||||
polls.insert(key, (String::new(), Vec::new(), true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Records one person's answer, replacing whatever they answered before.
|
||||
pub fn set_matrix_poll_vote(&self, buffer_id: &str, poll_id: &str, voter: &str, answer: &str) {
|
||||
self.matrix_poll_votes
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entry((buffer_id.to_string(), poll_id.to_string()))
|
||||
.or_default()
|
||||
.insert(voter.to_string(), answer.to_string());
|
||||
}
|
||||
|
||||
pub fn matrix_poll_votes(&self, buffer_id: &str, poll_id: &str) -> HashMap<String, String> {
|
||||
self.matrix_poll_votes
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&(buffer_id.to_string(), poll_id.to_string()))
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn live_card(&self, buffer_id: &str, kind: &str) -> Option<serde_json::Value> {
|
||||
self.live_cards.lock().unwrap().get(&format!("{buffer_id}|{kind}")).cloned()
|
||||
}
|
||||
|
||||
/// Every card running in a conversation, for a window that has just
|
||||
/// opened it.
|
||||
pub fn kick_polls_for(&self, buffer_id: &str) -> Vec<serde_json::Value> {
|
||||
pub fn live_cards_for(&self, buffer_id: &str) -> Vec<serde_json::Value> {
|
||||
let prefix = format!("{buffer_id}|");
|
||||
self.kick_polls
|
||||
self.live_cards
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
@@ -2566,8 +2636,8 @@ impl Runtime {
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn forget_kick_poll(&self, buffer_id: &str, kind: &str) {
|
||||
self.kick_polls.lock().unwrap().remove(&format!("{buffer_id}|{kind}"));
|
||||
pub fn forget_live_card(&self, buffer_id: &str, kind: &str) {
|
||||
self.live_cards.lock().unwrap().remove(&format!("{buffer_id}|{kind}"));
|
||||
}
|
||||
|
||||
pub fn kick_stream(&self, buffer_id: &str) -> Option<serde_json::Value> {
|
||||
|
||||
Reference in New Issue
Block a user