diff --git a/src/utils.rs b/src/utils.rs index 3fbd1f6..1a7e011 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -96,6 +96,61 @@ pub struct Author { pub distinguished: String, } +pub struct Poll { + pub poll_options: Vec, + pub voting_end_timestamp: (String, String), + pub total_vote_count: u64, +} + +impl Poll { + pub fn parse(poll_data: &Value) -> Option { + poll_data.as_object()?; + + let total_vote_count = poll_data["total_vote_count"].as_u64()?; + // voting_end_timestamp is in the format of milliseconds + let voting_end_timestamp = time(poll_data["voting_end_timestamp"].as_f64()? / 1000.0); + let poll_options = PollOption::parse(&poll_data["options"])?; + + Some(Self { + poll_options, + total_vote_count, + voting_end_timestamp, + }) + } + + pub fn most_votes(&self) -> u64 { + self.poll_options.iter().filter_map(|o| o.vote_count).max().unwrap_or(0) + } +} + +pub struct PollOption { + pub id: u64, + pub text: String, + pub vote_count: Option, +} + +impl PollOption { + pub fn parse(options: &Value) -> Option> { + Some( + options + .as_array()? + .iter() + .filter_map(|option| { + // For each poll option + + // we can't just use as_u64() because "id": String("...") and serde would parse it as None + let id = option["id"].as_str()?.parse::().ok()?; + let text = option["text"].as_str()?.to_owned(); + let vote_count = option["vote_count"].as_u64(); + + // Construct PollOption items + Some(Self { id, text, vote_count }) + }) + .collect::>(), + ) + } +} + // Post flags with nsfw and stickied pub struct Flags { pub nsfw: bool, @@ -233,6 +288,7 @@ pub struct Post { pub body: String, pub author: Author, pub permalink: String, + pub poll: Option, pub score: (String, String), pub upvote_ratio: i64, pub post_type: String, @@ -342,6 +398,7 @@ impl Post { stickied: data["stickied"].as_bool().unwrap_or_default() || data["pinned"].as_bool().unwrap_or_default(), }, permalink: val(post, "permalink"), + poll: Poll::parse(&data["poll_data"]), rel_time, created, num_duplicates: post["data"]["num_duplicates"].as_u64().unwrap_or(0), @@ -600,6 +657,8 @@ pub async fn parse_post(post: &serde_json::Value) -> Post { let permalink = val(post, "permalink"); + let poll = Poll::parse(&post["data"]["poll_data"]); + let body = if val(post, "removed_by_category") == "moderator" { format!( "

[removed] — view removed post

", @@ -630,6 +689,7 @@ pub async fn parse_post(post: &serde_json::Value) -> Post { distinguished: val(post, "distinguished"), }, permalink, + poll, score: format_num(score), upvote_ratio: ratio as i64, post_type, @@ -815,20 +875,31 @@ pub fn format_num(num: i64) -> (String, String) { // Parse a relative and absolute time from a UNIX timestamp pub fn time(created: f64) -> (String, String) { let time = OffsetDateTime::from_unix_timestamp(created.round() as i64).unwrap_or(OffsetDateTime::UNIX_EPOCH); - let time_delta = OffsetDateTime::now_utc() - time; + let now = OffsetDateTime::now_utc(); + let min = time.min(now); + let max = time.max(now); + let time_delta = max - min; // If the time difference is more than a month, show full date - let rel_time = if time_delta > Duration::days(30) { + let mut rel_time = if time_delta > Duration::days(30) { time.format(format_description!("[month repr:short] [day] '[year repr:last_two]")).unwrap_or_default() // Otherwise, show relative date/time } else if time_delta.whole_days() > 0 { - format!("{}d ago", time_delta.whole_days()) + format!("{}d", time_delta.whole_days()) } else if time_delta.whole_hours() > 0 { - format!("{}h ago", time_delta.whole_hours()) + format!("{}h", time_delta.whole_hours()) } else { - format!("{}m ago", time_delta.whole_minutes()) + format!("{}m", time_delta.whole_minutes()) }; + if time_delta <= Duration::days(30) { + if now < time { + rel_time += " left"; + } else { + rel_time += " ago"; + } + } + ( rel_time, time diff --git a/static/style.css b/static/style.css index 9683cff..fe3631c 100644 --- a/static/style.css +++ b/static/style.css @@ -761,6 +761,7 @@ a.search_subreddit:hover { "post_score post_title post_thumbnail" 1fr "post_score post_media post_thumbnail" auto "post_score post_body post_thumbnail" auto + "post_score post_poll post_thumbnail" auto "post_score post_notification post_thumbnail" auto "post_score post_footer post_thumbnail" auto / minmax(40px, auto) minmax(0, 1fr) fit-content(min(20%, 152px)); @@ -961,6 +962,44 @@ a.search_subreddit:hover { overflow-wrap: anywhere; } +.post_poll { + grid-area: post_poll; + padding: 5px 15px 5px 12px; +} + +.poll_option { + position: relative; + margin-right: 15px; + margin-top: 14px; + z-index: 0; + display: flex; + align-items: center; +} + +.poll_chart { + padding: 14px 0; + background-color: var(--accent); + opacity: 0.2; + border-radius: 5px; + z-index: -1; + position: absolute; +} + +.poll_option span { + margin-left: 8px; + color: var(--text); +} + +.poll_option span:nth-of-type(1) { + min-width: 10%; + font-weight: bold; +} + +.most_voted { + opacity: 0.45; + width: 100%; +} + /* Used only for text post preview */ .post_preview { -webkit-mask-image: linear-gradient(180deg,#000 60%,transparent);; @@ -1573,6 +1612,7 @@ td, th { "post_title post_title post_thumbnail" 1fr "post_media post_media post_thumbnail" auto "post_body post_body post_thumbnail" auto + "post_poll post_poll post_thumbnail" auto "post_notification post_notification post_thumbnail" auto "post_score post_footer post_thumbnail" auto / auto 1fr fit-content(min(20%, 152px)); @@ -1582,6 +1622,10 @@ td, th { margin: 5px 0px 20px 15px; padding: 0; } + + .post_poll { + padding: 5px 15px 10px 12px; + } .compact .post_score { padding: 0; } diff --git a/templates/utils.html b/templates/utils.html index 3fdd76d..8e77d26 100644 --- a/templates/utils.html +++ b/templates/utils.html @@ -148,6 +148,9 @@
{{ post.body|safe }}
{{ post.score.0 }} Upvotes
+ + {% call poll(post) %} + {%- endmacro %} + +{% macro poll(post) -%} + {% match post.poll %} + {% when Some with (poll) %} + {% let widest = poll.most_votes() %} +
+ {{ poll.total_vote_count }} votes, + {{ poll.voting_end_timestamp.0 }} + {% for option in poll.poll_options %} +
+ {# Posts without vote_count (all open polls) will show up without votes. + This is an issue with Reddit API, it doesn't work on Old Reddit either. #} + {% match option.vote_count %} + {% when Some with (vote_count) %} + {% if vote_count.eq(widest) || widest == 0 %} +
+ {% else %} +
+ {% endif %} + {{ vote_count }} + {% when None %} +
+ + {% endmatch %} + {{ option.text }} +
+ {% endfor %} +
+ {% when None %} + {% endmatch %} +{%- endmacro %}