Add notifications page to manage follow requests

This commit is contained in:
asonix 2021-01-14 23:51:17 -06:00
parent 0a64034ea9
commit c9b0c1a7cf
12 changed files with 599 additions and 17 deletions

View file

@ -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" }

View file

@ -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};

View file

@ -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;

View file

@ -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)?

View file

@ -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::<Query<Vec<(String, String)>>>::extract(req);
let admin = Option::<Admin>::extract(req);
let path = req.uri().path().to_owned();
let state = Data::<State>::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");

486
server/src/notifications.rs Normal file
View file

@ -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<State>,
) -> Result<HttpResponse, Error> {
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<State>) -> Result<HttpResponse, Error> {
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<State>) -> Result<HttpResponse, Error> {
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<State>) -> Result<HttpResponse, Error> {
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<Uuid>,
profile: Profile,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
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<Uuid>,
profile: Profile,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
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<Uuid>,
profile: Profile,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
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<String> {
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<Link> {
self.submission_path().map(|path| {
let mut link = Link::new_tab(&path);
link.plain(true).dark(dark);
link
})
}
fn reply_to_path(&self) -> Option<String> {
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<Link> {
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<Uuid, Comment>,
profile_hm: HashMap<Uuid, Profile>,
fr_profile_hm: HashMap<Uuid, Uuid>,
comments: Vec<Uuid>,
follow_requests: Vec<Uuid>,
count: u64,
}
impl NotificationsView {
pub(crate) fn comments<'a>(&'a self) -> impl Iterator<Item = CommentView<'a>> + '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<Item = FollowRequestView<'a>> + '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<Self, Error> {
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<u64, Error> {
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<State>,
) -> Result<HttpResponse, Error> {
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<HttpResponse, Error> {
Ok(to_notifications_page())
}
fn to_notifications_page() -> HttpResponse {
crate::redirect("/notifications")
}

View file

@ -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},

View file

@ -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");

View file

@ -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)]

View file

@ -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({
<p>This will clear all notifications except for Follow Requests</p>
})
@: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 })
}
<div class="button-section">
@:button_group(&[
&c.view_button(nav_state.dark()),
&c.remove_button(nav_state.dark()),
])
</div>
})
}
@:card_body({
@:button_group(&[
&view.clear_comments_button(nav_state.dark()),
])
})
})
}
})

View file

@ -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<DateTime<Utc>>, dark: bool, body: Content)
<div class="profile-box">
@:tkicon(&profile.view_path(), true, dark, {
@ -27,7 +28,7 @@
@profile.full_handle()
})
</div>
@if let Some(published) = submission.published() {
@if let Some(published) = published {
<div class="profile-box--meta--date">
posted
@:ago(published, dark)

View file

@ -29,7 +29,7 @@
</div>
})
@: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
}