diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp
index c81ef23..1f62828 100644
--- a/crates/app/src/public/html/components.lisp
+++ b/crates/app/src/public/html/components.lisp
@@ -1798,8 +1798,8 @@
(span ("class" "notification chip") (text "{{ total }} votes"))
(text "{% if not poll[2] -%}")
(span
- ("class" "notification chip")
- (text "Expires in ")
+ ("class" "notification chip flex items-center gap-1")
+ (text "Expires in")
(span
("class" "poll_date")
("data-created" "{{ poll[0].created }}")
diff --git a/crates/app/src/public/html/misc/requests.lisp b/crates/app/src/public/html/misc/requests.lisp
index b9700f0..8f4bdb6 100644
--- a/crates/app/src/public/html/misc/requests.lisp
+++ b/crates/app/src/public/html/misc/requests.lisp
@@ -62,12 +62,15 @@
("class" "card-nest")
(div
("class" "card small flex items-center gap-2")
- (text "{{ icon \"user-plus\" }}")
+ (a
+ ("href" "/api/v1/auth/user/find/{{ request.id }}")
+ (text "{{ components::avatar(username=request.id, selector_type=\"id\") }}"))
(span
(text "{{ text \"requests:label.user_follow_request\" }}")))
(div
("class" "card flex flex-col gap-2")
(span
+ ("class" "flex items-center gap-2")
(text "{{ text \"requests:label.user_follow_request_message\" }}"))
(div
("class" "card flex flex-wrap w-full secondary gap-2")
diff --git a/crates/app/src/public/html/profile/private.lisp b/crates/app/src/public/html/profile/private.lisp
index 11740c9..8bd94e9 100644
--- a/crates/app/src/public/html/profile/private.lisp
+++ b/crates/app/src/public/html/profile/private.lisp
@@ -35,6 +35,7 @@
(text "{{ icon \"user-plus\" }}")
(span
(text "{{ text \"auth:action.request_to_follow\" }}")))
+ (text "{% if follow_requested -%}")
(button
("onclick" "cancel_follow_user(event)")
("class" "lowered red{% if not follow_requested -%} hidden{%- endif %}")
@@ -42,7 +43,7 @@
(text "{{ icon \"user-minus\" }}")
(span
(text "{{ text \"auth:action.cancel_follow_request\" }}")))
- (text "{% else %}")
+ (text "{%- endif %} {% else %}")
(button
("onclick" "toggle_follow_user(event)")
("class" "lowered red")
diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp
index 6be8134..046f425 100644
--- a/crates/app/src/public/html/profile/settings.lisp
+++ b/crates/app/src/public/html/profile/settings.lisp
@@ -137,6 +137,12 @@
(text "{{ icon \"rss\" }}")
(span
(text "{{ text \"auth:label.following\" }}")))
+ (a
+ ("data-tab-button" "account/followers")
+ ("href" "#/account/followers")
+ (text "{{ icon \"rss\" }}")
+ (span
+ (text "{{ text \"auth:label.followers\" }}")))
(a
("data-tab-button" "account/blocks")
("href" "#/account/blocks")
@@ -457,7 +463,7 @@
(text "{{ icon \"external-link\" }}")
(span
(text "{{ text \"requests:action.view_profile\" }}")))))
- (text "{% endfor %}"))))
+ (text "{% endfor %} {{ components::pagination(page=page, items=following|length, key=\"#/account/following\") }}"))))
(script
(text "globalThis.toggle_follow_user = async (uid) => {
await trigger(\"atto::debounce\", [\"users::follow\"]);
@@ -473,6 +479,62 @@
]);
});
};")))
+ (div
+ ("class" "w-full flex flex-col gap-2 hidden")
+ ("data-tab" "account/followers")
+ (div
+ ("class" "card lowered flex flex-col gap-2")
+ (a
+ ("href" "#/account")
+ ("class" "button secondary")
+ (text "{{ icon \"arrow-left\" }}")
+ (span
+ (text "{{ text \"general:action.back\" }}")))
+ (div
+ ("class" "card-nest")
+ (div
+ ("class" "card flex items-center gap-2 small")
+ (text "{{ icon \"rss\" }}")
+ (span
+ (text "{{ text \"auth:label.followers\" }}")))
+ (div
+ ("class" "card flex flex-col gap-2")
+ (text "{% for userfollow in followers %} {% set user = userfollow[1] %}")
+ (div
+ ("class" "card secondary flex flex-wrap gap-2 items-center justify-between")
+ (div
+ ("class" "flex gap-2")
+ (text "{{ components::avatar(username=user.username) }} {{ components::full_username(user=user) }}"))
+ (div
+ ("class" "flex gap-2")
+ (button
+ ("class" "lowered red small")
+ ("onclick" "force_unfollow_me('{{ user.id }}')")
+ (text "{{ icon \"user-minus\" }}")
+ (span
+ (str (text "stacks:label.remove"))))
+ (a
+ ("href" "/@{{ user.username }}")
+ ("class" "button lowered small")
+ (text "{{ icon \"external-link\" }}")
+ (span
+ (text "{{ text \"requests:action.view_profile\" }}")))))
+ (text "{% endfor %} {{ components::pagination(page=page, items=following|length, key=\"#/account/followers\") }}"))))
+ (script
+ (text "globalThis.force_unfollow_me = async (uid) => {
+ await trigger(\"atto::debounce\", [\"users::follow\"]);
+
+ fetch(`/api/v1/auth/user/${uid}/force_unfollow_me`, {
+ method: \"POST\",
+ })
+ .then((res) => res.json())
+ .then((res) => {
+ trigger(\"atto::toast\", [
+ res.ok ? \"success\" : \"error\",
+ res.message,
+ ]);
+ });
+ };")))
(div
("class" "w-full flex flex-col gap-2 hidden")
("data-tab" "account/blocks")
diff --git a/crates/app/src/routes/api/v1/auth/social.rs b/crates/app/src/routes/api/v1/auth/social.rs
index 88a78b5..17ca6cf 100644
--- a/crates/app/src/routes/api/v1/auth/social.rs
+++ b/crates/app/src/routes/api/v1/auth/social.rs
@@ -154,6 +154,31 @@ pub async fn accept_follow_request(
}
}
+pub async fn force_unfollow_me_request(
+ jar: CookieJar,
+ Path(id): Path,
+ Extension(data): Extension,
+) -> impl IntoResponse {
+ let data = &(data.read().await).0;
+ let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageFollowing) {
+ Some(ua) => ua,
+ None => return Json(Error::NotAllowed.into()),
+ };
+
+ if let Ok(userfollow) = data.get_userfollow_by_receiver_initiator(user.id, id).await {
+ match data.delete_userfollow(userfollow.id, &user, false).await {
+ Ok(_) => Json(ApiReturn {
+ ok: true,
+ message: "User is no longer following you".to_string(),
+ payload: (),
+ }),
+ Err(e) => Json(e.into()),
+ }
+ } else {
+ return Json(Error::GeneralNotFound("user follow".to_string()).into());
+ }
+}
+
/// Toggle blocking on the given user.
pub async fn block_request(
jar: CookieJar,
diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs
index 517016b..0fbfc40 100644
--- a/crates/app/src/routes/api/v1/mod.rs
+++ b/crates/app/src/routes/api/v1/mod.rs
@@ -293,6 +293,10 @@ pub fn routes() -> Router {
"/auth/user/{id}/follow/accept",
post(auth::social::accept_follow_request),
)
+ .route(
+ "/auth/user/{id}/force_unfollow_me",
+ post(auth::social::force_unfollow_me_request),
+ )
.route("/auth/user/{id}/block", post(auth::social::block_request))
.route(
"/auth/user/{id}/block_ip",
diff --git a/crates/app/src/routes/pages/profile.rs b/crates/app/src/routes/pages/profile.rs
index 4d12556..11966a6 100644
--- a/crates/app/src/routes/pages/profile.rs
+++ b/crates/app/src/routes/pages/profile.rs
@@ -63,11 +63,27 @@ pub async fn settings_request(
}
};
+ let followers = match data
+ .0
+ .fill_userfollows_with_initiator(
+ data.0
+ .get_userfollows_by_receiver(profile.id, 12, req.page)
+ .await
+ .unwrap_or(Vec::new()),
+ &None,
+ false,
+ )
+ .await
+ {
+ Ok(r) => r,
+ Err(e) => return Err(Html(render_error(e, &jar, &data, &None).await)),
+ };
+
let following = match data
.0
.fill_userfollows_with_receiver(
data.0
- .get_userfollows_by_initiator_all(profile.id)
+ .get_userfollows_by_initiator(profile.id, 12, req.page)
.await
.unwrap_or(Vec::new()),
&None,
@@ -138,6 +154,7 @@ pub async fn settings_request(
context.insert("page", &req.page);
context.insert("uploads", &uploads);
context.insert("stacks", &stacks);
+ context.insert("followers", &followers);
context.insert("following", &following);
context.insert("blocks", &blocks);
context.insert("stackblocks", &stackblocks);
diff --git a/justfile b/justfile
index a83d0c4..56aa26b 100644
--- a/justfile
+++ b/justfile
@@ -10,5 +10,6 @@ doc:
cargo doc --document-private-items --no-deps
test:
+ sudo pkill -e tetratto
cd example && LITTLEWEB=true PORT=4119 cargo run &
cd example && cargo run