add: user follow requests

add: nsfw questions
fix: inherit nsfw status from questions
fix: inherit community from questions
This commit is contained in:
trisua 2025-04-14 17:21:52 -04:00
parent d6c7372610
commit ad17acec98
24 changed files with 492 additions and 59 deletions

8
Cargo.lock generated
View file

@ -3155,7 +3155,7 @@ dependencies = [
[[package]]
name = "tetratto"
version = "1.0.4"
version = "1.0.5"
dependencies = [
"ammonia",
"axum",
@ -3180,7 +3180,7 @@ dependencies = [
[[package]]
name = "tetratto-core"
version = "1.0.4"
version = "1.0.5"
dependencies = [
"async-recursion",
"bb8-postgres",
@ -3199,7 +3199,7 @@ dependencies = [
[[package]]
name = "tetratto-l10n"
version = "1.0.4"
version = "1.0.5"
dependencies = [
"pathbufd",
"serde",
@ -3208,7 +3208,7 @@ dependencies = [
[[package]]
name = "tetratto-shared"
version = "1.0.4"
version = "1.0.5"
dependencies = [
"ammonia",
"chrono",

View file

@ -1,6 +1,6 @@
[package]
name = "tetratto"
version = "1.0.4"
version = "1.0.5"
edition = "2024"
[features]

View file

@ -47,6 +47,7 @@ pub const PROFILE_SETTINGS: &str = include_str!("./public/html/profile/settings.
pub const PROFILE_FOLLOWING: &str = include_str!("./public/html/profile/following.html");
pub const PROFILE_FOLLOWERS: &str = include_str!("./public/html/profile/followers.html");
pub const PROFILE_WARNING: &str = include_str!("./public/html/profile/warning.html");
pub const PROFILE_PRIVATE: &str = include_str!("./public/html/profile/private.html");
pub const COMMUNITIES_LIST: &str = include_str!("./public/html/communities/list.html");
pub const COMMUNITIES_BASE: &str = include_str!("./public/html/communities/base.html");
@ -192,6 +193,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD {
write_template!(html_path->"profile/following.html"(crate::assets::PROFILE_FOLLOWING) --config=config);
write_template!(html_path->"profile/followers.html"(crate::assets::PROFILE_FOLLOWERS) --config=config);
write_template!(html_path->"profile/warning.html"(crate::assets::PROFILE_WARNING) --config=config);
write_template!(html_path->"profile/private.html"(crate::assets::PROFILE_PRIVATE) --config=config);
write_template!(html_path->"communities/list.html"(crate::assets::COMMUNITIES_LIST) -d "communities" --config=config);
write_template!(html_path->"communities/base.html"(crate::assets::COMMUNITIES_BASE) --config=config);

View file

@ -16,6 +16,7 @@ version = "1.0.0"
"general:link.ip_bans" = "IP bans"
"general:action.save" = "Save"
"general:action.delete" = "Delete"
"general:action.accept" = "Accept"
"general:action.back" = "Back"
"general:action.report" = "Report"
"general:action.manage" = "Manage"
@ -52,6 +53,10 @@ version = "1.0.0"
"auth:label.joined_communities" = "Joined communities"
"auth:label.recent_posts" = "Recent posts"
"auth:label.before_you_view" = "Before you view"
"auth:label.private_profile" = "Private profile"
"auth:label.private_profile_message" = "This profile is private, meaning you can only view it if they follow you."
"auto:action.request_to_follow" = "Request to follow"
"auto:action.cancel_follow_request" = "Cancel follow request"
"communities:action.create" = "Create"
"communities:action.select" = "Select"
@ -128,3 +133,6 @@ version = "1.0.0"
"requests:label.review" = "Review"
"requests:label.ask_question" = "Ask question"
"requests:label.answer" = "Answer"
"requests:label.user_follow_request" = "User follow request"
"requests:action.view_profile" = "View profile"
"requests:label.user_follow_request_message" = "Accepting this request will not allow them to see your profile. For that, you must follow them back."

View file

@ -58,6 +58,7 @@
</main>
<script>
const community = "{{ question.community }}";
async function answer_question_from_form(e, answering) {
e.preventDefault();
await trigger("atto::debounce", ["posts::create"]);
@ -68,7 +69,7 @@
},
body: JSON.stringify({
content: e.target.content.value,
community: "{{ config.town_square }}",
community: community ? community : "{{ config.town_square }}",
answering,
}),
})

View file

@ -619,7 +619,15 @@ show_community=true, secondary=false) -%}
{{ icon "message-circle-heart" }}
</span>
{% if question.community > 0 and show_community %}
{% if question.context.is_nsfw %}
<span
title="NSFW community"
class="flex items-center"
style="color: var(--color-primary)"
>
{{ icon "square-asterisk" }}
</span>
{% endif %} {% if question.community > 0 and show_community %}
<a
href="/api/v1/communities/find/{{ question.community }}"
class="flex items-center"

View file

@ -45,6 +45,47 @@
</button>
</div>
</div>
{% elif request.action_type == "Follow" %}
<div class="card-nest">
<div class="card small flex items-center gap-2">
{{ icon "user-plus" }}
<span>{{ text "requests:label.user_follow_request" }}</span>
</div>
<div class="card flex flex-col gap-2">
<span>
{{ text "requests:label.user_follow_request_message" }}
</span>
<div class="card flex w-full secondary gap-2">
<a
href="/api/v1/auth/user/find/{{ request.id }}"
class="button"
>
{{ icon "external-link" }}
<span
>{{ text "requests:action.view_profile" }}</span
>
</a>
<button
class="quaternary green"
onclick="accept_follow_request(event, '{{ request.id }}')"
>
{{ icon "check" }}
<span>{{ text "general:action.accept" }}</span>
</button>
<button
class="quaternary red"
onclick="remove_request('{{ request.id }}', '{{ request.linked_asset }}')"
>
{{ icon "trash" }}
<span>{{ text "general:action.delete" }}</span>
</button>
</div>
</div>
</div>
{% endif %} {% endfor %} {% for question in questions %}
<!-- prettier-ignore -->
<div class="card-nest">
@ -147,5 +188,49 @@
}
});
}
globalThis.accept_follow_request = async (e, id) => {
await trigger("atto::debounce", ["users::follow"]);
if (
!(await trigger("atto::confirm", [
"Are you sure you would like to do this?",
]))
) {
return;
}
fetch(`/api/v1/auth/user/${id}/follow/accept`, {
method: "POST",
})
.then((res) => res.json())
.then(async (res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
if (res.ok) {
e.target.parentElement.parentElement.parentElement.parentElement.remove();
if (
await trigger("atto::confirm", [
"Would you like to follow this user back? This will allow them to view your profile.",
])
) {
fetch(`/api/v1/auth/user/${id}/follow`, {
method: "POST",
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
}
}
});
};
</script>
{% endblock %}

View file

@ -0,0 +1,131 @@
{% extends "root.html" %} {% block head %}
<title>{{ profile.username }} (private profile) - {{ config.name }}</title>
{% endblock %} {% block body %} {{ macros::nav() }}
<main class="flex flex-col gap-2">
<div class="card-nest">
<div class="card small flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
{{ components::avatar(username=profile.username, size="24px") }}
<span>{{ profile.username }}</span>
</div>
<b class="notification chip"
>{{ text "auth:label.private_profile" }}</b
>
</div>
<div class="card flex flex-col gap-2">
<span>{{ text "auth:label.private_profile_message" }}</span>
<div class="card w-full secondary flex gap-2">
{% if user %} {% if not is_following %}
<button
onclick="toggle_follow_user(event)"
class="{% if follow_requested %} hidden{% endif %}"
atto_tag="user.follow_request"
>
{{ icon "user-plus" }}
<span>{{ text "auto:action.request_to_follow" }}</span>
</button>
<button
onclick="cancel_follow_user(event)"
class="quaternary red{% if not follow_requested %} hidden{% endif %}"
atto_tag="user.cancel_request"
>
{{ icon "user-minus" }}
<span>{{ text "auto:action.cancel_follow_request" }}</span>
</button>
{% else %}
<button
onclick="toggle_follow_user(event)"
class="quaternary red"
atto_tag="user.unfollow"
>
{{ icon "user-minus" }}
<span>{{ text "auto:action.unfollow" }}</span>
</button>
{% endif %}
<script>
globalThis.toggle_follow_user = async (e) => {
await trigger("atto::debounce", ["users::follow"]);
fetch("/api/v1/auth/user/{{ profile.id }}/follow", {
method: "POST",
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
if (
e.target.getAttribute("atto_tag") ===
"user.follow_request"
) {
document
.querySelector(
'[atto_tag="user.follow_request"]',
)
.classList.add("hidden");
document
.querySelector(
'[atto_tag="user.cancel_request"]',
)
.classList.remove("hidden");
} else {
window.location.reload();
}
});
};
globalThis.cancel_follow_user = async (e) => {
await trigger("atto::debounce", ["users::follow"]);
if (
!(await trigger("atto::confirm", [
"Are you sure you would like to do this?",
]))
) {
return;
}
fetch(
"/api/v1/auth/user/{{ profile.id }}/follow/cancel",
{
method: "POST",
},
)
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
document
.querySelector(
'[atto_tag="user.cancel_request"]',
)
.classList.add("hidden");
document
.querySelector(
'[atto_tag="user.follow_request"]',
)
.classList.remove("hidden");
});
};
</script>
{% endif %}
<a href="/" class="button red quaternary">
{{ icon "x" }}
<span>{{ text "general:action.back" }}</span>
</a>
</div>
</div>
</div>
</main>
{% endblock %}

View file

@ -4,7 +4,7 @@
<main class="flex flex-col gap-2">
{{ macros::timelines_nav(selected="popular") }} {{
macros::timelines_secondary_nav(posts="/popular",
questions="/popular/questions", selected="popular") }}
questions="/popular/questions", selected="questions") }}
<!-- prettier-ignore -->
<div class="card w-full flex flex-col gap-2">

