瀏覽代碼

Merge branch 'list-post-duplicates'

Daniel Valentine 2 年之前
父節點
當前提交
3ff5aff32f
共有 12 個文件被更改,包括 595 次插入197 次删除
  1. 228 0
      src/duplicates.rs
  2. 6 0
      src/main.rs
  3. 2 89
      src/post.rs
  4. 1 1
      src/search.rs
  5. 1 1
      src/subreddit.rs
  6. 1 1
      src/user.rs
  7. 104 7
      src/utils.rs
  8. 38 0
      static/style.css
  9. 107 0
      templates/duplicates.html
  10. 1 95
      templates/post.html
  11. 3 3
      templates/search.html
  12. 103 0
      templates/utils.html

+ 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 - 89
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,93 +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)
-				|| post["data"]["pinned"].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,

+ 104 - 7
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,
@@ -324,6 +325,7 @@ impl Post {
 				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,
 	}
 }
 

+ 38 - 0
static/style.css

@@ -835,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);
@@ -1273,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) {
@@ -1373,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 }
 }

+ 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 %}

+ 1 - 95
templates/post.html

@@ -40,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">