From 07a23f505b11d5ed5b310288cd86f40bf5c275b2 Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 6 Jul 2025 13:34:20 -0400 Subject: [PATCH] add: dedicated responses tab for profiles --- crates/app/src/assets.rs | 2 + crates/app/src/langs/en-US.toml | 1 + crates/app/src/public/html/components.lisp | 8 +- crates/app/src/public/html/macros.lisp | 12 ++- .../src/public/html/profile/responses.lisp | 55 +++++++++++++ .../app/src/public/html/profile/settings.lisp | 25 +++++- crates/app/src/public/js/atto.js | 3 +- crates/app/src/public/js/streams.js | 8 +- crates/app/src/routes/api/v1/auth/profile.rs | 6 +- crates/app/src/routes/api/v1/auth/social.rs | 2 +- .../src/routes/api/v1/communities/drafts.rs | 2 +- .../src/routes/api/v1/communities/posts.rs | 10 +-- .../routes/api/v1/communities/questions.rs | 4 +- crates/app/src/routes/api/v1/journals.rs | 2 +- crates/app/src/routes/api/v1/notes.rs | 2 +- crates/app/src/routes/pages/misc.rs | 24 ++++-- crates/app/src/routes/pages/mod.rs | 4 + crates/app/src/routes/pages/profile.rs | 19 ++++- crates/core/src/database/auth.rs | 21 ++++- crates/core/src/database/memberships.rs | 8 +- crates/core/src/database/posts.rs | 80 ++++++++++++++++++- crates/core/src/database/reactions.rs | 10 +-- crates/core/src/database/userfollows.rs | 35 +++++--- crates/core/src/model/auth.rs | 44 +++++++++- 24 files changed, 332 insertions(+), 55 deletions(-) create mode 100644 crates/app/src/public/html/profile/responses.lisp diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index 89af907..1bc09ad 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -71,6 +71,7 @@ pub const PROFILE_BANNED: &str = include_str!("./public/html/profile/banned.lisp pub const PROFILE_REPLIES: &str = include_str!("./public/html/profile/replies.lisp"); pub const PROFILE_MEDIA: &str = include_str!("./public/html/profile/media.lisp"); pub const PROFILE_OUTBOX: &str = include_str!("./public/html/profile/outbox.lisp"); +pub const PROFILE_RESPONSES: &str = include_str!("./public/html/profile/responses.lisp"); pub const COMMUNITIES_LIST: &str = include_str!("./public/html/communities/list.lisp"); pub const COMMUNITIES_BASE: &str = include_str!("./public/html/communities/base.lisp"); @@ -370,6 +371,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"profile/replies.html"(crate::assets::PROFILE_REPLIES) --config=config --lisp plugins); write_template!(html_path->"profile/media.html"(crate::assets::PROFILE_MEDIA) --config=config --lisp plugins); write_template!(html_path->"profile/outbox.html"(crate::assets::PROFILE_OUTBOX) --config=config --lisp plugins); + write_template!(html_path->"profile/responses.html"(crate::assets::PROFILE_RESPONSES) --config=config --lisp plugins); write_template!(html_path->"communities/list.html"(crate::assets::COMMUNITIES_LIST) -d "communities" --config=config --lisp plugins); write_template!(html_path->"communities/base.html"(crate::assets::COMMUNITIES_BASE) --config=config --lisp plugins); diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index ea87729..0842e62 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -76,6 +76,7 @@ version = "1.0.0" "auth:label.recent_replies" = "Recent replies" "auth:label.recent_posts_with_media" = "Recent posts (with media)" "auth:label.posts" = "Posts" +"auth:label.responses" = "Answers" "auth:label.replies" = "Replies" "auth:label.media" = "Media" "auth:label.outbox" = "Outbox" diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index a15fe19..626efc0 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -800,7 +800,7 @@ }")) (text "{%- endif %}")) - (text "{% if not is_global and allow_anonymous -%}") + (text "{% if not is_global and allow_anonymous and not user -%}") (div ("class" "flex gap-2 items-center") (input @@ -1155,10 +1155,8 @@ (icon (text "code")) (str (text "general:link.source_code"))) - (a - ("href" "/reference/tetratto/index.html") - ("class" "button") - ("data-turbo" "false") + (button + ("onclick" "trigger('me::achievement_link', ['OpenReference', '/reference/tetratto/index.html'])") (icon (text "rabbit")) (str (text "general:link.reference"))) diff --git a/crates/app/src/public/html/macros.lisp b/crates/app/src/public/html/macros.lisp index b2e8863..a554351 100644 --- a/crates/app/src/public/html/macros.lisp +++ b/crates/app/src/public/html/macros.lisp @@ -252,10 +252,17 @@ ("class" "pillmenu") (text "{% if is_self or is_helper or not profile.settings.hide_extra_post_tabs -%}") (a - ("href" "/@{{ profile.username }}") + ("href" "/@{{ profile.username }}?f=true") ("class" "{% if selected == 'posts' -%}active{%- endif %}") (str (text "auth:label.posts"))) + (text "{% if profile.settings.enable_questions -%}") + (a + ("href" "/@{{ profile.username }}?r=true") + ("class" "{% if selected == 'responses' -%}active{%- endif %}") + (str (text "auth:label.responses"))) + (text "{%- endif %}") + (a ("href" "/@{{ profile.username }}/replies") ("class" "{% if selected == 'replies' -%}active{%- endif %}") @@ -311,8 +318,9 @@ (span (text "{{ text \"settings:tab.theme\" }}"))) (a + ("href" "#") ("data-tab-button" "sessions") - ("href" "#/sessions") + ("onclick" "trigger('me::achievement_link', ['OpenSessionSettings', '#/sessions'])") (text "{{ icon \"cookie\" }}") (span (text "{{ text \"settings:tab.sessions\" }}"))) diff --git a/crates/app/src/public/html/profile/responses.lisp b/crates/app/src/public/html/profile/responses.lisp new file mode 100644 index 0000000..868f959 --- /dev/null +++ b/crates/app/src/public/html/profile/responses.lisp @@ -0,0 +1,55 @@ +(text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}") +(div + ("style" "display: contents") + (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings, allow_anonymous=profile.settings.allow_anonymous_questions) }}")) + +(text "{%- endif %} {% if not tag and pinned|length != 0 -%}") +(div + ("class" "card-nest") + (div + ("class" "card small flex gap-2 items-center") + (text "{{ icon \"pin\" }}") + (span + (text "{{ text \"communities:label.pinned\" }}"))) + (div + ("class" "card flex flex-col gap-4") + (text "{% for post in pinned %} {% if post[2].read_access == \"Everybody\" -%} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true, can_manage_post=is_self) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], can_manage_post=is_self, poll=post[5]) }} {%- endif %} {%- endif %} {% endfor %}"))) + +(text "{%- endif %} {{ macros::profile_nav(selected=\"responses\") }}") +(div + ("class" "card-nest") + (div + ("class" "card small flex gap-2 justify-between items-center") + (div + ("class" "flex gap-2 items-center") + (text "{% if not tag -%} {{ icon \"clock\" }}") + (span + (text "{{ text \"auth:label.recent_posts\" }}")) + (text "{% else %} {{ icon \"tag\" }}") + (span + (text "{{ text \"auth:label.recent_with_tag\" }}: ") + (b + (text "{{ tag }}"))) + (text "{%- endif %}")) + (text "{% if user -%}") + (a + ("href" "/search?profile={{ profile.id }}") + ("class" "button lowered small") + (text "{{ icon \"search\" }}") + (span + (text "{{ text \"general:link.search\" }}"))) + (text "{%- endif %}")) + (div + ("class" "card w-full flex flex-col gap-2") + ("ui_ident" "io_data_load") + (div ("ui_ident" "io_data_marker")))) + +(text "{% set paged = user and user.settings.paged_timelines %}") +(script + (text "setTimeout(async () => { + await trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?user_id={{ profile.id }}&tag={{ tag }}&responses_only=true&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]); + (await ns(\"ui\")).IO_DATA_DISABLE_RELOAD = true; + console.log(\"created profile timeline\"); + }, 1000);")) + +(text "{% endblock %}") diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index 8acd9c3..b7f0947 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -757,7 +757,29 @@ (text "{{ icon \"check\" }}"))) (span ("class" "fade") - (text "Use an image of 1100x350px for the best results."))))) + (text "Use an image of 1100x350px for the best results.")))) + (div + ("class" "card-nest") + ("ui_ident" "default_profile_page") + (div + ("class" "card small") + (b + (text "Default profile tab"))) + (div + ("class" "card") + (select + ("onchange" "window.SETTING_SET_FUNCTIONS[0]('default_profile_tab', event.target.selectedOptions[0].value)") + (option + ("value" "Posts") + ("selected" "{% if profile.settings.default_profile_tab == 'Posts' -%}true{% else %}false{%- endif %}") + (text "Posts")) + (option + ("value" "Responses") + ("selected" "{% if profile.settings.default_profile_tab == 'Responses' -%}true{% else %}false{%- endif %}") + (text "Responses"))) + (span + ("class" "fade") + (text "This represents the timeline that is shown on your profile by default."))))) (button ("onclick" "save_settings()") ("id" "save_button") @@ -1387,6 +1409,7 @@ \"supporter_ad\", \"change_avatar\", \"change_banner\", + \"default_profile_page\", ]); ui.refresh_container(theme_settings, [ \"supporter_ad\", diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js index d3b4bbb..be40e7f 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -1363,7 +1363,8 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} JSON.stringify(accepted_warnings), ); - setTimeout(() => { + setTimeout(async () => { + await trigger("me::achievement", ["AcceptProfileWarning"]); window.history.back(); }, 100); }); diff --git a/crates/app/src/public/js/streams.js b/crates/app/src/public/js/streams.js index 8b9954d..7c5adf7 100644 --- a/crates/app/src/public/js/streams.js +++ b/crates/app/src/public/js/streams.js @@ -43,6 +43,12 @@ }; socket.addEventListener("message", async (event) => { + const sock = await $.sock(stream); + + if (!sock) { + return; + } + if (event.data === "Ping") { return socket.send("Pong"); } @@ -54,7 +60,7 @@ return console.info(`${stream} ${data.data}`); } - return (await $.sock(stream)).events.message(data); + return sock.events.message(data); }); return $.STREAMS[stream]; diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs index 75247f1..8104c71 100644 --- a/crates/app/src/routes/api/v1/auth/profile.rs +++ b/crates/app/src/routes/api/v1/auth/profile.rs @@ -154,7 +154,7 @@ pub async fn update_user_settings_request( // award achievement if let Err(e) = data - .add_achievement(&mut user, AchievementName::EditSettings.into()) + .add_achievement(&mut user, AchievementName::EditSettings.into(), true) .await { return Json(e.into()); @@ -500,7 +500,7 @@ pub async fn enable_totp_request( // award achievement if let Err(e) = data - .add_achievement(&mut user, AchievementName::Enable2fa.into()) + .add_achievement(&mut user, AchievementName::Enable2fa.into(), true) .await { return Json(e.into()); @@ -968,7 +968,7 @@ pub async fn self_serve_achievement_request( } // award achievement - match data.add_achievement(&mut user, req.name.into()).await { + match data.add_achievement(&mut user, req.name.into(), true).await { Ok(_) => Json(ApiReturn { ok: true, message: "Achievement granted".to_string(), diff --git a/crates/app/src/routes/api/v1/auth/social.rs b/crates/app/src/routes/api/v1/auth/social.rs index 730746a..b80bd14 100644 --- a/crates/app/src/routes/api/v1/auth/social.rs +++ b/crates/app/src/routes/api/v1/auth/social.rs @@ -62,7 +62,7 @@ pub async fn follow_request( // award achievement if let Err(e) = data - .add_achievement(&mut user, AchievementName::FollowUser.into()) + .add_achievement(&mut user, AchievementName::FollowUser.into(), true) .await { return Json(e.into()); diff --git a/crates/app/src/routes/api/v1/communities/drafts.rs b/crates/app/src/routes/api/v1/communities/drafts.rs index 346a253..75f0948 100644 --- a/crates/app/src/routes/api/v1/communities/drafts.rs +++ b/crates/app/src/routes/api/v1/communities/drafts.rs @@ -27,7 +27,7 @@ pub async fn create_request( // award achievement if let Err(e) = data - .add_achievement(&mut user, AchievementName::CreateDraft.into()) + .add_achievement(&mut user, AchievementName::CreateDraft.into(), true) .await { return Json(e.into()); diff --git a/crates/app/src/routes/api/v1/communities/posts.rs b/crates/app/src/routes/api/v1/communities/posts.rs index 7ea70e5..d6554ff 100644 --- a/crates/app/src/routes/api/v1/communities/posts.rs +++ b/crates/app/src/routes/api/v1/communities/posts.rs @@ -181,7 +181,7 @@ pub async fn create_request( // achievements if let Err(e) = data - .add_achievement(&mut user, AchievementName::CreatePost.into()) + .add_achievement(&mut user, AchievementName::CreatePost.into(), true) .await { return Json(e.into()); @@ -189,7 +189,7 @@ pub async fn create_request( if user.post_count >= 49 { if let Err(e) = data - .add_achievement(&mut user, AchievementName::Create50Posts.into()) + .add_achievement(&mut user, AchievementName::Create50Posts.into(), true) .await { return Json(e.into()); @@ -198,7 +198,7 @@ pub async fn create_request( if user.post_count >= 99 { if let Err(e) = data - .add_achievement(&mut user, AchievementName::Create100Posts.into()) + .add_achievement(&mut user, AchievementName::Create100Posts.into(), true) .await { return Json(e.into()); @@ -207,7 +207,7 @@ pub async fn create_request( if user.post_count >= 999 { if let Err(e) = data - .add_achievement(&mut user, AchievementName::Create1000Posts.into()) + .add_achievement(&mut user, AchievementName::Create1000Posts.into(), true) .await { return Json(e.into()); @@ -348,7 +348,7 @@ pub async fn update_content_request( // award achievement if let Err(e) = data - .add_achievement(&mut user, AchievementName::EditPost.into()) + .add_achievement(&mut user, AchievementName::EditPost.into(), true) .await { return Json(e.into()); diff --git a/crates/app/src/routes/api/v1/communities/questions.rs b/crates/app/src/routes/api/v1/communities/questions.rs index 631e5c0..1d1a7ba 100644 --- a/crates/app/src/routes/api/v1/communities/questions.rs +++ b/crates/app/src/routes/api/v1/communities/questions.rs @@ -55,7 +55,7 @@ pub async fn create_request( let mut user = user.clone(); if let Err(e) = data - .add_achievement(&mut user, AchievementName::CreateQuestion.into()) + .add_achievement(&mut user, AchievementName::CreateQuestion.into(), true) .await { return Json(e.into()); @@ -63,7 +63,7 @@ pub async fn create_request( if drawings.len() > 0 { if let Err(e) = data - .add_achievement(&mut user, AchievementName::CreateDrawing.into()) + .add_achievement(&mut user, AchievementName::CreateDrawing.into(), true) .await { return Json(e.into()); diff --git a/crates/app/src/routes/api/v1/journals.rs b/crates/app/src/routes/api/v1/journals.rs index 95ae3da..0b1b394 100644 --- a/crates/app/src/routes/api/v1/journals.rs +++ b/crates/app/src/routes/api/v1/journals.rs @@ -110,7 +110,7 @@ pub async fn create_request( Ok(x) => { // award achievement if let Err(e) = data - .add_achievement(&mut user, AchievementName::CreateJournal.into()) + .add_achievement(&mut user, AchievementName::CreateJournal.into(), true) .await { return Json(e.into()); diff --git a/crates/app/src/routes/api/v1/notes.rs b/crates/app/src/routes/api/v1/notes.rs index ae67c4d..6b274ff 100644 --- a/crates/app/src/routes/api/v1/notes.rs +++ b/crates/app/src/routes/api/v1/notes.rs @@ -198,7 +198,7 @@ pub async fn update_content_request( // award achievement if let Err(e) = data - .add_achievement(&mut user, AchievementName::EditNote.into()) + .add_achievement(&mut user, AchievementName::EditNote.into(), true) .await { return Json(e.into()); diff --git a/crates/app/src/routes/pages/misc.rs b/crates/app/src/routes/pages/misc.rs index 141ec25..e65f4b5 100644 --- a/crates/app/src/routes/pages/misc.rs +++ b/crates/app/src/routes/pages/misc.rs @@ -464,7 +464,7 @@ pub async fn achievements_request( // award achievement if let Err(e) = data .0 - .add_achievement(&mut user, AchievementName::OpenAchievements.into()) + .add_achievement(&mut user, AchievementName::OpenAchievements.into(), true) .await { return Err(Html(render_error(e, &jar, &data, &None).await)); @@ -633,6 +633,8 @@ pub struct TimelineQuery { pub paginated: bool, #[serde(default)] pub before: usize, + #[serde(default)] + pub responses_only: bool, } /// `/_swiss_army_timeline` @@ -680,11 +682,23 @@ pub async fn swiss_army_timeline_request( check_user_blocked_or_private!(user, other_user, data, jar); if req.tag.is_empty() { - data.0.get_posts_by_user(req.user_id, 12, req.page).await + if req.responses_only { + data.0 + .get_responses_by_user(req.user_id, 12, req.page) + .await + } else { + data.0.get_posts_by_user(req.user_id, 12, req.page).await + } } else { - data.0 - .get_posts_by_user_tag(req.user_id, &req.tag, 12, req.page) - .await + if req.responses_only { + data.0 + .get_responses_by_user_tag(req.user_id, &req.tag, 12, req.page) + .await + } else { + data.0 + .get_posts_by_user_tag(req.user_id, &req.tag, 12, req.page) + .await + } } } else { // everything else diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index abc0b32..6cf5431 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -179,6 +179,10 @@ pub struct ProfileQuery { pub warning: bool, #[serde(default)] pub tag: String, + #[serde(default, alias = "r")] + pub responses_only: bool, + #[serde(default, alias = "f")] + pub force: bool, } #[derive(Deserialize)] diff --git a/crates/app/src/routes/pages/profile.rs b/crates/app/src/routes/pages/profile.rs index ed7adcd..186d291 100644 --- a/crates/app/src/routes/pages/profile.rs +++ b/crates/app/src/routes/pages/profile.rs @@ -11,7 +11,12 @@ use axum::{ use axum_extra::extract::CookieJar; use serde::Deserialize; use tera::Context; -use tetratto_core::model::{auth::User, communities::Community, permissions::FinePermission, Error}; +use tetratto_core::model::{ + auth::{DefaultProfileTabChoice, User}, + communities::Community, + permissions::FinePermission, + Error, +}; use tetratto_shared::hash::hash; use contrasted::{Color, MINIMUM_CONTRAST_THRESHOLD}; @@ -252,6 +257,10 @@ pub async fn posts_request( check_user_blocked_or_private!(user, other_user, data, jar); + let responses_only = props.responses_only + | (other_user.settings.default_profile_tab == DefaultProfileTabChoice::Responses + && !props.force); + // check for warning if props.warning { let lang = get_lang!(jar, data.0); @@ -356,7 +365,13 @@ pub async fn posts_request( ); // return - Ok(Html(data.1.render("profile/posts.html", &context).unwrap())) + if responses_only { + Ok(Html( + data.1.render("profile/responses.html", &context).unwrap(), + )) + } else { + Ok(Html(data.1.render("profile/posts.html", &context).unwrap())) + } } /// `/@{username}/replies` diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index 3cadb2e..fbf229b 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -1,6 +1,8 @@ use super::common::NAME_REGEX; use oiseau::cache::Cache; -use crate::model::auth::{Achievement, AchievementRarity, Notification, UserConnections}; +use crate::model::auth::{ + Achievement, AchievementName, AchievementRarity, Notification, UserConnections, ACHIEVEMENTS, +}; use crate::model::moderation::AuditLogEntry; use crate::model::oauth::AuthGrant; use crate::model::permissions::SecondaryPermission; @@ -764,7 +766,13 @@ impl DataManager { /// Add an achievement to a user. /// /// Still returns `Ok` if the user already has the achievement. - pub async fn add_achievement(&self, user: &mut User, achievement: Achievement) -> Result<()> { + #[async_recursion::async_recursion] + pub async fn add_achievement( + &self, + user: &mut User, + achievement: Achievement, + check_for_final: bool, + ) -> Result<()> { if user.settings.disable_achievements { return Ok(()); } @@ -794,6 +802,15 @@ impl DataManager { self.update_user_achievements(user.id, user.achievements.to_owned()) .await?; + // check for final + if check_for_final { + if user.achievements.len() + 1 == ACHIEVEMENTS { + self.add_achievement(user, AchievementName::GetAllOtherAchievements.into(), false) + .await?; + } + } + + // ... Ok(()) } diff --git a/crates/core/src/database/memberships.rs b/crates/core/src/database/memberships.rs index 01f286b..610d0a0 100644 --- a/crates/core/src/database/memberships.rs +++ b/crates/core/src/database/memberships.rs @@ -242,8 +242,12 @@ impl DataManager { Ok(if data.role.check(CommunityPermission::REQUESTED) { "Join request sent".to_string() } else { - self.add_achievement(&mut user.clone(), AchievementName::JoinCommunity.into()) - .await?; + self.add_achievement( + &mut user.clone(), + AchievementName::JoinCommunity.into(), + true, + ) + .await?; "Community joined".to_string() }) diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index cc864cd..becb780 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -758,6 +758,37 @@ impl DataManager { Ok(res.unwrap()) } + /// Get all posts (that are answering a question) from the given user (from most recent). + /// + /// # Arguments + /// * `id` - the ID of the user the requested posts belong to + /// * `batch` - the limit of posts in each page + /// * `page` - the page number + pub async fn get_responses_by_user( + &self, + id: usize, + batch: usize, + page: usize, + ) -> Result> { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_rows!( + &conn, + "SELECT * FROM posts WHERE owner = $1 AND replying_to = 0 AND NOT (context::json->>'is_profile_pinned')::boolean AND is_deleted = 0 AND NOT context::jsonb->>'answering' = '0' ORDER BY created DESC LIMIT $2 OFFSET $3", + &[&(id as i64), &(batch as i64), &((page * batch) as i64)], + |x| { Self::get_post_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("post".to_string())); + } + + 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)) @@ -1066,6 +1097,45 @@ impl DataManager { Ok(res.unwrap()) } + /// Get all posts (that are answering a question) from the given user + /// with the given tag (from most recent). + /// + /// # Arguments + /// * `id` - the ID of the user the requested posts belong to + /// * `tag` - the tag to filter by + /// * `batch` - the limit of posts in each page + /// * `page` - the page number + pub async fn get_responses_by_user_tag( + &self, + id: usize, + tag: &str, + batch: usize, + page: usize, + ) -> Result> { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_rows!( + &conn, + "SELECT * FROM posts WHERE owner = $1 AND context::json->>'tags' LIKE $2 AND is_deleted = 0 AND NOT context::jsonb->>'answering' = '0' ORDER BY created DESC LIMIT $3 OFFSET $4", + params![ + &(id as i64), + &format!("%\"{tag}\"%"), + &(batch as i64), + &((page * batch) as i64) + ], + |x| { Self::get_post_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("post".to_string())); + } + + Ok(res.unwrap()) + } + /// Get all posts from the given community (from most recent). /// /// # Arguments @@ -1661,8 +1731,12 @@ impl DataManager { } // award achievement - self.add_achievement(&mut owner, AchievementName::CreatePostWithTitle.into()) - .await?; + self.add_achievement( + &mut owner, + AchievementName::CreatePostWithTitle.into(), + true, + ) + .await?; } } @@ -1803,7 +1877,7 @@ impl DataManager { } // award achievement - self.add_achievement(&mut owner, AchievementName::CreateRepost.into()) + self.add_achievement(&mut owner, AchievementName::CreateRepost.into(), true) .await?; } diff --git a/crates/core/src/database/reactions.rs b/crates/core/src/database/reactions.rs index 0a61261..c26c3dc 100644 --- a/crates/core/src/database/reactions.rs +++ b/crates/core/src/database/reactions.rs @@ -162,26 +162,26 @@ impl DataManager { // achievements if user.id != post.owner { let mut owner = self.get_user_by_id(post.owner).await?; - self.add_achievement(&mut owner, AchievementName::Get1Like.into()) + self.add_achievement(&mut owner, AchievementName::Get1Like.into(), true) .await?; if post.likes >= 9 { - self.add_achievement(&mut owner, AchievementName::Get10Likes.into()) + self.add_achievement(&mut owner, AchievementName::Get10Likes.into(), true) .await?; } if post.likes >= 49 { - self.add_achievement(&mut owner, AchievementName::Get50Likes.into()) + self.add_achievement(&mut owner, AchievementName::Get50Likes.into(), true) .await?; } if post.likes >= 99 { - self.add_achievement(&mut owner, AchievementName::Get100Likes.into()) + self.add_achievement(&mut owner, AchievementName::Get100Likes.into(), true) .await?; } if post.dislikes >= 24 { - self.add_achievement(&mut owner, AchievementName::Get25Dislikes.into()) + self.add_achievement(&mut owner, AchievementName::Get25Dislikes.into(), true) .await?; } } diff --git a/crates/core/src/database/userfollows.rs b/crates/core/src/database/userfollows.rs index ffcd891..5428f67 100644 --- a/crates/core/src/database/userfollows.rs +++ b/crates/core/src/database/userfollows.rs @@ -262,33 +262,50 @@ impl DataManager { // check if we're staff if initiator.permissions.check(FinePermission::STAFF_BADGE) { - self.add_achievement(&mut other_user, AchievementName::FollowedByStaff.into()) - .await?; + self.add_achievement( + &mut other_user, + AchievementName::FollowedByStaff.into(), + true, + ) + .await?; } // other achivements - self.add_achievement(&mut other_user, AchievementName::Get1Follower.into()) + self.add_achievement(&mut other_user, AchievementName::Get1Follower.into(), true) .await?; if other_user.follower_count >= 9 { - self.add_achievement(&mut other_user, AchievementName::Get10Followers.into()) - .await?; + self.add_achievement( + &mut other_user, + AchievementName::Get10Followers.into(), + true, + ) + .await?; } if other_user.follower_count >= 49 { - self.add_achievement(&mut other_user, AchievementName::Get50Followers.into()) - .await?; + self.add_achievement( + &mut other_user, + AchievementName::Get50Followers.into(), + true, + ) + .await?; } if other_user.follower_count >= 99 { - self.add_achievement(&mut other_user, AchievementName::Get100Followers.into()) - .await?; + self.add_achievement( + &mut other_user, + AchievementName::Get100Followers.into(), + true, + ) + .await?; } if initiator.following_count >= 9 { self.add_achievement( &mut initiator.clone(), AchievementName::Follow10Users.into(), + true, ) .await?; } diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index 91b67d9..2b47562 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -124,6 +124,20 @@ impl DefaultTimelineChoice { } } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum DefaultProfileTabChoice { + /// General posts (in any community) from the user. + Posts, + /// Responses to questions. + Responses, +} + +impl Default for DefaultProfileTabChoice { + fn default() -> Self { + Self::Posts + } +} + #[derive(Clone, Debug, Serialize, Deserialize, Default)] pub struct UserSettings { #[serde(default)] @@ -285,6 +299,9 @@ pub struct UserSettings { /// Automatically hide users that you've blocked on your other accounts from your timelines. #[serde(default)] pub hide_associated_blocked_users: bool, + /// Which tab is shown by default on the user's profile. + #[serde(default)] + pub default_profile_tab: DefaultProfileTabChoice, } fn mime_avif() -> String { @@ -504,10 +521,15 @@ pub struct ExternalConnectionData { } /// The total number of achievements needed to 100% Tetratto! -pub const ACHIEVEMENTS: usize = 30; +pub const ACHIEVEMENTS: usize = 34; /// "self-serve" achievements can be granted by the user through the API. -pub const SELF_SERVE_ACHIEVEMENTS: &[AchievementName] = - &[AchievementName::OpenTos, AchievementName::OpenPrivacyPolicy]; +pub const SELF_SERVE_ACHIEVEMENTS: &[AchievementName] = &[ + AchievementName::OpenReference, + AchievementName::OpenTos, + AchievementName::OpenPrivacyPolicy, + AchievementName::AcceptProfileWarning, + AchievementName::OpenSessionSettings, +]; #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub enum AchievementName { @@ -541,6 +563,10 @@ pub enum AchievementName { CreateRepost, OpenTos, OpenPrivacyPolicy, + OpenReference, + GetAllOtherAchievements, + AcceptProfileWarning, + OpenSessionSettings, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -583,6 +609,10 @@ impl AchievementName { Self::CreateRepost => "More than a like or comment...", Self::OpenTos => "Well informed!", Self::OpenPrivacyPolicy => "Privacy conscious", + Self::OpenReference => "What does this do?", + Self::GetAllOtherAchievements => "The final performance", + Self::AcceptProfileWarning => "I accept the risks!", + Self::OpenSessionSettings => "Am I alone in here?", } } @@ -618,6 +648,10 @@ impl AchievementName { Self::CreateRepost => "Create a repost or quote.", Self::OpenTos => "Open the terms of service.", Self::OpenPrivacyPolicy => "Open the privacy policy.", + Self::OpenReference => "Open the source code reference documentation.", + Self::GetAllOtherAchievements => "Get every other achievement.", + Self::AcceptProfileWarning => "Accept a profile warning.", + Self::OpenSessionSettings => "Open your session settings.", } } @@ -655,6 +689,10 @@ impl AchievementName { Self::CreateRepost => Common, Self::OpenTos => Uncommon, Self::OpenPrivacyPolicy => Uncommon, + Self::OpenReference => Uncommon, + Self::GetAllOtherAchievements => Rare, + Self::AcceptProfileWarning => Common, + Self::OpenSessionSettings => Common, } } }