From e579b97442b569c0d18bcd4ff6239f249cc07e8b Mon Sep 17 00:00:00 2001 From: Daniel Valentine Date: Wed, 9 Nov 2022 09:16:51 -0700 Subject: [PATCH] List post duplicates (resolves #574). --- CREDITS | 4 + Cargo.lock | 49 +++---- Cargo.toml | 10 +- Dockerfile.arm | 9 +- README.md | 17 +-- src/client.rs | 17 ++- src/duplicates.rs | 228 +++++++++++++++++++++++++++++++++ src/main.rs | 6 + src/post.rs | 90 +------------ src/search.rs | 2 +- src/subreddit.rs | 2 +- src/user.rs | 2 +- src/utils.rs | 113 ++++++++++++++-- static/style.css | 39 ++++++ static/themes/gruvboxdark.css | 13 ++ static/themes/gruvboxlight.css | 13 ++ templates/base.html | 2 +- templates/duplicates.html | 107 ++++++++++++++++ templates/post.html | 113 ++-------------- templates/search.html | 6 +- templates/utils.html | 103 +++++++++++++++ 21 files changed, 695 insertions(+), 250 deletions(-) create mode 100644 src/duplicates.rs create mode 100644 static/themes/gruvboxdark.css create mode 100644 static/themes/gruvboxlight.css create mode 100644 templates/duplicates.html diff --git a/CREDITS b/CREDITS index 2bfb498..2def374 100644 --- a/CREDITS +++ b/CREDITS @@ -36,7 +36,10 @@ Kazi Kieran <42723993+EnderDev@users.noreply.github.com> Kieran Kyle Roth +laazyCmd Laurențiu Nicola +Lena <102762572+MarshDeer@users.noreply.github.com> +Macic <46872282+Macic-Dev@users.noreply.github.com> Mario A <10923513+Midblyte@users.noreply.github.com> Matthew Crossman Matthew E @@ -47,6 +50,7 @@ Nathan Moos Nicholas Christopher Nick Lowery Nico +NKIPSC <15067635+NKIPSC@users.noreply.github.com> obeho <71698631+obeho@users.noreply.github.com> obscurity Om G <34579088+OxyMagnesium@users.noreply.github.com> diff --git a/Cargo.lock b/Cargo.lock index fd537f6..cdfffb3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -211,9 +211,9 @@ checksum = "3a4f925191b4367301851c6d99b09890311d74b0d43f274c0b34c86d308a3663" [[package]] name = "cc" -version = "1.0.74" +version = "1.0.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "581f5dba903aac52ea3feb5ec4810848460ee833876f1f9b0fdeab1f19091574" +checksum = "76a284da2e6fe2092f2353e51713435363112dfd60030e22add80be333fb928f" [[package]] name = "cfg-if" @@ -223,9 +223,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "clap" -version = "4.0.18" +version = "4.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335867764ed2de42325fafe6d18b8af74ba97ee0c590fa016f157535b42ab04b" +checksum = "60494cedb60cb47462c0ff7be53de32c0e42a6fc2c772184554fa12bd9489c03" dependencies = [ "bitflags", "clap_lex", @@ -543,9 +543,9 @@ checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" [[package]] name = "hyper" -version = "0.14.22" +version = "0.14.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abfba89e19b959ca163c7752ba59d737c1ceea53a5d31a149c805446fc958064" +checksum = "034711faac9d2166cb1baf1a2fb0b60b1f277f8492fd72176c17f3515e1abd3c" dependencies = [ "bytes", "futures-channel", @@ -664,7 +664,7 @@ dependencies = [ [[package]] name = "libreddit" -version = "0.23.2" +version = "0.24.3" dependencies = [ "askama", "async-recursion", @@ -777,15 +777,6 @@ dependencies = [ "libc", ] -[[package]] -name = "num_threads" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2819ce041d2ee131036f4fc9d6ae7ae125a3a40e97ba64d04fe799ad9dabbb44" -dependencies = [ - "libc", -] - [[package]] name = "once_cell" version = "1.16.0" @@ -800,9 +791,9 @@ checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" [[package]] name = "os_str_bytes" -version = "6.3.1" +version = "6.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3baf96e39c5359d2eb0dd6ccb42c62b91d9678aa68160d261b9e0ccbf9e9dea9" +checksum = "7b5bf27447411e9ee3ff51186bf7a08e16c341efdde93f4d823e8844429bed7e" [[package]] name = "parking" @@ -853,9 +844,9 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "ppv-lite86" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb9f9e6e233e5c4a35559a617bf40a4ec447db2e84c20b55a6f83167b7e57872" +checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" [[package]] name = "proc-macro2" @@ -916,9 +907,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.6.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c4eb3267174b8c6c2f654116623910a0fef09c4753f8dd83db29c48a0df988b" +checksum = "e076559ef8e241f2ae3479e36f97bd5741c0330689e217ad51ce2c76808b868a" dependencies = [ "aho-corasick", "memchr", @@ -927,9 +918,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.6.27" +version = "0.6.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3f87b73ce11b1619a3c6332f45341e0047173771e8b8b73f87bfeefb7b56244" +checksum = "456c603be3e8d448b072f410900c09faf164fbce2d480456f50eea6e25f9c848" [[package]] name = "ring" @@ -1211,13 +1202,11 @@ dependencies = [ [[package]] name = "time" -version = "0.3.16" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fab5c8b9980850e06d92ddbe3ab839c062c801f3927c0fb8abd6fc8e918fbca" +checksum = "a561bf4617eebd33bca6434b988f39ed798e527f51a1e797d0ee4f61c0a38376" dependencies = [ "itoa", - "libc", - "num_threads", "serde", "time-core", "time-macros", @@ -1231,9 +1220,9 @@ checksum = "2e153e1f1acaef8acc537e68b44906d2db6436e2b35ac2c6b42640fff91f00fd" [[package]] name = "time-macros" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bb801831d812c562ae7d2bfb531f26e66e4e1f6b17307ba4149c5064710e5b" +checksum = "d967f99f534ca7e495c575c62638eebc2898a8c84c119b89e250477bc4ba16b2" dependencies = [ "time-core", ] diff --git a/Cargo.toml b/Cargo.toml index da1ee8a..4a55d4c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ name = "libreddit" description = " Alternative private front-end to Reddit" license = "AGPL-3.0" repository = "https://github.com/spikecodes/libreddit" -version = "0.23.2" +version = "0.24.3" authors = ["spikecodes <19519553+spikecodes@users.noreply.github.com>"] edition = "2021" @@ -11,18 +11,18 @@ edition = "2021" askama = { version = "0.11.1", default-features = false } async-recursion = "1.0.0" cached = "0.40.0" -clap = { version = "4.0.18", default-features = false, features = ["std"] } -regex = "1.6.0" +clap = { version = "4.0.24", default-features = false, features = ["std"] } +regex = "1.7.0" serde = { version = "1.0.147", features = ["derive"] } cookie = "0.16.1" futures-lite = "1.12.0" -hyper = { version = "0.14.22", features = ["full"] } +hyper = { version = "0.14.23", features = ["full"] } hyper-rustls = "0.23.0" percent-encoding = "2.2.0" route-recognizer = "0.3.1" serde_json = "1.0.87" tokio = { version = "1.21.2", features = ["full"] } -time = "0.3.16" +time = "0.3.17" url = "2.3.1" rust-embed = { version = "6.4.2", features = ["include-exclude"] } libflate = "1.2.0" diff --git a/Dockerfile.arm b/Dockerfile.arm index 93703d0..098bf13 100644 --- a/Dockerfile.arm +++ b/Dockerfile.arm @@ -3,13 +3,18 @@ #################################################################################################### FROM rust:alpine AS builder -RUN apk add --no-cache g++ +RUN apk add --no-cache g++ git WORKDIR /usr/src/libreddit COPY . . -RUN cargo install --path . +# net.git-fetch-with-cli is specified in order to prevent a potential OOM kill +# in low memory environments. See: +# https://users.rust-lang.org/t/cargo-uses-too-much-memory-being-run-in-qemu/76531 +# This is tracked under issue #641. This also requires us to install git in the +# builder. +RUN cargo install --config net.git-fetch-with-cli=true --path . #################################################################################################### ## Final image diff --git a/README.md b/README.md index 9e23c8f..ed7aa30 100644 --- a/README.md +++ b/README.md @@ -69,15 +69,15 @@ This section outlines how Libreddit compares to Reddit. ## Speed -Lasted tested Jan 17, 2021. +Lasted tested Nov 11, 2022. -Results from Google Lighthouse ([Libreddit Report](https://lighthouse-dot-webdotdevsite.appspot.com/lh/html?url=https%3A%2F%2Flibredd.it), [Reddit Report](https://lighthouse-dot-webdotdevsite.appspot.com/lh/html?url=https%3A%2F%2Fwww.reddit.com%2F)). +Results from Google PageSpeed Insights ([Libreddit Report](https://pagespeed.web.dev/report?url=https%3A%2F%2Flibreddit.spike.codes%2F), [Reddit Report](https://pagespeed.web.dev/report?url=https://www.reddit.com)). -| | Libreddit | Reddit | -|------------------------|---------------|------------| -| Requests | 20 | 70 | -| Resource Size (card ui)| 1,224 KiB | 1,690 KiB | -| Time to Interactive | **1.5 s** | **11.2 s** | +| | Libreddit | Reddit | +|------------------------|-------------|-----------| +| Requests | 60 | 83 | +| Speed Index | 2.0s | 10.4s | +| Time to Interactive | **2.8s** | **12.4s** | ## Privacy @@ -188,13 +188,14 @@ Assign a default value for each setting by passing environment variables to Libr | Name | Possible values | Default value | |-------------------------|-----------------------------------------------------------------------------------------------------|---------------| -| `THEME` | `["system", "light", "dark", "black", "dracula", "nord", "laserwave", "violet", "gold", "rosebox"]` | `system` | +| `THEME` | `["system", "light", "dark", "black", "dracula", "nord", "laserwave", "violet", "gold", "rosebox", "gruvboxdark", "gruvboxlight"]` | `system` | | `FRONT_PAGE` | `["default", "popular", "all"]` | `default` | | `LAYOUT` | `["card", "clean", "compact"]` | `card` | | `WIDE` | `["on", "off"]` | `off` | | `POST_SORT` | `["hot", "new", "top", "rising", "controversial"]` | `hot` | | `COMMENT_SORT` | `["confidence", "top", "new", "controversial", "old"]` | `confidence` | | `SHOW_NSFW` | `["on", "off"]` | `off` | +| `BLUR_NSFW` | `["on", "off"]` | `off` | | `USE_HLS` | `["on", "off"]` | `off` | | `HIDE_HLS_NOTIFICATION` | `["on", "off"]` | `off` | | `AUTOPLAY_VIDEOS` | `["on", "off"]` | `off` | diff --git a/src/client.rs b/src/client.rs index da78304..f577f95 100644 --- a/src/client.rs +++ b/src/client.rs @@ -158,10 +158,21 @@ fn request(method: &'static Method, path: String, redirect: bool, quarantine: bo method, response .headers() - .get("Location") + .get(header::LOCATION) .map(|val| { - let new_url = percent_encode(val.as_bytes(), CONTROLS).to_string(); - format!("{}{}raw_json=1", new_url, if new_url.contains('?') { "&" } else { "?" }) + // We need to make adjustments to the URI + // we get back from Reddit. Namely, we + // must: + // + // 1. Remove the authority (e.g. + // https://www.reddit.com) that may be + // present, so that we recurse on the + // path (and query parameters) as + // required. + // + // 2. Percent-encode the path. + let new_path = percent_encode(val.as_bytes(), CONTROLS).to_string().trim_start_matches(REDDIT_URL_BASE).to_string(); + format!("{}{}raw_json=1", new_path, if new_path.contains('?') { "&" } else { "?" }) }) .unwrap_or_default() .to_string(), diff --git a/src/duplicates.rs b/src/duplicates.rs new file mode 100644 index 0000000..6a64fc8 --- /dev/null +++ b/src/duplicates.rs @@ -0,0 +1,228 @@ +// Handler for post duplicates. + +use crate::client::json; +use crate::server::RequestExt; +use crate::subreddit::{can_access_quarantine, quarantine}; +use crate::utils::{error, filter_posts, get_filters, parse_post, template, Post, Preferences}; + +use askama::Template; +use hyper::{Body, Request, Response}; +use serde_json::Value; +use std::borrow::ToOwned; +use std::collections::HashSet; +use std::vec::Vec; + +/// DuplicatesParams contains the parameters in the URL. +struct DuplicatesParams { + before: String, + after: String, + sort: String, +} + +/// DuplicatesTemplate defines an Askama template for rendering duplicate +/// posts. +#[derive(Template)] +#[template(path = "duplicates.html")] +struct DuplicatesTemplate { + /// params contains the relevant request parameters. + params: DuplicatesParams, + + /// post is the post whose ID is specified in the reqeust URL. Note that + /// this is not necessarily the "original" post. + post: Post, + + /// duplicates is the list of posts that, per Reddit, are duplicates of + /// Post above. + duplicates: Vec, + + /// prefs are the user preferences. + prefs: Preferences, + + /// url is the request URL. + url: String, + + /// num_posts_filtered counts how many posts were filtered from the + /// duplicates list. + num_posts_filtered: u64, + + /// all_posts_filtered is true if every duplicate was filtered. This is an + /// edge case but can still happen. + all_posts_filtered: bool, +} + +/// Make the GET request to Reddit. It assumes `req` is the appropriate Reddit +/// REST endpoint for enumerating post duplicates. +pub async fn item(req: Request) -> Result, String> { + let path: String = format!("{}.json?{}&raw_json=1", req.uri().path(), req.uri().query().unwrap_or_default()); + let sub = req.param("sub").unwrap_or_default(); + let quarantined = can_access_quarantine(&req, &sub); + + // Log the request in debugging mode + #[cfg(debug_assertions)] + dbg!(req.param("id").unwrap_or_default()); + + // Send the GET, and await JSON. + match json(path, quarantined).await { + // Process response JSON. + Ok(response) => { + let filters = get_filters(&req); + let post = parse_post(&response[0]["data"]["children"][0]).await; + let (duplicates, num_posts_filtered, all_posts_filtered) = parse_duplicates(&response[1], &filters).await; + + // These are the values for the "before=", "after=", and "sort=" + // query params, respectively. + let mut before: String = String::new(); + let mut after: String = String::new(); + let mut sort: String = String::new(); + + // FIXME: We have to perform a kludge to work around a Reddit API + // bug. + // + // The JSON object in "data" will never contain a "before" value so + // it is impossible to use it to determine our position in a + // listing. We'll make do by getting the ID of the first post in + // the listing, setting that as our "before" value, and ask Reddit + // to give us a batch of duplicate posts up to that post. + // + // Likewise, if we provide a "before" request in the GET, the + // result won't have an "after" in the JSON, in addition to missing + // the "before." So we will have to use the final post in the list + // of duplicates. + // + // That being said, we'll also need to capture the value of the + // "sort=" parameter as well, so we will need to inspect the + // query key-value pairs anyway. + let l = duplicates.len(); + if l > 0 { + // This gets set to true if "before=" is one of the GET params. + let mut have_before: bool = false; + + // This gets set to true if "after=" is one of the GET params. + let mut have_after: bool = false; + + // Inspect the query key-value pairs. We will need to record + // the value of "sort=", along with checking to see if either + // one of "before=" or "after=" are given. + // + // If we're in the middle of the batch (evidenced by the + // presence of a "before=" or "after=" parameter in the GET), + // then use the first post as the "before" reference. + // + // We'll do this iteratively. Better than with .map_or() + // since a closure will continue to operate on remaining + // elements even after we've determined one of "before=" or + // "after=" (or both) are in the GET request. + // + // In practice, here should only ever be one of "before=" or + // "after=" and never both. + let query_str = req.uri().query().unwrap_or_default().to_string(); + + if !query_str.is_empty() { + for param in query_str.split('&') { + let kv: Vec<&str> = param.split('=').collect(); + if kv.len() < 2 { + // Reject invalid query parameter. + continue; + } + + let key: &str = kv[0]; + match key { + "before" => have_before = true, + "after" => have_after = true, + "sort" => { + let val: &str = kv[1]; + match val { + "new" | "num_comments" => sort = val.to_string(), + _ => {} + } + } + _ => {} + } + } + } + + if have_after { + before = "t3_".to_owned(); + before.push_str(&duplicates[0].id); + } + + // Address potentially missing "after". If "before=" is in the + // GET, then "after" will be null in the JSON (see FIXME + // above). + if have_before { + // The next batch will need to start from one after the + // last post in the current batch. + after = "t3_".to_owned(); + after.push_str(&duplicates[l - 1].id); + + // Here is where things get terrible. Notice that we + // haven't set `before`. In order to do so, we will + // need to know if there is a batch that exists before + // this one, and doing so requires actually fetching the + // previous batch. In other words, we have to do yet one + // more GET to Reddit. There is no other way to determine + // whether or not to define `before`. + // + // We'll mitigate that by requesting at most one duplicate. + let new_path: String = format!( + "{}.json?before=t3_{}&sort={}&limit=1&raw_json=1", + req.uri().path(), + &duplicates[0].id, + if sort.is_empty() { "num_comments".to_string() } else { sort.clone() } + ); + match json(new_path, true).await { + Ok(response) => { + if !response[1]["data"]["children"].as_array().unwrap_or(&Vec::new()).is_empty() { + before = "t3_".to_owned(); + before.push_str(&duplicates[0].id); + } + } + Err(msg) => { + // Abort entirely if we couldn't get the previous + // batch. + return error(req, msg).await; + } + } + } else { + after = response[1]["data"]["after"].as_str().unwrap_or_default().to_string(); + } + } + let url = req.uri().to_string(); + + template(DuplicatesTemplate { + params: DuplicatesParams { before, after, sort }, + post, + duplicates, + prefs: Preferences::new(req), + url, + num_posts_filtered, + all_posts_filtered, + }) + } + + // Process error. + Err(msg) => { + if msg == "quarantined" { + let sub = req.param("sub").unwrap_or_default(); + quarantine(req, sub) + } else { + error(req, msg).await + } + } + } +} + +// DUPLICATES +async fn parse_duplicates(json: &serde_json::Value, filters: &HashSet) -> (Vec, u64, bool) { + let post_duplicates: &Vec = &json["data"]["children"].as_array().map_or(Vec::new(), ToOwned::to_owned); + let mut duplicates: Vec = Vec::new(); + + // Process each post and place them in the Vec. + for val in post_duplicates.iter() { + let post: Post = parse_post(val).await; + duplicates.push(post); + } + + let (num_posts_filtered, all_posts_filtered) = filter_posts(&mut duplicates, filters); + (duplicates, num_posts_filtered, all_posts_filtered) +} diff --git a/src/main.rs b/src/main.rs index 8592e3c..3b45bd2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ #![allow(clippy::cmp_owned)] // Reference local files +mod duplicates; mod post; mod search; mod settings; @@ -244,6 +245,11 @@ async fn main() { app.at("/comments/:id/:title").get(|r| post::item(r).boxed()); app.at("/comments/:id/:title/:comment_id").get(|r| post::item(r).boxed()); + app.at("/r/:sub/duplicates/:id").get(|r| duplicates::item(r).boxed()); + app.at("/r/:sub/duplicates/:id/:title").get(|r| duplicates::item(r).boxed()); + app.at("/duplicates/:id").get(|r| duplicates::item(r).boxed()); + app.at("/duplicates/:id/:title").get(|r| duplicates::item(r).boxed()); + app.at("/r/:sub/search").get(|r| search::find(r).boxed()); app diff --git a/src/post.rs b/src/post.rs index 5f3142a..e467fe7 100644 --- a/src/post.rs +++ b/src/post.rs @@ -3,7 +3,7 @@ use crate::client::json; use crate::server::RequestExt; use crate::subreddit::{can_access_quarantine, quarantine}; use crate::utils::{ - error, format_num, format_url, get_filters, param, rewrite_urls, setting, template, time, val, Author, Awards, Comment, Flags, Flair, FlairPart, Media, Post, Preferences, + error, format_num, get_filters, param, parse_post, rewrite_urls, setting, template, time, val, Author, Awards, Comment, Flair, FlairPart, Post, Preferences, }; use hyper::{Body, Request, Response}; @@ -54,7 +54,7 @@ pub async fn item(req: Request) -> Result, String> { // Otherwise, grab the JSON output from the request Ok(response) => { // Parse the JSON into Post and Comment structs - let post = parse_post(&response[0]).await; + let post = parse_post(&response[0]["data"]["children"][0]).await; let comments = parse_comments(&response[1], &post.permalink, &post.author.name, highlighted_comment, &get_filters(&req)); let url = req.uri().to_string(); @@ -80,92 +80,6 @@ pub async fn item(req: Request) -> Result, String> { } } -// POSTS -async fn parse_post(json: &serde_json::Value) -> Post { - // Retrieve post (as opposed to comments) from JSON - let post: &serde_json::Value = &json["data"]["children"][0]; - - // Grab UTC time as unix timestamp - let (rel_time, created) = time(post["data"]["created_utc"].as_f64().unwrap_or_default()); - // Parse post score and upvote ratio - let score = post["data"]["score"].as_i64().unwrap_or_default(); - let ratio: f64 = post["data"]["upvote_ratio"].as_f64().unwrap_or(1.0) * 100.0; - - // Determine the type of media along with the media URL - let (post_type, media, gallery) = Media::parse(&post["data"]).await; - - let awards: Awards = Awards::parse(&post["data"]["all_awardings"]); - - let permalink = val(post, "permalink"); - - let body = if val(post, "removed_by_category") == "moderator" { - format!( - "

[removed] — view removed post

", - permalink - ) - } else { - rewrite_urls(&val(post, "selftext_html")) - }; - - // Build a post using data parsed from Reddit post API - Post { - id: val(post, "id"), - title: val(post, "title"), - community: val(post, "subreddit"), - body, - author: Author { - name: val(post, "author"), - flair: Flair { - flair_parts: FlairPart::parse( - post["data"]["author_flair_type"].as_str().unwrap_or_default(), - post["data"]["author_flair_richtext"].as_array(), - post["data"]["author_flair_text"].as_str(), - ), - text: val(post, "link_flair_text"), - background_color: val(post, "author_flair_background_color"), - foreground_color: val(post, "author_flair_text_color"), - }, - distinguished: val(post, "distinguished"), - }, - permalink, - score: format_num(score), - upvote_ratio: ratio as i64, - post_type, - media, - thumbnail: Media { - url: format_url(val(post, "thumbnail").as_str()), - alt_url: String::new(), - width: post["data"]["thumbnail_width"].as_i64().unwrap_or_default(), - height: post["data"]["thumbnail_height"].as_i64().unwrap_or_default(), - poster: "".to_string(), - }, - flair: Flair { - flair_parts: FlairPart::parse( - post["data"]["link_flair_type"].as_str().unwrap_or_default(), - post["data"]["link_flair_richtext"].as_array(), - post["data"]["link_flair_text"].as_str(), - ), - text: val(post, "link_flair_text"), - background_color: val(post, "link_flair_background_color"), - foreground_color: if val(post, "link_flair_text_color") == "dark" { - "black".to_string() - } else { - "white".to_string() - }, - }, - flags: Flags { - nsfw: post["data"]["over_18"].as_bool().unwrap_or(false), - stickied: post["data"]["stickied"].as_bool().unwrap_or(false), - }, - domain: val(post, "domain"), - rel_time, - created, - comments: format_num(post["data"]["num_comments"].as_i64().unwrap_or_default()), - gallery, - awards, - } -} - // COMMENTS fn parse_comments(json: &serde_json::Value, post_link: &str, post_author: &str, highlighted_comment: &str, filters: &HashSet) -> Vec { // Parse the comment JSON into a Vector of Comments diff --git a/src/search.rs b/src/search.rs index 4b13594..0a62b06 100644 --- a/src/search.rs +++ b/src/search.rs @@ -107,7 +107,7 @@ pub async fn find(req: Request) -> Result, String> { } else { match Post::fetch(&path, quarantined).await { Ok((mut posts, after)) => { - let all_posts_filtered = filter_posts(&mut posts, &filters); + let (_, all_posts_filtered) = filter_posts(&mut posts, &filters); let all_posts_hidden_nsfw = posts.iter().all(|p| p.flags.nsfw) && setting(&req, "show_nsfw") != "on"; template(SearchTemplate { posts, diff --git a/src/subreddit.rs b/src/subreddit.rs index 75e271a..4aff027 100644 --- a/src/subreddit.rs +++ b/src/subreddit.rs @@ -118,7 +118,7 @@ pub async fn community(req: Request) -> Result, String> { } else { match Post::fetch(&path, quarantined).await { Ok((mut posts, after)) => { - let all_posts_filtered = filter_posts(&mut posts, &filters); + let (_, all_posts_filtered) = filter_posts(&mut posts, &filters); let all_posts_hidden_nsfw = posts.iter().all(|p| p.flags.nsfw) && setting(&req, "show_nsfw") != "on"; template(SubredditTemplate { sub, diff --git a/src/user.rs b/src/user.rs index 0d03f2e..8c0540c 100644 --- a/src/user.rs +++ b/src/user.rs @@ -66,7 +66,7 @@ pub async fn profile(req: Request) -> Result, String> { // Request user posts/comments from Reddit match Post::fetch(&path, false).await { Ok((mut posts, after)) => { - let all_posts_filtered = filter_posts(&mut posts, &filters); + let (_, all_posts_filtered) = filter_posts(&mut posts, &filters); let all_posts_hidden_nsfw = posts.iter().all(|p| p.flags.nsfw) && setting(&req, "show_nsfw") != "on"; template(UserTemplate { user, diff --git a/src/utils.rs b/src/utils.rs index 0f15537..06237e9 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -225,6 +225,7 @@ pub struct Post { pub domain: String, pub rel_time: String, pub created: String, + pub num_duplicates: u64, pub comments: (String, String), pub gallery: Vec, pub awards: Awards, @@ -319,11 +320,12 @@ impl Post { }, flags: Flags { nsfw: data["over_18"].as_bool().unwrap_or_default(), - stickied: data["stickied"].as_bool().unwrap_or_default(), + stickied: data["stickied"].as_bool().unwrap_or_default() || data["pinned"].as_bool().unwrap_or_default(), }, permalink: val(post, "permalink"), rel_time, created, + num_duplicates: post["data"]["num_duplicates"].as_u64().unwrap_or(0), comments: format_num(data["num_comments"].as_i64().unwrap_or_default()), gallery, awards, @@ -511,15 +513,110 @@ pub fn get_filters(req: &Request) -> HashSet { setting(req, "filters").split('+').map(String::from).filter(|s| !s.is_empty()).collect::>() } -/// Filters a `Vec` by the given `HashSet` of filters (each filter being a subreddit name or a user name). If a -/// `Post`'s subreddit or author is found in the filters, it is removed. Returns `true` if _all_ posts were filtered -/// out, or `false` otherwise. -pub fn filter_posts(posts: &mut Vec, filters: &HashSet) -> bool { +/// Filters a `Vec` by the given `HashSet` of filters (each filter being +/// a subreddit name or a user name). If a `Post`'s subreddit or author is +/// found in the filters, it is removed. +/// +/// The first value of the return tuple is the number of posts filtered. The +/// second return value is `true` if all posts were filtered. +pub fn filter_posts(posts: &mut Vec, filters: &HashSet) -> (u64, bool) { + // This is the length of the Vec prior to applying the filter. + let lb: u64 = posts.len().try_into().unwrap_or(0); + if posts.is_empty() { - false + (0, false) } else { - posts.retain(|p| !filters.contains(&p.community) && !filters.contains(&["u_", &p.author.name].concat())); - posts.is_empty() + posts.retain(|p| !(filters.contains(&p.community) || filters.contains(&["u_", &p.author.name].concat()))); + + // Get the length of the Vec after applying the filter. + // If lb > la, then at least one post was removed. + let la: u64 = posts.len().try_into().unwrap_or(0); + + (lb - la, posts.is_empty()) + } +} + +/// Creates a [`Post`] from a provided JSON. +pub async fn parse_post(post: &serde_json::Value) -> Post { + // Grab UTC time as unix timestamp + let (rel_time, created) = time(post["data"]["created_utc"].as_f64().unwrap_or_default()); + // Parse post score and upvote ratio + let score = post["data"]["score"].as_i64().unwrap_or_default(); + let ratio: f64 = post["data"]["upvote_ratio"].as_f64().unwrap_or(1.0) * 100.0; + + // Determine the type of media along with the media URL + let (post_type, media, gallery) = Media::parse(&post["data"]).await; + + let awards: Awards = Awards::parse(&post["data"]["all_awardings"]); + + let permalink = val(post, "permalink"); + + let body = if val(post, "removed_by_category") == "moderator" { + format!( + "

[removed] — view removed post

", + permalink + ) + } else { + rewrite_urls(&val(post, "selftext_html")) + }; + + // Build a post using data parsed from Reddit post API + Post { + id: val(post, "id"), + title: val(post, "title"), + community: val(post, "subreddit"), + body, + author: Author { + name: val(post, "author"), + flair: Flair { + flair_parts: FlairPart::parse( + post["data"]["author_flair_type"].as_str().unwrap_or_default(), + post["data"]["author_flair_richtext"].as_array(), + post["data"]["author_flair_text"].as_str(), + ), + text: val(post, "link_flair_text"), + background_color: val(post, "author_flair_background_color"), + foreground_color: val(post, "author_flair_text_color"), + }, + distinguished: val(post, "distinguished"), + }, + permalink, + score: format_num(score), + upvote_ratio: ratio as i64, + post_type, + media, + thumbnail: Media { + url: format_url(val(post, "thumbnail").as_str()), + alt_url: String::new(), + width: post["data"]["thumbnail_width"].as_i64().unwrap_or_default(), + height: post["data"]["thumbnail_height"].as_i64().unwrap_or_default(), + poster: String::new(), + }, + flair: Flair { + flair_parts: FlairPart::parse( + post["data"]["link_flair_type"].as_str().unwrap_or_default(), + post["data"]["link_flair_richtext"].as_array(), + post["data"]["link_flair_text"].as_str(), + ), + text: val(post, "link_flair_text"), + background_color: val(post, "link_flair_background_color"), + foreground_color: if val(post, "link_flair_text_color") == "dark" { + "black".to_string() + } else { + "white".to_string() + }, + }, + flags: Flags { + nsfw: post["data"]["over_18"].as_bool().unwrap_or_default(), + stickied: post["data"]["stickied"].as_bool().unwrap_or_default() || post["data"]["pinned"].as_bool().unwrap_or(false), + }, + domain: val(post, "domain"), + rel_time, + created, + num_duplicates: post["data"]["num_duplicates"].as_u64().unwrap_or(0), + comments: format_num(post["data"]["num_comments"].as_i64().unwrap_or_default()), + gallery, + awards, } } diff --git a/static/style.css b/static/style.css index ba07fc2..500646d 100644 --- a/static/style.css +++ b/static/style.css @@ -154,6 +154,7 @@ main { } #column_one { + width: 100%; max-width: 750px; border-radius: 5px; overflow: inherit; @@ -834,6 +835,16 @@ a.search_subreddit:hover { margin-right: 15px; } +#post_links > li.desktop_item { + display: auto; +} + +@media screen and (min-width: 480px) { + #post_links > li.mobile_item { + display: none; + } +} + .post_thumbnail { border-radius: 5px; border: var(--panel-border); @@ -1272,6 +1283,29 @@ td, th { #error h3 { opacity: 0.85; } #error a { color: var(--accent); } +/* Messages */ + +#duplicates_msg h3 { + display: inline-block; + margin-top: 10px; + margin-bottom: 10px; + text-align: center; + width: 100%; +} + +/* Warnings */ + +.listing_warn { + display: inline-block; + margin: 10px; + text-align: center; + width: 100%; +} + +.listing_warn a { + color: var(--accent); +} + /* Mobile */ @media screen and (max-width: 800px) { @@ -1372,4 +1406,9 @@ td, th { padding: 7px 0px; margin-right: -5px; } + + #post_links > li { margin-right: 10px } + #post_links > li.desktop_item { display: none } + #post_links > li.mobile_item { display: auto } + .post_footer > p > span#upvoted { display: none } } diff --git a/static/themes/gruvboxdark.css b/static/themes/gruvboxdark.css new file mode 100644 index 0000000..fb9e3ee --- /dev/null +++ b/static/themes/gruvboxdark.css @@ -0,0 +1,13 @@ +/* Gruvbox-Dark theme setting */ +.gruvboxdark { + --accent: #8ec07c; + --green: #b8bb26; + --text: #ebdbb2; + --foreground: #3c3836; + --background: #282828; + --outside: #3c3836; + --post: #3c3836; + --panel-border: 1px solid #504945; + --highlighted: #282828; + --shadow: 0 1px 3px rgba(0, 0, 0, 0.5); +} diff --git a/static/themes/gruvboxlight.css b/static/themes/gruvboxlight.css new file mode 100644 index 0000000..d39f8e9 --- /dev/null +++ b/static/themes/gruvboxlight.css @@ -0,0 +1,13 @@ +/* Gruvbox-Light theme setting */ +.gruvboxlight { + --accent: #427b58; + --green: #79740e; + --text: #3c3836; + --foreground: #ebdbb2; + --background: #fbf1c7; + --outside: #ebdbb2; + --post: #ebdbb2; + --panel-border: 1px solid #d5c4a1; + --highlighted: #fbf1c7; + --shadow: 0 1px 3px rgba(0, 0, 0, 0.25); +} diff --git a/templates/base.html b/templates/base.html index f30aaaf..e9b51ec 100644 --- a/templates/base.html +++ b/templates/base.html @@ -19,7 +19,7 @@ - + {% endblock %} + {% call utils::post(post) %} + + + {% if post.num_duplicates == 0 %} + (No duplicates found) + {% else if post.flags.nsfw && prefs.show_nsfw != "on" %} + (Enable "Show NSFW posts" in settings to show duplicates) + {% else %} +

Duplicates

+ {% if num_posts_filtered > 0 %} + + {% if all_posts_filtered %} + (All posts have been filtered) + {% else %} + (Some posts have been filtered) + {% endif %} + + {% endif %} + + + +
+ {% for post in duplicates -%} + {# TODO: utils::post should be reworked to permit a truncated display of a post as below #} + {% if !(post.flags.nsfw) || prefs.show_nsfw == "on" %} +
+

+ {% let community -%} + {% if post.community.starts_with("u_") -%} + {% let community = format!("u/{}", &post.community[2..]) -%} + {% else -%} + {% let community = format!("r/{}", post.community) -%} + {% endif -%} + {{ post.community }} + + + + {{ post.rel_time }} + {% if !post.awards.is_empty() %} + {% for award in post.awards.clone() %} + + {{ award.name }} + + {% endfor %} + {% endif %} +

+

+ {% if post.flair.flair_parts.len() > 0 %} + {% call utils::render_flair(post.flair.flair_parts) %} + {% endif %} + {{ post.title }}{% if post.flags.nsfw %} NSFW{% endif %} +

+ +
{{ post.score.0 }} Upvotes
+ + +
+ {% endif %} + {%- endfor %} +
+ +
+ {% if params.before != "" %} + PREV + {% endif %} + + {% if params.after != "" %} + NEXT + {% endif %} +
+ {% endif %} + +{% endblock %} \ No newline at end of file diff --git a/templates/post.html b/templates/post.html index 8f12131..d69644b 100644 --- a/templates/post.html +++ b/templates/post.html @@ -13,16 +13,25 @@ - - - - + + {% if post.post_type == "image" %} + + + + {% else if post.post_type == "video" || post.post_type == "gif" %} + + + + + {% else %} + + {% endif %} {% endblock %} {% block subscriptions %} @@ -31,101 +40,7 @@ {% block content %}
- - -
-

- r/{{ post.community }} - - - {% if post.author.flair.flair_parts.len() > 0 %} - {% call utils::render_flair(post.author.flair.flair_parts) %} - {% endif %} - - {{ post.rel_time }} - {% if !post.awards.is_empty() %} - - - {% for award in post.awards.clone() %} - - {{ award.name }} - {{ award.count }} - - {% endfor %} - - {% endif %} -

-

- {{ post.title }} - {% if post.flair.flair_parts.len() > 0 %} - {% call utils::render_flair(post.flair.flair_parts) %} - {% endif %} - {% if post.flags.nsfw %} NSFW{% endif %} -

- - - - {% if post.post_type == "image" %} -
- - - - - Post image - - - -
- {% else if post.post_type == "video" || post.post_type == "gif" %} - {% if prefs.use_hls == "on" && !post.media.alt_url.is_empty() %} - -
- -
- - {% else %} -
- -
- {% call utils::render_hls_notification(post.permalink[1..]) %} - {% endif %} - {% else if post.post_type == "gallery" %} - - {% else if post.post_type == "link" %} - {{ post.media.url }} - {% endif %} - - -
{{ post.body|safe }}
-
{{ post.score.0 }} Upvotes
- -
+ {% call utils::post(post) %}
diff --git a/templates/search.html b/templates/search.html index b53e279..43fadb4 100644 --- a/templates/search.html +++ b/templates/search.html @@ -58,13 +58,13 @@ {% endif %} {% if all_posts_hidden_nsfw %} -
All posts are hidden because they are NSFW. Enable "Show NSFW posts" in settings to view.
+ All posts are hidden because they are NSFW. Enable "Show NSFW posts" in settings to view. {% endif %} {% if all_posts_filtered %} -
(All content on this page has been filtered)
+ (All content on this page has been filtered) {% else if is_filtered %} -
(Content from r/{{ sub }} has been filtered)
+ (Content from r/{{ sub }} has been filtered) {% else if params.typed != "sr_user" %} {% for post in posts %} {% if post.flags.nsfw && prefs.show_nsfw != "on" %} diff --git a/templates/utils.html b/templates/utils.html index 110dcbe..87d47a3 100644 --- a/templates/utils.html +++ b/templates/utils.html @@ -61,6 +61,109 @@ {% endif %} {%- endmacro %} +{% macro post(post) -%} + +
+

+ r/{{ post.community }} + + + {% if post.author.flair.flair_parts.len() > 0 %} + {% call render_flair(post.author.flair.flair_parts) %} + {% endif %} + + {{ post.rel_time }} + {% if !post.awards.is_empty() %} + + + {% for award in post.awards.clone() %} + + {{ award.name }} + {{ award.count }} + + {% endfor %} + + {% endif %} +

+

+ {{ post.title }} + {% if post.flair.flair_parts.len() > 0 %} + {% call render_flair(post.flair.flair_parts) %} + {% endif %} + {% if post.flags.nsfw %} NSFW{% endif %} +

+ + + + {% if post.post_type == "image" %} +
+ + + + + Post image + + + +
+ {% else if post.post_type == "video" || post.post_type == "gif" %} + {% if prefs.use_hls == "on" && !post.media.alt_url.is_empty() %} + +
+ +
+ + {% else %} +
+ +
+ {% call render_hls_notification(post.permalink[1..]) %} + {% endif %} + {% else if post.post_type == "gallery" %} + + {% else if post.post_type == "link" %} + {{ post.media.url }} + {% endif %} + + +
{{ post.body|safe }}
+
{{ post.score.0 }} Upvotes
+ +
+{%- endmacro %} + {% macro post_in_list(post) -%}