chiark / gitweb /
Add support for polls in other users' posts.
authorSimon Tatham <anakin@pobox.com>
Sun, 7 Jan 2024 22:17:27 +0000 (22:17 +0000)
committerSimon Tatham <anakin@pobox.com>
Sun, 7 Jan 2024 22:18:06 +0000 (22:18 +0000)
That is, we can't post them ourselves yet, but we can see them when
other users post them, and vote in them.

TODO.md
src/client.rs
src/file.rs
src/text.rs
src/types.rs

diff --git a/TODO.md b/TODO.md
index 4e3b708af2c75758f288fb593230c1653319ae52..f8186400e15d2f98614e1ab87ae4b4ff066e9c29 100644 (file)
--- a/TODO.md
+++ b/TODO.md
@@ -47,13 +47,6 @@ not even make sense without.
 Probably better to go all the way, and actually hide the post until
 told otherwise.
 
-### Polls
-
-If someone posts a toot containing a poll, we should show it, and
-offer an option to vote in it.
-
-An expired poll should show its results.
-
 ### Scrolling to keep the selection in view
 
 Various keyboard commands let you select a user or a post from a file
index 0e1678356ac00e1b15989bcad8508d430895a13f..7f0110c9b868ba54f17576c44e6db04059f3b358 100644 (file)
@@ -62,6 +62,7 @@ pub struct Client {
     accounts: HashMap<String, Account>,
     statuses: HashMap<String, Status>,
     notifications: HashMap<String, Notification>,
+    polls: HashMap<String, Poll>,
     feeds: HashMap<FeedId, Feed>,
     instance: Option<Instance>,
     permit_write: bool,
@@ -186,6 +187,9 @@ impl ReqParam for &String {
 impl ReqParam for i32 {
     fn param_value(self) -> String { self.to_string() }
 }
+impl ReqParam for usize {
+    fn param_value(self) -> String { self.to_string() }
+}
 impl ReqParam for bool {
     fn param_value(self) -> String {
         match self {
@@ -353,6 +357,7 @@ impl Client {
             accounts: HashMap::new(),
             statuses: HashMap::new(),
             notifications: HashMap::new(),
+            polls: HashMap::new(),
             feeds: HashMap::new(),
             instance: None,
             permit_write: false,
@@ -437,6 +442,9 @@ impl Client {
 
     fn cache_status(&mut self, st: &Status) {
         self.cache_account(&st.account);
+        if let Some(poll) = &st.poll {
+            self.cache_poll(poll);
+        }
         self.statuses.insert(st.id.to_string(), st.clone());
         if let Some(ref sub) = st.reblog {
             self.statuses.insert(sub.id.to_string(), *sub.clone());
@@ -451,6 +459,10 @@ impl Client {
         self.notifications.insert(n.id.to_string(), n.clone());
     }
 
+    fn cache_poll(&mut self, poll: &Poll) {
+        self.polls.insert(poll.id.to_string(), poll.clone());
+    }
+
     pub fn account_by_id(&mut self, id: &str) -> Result<Account, ClientError> {
         if let Some(st) = self.accounts.get(id) {
             return Ok(st.clone());
@@ -477,6 +489,31 @@ impl Client {
         Ok(ac)
     }
 
+    pub fn poll_by_id(&mut self, id: &str) -> Result<Poll, ClientError> {
+        if let Some(st) = self.polls.get(id) {
+            return Ok(st.clone());
+        }
+
+        let (url, rsp) = self.api_request(Req::get(
+            &("v1/polls/".to_owned() + id)))?;
+        let rspstatus = rsp.status();
+        let poll: Poll = if !rspstatus.is_success() {
+            Err(ClientError::UrlError(url.clone(), rspstatus.to_string()))
+        } else {
+            match serde_json::from_str(&rsp.text()?) {
+                Ok(poll) => Ok(poll),
+                Err(e) => Err(ClientError::UrlError(
+                    url.clone(), e.to_string())),
+            }
+        }?;
+        if poll.id != id {
+            return Err(ClientError::UrlError(
+                url, format!("request returned wrong poll id {}", &poll.id)));
+        }
+        self.cache_poll(&poll);
+        Ok(poll)
+    }
+
     pub fn account_relationship_by_id(&mut self, id: &str) ->
         Result<Relationship, ClientError>
     {
@@ -510,6 +547,11 @@ impl Client {
                 // we had cached
                 st.account = ac.clone();
             }
+            if let Some(poll) = st.poll.as_ref().and_then(
+                |poll| self.polls.get(&poll.id)) {
+                // Ditto with the poll, if any
+                st.poll = Some(poll.clone());
+            }
             return Ok(st);
         }
 
@@ -1085,4 +1127,30 @@ impl Client {
         }
         Ok(ctx)
     }
+
+    pub fn vote_in_poll(&mut self, id: &str,
+                        choices: impl Iterator<Item = usize>)
+                        -> Result<(), ClientError>
+    {
+        let choices: Vec<_> = choices.collect();
+        let mut req = Req::post(&format!("v1/polls/{id}/votes"));
+        for choice in choices {
+            req = req.param("choices[]", choice);
+        }
+        let (url, rsp) = self.api_request(req)?;
+        let rspstatus = rsp.status();
+        // Cache the returned poll so as to update its faved/boosted flags
+        let poll: Poll = if !rspstatus.is_success() {
+            Err(ClientError::UrlError(url, rspstatus.to_string()))
+        } else {
+            match serde_json::from_str(&rsp.text()?) {
+                Ok(poll) => Ok(poll),
+                Err(e) => {
+                    Err(ClientError::UrlError(url, e.to_string()))
+                }
+            }
+        }?;
+        self.cache_poll(&poll);
+        Ok(())
+    }
 }
index f0458c3ed927fab3e2f4dba53e7cc3efb21f13c8..ffc1c2bc451d4297e882c84b46fdcf01f911296c 100644 (file)
@@ -298,6 +298,7 @@ enum SelectionPurpose {
     Boost,
     Thread,
     Reply,
+    Vote,
 }
 
 #[derive(Debug, PartialEq, Eq, Clone, Copy)]
@@ -318,7 +319,9 @@ struct File<Type: FileType, Source: FileDataSource> {
     last_size: Option<(usize, usize)>,
     ui_mode: UIMode,
     selection: Option<(isize, usize)>,
+    selection_restrict_to_item: Option<isize>,
     select_aux: Option<bool>, // distinguishes fave from unfave, etc
+    poll_options_selected: HashSet<usize>,
     file_desc: Type,
     search_direction: Option<SearchDirection>,
     last_search: Option<Regex>,
@@ -389,7 +392,9 @@ impl<Type: FileType, Source: FileDataSource> File<Type, Source> {
             last_size: None,
             ui_mode: UIMode::Normal,
             selection: None,
+            selection_restrict_to_item: None,
             select_aux: None,
+            poll_options_selected: HashSet::new(),
             file_desc,
             search_direction: None,
             last_search: None,
@@ -416,8 +421,15 @@ impl<Type: FileType, Source: FileDataSource> File<Type, Source> {
                 _ => None,
             };
 
+            let poll_options =
+                if self.selection_restrict_to_item == Some(index) {
+                    Some(&self.poll_options_selected)
+                } else {
+                    None
+                };
+
             for line in self.contents.get(index)
-                .render_highlighted(w, highlight)
+                .render_highlighted(w, highlight, poll_options)
             {
                 for frag in line.split(w) {
                     lines.push(frag.to_owned());
@@ -676,6 +688,11 @@ impl<Type: FileType, Source: FileDataSource> File<Type, Source> {
     fn change_selection_to(&mut self, new_selection: Option<(isize, usize)>,
                            none_ok: bool, client: &mut Client) -> LogicalAction
     {
+        if self.selection_restrict_to_item.is_some_and(|restricted|
+            new_selection.is_some_and(|(item, _)| item != restricted)) {
+            return LogicalAction::Beep;
+        }
+
         let result = if new_selection.is_some() {
             self.rerender_selected_item();
             self.selection = new_selection;
@@ -756,12 +773,33 @@ impl<Type: FileType, Source: FileDataSource> File<Type, Source> {
         self.change_selection_to(new_selection, false, client)
     }
 
+    fn vote(&mut self) -> LogicalAction {
+        let (item, sub) = self.selection.expect(
+            "we should only call this if we have a selection");
+        self.selection_restrict_to_item = Some(item);
+        if self.contents.get(item).is_multiple_choice_poll() {
+            if self.poll_options_selected.contains(&sub) {
+                self.poll_options_selected.remove(&sub);
+            } else {
+                self.poll_options_selected.insert(sub);
+            }
+        } else {
+            self.poll_options_selected.clear();
+            self.poll_options_selected.insert(sub);
+        }
+        dbg!(&self.poll_options_selected);
+        self.rerender_selected_item();
+        self.ensure_enough_rendered();
+        LogicalAction::Nothing
+    }
+
     fn end_selection(&mut self) {
         if self.selection.is_some() {
             self.rerender_selected_item();
             self.selection = None;
             self.ensure_enough_rendered();
         }
+        self.poll_options_selected.clear();
         self.ui_mode = UIMode::Normal;
     }
 
@@ -831,6 +869,16 @@ impl<Type: FileType, Source: FileDataSource> File<Type, Source> {
                     UtilityActivity::ComposeReply(id).into()),
                 SelectionPurpose::Thread => LogicalAction::Goto(
                     UtilityActivity::ThreadFile(id, alt).into()),
+                SelectionPurpose::Vote => {
+                    match client.vote_in_poll(
+                        &id, self.poll_options_selected.iter().copied()) {
+                        Ok(_) => {
+                            self.contents.update_items(client);
+                            LogicalAction::Nothing
+                        }
+                        Err(_) => LogicalAction::Beep,
+                    }
+                }
             }
         } else {
             LogicalAction::Beep
@@ -984,6 +1032,13 @@ impl<Type: FileType, Source: FileDataSource>
                 } else {
                     fs
                 };
+                let fs = if Type::Item::can_highlight(
+                    HighlightType::PollOption)
+                {
+                    fs.add(Ctrl('V'), "Vote", 10)
+                } else {
+                    fs
+                };
                 let fs = fs
                     .add(Pr('/'), "Search Down", 20)
                     .add(Pr('\\'), "Search Up", 20)
@@ -1055,6 +1110,26 @@ impl<Type: FileType, Source: FileDataSource>
                         fs.add(Space, "Thread Context", 98)
                             .add(Pr('F'), "Full Thread", 97)
                     }
+                    SelectionPurpose::Vote => {
+                        // Different verb for selecting items
+                        // depending on whether the vote lets you
+                        // select more than one
+                        let verb = if self.contents.get(item)
+                            .is_multiple_choice_poll()
+                        { "Toggle" } else { "Select" };
+
+                        // If you've selected nothing yet, prioritise
+                        // the keypress for selecting something.
+                        // Otherwise, prioritise the one for
+                        // submitting your answer.
+                        if self.poll_options_selected.is_empty() {
+                            fs.add(Space, verb, 98)
+                                .add(Ctrl('V'), "Submit Vote", 97)
+                        } else {
+                            fs.add(Space, verb, 97)
+                                .add(Ctrl('V'), "Submit Vote", 98)
+                        }
+                    }
                 };
                 fs.add(Pr('+'), "Down", 99)
                     .add(Pr('-'), "Up", 99)
@@ -1188,6 +1263,16 @@ impl<Type: FileType, Source: FileDataSource>
                     }
                 }
 
+                Ctrl('V') => {
+                    if Type::Item::can_highlight(HighlightType::PollOption) {
+                        self.start_selection(HighlightType::PollOption,
+                                             SelectionPurpose::Vote,
+                                             client)
+                    } else {
+                        LogicalAction::Nothing
+                    }
+                }
+
                 Pr('l') | Pr('L') => {
                     if Type::CAN_LIST != CanList::Nothing {
                         self.ui_mode = UIMode::ListSubmenu;
@@ -1274,7 +1359,10 @@ impl<Type: FileType, Source: FileDataSource>
                 _ => LogicalAction::Nothing,
             }
             UIMode::Select(_, purpose) => match key {
-                Space => self.complete_selection(client, false),
+                Space => match purpose {
+                    SelectionPurpose::Vote => self.vote(),
+                    _ => self.complete_selection(client, false),
+                }
                 Pr('d') | Pr('D') => match purpose {
                     SelectionPurpose::Favourite | SelectionPurpose::Boost =>
                         self.complete_selection(client, true),
@@ -1285,6 +1373,11 @@ impl<Type: FileType, Source: FileDataSource>
                         self.complete_selection(client, true),
                     _ => LogicalAction::Nothing,
                 }
+                Ctrl('V') => match purpose {
+                    SelectionPurpose::Vote =>
+                        self.complete_selection(client, false),
+                    _ => LogicalAction::Nothing,
+                }
                 Pr('-') | Up => self.selection_up(client),
                 Pr('+') | Down => self.selection_down(client),
                 Pr('q') | Pr('Q') => self.abort_selection(),
index a2bfd70078cb306ccf58ffe2c74d618477543929..227934c1b8dbc0ab672f86546af733ebf5cd70cc 100644 (file)
@@ -2,7 +2,7 @@ use chrono::{DateTime, Local, Utc};
 #[cfg(test)]
 use chrono::NaiveDateTime;
 use core::cmp::{min, max};
-use std::collections::BTreeMap;
+use std::collections::{BTreeMap, HashSet};
 use unicode_width::UnicodeWidthStr;
 
 use super::html;
@@ -12,7 +12,7 @@ use super::tui::OurKey;
 use super::coloured_string::{ColouredString, ColouredStringSlice};
 
 #[derive(Debug, PartialEq, Eq, Clone, Copy)]
-pub enum HighlightType { User, Status, WholeStatus }
+pub enum HighlightType { User, Status, WholeStatus, PollOption }
 
 #[derive(Debug, PartialEq, Eq, Clone, Copy)]
 pub struct Highlight(pub HighlightType, pub usize);
@@ -42,7 +42,7 @@ impl ConsumableHighlight for Option<Highlight> {
 
 pub trait TextFragment {
     fn render(&self, width: usize) -> Vec<ColouredString> {
-        self.render_highlighted(width, None)
+        self.render_highlighted(width, None, None)
     }
 
     fn can_highlight(_htype: HighlightType) -> bool where Self : Sized {
@@ -53,25 +53,30 @@ pub trait TextFragment {
     fn highlighted_id(&self, _highlight: Option<Highlight>) -> Option<String> {
         None
     }
-    fn render_highlighted(&self, width: usize, _highlight: Option<Highlight>)
+    fn render_highlighted(&self, width: usize, highlight: Option<Highlight>,
+                          poll_options: Option<&HashSet<usize>>)
                           -> Vec<ColouredString>;
 
+    fn is_multiple_choice_poll(&self) -> bool { false }
+
     fn render_highlighted_update(
-        &self, width: usize, highlight: &mut Option<Highlight>)
+        &self, width: usize, highlight: &mut Option<Highlight>,
+        poll_options: Option<&HashSet<usize>>)
         -> Vec<ColouredString>
     {
         let (new_highlight, text) = match *highlight {
             Some(Highlight(htype, index)) => {
                 let count = self.count_highlightables(htype);
                 if index < count {
-                    (None, self.render_highlighted(width, *highlight))
+                    (None, self.render_highlighted(
+                        width, *highlight, poll_options))
                 } else {
                     (Some(Highlight(htype, index - count)),
-                     self.render_highlighted(width, None))
+                     self.render_highlighted(width, None, poll_options))
                 }
             }
             None => {
-                (None, self.render_highlighted(width, None))
+                (None, self.render_highlighted(width, None, poll_options))
             }
         };
         *highlight = new_highlight;
@@ -109,10 +114,12 @@ impl<T: TextFragment> TextFragment for Option<T> {
             None => 0,
         }
     }
-    fn render_highlighted(&self, width: usize, highlight: Option<Highlight>)
+    fn render_highlighted(&self, width: usize, highlight: Option<Highlight>,
+                          poll_options: Option<&HashSet<usize>>)
                           -> Vec<ColouredString> {
         match self {
-            Some(ref inner) => inner.render_highlighted(width, highlight),
+            Some(ref inner) => inner.render_highlighted(
+                width, highlight, poll_options),
             None => Vec::new(),
         }
     }
@@ -132,11 +139,14 @@ impl<T: TextFragment> TextFragment for Vec<T> {
     fn count_highlightables(&self, htype: HighlightType) -> usize {
         self.iter().map(|x| x.count_highlightables(htype)).sum()
     }
-    fn render_highlighted(&self, width: usize, highlight: Option<Highlight>)
+    fn render_highlighted(&self, width: usize, highlight: Option<Highlight>,
+                          _poll_options: Option<&HashSet<usize>>)
                           -> Vec<ColouredString> {
         let mut highlight = highlight;
+        // Ignore poll_options, because that's only used when we're
+        // rendering a single item containing the poll in question
         itertools::concat(self.iter().map(
-            |x| x.render_highlighted_update(width, &mut highlight)))
+            |x| x.render_highlighted_update(width, &mut highlight, None)))
     }
     fn highlighted_id(&self, highlight: Option<Highlight>) -> Option<String> {
         let mut highlight = highlight;
@@ -166,7 +176,8 @@ impl BlankLine {
 }
 
 impl TextFragment for BlankLine {
-    fn render_highlighted(&self, _width: usize, _highlight: Option<Highlight>)
+    fn render_highlighted(&self, _width: usize, _highlight: Option<Highlight>,
+                          _poll_options: Option<&HashSet<usize>>)
                           -> Vec<ColouredString>
     {
         Self::render_static()
@@ -205,7 +216,8 @@ fn format_date(date: DateTime<Utc>) -> String {
 }
 
 impl TextFragment for SeparatorLine {
-    fn render_highlighted(&self, width: usize, _highlight: Option<Highlight>)
+    fn render_highlighted(&self, width: usize, _highlight: Option<Highlight>,
+                          _poll_options: Option<&HashSet<usize>>)
                           -> Vec<ColouredString>
     {
         let mut suffix = ColouredString::plain("");
@@ -259,7 +271,8 @@ impl EditorHeaderSeparator {
 }
 
 impl TextFragment for EditorHeaderSeparator {
-    fn render_highlighted(&self, width: usize, _highlight: Option<Highlight>)
+    fn render_highlighted(&self, width: usize, _highlight: Option<Highlight>,
+                          _poll_options: Option<&HashSet<usize>>)
                           -> Vec<ColouredString>
     {
         vec! {
@@ -312,7 +325,8 @@ impl UsernameHeader {
 }
 
 impl TextFragment for UsernameHeader {
-    fn render_highlighted(&self, _width: usize, highlight: Option<Highlight>)
+    fn render_highlighted(&self, _width: usize, highlight: Option<Highlight>,
+                          _poll_options: Option<&HashSet<usize>>)
                           -> Vec<ColouredString>
     {
         let header = ColouredString::plain(&format!("{}: ", self.header));
@@ -540,7 +554,8 @@ fn test_para_build() {
 }
 
 impl TextFragment for Paragraph {
-    fn render_highlighted(&self, width: usize, _highlight: Option<Highlight>)
+    fn render_highlighted(&self, width: usize, _highlight: Option<Highlight>,
+                          _poll_options: Option<&HashSet<usize>>)
                           -> Vec<ColouredString>
     {
         let mut lines = Vec::new();
@@ -646,7 +661,8 @@ impl FileHeader {
 }
 
 impl TextFragment for FileHeader {
-    fn render_highlighted(&self, width: usize, _highlight: Option<Highlight>)
+    fn render_highlighted(&self, width: usize, _highlight: Option<Highlight>,
+                          _poll_options: Option<&HashSet<usize>>)
                           -> Vec<ColouredString>
     {
         let elephants = width >= 16;
@@ -766,7 +782,8 @@ impl Html {
 }
 
 impl TextFragment for Html {
-    fn render_highlighted(&self, width: usize, _highlight: Option<Highlight>)
+    fn render_highlighted(&self, width: usize, _highlight: Option<Highlight>,
+                          _poll_options: Option<&HashSet<usize>>)
                           -> Vec<ColouredString>
     {
         match self {
@@ -861,7 +878,8 @@ impl ExtendableIndicator {
 }
 
 impl TextFragment for ExtendableIndicator {
-    fn render_highlighted(&self, width: usize, _highlight: Option<Highlight>)
+    fn render_highlighted(&self, width: usize, _highlight: Option<Highlight>,
+                          _poll_options: Option<&HashSet<usize>>)
                           -> Vec<ColouredString>
     {
         let message = if self.primed {
@@ -933,7 +951,8 @@ impl InReplyToLine {
 }
 
 impl TextFragment for InReplyToLine {
-    fn render_highlighted(&self, width: usize, _highlight: Option<Highlight>)
+    fn render_highlighted(&self, width: usize, _highlight: Option<Highlight>,
+                          _poll_options: Option<&HashSet<usize>>)
                           -> Vec<ColouredString>
     {
         let rendered_para = self.para.render(width - min(width, 3));
@@ -981,7 +1000,8 @@ impl VisibilityLine {
 }
 
 impl TextFragment for VisibilityLine {
-    fn render_highlighted(&self, width: usize, _highlight: Option<Highlight>)
+    fn render_highlighted(&self, width: usize, _highlight: Option<Highlight>,
+                          _poll_options: Option<&HashSet<usize>>)
                           -> Vec<ColouredString>
     {
         let line = match self.vis {
@@ -1060,7 +1080,8 @@ impl NotificationLog {
 }
 
 impl TextFragment for NotificationLog {
-    fn render_highlighted(&self, width: usize, highlight: Option<Highlight>)
+    fn render_highlighted(&self, width: usize, highlight: Option<Highlight>,
+                          _poll_options: Option<&HashSet<usize>>)
                           -> Vec<ColouredString>
     {
         let mut full_para = Paragraph::new().set_indent(0, 2);
@@ -1172,7 +1193,8 @@ impl UserListEntry {
 }
 
 impl TextFragment for UserListEntry {
-    fn render_highlighted(&self, width: usize, highlight: Option<Highlight>)
+    fn render_highlighted(&self, width: usize, highlight: Option<Highlight>,
+                          _poll_options: Option<&HashSet<usize>>)
                           -> Vec<ColouredString>
     {
         let mut para = Paragraph::new().set_indent(0, 2);
@@ -1244,7 +1266,8 @@ impl Media {
 }
 
 impl TextFragment for Media {
-    fn render_highlighted(&self, width: usize, _highlight: Option<Highlight>)
+    fn render_highlighted(&self, width: usize, _highlight: Option<Highlight>,
+                          _poll_options: Option<&HashSet<usize>>)
                           -> Vec<ColouredString>
     {
         let mut lines: Vec<_> = ColouredString::uniform(&self.url, 'M')
@@ -1436,7 +1459,8 @@ fn test_filestatus_build() {
 }
 
 impl TextFragment for FileStatusLineFinal {
-    fn render_highlighted(&self, width: usize, _highlight: Option<Highlight>)
+    fn render_highlighted(&self, width: usize, _highlight: Option<Highlight>,
+                          _poll_options: Option<&HashSet<usize>>)
                           -> Vec<ColouredString>
     {
         let mut line = ColouredString::plain("");
@@ -1624,7 +1648,8 @@ impl MenuKeypressLine {
 }
 
 impl TextFragment for MenuKeypressLine {
-    fn render_highlighted(&self, width: usize, _highlight: Option<Highlight>)
+    fn render_highlighted(&self, width: usize, _highlight: Option<Highlight>,
+                          _poll_options: Option<&HashSet<usize>>)
                           -> Vec<ColouredString>
     {
         let ourwidth = self.lmaxwid + self.rmaxwid + 3; // " = " in the middle
@@ -1697,7 +1722,8 @@ impl CentredInfoLine {
 }
 
 impl TextFragment for CentredInfoLine {
-    fn render_highlighted(&self, width: usize, _highlight: Option<Highlight>)
+    fn render_highlighted(&self, width: usize, _highlight: Option<Highlight>,
+                          _poll_options: Option<&HashSet<usize>>)
                           -> Vec<ColouredString>
     {
         let twidth = width.saturating_sub(1);
@@ -1709,6 +1735,14 @@ impl TextFragment for CentredInfoLine {
     }
 }
 
+struct Poll {
+    title: Paragraph,
+    options: Vec<(bool, ColouredString)>,
+    id: String,
+    eligible: bool,
+    multiple: bool,
+}
+
 pub struct StatusDisplay {
     sep: SeparatorLine,
     from: UsernameHeader,
@@ -1717,6 +1751,7 @@ pub struct StatusDisplay {
     vis: Option<VisibilityLine>,
     content: Html,
     media: Vec<Media>,
+    poll: Option<Poll>,
     blank: BlankLine,
     id: String,
 }
@@ -1759,6 +1794,59 @@ impl StatusDisplay {
             Media::new(&m.url, desc_ref)
         }).collect();
 
+        let poll = st.poll.map(|poll| {
+            let mut extras = Vec::new();
+            let voters = poll.voters_count.unwrap_or(poll.votes_count);
+            if poll.multiple {
+                extras.push(ColouredString::uniform("multiple choice", 'K'));
+            }
+            if poll.expired {
+                extras.push(ColouredString::uniform("expired", 'H'));
+                extras.push(ColouredString::uniform(
+                    &format!("{} voters", voters), 'H'));
+            } else {
+                if let Some(date) = poll.expires_at {
+                    extras.push(ColouredString::uniform(
+                        &format!("expires {}", format_date(date)), 'H'));
+                } else {
+                    extras.push(ColouredString::uniform("no expiry", 'H'));
+                }
+                extras.push(ColouredString::uniform(
+                    &format!("{} voters so far", voters), 'H'));
+            }
+            let mut desc = ColouredString::uniform("Poll: ", 'H');
+            for (i, extra) in extras.iter().enumerate() {
+                if i > 0 {
+                    desc.push_str(&ColouredString::uniform(", ", 'H').slice());
+                }
+                desc.push_str(&extra.slice());
+            }
+
+            let title = Paragraph::new().set_indent(0, 2).add(&desc);
+
+            let mut options = Vec::new();
+            for (thisindex, opt) in poll.options.iter().enumerate() {
+                let voted = poll.own_votes.as_ref().map_or(false, |votes| {
+                    votes.iter().any(|i| *i == thisindex)
+                });
+                let mut desc = ColouredString::plain(" ");
+                desc.push_str(&ColouredString::plain(&opt.title).slice());
+                if let Some(n) = opt.votes_count {
+                    desc.push_str(&ColouredString::uniform(
+                        &format!(" ({})", n), 'H').slice());
+                }
+                options.push((voted, desc));
+            }
+
+            Poll {
+                title,
+                options,
+                id: poll.id.clone(),
+                eligible: poll.voted != Some(true) && !poll.expired,
+                multiple: poll.multiple,
+            }
+        });
+
         StatusDisplay {
             sep,
             from,
@@ -1767,6 +1855,7 @@ impl StatusDisplay {
             vis,
             content,
             media,
+            poll,
             blank: BlankLine::new(),
             id: st.id,
         }
@@ -1784,7 +1873,8 @@ fn push_fragment_highlighted(lines: &mut Vec<ColouredString>,
 }
 
 impl TextFragment for StatusDisplay {
-    fn render_highlighted(&self, width: usize, highlight: Option<Highlight>)
+    fn render_highlighted(&self, width: usize, highlight: Option<Highlight>,
+                          poll_options_selected: Option<&HashSet<usize>>)
                           -> Vec<ColouredString>
     {
         let mut lines = Vec::new();
@@ -1801,9 +1891,9 @@ impl TextFragment for StatusDisplay {
 
         push_fragment(&mut lines, self.sep.render(width));
         push_fragment(&mut lines, self.from.render_highlighted_update(
-            width, &mut highlight));
+            width, &mut highlight, None));
         push_fragment(&mut lines, self.via.render_highlighted_update(
-            width, &mut highlight));
+            width, &mut highlight, None));
         push_fragment(&mut lines, self.vis.render(width));
         push_fragment(&mut lines, self.irt.render(width));
         push_fragment(&mut lines, self.blank.render(width));
@@ -1817,13 +1907,35 @@ impl TextFragment for StatusDisplay {
             push_fragment_opt_highlight(&mut lines, m.render(width));
             push_fragment_opt_highlight(&mut lines, self.blank.render(width));
         }
+        if let Some(poll) = &self.poll {
+            push_fragment_opt_highlight(&mut lines, poll.title.render(width));
+            for (i, (voted, desc)) in poll.options.iter().enumerate() {
+                let highlighting_this_option =
+                    highlight.consume(HighlightType::PollOption, 1) == Some(0);
+                let voted = poll_options_selected.map_or(
+                    *voted, |opts| opts.contains(&i));
+                let option = Paragraph::new().set_indent(0, 2).add(
+                    &ColouredString::uniform(
+                        if voted {"[X]"} else {"[ ]"}, 'H'))
+                    .add(&desc);
+                let push_fragment_opt_highlight = if highlighting_this_option {
+                    push_fragment_highlighted
+                } else {
+                    push_fragment_opt_highlight
+                };
+                push_fragment_opt_highlight(&mut lines, option.render(width));
+            }
+            push_fragment_opt_highlight(&mut lines, self.blank.render(width));
+        }
 
         lines
     }
 
     fn can_highlight(htype: HighlightType) -> bool where Self : Sized {
-        htype == HighlightType::User || htype == HighlightType::Status ||
-            htype == HighlightType::WholeStatus
+        htype == HighlightType::User ||
+            htype == HighlightType::Status ||
+            htype == HighlightType::WholeStatus ||
+            htype == HighlightType::PollOption
     }
 
     fn count_highlightables(&self, htype: HighlightType) -> usize {
@@ -1834,6 +1946,10 @@ impl TextFragment for StatusDisplay {
             }
             HighlightType::Status => 1,
             HighlightType::WholeStatus => 1,
+
+            HighlightType::PollOption => self.poll.as_ref()
+                .filter(|poll| poll.eligible)
+                .map_or(0, |poll| poll.options.len()),
         }
     }
 
@@ -1855,9 +1971,17 @@ impl TextFragment for StatusDisplay {
             }
             Some(Highlight(HighlightType::WholeStatus, 0)) |
             Some(Highlight(HighlightType::Status, 0)) => Some(self.id.clone()),
+            Some(Highlight(HighlightType::PollOption, i)) => self.poll.as_ref()
+                .filter(|poll| poll.eligible)
+                .filter(|poll| i < poll.options.len())
+                .map(|poll| poll.id.clone()),
             _ => None,
         }
     }
+
+    fn is_multiple_choice_poll(&self) -> bool {
+        self.poll.as_ref().map_or(false, |poll| poll.multiple)
+    }
 }
 
 pub struct DetailedStatusDisplay {
@@ -2019,14 +2143,15 @@ impl DetailedStatusDisplay {
 }
 
 impl TextFragment for DetailedStatusDisplay {
-    fn render_highlighted(&self, width: usize, highlight: Option<Highlight>)
+    fn render_highlighted(&self, width: usize, highlight: Option<Highlight>,
+                          poll_options: Option<&HashSet<usize>>)
                           -> Vec<ColouredString>
     {
         let mut lines = Vec::new();
         let mut highlight = highlight;
 
         push_fragment(&mut lines, self.sd.render_highlighted_update(
-            width, &mut highlight));
+            width, &mut highlight, poll_options));
         push_fragment(&mut lines, self.sep.render(width));
         push_fragment(&mut lines, self.blank.render(width));
 
@@ -2095,7 +2220,8 @@ impl TextFragment for DetailedStatusDisplay {
     }
 
     fn can_highlight(htype: HighlightType) -> bool where Self : Sized {
-        htype == HighlightType::User || htype == HighlightType::Status
+        htype == HighlightType::User || htype == HighlightType::Status ||
+            htype == HighlightType::PollOption
     }
 
     fn count_highlightables(&self, htype: HighlightType) -> usize {
@@ -2148,6 +2274,10 @@ impl TextFragment for DetailedStatusDisplay {
         }
         None
     }
+
+    fn is_multiple_choice_poll(&self) -> bool {
+        self.sd.is_multiple_choice_poll()
+    }
 }
 
 pub struct ExamineUserDisplay {
@@ -2358,7 +2488,8 @@ impl ExamineUserDisplay {
 }
 
 impl TextFragment for ExamineUserDisplay {
-    fn render_highlighted(&self, width: usize, _highlight: Option<Highlight>)
+    fn render_highlighted(&self, width: usize, _highlight: Option<Highlight>,
+                          _poll_options: Option<&HashSet<usize>>)
                           -> Vec<ColouredString>
     {
         let mut lines = Vec::new();
index fce4e209add2776c27a8bf87b03ed1cc1fccc12c..e512e97aa1b084aa5e04d601c84e392da1a5d011 100644 (file)
@@ -165,6 +165,26 @@ pub struct StatusMention {
     pub acct: String,
 }
 
+#[derive(Deserialize, Debug, Clone)]
+pub struct Poll {
+    pub id: String,
+    pub expires_at: Option<DateTime<Utc>>,
+    pub multiple: bool,
+    pub expired: bool,
+    pub votes_count: u64,
+    pub voters_count: Option<u64>, // if !multiple, doesn't need to be separate
+    pub options: Vec<PollOption>,
+    // pub emojis: Vec<Emoji>,
+    pub voted: Option<bool>,
+    pub own_votes: Option<Vec<usize>>,
+}
+
+#[derive(Deserialize, Debug, Clone)]
+pub struct PollOption {
+    pub title: String,
+    pub votes_count: Option<u64>,
+}
+
 #[derive(Deserialize, Debug, Clone)]
 pub struct Status {
     pub id: String,
@@ -187,7 +207,7 @@ pub struct Status {
     pub in_reply_to_id: Option<String>,
     pub in_reply_to_account_id: Option<String>,
     pub reblog: Option<Box<Status>>,
-    // pub poll: Option<Poll>,
+    pub poll: Option<Poll>,
     // pub card: Option<PreviewCard>,
     pub language: Option<String>,
     pub text: Option<String>,