View file

@ -4,7 +4,7 @@ use crate::{
};
use axum::{Extension, Json, extract::Path, response::IntoResponse};
use axum_extra::extract::CookieJar;
use tetratto_core::model::auth::{Notification, UserBlock, UserFollow};
use tetratto_core::model::auth::{FollowResult, Notification, UserBlock, UserFollow};
/// Toggle following on the given user.
pub async fn follow_request(
@ -30,33 +30,111 @@ pub async fn follow_request(
}
} else {
// create
match data.create_userfollow(UserFollow::new(user.id, id)).await {
Ok(_) => {
if let Err(e) = data
.create_notification(Notification::new(
"Somebody has followed you!".to_string(),
format!(
"You have been followed by [@{}](/api/v1/auth/user/find/{}).",
user.username, user.id
),
id,
))
.await
{
return Json(e.into());
};
match data
.create_userfollow(UserFollow::new(user.id, id), false)
.await
{
Ok(r) => {
if r == FollowResult::Followed {
if let Err(e) = data
.create_notification(Notification::new(
"Somebody has followed you!".to_string(),
format!(
"You have been followed by [@{}](/api/v1/auth/user/find/{}).",
user.username, user.id
),
id,
))
.await
{
return Json(e.into());
};
Json(ApiReturn {
ok: true,
message: "User followed".to_string(),
payload: (),
})
Json(ApiReturn {
ok: true,
message: "User followed".to_string(),
payload: (),
})
} else {
Json(ApiReturn {
ok: true,
message: "Asked to follow user".to_string(),
payload: (),
})
}
}
Err(e) => Json(e.into()),
}
}
}
pub async fn cancel_follow_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_request(user.id, id, &user, true).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Follow request deleted".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn accept_follow_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()),
};
// delete the request
if let Err(e) = data.delete_request(id, user.id, &user, true).await {
return Json(e.into());
}
// create follow
match data
.create_userfollow(UserFollow::new(id, user.id), true)
.await
{
Ok(_) => {
if let Err(e) = data
.create_notification(Notification::new(
"Somebody has accepted your follow request!".to_string(),
format!(
"You are now following [@{}](/api/v1/auth/user/find/{}).",
user.username, user.id
),
id,
))
.await
{
return Json(e.into());
};
Json(ApiReturn {
ok: true,
message: "User follow request accepted".to_string(),
payload: (),
})
}
Err(e) => Json(e.into()),
}
}
/// Toggle blocking on the given user.
pub async fn block_request(
jar: CookieJar,

View file

@ -118,6 +118,14 @@ pub fn routes() -> Router {
.route("/auth/user/{id}/avatar", get(auth::images::avatar_request))
.route("/auth/user/{id}/banner", get(auth::images::banner_request))
.route("/auth/user/{id}/follow", post(auth::social::follow_request))
.route(
"/auth/user/{id}/follow/cancel",
post(auth::social::cancel_follow_request),
)
.route(
"/auth/user/{id}/follow/accept",
post(auth::social::accept_follow_request),
)
.route("/auth/user/{id}/block", post(auth::social::block_request))
.route(
"/auth/user/{id}/settings",

View file

@ -14,7 +14,7 @@ pub async fn delete_request(
None => return Json(Error::NotAllowed.into()),
};
match data.delete_request(id, linked_asset, &user).await {
match data.delete_request(id, linked_asset, &user, false).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Request deleted".to_string(),

View file

@ -137,13 +137,41 @@ pub async fn posts_request(
.await
.is_err()
{
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &user).await,
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0, lang, &user).await;
context.insert("profile", &other_user);
context.insert(
"follow_requested",
&data
.0
.get_request_by_id_linked_asset(ua.id, other_user.id)
.await
.is_ok(),
);
context.insert(
"is_following",
&data
.0
.get_userfollow_by_initiator_receiver(ua.id, other_user.id)
.await
.is_ok(),
);
return Ok(Html(
data.1.render("profile/private.html", &context).unwrap(),
));
}
} else {
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &user).await,
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0, lang, &user).await;
context.insert("profile", &other_user);
context.insert("follow_requested", &false);
context.insert("is_following", &false);
return Ok(Html(
data.1.render("profile/private.html", &context).unwrap(),
));
}
}

