浏览代码

List post duplicates (resolves #574).

Daniel Valentine 2 年之前
父节点
当前提交
e579b97442
共有 21 个文件被更改,包括 695 次插入250 次删除
  1. 4 0
      CREDITS
  2. 19 30
      Cargo.lock
  3. 5 5
      Cargo.toml
  4. 7 2
      Dockerfile.arm
  5. 9 8
      README.md
  6. 14 3
      src/client.rs
  7. 228 0
      src/duplicates.rs
  8. 6 0
      src/main.rs
  9. 2 88
      src/post.rs
  10. 1 1
      src/search.rs
  11. 1 1
      src/subreddit.rs
  12. 1 1
      src/user.rs
  13. 105 8
      src/utils.rs
  14. 39 0
      static/style.css
  15. 13 0
      static/themes/gruvboxdark.css
  16. 13 0
      static/themes/gruvboxlight.css
  17. 1 1
      templates/base.html
  18. 107 0
      templates/duplicates.html
  19. 14 99
      templates/post.html
  20. 3 3
      templates/search.html
  21. 103 0
      templates/utils.html

+ 4 - 0
CREDITS

@@ -36,7 +36,10 @@ Kazi <kzshantonu@users.noreply.github.com>
 Kieran <42723993+EnderDev@users.noreply.github.com>
 Kieran <kieran@dothq.co>
 Kyle Roth <kylrth@gmail.com>
+laazyCmd <laazy.pr00gramming@protonmail.com>
 Laurențiu Nicola <lnicola@users.noreply.github.com>
+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 <matt@crossman.page>
 Matthew E <matt@matthew.science>
@@ -47,6 +50,7 @@ Nathan Moos <moosingin3space@gmail.com>
 Nicholas Christopher <nchristopher@tuta.io>
 Nick Lowery <ClockVapor@users.noreply.github.com>
 Nico <github@dr460nf1r3.org>
+NKIPSC <15067635+NKIPSC@users.noreply.github.com>
 obeho <71698631+obeho@users.noreply.github.com>
 obscurity <z@x4.pm>
 Om G <34579088+OxyMagnesium@users.noreply.github.com>

+ 19 - 30
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",
 ]

+ 5 - 5
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"

+ 7 - 2
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

