diff --git a/crates/app/src/public/html/profile/base.lisp b/crates/app/src/public/html/profile/base.lisp
index 9698f1e..15ee7bf 100644
--- a/crates/app/src/public/html/profile/base.lisp
+++ b/crates/app/src/public/html/profile/base.lisp
@@ -160,6 +160,14 @@
(text "Posts"))
(span
(text "{{ profile.post_count }}")))
+ (div
+ ("class" "w-full flex justify-between items-center")
+ ("title" "great post average (limited time)")
+ (span
+ ("class" "notification chip")
+ (text "GPA 🐇"))
+ (span
+ (text "{{ gpa }}")))
(text "{% if not profile.settings.private_last_seen or is_self or is_helper %}")
(div
("class" "w-full flex justify-between items-center")
diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs
index f7f88d9..df0eea4 100644
--- a/crates/app/src/routes/api/v1/auth/profile.rs
+++ b/crates/app/src/routes/api/v1/auth/profile.rs
@@ -645,3 +645,33 @@ pub async fn post_to_socket_request(
payload: (),
})
}
+
+/// Calculate the user's great post average.
+pub async fn get_user_gpa_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) {
+ Some(ua) => ua,
+ None => return Json(Error::NotAllowed.into()),
+ };
+
+ if !user.permissions.check(FinePermission::MANAGE_USERS) {
+ return Json(Error::NotAllowed.into());
+ }
+
+ let gpa = data.calculate_user_gpa(id).await;
+ return Json(ApiReturn {
+ ok: true,
+ message: if gpa >= 3 {
+ "cool".to_string()
+ } else if gpa >= 4 {
+ "extraordinary".to_string()
+ } else {
+ "ok".to_string()
+ },
+ payload: Some(gpa),
+ });
+}
diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs
index 9cdfd5d..27c34e8 100644
--- a/crates/app/src/routes/api/v1/mod.rs
+++ b/crates/app/src/routes/api/v1/mod.rs
@@ -221,6 +221,10 @@ pub fn routes() -> Router {
get(auth::profile::redirect_from_ip),
)
.route("/auth/ip/{ip}/block", post(auth::social::ip_block_request))
+ .route(
+ "/auth/user/{id}/gpa",
+ get(auth::profile::get_user_gpa_request),
+ )
.route(
"/auth/user/{id}/_connect/{stream}",
any(auth::profile::subscription_handler),
diff --git a/crates/app/src/routes/pages/profile.rs b/crates/app/src/routes/pages/profile.rs
index 3efe2f0..ef48326 100644
--- a/crates/app/src/routes/pages/profile.rs
+++ b/crates/app/src/routes/pages/profile.rs
@@ -350,6 +350,7 @@ pub async fn posts_request(
context.insert("pinned", &pinned);
context.insert("page", &props.page);
context.insert("tag", &props.tag);
+ context.insert("gpa", &data.0.calculate_user_gpa(other_user.id).await);
profile_context(
&mut context,
&user,
@@ -453,6 +454,7 @@ pub async fn replies_request(
context.insert("posts", &posts);
context.insert("page", &props.page);
+ context.insert("gpa", &data.0.calculate_user_gpa(other_user.id).await);
profile_context(
&mut context,
&user,
@@ -558,6 +560,7 @@ pub async fn media_request(
context.insert("posts", &posts);
context.insert("page", &props.page);
+ context.insert("gpa", &data.0.calculate_user_gpa(other_user.id).await);
profile_context(
&mut context,
&user,
@@ -652,6 +655,7 @@ pub async fn outbox_request(
context.insert("questions", &questions);
context.insert("page", &props.page);
+ context.insert("gpa", &data.0.calculate_user_gpa(other_user.id).await);
profile_context(
&mut context,
&Some(user),
@@ -746,6 +750,7 @@ pub async fn following_request(
context.insert("list", &list);
context.insert("page", &props.page);
+ context.insert("gpa", &data.0.calculate_user_gpa(other_user.id).await);
profile_context(
&mut context,
&user,
@@ -840,6 +845,7 @@ pub async fn followers_request(
context.insert("list", &list);
context.insert("page", &props.page);
+ context.insert("gpa", &data.0.calculate_user_gpa(other_user.id).await);
profile_context(
&mut context,
&user,
diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs
index f3e483d..9633c01 100644
--- a/crates/core/src/database/posts.rs
+++ b/crates/core/src/database/posts.rs
@@ -510,6 +510,80 @@ impl DataManager {
Ok(res.unwrap())
}
+ /// Calculate the GPA (great post average) of a given user.
+ ///
+ /// To be considered a "great post", a post must have a score ((likes - dislikes) / (likes + dislikes))
+ /// of at least 0.6.
+ ///
+ /// GPA is calculated based on the user's last 250 posts.
+ pub async fn calculate_user_gpa(&self, id: usize) -> usize {
+ // just for note, this is SUPER bad for performance... which is why we
+ // only calculate this when it expires in the cache (every week)
+ if let Some(cached) = self.0.1.get(format!("atto.user.gpa:{}", id)).await {
+ if let Ok(c) = cached.parse() {
+ return c;
+ }
+ }
+
+ // ...
+ let conn = match self.0.connect().await {
+ Ok(c) => c,
+ Err(_) => return 0,
+ };
+
+ let res = query_rows!(
+ &conn,
+ &format!("SELECT * FROM posts WHERE owner = $1 ORDER BY created DESC LIMIT 250",),
+ &[&(id as i64)],
+ |x| { Self::get_post_from_row(x) }
+ );
+
+ if res.is_err() {
+ return 0;
+ }
+
+ // ...
+ let mut real_posts_count: usize = 0; // posts which can be scored
+ let mut good_posts: usize = 0;
+ // let mut bad_posts: usize = 0;
+
+ let posts = res.unwrap();
+
+ for post in posts {
+ if post.likes == 0 && post.dislikes == 0 {
+ // post has no likes or dislikes... doesn't count
+ continue;
+ }
+
+ real_posts_count += 1;
+
+ // likes percentage / total likes
+ let score: f32 = (post.likes as f32 - post.dislikes as f32)
+ / (post.likes as f32 + post.dislikes as f32);
+
+ if score.is_sign_negative() {
+ // bad_posts += 1;
+ continue;
+ }
+
+ if score > 0.6 {
+ good_posts += 1;
+ }
+ // } else {
+ // bad_posts += 1;
+ // }
+ }
+
+ let gpa = ((good_posts as f32 / real_posts_count as f32) * 4.0).round() as usize;
+
+ self.0
+ .1
+ .set(format!("atto.user.gpa:{}", id), gpa.to_string())
+ .await;
+
+ gpa
+ }
+
/// Get all replies from the given user (from most recent).
///
/// # Arguments