add: notifications for likes

TODO: notifications ui
This commit is contained in:
trisua 2025-03-29 23:51:13 -04:00
parent 6413ed09fb
commit 9dc75d7095
9 changed files with 292 additions and 27 deletions

View file

@ -600,6 +600,10 @@ nav .button:not(.title):not(.active):hover {
top: unset; top: unset;
} }
body {
padding-bottom: 72px;
}
nav button:not(.dropdown *), nav button:not(.dropdown *),
nav .button:not(.dropdown *) { nav .button:not(.dropdown *) {
font-size: 12px; font-size: 12px;

View file

@ -25,18 +25,6 @@
</h3> </h3>
<span class="fade">{{ community.title }}</span> <span class="fade">{{ community.title }}</span>
{% if user %}
<div
class="flex gap-1 reactions_box"
hook="check_reactions"
hook-arg:id="{{ community.id }}"
>
{{ components::likes(id=community.id,
asset_type="Community", likes=community.likes,
dislikes=community.dislikes) }}
</div>
{% endif %}
</div> </div>
</div> </div>
@ -69,7 +57,51 @@
<div <div
id="manage_fields" id="manage_fields"
class="flex flex-col gap-2" class="flex flex-col gap-2"
></div> >
<div class="card-nest">
<div class="card small">
<b>Read access</b>
</div>
<div class="card">
<select
onchange="save_access(event, 'read')"
>
<option value="Everybody">
Everybody
</option>
<option value="Unlisted">
Unlisted
</option>
<option value="Private">
Private
</option>
</select>
</div>
</div>
<div class="card-nest">
<div class="card small">
<b>Write access</b>
</div>
<div class="card">
<select
onchange="save_access(event, 'write')"
>
<option value="Everybody">
Everybody
</option>
<option value="Joined">
Joined
</option>
<option value="Owner">
Owner only
</option>
</select>
</div>
</div>
</div>
<hr class="margin" /> <hr class="margin" />
@ -96,7 +128,7 @@
document.getElementById("manage_fields"), document.getElementById("manage_fields"),
[ [
[ [
["display_name", "Title"], ["display_name", "Display title"],
"{{ community.context.display_name }}", "{{ community.context.display_name }}",
"input", "input",
], ],
@ -111,7 +143,7 @@
window.save_context = () => { window.save_context = () => {
fetch( fetch(
`/api/v1/communities/{{ community.id }}/context`, "/api/v1/communities/{{ community.id }}/context",
{ {
method: "POST", method: "POST",
headers: { headers: {
@ -131,6 +163,31 @@
]); ]);
}); });
}; };
window.save_access = (event, mode) => {
const selected =
event.target.selectedOptions[0];
fetch(
`/api/v1/communities/{{ community.id }}/access/${mode}`,
{
method: "POST",
headers: {
"Content-Type":
"application/json",
},
body: JSON.stringify({
access: selected.value,
}),
},
)
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
};
}, 250); }, 250);
</script> </script>
{% endif %} {% endif %}
@ -159,6 +216,28 @@
<span class="notification chip">Created</span> <span class="notification chip">Created</span>
<span class="date">{{ community.created }}</span> <span class="date">{{ community.created }}</span>
</div> </div>
<div class="w-full flex justify-between items-center">
<span class="notification chip">Score</span>
<div class="flex gap-2">
<b
>{{ community.likes - community.dislikes
}}</b
>
{% if user %}
<div
class="flex gap-1 reactions_box"
hook="check_reactions"
hook-arg:id="{{ community.id }}"
>
{{ components::likes(id=community.id,
asset_type="Community",
likes=community.likes,
dislikes=community.dislikes) }}
</div>
{% endif %}
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,5 +1,6 @@
pub mod auth; pub mod auth;
pub mod communities; pub mod communities;
pub mod notifications;
pub mod reactions; pub mod reactions;
use axum::{ use axum::{
@ -120,6 +121,16 @@ pub fn routes() -> Router {
"/auth/profile/find/{id}", "/auth/profile/find/{id}",
get(auth::profile::redirect_from_id), get(auth::profile::redirect_from_id),
) )
// notifications
.route(
"/notifications/my",
delete(notifications::delete_all_request),
)
.route("/notifications/{id}", delete(notifications::delete_request))
.route(
"/notifications/{id}/read",
delete(notifications::update_read_status_request),
)
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -182,3 +193,8 @@ pub struct CreateReaction {
pub struct UpdateUserIsVerified { pub struct UpdateUserIsVerified {
pub is_verified: bool, pub is_verified: bool,
} }
#[derive(Deserialize)]
pub struct UpdateNotificationRead {
pub read: bool,
}

View file

@ -0,0 +1,70 @@
use axum::{Extension, Json, extract::Path, response::IntoResponse};
use axum_extra::extract::CookieJar;
use tetratto_core::model::{ApiReturn, Error};
use crate::{State, get_user_from_token};
use super::UpdateNotificationRead;
pub async fn delete_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data.delete_notification(id, &user).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Notification deleted".to_string(),
payload: (),
}),
Err(e) => return Json(e.into()),
}
}
pub async fn delete_all_request(
jar: CookieJar,
Extension(data): Extension<State>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data.delete_all_notifications(&user).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Notifications deleted".to_string(),
payload: (),
}),
Err(e) => return Json(e.into()),
}
}
pub async fn update_read_status_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
Json(req): Json<UpdateNotificationRead>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data.update_notification_read(id, req.read, &user).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Notification updated".to_string(),
payload: (),
}),
Err(e) => return Json(e.into()),
}
}

