Filter subreddits and users (#317)

* Initial work on filtering subreddits and users

* Fix doubly-prefixed subreddit name in search alt text (e.g. r/r/pics)

* Don't set post title to "Comment" if empty - this could throw off actual posts with the title "Comment"

* Filter search results

* Fix filtering to differentiate between "this subject itself is filtered" vs "all posts on this current page have been filtered"

* Remove unnecessary check

* Clean up

* Cargo format

* Collapse comments from filtered users

Co-authored-by: spikecodes <19519553+spikecodes@users.noreply.github.com>
This commit is contained in:
Nick Lowery 2021-11-25 21:02:04 -07:00 committed by GitHub
parent beada1f2b2
commit 888e7b302d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 374 additions and 143 deletions

View File

@ -215,8 +215,10 @@ async fn main() {
.at("/r/u_:name") .at("/r/u_:name")
.get(|r| async move { Ok(redirect(format!("/user/{}", r.param("name").unwrap_or_default()))) }.boxed()); .get(|r| async move { Ok(redirect(format!("/user/{}", r.param("name").unwrap_or_default()))) }.boxed());
app.at("/r/:sub/subscribe").post(|r| subreddit::subscriptions(r).boxed()); app.at("/r/:sub/subscribe").post(|r| subreddit::subscriptions_filters(r).boxed());
app.at("/r/:sub/unsubscribe").post(|r| subreddit::subscriptions(r).boxed()); app.at("/r/:sub/unsubscribe").post(|r| subreddit::subscriptions_filters(r).boxed());
app.at("/r/:sub/filter").post(|r| subreddit::subscriptions_filters(r).boxed());
app.at("/r/:sub/unfilter").post(|r| subreddit::subscriptions_filters(r).boxed());
app.at("/r/:sub/comments/:id").get(|r| post::item(r).boxed()); app.at("/r/:sub/comments/:id").get(|r| post::item(r).boxed());
app.at("/r/:sub/comments/:id/:title").get(|r| post::item(r).boxed()); app.at("/r/:sub/comments/:id/:title").get(|r| post::item(r).boxed());

View File

@ -4,11 +4,12 @@ use crate::esc;
use crate::server::RequestExt; use crate::server::RequestExt;
use crate::subreddit::{can_access_quarantine, quarantine}; use crate::subreddit::{can_access_quarantine, quarantine};
use crate::utils::{ use crate::utils::{
error, format_num, format_url, param, rewrite_urls, setting, template, time, val, Author, Awards, Comment, Flags, Flair, FlairPart, Media, Post, Preferences, error, format_num, format_url, get_filters, param, rewrite_urls, setting, template, time, val, Author, Awards, Comment, Flags, Flair, FlairPart, Media, Post, Preferences,
}; };
use hyper::{Body, Request, Response}; use hyper::{Body, Request, Response};
use askama::Template; use askama::Template;
use std::collections::HashSet;
// STRUCTS // STRUCTS
#[derive(Template)] #[derive(Template)]
@ -55,7 +56,7 @@ pub async fn item(req: Request<Body>) -> Result<Response<Body>, String> {
Ok(response) => { Ok(response) => {
// Parse the JSON into Post and Comment structs // Parse the JSON into Post and Comment structs
let post = parse_post(&response[0]).await; let post = parse_post(&response[0]).await;
let comments = parse_comments(&response[1], &post.permalink, &post.author.name, highlighted_comment); let comments = parse_comments(&response[1], &post.permalink, &post.author.name, highlighted_comment, &get_filters(&req));
let url = req.uri().to_string(); let url = req.uri().to_string();
// Use the Post and Comment structs to generate a website to show users // Use the Post and Comment structs to generate a website to show users
@ -156,7 +157,7 @@ async fn parse_post(json: &serde_json::Value) -> Post {
} }
// COMMENTS // COMMENTS
fn parse_comments(json: &serde_json::Value, post_link: &str, post_author: &str, highlighted_comment: &str) -> Vec<Comment> { 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 // Parse the comment JSON into a Vector of Comments
let comments = json["data"]["children"].as_array().map_or(Vec::new(), std::borrow::ToOwned::to_owned); let comments = json["data"]["children"].as_array().map_or(Vec::new(), std::borrow::ToOwned::to_owned);
@ -177,7 +178,7 @@ fn parse_comments(json: &serde_json::Value, post_link: &str, post_author: &str,
// If this comment contains replies, handle those too // If this comment contains replies, handle those too
let replies: Vec<Comment> = if data["replies"].is_object() { let replies: Vec<Comment> = if data["replies"].is_object() {
parse_comments(&data["replies"], post_link, post_author, highlighted_comment) parse_comments(&data["replies"], post_link, post_author, highlighted_comment, filters)
} else { } else {
Vec::new() Vec::new()
}; };
@ -190,23 +191,7 @@ fn parse_comments(json: &serde_json::Value, post_link: &str, post_author: &str,
let id = val(&comment, "id"); let id = val(&comment, "id");
let highlighted = id == highlighted_comment; let highlighted = id == highlighted_comment;
// Many subreddits have a default comment posted about the sub's rules etc. let author = Author {
// Many libreddit users do not wish to see this kind of comment by default.
// Reddit does not tell us which users are "bots", so a good heuristic is to
// collapse stickied moderator comments.
let is_moderator_comment = data["distinguished"].as_str().unwrap_or_default() == "moderator";
let is_stickied = data["stickied"].as_bool().unwrap_or_default();
let collapsed = is_moderator_comment && is_stickied;
Comment {
id,
kind,
parent_id: parent_info[1].to_string(),
parent_kind: parent_info[0].to_string(),
post_link: post_link.to_string(),
post_author: post_author.to_string(),
body,
author: Author {
name: val(&comment, "author"), name: val(&comment, "author"),
flair: Flair { flair: Flair {
flair_parts: FlairPart::parse( flair_parts: FlairPart::parse(
@ -219,7 +204,26 @@ fn parse_comments(json: &serde_json::Value, post_link: &str, post_author: &str,
foreground_color: val(&comment, "author_flair_text_color"), foreground_color: val(&comment, "author_flair_text_color"),
}, },
distinguished: val(&comment, "distinguished"), distinguished: val(&comment, "distinguished"),
}, };
let is_filtered = filters.contains(&["u_", author.name.as_str()].concat());
// Many subreddits have a default comment posted about the sub's rules etc.
// Many libreddit users do not wish to see this kind of comment by default.
// Reddit does not tell us which users are "bots", so a good heuristic is to
// collapse stickied moderator comments.
let is_moderator_comment = data["distinguished"].as_str().unwrap_or_default() == "moderator";
let is_stickied = data["stickied"].as_bool().unwrap_or_default();
let collapsed = (is_moderator_comment && is_stickied) || is_filtered;
Comment {
id,
kind,
parent_id: parent_info[1].to_string(),
parent_kind: parent_info[0].to_string(),
post_link: post_link.to_string(),
post_author: post_author.to_string(),
body,
author,
score: if data["score_hidden"].as_bool().unwrap_or_default() { score: if data["score_hidden"].as_bool().unwrap_or_default() {
("\u{2022}".to_string(), "Hidden".to_string()) ("\u{2022}".to_string(), "Hidden".to_string())
} else { } else {
@ -232,6 +236,7 @@ fn parse_comments(json: &serde_json::Value, post_link: &str, post_author: &str,
highlighted, highlighted,
awards, awards,
collapsed, collapsed,
is_filtered,
} }
}) })
.collect() .collect()

View File

@ -1,5 +1,5 @@
// CRATES // CRATES
use crate::utils::{catch_random, error, format_num, format_url, param, redirect, setting, template, val, Post, Preferences}; use crate::utils::{catch_random, error, filter_posts, format_num, format_url, get_filters, param, redirect, setting, template, val, Post, Preferences};
use crate::{ use crate::{
client::json, client::json,
subreddit::{can_access_quarantine, quarantine}, subreddit::{can_access_quarantine, quarantine},
@ -37,6 +37,11 @@ struct SearchTemplate {
params: SearchParams, params: SearchParams,
prefs: Preferences, prefs: Preferences,
url: String, url: String,
/// Whether the subreddit itself is filtered.
is_filtered: bool,
/// Whether all fetched posts are filtered (to differentiate between no posts fetched in the first place,
/// and all fetched posts being filtered).
all_posts_filtered: bool,
} }
// SERVICES // SERVICES
@ -59,14 +64,45 @@ pub async fn find(req: Request<Body>) -> Result<Response<Body>, String> {
let typed = param(&path, "type").unwrap_or_default(); let typed = param(&path, "type").unwrap_or_default();
let sort = param(&path, "sort").unwrap_or_else(|| "relevance".to_string()); let sort = param(&path, "sort").unwrap_or_else(|| "relevance".to_string());
let filters = get_filters(&req);
// If search is not restricted to this subreddit, show other subreddits in search results // If search is not restricted to this subreddit, show other subreddits in search results
let subreddits = param(&path, "restrict_sr").map_or(search_subreddits(&query, &typed).await, |_| Vec::new()); let subreddits = if param(&path, "restrict_sr").is_none() {
let mut subreddits = search_subreddits(&query, &typed).await;
subreddits.retain(|s| !filters.contains(s.name.as_str()));
subreddits
} else {
Vec::new()
};
let url = String::from(req.uri().path_and_query().map_or("", |val| val.as_str())); let url = String::from(req.uri().path_and_query().map_or("", |val| val.as_str()));
match Post::fetch(&path, String::new(), quarantined).await { // If all requested subs are filtered, we don't need to fetch posts.
Ok((posts, after)) => template(SearchTemplate { if sub.split("+").all(|s| filters.contains(s)) {
template(SearchTemplate {
posts: Vec::new(),
subreddits,
sub,
params: SearchParams {
q: query.replace('"', "&quot;"),
sort,
t: param(&path, "t").unwrap_or_default(),
before: param(&path, "after").unwrap_or_default(),
after: "".to_string(),
restrict_sr: param(&path, "restrict_sr").unwrap_or_default(),
typed,
},
prefs: Preferences::new(req),
url,
is_filtered: true,
all_posts_filtered: false,
})
} else {
match Post::fetch(&path, quarantined).await {
Ok((mut posts, after)) => {
let all_posts_filtered = filter_posts(&mut posts, &filters);
template(SearchTemplate {
posts, posts,
subreddits, subreddits,
sub, sub,
@ -81,7 +117,10 @@ pub async fn find(req: Request<Body>) -> Result<Response<Body>, String> {
}, },
prefs: Preferences::new(req), prefs: Preferences::new(req),
url, url,
}), is_filtered: false,
all_posts_filtered,
})
}
Err(msg) => { Err(msg) => {
if msg == "quarantined" { if msg == "quarantined" {
let sub = req.param("sub").unwrap_or_default(); let sub = req.param("sub").unwrap_or_default();
@ -91,6 +130,7 @@ pub async fn find(req: Request<Body>) -> Result<Response<Body>, String> {
} }
} }
} }
}
} }
async fn search_subreddits(q: &str, typed: &str) -> Vec<Subreddit> { async fn search_subreddits(q: &str, typed: &str) -> Vec<Subreddit> {
@ -109,7 +149,7 @@ async fn search_subreddits(q: &str, typed: &str) -> Vec<Subreddit> {
let icon = subreddit["data"]["community_icon"].as_str().map_or_else(|| val(subreddit, "icon_img"), ToString::to_string); let icon = subreddit["data"]["community_icon"].as_str().map_or_else(|| val(subreddit, "icon_img"), ToString::to_string);
Subreddit { Subreddit {
name: val(subreddit, "display_name_prefixed"), name: val(subreddit, "display_name"),
url: val(subreddit, "url"), url: val(subreddit, "url"),
icon: format_url(&icon), icon: format_url(&icon),
description: val(subreddit, "public_description"), description: val(subreddit, "public_description"),

View File

@ -109,7 +109,7 @@ fn set_cookies_method(req: Request<Body>, remove_cookies: bool) -> Response<Body
let mut response = redirect(path); let mut response = redirect(path);
for name in [PREFS.to_vec(), vec!["subscriptions"]].concat() { for name in [PREFS.to_vec(), vec!["subscriptions", "filters"]].concat() {
match form.get(name) { match form.get(name) {
Some(value) => response.insert_cookie( Some(value) => response.insert_cookie(
Cookie::build(name.to_owned(), value.clone()) Cookie::build(name.to_owned(), value.clone())

View File

@ -1,6 +1,8 @@
// CRATES // CRATES
use crate::esc; use crate::esc;
use crate::utils::{catch_random, error, format_num, format_url, param, redirect, rewrite_urls, setting, template, val, Post, Preferences, Subreddit}; use crate::utils::{
catch_random, error, filter_posts, format_num, format_url, get_filters, param, redirect, rewrite_urls, setting, template, val, Post, Preferences, Subreddit,
};
use crate::{client::json, server::ResponseExt, RequestExt}; use crate::{client::json, server::ResponseExt, RequestExt};
use askama::Template; use askama::Template;
use cookie::Cookie; use cookie::Cookie;
@ -17,6 +19,11 @@ struct SubredditTemplate {
ends: (String, String), ends: (String, String),
prefs: Preferences, prefs: Preferences,
url: String, url: String,
/// Whether the subreddit itself is filtered.
is_filtered: bool,
/// Whether all fetched posts are filtered (to differentiate between no posts fetched in the first place,
/// and all fetched posts being filtered).
all_posts_filtered: bool,
} }
#[derive(Template)] #[derive(Template)]
@ -48,7 +55,7 @@ pub async fn community(req: Request<Body>) -> Result<Response<Body>, String> {
let post_sort = req.cookie("post_sort").map_or_else(|| "hot".to_string(), |c| c.value().to_string()); let post_sort = req.cookie("post_sort").map_or_else(|| "hot".to_string(), |c| c.value().to_string());
let sort = req.param("sort").unwrap_or_else(|| req.param("id").unwrap_or(post_sort)); let sort = req.param("sort").unwrap_or_else(|| req.param("id").unwrap_or(post_sort));
let sub = req.param("sub").unwrap_or(if front_page == "default" || front_page.is_empty() { let sub_name = req.param("sub").unwrap_or(if front_page == "default" || front_page.is_empty() {
if subscribed.is_empty() { if subscribed.is_empty() {
"popular".to_string() "popular".to_string()
} else { } else {
@ -57,43 +64,58 @@ pub async fn community(req: Request<Body>) -> Result<Response<Body>, String> {
} else { } else {
front_page.clone() front_page.clone()
}); });
let quarantined = can_access_quarantine(&req, &sub) || root; let quarantined = can_access_quarantine(&req, &sub_name) || root;
// Handle random subreddits // Handle random subreddits
if let Ok(random) = catch_random(&sub, "").await { if let Ok(random) = catch_random(&sub_name, "").await {
return Ok(random); return Ok(random);
} }
if req.param("sub").is_some() && sub.starts_with("u_") { if req.param("sub").is_some() && sub_name.starts_with("u_") {
return Ok(redirect(["/user/", &sub[2..]].concat())); return Ok(redirect(["/user/", &sub_name[2..]].concat()));
} }
let path = format!("/r/{}/{}.json?{}&raw_json=1", sub, sort, req.uri().query().unwrap_or_default()); // Request subreddit metadata
let sub = if !sub_name.contains('+') && sub_name != subscribed && sub_name != "popular" && sub_name != "all" {
match Post::fetch(&path, String::new(), quarantined).await {
Ok((posts, after)) => {
// If you can get subreddit posts, also request subreddit metadata
let sub = if !sub.contains('+') && sub != subscribed && sub != "popular" && sub != "all" {
// Regular subreddit // Regular subreddit
subreddit(&sub, quarantined).await.unwrap_or_default() subreddit(&sub_name, quarantined).await.unwrap_or_default()
} else if sub == subscribed { } else if sub_name == subscribed {
// Subscription feed // Subscription feed
if req.uri().path().starts_with("/r/") { if req.uri().path().starts_with("/r/") {
subreddit(&sub, quarantined).await.unwrap_or_default() subreddit(&sub_name, quarantined).await.unwrap_or_default()
} else { } else {
Subreddit::default() Subreddit::default()
} }
} else if sub.contains('+') { } else if sub_name.contains('+') {
// Multireddit // Multireddit
Subreddit { Subreddit {
name: sub, name: sub_name.clone(),
..Subreddit::default() ..Subreddit::default()
} }
} else { } else {
Subreddit::default() Subreddit::default()
}; };
let path = format!("/r/{}/{}.json?{}&raw_json=1", sub_name.clone(), sort, req.uri().query().unwrap_or_default());
let url = String::from(req.uri().path_and_query().map_or("", |val| val.as_str())); let url = String::from(req.uri().path_and_query().map_or("", |val| val.as_str()));
let filters = get_filters(&req);
// If all requested subs are filtered, we don't need to fetch posts.
if sub_name.split("+").all(|s| filters.contains(s)) {
template(SubredditTemplate {
sub,
posts: Vec::new(),
sort: (sort, param(&path, "t").unwrap_or_default()),
ends: (param(&path, "after").unwrap_or_default(), "".to_string()),
prefs: Preferences::new(req),
url,
is_filtered: true,
all_posts_filtered: false,
})
} else {
match Post::fetch(&path, quarantined).await {
Ok((mut posts, after)) => {
let all_posts_filtered = filter_posts(&mut posts, &filters);
template(SubredditTemplate { template(SubredditTemplate {
sub, sub,
@ -102,15 +124,18 @@ pub async fn community(req: Request<Body>) -> Result<Response<Body>, String> {
ends: (param(&path, "after").unwrap_or_default(), after), ends: (param(&path, "after").unwrap_or_default(), after),
prefs: Preferences::new(req), prefs: Preferences::new(req),
url, url,
is_filtered: false,
all_posts_filtered,
}) })
} }
Err(msg) => match msg.as_str() { Err(msg) => match msg.as_str() {
"quarantined" => quarantine(req, sub), "quarantined" => quarantine(req, sub_name),
"private" => error(req, format!("r/{} is a private community", sub)).await, "private" => error(req, format!("r/{} is a private community", sub_name)).await,
"banned" => error(req, format!("r/{} has been banned from Reddit", sub)).await, "banned" => error(req, format!("r/{} has been banned from Reddit", sub_name)).await,
_ => error(req, msg).await, _ => error(req, msg).await,
}, },
} }
}
} }
pub fn quarantine(req: Request<Body>, sub: String) -> Result<Response<Body>, String> { pub fn quarantine(req: Request<Body>, sub: String) -> Result<Response<Body>, String> {
@ -150,18 +175,25 @@ pub fn can_access_quarantine(req: &Request<Body>, sub: &str) -> bool {
setting(req, &format!("allow_quaran_{}", sub.to_lowercase())).parse().unwrap_or_default() setting(req, &format!("allow_quaran_{}", sub.to_lowercase())).parse().unwrap_or_default()
} }
// Sub or unsub by setting subscription cookie using response "Set-Cookie" header // Sub, filter, unfilter, or unsub by setting subscription cookie using response "Set-Cookie" header
pub async fn subscriptions(req: Request<Body>) -> Result<Response<Body>, String> { pub async fn subscriptions_filters(req: Request<Body>) -> Result<Response<Body>, String> {
let sub = req.param("sub").unwrap_or_default(); let sub = req.param("sub").unwrap_or_default();
let action: Vec<String> = req.uri().path().split('/').map(String::from).collect();
// Handle random subreddits // Handle random subreddits
if sub == "random" || sub == "randnsfw" { if sub == "random" || sub == "randnsfw" {
if action.contains(&"filter".to_string()) || action.contains(&"unfilter".to_string()) {
return Err("Can't filter random subreddit!".to_string());
} else {
return Err("Can't subscribe to random subreddit!".to_string()); return Err("Can't subscribe to random subreddit!".to_string());
} }
}
let query = req.uri().query().unwrap_or_default().to_string(); let query = req.uri().query().unwrap_or_default().to_string();
let action: Vec<String> = req.uri().path().split('/').map(String::from).collect();
let mut sub_list = Preferences::new(req).subscriptions; let preferences = Preferences::new(req);
let mut sub_list = preferences.subscriptions;
let mut filters = preferences.filters;
// Retrieve list of posts for these subreddits to extract display names // Retrieve list of posts for these subreddits to extract display names
let posts = json(format!("/r/{}/hot.json?raw_json=1", sub), true).await?; let posts = json(format!("/r/{}/hot.json?raw_json=1", sub), true).await?;
@ -182,8 +214,10 @@ pub async fn subscriptions(req: Request<Body>) -> Result<Response<Body>, String>
for part in sub.split('+') { for part in sub.split('+') {
// Retrieve display name for the subreddit // Retrieve display name for the subreddit
let display; let display;
let part = if let Some(&(_, display)) = display_lookup.iter().find(|x| x.0 == part.to_lowercase()) { let part = if part.starts_with("u_") {
// This is already known, doesn't require seperate request part
} else if let Some(&(_, display)) = display_lookup.iter().find(|x| x.0 == part.to_lowercase()) {
// This is already known, doesn't require separate request
display display
} else { } else {
// This subreddit display name isn't known, retrieve it // This subreddit display name isn't known, retrieve it
@ -196,16 +230,28 @@ pub async fn subscriptions(req: Request<Body>) -> Result<Response<Body>, String>
if action.contains(&"subscribe".to_string()) && !sub_list.contains(&part.to_owned()) { if action.contains(&"subscribe".to_string()) && !sub_list.contains(&part.to_owned()) {
// Add each sub name to the subscribed list // Add each sub name to the subscribed list
sub_list.push(part.to_owned()); sub_list.push(part.to_owned());
// Reorder sub names alphabettically filters.retain(|s| s.to_lowercase() != part.to_lowercase());
// Reorder sub names alphabetically
sub_list.sort_by_key(|a| a.to_lowercase()); sub_list.sort_by_key(|a| a.to_lowercase());
filters.sort_by_key(|a| a.to_lowercase());
} else if action.contains(&"unsubscribe".to_string()) { } else if action.contains(&"unsubscribe".to_string()) {
// Remove sub name from subscribed list // Remove sub name from subscribed list
sub_list.retain(|s| s.to_lowercase() != part.to_lowercase()); sub_list.retain(|s| s.to_lowercase() != part.to_lowercase());
} else if action.contains(&"filter".to_string()) && !filters.contains(&part.to_owned()) {
// Add each sub name to the filtered list
filters.push(part.to_owned());
sub_list.retain(|s| s.to_lowercase() != part.to_lowercase());
// Reorder sub names alphabetically
filters.sort_by_key(|a| a.to_lowercase());
sub_list.sort_by_key(|a| a.to_lowercase());
} else if action.contains(&"unfilter".to_string()) {
// Remove sub name from filtered list
filters.retain(|s| s.to_lowercase() != part.to_lowercase());
} }
} }
// Redirect back to subreddit // Redirect back to subreddit
// check for redirect parameter if unsubscribing from outside sidebar // check for redirect parameter if unsubscribing/unfiltering from outside sidebar
let path = if let Some(redirect_path) = param(&format!("?{}", query), "redirect") { let path = if let Some(redirect_path) = param(&format!("?{}", query), "redirect") {
format!("/{}/", redirect_path) format!("/{}/", redirect_path)
} else { } else {
@ -226,6 +272,17 @@ pub async fn subscriptions(req: Request<Body>) -> Result<Response<Body>, String>
.finish(), .finish(),
); );
} }
if filters.is_empty() {
response.remove_cookie("filters".to_string());
} else {
response.insert_cookie(
Cookie::build("filters", filters.join("+"))
.path("/")
.http_only(true)
.expires(OffsetDateTime::now_utc() + Duration::weeks(52))
.finish(),
);
}
Ok(response) Ok(response)
} }

View File

@ -2,7 +2,7 @@
use crate::client::json; use crate::client::json;
use crate::esc; use crate::esc;
use crate::server::RequestExt; use crate::server::RequestExt;
use crate::utils::{error, format_url, param, template, Post, Preferences, User}; use crate::utils::{error, filter_posts, format_url, get_filters, param, template, Post, Preferences, User};
use askama::Template; use askama::Template;
use hyper::{Body, Request, Response}; use hyper::{Body, Request, Response};
use time::OffsetDateTime; use time::OffsetDateTime;
@ -17,6 +17,11 @@ struct UserTemplate {
ends: (String, String), ends: (String, String),
prefs: Preferences, prefs: Preferences,
url: String, url: String,
/// Whether the user themself is filtered.
is_filtered: bool,
/// Whether all fetched posts are filtered (to differentiate between no posts fetched in the first place,
/// and all fetched posts being filtered).
all_posts_filtered: bool,
} }
// FUNCTIONS // FUNCTIONS
@ -27,20 +32,31 @@ pub async fn profile(req: Request<Body>) -> Result<Response<Body>, String> {
req.param("name").unwrap_or_else(|| "reddit".to_string()), req.param("name").unwrap_or_else(|| "reddit".to_string()),
req.uri().query().unwrap_or_default() req.uri().query().unwrap_or_default()
); );
let url = String::from(req.uri().path_and_query().map_or("", |val| val.as_str()));
// Retrieve other variables from Libreddit request // Retrieve other variables from Libreddit request
let sort = param(&path, "sort").unwrap_or_default(); let sort = param(&path, "sort").unwrap_or_default();
let username = req.param("name").unwrap_or_default(); let username = req.param("name").unwrap_or_default();
// Request user posts/comments from Reddit
let posts = Post::fetch(&path, "Comment".to_string(), false).await;
let url = String::from(req.uri().path_and_query().map_or("", |val| val.as_str()));
match posts {
Ok((posts, after)) => {
// If you can get user posts, also request user data
let user = user(&username).await.unwrap_or_default(); let user = user(&username).await.unwrap_or_default();
let filters = get_filters(&req);
if filters.contains(&["u_", &username].concat()) {
template(UserTemplate {
user,
posts: Vec::new(),
sort: (sort, param(&path, "t").unwrap_or_default()),
ends: (param(&path, "after").unwrap_or_default(), "".to_string()),
prefs: Preferences::new(req),
url,
is_filtered: true,
all_posts_filtered: false,
})
} else {
// 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);
template(UserTemplate { template(UserTemplate {
user, user,
posts, posts,
@ -48,11 +64,14 @@ pub async fn profile(req: Request<Body>) -> Result<Response<Body>, String> {
ends: (param(&path, "after").unwrap_or_default(), after), ends: (param(&path, "after").unwrap_or_default(), after),
prefs: Preferences::new(req), prefs: Preferences::new(req),
url, url,
is_filtered: false,
all_posts_filtered,
}) })
} }
// If there is an error show error page // If there is an error show error page
Err(msg) => error(req, msg).await, Err(msg) => error(req, msg).await,
} }
}
} }
// USER // USER

View File

@ -7,7 +7,7 @@ use cookie::Cookie;
use hyper::{Body, Request, Response}; use hyper::{Body, Request, Response};
use regex::Regex; use regex::Regex;
use serde_json::Value; use serde_json::Value;
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use std::str::FromStr; use std::str::FromStr;
use time::{Duration, OffsetDateTime}; use time::{Duration, OffsetDateTime};
use url::Url; use url::Url;
@ -219,7 +219,7 @@ pub struct Post {
impl Post { impl Post {
// Fetch posts of a user or subreddit and return a vector of posts and the "after" value // Fetch posts of a user or subreddit and return a vector of posts and the "after" value
pub async fn fetch(path: &str, fallback_title: String, quarantine: bool) -> Result<(Vec<Self>, String), String> { pub async fn fetch(path: &str, quarantine: bool) -> Result<(Vec<Self>, String), String> {
let res; let res;
let post_list; let post_list;
@ -262,7 +262,7 @@ impl Post {
posts.push(Self { posts.push(Self {
id: val(post, "id"), id: val(post, "id"),
title: esc!(if title.is_empty() { fallback_title.clone() } else { title }), title,
community: val(post, "subreddit"), community: val(post, "subreddit"),
body, body,
author: Author { author: Author {
@ -346,6 +346,7 @@ pub struct Comment {
pub highlighted: bool, pub highlighted: bool,
pub awards: Awards, pub awards: Awards,
pub collapsed: bool, pub collapsed: bool,
pub is_filtered: bool,
} }
#[derive(Default, Clone)] #[derive(Default, Clone)]
@ -458,6 +459,7 @@ pub struct Preferences {
pub comment_sort: String, pub comment_sort: String,
pub post_sort: String, pub post_sort: String,
pub subscriptions: Vec<String>, pub subscriptions: Vec<String>,
pub filters: Vec<String>,
} }
impl Preferences { impl Preferences {
@ -475,10 +477,28 @@ impl Preferences {
comment_sort: setting(&req, "comment_sort"), comment_sort: setting(&req, "comment_sort"),
post_sort: setting(&req, "post_sort"), post_sort: setting(&req, "post_sort"),
subscriptions: setting(&req, "subscriptions").split('+').map(String::from).filter(|s| !s.is_empty()).collect(), subscriptions: setting(&req, "subscriptions").split('+').map(String::from).filter(|s| !s.is_empty()).collect(),
filters: setting(&req, "filters").split('+').map(String::from).filter(|s| !s.is_empty()).collect(),
} }
} }
} }
/// Gets a `HashSet` of filters from the cookie in the given `Request`.
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 {
if posts.is_empty() {
false
} else {
posts.retain(|p| !filters.contains(&p.community) && !filters.contains(&["u_", &p.author.name].concat()));
posts.is_empty()
}
}
// //
// FORMATTING // FORMATTING
// //
@ -515,7 +535,7 @@ pub fn setting(req: &Request<Body>, name: &str) -> String {
// Detect and redirect in the event of a random subreddit // Detect and redirect in the event of a random subreddit
pub async fn catch_random(sub: &str, additional: &str) -> Result<Response<Body>, String> { pub async fn catch_random(sub: &str, additional: &str) -> Result<Response<Body>, String> {
if (sub == "random" || sub == "randnsfw") && !sub.contains('+') { if sub == "random" || sub == "randnsfw" {
let new_sub = json(format!("/r/{}/about.json?raw_json=1", sub), false).await?["data"]["display_name"] let new_sub = json(format!("/r/{}/about.json?raw_json=1", sub), false).await?["data"]["display_name"]
.as_str() .as_str()
.unwrap_or_default() .unwrap_or_default()

View File

@ -372,7 +372,7 @@ aside {
margin-bottom: 20px; margin-bottom: 20px;
} }
#user_details, #sub_details { #user_details, #sub_details, #sub_actions, #user_actions {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
grid-column-gap: 20px; grid-column-gap: 20px;
@ -384,7 +384,7 @@ aside {
/* Subscriptions */ /* Subscriptions */
#sub_subscription, #user_subscription { #sub_subscription, #user_subscription, #user_filter, #sub_filter {
margin-top: 20px; margin-top: 20px;
} }
@ -392,18 +392,18 @@ aside {
margin-bottom: 20px; margin-bottom: 20px;
} }
.subscribe, .unsubscribe { .subscribe, .unsubscribe, .filter, .unfilter {
padding: 10px 20px; padding: 10px 20px;
border-radius: 5px; border-radius: 5px;
cursor: pointer; cursor: pointer;
} }
.subscribe { .subscribe, .filter {
color: var(--foreground); color: var(--foreground);
background-color: var(--accent); background-color: var(--accent);
} }
.unsubscribe { .unsubscribe, .unfilter {
color: var(--text); color: var(--text);
background-color: var(--highlighted); background-color: var(--highlighted);
} }
@ -1042,7 +1042,7 @@ a.search_subreddit:hover {
overflow: auto; overflow: auto;
} }
.comment_body.highlighted { .comment_body.highlighted, .comment_body_filtered.highlighted {
background: var(--highlighted); background: var(--highlighted);
} }
@ -1055,6 +1055,15 @@ a.search_subreddit:hover {
color: var(--accent); color: var(--accent);
} }
.comment_body_filtered {
opacity: 0.4;
font-weight: normal;
font-style: italic;
padding: 5px 5px;
margin: 5px 0;
overflow: auto;
}
.deeper_replies { .deeper_replies {
color: var(--accent); color: var(--accent);
margin-left: 15px; margin-left: 15px;
@ -1226,6 +1235,14 @@ input[type="submit"] {
color: var(--accent); color: var(--accent);
} }
#settings_filters .unsubscribe {
margin-left: 30px;
}
#settings_filters a {
color: var(--accent);
}
.helper { .helper {
padding: 10px; padding: 10px;
width: 250px; width: 250px;

View File

@ -8,7 +8,7 @@
<p class="comment_score" title="{{ score.1 }}">{{ score.0 }}</p> <p class="comment_score" title="{{ score.1 }}">{{ score.0 }}</p>
<div class="line"></div> <div class="line"></div>
</div> </div>
<details class="comment_right" {% if collapsed == false %}open{% endif %}> <details class="comment_right" {% if !collapsed || highlighted %}open{% endif %}>
<summary class="comment_data"> <summary class="comment_data">
<a class="comment_author {{ author.distinguished }} {% if author.name == post_author %}op{% endif %}" href="/user/{{ author.name }}">u/{{ author.name }}</a> <a class="comment_author {{ author.distinguished }} {% if author.name == post_author %}op{% endif %}" href="/user/{{ author.name }}">u/{{ author.name }}</a>
{% if author.flair.flair_parts.len() > 0 %} {% if author.flair.flair_parts.len() > 0 %}
@ -25,7 +25,11 @@
{% endfor %} {% endfor %}
{% endif %} {% endif %}
</summary> </summary>
{% if is_filtered %}
<div class="comment_body_filtered {% if highlighted %}highlighted{% endif %}">(Filtered content)</div>
{% else %}
<div class="comment_body {% if highlighted %}highlighted{% endif %}">{{ body }}</div> <div class="comment_body {% if highlighted %}highlighted{% endif %}">{{ body }}</div>
{% endif %}
<blockquote class="replies">{% for c in replies -%}{{ c.render().unwrap() }}{%- endfor %} <blockquote class="replies">{% for c in replies -%}{{ c.render().unwrap() }}{%- endfor %}
</blockquote> </blockquote>
</details> </details>

View File

@ -31,6 +31,7 @@
</button> </button>
</form> </form>
{% if !is_filtered %}
{% if subreddits.len() > 0 || params.typed == "sr_user" %} {% if subreddits.len() > 0 || params.typed == "sr_user" %}
<div id="search_subreddits"> <div id="search_subreddits">
{% if params.typed == "sr_user" %} {% if params.typed == "sr_user" %}
@ -41,7 +42,7 @@
<div class="search_subreddit_left">{% if subreddit.icon != "" %}<img loading="lazy" src="{{ subreddit.icon }}" alt="r/{{ subreddit.name }} icon">{% endif %}</div> <div class="search_subreddit_left">{% if subreddit.icon != "" %}<img loading="lazy" src="{{ subreddit.icon }}" alt="r/{{ subreddit.name }} icon">{% endif %}</div>
<div class="search_subreddit_right"> <div class="search_subreddit_right">
<p class="search_subreddit_header"> <p class="search_subreddit_header">
<span class="search_subreddit_name">{{ subreddit.name }}</span> <span class="search_subreddit_name">r/{{ subreddit.name }}</span>
<span class="dot">&bull;</span> <span class="dot">&bull;</span>
<span class="search_subreddit_members" title="{{ subreddit.subscribers.1 }} Members">{{ subreddit.subscribers.0 }} Members</span> <span class="search_subreddit_members" title="{{ subreddit.subscribers.1 }} Members">{{ subreddit.subscribers.0 }} Members</span>
</p> </p>
@ -54,10 +55,15 @@
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
{% if params.typed != "sr_user" %} {% endif %}
{% if all_posts_filtered %}
<center>(All content on this page has been filtered)</center>
{% else if is_filtered %}
<center>(Content from r/{{ sub }} has been filtered)</center>
{% else if params.typed != "sr_user" %}
{% for post in posts %} {% for post in posts %}
{% if post.flags.nsfw && prefs.show_nsfw != "on" %} {% if post.flags.nsfw && prefs.show_nsfw != "on" %}
{% else if post.title != "Comment" %} {% else if !post.title.is_empty() %}
{% call utils::post_in_list(post) %} {% call utils::post_in_list(post) %}
{% else %} {% else %}
<div class="comment"> <div class="comment">

View File

@ -92,10 +92,25 @@
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
{% if !prefs.filters.is_empty() %}
<div class="prefs" id="settings_filters">
<p>Filtered Feeds</p>
{% for sub in prefs.filters %}
<div>
{% let feed -%}
{% if sub.starts_with("u_") -%}{% let feed = format!("u/{}", &sub[2..]) -%}{% else -%}{% let feed = format!("r/{}", sub) -%}{% endif -%}
<a href="/{{ feed }}">{{ feed }}</a>
<form action="/r/{{ sub }}/unfilter/?redirect=settings" method="POST">
<button class="unfilter">Unfilter</button>
</form>
</div>
{% endfor %}
</div>
{% endif %}
<div id="settings_note"> <div id="settings_note">
<p><b>Note:</b> settings and subscriptions are saved in browser cookies. Clearing your cookies will reset them.</p><br> <p><b>Note:</b> settings and subscriptions are saved in browser cookies. Clearing your cookies will reset them.</p><br>
<p>You can restore your current settings and subscriptions after clearing your cookies using <a href="/settings/restore/?theme={{ prefs.theme }}&front_page={{ prefs.front_page }}&layout={{ prefs.layout }}&wide={{ prefs.wide }}&comment_sort={{ prefs.comment_sort }}&show_nsfw={{ prefs.show_nsfw }}&use_hls={{ prefs.use_hls }}&hide_hls_notification={{ prefs.hide_hls_notification }}&subscriptions={{ prefs.subscriptions.join("%2B") }}">this link</a>.</p> <p>You can restore your current settings and subscriptions after clearing your cookies using <a href="/settings/restore/?theme={{ prefs.theme }}&front_page={{ prefs.front_page }}&layout={{ prefs.layout }}&wide={{ prefs.wide }}&comment_sort={{ prefs.comment_sort }}&show_nsfw={{ prefs.show_nsfw }}&use_hls={{ prefs.use_hls }}&hide_hls_notification={{ prefs.hide_hls_notification }}&subscriptions={{ prefs.subscriptions.join("%2B") }}&filters={{ prefs.filters.join("%2B") }}">this link</a>.</p>
</div> </div>
</div> </div>

View File

@ -17,6 +17,7 @@
{% block body %} {% block body %}
<main> <main>
{% if !is_filtered %}
<div id="column_one"> <div id="column_one">
<form id="sort"> <form id="sort">
<div id="sort_options"> <div id="sort_options">
@ -45,6 +46,9 @@
</form> </form>
{% endif %} {% endif %}
{% if all_posts_filtered %}
<center>(All content on this page has been filtered)</center>
{% else %}
<div id="posts"> <div id="posts">
{% for post in posts %} {% for post in posts %}
{% if !(post.flags.nsfw && prefs.show_nsfw != "on") %} {% if !(post.flags.nsfw && prefs.show_nsfw != "on") %}
@ -57,6 +61,7 @@
<script src="/playHLSVideo.js"></script> <script src="/playHLSVideo.js"></script>
{% endif %} {% endif %}
</div> </div>
{% endif %}
<footer> <footer>
{% if ends.0 != "" %} {% if ends.0 != "" %}
@ -68,8 +73,13 @@
{% endif %} {% endif %}
</footer> </footer>
</div> </div>
{% if sub.name != "" && !sub.name.contains("+") %} {% endif %}
{% if is_filtered || (sub.name != "" && !sub.name.contains("+")) %}
<aside> <aside>
{% if is_filtered %}
<center>(Content from r/{{ sub.name }} has been filtered)</center>
{% endif %}
{% if sub.name != "" && !sub.name.contains("+") %}
<div class="panel" id="subreddit"> <div class="panel" id="subreddit">
{% if sub.wiki %} {% if sub.wiki %}
<div id="top"> <div id="top">
@ -88,6 +98,7 @@
<div title="{{ sub.members.1 }}">{{ sub.members.0 }}</div> <div title="{{ sub.members.1 }}">{{ sub.members.0 }}</div>
<div title="{{ sub.active.1 }}">{{ sub.active.0 }}</div> <div title="{{ sub.active.1 }}">{{ sub.active.0 }}</div>
</div> </div>
<div id="sub_actions">
<div id="sub_subscription"> <div id="sub_subscription">
{% if prefs.subscriptions.contains(sub.name) %} {% if prefs.subscriptions.contains(sub.name) %}
<form action="/r/{{ sub.name }}/unsubscribe" method="POST"> <form action="/r/{{ sub.name }}/unsubscribe" method="POST">
@ -99,6 +110,18 @@
</form> </form>
{% endif %} {% endif %}
</div> </div>
<div id="sub_filter">
{% if prefs.filters.contains(sub.name) %}
<form action="/r/{{ sub.name }}/unfilter" method="POST">
<button class="unfilter">Unfilter</button>
</form>
{% else %}
<form action="/r/{{ sub.name }}/filter" method="POST">
<button class="filter">Filter</button>
</form>
{% endif %}
</div>
</div>
</div> </div>
</div> </div>
<details class="panel" id="sidebar"> <details class="panel" id="sidebar">
@ -115,6 +138,7 @@
</ul> #} </ul> #}
</div> </div>
</details> </details>
{% endif %}
</aside> </aside>
{% endif %} {% endif %}
</main> </main>

View File

@ -13,6 +13,7 @@
{% block body %} {% block body %}
<main> <main>
{% if !is_filtered %}
<div id="column_one"> <div id="column_one">
<form id="sort"> <form id="sort">
<select name="sort"> <select name="sort">
@ -28,11 +29,14 @@
</button> </button>
</form> </form>
{% if all_posts_filtered %}
<center>(All content on this page has been filtered)</center>
{% else %}
<div id="posts"> <div id="posts">
{% for post in posts %} {% for post in posts %}
{% if post.flags.nsfw && prefs.show_nsfw != "on" %} {% if post.flags.nsfw && prefs.show_nsfw != "on" %}
{% else if post.title != "Comment" %} {% else if !post.title.is_empty() %}
{% call utils::post_in_list(post) %} {% call utils::post_in_list(post) %}
{% else %} {% else %}
<div class="comment"> <div class="comment">
@ -55,6 +59,7 @@
<script src="/playHLSVideo.js"></script> <script src="/playHLSVideo.js"></script>
{% endif %} {% endif %}
</div> </div>
{% endif %}
<footer> <footer>
{% if ends.0 != "" %} {% if ends.0 != "" %}
@ -66,7 +71,11 @@
{% endif %} {% endif %}
</footer> </footer>
</div> </div>
{% endif %}
<aside> <aside>
{% if is_filtered %}
<center>(Content from u/{{ user.name }} has been filtered)</center>
{% endif %}
<div class="panel" id="user"> <div class="panel" id="user">
<img loading="lazy" id="user_icon" src="{{ user.icon }}" alt="User icon"> <img loading="lazy" id="user_icon" src="{{ user.icon }}" alt="User icon">
<p id="user_title">{{ user.title }}</p> <p id="user_title">{{ user.title }}</p>
@ -78,18 +87,31 @@
<div>{{ user.karma }}</div> <div>{{ user.karma }}</div>
<div>{{ user.created }}</div> <div>{{ user.created }}</div>
</div> </div>
<div id="user_subscription"> <div id="user_actions">
{% let name = ["u_", user.name.as_str()].join("") %} {% let name = ["u_", user.name.as_str()].join("") %}
<div id="user_subscription">
{% if prefs.subscriptions.contains(name) %} {% if prefs.subscriptions.contains(name) %}
<form action="/r/u_{{ user.name }}/unsubscribe" method="POST"> <form action="/r/{{ name }}/unsubscribe" method="POST">
<button class="unsubscribe">Unfollow</button> <button class="unsubscribe">Unfollow</button>
</form> </form>
{% else %} {% else %}
<form action="/r/u_{{ user.name }}/subscribe" method="POST"> <form action="/r/{{ name }}/subscribe" method="POST">
<button class="subscribe">Follow</button> <button class="subscribe">Follow</button>
</form> </form>
{% endif %} {% endif %}
</div> </div>
<div id="user_filter">
{% if prefs.filters.contains(name) %}
<form action="/r/{{ name }}/unfilter" method="POST">
<button class="unfilter">Unfilter</button>
</form>
{% else %}
<form action="/r/{{ name }}/filter" method="POST">
<button class="filter">Filter</button>
</form>
{% endif %}
</div>
</div>
</div> </div>
</aside> </aside>
</main> </main>