View file

@ -1,6 +1,6 @@
[package]
name = "tetratto-core"
version = "1.0.4"
version = "1.0.5"
edition = "2024"
[features]

View file

@ -621,7 +621,7 @@ impl DataManager {
}
if !question.is_global {
self.delete_request(question.owner, question.id, &owner)
self.delete_request(question.owner, question.id, &owner, false)
.await?;
} else {
self.incr_question_answer_count(data.context.answering)
@ -629,15 +629,23 @@ impl DataManager {
}
// create notification for question owner
self.create_notification(Notification::new(
"Your question has received a new answer!".to_string(),
format!(
"[@{}](/api/v1/auth/user/find/{}) has answered your [question](/question/{}).",
owner.username, owner.id, question.id
),
question.owner,
))
.await?;
// (if the current user isn't the owner)
if question.owner != data.owner {
self.create_notification(Notification::new(
"Your question has received a new answer!".to_string(),
format!(
"[@{}](/api/v1/auth/user/find/{}) has answered your [question](/question/{}).",
owner.username, owner.id, question.id
),
question.owner,
))
.await?;
}
// inherit nsfw status if we didn't get it from the community
if question.context.is_nsfw {
data.context.is_nsfw = question.context.is_nsfw;
}
}
// check if we're reposting a post

