From c9b0c1a7cfa3b5e873d161b90b2340df1e9dd516 Mon Sep 17 00:00:00 2001 From: asonix Date: Thu, 14 Jan 2021 23:51:17 -0600 Subject: [PATCH] Add notifications page to manage follow requests --- server/Cargo.toml | 3 +- server/src/admin.rs | 2 +- server/src/jobs.rs | 2 +- server/src/main.rs | 2 + server/src/nav.rs | 26 +- server/src/notifications.rs | 486 ++++++++++++++++++ server/src/profiles/middleware.rs | 3 +- server/src/profiles/mod.rs | 6 +- server/src/submissions/middleware.rs | 2 +- server/templates/notifications/index.rs.html | 75 +++ .../templates/submissions/profile_box.rs.html | 7 +- server/templates/submissions/public.rs.html | 2 +- 12 files changed, 599 insertions(+), 17 deletions(-) create mode 100644 server/src/notifications.rs create mode 100644 server/templates/notifications/index.rs.html diff --git a/server/Cargo.toml b/server/Cargo.toml index 54d4193..28183c9 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -17,8 +17,7 @@ base64 = "0.13.0" chrono = { version = "0.4.19", features = ["serde"] } env_logger = "0.8.2" event-listener = "2.5.1" -futures-core = "0.3.8" -futures-util = "0.3.8" +futures = "0.3.11" hyaenidae-accounts = { version = "0.1.0", path = "../accounts" } hyaenidae-profiles = { version = "0.1.0", path = "../profiles" } hyaenidae-toolkit = { version = "0.1.0", path = "../toolkit" } diff --git a/server/src/admin.rs b/server/src/admin.rs index 755ad5a..2091d78 100644 --- a/server/src/admin.rs +++ b/server/src/admin.rs @@ -8,7 +8,7 @@ use crate::{ }; use actix_web::{dev::Payload, web, FromRequest, HttpRequest, HttpResponse, Scope}; use chrono::{DateTime, Utc}; -use futures_core::future::LocalBoxFuture; +use futures::future::LocalBoxFuture; use hyaenidae_accounts::{State as AccountState, User}; use hyaenidae_profiles::store::ReportKind; use hyaenidae_toolkit::{Select, TextInput}; diff --git a/server/src/jobs.rs b/server/src/jobs.rs index 2d9f5b7..f36fe3d 100644 --- a/server/src/jobs.rs +++ b/server/src/jobs.rs @@ -4,7 +4,7 @@ use background_jobs::{ create_server, memory_storage::Storage, ActixJob, Backoff, MaxRetries, QueueHandle, WorkerConfig, }; -use futures_core::future::LocalBoxFuture; +use futures::future::LocalBoxFuture; use hyaenidae_profiles::Spawner; use url::Url; use uuid::Uuid; diff --git a/server/src/main.rs b/server/src/main.rs index bb4ce52..5446fbe 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -20,6 +20,7 @@ mod error; mod images; mod jobs; mod nav; +mod notifications; mod profiles; mod submissions; @@ -128,6 +129,7 @@ async fn main() -> anyhow::Result<()> { .service(submissions::scope()) .service(comments::scope()) .service(admin::scope()) + .service(notifications::scope()) .default_service(web::route().to(|| async move { to_404() })) }) .bind(config.bind_address)? diff --git a/server/src/nav.rs b/server/src/nav.rs index c2a4318..70db3f9 100644 --- a/server/src/nav.rs +++ b/server/src/nav.rs @@ -1,6 +1,10 @@ -use crate::{admin::Admin, profiles::Profile}; -use actix_web::{dev::Payload, web::Query, FromRequest, HttpRequest}; -use futures_core::future::LocalBoxFuture; +use crate::{admin::Admin, notifications::total_for_profile, profiles::Profile, State}; +use actix_web::{ + dev::Payload, + web::{Data, Query}, + FromRequest, HttpRequest, +}; +use futures::future::LocalBoxFuture; use hyaenidae_accounts::LogoutState; use hyaenidae_toolkit::Button; @@ -15,12 +19,14 @@ impl FromRequest for NavState { let query = Option::>>::extract(req); let admin = Option::::extract(req); let path = req.uri().path().to_owned(); + let state = Data::::extract(req); Box::pin(async move { let profile = profile.await?; let logout = logout.await?; let query = query.await?; let admin = admin.await?; + let state = state.await?; let dark = true; let mut nav = vec![]; @@ -35,7 +41,7 @@ impl FromRequest for NavState { account.href("/session/account").dark(dark); logout.dark(dark); - let profile = if let Some(profile) = profile { + let profile_btn = if let Some(profile) = profile.as_ref() { let btn = Button::secondary("Profile"); btn.href(&profile.view_path()).dark(dark); btn @@ -46,7 +52,17 @@ impl FromRequest for NavState { }; nav.push(submission); - nav.push(profile); + nav.push(profile_btn); + if let Some(profile) = profile.as_ref() { + if let Ok(count) = total_for_profile(profile.id(), &state).await { + if count > 0 { + let btn = Button::secondary("Notifications"); + btn.href("/notifications").dark(dark); + nav.push(btn); + } + } + } + nav.push(account); if admin.is_some() { let admin = Button::secondary("Admin"); diff --git a/server/src/notifications.rs b/server/src/notifications.rs new file mode 100644 index 0000000..b237a8b --- /dev/null +++ b/server/src/notifications.rs @@ -0,0 +1,486 @@ +use crate::{comments::Comment, error::Error, nav::NavState, profiles::Profile, State}; +use actix_web::{web, HttpResponse, Scope}; +use futures::stream::{FuturesUnordered, StreamExt}; +use hyaenidae_toolkit::{Button, Link}; +use std::collections::HashMap; +use uuid::Uuid; + +pub(super) fn scope() -> Scope { + web::scope("/notifications") + .service( + web::resource("") + .route(web::get().to(notifications_page)) + .route(web::post().to(update_notifications)), + ) + .service( + web::scope("/follow-requests") + .service( + web::resource("/accept-all") + .route(web::get().to(to_notifications_page)) + .route(web::post().to(accept_all)), + ) + .service( + web::resource("/reject-all") + .route(web::get().to(to_notifications_page)) + .route(web::post().to(reject_all)), + ) + .service( + web::scope("/{id}") + .service( + web::resource("/accept") + .route(web::get().to(to_notifications_page)) + .route(web::post().to(accept_follow_request)), + ) + .service( + web::resource("/reject") + .route(web::get().to(to_notifications_page)) + .route(web::post().to(reject_follow_request)), + ), + ), + ) + .service( + web::scope("/comments") + .service( + web::resource("/clear-all") + .route(web::get().to(to_notifications_page)) + .route(web::post().to(clear_comments)), + ) + .service( + web::resource("/{id}/remove") + .route(web::get().to(to_notifications_page)) + .route(web::post().to(remove_comment_notification)), + ), + ) + .service( + web::resource("/clear") + .route(web::get().to(to_notifications_page)) + .route(web::post().to(clear_notifications)), + ) +} + +async fn clear_notifications( + profile: Profile, + state: web::Data, +) -> Result { + let profile_id = profile.id(); + let comments = state.profiles.store.view.comments.clone(); + let reacts = state.profiles.store.view.reacts.clone(); + + web::block(move || { + reacts.clear(profile_id, None)?; + comments.clear(profile_id, None)?; + + Ok(()) + }) + .await?; + + Ok(to_notifications_page()) +} + +async fn clear_comments(profile: Profile, state: web::Data) -> Result { + let profile_id = profile.id(); + let comments = state.profiles.store.view.comments.clone(); + web::block(move || { + comments.clear(profile_id, None)?; + Ok(()) + }) + .await?; + + Ok(to_notifications_page()) +} + +async fn accept_all(profile: Profile, state: web::Data) -> Result { + use hyaenidae_profiles::apub::actions::AcceptFollowRequest; + + let profile_id = profile.id(); + let follow_requests = state.profiles.store.view.follow_request_notifs.clone(); + let requests: Vec<_> = + web::block(move || Ok(follow_requests.for_profile(profile_id).collect())).await?; + + let mut unorderd = FuturesUnordered::new(); + + for request_id in requests { + unorderd.push(state.profiles.run(AcceptFollowRequest::from_id(request_id))); + } + + while let Some(res) = unorderd.next().await { + res?; + } + + Ok(to_notifications_page()) +} + +async fn reject_all(profile: Profile, state: web::Data) -> Result { + use hyaenidae_profiles::apub::actions::RejectFollowRequest; + + let profile_id = profile.id(); + let follow_requests = state.profiles.store.view.follow_request_notifs.clone(); + let requests: Vec<_> = + web::block(move || Ok(follow_requests.for_profile(profile_id).collect())).await?; + + let mut unorderd = FuturesUnordered::new(); + + for request_id in requests { + unorderd.push(state.profiles.run(RejectFollowRequest::from_id(request_id))); + } + + while let Some(res) = unorderd.next().await { + res?; + } + + Ok(to_notifications_page()) +} + +async fn accept_follow_request( + freq_id: web::Path, + profile: Profile, + state: web::Data, +) -> Result { + use hyaenidae_profiles::apub::actions::AcceptFollowRequest; + + let profile_id = profile.id(); + let freq_id = freq_id.into_inner(); + let follow_requests = state.profiles.store.view.follow_requests.clone(); + let freq_is_valid = web::block(move || { + Ok(follow_requests + .left(freq_id)? + .map(|left_id| left_id == profile_id) + .unwrap_or(false)) + }) + .await?; + + if !freq_is_valid { + return Ok(crate::to_404()); + } + + state + .profiles + .run(AcceptFollowRequest::from_id(freq_id)) + .await?; + + Ok(to_notifications_page()) +} + +async fn reject_follow_request( + freq_id: web::Path, + profile: Profile, + state: web::Data, +) -> Result { + use hyaenidae_profiles::apub::actions::RejectFollowRequest; + + let profile_id = profile.id(); + let freq_id = freq_id.into_inner(); + let follow_requests = state.profiles.store.view.follow_requests.clone(); + let freq_is_valid = web::block(move || { + Ok(follow_requests + .left(freq_id)? + .map(|left_id| left_id == profile_id) + .unwrap_or(false)) + }) + .await?; + + if !freq_is_valid { + return Ok(crate::to_404()); + } + + state + .profiles + .run(RejectFollowRequest::from_id(freq_id)) + .await?; + + Ok(to_notifications_page()) +} + +async fn remove_comment_notification( + comment_id: web::Path, + profile: Profile, + state: web::Data, +) -> Result { + let profile_id = profile.id(); + let comment_id = comment_id.into_inner(); + + let comment_notifs = state.profiles.store.view.comments.clone(); + web::block(move || Ok(comment_notifs.clear(profile_id, Some(vec![comment_id]))?)).await?; + + Ok(to_notifications_page()) +} + +pub(crate) struct CommentView<'a> { + comment: &'a Comment, + author: &'a Profile, + id: Uuid, +} + +impl<'a> CommentView<'a> { + fn submission_path(&self) -> Option { + if self.comment.comment_id().is_none() { + Some(format!("/submissions/{}", self.comment.submission_id())) + } else { + None + } + } + + pub(crate) fn submission_link(&self, dark: bool) -> Option { + self.submission_path().map(|path| { + let mut link = Link::new_tab(&path); + link.plain(true).dark(dark); + link + }) + } + + fn reply_to_path(&self) -> Option { + if let Some(reply_to_id) = self.comment.comment_id() { + Some(format!("/comments/{}", reply_to_id)) + } else { + None + } + } + + pub(crate) fn reply_to_link(&self, dark: bool) -> Option { + self.reply_to_path().map(|path| { + let mut link = Link::new_tab(&path); + link.plain(true).dark(dark); + link + }) + } + + fn comment_path(&self) -> String { + format!("/comments/{}", self.comment.id()) + } + + pub(crate) fn view_button(&self, dark: bool) -> Button { + let btn = Button::primary("View"); + btn.href(&self.comment_path()).new_tab().dark(dark); + btn + } + + fn remove_path(&self) -> String { + format!("/notifications/comments/{}/remove", self.id) + } + + pub(crate) fn remove_button(&self, dark: bool) -> Button { + let btn = Button::primary_outline("Remove"); + btn.form(&self.remove_path()).dark(dark); + btn + } + + pub(crate) fn author_name(&self) -> String { + self.author.name() + } + + pub(crate) fn author_link(&self, dark: bool) -> Link { + let mut link = Link::new_tab(&self.author.view_path()); + link.plain(true).dark(dark); + link + } +} + +pub(crate) struct FollowRequestView<'a> { + pub(crate) profile: &'a Profile, + id: Uuid, +} + +impl<'a> FollowRequestView<'a> { + pub(crate) fn accept_button(&self, dark: bool) -> Button { + let btn = Button::primary("Accept"); + btn.form(&format!( + "/notifications/follow-requests/{}/accept", + self.id + )) + .dark(dark); + btn + } + + pub(crate) fn reject_button(&self, dark: bool) -> Button { + let btn = Button::primary_outline("Reject"); + btn.form(&format!( + "/notifications/follow-requests/{}/reject", + self.id + )) + .dark(dark); + btn + } +} + +#[derive(Debug)] +pub struct NotificationsView { + comment_hm: HashMap, + profile_hm: HashMap, + fr_profile_hm: HashMap, + comments: Vec, + follow_requests: Vec, + count: u64, +} + +impl NotificationsView { + pub(crate) fn comments<'a>(&'a self) -> impl Iterator> + 'a { + self.comments.iter().filter_map(move |comment_id| { + let comment = self.comment_hm.get(comment_id)?; + let author = self.profile_hm.get(&comment.profile_id())?; + + Some(CommentView { + comment, + author, + id: *comment_id, + }) + }) + } + + pub(crate) fn follow_requests<'a>( + &'a self, + ) -> impl Iterator> + 'a { + self.follow_requests.iter().filter_map(move |fr_id| { + let profile_id = self.fr_profile_hm.get(fr_id)?; + let profile = self.profile_hm.get(profile_id)?; + + Some(FollowRequestView { + id: *fr_id, + profile, + }) + }) + } + + fn clear_path(&self) -> &'static str { + "/notifications/clear" + } + + pub(crate) fn clear_button(&self, dark: bool) -> Button { + let btn = Button::primary("Clear All"); + btn.form(self.clear_path()).dark(dark); + btn + } + + fn reject_path(&self) -> &'static str { + "/notifications/follow-requests/reject-all" + } + + pub(crate) fn reject_all_button(&self, dark: bool) -> Button { + let btn = Button::primary_outline("Reject All"); + btn.form(self.reject_path()).dark(dark); + btn + } + + fn accept_path(&self) -> &'static str { + "/notifications/follow-requests/accept-all" + } + + pub(crate) fn accept_all_button(&self, dark: bool) -> Button { + let btn = Button::primary("Accept All"); + btn.form(self.accept_path()).dark(dark); + btn + } + + fn clear_comments_path(&self) -> &'static str { + "/notifications/comments/clear-all" + } + + pub(crate) fn clear_comments_button(&self, dark: bool) -> Button { + let btn = Button::primary("Clear Comments"); + btn.form(self.clear_comments_path()).dark(dark); + btn + } + + pub(crate) fn count(&self) -> u64 { + self.count + } + + pub(crate) fn has_follow_requests(&self) -> bool { + !self.follow_requests.is_empty() + } + + pub(crate) fn has_comments(&self) -> bool { + !self.comments.is_empty() + } + + async fn build(profile_id: Uuid, state: &State) -> Result { + let count = total_for_profile(profile_id, state).await?; + + let comment_store = state.profiles.store.comments.clone(); + let profile_store = state.profiles.store.profiles.clone(); + let file_store = state.profiles.store.files.clone(); + let follow_request_store = state.profiles.store.view.follow_requests.clone(); + let comment_notifs = state.profiles.store.view.comments.clone(); + let follow_request_notifs = state.profiles.store.view.follow_request_notifs.clone(); + + let view = web::block(move || { + let mut view = NotificationsView { + comment_hm: HashMap::new(), + profile_hm: HashMap::new(), + fr_profile_hm: HashMap::new(), + comments: vec![], + follow_requests: vec![], + count, + }; + + for comment_id in comment_notifs.for_profile(profile_id) { + if let Some(comment) = comment_store.by_id(comment_id)? { + if !view.profile_hm.contains_key(&comment.profile_id()) { + let profile = Profile::from_stores( + comment.profile_id(), + &profile_store, + &file_store, + )?; + view.profile_hm.insert(profile.id(), profile); + } + + view.comments.push(comment.id()); + view.comment_hm.insert(comment.id(), comment); + } + } + + for fr_id in follow_request_notifs.for_profile(profile_id) { + if let Some(follow_req) = follow_request_store.by_id(fr_id)? { + if !view.profile_hm.contains_key(&follow_req.right) { + let profile = + Profile::from_stores(follow_req.right, &profile_store, &file_store)?; + view.profile_hm.insert(profile.id(), profile); + } + + view.fr_profile_hm.insert(follow_req.id, follow_req.right); + view.follow_requests.push(follow_req.id); + } + } + + Ok(view) + }) + .await?; + + Ok(view) + } +} + +pub(crate) async fn total_for_profile(profile_id: Uuid, state: &State) -> Result { + let follow_requests = state.profiles.store.view.follow_request_notifs.clone(); + let comments = state.profiles.store.view.comments.clone(); + let reacts = state.profiles.store.view.reacts.clone(); + + let count = web::block(move || { + let count = follow_requests.count(profile_id)? + + comments.count(profile_id)? + + reacts.count(profile_id)?; + Ok(count) + }) + .await?; + + Ok(count) +} + +async fn notifications_page( + profile: Profile, + nav_state: NavState, + state: web::Data, +) -> Result { + let view = NotificationsView::build(profile.id(), &state).await?; + + crate::rendered(HttpResponse::Ok(), |cursor| { + crate::templates::notifications::index(cursor, &view, &nav_state) + }) +} + +async fn update_notifications(_: NavState) -> Result { + Ok(to_notifications_page()) +} + +fn to_notifications_page() -> HttpResponse { + crate::redirect("/notifications") +} diff --git a/server/src/profiles/middleware.rs b/server/src/profiles/middleware.rs index dcc2ab7..7343e0c 100644 --- a/server/src/profiles/middleware.rs +++ b/server/src/profiles/middleware.rs @@ -7,8 +7,7 @@ use actix_web::{ FromRequest, HttpMessage, HttpRequest, HttpResponse, ResponseError, }; use event_listener::Event; -use futures_core::future::LocalBoxFuture; -use futures_util::future::{ok, Ready}; +use futures::future::{ok, LocalBoxFuture, Ready}; use hyaenidae_accounts::Authenticated; use std::{ cell::{Cell, RefCell}, diff --git a/server/src/profiles/mod.rs b/server/src/profiles/mod.rs index 5f81485..4943f3b 100644 --- a/server/src/profiles/mod.rs +++ b/server/src/profiles/mod.rs @@ -1218,7 +1218,11 @@ async fn profile_buttons( buttons.push(edit); buttons.push(switch); } else if is_follower || is_follow_requested { - let unfollow = Button::secondary("Unfollow"); + let unfollow = if is_follower { + Button::secondary("Unfollow") + } else { + Button::secondary("Remove Request") + }; let block = Button::secondary("Block"); let report = Button::primary_outline("Report"); diff --git a/server/src/submissions/middleware.rs b/server/src/submissions/middleware.rs index e89240b..9e4b968 100644 --- a/server/src/submissions/middleware.rs +++ b/server/src/submissions/middleware.rs @@ -4,7 +4,7 @@ use actix_web::{ web::{Data, Path}, FromRequest, HttpRequest, }; -use futures_core::future::LocalBoxFuture; +use futures::future::LocalBoxFuture; use uuid::Uuid; #[derive(Clone, Debug, serde::Deserialize)] diff --git a/server/templates/notifications/index.rs.html b/server/templates/notifications/index.rs.html new file mode 100644 index 0000000..00445ed --- /dev/null +++ b/server/templates/notifications/index.rs.html @@ -0,0 +1,75 @@ +@use crate::nav::NavState; +@use crate::notifications::NotificationsView; +@use crate::templates::layouts::home; +@use crate::templates::submissions::profile_box; +@use hyaenidae_toolkit::templates::button_group; +@use hyaenidae_toolkit::{templates::{card, card_body, card_title}, Card}; +@use hyaenidae_toolkit::templates::link; + +@(view: &NotificationsView, nav_state: &NavState) + +@:home(&format!("Notifications: {}", view.count()), "Notifications on Hyaenidae", nav_state, {}, { + @:card(Card::full_width().dark(nav_state.dark()), { + @:card_title({ Clear All }) + @:card_body({ +

This will clear all notifications except for Follow Requests

+ }) + @:card_body({ + @:button_group(&[ + &view.clear_button(nav_state.dark()), + ]) + }) + }) + @if view.has_follow_requests() { + @:card(Card::full_width().dark(nav_state.dark()), { + @:card_title({ Follow Requests }) + @for fr in view.follow_requests() { + @:card_body({ + @:profile_box(fr.profile, None, nav_state.dark(), { + @:button_group(&[ + &fr.accept_button(nav_state.dark()), + &fr.reject_button(nav_state.dark()), + ]) + }) + }) + } + @:card_body({ + @:button_group(&[ + &view.accept_all_button(nav_state.dark()), + &view.reject_all_button(nav_state.dark()), + ]) + }) + }) + } + @if view.has_comments() { + @:card(Card::full_width().dark(nav_state.dark()), { + @:card_title({ Comments }) + @for c in view.comments() { + @:card_body({ + @:link(&c.author_link(nav_state.dark()), { + @c.author_name() + }) + @if let Some(l) = c.submission_link(nav_state.dark()) { + commented on your + @:link(&l, { submission }) + } + @if let Some(l) = c.reply_to_link(nav_state.dark()) { + replied to your + @:link(&l, { comment }) + } +
+ @:button_group(&[ + &c.view_button(nav_state.dark()), + &c.remove_button(nav_state.dark()), + ]) +
+ }) + } + @:card_body({ + @:button_group(&[ + &view.clear_comments_button(nav_state.dark()), + ]) + }) + }) + } +}) diff --git a/server/templates/submissions/profile_box.rs.html b/server/templates/submissions/profile_box.rs.html index a7c68b8..bd60f01 100644 --- a/server/templates/submissions/profile_box.rs.html +++ b/server/templates/submissions/profile_box.rs.html @@ -1,10 +1,11 @@ @use crate::templates::profiles::icon; -@use crate::{profiles::Profile, submissions::Submission}; +@use crate::profiles::Profile; +@use chrono::{DateTime, Utc}; @use hyaenidae_toolkit::{templates::link, Link}; @use hyaenidae_toolkit::templates::ago; @use hyaenidae_toolkit::templates::icon as tkicon; -@(profile: &Profile, submission: &Submission, dark: bool, body: Content) +@(profile: &Profile, published: Option>, dark: bool, body: Content)
@:tkicon(&profile.view_path(), true, dark, { @@ -27,7 +28,7 @@ @profile.full_handle() })
- @if let Some(published) = submission.published() { + @if let Some(published) = published {
posted @:ago(published, dark) diff --git a/server/templates/submissions/public.rs.html b/server/templates/submissions/public.rs.html index 4551a42..80372df 100644 --- a/server/templates/submissions/public.rs.html +++ b/server/templates/submissions/public.rs.html @@ -29,7 +29,7 @@
}) @:card_body({ - @:profile_box(&view.poster, &view.submission, nav_state.dark(), { + @:profile_box(&view.poster, view.submission.published(), nav_state.dark(), { @if let Some(description) = view.submission.description() { @description }