From 7bda718082d91b602cbb95ac7daef5cc2984a77f Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 1 Jun 2025 19:26:55 -0400 Subject: [PATCH] add: outbox tab on profile tab is only visible to profile owner and mods --- crates/app/src/assets.rs | 10 +- crates/app/src/langs/en-US.toml | 1 + crates/app/src/public/css/style.css | 17 +++- crates/app/src/public/html/components.lisp | 22 ++++- crates/app/src/public/html/macros.lisp | 15 ++- crates/app/src/public/html/misc/requests.lisp | 15 ++- .../app/src/public/html/profile/outbox.lisp | 44 +++++++++ crates/app/src/public/js/atto.js | 2 +- crates/app/src/routes/pages/misc.rs | 24 ++++- crates/app/src/routes/pages/mod.rs | 1 + crates/app/src/routes/pages/profile.rs | 96 +++++++++++++++++++ crates/core/src/database/questions.rs | 26 +++++ 12 files changed, 264 insertions(+), 9 deletions(-) create mode 100644 crates/app/src/public/html/profile/outbox.lisp diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index 7a86bb3..b451601 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -65,6 +65,7 @@ pub const PROFILE_BLOCKED: &str = include_str!("./public/html/profile/blocked.li 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 COMMUNITIES_LIST: &str = include_str!("./public/html/communities/list.lisp"); pub const COMMUNITIES_BASE: &str = include_str!("./public/html/communities/base.lisp"); @@ -144,7 +145,13 @@ pub(crate) async fn pull_icon(icon: &str, icons_dir: &str) { } println!("download icon: {icon}"); - let svg = reqwest::get(icon_url).await.unwrap().text().await.unwrap(); + let svg = reqwest::get(icon_url) + .await + .unwrap() + .text() + .await + .unwrap() + .replace("\n", ""); write(&file_path, &svg).unwrap(); writer.insert(icon.to_string(), svg); @@ -331,6 +338,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"profile/banned.html"(crate::assets::PROFILE_BANNED) --config=config --lisp plugins); 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->"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 d251b81..f364489 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -70,6 +70,7 @@ version = "1.0.0" "auth:label.posts" = "Posts" "auth:label.replies" = "Replies" "auth:label.media" = "Media" +"auth:label.outbox" = "Outbox" "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." diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css index b407bfe..9ae4ac8 100644 --- a/crates/app/src/public/css/style.css +++ b/crates/app/src/public/css/style.css @@ -1292,10 +1292,25 @@ details summary::-webkit-details-marker { } details[open] summary { - background: hsla(var(--color-primary-hsl), 25%); + position: relative; + color: var(--color-primary); + background: var(--color-super-lowered); margin-bottom: 0.25rem; } +details[open] summary::after { + top: 0; + left: 0; + width: 5px; + content: ""; + height: 100%; + position: absolute; + background: var(--color-primary); + border-top-left-radius: var(--radius); + border-bottom-left-radius: var(--radius); + animation: fadein ease-in-out 1 0.1s forwards running; +} + details .card { background: var(--color-super-raised); } diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 1190070..8ce9a8a 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -202,8 +202,9 @@ (text "{%- endif %} {%- endif %}")) (text "{{ self::post_media(upload_ids=post.uploads) }} {% else %}") (details + ("class" "card tiny tertiary w-full") (summary - ("class" "card flex gap-2 flex-wrap items-center tertiary red w-full") + ("class" "flex gap-2 flex-wrap items-center red w-full") (text "{{ icon \"triangle-alert\" }}") (b (text "{{ post.context.content_warning }}"))) @@ -541,7 +542,7 @@ (text "{%- endif %} {%- endmacro %} {% macro question(question, owner, show_community=true, secondary=false, profile=false) -%}") (div - ("class" "card{% if secondary -%} secondary{%- endif %} flex gap-2") + ("class" "card {% if secondary -%}secondary{%- endif %} flex gap-2") (text "{% if owner.id == 0 -%}") (span (text "{% if profile and profile.settings.anonymous_avatar_url -%}") @@ -558,7 +559,7 @@ (text "{{ self::avatar(username=owner.username, selector_type=\"username\", size=\"52px\") }}")) (text "{%- endif %}") (div - ("class" "flex flex-col gap-1") + ("class" "flex flex-col gap-1 w-full") (div ("class" "flex items-center gap-2 flex-wrap") (span @@ -606,6 +607,21 @@ ("class" "no_p_margin") ("style" "font-weight: 500") (text "{{ question.content|markdown|safe }}")) + ; anonymous user ip thing + ; this is only shown if the post author is anonymous AND we are a helper + (text "{% if is_helper and owner.id == 0 %}") + (details + ("class" "card tiny tertiary w-full") + (summary + ("class" "w-full flex gap-2 flex-wrap items-center") + (icon (text "shield")) + (span (text "View IP"))) + + (div + ("class" "card secondary") + (pre (code (text "{{ question.ip }}"))))) + (text "{% endif %}") + ; ... (div ("class" "flex gap-2 items-center justify-between")))) diff --git a/crates/app/src/public/html/macros.lisp b/crates/app/src/public/html/macros.lisp index 858ad66..9f3a66d 100644 --- a/crates/app/src/public/html/macros.lisp +++ b/crates/app/src/public/html/macros.lisp @@ -211,5 +211,18 @@ (a ("href" "/@{{ profile.username }}/media") ("class" "{% if selected == 'media' -%}active{%- endif %}") - (str (text "auth:label.media")))) + (str (text "auth:label.media"))) + + (text "{% if is_self or is_helper %}") + (a + ("href" "/@{{ profile.username }}/outbox") + ("class" "{% if selected == 'outbox' -%}active{%- endif %}") + (str (text "auth:label.outbox"))) + (text "{% endif %}") + + (text "{% if is_helper %}") + (a + ("href" "/requests?id={{ profile.id }}") + (str (text "requests:label.requests"))) + (text "{% endif %}")) (text "{%- endmacro %}") diff --git a/crates/app/src/public/html/misc/requests.lisp b/crates/app/src/public/html/misc/requests.lisp index 4b97b53..558ea6a 100644 --- a/crates/app/src/public/html/misc/requests.lisp +++ b/crates/app/src/public/html/misc/requests.lisp @@ -5,6 +5,17 @@ (text "{% endblock %} {% block body %} {{ macros::nav(selected=\"requests\") }}") (main ("class" "flex flex-col gap-2") + + ; viewing other user's requests warning + (text "{% if profile.id != user.id -%}") + (div + ("class" "card w-full red flex gap-2 items-center") + (text "{{ icon \"skull\" }}") + (b + (text "Viewing other user's requests! Please be careful."))) + (text "{%- endif %}") + + ; ... (div ("class" "card-nest") (div @@ -14,12 +25,14 @@ (text "{{ icon \"inbox\" }}") (span (text "{{ text \"requests:label.requests\" }}"))) + (text "{% if profile.id == user.id -%}") (button ("onclick" "clear_requests()") ("class" "small red quaternary") (text "{{ icon \"bomb\" }}") (span - (text "{{ text \"notifs:action.clear\" }}")))) + (text "{{ text \"notifs:action.clear\" }}"))) + (text "{% endif %}")) (div ("class" "card tertiary flex flex-col gap-4") (text "{% for request in requests %} {% if request.action_type == \"CommunityJoin\" %}") diff --git a/crates/app/src/public/html/profile/outbox.lisp b/crates/app/src/public/html/profile/outbox.lisp new file mode 100644 index 0000000..97efc2d --- /dev/null +++ b/crates/app/src/public/html/profile/outbox.lisp @@ -0,0 +1,44 @@ +(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) }}")) + +(text "{%- endif %} {{ macros::profile_nav(selected=\"outbox\") }}") +(div + ("class" "card-nest") + (div + ("class" "card small flex gap-2 justify-between items-center") + (div + ("class" "flex gap-2 items-center") + (text "{{ icon \"send\" }}") + (span + (text "{{ text \"auth:label.outbox\" }}")))) + (div + ("class" "card tertiary flex flex-col gap-4") + (text "{% for question in questions %}") + (div + ("class" "card-nest") + + ; show the actual question + (text "{{ components::question(question=question[0], owner=question[1], profile=user, secondary=true) }}") + + ; options + (div + ("class" "card small flex justify-between items-center gap-2") + ; show the avatar of the person we sent the question to + (a + ("class" "flex items-center gap-2 flush") + ("href" "/api/v1/auth/user/find/{{ question[0].receiver }}") + (icon (text "send")) + (text "{{ components::avatar(username=question[0].receiver, selector_type='id') }}")) + + ; show button to delete question + (button + ("class" "quaternary small red") + ("onclick" "trigger('me::remove_question', ['{{ question[0].id }}'])") + (icon (text "trash")) + (str (text "general:action.delete"))))) + (text "{% endfor %}") + (text "{{ components::pagination(page=page, items=questions|length) }}"))) + +(text "{% endblock %}") diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js index 2cd1100..ea4e680 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -133,7 +133,7 @@ media_theme_pref(); element.setAttribute("title", then.toLocaleString()); - let pretty = $.rel_date(then); + let pretty = $.rel_date(then) || ""; if ( (screen.width < 900 && pretty !== undefined) | diff --git a/crates/app/src/routes/pages/misc.rs b/crates/app/src/routes/pages/misc.rs index 96f1a06..52f806b 100644 --- a/crates/app/src/routes/pages/misc.rs +++ b/crates/app/src/routes/pages/misc.rs @@ -387,10 +387,17 @@ pub async fn notifications_request( )) } +#[derive(Deserialize)] +pub struct RequestsProps { + #[serde(default)] + pub id: usize, +} + /// `/requests` pub async fn requests_request( jar: CookieJar, Extension(data): Extension, + Query(props): Query, ) -> impl IntoResponse { let data = data.read().await; let user = match get_user_from_token!(jar, data.0) { @@ -402,7 +409,20 @@ pub async fn requests_request( } }; - let requests = match data.0.get_requests_by_owner(user.id).await { + let profile = if props.id != 0 { + match data.0.get_user_by_id(props.id).await { + Ok(p) => p, + Err(e) => return Err(Html(render_error(e, &jar, &data, &None).await)), + } + } else { + user.clone() + }; + + let requests = match data + .0 + .get_requests_by_owner(if props.id != 0 { props.id } else { user.id }) + .await + { Ok(p) => p, Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), }; @@ -448,6 +468,8 @@ pub async fn requests_request( let lang = get_lang!(jar, data.0); let mut context = initial_context(&data.0.0, lang, &Some(user)).await; + + context.insert("profile", &profile); context.insert("requests", &requests); context.insert("questions", &questions); diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index aa5956f..4206d4e 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -70,6 +70,7 @@ pub fn routes() -> Router { .route("/settings", get(profile::settings_request)) .route("/@{username}", get(profile::posts_request)) .route("/@{username}/media", get(profile::media_request)) + .route("/@{username}/outbox", get(profile::outbox_request)) .route("/@{username}/replies", get(profile::replies_request)) .route("/@{username}/following", get(profile::following_request)) .route("/@{username}/followers", get(profile::followers_request)) diff --git a/crates/app/src/routes/pages/profile.rs b/crates/app/src/routes/pages/profile.rs index d006b3b..faee31d 100644 --- a/crates/app/src/routes/pages/profile.rs +++ b/crates/app/src/routes/pages/profile.rs @@ -573,6 +573,102 @@ pub async fn media_request( Ok(Html(data.1.render("profile/media.html", &context).unwrap())) } +/// `/@{username}/outbox` +pub async fn outbox_request( + jar: CookieJar, + Path(username): Path, + Query(props): Query, + Extension(data): Extension, +) -> impl IntoResponse { + let data = data.read().await; + let user = match get_user_from_token!(jar, data.0) { + Some(ua) => ua, + None => { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &None).await, + )); + } + }; + + let other_user = match data.0.get_user_by_username(&username).await { + Ok(ua) => ua, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + if user.id != other_user.id && !user.permissions.check(FinePermission::MANAGE_QUESTIONS) { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &Some(user)).await, + )); + } + + check_user_blocked_or_private!(Some(user.clone()), other_user, data, jar); + + // fetch data + let ignore_users = crate::ignore_users_gen!(user!, data); + + let questions = match data + .0 + .get_questions_by_owner_paginated(other_user.id, 12, props.page) + .await + { + Ok(p) => match data.0.fill_questions(p, &ignore_users).await { + Ok(p) => p, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + let communities = match data.0.get_memberships_by_owner(other_user.id).await { + Ok(m) => match data.0.fill_communities(m).await { + Ok(m) => m, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + // init context + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0, lang, &Some(user.clone())).await; + + let is_self = user.id == other_user.id; + + let is_following = data + .0 + .get_userfollow_by_initiator_receiver(user.id, other_user.id) + .await + .is_ok(); + + let is_following_you = data + .0 + .get_userfollow_by_receiver_initiator(user.id, other_user.id) + .await + .is_ok(); + + let is_blocking = data + .0 + .get_userblock_by_initiator_receiver(user.id, other_user.id) + .await + .is_ok(); + + context.insert("questions", &questions); + context.insert("page", &props.page); + profile_context( + &mut context, + &Some(user), + &other_user, + &communities, + is_self, + is_following, + is_following_you, + is_blocking, + ); + + // return + Ok(Html( + data.1.render("profile/outbox.html", &context).unwrap(), + )) +} + /// `/@{username}/following` pub async fn following_request( jar: CookieJar, diff --git a/crates/core/src/database/questions.rs b/crates/core/src/database/questions.rs index 931b001..314e853 100644 --- a/crates/core/src/database/questions.rs +++ b/crates/core/src/database/questions.rs @@ -97,6 +97,32 @@ impl DataManager { Ok(res.unwrap()) } + /// Get all questions by `owner` (paginated). + pub async fn get_questions_by_owner_paginated( + &self, + owner: usize, + batch: usize, + page: usize, + ) -> Result> { + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_rows!( + &conn, + "SELECT * FROM questions WHERE owner = $1 AND NOT context LIKE '%\"is_nsfw\":true%' ORDER BY created DESC LIMIT $2 OFFSET $3", + &[&(owner as i64), &(batch as i64), &((page * batch) as i64)], + |x| { Self::get_question_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("question".to_string())); + } + + Ok(res.unwrap()) + } + /// Get all questions by `receiver`. pub async fn get_questions_by_receiver(&self, receiver: usize) -> Result> { let conn = match self.connect().await {