View file

@ -37,6 +37,8 @@ impl DataManager {
// likes
likes: get!(x->8(i32)) as isize,
dislikes: get!(x->9(i32)) as isize,
// ...
context: serde_json::from_str(&get!(x->10(String))).unwrap(),
}
}
@ -300,6 +302,9 @@ impl DataManager {
{
return Err(Error::QuestionsDisabled);
}
// inherit nsfw status
data.context.is_nsfw = community.context.is_nsfw;
} else {
let receiver = self.get_user_by_id(data.receiver).await?;
@ -323,7 +328,7 @@ impl DataManager {
let res = execute!(
&conn,
"INSERT INTO questions VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
"INSERT INTO questions VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)",
params![
&(data.id as i64),
&(data.created as i64),
@ -334,7 +339,8 @@ impl DataManager {
&0_i32,
&(data.community as i64),
&0_i32,
&0_i32
&0_i32,
&serde_json::to_string(&data.context).unwrap()
]
);
@ -404,7 +410,7 @@ impl DataManager {
{
// requests are also deleted when a post is created answering the given question
// (unless the question is global)
self.delete_request(y.owner, y.id, user).await?;
self.delete_request(y.owner, y.id, user, false).await?;
}
// delete all posts answering question

View file

@ -119,13 +119,21 @@ impl DataManager {
Ok(())
}
pub async fn delete_request(&self, id: usize, linked_asset: usize, user: &User) -> Result<()> {
pub async fn delete_request(
&self,
id: usize,
linked_asset: usize,
user: &User,
force: bool,
) -> Result<()> {
let y = self
.get_request_by_id_linked_asset(id, linked_asset)
.await?;
if user.id != y.owner && !user.permissions.check(FinePermission::MANAGE_REQUESTS) {
return Err(Error::NotAllowed);
if !force {
if user.id != y.owner && !user.permissions.check(FinePermission::MANAGE_REQUESTS) {
return Err(Error::NotAllowed);
}
}
let conn = match self.connect().await {
@ -133,13 +141,21 @@ impl DataManager {
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = execute!(&conn, "DELETE FROM requests WHERE id = $1", &[&(id as i64)]);
let res = execute!(
&conn,
"DELETE FROM requests WHERE id = $1",
&[&(y.id as i64)]
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
self.2.remove(format!("atto.request:{}", id)).await;
self.2.remove(format!("atto.request:{}", y.id)).await;
self.2
.remove(format!("atto.request:{}:{}", id, linked_asset))
.await;
// decr request count
let owner = self.get_user_by_id(y.owner).await?;
@ -159,7 +175,8 @@ impl DataManager {
return Err(Error::NotAllowed);
}
self.delete_request(x.id, x.linked_asset, user).await?;
self.delete_request(x.id, x.linked_asset, user, false)
.await?;
// delete question
if x.action_type == ActionType::Answer {

View file

@ -1,5 +1,7 @@
use super::*;
use crate::cache::Cache;
use crate::model::auth::FollowResult;
use crate::model::requests::{ActionRequest, ActionType};
use crate::model::{Error, Result, auth::User, auth::UserFollow, permissions::FinePermission};
use crate::{auto_method, execute, get, query_row, query_rows, params};
@ -219,7 +221,26 @@ impl DataManager {
///
/// # Arguments
/// * `data` - a mock [`UserFollow`] object to insert
pub async fn create_userfollow(&self, data: UserFollow) -> Result<()> {
/// * `force` - if we should skip the request stage
pub async fn create_userfollow(&self, data: UserFollow, force: bool) -> Result<FollowResult> {
if !force {
let other_user = self.get_user_by_id(data.receiver).await?;
if other_user.settings.private_profile {
// send follow request instead
self.create_request(ActionRequest::with_id(
data.initiator,
data.receiver,
ActionType::Follow,
data.receiver,
))
.await?;
return Ok(FollowResult::Requested);
}
}
// ...
let conn = match self.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
@ -248,13 +269,16 @@ impl DataManager {
self.incr_user_follower_count(data.receiver).await.unwrap();
// return
Ok(())
Ok(FollowResult::Followed)
}
pub async fn delete_userfollow(&self, id: usize, user: &User) -> Result<()> {
let follow = self.get_userfollow_by_id(id).await?;
if (user.id != follow.initiator) && (user.id != follow.receiver) && !user.permissions.check(FinePermission::MANAGE_FOLLOWS) {
if (user.id != follow.initiator)
&& (user.id != follow.receiver)
&& !user.permissions.check(FinePermission::MANAGE_FOLLOWS)
{
return Err(Error::NotAllowed);
}

View file

@ -322,6 +322,14 @@ impl UserFollow {
}
}
#[derive(Serialize, Deserialize, PartialEq, Eq)]
pub enum FollowResult {
/// Request sent to follow other user.
Requested,
/// Successfully followed other user.
Followed,
}
#[derive(Serialize, Deserialize)]
pub struct UserBlock {
pub id: usize,

View file

@ -307,6 +307,8 @@ pub struct Question {
pub likes: isize,
#[serde(default)]
pub dislikes: isize,
#[serde(default)]
pub context: QuestionContext,
}
impl Question {
@ -326,6 +328,19 @@ impl Question {
community: 0,
likes: 0,
dislikes: 0,
context: QuestionContext::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QuestionContext {
#[serde(default)]
pub is_nsfw: bool,
}
impl Default for QuestionContext {
fn default() -> Self {
Self { is_nsfw: false }
}
}

View file

@ -11,6 +11,10 @@ pub enum ActionType {
///
/// `questions` table.
Answer,
/// A request follow a private account.
///
/// `users` table.
Follow,
}
#[derive(Serialize, Deserialize)]

View file

@ -1,6 +1,6 @@
[package]
name = "tetratto-l10n"
version = "1.0.4"
version = "1.0.5"
edition = "2024"
authors.workspace = true
repository.workspace = true

View file

@ -1,6 +1,6 @@
[package]
name = "tetratto-shared"
version = "1.0.4"
version = "1.0.5"
edition = "2024"
authors.workspace = true
repository.workspace = true

View file

@ -0,0 +1,2 @@
ALTER TABLE questions
ADD COLUMN context TEXT NOT NULL DEFAULT '{}';