+ 9 - 8
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`         |

+ 14 - 3
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(),

+ 228 - 0
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<Post>,
+
+	/// 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<Body>) -> Result<Response<Body>, 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<String>) -> (Vec<Post>, u64, bool) {
+	let post_duplicates: &Vec<Value> = &json["data"]["children"].as_array().map_or(Vec::new(), ToOwned::to_owned);
+	let mut duplicates: Vec<Post> = Vec::new();
+
+	// Process each post and place them in the Vec<Post>.
+	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)
+}

+ 6 - 0
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

+ 2 - 88
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<Body>) -> Result<Response<Body>, 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<Body>) -> Result<Response<Body>, 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!(
-			"<div class=\"md\"><p>[removed] — <a href=\"https://www.unddit.com{}\">view removed post</a></p></div>",
-			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<String>) -> Vec<Comment> {
 	// Parse the comment JSON into a Vector of Comments

+ 1 - 1
src/search.rs

@@ -107,7 +107,7 @@ pub async fn find(req: Request<Body>) -> Result<Response<Body>, 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,

+ 1 - 1
src/subreddit.rs

@@ -118,7 +118,7 @@ pub async fn community(req: Request<Body>) -> Result<Response<Body>, 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,

+ 1 - 1
src/user.rs

@@ -66,7 +66,7 @@ pub async fn profile(req: Request<Body>) -> Result<Response<Body>, 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,

+ 105 - 8
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<GalleryMedia>,
 	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<Body>) -> HashSet<String> {
 	setting(req, "filters").split('+').map(String::from).filter(|s| !s.is_empty()).collect::<HashSet<String>>()
 }
 
-/// Filters a `Vec<Post>` 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<Post>, filters: &HashSet<String>) -> bool {
+/// Filters a `Vec<Post>` 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<Post>, filters: &HashSet<String>) -> (u64, bool) {
+	// This is the length of the Vec<Post> 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<Post> 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!(
+			"<div class=\"md\"><p>[removed] — <a href=\"https://www.unddit.com{}\">view removed post</a></p></div>",
+			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,
 	}
 }
 

+ 39 - 0
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 }
 }

+ 13 - 0
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);
+}

+ 13 - 0
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);
+}

+ 1 - 1
templates/base.html

@@ -19,7 +19,7 @@
 		<!-- PWA Manifest -->
 		<link rel="manifest" type="application/json" href="/manifest.json">
 		<link rel="shortcut icon" type="image/x-icon" href="/favicon.ico"> 
-		<link rel="stylesheet" type="text/css" href="/style.css">
+		<link rel="stylesheet" type="text/css" href="/style.css?v={{ env!("CARGO_PKG_VERSION") }}">
 		{% endblock %}
 		</head>
 	<body class="

+ 107 - 0
templates/duplicates.html

@@ -0,0 +1,107 @@
+{% extends "base.html" %}
+{% import "utils.html" as utils %}
+
+{% block title %}{{ post.title }} - r/{{ post.community }}{% endblock %}
+
+{% block search %}
+	{% call utils::search(["/r/", post.community.as_str()].concat(), "") %}
+{% endblock %}
+
+{% block root %}/r/{{ post.community }}{% endblock %}{% block location %}r/{{ post.community }}{% endblock %}
+{% block head %}
+	{% call super() %}
+{% endblock %}
+
+{% block subscriptions %}
+	{% call utils::sub_list(post.community.as_str()) %}
+{% endblock %}
+
+{% block content %}
+    <div id="column_one">
+		{% call utils::post(post) %}
+
+        <!-- DUPLICATES -->
+        {% if post.num_duplicates == 0 %}
+            <span class="listing_warn">(No duplicates found)</span>
+        {% else if post.flags.nsfw && prefs.show_nsfw != "on" %}
+            <span class="listing_warn">(Enable "Show NSFW posts" in <a href="/settings">settings</a> to show duplicates)</span>
+        {% else %}
+            <div id="duplicates_msg"><h3>Duplicates</h3></div>
+            {% if num_posts_filtered > 0 %}
+            <span class="listing_warn">
+                {% if all_posts_filtered %}
+                    (All posts have been filtered)
+                {% else %}
+                    (Some posts have been filtered)
+                {% endif %}
+            </span>
+            {% endif %}
+
+            <div id="sort">
+                <div id="sort_options">
+                    <a {% if params.sort.is_empty() || params.sort.eq("num_comments") %}class="selected"{% endif %} href="?sort=num_comments">
+                        Number of comments
+                    </a>
+                    <a {% if params.sort.eq("new") %}class="selected"{% endif %} href="?sort=new">
+                        New
+                    </a>
+                </div>
+            </div>
+
+            <div id="posts">
+            {% 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" %}
+                <div class="post {% if post.flags.stickied %}stickied{% endif %}" id="{{ post.id }}">
+                        <p class="post_header">
+                            {% let community -%}
+                            {% if post.community.starts_with("u_") -%}
+                                {% let community = format!("u/{}", &post.community[2..]) -%}
+                            {% else -%}
+                                {% let community = format!("r/{}", post.community) -%}
+                            {% endif -%}
+                            <a class="post_subreddit" href="/r/{{ post.community }}">{{ post.community }}</a>
+                            <span class="dot">&bull;</span>
+                            <a class="post_author {{ post.author.distinguished }}" href="/u/{{ post.author.name }}">u/{{ post.author.name }}</a>
+                            <span class="dot">&bull;</span>
+                            <span class="created" title="{{ post.created }}">{{ post.rel_time }}</span>
+                            {% if !post.awards.is_empty() %}
+                                {% for award in post.awards.clone() %}
+                                <span class="award" title="{{ award.name }}">
+                                    <img alt="{{ award.name }}" src="{{ award.icon_url }}" width="16" height="16"/>
+                                </span>
+                                {% endfor %}
+                            {% endif %}
+                        </p>
+                        <h2 class="post_title">
+                            {% if post.flair.flair_parts.len() > 0 %}
+                                <a href="/r/{{ post.community }}/search?q=flair_name%3A%22{{ post.flair.text }}%22&restrict_sr=on"
+                                    class="post_flair"
+                                    style="color:{{ post.flair.foreground_color }}; background:{{ post.flair.background_color }};"
+                                    dir="ltr">{% call utils::render_flair(post.flair.flair_parts) %}</a>
+                            {% endif %}
+                            <a href="{{ post.permalink }}">{{ post.title }}</a>{% if post.flags.nsfw %} <small class="nsfw">NSFW</small>{% endif %}
+                        </h2>
+
+                        <div class="post_score" title="{{ post.score.1 }}">{{ post.score.0 }}<span class="label"> Upvotes</span></div>
+                        <div class="post_footer">
+                            <a href="{{ post.permalink }}" class="post_comments" title="{{ post.comments.1 }} comments">{{ post.comments.0 }} comments</a>
+                        </div>
+
+                </div>
+                {% endif %}
+            {%- endfor %}
+            </div>
+
+            <footer>
+                {% if params.before != "" %}
+                <a href="?before={{ params.before }}{% if !params.sort.is_empty() %}&sort={{ params.sort }}{% endif %}" accesskey="P">PREV</a>
+                {% endif %}
+
+                {% if params.after != "" %}
+                <a href="?after={{ params.after }}{% if !params.sort.is_empty() %}&sort={{ params.sort }}{% endif %}" accesskey="N">NEXT</a>
+                {% endif %}
+            </footer>
+        {% endif %}
+    </div>
+{% endblock %}

+ 14 - 99
templates/post.html

@@ -13,16 +13,25 @@
 	<!-- Meta Tags -->
 	<meta name="author" content="u/{{ post.author.name }}">
 	<meta name="title" content="{{ post.title }} - r/{{ post.community }}">
-	<meta property="og:type" content="website">
-	<meta property="og:url" content="{{ post.permalink }}">
 	<meta property="og:title" content="{{ post.title }} - r/{{ post.community }}">
 	<meta property="og:description" content="View on Libreddit, an alternative private front-end to Reddit.">
-	<meta property="og:image" content="{{ post.thumbnail.url }}">
-	<meta property="twitter:card" content="summary_large_image">
+	<meta property="og:url" content="{{ post.permalink }}">
 	<meta property="twitter:url" content="{{ post.permalink }}">
 	<meta property="twitter:title" content="{{ post.title }} - r/{{ post.community }}">
 	<meta property="twitter:description" content="View on Libreddit, an alternative private front-end to Reddit.">
+	{% if post.post_type == "image" %}
+	<meta property="og:type" content="image">
+	<meta property="og:image" content="{{ post.thumbnail.url }}">
+	<meta property="twitter:card" content="summary_large_image">
 	<meta property="twitter:image" content="{{ post.thumbnail.url }}">
+	{% else if post.post_type == "video" || post.post_type == "gif" %}
+	<meta property="twitter:card" content="video">
+	<meta property="og:type" content="video">
+	<meta property="og:video" content="{{ post.media.url }}">
+	<meta property="og:video:type" content="video/mp4">
+	{% else %}
+	<meta property="og:type" content="website">
+	{% endif %}
 {% endblock %}
 
 {% block subscriptions %}
@@ -31,101 +40,7 @@
 
 {% block content %}
 	<div id="column_one">
-
-		<!-- POST CONTENT -->
-		<div class="post highlighted">
-			<p class="post_header">
-				<a class="post_subreddit" href="/r/{{ post.community }}">r/{{ post.community }}</a>
-				<span class="dot">&bull;</span>
-				<a class="post_author {{ post.author.distinguished }}" href="/user/{{ post.author.name }}">u/{{ post.author.name }}</a>
-				{% if post.author.flair.flair_parts.len() > 0 %}
-					<small class="author_flair">{% call utils::render_flair(post.author.flair.flair_parts) %}</small>
-				{% endif %}
-				<span class="dot">&bull;</span>
-				<span class="created" title="{{ post.created }}">{{ post.rel_time }}</span>
-				{% if !post.awards.is_empty() %}
-				<span class="dot">&bull;</span>
-				<span class="awards">
-					{% for award in post.awards.clone() %}
-					<span class="award" title="{{ award.name }}">
-						<img alt="{{ award.name }}" src="{{ award.icon_url }}" width="16" height="16"/>
-						{{ award.count }}
-					</span>
-					{% endfor %}
-				</span>
-				{% endif %}
-			</p>
-			<h1 class="post_title">
-				{{ post.title }}
-				{% if post.flair.flair_parts.len() > 0 %}
-					<a href="/r/{{ post.community }}/search?q=flair_name%3A%22{{ post.flair.text }}%22&restrict_sr=on"
-						class="post_flair"
-						style="color:{{ post.flair.foreground_color }}; background:{{ post.flair.background_color }};">{% call utils::render_flair(post.flair.flair_parts) %}</a>
-				{% endif %}
-				{% if post.flags.nsfw %} <small class="nsfw">NSFW</small>{% endif %}
-			</h1>
-
-			<!-- POST MEDIA -->
-			<!-- post_type: {{ post.post_type }} -->
-			{% if post.post_type == "image" %}
-			<div class="post_media_content">
-				<a href="{{ post.media.url }}" class="post_media_image" >
-					<svg
-						width="{{ post.media.width }}px"
-						height="{{ post.media.height }}px"
-						xmlns="http://www.w3.org/2000/svg">
-							<image width="100%" height="100%" href="{{ post.media.url }}"/>
-							<desc>
-								<img loading="lazy" alt="Post image" src="{{ post.media.url }}"/>
-							</desc>
-					</svg>
-				</a>
-			</div>
-			{% else if post.post_type == "video" || post.post_type == "gif" %}
-			{% if prefs.use_hls == "on" && !post.media.alt_url.is_empty() %}
-			<script src="/hls.min.js"></script>
-			<div class="post_media_content">
-				<video class="post_media_video short {% if prefs.autoplay_videos == "on" %}hls_autoplay{% endif %}" width="{{ post.media.width }}" height="{{ post.media.height }}" poster="{{ post.media.poster }}" preload="none" controls>
-					<source src="{{ post.media.alt_url }}" type="application/vnd.apple.mpegurl" />
-					<source src="{{ post.media.url }}" type="video/mp4" />
-				</video>
-			</div>
-			<script src="/playHLSVideo.js"></script>
-			{% else %}
-			<div class="post_media_content">
-				<video class="post_media_video" src="{{ post.media.url }}" controls {% if prefs.autoplay_videos == "on" %}autoplay{% endif %} loop><a href={{ post.media.url }}>Video</a></video>
-			</div>
-			{% call utils::render_hls_notification(post.permalink[1..]) %}
-			{% endif %}
-			{% else if post.post_type == "gallery" %}
-			<div class="gallery">
-			{% for image in post.gallery -%}
-				<figure>
-					<a href="{{ image.url }}" ><img loading="lazy" alt="Gallery image" src="{{ image.url }}"/></a>
-					<figcaption>
-						<p>{{ image.caption }}</p>
-						{% if image.outbound_url.len() > 0 %}
-						<p><a class="outbound_url" href="{{ image.outbound_url }}" rel="nofollow">{{ image.outbound_url }}</a>
-						{% endif %}
-					</figcaption>
-				</figure>
-			{%- endfor %}
-			</div>			
-			{% else if post.post_type == "link" %}
-			<a id="post_url" href="{{ post.media.url }}" rel="nofollow">{{ post.media.url }}</a>
-			{% endif %}
-
-			<!-- POST BODY -->
-			<div class="post_body">{{ post.body|safe }}</div>
-			<div class="post_score" title="{{ post.score.1 }}">{{ post.score.0 }}<span class="label"> Upvotes</span></div>
-			<div class="post_footer">
-				<ul id="post_links">
-					<li><a href="{{ post.permalink }}">permalink</a></li>
-					<li><a href="https://reddit.com{{ post.permalink }}" rel="nofollow">reddit</a></li>
-				</ul>
-				<p>{{ post.upvote_ratio }}% Upvoted</p>
-			</div>
-		</div>
+		{% call utils::post(post) %}
 
 		<!-- SORT FORM -->
 		<form id="sort">

+ 3 - 3
templates/search.html

@@ -58,13 +58,13 @@
 		{% endif %}
 
 		{% if all_posts_hidden_nsfw %}
-		<center>All posts are hidden because they are NSFW. Enable "Show NSFW posts" in settings to view.</center>
+		<span class="listing_warn">All posts are hidden because they are NSFW. Enable "Show NSFW posts" in settings to view.</span>
 		{% endif %}
 
 		{% if all_posts_filtered %}
-			<center>(All content on this page has been filtered)</center>
+			<span class="listing_warn">(All content on this page has been filtered)</span>
 		{% else if is_filtered %}
-			<center>(Content from r/{{ sub }} has been filtered)</center>
+			<span class="listing_warn">(Content from r/{{ sub }} has been filtered)</span>
 		{% else if params.typed != "sr_user" %}
 			{% for post in posts %}
 				{% if post.flags.nsfw && prefs.show_nsfw != "on" %}

+ 103 - 0
templates/utils.html

@@ -61,6 +61,109 @@
 {% endif %}
 {%- endmacro %}
 
+{% macro post(post) -%}
+<!-- POST CONTENT -->
+<div class="post highlighted">
+	<p class="post_header">
+		<a class="post_subreddit" href="/r/{{ post.community }}">r/{{ post.community }}</a>
+		<span class="dot">&bull;</span>
+		<a class="post_author {{ post.author.distinguished }}" href="/user/{{ post.author.name }}">u/{{ post.author.name }}</a>
+		{% if post.author.flair.flair_parts.len() > 0 %}
+			<small class="author_flair">{% call render_flair(post.author.flair.flair_parts) %}</small>
+		{% endif %}
+		<span class="dot">&bull;</span>
+		<span class="created" title="{{ post.created }}">{{ post.rel_time }}</span>
+		{% if !post.awards.is_empty() %}
+		<span class="dot">&bull;</span>
+		<span class="awards">
+			{% for award in post.awards.clone() %}
+			<span class="award" title="{{ award.name }}">
+				<img alt="{{ award.name }}" src="{{ award.icon_url }}" width="16" height="16"/>
+				{{ award.count }}
+			</span>
+			{% endfor %}
+		</span>
+		{% endif %}
+	</p>
+	<h1 class="post_title">
+		{{ post.title }}
+		{% if post.flair.flair_parts.len() > 0 %}
+			<a href="/r/{{ post.community }}/search?q=flair_name%3A%22{{ post.flair.text }}%22&restrict_sr=on"
+				class="post_flair"
+				style="color:{{ post.flair.foreground_color }}; background:{{ post.flair.background_color }};">{% call render_flair(post.flair.flair_parts) %}</a>
+		{% endif %}
+		{% if post.flags.nsfw %} <small class="nsfw">NSFW</small>{% endif %}
+	</h1>
+
+	<!-- POST MEDIA -->
+	<!-- post_type: {{ post.post_type }} -->
+	{% if post.post_type == "image" %}
+	<div class="post_media_content">
+		<a href="{{ post.media.url }}" class="post_media_image" >
+			<svg
+				width="{{ post.media.width }}px"
+				height="{{ post.media.height }}px"
+				xmlns="http://www.w3.org/2000/svg">
+					<image width="100%" height="100%" href="{{ post.media.url }}"/>
+					<desc>
+						<img loading="lazy" alt="Post image" src="{{ post.media.url }}"/>
+					</desc>
+			</svg>
+		</a>
+	</div>
+	{% else if post.post_type == "video" || post.post_type == "gif" %}
+	{% if prefs.use_hls == "on" && !post.media.alt_url.is_empty() %}
+	<script src="/hls.min.js"></script>
+	<div class="post_media_content">
+		<video class="post_media_video short {% if prefs.autoplay_videos == "on" %}hls_autoplay{% endif %}" width="{{ post.media.width }}" height="{{ post.media.height }}" poster="{{ post.media.poster }}" preload="none" controls>
+			<source src="{{ post.media.alt_url }}" type="application/vnd.apple.mpegurl" />
+			<source src="{{ post.media.url }}" type="video/mp4" />
+		</video>
+	</div>
+	<script src="/playHLSVideo.js"></script>
+	{% else %}
+	<div class="post_media_content">
+		<video class="post_media_video" src="{{ post.media.url }}" controls {% if prefs.autoplay_videos == "on" %}autoplay{% endif %} loop><a href={{ post.media.url }}>Video</a></video>
+	</div>
+	{% call render_hls_notification(post.permalink[1..]) %}
+	{% endif %}
+	{% else if post.post_type == "gallery" %}
+	<div class="gallery">
+	{% for image in post.gallery -%}
+		<figure>
+			<a href="{{ image.url }}" ><img loading="lazy" alt="Gallery image" src="{{ image.url }}"/></a>
+			<figcaption>
+				<p>{{ image.caption }}</p>
+				{% if image.outbound_url.len() > 0 %}
+				<p><a class="outbound_url" href="{{ image.outbound_url }}" rel="nofollow">{{ image.outbound_url }}</a>
+				{% endif %}
+			</figcaption>
+		</figure>
+	{%- endfor %}
+	</div>
+	{% else if post.post_type == "link" %}
+	<a id="post_url" href="{{ post.media.url }}" rel="nofollow">{{ post.media.url }}</a>
+	{% endif %}
+
+	<!-- POST BODY -->
+	<div class="post_body">{{ post.body|safe }}</div>
+	<div class="post_score" title="{{ post.score.1 }}">{{ post.score.0 }}<span class="label"> Upvotes</span></div>
+	<div class="post_footer">
+		<ul id="post_links">
+			<li class="desktop_item"><a href="{{ post.permalink }}">permalink</a></li>
+			<li class="mobile_item"><a href="{{ post.permalink }}">link</a></li>
+			{% if post.num_duplicates > 0 %}
+			<li class="desktop_item"><a href="/r/{{ post.community }}/duplicates/{{ post.id }}">duplicates</a></li>
+			<li class="mobile_item"><a href="/r/{{ post.community }}/duplicates/{{ post.id }}">dupes</a></li>
+			{% endif %}
+			<li class="desktop_item"><a href="https://reddit.com{{ post.permalink }}" rel="nofollow">reddit</a></li>
+			<li class="mobile_item"><a href="https://reddit.com{{ post.permalink }}" rel="nofollow">reddit</a></li>
+		</ul>
+		<p>{{ post.upvote_ratio }}%<span id="upvoted"> Upvoted</span></p>
+	</div>
+</div>
+{%- endmacro %}
+
 {% macro post_in_list(post) -%}
 <div class="post {% if post.flags.stickied %}stickied{% endif %}" id="{{ post.id }}">
 	<p class="post_header">