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;
}
body {
padding-bottom: 72px;
}
nav button:not(.dropdown *),
nav .button:not(.dropdown *) {
font-size: 12px;

View file

@ -25,18 +25,6 @@
</h3>
<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>
@ -69,7 +57,51 @@
<div
id="manage_fields"
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" />
@ -96,7 +128,7 @@
document.getElementById("manage_fields"),
[
[
["display_name", "Title"],
["display_name", "Display title"],
"{{ community.context.display_name }}",
"input",
],
@ -111,7 +143,7 @@
window.save_context = () => {
fetch(
`/api/v1/communities/{{ community.id }}/context`,
"/api/v1/communities/{{ community.id }}/context",
{
method: "POST",
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);
</script>
{% endif %}
@ -159,6 +216,28 @@
<span class="notification chip">Created</span>
<span class="date">{{ community.created }}</span>
</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>

View file

@ -1,5 +1,6 @@
pub mod auth;
pub mod communities;
pub mod notifications;
pub mod reactions;
use axum::{
@ -120,6 +121,16 @@ pub fn routes() -> Router {
"/auth/profile/find/{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)]
@ -182,3 +193,8 @@ pub struct CreateReaction {
pub struct UpdateUserIsVerified {
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
match data
.create_reaction(Reaction::new(
user.id,
asset_id,
req.asset_type,
req.is_like,
))
.create_reaction(
Reaction::new(user.id, asset_id, req.asset_type, req.is_like),
&user,
)
.await
{
Ok(_) => Json(ApiReturn {

View file

@ -3,5 +3,6 @@ CREATE TABLE IF NOT EXISTS notifications (
created INTEGER NOT NULL,
title 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)),
content: get!(x->3(String)),
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!(
&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.created.to_string().as_str(),
&data.title.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(())
}
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?;
if user.id != notification.owner {
@ -114,4 +116,61 @@ impl DataManager {
// return
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::model::{
Error, Result,
auth::User,
auth::{Notification, User},
permissions::FinePermission,
reactions::{AssetType, Reaction},
};
@ -61,7 +61,7 @@ impl DataManager {
///
/// # Arguments
/// * `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 {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
@ -95,6 +95,24 @@ impl DataManager {
}
} {
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 => {
@ -106,6 +124,24 @@ impl DataManager {
}
} {
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 content: String,
pub owner: usize,
pub read: bool,
}
impl Notification {
@ -128,6 +129,7 @@ impl Notification {
title,
content,
owner,
read: false,
}
}
}