View file

@ -61,12 +61,10 @@ pub async fn create_request(
// create reaction // create reaction
match data match data
.create_reaction(Reaction::new( .create_reaction(
user.id, Reaction::new(user.id, asset_id, req.asset_type, req.is_like),
asset_id, &user,
req.asset_type, )
req.is_like,
))
.await .await
{ {
Ok(_) => Json(ApiReturn { Ok(_) => Json(ApiReturn {

View file

@ -3,5 +3,6 @@ CREATE TABLE IF NOT EXISTS notifications (
created INTEGER NOT NULL, created INTEGER NOT NULL,
title TEXT NOT NULL, title TEXT NOT NULL,
content TEXT NOT NULL, content TEXT NOT NULL,
owner INTEGER NOT NULL owner INTEGER NOT NULL,
read INTEGER NOT NULL
) )

View file

@ -21,6 +21,7 @@ impl DataManager {
title: get!(x->2(String)), title: get!(x->2(String)),
content: get!(x->3(String)), content: get!(x->3(String)),
owner: get!(x->4(isize)) as usize, owner: get!(x->4(isize)) as usize,
read: if get!(x->5(i8)) == 1 { true } else { false },
} }
} }
@ -59,13 +60,14 @@ impl DataManager {
let res = execute!( let res = execute!(
&conn, &conn,
"INSERT INTO reactions VALUES ($1, $2, $3, $4, $5)", "INSERT INTO notifications VALUES ($1, $2, $3, $4, $5, $6)",
&[ &[
&data.id.to_string().as_str(), &data.id.to_string().as_str(),
&data.created.to_string().as_str(), &data.created.to_string().as_str(),
&data.title.to_string().as_str(), &data.title.to_string().as_str(),
&data.content.to_string().as_str(), &data.content.to_string().as_str(),
&data.owner.to_string().as_str() &data.owner.to_string().as_str(),
&(if data.read { 1 } else { 0 }).to_string().as_str()
] ]
); );
@ -80,7 +82,7 @@ impl DataManager {
Ok(()) Ok(())
} }
pub async fn delete_notification(&self, id: usize, user: User) -> Result<()> { pub async fn delete_notification(&self, id: usize, user: &User) -> Result<()> {
let notification = self.get_notification_by_id(id).await?; let notification = self.get_notification_by_id(id).await?;
if user.id != notification.owner { if user.id != notification.owner {
@ -114,4 +116,61 @@ impl DataManager {
// return // return
Ok(()) Ok(())
} }
pub async fn delete_all_notifications(&self, user: &User) -> Result<()> {
let notifications = self.get_notifications_by_owner(user.id).await?;
for notification in notifications {
if user.id != notification.owner {
if !user.permissions.check(FinePermission::MANAGE_NOTIFICATIONS) {
return Err(Error::NotAllowed);
}
}
self.delete_notification(notification.id, user).await?
}
Ok(())
}
pub async fn update_notification_read(
&self,
id: usize,
new_read: bool,
user: &User,
) -> Result<()> {
let y = self.get_notification_by_id(id).await?;
if y.owner != user.id {
if !user.permissions.check(FinePermission::MANAGE_NOTIFICATIONS) {
return Err(Error::NotAllowed);
}
}
// ...
let conn = match self.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = execute!(
&conn,
"UPDATE notifications SET read = $1 WHERE id = $2",
&[&(if new_read { 1 } else { 0 }).to_string(), &id.to_string()]
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
self.2.remove(format!("atto.notification:{}", id)).await;
if (y.read == true) && (new_read == false) {
self.incr_user_notifications(user.id).await?;
} else if (y.read == false) && (new_read == true) {
self.decr_user_notifications(user.id).await?;
}
Ok(())
}
} }

View file

@ -2,7 +2,7 @@ use super::*;
use crate::cache::Cache; use crate::cache::Cache;
use crate::model::{ use crate::model::{
Error, Result, Error, Result,
auth::User, auth::{Notification, User},
permissions::FinePermission, permissions::FinePermission,
reactions::{AssetType, Reaction}, reactions::{AssetType, Reaction},
}; };
@ -61,7 +61,7 @@ impl DataManager {
/// ///
/// # Arguments /// # Arguments
/// * `data` - a mock [`Reaction`] object to insert /// * `data` - a mock [`Reaction`] object to insert
pub async fn create_reaction(&self, data: Reaction) -> Result<()> { pub async fn create_reaction(&self, data: Reaction, user: &User) -> Result<()> {
let conn = match self.connect().await { let conn = match self.connect().await {
Ok(c) => c, Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())), Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
@ -95,6 +95,24 @@ impl DataManager {
} }
} { } {
return Err(e); return Err(e);
} else if data.is_like {
let community = self.get_community_by_id(data.asset).await.unwrap();
if community.owner != user.id {
if let Err(e) = self
.create_notification(Notification::new(
"Your community has received a like!".to_string(),
format!(
"[@{}](/api/v1/auth/profile/find/{}) has liked your community!",
user.username, user.id
),
community.owner,
))
.await
{
return Err(e);
}
}
} }
} }
AssetType::Post => { AssetType::Post => {
@ -106,6 +124,24 @@ impl DataManager {
} }
} { } {
return Err(e); return Err(e);
} else if data.is_like {
let post = self.get_post_by_id(data.asset).await.unwrap();
if post.owner != user.id {
if let Err(e) = self
.create_notification(Notification::new(
"Your post has received a like!".to_string(),
format!(
"[@{}](/api/v1/auth/profile/find/{}) has liked your post!",
user.username, user.id
),
post.owner,
))
.await
{
return Err(e);
}
}
} }
} }
}; };

View file

@ -114,6 +114,7 @@ pub struct Notification {
pub title: String, pub title: String,
pub content: String, pub content: String,
pub owner: usize, pub owner: usize,
pub read: bool,
} }
impl Notification { impl Notification {
@ -128,6 +129,7 @@ impl Notification {
title, title,
content, content,
owner, owner,
read: false,
} }
} }
} }