From b71ae1f5a4ad543c6acb7cb6ce880f939b0948e9 Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 15 Jun 2025 11:52:44 -0400 Subject: [PATCH 01/75] add: block list stacks --- crates/app/src/assets.rs | 4 +- crates/app/src/langs/en-US.toml | 2 + crates/app/src/macros.rs | 19 +- crates/app/src/public/css/style.css | 6 +- crates/app/src/public/html/components.lisp | 17 ++ .../app/src/public/html/developer/home.lisp | 10 +- .../app/src/public/html/profile/settings.lisp | 16 ++ crates/app/src/public/html/stacks/feed.lisp | 85 +++++++ crates/app/src/public/html/stacks/list.lisp | 15 +- crates/app/src/public/html/stacks/manage.lisp | 6 +- crates/app/src/public/html/stacks/posts.lisp | 37 --- crates/app/src/public/js/atto.js | 17 +- crates/app/src/routes/api/v1/mod.rs | 3 + crates/app/src/routes/api/v1/stacks.rs | 105 +++++++- crates/app/src/routes/pages/mod.rs | 2 +- crates/app/src/routes/pages/profile.rs | 88 ++++--- crates/app/src/routes/pages/stacks.rs | 67 ++++-- crates/core/src/database/common.rs | 6 +- crates/core/src/database/drivers/common.rs | 1 + .../drivers/sql/create_stackblocks.sql | 6 + crates/core/src/database/mod.rs | 1 + crates/core/src/database/posts.rs | 12 + crates/core/src/database/reactions.rs | 12 +- crates/core/src/database/stackblocks.rs | 224 ++++++++++++++++++ crates/core/src/database/stacks.rs | 30 +++ crates/core/src/database/userfollows.rs | 37 +++ crates/core/src/model/oauth.rs | 66 +----- crates/core/src/model/stacks.rs | 25 ++ 28 files changed, 700 insertions(+), 219 deletions(-) create mode 100644 crates/app/src/public/html/stacks/feed.lisp delete mode 100644 crates/app/src/public/html/stacks/posts.lisp create mode 100644 crates/core/src/database/drivers/sql/create_stackblocks.sql create mode 100644 crates/core/src/database/stackblocks.rs diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index 45f40f1..877161c 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -113,7 +113,7 @@ pub const CHATS_MESSAGE: &str = include_str!("./public/html/chats/message.lisp") pub const CHATS_CHANNELS: &str = include_str!("./public/html/chats/channels.lisp"); pub const STACKS_LIST: &str = include_str!("./public/html/stacks/list.lisp"); -pub const STACKS_POSTS: &str = include_str!("./public/html/stacks/posts.lisp"); +pub const STACKS_FEED: &str = include_str!("./public/html/stacks/feed.lisp"); pub const STACKS_MANAGE: &str = include_str!("./public/html/stacks/manage.lisp"); pub const FORGE_HOME: &str = include_str!("./public/html/forge/home.lisp"); @@ -401,7 +401,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"chats/channels.html"(crate::assets::CHATS_CHANNELS) --config=config --lisp plugins); write_template!(html_path->"stacks/list.html"(crate::assets::STACKS_LIST) -d "stacks" --config=config --lisp plugins); - write_template!(html_path->"stacks/posts.html"(crate::assets::STACKS_POSTS) --config=config --lisp plugins); + write_template!(html_path->"stacks/feed.html"(crate::assets::STACKS_FEED) --config=config --lisp plugins); write_template!(html_path->"stacks/manage.html"(crate::assets::STACKS_MANAGE) --config=config --lisp plugins); write_template!(html_path->"forge/home.html"(crate::assets::FORGE_HOME) -d "forge" --config=config --lisp plugins); diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index d32ef2e..270feec 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -204,6 +204,8 @@ version = "1.0.0" "stacks:tab.users" = "Users" "stacks:label.add_user" = "Add user" "stacks:label.remove" = "Remove" +"stacks:label.block_all" = "Block all" +"stacks:label.unblock_all" = "Unblock all" "forge:label.my_forges" = "My forges" "forge:label.create_new" = "Create new forge" diff --git a/crates/app/src/macros.rs b/crates/app/src/macros.rs index a990ee1..6377581 100644 --- a/crates/app/src/macros.rs +++ b/crates/app/src/macros.rs @@ -203,11 +203,16 @@ macro_rules! check_user_blocked_or_private { // check if we're blocked if let Some(ref ua) = $user { - if $data + if ($data .0 .get_userblock_by_initiator_receiver($other_user.id, ua.id) .await .is_ok() + | $data + .0 + .get_user_stack_blocked_users($other_user.id) + .await + .contains(&ua.id)) && !ua.permissions.check(FinePermission::MANAGE_USERS) { let lang = get_lang!($jar, $data.0); @@ -291,10 +296,14 @@ macro_rules! check_user_blocked_or_private { ($user:expr, $other_user:ident, $data:ident, @api) => { // check if we're blocked if let Some(ref ua) = $user { - if $data + if ($data .get_userblock_by_initiator_receiver($other_user.id, ua.id) .await .is_ok() + | $data + .get_user_stack_blocked_users($other_user.id) + .await + .contains(&ua.id)) && !ua .permissions .check(tetratto_core::model::permissions::FinePermission::MANAGE_USERS) @@ -338,6 +347,7 @@ macro_rules! ignore_users_gen { [ $data.0.get_userblocks_receivers(ua.id).await, $data.0.get_userblocks_initiator_by_receivers(ua.id).await, + $data.0.get_user_stack_blocked_users(ua.id).await, ] .concat() } else { @@ -345,16 +355,17 @@ macro_rules! ignore_users_gen { } }; - ($user:ident!, $data:ident) => { + ($user:ident!, $data:ident) => {{ [ $data.0.get_userblocks_receivers($user.id).await, $data .0 .get_userblocks_initiator_by_receivers($user.id) .await, + $data.0.get_user_stack_blocked_users($user.id).await, ] .concat() - }; + }}; ($user:ident!, #$data:ident) => { [ diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css index 154f64c..fef2659 100644 --- a/crates/app/src/public/css/style.css +++ b/crates/app/src/public/css/style.css @@ -56,7 +56,8 @@ animation: popin ease-in-out 1 0.15s forwards running; } -.lightbox a { +.lightbox a, +.lightbox img { --padding: 2rem; cursor: zoom-in; max-height: calc(100dvh - var(--padding)); @@ -684,6 +685,9 @@ nav .button:not(.title):not(.active):hover { gap: var(--pad-2); align-items: center; justify-content: space-between; + position: sticky; + top: 0; + z-index: 1; } /* mobile nav chip nav */ diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 8a4c953..239d376 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -1817,3 +1817,20 @@ ("class" "no_p_margin") (text "{{ post.title|markdown|safe }}")))) (text "{%- endmacro %}") + +(text "{% macro stack_listing(stack) -%}") +(a + ("href" "/stacks/{{ stack.id }}") + ("class" "card secondary flex flex-col gap-2") + (div + ("class" "flex items-center gap-2") + (text "{{ icon \"list\" }}") + (b + (text "{{ stack.name }}"))) + (span + (text "Created ") + (span + ("class" "date") + (text "{{ stack.created }}")) + (text "; {{ stack.privacy }}; {{ stack.users|length }} users"))) +(text "{%- endmacro %}") diff --git a/crates/app/src/public/html/developer/home.lisp b/crates/app/src/public/html/developer/home.lisp index ffc68b8..3a9543f 100644 --- a/crates/app/src/public/html/developer/home.lisp +++ b/crates/app/src/public/html/developer/home.lisp @@ -101,15 +101,15 @@ (li (a ("href" "https://trisua.com/t/tetratto") (text "Source code"))) (li - (a ("href" "https://tetratto.com/reference/tetratto/index.html") (text "Source code reference"))) + (a ("href" "https://tetratto.com/reference/tetratto/index.html") ("data-turbo" "false") (text "Source code reference"))) (li - (a ("href" "https://tetratto.com/reference/tetratto/model/struct.ApiReturn.html") (text "API response structure"))) + (a ("href" "https://tetratto.com/reference/tetratto/model/struct.ApiReturn.html") ("data-turbo" "false") (text "API response structure"))) (li - (a ("href" "https://tetratto.com/reference/tetratto/model/oauth/enum.AppScope.html") (text "App scopes"))) + (a ("href" "https://tetratto.com/reference/tetratto/model/oauth/enum.AppScope.html") ("data-turbo" "false") (text "App scopes"))) (li - (a ("href" "https://tetratto.com/reference/tetratto/model/permissions/struct.FinePermission.html") (text "User permissions"))) + (a ("href" "https://tetratto.com/reference/tetratto/model/permissions/struct.FinePermission.html") ("data-turbo" "false") (text "User permissions"))) (li - (a ("href" "https://tetratto.com/reference/tetratto/model/communities_permissions/struct.CommunityPermission.html") (text "Community member permissions"))) + (a ("href" "https://tetratto.com/reference/tetratto/model/communities_permissions/struct.CommunityPermission.html") ("data-turbo" "false") (text "Community member permissions"))) (li (a ("href" "https://tetratto.com/forge/tetratto") (text "Report issues"))))))) diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index 8514e34..d317d95 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -388,6 +388,22 @@ (text "{{ icon \"arrow-left\" }}") (span (text "{{ text \"general:action.back\" }}"))) + + ; stack blocks + (div + ("class" "card-nest") + (div + ("class" "card flex items-center gap-2 small") + (text "{{ icon \"layers\" }}") + (span + (text "{{ text \"stacks:link.stacks\" }}"))) + (div + ("class" "card flex flex-col gap-2") + (text "{% for stack in stackblocks %}") + (text "{{ components::stack_listing(stack=stack) }}") + (text "{% endfor %}"))) + + ; user blocks (div ("class" "card-nest") (div diff --git a/crates/app/src/public/html/stacks/feed.lisp b/crates/app/src/public/html/stacks/feed.lisp new file mode 100644 index 0000000..6107546 --- /dev/null +++ b/crates/app/src/public/html/stacks/feed.lisp @@ -0,0 +1,85 @@ +(text "{% extends \"root.html\" %} {% block head %}") +(title + (text "{{ stack.name }} - {{ config.name }}")) + +(text "{% endblock %} {% block body %} {{ macros::nav() }}") +(main + ("class" "flex flex-col gap-2") + (text "{{ macros::timelines_nav(selected=\"stacks\") }}") + (div + ("class" "card-nest w-full") + (div + ("class" "card small flex items-center justify-between gap-2") + (div + ("class" "flex items-center gap-2") + (text "{{ icon \"list\" }}") + (span + (text "{{ stack.name }}"))) + (text "{% if user and user.id == stack.owner -%}") + (a + ("href" "/stacks/{{ stack.id }}/manage") + ("class" "button lowered small") + (text "{{ icon \"pencil\" }}") + (span + (text "{{ text \"general:action.manage\" }}"))) + (text "{%- endif %}")) + (div + ("class" "card w-full flex flex-col gap-2") + (text "{% if list|length == 0 -%}") + (p + (text "No items yet! Maybe ") + (a + ("href" "/stacks/{{ stack.id }}/manage#/users") + (text "add a user to this stack")) + (text "!")) + (text "{%- endif %}") + + (text "{% if stack.mode == 'BlockList' -%}") + (text "{% if not is_blocked -%}") + (button + ("onclick" "block_all()") + (str (text "stacks:label.block_all"))) + (text "{% else %}") + (button + ("onclick" "block_all(false)") + (str (text "stacks:label.unblock_all"))) + (text "{%- endif %}") + (div + ("class" "flex gap-2 flex-wrap w-full") + (text "{% for user in list %} {{ components::user_plate(user=user, secondary=true) }} {% endfor %}")) + (text "{% else %}") + (text "{% for post in list %} + {% 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) }} + {% else %} + {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], poll=post[5]) }} + {%- endif %} {%- endif %} {% endfor %}") + (text "{%- endif %} {{ components::pagination(page=page, items=list|length) }}")))) + +(script + (text "async function block_all(block = true) { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + fetch(\"/api/v1/stacks/{{ stack.id }}/block\", { + method: block ? \"POST\" : \"DELETE\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + window.location.href = \"/settings#/account/blocks\"; + } + }); + }")) +(text "{% endblock %}") diff --git a/crates/app/src/public/html/stacks/list.lisp b/crates/app/src/public/html/stacks/list.lisp index 6ef2cab..50246ef 100644 --- a/crates/app/src/public/html/stacks/list.lisp +++ b/crates/app/src/public/html/stacks/list.lisp @@ -44,20 +44,7 @@ (div ("class" "card flex flex-col gap-2") (text "{% for item in list %}") - (a - ("href" "/stacks/{{ item.id }}") - ("class" "card secondary flex flex-col gap-2") - (div - ("class" "flex items-center gap-2") - (text "{{ icon \"list\" }}") - (b - (text "{{ item.name }}"))) - (span - (text "Created ") - (span - ("class" "date") - (text "{{ item.created }}")) - (text "; {{ item.privacy }}; {{ item.users|length }} users"))) + (text "{{ components::stack_listing(stack=item) }}") (text "{% endfor %}")))) (script diff --git a/crates/app/src/public/html/stacks/manage.lisp b/crates/app/src/public/html/stacks/manage.lisp index a05e680..ef608c5 100644 --- a/crates/app/src/public/html/stacks/manage.lisp +++ b/crates/app/src/public/html/stacks/manage.lisp @@ -63,7 +63,11 @@ (option ("value" "Exclude") ("selected" "{% if stack.mode == 'Exclude' -%}true{% else %}false{%- endif %}") - (text "Exclude"))))) + (text "Exclude")) + (option + ("value" "BlockList") + ("selected" "{% if stack.mode == 'BlockList' -%}true{% else %}false{%- endif %}") + (text "Block list"))))) (div ("class" "card-nest") ("ui_ident" "sort") diff --git a/crates/app/src/public/html/stacks/posts.lisp b/crates/app/src/public/html/stacks/posts.lisp deleted file mode 100644 index cddf9c7..0000000 --- a/crates/app/src/public/html/stacks/posts.lisp +++ /dev/null @@ -1,37 +0,0 @@ -(text "{% extends \"root.html\" %} {% block head %}") -(title - (text "{{ stack.name }} - {{ config.name }}")) - -(text "{% endblock %} {% block body %} {{ macros::nav() }}") -(main - ("class" "flex flex-col gap-2") - (text "{{ macros::timelines_nav(selected=\"stacks\") }}") - (div - ("class" "card-nest w-full") - (div - ("class" "card small flex items-center justify-between gap-2") - (div - ("class" "flex items-center gap-2") - (text "{{ icon \"list\" }}") - (span - (text "{{ stack.name }}"))) - (text "{% if user and user.id == stack.owner -%}") - (a - ("href" "/stacks/{{ stack.id }}/manage") - ("class" "button lowered small") - (text "{{ icon \"pencil\" }}") - (span - (text "{{ text \"general:action.manage\" }}"))) - (text "{%- endif %}")) - (div - ("class" "card w-full flex flex-col gap-2") - (text "{% if list|length == 0 -%}") - (p - (text "No posts yet! Maybe ") - (a - ("href" "/stacks/{{ stack.id }}/manage#/users") - (text "add a user to this stack")) - (text "!")) - (text "{%- endif %} {% for post in list %} {% 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) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], poll=post[5]) }} {%- endif %} {%- endif %} {% endfor %} {{ components::pagination(page=page, items=list|length) }}")))) - -(text "{% endblock %}") diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js index 1e34ef2..28cd3fc 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -1104,24 +1104,9 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} ); // lightbox - self.define("lightbox_open", async (_, src) => { + self.define("lightbox_open", (_, src) => { document.getElementById("lightbox_img").src = src; document.getElementById("lightbox_img_a").href = src; - - await (async () => { - return new Promise((resolve, reject) => { - let idx = 0; - const inter = setInterval(() => { - idx += 1; - if (document.getElementById("lightbox_img").complete) { - console.log(`img loaded (took ${idx})`); - clearInterval(inter); - return resolve(); - } - }, 25); - }); - })(); - document.getElementById("lightbox").classList.remove("hidden"); }); diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index ca5f747..4b81eb5 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -537,8 +537,11 @@ pub fn routes() -> Router { .route("/stacks/{id}/privacy", post(stacks::update_privacy_request)) .route("/stacks/{id}/mode", post(stacks::update_mode_request)) .route("/stacks/{id}/sort", post(stacks::update_sort_request)) + .route("/stacks/{id}/users", get(stacks::get_users_request)) .route("/stacks/{id}/users", post(stacks::add_user_request)) .route("/stacks/{id}/users", delete(stacks::remove_user_request)) + .route("/stacks/{id}/block", post(stacks::block_request)) + .route("/stacks/{id}/block", delete(stacks::unblock_request)) .route("/stacks/{id}", delete(stacks::delete_request)) // uploads .route("/uploads/{id}", get(uploads::get_request)) diff --git a/crates/app/src/routes/api/v1/stacks.rs b/crates/app/src/routes/api/v1/stacks.rs index 8faab1f..94b2c5a 100644 --- a/crates/app/src/routes/api/v1/stacks.rs +++ b/crates/app/src/routes/api/v1/stacks.rs @@ -1,7 +1,16 @@ -use crate::{State, get_user_from_token}; -use axum::{Extension, Json, extract::Path, response::IntoResponse}; +use crate::{get_user_from_token, routes::pages::PaginatedQuery, State}; +use axum::{ + extract::{Path, Query}, + response::IntoResponse, + Extension, Json, +}; use axum_extra::extract::CookieJar; -use tetratto_core::model::{oauth, stacks::UserStack, ApiReturn, Error}; +use tetratto_core::model::{ + oauth, + permissions::FinePermission, + stacks::{StackBlock, StackPrivacy, UserStack}, + ApiReturn, Error, +}; use super::{ AddOrRemoveStackUser, CreateStack, UpdateStackMode, UpdateStackName, UpdateStackPrivacy, UpdateStackSort, @@ -221,3 +230,93 @@ pub async fn delete_request( Err(e) => Json(e.into()), } } + +pub async fn get_users_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Query(props): Query, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadProfiles) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let stack = match data.get_stack_by_id(id).await { + Ok(s) => s, + Err(e) => return Json(e.into()), + }; + + if stack.privacy == StackPrivacy::Private + && user.id != stack.owner + && !user.permissions.check(FinePermission::MANAGE_STACKS) + { + return Json(Error::NotAllowed.into()); + } + + match data.get_stack_users(id, 12, props.page).await { + Ok(users) => Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some({ + let mut out = Vec::new(); + + for mut u in users.clone() { + u.clean(); + out.push(u) + } + + out + }), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn block_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadProfiles) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.create_stackblock(StackBlock::new(user.id, id)).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn unblock_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadProfiles) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let block = match data.get_stackblock_by_initiator_stack(user.id, id).await { + Ok(b) => b, + Err(e) => return Json(e.into()), + }; + + match data.delete_stackblock(block.id, user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index f7d542f..3a67aa7 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -126,7 +126,7 @@ pub fn routes() -> Router { .route("/developer/app/{id}", get(developer::app_request)) // stacks .route("/stacks", get(stacks::list_request)) - .route("/stacks/{id}", get(stacks::posts_request)) + .route("/stacks/{id}", get(stacks::feed_request)) .route("/stacks/{id}/manage", get(stacks::manage_request)) } diff --git a/crates/app/src/routes/pages/profile.rs b/crates/app/src/routes/pages/profile.rs index f559b3c..2713b26 100644 --- a/crates/app/src/routes/pages/profile.rs +++ b/crates/app/src/routes/pages/profile.rs @@ -80,6 +80,19 @@ pub async fn settings_request( Err(e) => return Err(Html(render_error(e, &jar, &data, &None).await)), }; + let stackblocks = { + let mut out = Vec::new(); + + for block in data.0.get_stackblocks_by_initiator(profile.id).await { + out.push(match data.0.get_stack_by_id(block.stack).await { + Ok(s) => s, + Err(_) => continue, + }); + } + + out + }; + let uploads = match data.0.get_uploads_by_owner(profile.id, 12, req.page).await { Ok(ua) => ua, Err(e) => { @@ -98,6 +111,7 @@ pub async fn settings_request( context.insert("stacks", &stacks); context.insert("following", &following); context.insert("blocks", &blocks); + context.insert("stackblocks", &stackblocks); context.insert( "user_tokens_serde", &serde_json::to_string(&tokens) @@ -128,59 +142,37 @@ pub async fn settings_request( ) .unwrap(); - let light = serde_json::Value::from("Light"); - let mut profile_theme = settings_map - .get("profile_theme") - .unwrap_or(&light) - .as_str() - .unwrap(); + if let Some(color_surface) = settings_map.get("theme_color_surface") { + let color_surface = color_surface.as_str().unwrap(); + for setting in &settings_map { + if !setting.0.starts_with("theme_color_text") + | (setting.0 == "theme_color_text_primary") + | (setting.0 == "theme_color_text_secondary") + { + continue; + } - if profile_theme.is_empty() | (profile_theme == "Auto") { - profile_theme = "Light"; - } + let value = setting.1.as_str().unwrap(); - let default_surface = serde_json::Value::from(if profile_theme == "Light" { - "#f3f2f1" + if !value.starts_with("#") { + // we can only parse hex right now + continue; + } + + let c1 = Color::from(color_surface); + let c2 = Color::from(value); + let contrast = c1.contrast(&c2); + + if contrast < MINIMUM_CONTRAST_THRESHOLD { + failing_color_keys.push((setting.0, contrast)); + } + } + + context.insert("failing_color_keys", &failing_color_keys); } else { - "#19171c" - }); - - let mut color_surface = settings_map - .get("theme_color_surface") - .unwrap_or(&default_surface) - .as_str() - .unwrap(); - - if color_surface.is_empty() { - color_surface = default_surface.as_str().unwrap(); + context.insert("failing_color_keys", &Vec::<&str>::new()); } - for setting in &settings_map { - if !setting.0.starts_with("theme_color_text") - | (setting.0 == "theme_color_text_primary") - | (setting.0 == "theme_color_text_secondary") - { - continue; - } - - let value = setting.1.as_str().unwrap(); - - if !value.starts_with("#") { - // we can only parse hex right now - continue; - } - - let c1 = Color::from(color_surface); - let c2 = Color::from(value); - let contrast = c1.contrast(&c2); - - if contrast < MINIMUM_CONTRAST_THRESHOLD { - failing_color_keys.push((setting.0, contrast)); - } - } - - context.insert("failing_color_keys", &failing_color_keys); - // return Ok(Html( data.1.render("profile/settings.html", &context).unwrap(), diff --git a/crates/app/src/routes/pages/stacks.rs b/crates/app/src/routes/pages/stacks.rs index 48bfdb1..656fee3 100644 --- a/crates/app/src/routes/pages/stacks.rs +++ b/crates/app/src/routes/pages/stacks.rs @@ -4,7 +4,12 @@ use axum::{ Extension, }; use axum_extra::extract::CookieJar; -use tetratto_core::model::{permissions::FinePermission, stacks::StackPrivacy, Error, auth::User}; +use tetratto_core::model::{ + auth::User, + permissions::FinePermission, + stacks::{StackMode, StackPrivacy}, + Error, +}; use crate::{assets::initial_context, get_lang, get_user_from_token, State}; use super::{render_error, PaginatedQuery}; @@ -35,7 +40,7 @@ pub async fn list_request(jar: CookieJar, Extension(data): Extension) -> } /// `/stacks/{id}` -pub async fn posts_request( +pub async fn feed_request( jar: CookieJar, Extension(data): Extension, Path(id): Path, @@ -65,32 +70,50 @@ pub async fn posts_request( )); } - let ignore_users = crate::ignore_users_gen!(user!, data); - let list = match data - .0 - .get_stack_posts( - user.id, - stack.id, - 12, - req.page, - &ignore_users, - &Some(user.clone()), - ) - .await - { - Ok(l) => l, - Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), - }; - let lang = get_lang!(jar, data.0); - let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; + let mut context = initial_context(&data.0.0.0, lang, &Some(user.clone())).await; context.insert("page", &req.page); context.insert("stack", &stack); - context.insert("list", &list); + + if stack.mode == StackMode::BlockList { + let list = match data.0.get_stack_users(stack.id, 12, req.page).await { + Ok(l) => l, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + context.insert("list", &list); + context.insert( + "is_blocked", + &data + .0 + .get_stackblock_by_initiator_stack(user.id, stack.id) + .await + .is_ok(), + ); + } else { + let ignore_users = crate::ignore_users_gen!(user!, data); + let list = match data + .0 + .get_stack_posts( + user.id, + stack.id, + 12, + req.page, + &ignore_users, + &Some(user.clone()), + ) + .await + { + Ok(l) => l, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + context.insert("list", &list); + } // return - Ok(Html(data.1.render("stacks/posts.html", &context).unwrap())) + Ok(Html(data.1.render("stacks/feed.html", &context).unwrap())) } /// `/stacks/{id}/manage` diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index cc62849..8536b88 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -35,6 +35,7 @@ impl DataManager { execute!(&conn, common::CREATE_TABLE_POLLS).unwrap(); execute!(&conn, common::CREATE_TABLE_POLLVOTES).unwrap(); execute!(&conn, common::CREATE_TABLE_APPS).unwrap(); + execute!(&conn, common::CREATE_TABLE_STACKBLOCKS).unwrap(); self.0 .1 @@ -363,7 +364,10 @@ macro_rules! auto_method { } else { self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new( user.id, - format!("invoked `{}` with x value `{x:?}`", stringify!($name)), + format!( + "invoked `{}` with x value `{id}` and y value `{x:?}`", + stringify!($name) + ), )) .await? } diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs index 9d8df41..94cc123 100644 --- a/crates/core/src/database/drivers/common.rs +++ b/crates/core/src/database/drivers/common.rs @@ -22,3 +22,4 @@ pub const CREATE_TABLE_DRAFTS: &str = include_str!("./sql/create_drafts.sql"); pub const CREATE_TABLE_POLLS: &str = include_str!("./sql/create_polls.sql"); pub const CREATE_TABLE_POLLVOTES: &str = include_str!("./sql/create_pollvotes.sql"); pub const CREATE_TABLE_APPS: &str = include_str!("./sql/create_apps.sql"); +pub const CREATE_TABLE_STACKBLOCKS: &str = include_str!("./sql/create_stackblocks.sql"); diff --git a/crates/core/src/database/drivers/sql/create_stackblocks.sql b/crates/core/src/database/drivers/sql/create_stackblocks.sql new file mode 100644 index 0000000..6e30beb --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_stackblocks.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS stackblocks ( + id BIGINT NOT NULL PRIMARY KEY, + created BIGINT NOT NULL, + initiator BIGINT NOT NULL, + stack BIGINT NOT NULL +) diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index c7290bf..458897f 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -18,6 +18,7 @@ mod questions; mod reactions; mod reports; mod requests; +mod stackblocks; mod stacks; mod uploads; mod user_warnings; diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index ac28744..7e06d67 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -1507,6 +1507,10 @@ impl DataManager { .get_userblock_by_initiator_receiver(rt.owner, data.owner) .await .is_ok() + | self + .get_user_stack_blocked_users(rt.owner) + .await + .contains(&data.owner) { return Err(Error::NotAllowed); } @@ -1552,6 +1556,10 @@ impl DataManager { .get_userblock_by_initiator_receiver(rt.owner, data.owner) .await .is_ok() + | self + .get_user_stack_blocked_users(rt.owner) + .await + .contains(&data.owner) { return Err(Error::NotAllowed); } @@ -1571,6 +1579,10 @@ impl DataManager { .get_userblock_by_initiator_receiver(user.id, data.owner) .await .is_ok() + | self + .get_user_stack_blocked_users(user.id) + .await + .contains(&data.owner) { return Err(Error::NotAllowed); } diff --git a/crates/core/src/database/reactions.rs b/crates/core/src/database/reactions.rs index 438f282..4dce267 100644 --- a/crates/core/src/database/reactions.rs +++ b/crates/core/src/database/reactions.rs @@ -145,10 +145,14 @@ impl DataManager { if data.asset_type == AssetType::Post { let post = self.get_post_by_id(data.asset).await?; - if self + if (self .get_userblock_by_initiator_receiver(post.owner, user.id) .await .is_ok() + | self + .get_user_stack_blocked_users(post.owner) + .await + .contains(&user.id)) && !user.permissions.check(FinePermission::MANAGE_POSTS) { return Err(Error::NotAllowed); @@ -156,10 +160,14 @@ impl DataManager { } else if data.asset_type == AssetType::Question { let question = self.get_question_by_id(data.asset).await?; - if self + if (self .get_userblock_by_initiator_receiver(question.owner, user.id) .await .is_ok() + | self + .get_user_stack_blocked_users(question.owner) + .await + .contains(&user.id)) && !user.permissions.check(FinePermission::MANAGE_POSTS) { return Err(Error::NotAllowed); diff --git a/crates/core/src/database/stackblocks.rs b/crates/core/src/database/stackblocks.rs new file mode 100644 index 0000000..0ae9e82 --- /dev/null +++ b/crates/core/src/database/stackblocks.rs @@ -0,0 +1,224 @@ +use oiseau::cache::Cache; +use crate::model::stacks::StackPrivacy; +use crate::model::{Error, Result, auth::User, stacks::StackBlock, permissions::FinePermission}; +use crate::{auto_method, DataManager}; + +#[cfg(feature = "sqlite")] +use oiseau::SqliteRow; + +#[cfg(feature = "postgres")] +use oiseau::PostgresRow; + +use oiseau::{execute, get, params, query_row, query_rows}; + +impl DataManager { + /// Get a [`StackBlock`] from an SQL row. + pub(crate) fn get_stackblock_from_row( + #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, + #[cfg(feature = "postgres")] x: &PostgresRow, + ) -> StackBlock { + StackBlock { + id: get!(x->0(i64)) as usize, + created: get!(x->1(i64)) as usize, + initiator: get!(x->2(i64)) as usize, + stack: get!(x->3(i64)) as usize, + } + } + + auto_method!(get_stackblock_by_id()@get_stackblock_from_row -> "SELECT * FROM stackblocks WHERE id = $1" --name="stack block" --returns=StackBlock --cache-key-tmpl="atto.stackblock:{}"); + + pub async fn get_user_stack_blocked_users(&self, user_id: usize) -> Vec { + let mut stack_block_users = Vec::new(); + + for block in self.get_stackblocks_by_initiator(user_id).await { + for user in match self.fill_stackblocks_receivers(block.stack).await { + Ok(ul) => ul, + Err(_) => continue, + } { + stack_block_users.push(user); + } + } + + stack_block_users + } + + /// Fill a vector of stack blocks with their receivers (by pulling the stack). + pub async fn fill_stackblocks_receivers(&self, stack: usize) -> Result> { + let stack = self.get_stack_by_id(stack).await?; + let mut out = Vec::new(); + + for block in stack.users { + out.push(block); + } + + Ok(out) + } + + /// Get all stack blocks created by the given `initiator`. + pub async fn get_stackblocks_by_initiator(&self, initiator: usize) -> Vec { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(_) => return Vec::new(), + }; + + let res = query_rows!( + &conn, + "SELECT * FROM stackblocks WHERE initiator = $1", + &[&(initiator as i64)], + |x| { Self::get_stackblock_from_row(x) } + ); + + if res.is_err() { + return Vec::new(); + } + + // make sure all stacks still exist + let list = res.unwrap(); + + for block in &list { + if self.get_stack_by_id(block.stack).await.is_err() { + if self.delete_stackblock_sudo(block.id).await.is_err() { + continue; + } + } + } + + // return + list + } + + /// Get a stack block by `initiator` and `stack` (in that order). + pub async fn get_stackblock_by_initiator_stack( + &self, + initiator: usize, + stack: usize, + ) -> Result { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_row!( + &conn, + "SELECT * FROM stackblocks WHERE initiator = $1 AND stack = $2", + &[&(initiator as i64), &(stack as i64)], + |x| { Ok(Self::get_stackblock_from_row(x)) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("stack block".to_string())); + } + + Ok(res.unwrap()) + } + + /// Create a new stack block in the database. + /// + /// # Arguments + /// * `data` - a mock [`StackBlock`] object to insert + pub async fn create_stackblock(&self, data: StackBlock) -> Result<()> { + let initiator = self.get_user_by_id(data.initiator).await?; + let stack = self.get_stack_by_id(data.stack).await?; + + if initiator.id != stack.owner + && stack.privacy == StackPrivacy::Private + && !initiator.permissions.check(FinePermission::MANAGE_STACKS) + { + return Err(Error::NotAllowed); + } + + // ... + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "INSERT INTO stackblocks VALUES ($1, $2, $3, $4)", + params![ + &(data.id as i64), + &(data.created as i64), + &(data.initiator as i64), + &(data.stack as i64) + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // unfollow/remove follower + for user in stack.users { + if let Ok(f) = self + .get_userfollow_by_initiator_receiver(data.initiator, user) + .await + { + self.delete_userfollow_sudo(f.id, data.initiator).await?; + } + + if let Ok(f) = self + .get_userfollow_by_receiver_initiator(data.initiator, user) + .await + { + self.delete_userfollow_sudo(f.id, data.initiator).await?; + } + } + + // return + Ok(()) + } + + pub async fn delete_stackblock(&self, id: usize, user: User) -> Result<()> { + let block = self.get_stackblock_by_id(id).await?; + + if user.id != block.initiator { + // only the initiator (or moderators) can delete stack blocks! + if !user.permissions.check(FinePermission::MANAGE_FOLLOWS) { + return Err(Error::NotAllowed); + } + } + + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "DELETE FROM stackblocks WHERE id = $1", + &[&(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.0.1.remove(format!("atto.stackblock:{}", id)).await; + + // return + Ok(()) + } + + pub async fn delete_stackblock_sudo(&self, id: usize) -> Result<()> { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "DELETE FROM stackblocks WHERE id = $1", + &[&(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.0.1.remove(format!("atto.stackblock:{}", id)).await; + + // return + Ok(()) + } +} diff --git a/crates/core/src/database/stacks.rs b/crates/core/src/database/stacks.rs index 2407807..e0789c7 100644 --- a/crates/core/src/database/stacks.rs +++ b/crates/core/src/database/stacks.rs @@ -91,9 +91,39 @@ impl DataManager { } } } + StackMode::BlockList => { + return Err(Error::MiscError( + "You should use `get_stack_users` for this type".to_string(), + )); + } }) } + pub async fn get_stack_users(&self, id: usize, batch: usize, page: usize) -> Result> { + let stack = self.get_stack_by_id(id).await?; + + if stack.mode != StackMode::BlockList { + return Err(Error::MiscError( + "You should use `get_stack_posts` for this type".to_string(), + )); + } + + // build list + let mut out = Vec::new(); + let mut i = 0; + + for user in stack.users.iter().skip(batch * page) { + if i == batch { + break; + } + + out.push(self.get_user_by_id(user.to_owned()).await?); + i += 1; + } + + Ok(out) + } + /// Get all stacks by user. /// /// # Arguments diff --git a/crates/core/src/database/userfollows.rs b/crates/core/src/database/userfollows.rs index 12046c5..4a4889b 100644 --- a/crates/core/src/database/userfollows.rs +++ b/crates/core/src/database/userfollows.rs @@ -346,4 +346,41 @@ impl DataManager { // return Ok(()) } + + pub async fn delete_userfollow_sudo(&self, id: usize, user_id: usize) -> Result<()> { + let follow = self.get_userfollow_by_id(id).await?; + + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "DELETE FROM userfollows WHERE id = $1", + &[&(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.0.1.remove(format!("atto.userfollow:{}", id)).await; + + // decr counts + if follow.initiator != user_id { + self.decr_user_following_count(follow.initiator) + .await + .unwrap(); + } + + if follow.receiver != user_id { + self.decr_user_follower_count(follow.receiver) + .await + .unwrap(); + } + + // return + Ok(()) + } } diff --git a/crates/core/src/model/oauth.rs b/crates/core/src/model/oauth.rs index 438f5fd..b2a3798 100644 --- a/crates/core/src/model/oauth.rs +++ b/crates/core/src/model/oauth.rs @@ -76,6 +76,8 @@ pub enum AppScope { UserCreateCommunities, /// Create stacks on behalf of the user. UserCreateStacks, + /// Create circles on behalf of the user. + UserCreateCircles, /// Delete posts owned by the user. UserDeletePosts, /// Delete messages owned by the user. @@ -106,6 +108,8 @@ pub enum AppScope { UserManageRequests, /// Manage the user's uploads. UserManageUploads, + /// Manage the user's circles (add/remove users or delete). + UserManageCircles, /// Edit posts created by the user. UserEditPosts, /// Edit drafts created by the user. @@ -142,68 +146,6 @@ pub enum AppScope { CommunityManageChannels, } -impl AppScope { - /// Parse the given input string as a list of scopes. - pub fn parse(input: &str) -> Vec { - let mut out: Vec = Vec::new(); - for scope in input.split(" ") { - out.push(match scope { - "user-read-profiles" => Self::UserReadProfiles, - "user-read-profile" => Self::UserReadProfile, - "user-read-settings" => Self::UserReadSettings, - "user-read-sessions" => Self::UserReadSessions, - "user-read-posts" => Self::UserReadPosts, - "user-read-messages" => Self::UserReadMessages, - "user-read-drafts" => Self::UserReadDrafts, - "user-read-communities" => Self::UserReadCommunities, - "user-read-sockets" => Self::UserReadSockets, - "user-read-notifications" => Self::UserReadNotifications, - "user-read-requests" => Self::UserReadRequests, - "user-read-questions" => Self::UserReadQuestions, - "user-create-posts" => Self::UserCreatePosts, - "user-create-messages" => Self::UserCreateMessages, - "user-create-questions" => Self::UserCreateQuestions, - "user-create-ip-blocks" => Self::UserCreateIpBlock, - "user-create-drafts" => Self::UserCreateDrafts, - "user-create-communities" => Self::UserCreateCommunities, - "user-delete-posts" => Self::UserDeletePosts, - "user-delete-messages" => Self::UserDeleteMessages, - "user-delete-questions" => Self::UserDeleteQuestions, - "user-delete-drafts" => Self::UserDeleteDrafts, - "user-manage-profile" => Self::UserManageProfile, - "user-manage-stacks" => Self::UserManageStacks, - "user-manage-relationships" => Self::UserManageRelationships, - "user-manage-memberships" => Self::UserManageMemberships, - "user-manage-following" => Self::UserManageFollowing, - "user-manage-followers" => Self::UserManageFollowers, - "user-manage-blocks" => Self::UserManageBlocks, - "user-manage-notifications" => Self::UserManageNotifications, - "user-manage-requests" => Self::UserManageRequests, - "user-manage-uploads" => Self::UserManageUploads, - "user-edit-posts" => Self::UserEditPosts, - "user-edit-drafts" => Self::UserEditDrafts, - "user-vote" => Self::UserVote, - "user-react" => Self::UserReact, - "user-join-communities" => Self::UserJoinCommunities, - "mod-purge-posts" => Self::ModPurgePosts, - "mod-delete-posts" => Self::ModDeletePosts, - "mod-manage-warnings" => Self::ModManageWarnings, - "user-read-emojis" => Self::UserReadEmojis, - "community-create-emojis" => Self::CommunityCreateEmojis, - "community-manage-emojis" => Self::CommunityManageEmojis, - "community-delete" => Self::CommunityDelete, - "community-manage" => Self::CommunityManage, - "community-transfer-ownership" => Self::CommunityTransferOwnership, - "community-read-memberships" => Self::CommunityReadMemberships, - "community-create-channels" => Self::CommunityCreateChannels, - "community-manage-channels" => Self::CommunityManageChannels, - _ => continue, - }) - } - out - } -} - impl AuthGrant { /// Check a verifier against the stored challenge (using the given [`PkceChallengeMethod`]). pub fn check_verifier(&self, verifier: &str) -> Result<()> { diff --git a/crates/core/src/model/stacks.rs b/crates/core/src/model/stacks.rs index 809a0e0..a2e7487 100644 --- a/crates/core/src/model/stacks.rs +++ b/crates/core/src/model/stacks.rs @@ -23,6 +23,11 @@ pub enum StackMode { /// `users` vec contains ID of users to EXCLUDE from the timeline; /// every other user is included Exclude, + /// `users` vec contains ID of users to show in a user listing on the stack's + /// page (instead of a timeline). + /// + /// Other users can block the entire list (creating a `StackBlock`, not a `UserBlock`). + BlockList, } impl Default for StackMode { @@ -70,3 +75,23 @@ impl UserStack { } } } + +#[derive(Serialize, Deserialize)] +pub struct StackBlock { + pub id: usize, + pub created: usize, + pub initiator: usize, + pub stack: usize, +} + +impl StackBlock { + /// Create a new [`StackBlock`]. + pub fn new(initiator: usize, stack: usize) -> Self { + Self { + id: Snowflake::new().to_string().parse::().unwrap(), + created: unix_epoch_timestamp(), + initiator, + stack, + } + } +} From 0310418837a59f9f599869cea52228bdee712ba7 Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 15 Jun 2025 11:58:07 -0400 Subject: [PATCH 02/75] add: stack block limits --- .../app/src/public/html/profile/settings.lisp | 4 +++- crates/core/src/database/stackblocks.rs | 22 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index d317d95..33f6117 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -579,7 +579,9 @@ (li (text "Ability to create forges")) (li - (text "Ability to create more than 1 app"))) + (text "Ability to create more than 1 app")) + (li + (text "Create up to 10 stack blocks"))) (a ("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}") ("class" "button") diff --git a/crates/core/src/database/stackblocks.rs b/crates/core/src/database/stackblocks.rs index 0ae9e82..8d2d633 100644 --- a/crates/core/src/database/stackblocks.rs +++ b/crates/core/src/database/stackblocks.rs @@ -112,12 +112,34 @@ impl DataManager { Ok(res.unwrap()) } + const MAXIMUM_FREE_STACKBLOCKS: usize = 5; + const MAXIMUM_SUPPORTER_STACKBLOCKS: usize = 10; + /// Create a new stack block in the database. /// /// # Arguments /// * `data` - a mock [`StackBlock`] object to insert pub async fn create_stackblock(&self, data: StackBlock) -> Result<()> { let initiator = self.get_user_by_id(data.initiator).await?; + + // check number of stackblocks + let stackblocks = self.get_stackblocks_by_initiator(data.initiator).await; + + if !initiator.permissions.check(FinePermission::SUPPORTER) { + if stackblocks.len() >= Self::MAXIMUM_FREE_STACKBLOCKS { + return Err(Error::MiscError( + "You already have the maximum number of stack blocks you can have".to_string(), + )); + } + } else { + if stackblocks.len() >= Self::MAXIMUM_SUPPORTER_STACKBLOCKS { + return Err(Error::MiscError( + "You already have the maximum number of stack blocks you can have".to_string(), + )); + } + } + + // ... let stack = self.get_stack_by_id(data.stack).await?; if initiator.id != stack.owner From 50704d27a94d9e177bff6269241e3a94297f426a Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 15 Jun 2025 12:19:58 -0400 Subject: [PATCH 03/75] chore: remove features that don't compile add: posts "circle" column --- Cargo.lock | 61 +------------------ README.md | 4 +- crates/app/Cargo.toml | 10 +-- crates/app/src/assets.rs | 3 +- crates/app/src/public/html/macros.lisp | 4 +- crates/app/src/public/html/stacks/feed.lisp | 4 +- crates/app/src/routes/api/v1/auth/profile.rs | 4 -- crates/app/src/routes/api/v1/mod.rs | 4 +- crates/app/src/routes/pages/mod.rs | 4 +- crates/core/Cargo.toml | 8 +-- crates/core/src/database/apps.rs | 9 +-- crates/core/src/database/audit_log.rs | 9 +-- crates/core/src/database/auth.rs | 9 +-- crates/core/src/database/channels.rs | 13 +--- crates/core/src/database/communities.rs | 20 +----- crates/core/src/database/drafts.rs | 9 +-- crates/core/src/database/drivers/mod.rs | 6 -- .../src/database/drivers/sql/create_posts.sql | 3 +- crates/core/src/database/emojis.rs | 13 +--- crates/core/src/database/ipbans.rs | 13 +--- crates/core/src/database/ipblocks.rs | 9 +-- crates/core/src/database/memberships.rs | 13 +--- crates/core/src/database/messages.rs | 14 +---- crates/core/src/database/mod.rs | 7 +-- crates/core/src/database/notifications.rs | 14 +---- crates/core/src/database/polls.rs | 9 +-- crates/core/src/database/pollvotes.rs | 9 +-- crates/core/src/database/posts.rs | 19 ++---- crates/core/src/database/questions.rs | 9 +-- crates/core/src/database/reactions.rs | 13 +--- crates/core/src/database/reports.rs | 9 +-- crates/core/src/database/requests.rs | 13 +--- crates/core/src/database/stackblocks.rs | 9 +-- crates/core/src/database/stacks.rs | 9 +-- crates/core/src/database/uploads.rs | 9 +-- crates/core/src/database/user_warnings.rs | 13 +--- crates/core/src/database/userblocks.rs | 13 +--- crates/core/src/database/userfollows.rs | 13 +--- crates/core/src/lib.rs | 7 --- crates/core/src/model/communities.rs | 5 ++ crates/core/src/model/mod.rs | 8 +-- sql_changes/posts_circle.sql | 2 + 42 files changed, 71 insertions(+), 365 deletions(-) create mode 100644 sql_changes/posts_circle.sql diff --git a/Cargo.lock b/Cargo.lock index 1126bca..fd8c94a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -787,18 +787,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" -[[package]] -name = "fallible-iterator" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" - -[[package]] -name = "fallible-streaming-iterator" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" - [[package]] name = "fastrand" version = "1.9.0" @@ -839,12 +827,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "foreign-types" version = "0.3.2" @@ -1112,18 +1094,6 @@ name = "hashbrown" version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" -dependencies = [ - "foldhash", -] - -[[package]] -name = "hashlink" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" -dependencies = [ - "hashbrown", -] [[package]] name = "heck" @@ -1697,16 +1667,6 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa" -[[package]] -name = "libsqlite3-sys" -version = "0.34.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91632f3b4fb6bd1d72aa3d78f41ffecfcf2b1a6648d8c241dbe7dbfaf4875e15" -dependencies = [ - "pkg-config", - "vcpkg", -] - [[package]] name = "libwebp-sys" version = "0.9.6" @@ -2033,7 +1993,6 @@ checksum = "99b097052e28781d560587373845626a85460969a55d180fc418aecd58f6fef3" dependencies = [ "bb8-postgres", "redis", - "rusqlite", "serde", "tokio-postgres", ] @@ -2290,7 +2249,7 @@ dependencies = [ "base64 0.22.1", "byteorder", "bytes", - "fallible-iterator 0.2.0", + "fallible-iterator", "hmac", "md-5", "memchr", @@ -2306,7 +2265,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613283563cd90e1dfc3518d548caee47e0e725455ed619881f5cf21f36de4b48" dependencies = [ "bytes", - "fallible-iterator 0.2.0", + "fallible-iterator", "postgres-protocol", ] @@ -2725,20 +2684,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rusqlite" -version = "0.36.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3de23c3319433716cf134eed225fe9986bc24f63bed9be9f20c329029e672dc7" -dependencies = [ - "bitflags 2.9.1", - "fallible-iterator 0.3.0", - "fallible-streaming-iterator", - "hashlink", - "libsqlite3-sys", - "smallvec", -] - [[package]] name = "rustc-demangle" version = "0.1.24" @@ -3527,7 +3472,7 @@ dependencies = [ "async-trait", "byteorder", "bytes", - "fallible-iterator 0.2.0", + "fallible-iterator", "futures-channel", "futures-util", "log", diff --git a/README.md b/README.md index 77155e8..87e0a38 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,10 @@ Make sure you have AT LEAST rustc version 1.89.0-nightly. Everything Tetratto needs will be built into the main binary. You can build Tetratto with the following command: ```bash -cargo build -r --no-default-features --features=redis,sqlite +cargo build ``` -You can replace `sqlite` in the above command with `postgres`, if you'd like. Redis (or a Redis fork) is required for features such as chats and (realtime) notifications! +Tetratto **requires** a PostgreSQL server, as well as a Redis (or Redis fork) instance. You can then take the binary and place it somewhere else (highly recommended; the binary will create a fair number of files!). You can do this to move it to a directory just called "tetratto" in the parent directory: diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index a575e9d..333bff5 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -3,12 +3,6 @@ name = "tetratto" version = "7.0.0" edition = "2024" -[features] -postgres = ["tetratto-core/postgres"] -sqlite = ["tetratto-core/sqlite"] -redis = ["tetratto-core/redis"] -default = ["sqlite", "redis"] - [dependencies] pathbufd = "0.1.4" serde = { version = "1.0.219", features = ["derive"] } @@ -21,9 +15,7 @@ tokio = { version = "1.45.1", features = ["macros", "rt-multi-thread"] } axum-extra = { version = "0.10.1", features = ["cookie", "multipart"] } ammonia = "4.1.0" tetratto-shared = { path = "../shared" } -tetratto-core = { path = "../core", features = [ - "redis", -], default-features = false } +tetratto-core = { path = "../core" } tetratto-l10n = { path = "../l10n" } image = "0.25.6" diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index 877161c..03e8db3 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -17,7 +17,6 @@ use tetratto_core::{ auth::{DefaultTimelineChoice, User}, permissions::FinePermission, }, - PUBSUB_ENABLED, }; use tetratto_l10n::LangFile; use tetratto_shared::hash::salt; @@ -468,7 +467,7 @@ pub(crate) async fn initial_context( ) -> Context { let mut ctx = Context::new(); ctx.insert("config", &config); - ctx.insert("pubsub", &PUBSUB_ENABLED); + ctx.insert("pubsub", &true); ctx.insert("user", &user); ctx.insert("use_user_theme", &true); diff --git a/crates/app/src/public/html/macros.lisp b/crates/app/src/public/html/macros.lisp index f544f5e..80ed3ba 100644 --- a/crates/app/src/public/html/macros.lisp +++ b/crates/app/src/public/html/macros.lisp @@ -144,7 +144,9 @@ (text "{{ macros::timelines_nav_options(selected=selected) }}") ; secondary nav desktop only - (text "{{ macros::timelines_secondary_nav(posts=posts, questions=questions, selected=secondary_selected) }}")) + (text "{% if posts and questions -%}") + (text "{{ macros::timelines_secondary_nav(posts=posts, questions=questions, selected=secondary_selected) }}") + (text "{%- endif %}")) (text "{%- endmacro %}") (text "{% macro timelines_nav_options(selected=\"\") -%}") diff --git a/crates/app/src/public/html/stacks/feed.lisp b/crates/app/src/public/html/stacks/feed.lisp index 6107546..a723066 100644 --- a/crates/app/src/public/html/stacks/feed.lisp +++ b/crates/app/src/public/html/stacks/feed.lisp @@ -12,7 +12,9 @@ ("class" "card small flex items-center justify-between gap-2") (div ("class" "flex items-center gap-2") - (text "{{ icon \"list\" }}") + (a + ("href" "/api/v1/auth/user/find/{{ stack.owner }}") + (text "{{ components::avatar(username=stack.owner, selector_type=\"id\") }}")) (span (text "{{ stack.name }}"))) (text "{% if user and user.id == stack.owner -%}") diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs index c98a665..05eefb4 100644 --- a/crates/app/src/routes/api/v1/auth/profile.rs +++ b/crates/app/src/routes/api/v1/auth/profile.rs @@ -28,8 +28,6 @@ use tetratto_core::{ }, DataManager, }; - -#[cfg(feature = "redis")] use tetratto_core::cache::redis::Commands; use tetratto_shared::{ hash::{self, random_id}, @@ -534,7 +532,6 @@ pub async fn has_totp_enabled_request( } /// Handle a subscription to the websocket. -#[cfg(feature = "redis")] pub async fn subscription_handler( jar: CookieJar, ws: WebSocketUpgrade, @@ -557,7 +554,6 @@ pub async fn subscription_handler( })) } -#[cfg(feature = "redis")] pub async fn handle_socket(socket: WebSocket, db: DataManager, user_id: String, stream_id: String) { let (mut sink, mut stream) = socket.split(); let socket_id = tetratto_shared::hash::salt(); diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 4b81eb5..5c54aa9 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -1,5 +1,6 @@ pub mod apps; pub mod auth; +pub mod channels; pub mod communities; pub mod notifications; pub mod reactions; @@ -9,9 +10,6 @@ pub mod stacks; pub mod uploads; pub mod util; -#[cfg(feature = "redis")] -pub mod channels; - use axum::{ routing::{any, delete, get, post, put}, Router, diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index 3a67aa7..556b468 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -1,4 +1,5 @@ pub mod auth; +pub mod chats; pub mod communities; pub mod developer; pub mod forge; @@ -7,9 +8,6 @@ pub mod mod_panel; pub mod profile; pub mod stacks; -#[cfg(feature = "redis")] -pub mod chats; - use axum::{ routing::{get, post}, Router, diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 01e09c0..87988b0 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -3,12 +3,6 @@ name = "tetratto-core" version = "7.0.0" edition = "2024" -[features] -postgres = ["oiseau/postgres"] -sqlite = ["oiseau/sqlite"] -redis = ["oiseau/redis"] -default = ["sqlite", "redis"] - [dependencies] pathbufd = "0.1.4" serde = { version = "1.0.219", features = ["derive"] } @@ -25,4 +19,4 @@ base16ct = { version = "0.2.0", features = ["alloc"] } base64 = "0.22.1" emojis = "0.6.4" regex = "1.11.1" -oiseau = { version = "0.1.2", default-features = false } +oiseau = { version = "0.1.2", default-features = false, features = ["postgres", "redis"] } diff --git a/crates/core/src/database/apps.rs b/crates/core/src/database/apps.rs index 9e5e56d..9faf6d4 100644 --- a/crates/core/src/database/apps.rs +++ b/crates/core/src/database/apps.rs @@ -8,20 +8,13 @@ use crate::model::{ }; use crate::{auto_method, DataManager}; -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] use oiseau::PostgresRow; use oiseau::{execute, get, query_rows, params}; impl DataManager { /// Get a [`ThirdPartyApp`] from an SQL row. - pub(crate) fn get_app_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> ThirdPartyApp { + pub(crate) fn get_app_from_row(x: &PostgresRow) -> ThirdPartyApp { ThirdPartyApp { id: get!(x->0(i64)) as usize, created: get!(x->1(i64)) as usize, diff --git a/crates/core/src/database/audit_log.rs b/crates/core/src/database/audit_log.rs index f6f46de..d0c3147 100644 --- a/crates/core/src/database/audit_log.rs +++ b/crates/core/src/database/audit_log.rs @@ -2,20 +2,13 @@ use oiseau::cache::Cache; use crate::model::{Error, Result, auth::User, moderation::AuditLogEntry, permissions::FinePermission}; use crate::{auto_method, DataManager}; -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] use oiseau::PostgresRow; use oiseau::{execute, get, query_rows, params}; impl DataManager { /// Get an [`AuditLogEntry`] from an SQL row. - pub(crate) fn get_audit_log_entry_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> AuditLogEntry { + pub(crate) fn get_audit_log_entry_from_row(x: &PostgresRow) -> AuditLogEntry { AuditLogEntry { id: get!(x->0(i64)) as usize, created: get!(x->1(i64)) as usize, diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index 39626f3..ba07543 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -16,20 +16,13 @@ use tetratto_shared::{ }; use crate::{auto_method, DataManager}; -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] use oiseau::PostgresRow; use oiseau::{execute, get, query_row, params}; impl DataManager { /// Get a [`User`] from an SQL row. - pub(crate) fn get_user_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> User { + pub(crate) fn get_user_from_row(x: &PostgresRow) -> User { User { id: get!(x->0(i64)) as usize, created: get!(x->1(i64)) as usize, diff --git a/crates/core/src/database/channels.rs b/crates/core/src/database/channels.rs index d258fc6..ed28323 100644 --- a/crates/core/src/database/channels.rs +++ b/crates/core/src/database/channels.rs @@ -6,20 +6,11 @@ use crate::model::{ }; use crate::{auto_method, DataManager}; -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] -use oiseau::PostgresRow; - -use oiseau::{execute, get, query_row, query_rows, params}; +use oiseau::{PostgresRow, execute, get, query_row, query_rows, params}; impl DataManager { /// Get a [`Channel`] from an SQL row. - pub(crate) fn get_channel_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> Channel { + pub(crate) fn get_channel_from_row(x: &PostgresRow) -> Channel { Channel { id: get!(x->0(i64)) as usize, community: get!(x->1(i64)) as usize, diff --git a/crates/core/src/database/communities.rs b/crates/core/src/database/communities.rs index 52362f5..2642f37 100644 --- a/crates/core/src/database/communities.rs +++ b/crates/core/src/database/communities.rs @@ -14,20 +14,11 @@ use pathbufd::PathBufD; use std::fs::{exists, remove_file}; use crate::{auto_method, DataManager}; -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] -use oiseau::PostgresRow; - -use oiseau::{execute, get, query_row, query_rows, params}; +use oiseau::{PostgresRow, execute, get, query_row, query_rows, params}; impl DataManager { /// Get a [`Community`] from an SQL row. - pub(crate) fn get_community_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> Community { + pub(crate) fn get_community_from_row(x: &PostgresRow) -> Community { Community { id: get!(x->0(i64)) as usize, created: get!(x->1(i64)) as usize, @@ -139,15 +130,10 @@ impl DataManager { Err(e) => return Err(Error::DatabaseConnection(e.to_string())), }; - #[cfg(feature = "sqlite")] - let empty = []; - #[cfg(feature = "postgres")] - let empty = &[]; - let res = query_rows!( &conn, "SELECT * FROM communities WHERE NOT context LIKE '%\"is_nsfw\":true%' ORDER BY member_count DESC LIMIT 12", - empty, + params![], |x| { Self::get_community_from_row(x) } ); diff --git a/crates/core/src/database/drafts.rs b/crates/core/src/database/drafts.rs index 53a7abc..a573bcc 100644 --- a/crates/core/src/database/drafts.rs +++ b/crates/core/src/database/drafts.rs @@ -3,20 +3,13 @@ use crate::model::moderation::AuditLogEntry; use crate::model::{Error, Result, auth::User, communities::PostDraft, permissions::FinePermission}; use crate::{auto_method, DataManager}; -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] use oiseau::PostgresRow; use oiseau::{execute, get, query_rows, params}; impl DataManager { /// Get a [`PostDraft`] from an SQL row. - pub(crate) fn get_draft_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> PostDraft { + pub(crate) fn get_draft_from_row(x: &PostgresRow) -> PostDraft { PostDraft { id: get!(x->0(i64)) as usize, created: get!(x->1(i64)) as usize, diff --git a/crates/core/src/database/drivers/mod.rs b/crates/core/src/database/drivers/mod.rs index 57d4cb7..60e7435 100644 --- a/crates/core/src/database/drivers/mod.rs +++ b/crates/core/src/database/drivers/mod.rs @@ -2,13 +2,7 @@ pub mod common; use std::collections::HashMap; use tetratto_l10n::{read_langs, LangFile}; - -#[cfg(feature = "sqlite")] -use oiseau::sqlite::{DataManager as OiseauManager, Result}; - -#[cfg(feature = "postgres")] use oiseau::postgres::{DataManager as OiseauManager, Result}; - use crate::config::Config; #[derive(Clone)] diff --git a/crates/core/src/database/drivers/sql/create_posts.sql b/crates/core/src/database/drivers/sql/create_posts.sql index 58c3eea..b200901 100644 --- a/crates/core/src/database/drivers/sql/create_posts.sql +++ b/crates/core/src/database/drivers/sql/create_posts.sql @@ -17,5 +17,6 @@ CREATE TABLE IF NOT EXISTS posts ( tsvector_content tsvector GENERATED ALWAYS AS (to_tsvector ('english', coalesce(content, ''))) STORED, poll_id BIGINT NOT NULL, title TEXT NOT NULL, - is_open INT NOT NULL DEFAULT 1 + is_open INT NOT NULL DEFAULT 1, + circle BIGINT NOT NULL ) diff --git a/crates/core/src/database/emojis.rs b/crates/core/src/database/emojis.rs index 0349d01..c61fed6 100644 --- a/crates/core/src/database/emojis.rs +++ b/crates/core/src/database/emojis.rs @@ -7,20 +7,11 @@ use crate::model::{ }; use crate::{auto_method, DataManager}; -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] -use oiseau::PostgresRow; - -use oiseau::{execute, get, query_row, query_rows, params}; +use oiseau::{PostgresRow, execute, get, query_row, query_rows, params}; impl DataManager { /// Get a [`CustomEmoji`] from an SQL row. - pub(crate) fn get_emoji_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> CustomEmoji { + pub(crate) fn get_emoji_from_row(x: &PostgresRow) -> CustomEmoji { CustomEmoji { id: get!(x->0(i64)) as usize, owner: get!(x->1(i64)) as usize, diff --git a/crates/core/src/database/ipbans.rs b/crates/core/src/database/ipbans.rs index 4c9e09d..550570d 100644 --- a/crates/core/src/database/ipbans.rs +++ b/crates/core/src/database/ipbans.rs @@ -4,20 +4,11 @@ use crate::model::moderation::AuditLogEntry; use crate::model::{Error, Result, auth::IpBan, auth::User, permissions::FinePermission}; use crate::{auto_method, DataManager}; -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] -use oiseau::PostgresRow; - -use oiseau::{execute, get, query_row, query_rows, params}; +use oiseau::{PostgresRow, execute, get, query_row, query_rows, params}; impl DataManager { /// Get a [`IpBan`] from an SQL row. - pub(crate) fn get_ipban_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> IpBan { + pub(crate) fn get_ipban_from_row(x: &PostgresRow) -> IpBan { IpBan { ip: get!(x->0(String)), created: get!(x->1(i64)) as usize, diff --git a/crates/core/src/database/ipblocks.rs b/crates/core/src/database/ipblocks.rs index 8c2a8f2..f94ed51 100644 --- a/crates/core/src/database/ipblocks.rs +++ b/crates/core/src/database/ipblocks.rs @@ -2,20 +2,13 @@ use oiseau::cache::Cache; use crate::model::{Error, Result, auth::User, auth::IpBlock, permissions::FinePermission}; use crate::{auto_method, DataManager}; -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] use oiseau::PostgresRow; use oiseau::{execute, get, query_row, params}; impl DataManager { /// Get an [`IpBlock`] from an SQL row. - pub(crate) fn get_ipblock_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> IpBlock { + pub(crate) fn get_ipblock_from_row(x: &PostgresRow) -> IpBlock { IpBlock { id: get!(x->0(i64)) as usize, created: get!(x->1(i64)) as usize, diff --git a/crates/core/src/database/memberships.rs b/crates/core/src/database/memberships.rs index 76fbe2b..4ae7094 100644 --- a/crates/core/src/database/memberships.rs +++ b/crates/core/src/database/memberships.rs @@ -10,20 +10,11 @@ use crate::model::{ }; use crate::{auto_method, DataManager}; -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] -use oiseau::PostgresRow; - -use oiseau::{execute, get, query_row, query_rows, params}; +use oiseau::{PostgresRow, execute, get, query_row, query_rows, params}; impl DataManager { /// Get a [`CommunityMembership`] from an SQL row. - pub(crate) fn get_membership_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> CommunityMembership { + pub(crate) fn get_membership_from_row(x: &PostgresRow) -> CommunityMembership { CommunityMembership { id: get!(x->0(i64)) as usize, created: get!(x->1(i64)) as usize, diff --git a/crates/core/src/database/messages.rs b/crates/core/src/database/messages.rs index 17ec914..f5c7024 100644 --- a/crates/core/src/database/messages.rs +++ b/crates/core/src/database/messages.rs @@ -11,14 +11,7 @@ use serde::Serialize; use tetratto_shared::unix_epoch_timestamp; use crate::{auto_method, DataManager}; -#[cfg(feature = "redis")] -use oiseau::cache::redis::Commands; - -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] -use oiseau::PostgresRow; +use oiseau::{PostgresRow, cache::redis::Commands}; use oiseau::{execute, get, query_rows, params}; @@ -29,10 +22,7 @@ struct DeleteMessageEvent { impl DataManager { /// Get a [`Message`] from an SQL row. - pub(crate) fn get_message_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> Message { + pub(crate) fn get_message_from_row(x: &PostgresRow) -> Message { Message { id: get!(x->0(i64)) as usize, channel: get!(x->1(i64)) as usize, diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index 458897f..b26afbf 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -1,6 +1,7 @@ mod apps; mod audit_log; mod auth; +mod channels; mod common; mod communities; pub mod connections; @@ -10,6 +11,7 @@ mod emojis; mod ipbans; mod ipblocks; mod memberships; +mod messages; mod notifications; mod polls; mod pollvotes; @@ -25,9 +27,4 @@ mod user_warnings; mod userblocks; mod userfollows; -#[cfg(feature = "redis")] -mod channels; -#[cfg(feature = "redis")] -mod messages; - pub use drivers::DataManager; diff --git a/crates/core/src/database/notifications.rs b/crates/core/src/database/notifications.rs index 60659db..620434b 100644 --- a/crates/core/src/database/notifications.rs +++ b/crates/core/src/database/notifications.rs @@ -3,23 +3,13 @@ use crate::model::socket::{CrudMessageType, PacketType, SocketMessage, SocketMet use crate::model::{Error, Result, auth::Notification, auth::User, permissions::FinePermission}; use crate::{auto_method, DataManager}; -#[cfg(feature = "redis")] -use oiseau::cache::redis::Commands; - -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] -use oiseau::PostgresRow; +use oiseau::{PostgresRow, cache::redis::Commands}; use oiseau::{execute, get, query_rows, params}; impl DataManager { /// Get a [`Notification`] from an SQL row. - pub(crate) fn get_notification_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> Notification { + pub(crate) fn get_notification_from_row(x: &PostgresRow) -> Notification { Notification { id: get!(x->0(i64)) as usize, created: get!(x->1(i64)) as usize, diff --git a/crates/core/src/database/polls.rs b/crates/core/src/database/polls.rs index c683199..72046a4 100644 --- a/crates/core/src/database/polls.rs +++ b/crates/core/src/database/polls.rs @@ -4,20 +4,13 @@ use crate::model::moderation::AuditLogEntry; use crate::model::{Error, Result, auth::User, permissions::FinePermission}; use crate::{auto_method, DataManager}; -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] use oiseau::PostgresRow; use oiseau::{execute, get, query_rows, params}; impl DataManager { /// Get a [`Poll`] from an SQL row. - pub(crate) fn get_poll_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> Poll { + pub(crate) fn get_poll_from_row(x: &PostgresRow) -> Poll { Poll { id: get!(x->0(i64)) as usize, owner: get!(x->1(i64)) as usize, diff --git a/crates/core/src/database/pollvotes.rs b/crates/core/src/database/pollvotes.rs index f354b32..58d8d5f 100644 --- a/crates/core/src/database/pollvotes.rs +++ b/crates/core/src/database/pollvotes.rs @@ -5,20 +5,13 @@ use crate::model::moderation::AuditLogEntry; use crate::model::{Error, Result, auth::User, permissions::FinePermission}; use crate::{auto_method, DataManager}; -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] use oiseau::PostgresRow; use oiseau::{execute, get, query_row, params}; impl DataManager { /// Get a [`PollVote`] from an SQL row. - pub(crate) fn get_pollvote_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> PollVote { + pub(crate) fn get_pollvote_from_row(x: &PostgresRow) -> PollVote { PollVote { id: get!(x->0(i64)) as usize, owner: get!(x->1(i64)) as usize, diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index 7e06d67..1853e98 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -15,15 +15,7 @@ use crate::model::{ use tetratto_shared::unix_epoch_timestamp; use crate::{auto_method, DataManager}; -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] -use oiseau::PostgresRow; - -#[cfg(feature = "redis")] -use oiseau::cache::redis::Commands; - +use oiseau::{PostgresRow, cache::redis::Commands}; use oiseau::{execute, get, query_row, query_rows, params}; pub type FullPost = ( @@ -101,10 +93,7 @@ macro_rules! private_post_replying { impl DataManager { /// Get a [`Post`] from an SQL row. - pub(crate) fn get_post_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> Post { + pub(crate) fn get_post_from_row(x: &PostgresRow) -> Post { Post { id: get!(x->0(i64)) as usize, created: get!(x->1(i64)) as usize, @@ -125,6 +114,7 @@ impl DataManager { poll_id: get!(x->13(i64)) as usize, title: get!(x->14(String)), is_open: get!(x->15(i32)) as i8 == 1, + circle: get!(x->16(i64)) as usize, } } @@ -1620,7 +1610,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO posts VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, DEFAULT, $13, $14, $15)", + "INSERT INTO posts VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, DEFAULT, $13, $14, $15, $16)", params![ &(data.id as i64), &(data.created as i64), @@ -1641,6 +1631,7 @@ impl DataManager { &(data.poll_id as i64), &data.title, &{ if data.is_open { 1 } else { 0 } }, + &(data.circle as i64), ] ); diff --git a/crates/core/src/database/questions.rs b/crates/core/src/database/questions.rs index f76efc6..d88fb44 100644 --- a/crates/core/src/database/questions.rs +++ b/crates/core/src/database/questions.rs @@ -11,20 +11,13 @@ use crate::model::{ }; use crate::{auto_method, DataManager}; -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] use oiseau::PostgresRow; use oiseau::{execute, get, query_rows, params}; impl DataManager { /// Get a [`Question`] from an SQL row. - pub(crate) fn get_question_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> Question { + pub(crate) fn get_question_from_row(x: &PostgresRow) -> Question { Question { id: get!(x->0(i64)) as usize, created: get!(x->1(i64)) as usize, diff --git a/crates/core/src/database/reactions.rs b/crates/core/src/database/reactions.rs index 4dce267..72ef2bc 100644 --- a/crates/core/src/database/reactions.rs +++ b/crates/core/src/database/reactions.rs @@ -7,20 +7,11 @@ use crate::model::{ }; use crate::{auto_method, DataManager}; -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] -use oiseau::PostgresRow; - -use oiseau::{execute, get, query_row, query_rows, params}; +use oiseau::{PostgresRow, execute, get, query_row, query_rows, params}; impl DataManager { /// Get a [`Reaction`] from an SQL row. - pub(crate) fn get_reaction_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> Reaction { + pub(crate) fn get_reaction_from_row(x: &PostgresRow) -> Reaction { Reaction { id: get!(x->0(i64)) as usize, created: get!(x->1(i64)) as usize, diff --git a/crates/core/src/database/reports.rs b/crates/core/src/database/reports.rs index d2cd474..f29fdc8 100644 --- a/crates/core/src/database/reports.rs +++ b/crates/core/src/database/reports.rs @@ -3,20 +3,13 @@ use crate::model::moderation::AuditLogEntry; use crate::model::{Error, Result, auth::User, moderation::Report, permissions::FinePermission}; use crate::{auto_method, DataManager}; -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] use oiseau::PostgresRow; use oiseau::{execute, get, query_rows, params}; impl DataManager { /// Get a [`Report`] from an SQL row. - pub(crate) fn get_report_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> Report { + pub(crate) fn get_report_from_row(x: &PostgresRow) -> Report { Report { id: get!(x->0(i64)) as usize, created: get!(x->1(i64)) as usize, diff --git a/crates/core/src/database/requests.rs b/crates/core/src/database/requests.rs index c8d2383..5a82062 100644 --- a/crates/core/src/database/requests.rs +++ b/crates/core/src/database/requests.rs @@ -3,20 +3,11 @@ use crate::model::requests::ActionType; use crate::model::{Error, Result, requests::ActionRequest, auth::User, permissions::FinePermission}; use crate::DataManager; -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] -use oiseau::PostgresRow; - -use oiseau::{execute, get, query_row, query_rows, params}; +use oiseau::{PostgresRow, execute, get, query_row, query_rows, params}; impl DataManager { /// Get an [`ActionRequest`] from an SQL row. - pub(crate) fn get_request_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> ActionRequest { + pub(crate) fn get_request_from_row(x: &PostgresRow) -> ActionRequest { ActionRequest { id: get!(x->0(i64)) as usize, created: get!(x->1(i64)) as usize, diff --git a/crates/core/src/database/stackblocks.rs b/crates/core/src/database/stackblocks.rs index 8d2d633..4e3c779 100644 --- a/crates/core/src/database/stackblocks.rs +++ b/crates/core/src/database/stackblocks.rs @@ -3,20 +3,13 @@ use crate::model::stacks::StackPrivacy; use crate::model::{Error, Result, auth::User, stacks::StackBlock, permissions::FinePermission}; use crate::{auto_method, DataManager}; -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] use oiseau::PostgresRow; use oiseau::{execute, get, params, query_row, query_rows}; impl DataManager { /// Get a [`StackBlock`] from an SQL row. - pub(crate) fn get_stackblock_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> StackBlock { + pub(crate) fn get_stackblock_from_row(x: &PostgresRow) -> StackBlock { StackBlock { id: get!(x->0(i64)) as usize, created: get!(x->1(i64)) as usize, diff --git a/crates/core/src/database/stacks.rs b/crates/core/src/database/stacks.rs index e0789c7..5c92a37 100644 --- a/crates/core/src/database/stacks.rs +++ b/crates/core/src/database/stacks.rs @@ -8,20 +8,13 @@ use crate::model::{ }; use crate::{auto_method, DataManager}; -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] use oiseau::PostgresRow; use oiseau::{execute, get, query_rows, params}; impl DataManager { /// Get a [`UserStack`] from an SQL row. - pub(crate) fn get_stack_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> UserStack { + pub(crate) fn get_stack_from_row(x: &PostgresRow) -> UserStack { UserStack { id: get!(x->0(i64)) as usize, created: get!(x->1(i64)) as usize, diff --git a/crates/core/src/database/uploads.rs b/crates/core/src/database/uploads.rs index 49778de..e3b2cb5 100644 --- a/crates/core/src/database/uploads.rs +++ b/crates/core/src/database/uploads.rs @@ -4,20 +4,13 @@ use crate::model::permissions::FinePermission; use crate::model::{Error, Result, uploads::MediaUpload}; use crate::{auto_method, DataManager}; -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] use oiseau::PostgresRow; use oiseau::{execute, get, query_rows, params}; impl DataManager { /// Get a [`MediaUpload`] from an SQL row. - pub(crate) fn get_upload_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> MediaUpload { + pub(crate) fn get_upload_from_row(x: &PostgresRow) -> MediaUpload { MediaUpload { id: get!(x->0(i64)) as usize, created: get!(x->1(i64)) as usize, diff --git a/crates/core/src/database/user_warnings.rs b/crates/core/src/database/user_warnings.rs index 4aebfca..79af849 100644 --- a/crates/core/src/database/user_warnings.rs +++ b/crates/core/src/database/user_warnings.rs @@ -4,20 +4,11 @@ use crate::model::moderation::AuditLogEntry; use crate::model::{Error, Result, auth::User, permissions::FinePermission}; use crate::{auto_method, DataManager}; -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] -use oiseau::PostgresRow; - -use oiseau::{execute, get, query_row, query_rows, params}; +use oiseau::{PostgresRow, execute, get, query_row, query_rows, params}; impl DataManager { /// Get a [`UserWarning`] from an SQL row. - pub(crate) fn get_user_warning_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> UserWarning { + pub(crate) fn get_user_warning_from_row(x: &PostgresRow) -> UserWarning { UserWarning { id: get!(x->0(i64)) as usize, created: get!(x->1(i64)) as usize, diff --git a/crates/core/src/database/userblocks.rs b/crates/core/src/database/userblocks.rs index f54b97e..ac95cab 100644 --- a/crates/core/src/database/userblocks.rs +++ b/crates/core/src/database/userblocks.rs @@ -2,20 +2,11 @@ use oiseau::cache::Cache; use crate::model::{Error, Result, auth::User, auth::UserBlock, permissions::FinePermission}; use crate::{auto_method, DataManager}; -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] -use oiseau::PostgresRow; - -use oiseau::{execute, get, query_row, query_rows, params}; +use oiseau::{PostgresRow, execute, get, query_row, query_rows, params}; impl DataManager { /// Get a [`UserBlock`] from an SQL row. - pub(crate) fn get_userblock_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> UserBlock { + pub(crate) fn get_userblock_from_row(x: &PostgresRow) -> UserBlock { UserBlock { id: get!(x->0(i64)) as usize, created: get!(x->1(i64)) as usize, diff --git a/crates/core/src/database/userfollows.rs b/crates/core/src/database/userfollows.rs index 4a4889b..09504c0 100644 --- a/crates/core/src/database/userfollows.rs +++ b/crates/core/src/database/userfollows.rs @@ -4,20 +4,11 @@ use crate::model::requests::{ActionRequest, ActionType}; use crate::model::{Error, Result, auth::User, auth::UserFollow, permissions::FinePermission}; use crate::{auto_method, DataManager}; -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] -use oiseau::PostgresRow; - -use oiseau::{execute, get, query_row, query_rows, params}; +use oiseau::{PostgresRow, execute, get, query_row, query_rows, params}; impl DataManager { /// Get a [`UserFollow`] from an SQL row. - pub(crate) fn get_userfollow_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> UserFollow { + pub(crate) fn get_userfollow_from_row(x: &PostgresRow) -> UserFollow { UserFollow { id: get!(x->0(i64)) as usize, created: get!(x->1(i64)) as usize, diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 8e1e9b7..aa61770 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -4,10 +4,3 @@ pub mod model; pub use database::DataManager; pub use oiseau::cache; - -/// Tells us if pubsub capabilities are provided (via Redis). -/// -/// If we have access to pubsub, community channels/messages will be available. -/// -/// This is mostly used a flag for the UI. -pub const PUBSUB_ENABLED: bool = cfg!(feature = "redis"); diff --git a/crates/core/src/model/communities.rs b/crates/core/src/model/communities.rs index ed0d5b9..c4419c1 100644 --- a/crates/core/src/model/communities.rs +++ b/crates/core/src/model/communities.rs @@ -260,6 +260,10 @@ pub struct Post { pub title: String, /// If the post is "open". Posts can act as tickets in a forge community. pub is_open: bool, + /// The ID of the circle this post belongs to. 0 means no circle is connected. + /// + /// If circle is not 0, community should be 0 (and vice versa). + pub circle: usize, } impl Post { @@ -287,6 +291,7 @@ impl Post { poll_id, title: String::new(), is_open: true, + circle: 0, } } diff --git a/crates/core/src/model/mod.rs b/crates/core/src/model/mod.rs index 1693fa3..8beb286 100644 --- a/crates/core/src/model/mod.rs +++ b/crates/core/src/model/mod.rs @@ -1,6 +1,7 @@ pub mod addr; pub mod apps; pub mod auth; +pub mod channels; pub mod communities; pub mod communities_permissions; pub mod moderation; @@ -8,15 +9,10 @@ pub mod oauth; pub mod permissions; pub mod reactions; pub mod requests; +pub mod socket; pub mod stacks; pub mod uploads; -#[cfg(feature = "redis")] -pub mod channels; - -#[cfg(feature = "redis")] -pub mod socket; - use std::fmt::Display; use serde::{Deserialize, Serialize}; diff --git a/sql_changes/posts_circle.sql b/sql_changes/posts_circle.sql new file mode 100644 index 0000000..ad4d620 --- /dev/null +++ b/sql_changes/posts_circle.sql @@ -0,0 +1,2 @@ +ALTER TABLE posts +ADD COLUMN circle BIGINT NOT NULL DEFAULT 0; From 56cea8393301730a4f815825b733c349aa78e138 Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 15 Jun 2025 16:09:02 -0400 Subject: [PATCH 04/75] add: circle stacks --- Cargo.lock | 14 +- README.md | 2 +- crates/app/Cargo.toml | 4 +- crates/app/src/image.rs | 7 +- .../public/html/communities/create_post.lisp | 41 ++++- crates/app/src/public/html/components.lisp | 9 +- crates/app/src/public/html/post/post.lisp | 1 + .../app/src/public/html/profile/settings.lisp | 4 +- crates/app/src/public/html/stacks/feed.lisp | 36 +++- crates/app/src/public/html/stacks/manage.lisp | 6 +- crates/app/src/public/js/me.js | 12 +- .../src/routes/api/v1/communities/posts.rs | 33 ++-- crates/app/src/routes/api/v1/mod.rs | 3 + crates/app/src/routes/api/v1/stacks.rs | 33 +++- crates/app/src/routes/pages/communities.rs | 44 ++++- crates/app/src/routes/pages/profile.rs | 2 +- crates/app/src/routes/pages/stacks.rs | 3 +- crates/core/Cargo.toml | 4 +- crates/core/src/database/drafts.rs | 2 +- crates/core/src/database/posts.rs | 167 +++++++++++++++++- crates/core/src/database/stacks.rs | 68 ++++--- crates/core/src/model/communities.rs | 8 +- crates/core/src/model/oauth.rs | 4 - crates/core/src/model/stacks.rs | 12 +- crates/l10n/Cargo.toml | 2 +- crates/shared/Cargo.toml | 2 +- sql_changes/posts_circle.sql | 3 + 27 files changed, 419 insertions(+), 107 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fd8c94a..48e412e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2621,9 +2621,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "reqwest" -version = "0.12.19" +version = "0.12.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2f8e5513d63f2e5b386eb5106dc67eaf3f84e95258e210489136b8b92ad6119" +checksum = "eabf4c97d9130e2bf606614eb937e86edac8292eaa6f422f995d7e8de1eb1813" dependencies = [ "base64 0.22.1", "bytes", @@ -2638,12 +2638,10 @@ dependencies = [ "hyper-rustls", "hyper-tls 0.6.0", "hyper-util", - "ipnet", "js-sys", "log", "mime", "native-tls", - "once_cell", "percent-encoding", "pin-project-lite", "rustls-pki-types", @@ -3233,7 +3231,7 @@ dependencies = [ [[package]] name = "tetratto" -version = "7.0.0" +version = "8.0.0" dependencies = [ "ammonia", "async-stripe", @@ -3264,7 +3262,7 @@ dependencies = [ [[package]] name = "tetratto-core" -version = "7.0.0" +version = "8.0.0" dependencies = [ "async-recursion", "base16ct", @@ -3286,7 +3284,7 @@ dependencies = [ [[package]] name = "tetratto-l10n" -version = "7.0.0" +version = "8.0.0" dependencies = [ "pathbufd", "serde", @@ -3295,7 +3293,7 @@ dependencies = [ [[package]] name = "tetratto-shared" -version = "7.0.0" +version = "8.0.0" dependencies = [ "ammonia", "chrono", diff --git a/README.md b/README.md index 87e0a38..050f8ce 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Make sure you have AT LEAST rustc version 1.89.0-nightly. Everything Tetratto needs will be built into the main binary. You can build Tetratto with the following command: ```bash -cargo build +cargo build -r ``` Tetratto **requires** a PostgreSQL server, as well as a Redis (or Redis fork) instance. diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 333bff5..706ee8d 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto" -version = "7.0.0" +version = "8.0.0" edition = "2024" [dependencies] @@ -19,7 +19,7 @@ tetratto-core = { path = "../core" } tetratto-l10n = { path = "../l10n" } image = "0.25.6" -reqwest = { version = "0.12.19", features = ["json", "stream"] } +reqwest = { version = "0.12.20", features = ["json", "stream"] } regex = "1.11.1" serde_json = "1.0.140" mime_guess = "2.0.5" diff --git a/crates/app/src/image.rs b/crates/app/src/image.rs index 75b231c..a6fd32e 100644 --- a/crates/app/src/image.rs +++ b/crates/app/src/image.rs @@ -127,11 +127,8 @@ where Err(_) => return Err((StatusCode::BAD_REQUEST, "json data isn't utf8".to_string())), }) { Ok(s) => s, - Err(_) => { - return Err(( - StatusCode::BAD_REQUEST, - "could not parse json data as json".to_string(), - )); + Err(e) => { + return Err((StatusCode::BAD_REQUEST, e.to_string())); } }; diff --git a/crates/app/src/public/html/communities/create_post.lisp b/crates/app/src/public/html/communities/create_post.lisp index 51b7ebf..754e915 100644 --- a/crates/app/src/public/html/communities/create_post.lisp +++ b/crates/app/src/public/html/communities/create_post.lisp @@ -97,6 +97,13 @@ ("value" "{{ community.id }}") ("selected" "{% if selected_community == community.id -%}true{% else %}false{%- endif %}") (text "{% if community.context.display_name -%} {{ community.context.display_name }} {% else %} {{ community.title }} {%- endif %}")) + (text "{% endfor %}") + (text "{% for stack in stacks %}") + (option + ("value" "{{ stack.id }}") + ("selected" "{% if selected_stack == stack.id -%}true{% else %}false{%- endif %}") + ("is_stack" "true") + (text "{{ stack.name }} (circle)")) (text "{% endfor %}"))) (form ("class" "card flex flex-col gap-2") @@ -184,13 +191,19 @@ } } + const is_selected_stack = document.getElementById( + \"community_to_post_to\", + ).selectedOptions[0].getAttribute(\"is_stack\") === \"true\"; + const selected_community = document.getElementById( + \"community_to_post_to\", + ).selectedOptions[0].value; + body.append( \"body\", JSON.stringify({ content: e.target.content.value, - community: document.getElementById( - \"community_to_post_to\", - ).selectedOptions[0].value, + community: !is_selected_stack ? selected_community : \"0\", + stack: is_selected_stack ? selected_community : \"0\", poll: poll_data[1], title: e.target.title.value, }), @@ -316,12 +329,15 @@ (text "{% else %}") (script (text "async function create_post_from_form(e) { + e.preventDefault(); const id = await trigger(\"me::repost\", [ \"{{ quoting[1].id }}\", e.target.content.value, document.getElementById(\"community_to_post_to\") .selectedOptions[0].value, false, + document.getElementById(\"community_to_post_to\") + .selectedOptions[0].getAttribute(\"is_stack\") === \"true\", ]); // update settings @@ -394,27 +410,34 @@ (text "{%- endif %}")) (script - (text "const town_square = \"{{ config.town_square }}\"; + (text "(() => {const town_square = \"{{ config.town_square }}\"; const user_id = \"{{ user.id }}\"; - function update_community_avatar(e) { + window.update_community_avatar = (e) => { const element = e.target.parentElement.querySelector(\".avatar\"); + const is_stack = e.target.selectedOptions[0].getAttribute(\"is_stack\") === \"true\"; const id = e.target.selectedOptions[0].value; element.setAttribute(\"title\", id); element.setAttribute(\"alt\", `${id}'s avatar`); - if (id === town_square) { + if (id === town_square || is_stack) { element.src = `/api/v1/auth/user/${user_id}/avatar?selector_type=id`; } else { element.src = `/api/v1/communities/${id}/avatar`; } } - function check_community_supports_title(e) { + window.check_community_supports_title = async (e) => { const element = document.getElementById(\"title_field\"); + const is_stack = e.target.selectedOptions[0].getAttribute(\"is_stack\") === \"true\"; const id = e.target.selectedOptions[0].value; + if (is_stack) { + element.classList.add(\"hidden\"); + return; + } + fetch(`/api/v1/communities/${id}/supports_titles`) .then((res) => res.json()) .then((res) => { @@ -436,7 +459,7 @@ }); }, 150); - async function cancel_create_post() { + window.cancel_create_post = async () => { if ( !(await trigger(\"atto::confirm\", [ \"Are you sure you would like to do this? Your post content will be lost.\", @@ -446,6 +469,6 @@ } window.history.back(); - }")) + }})();")) (text "{% endblock %}") diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 239d376..1c87a44 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -173,8 +173,13 @@ ("class" "flex items-center") ("style" "color: var(--color-primary)") (text "{{ icon \"square-asterisk\" }}")) - (text "{%- endif %}") - (text "{% if community and community.is_forge -%} {% if post.is_open -%}") + (text "{%- endif %} {% if post.stack -%}") + (span + ("title" "Posted to a stack you're in") + ("class" "flex items-center") + ("style" "color: var(--color-primary)") + (text "{{ icon \"layers\" }}")) + (text "{%- endif %} {% if community and community.is_forge -%} {% if post.is_open -%}") (span ("title" "Open") ("class" "flex items-center green") diff --git a/crates/app/src/public/html/post/post.lisp b/crates/app/src/public/html/post/post.lisp index 705cec2..22f64e9 100644 --- a/crates/app/src/public/html/post/post.lisp +++ b/crates/app/src/public/html/post/post.lisp @@ -298,6 +298,7 @@ JSON.stringify({ content: e.target.content.value, community: \"{{ community.id }}\", + stack: \"{{ post.stack }}\", replying_to: \"{{ post.id }}\", poll: poll_data[1], }), diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index 33f6117..a89286f 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -581,7 +581,9 @@ (li (text "Ability to create more than 1 app")) (li - (text "Create up to 10 stack blocks"))) + (text "Create up to 10 stack blocks")) + (li + (text "Add unlimited users to stacks"))) (a ("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}") ("class" "button") diff --git a/crates/app/src/public/html/stacks/feed.lisp b/crates/app/src/public/html/stacks/feed.lisp index a723066..0317469 100644 --- a/crates/app/src/public/html/stacks/feed.lisp +++ b/crates/app/src/public/html/stacks/feed.lisp @@ -17,14 +17,27 @@ (text "{{ components::avatar(username=stack.owner, selector_type=\"id\") }}")) (span (text "{{ stack.name }}"))) - (text "{% if user and user.id == stack.owner -%}") - (a - ("href" "/stacks/{{ stack.id }}/manage") - ("class" "button lowered small") - (text "{{ icon \"pencil\" }}") - (span - (text "{{ text \"general:action.manage\" }}"))) - (text "{%- endif %}")) + (div + ("class" "flex gap-2") + (text "{% if stack.mode == 'Circle' -%}") + ; post button for circle stacks + (a + ("href" "/communities/intents/post?stack={{ stack.id }}") + ("class" "button lowered small") + (text "{{ icon \"plus\" }}") + (span + (text "{{ text \"general:action.post\" }}"))) + (text "{%- endif %}") + + (text "{% if user and user.id == stack.owner -%}") + ; manage button for stack owner only + (a + ("href" "/stacks/{{ stack.id }}/manage") + ("class" "button lowered small") + (text "{{ icon \"pencil\" }}") + (span + (text "{{ text \"general:action.manage\" }}"))) + (text "{%- endif %}"))) (div ("class" "card w-full flex flex-col gap-2") (text "{% if list|length == 0 -%}") @@ -37,6 +50,7 @@ (text "{%- endif %}") (text "{% if stack.mode == 'BlockList' -%}") + ; block button + user list for blocklist only (text "{% if not is_blocked -%}") (button ("onclick" "block_all()") @@ -50,6 +64,12 @@ ("class" "flex gap-2 flex-wrap w-full") (text "{% for user in list %} {{ components::user_plate(user=user, secondary=true) }} {% endfor %}")) (text "{% else %}") + ; user icons for circle stack + (text "{% if stack.mode == 'Circle' -%} {% for user in stack.users %}") + (text "{{ components::avatar(username=user, selector_type=\"id\", size=\"24px\") }}") + (text "{% endfor %} {%- endif %}") + + ; posts for all stacks except blocklist (text "{% for post in list %} {% if post[2].read_access == \"Everybody\" -%} {% if post[0].context.repost and post[0].context.repost.reposting -%} diff --git a/crates/app/src/public/html/stacks/manage.lisp b/crates/app/src/public/html/stacks/manage.lisp index ef608c5..95f8545 100644 --- a/crates/app/src/public/html/stacks/manage.lisp +++ b/crates/app/src/public/html/stacks/manage.lisp @@ -67,7 +67,11 @@ (option ("value" "BlockList") ("selected" "{% if stack.mode == 'BlockList' -%}true{% else %}false{%- endif %}") - (text "Block list"))))) + (text "Block list")) + (option + ("value" "Circle") + ("selected" "{% if stack.mode == 'Circle' -%}true{% else %}false{%- endif %}") + (text "Circle"))))) (div ("class" "card-nest") ("ui_ident" "sort") diff --git a/crates/app/src/public/js/me.js b/crates/app/src/public/js/me.js index 1a91bcd..e8f4ae2 100644 --- a/crates/app/src/public/js/me.js +++ b/crates/app/src/public/js/me.js @@ -259,7 +259,14 @@ self.define( "repost", - (_, id, content, community, do_not_redirect = false) => { + ( + _, + id, + content, + community, + do_not_redirect = false, + is_stack = false, + ) => { return new Promise((resolve, _) => { fetch(`/api/v1/posts/${id}/repost`, { method: "POST", @@ -268,7 +275,8 @@ }, body: JSON.stringify({ content, - community, + community: !is_stack ? community : "0", + stack: is_stack ? community : "0", }), }) .then((res) => res.json()) diff --git a/crates/app/src/routes/api/v1/communities/posts.rs b/crates/app/src/routes/api/v1/communities/posts.rs index 32f4b77..81a1fae 100644 --- a/crates/app/src/routes/api/v1/communities/posts.rs +++ b/crates/app/src/routes/api/v1/communities/posts.rs @@ -124,6 +124,10 @@ pub async fn create_request( }; } else { props.title = req.title; + props.stack = match req.stack.parse::() { + Ok(x) => x, + Err(e) => return Json(Error::MiscError(e.to_string()).into()), + }; } // check sizes @@ -197,18 +201,23 @@ pub async fn create_repost_request( None => return Json(Error::NotAllowed.into()), }; - match data - .create_post(Post::repost( - req.content, - match req.community.parse::() { - Ok(x) => x, - Err(e) => return Json(Error::MiscError(e.to_string()).into()), - }, - user.id, - id, - )) - .await - { + let mut props = Post::repost( + req.content, + match req.community.parse::() { + Ok(x) => x, + Err(e) => return Json(Error::MiscError(e.to_string()).into()), + }, + user.id, + id, + ); + + props.stack = match req.stack.parse::() { + Ok(x) => x, + Err(e) => return Json(Error::MiscError(e.to_string()).into()), + }; + + // ... + match data.create_post(props).await { Ok(id) => Json(ApiReturn { ok: true, message: "Post reposted".to_string(), diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 5c54aa9..80212b1 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -616,12 +616,15 @@ pub struct CreatePost { pub poll: Option, #[serde(default)] pub title: String, + #[serde(default)] + pub stack: String, } #[derive(Deserialize)] pub struct CreateRepost { pub content: String, pub community: String, + pub stack: String, } #[derive(Deserialize)] diff --git a/crates/app/src/routes/api/v1/stacks.rs b/crates/app/src/routes/api/v1/stacks.rs index 94b2c5a..ee4e5b7 100644 --- a/crates/app/src/routes/api/v1/stacks.rs +++ b/crates/app/src/routes/api/v1/stacks.rs @@ -5,11 +5,14 @@ use axum::{ Extension, Json, }; use axum_extra::extract::CookieJar; -use tetratto_core::model::{ - oauth, - permissions::FinePermission, - stacks::{StackBlock, StackPrivacy, UserStack}, - ApiReturn, Error, +use tetratto_core::{ + model::{ + oauth, + permissions::FinePermission, + stacks::{StackBlock, StackMode, StackPrivacy, UserStack}, + ApiReturn, Error, + }, + DataManager, }; use super::{ AddOrRemoveStackUser, CreateStack, UpdateStackMode, UpdateStackName, UpdateStackPrivacy, @@ -161,6 +164,25 @@ pub async fn add_user_request( }; stack.users.push(other_user.id); + + // check number of stacks + let owner = match data.get_user_by_id(stack.owner).await { + Ok(ua) => ua, + Err(e) => return Json(e.into()), + }; + + if !owner.permissions.check(FinePermission::SUPPORTER) { + if stack.users.len() >= DataManager::MAXIMUM_FREE_STACK_USERS { + return Json( + Error::MiscError( + "This stack already has the maximum users it can have".to_string(), + ) + .into(), + ); + } + } + + // ... match data.update_stack_users(id, &user, stack.users).await { Ok(_) => Json(ApiReturn { ok: true, @@ -250,6 +272,7 @@ pub async fn get_users_request( if stack.privacy == StackPrivacy::Private && user.id != stack.owner + && !(stack.mode == StackMode::Circle && stack.users.contains(&user.id)) && !user.permissions.check(FinePermission::MANAGE_STACKS) { return Json(Error::NotAllowed.into()); diff --git a/crates/app/src/routes/pages/communities.rs b/crates/app/src/routes/pages/communities.rs index d2363b8..556728e 100644 --- a/crates/app/src/routes/pages/communities.rs +++ b/crates/app/src/routes/pages/communities.rs @@ -11,8 +11,12 @@ use axum_extra::extract::CookieJar; use serde::Deserialize; use tera::Context; use tetratto_core::model::{ - auth::User, communities::Community, communities_permissions::CommunityPermission, - permissions::FinePermission, Error, + auth::User, + communities::Community, + communities_permissions::CommunityPermission, + permissions::FinePermission, + stacks::{StackMode, UserStack}, + Error, }; #[macro_export] @@ -245,6 +249,8 @@ pub struct CreatePostProps { #[serde(default)] pub community: usize, #[serde(default)] + pub stack: usize, + #[serde(default)] pub from_draft: usize, #[serde(default)] pub quote: usize, @@ -286,6 +292,16 @@ pub async fn create_post_request( communities.push(community) } + let stacks = match data.0.get_stacks_by_user(user.id).await { + Ok(s) => s, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + let stacks: Vec<&UserStack> = stacks + .iter() + .filter(|x| x.mode == StackMode::Circle) + .collect(); + // get draft let draft = if props.from_draft != 0 { match data.0.get_draft_by_id(props.from_draft).await { @@ -326,8 +342,10 @@ pub async fn create_post_request( context.insert("draft", &draft); context.insert("drafts", &drafts); + context.insert("stacks", &stacks); context.insert("quoting", "ing); context.insert("communities", &communities); + context.insert("selected_stack", &props.stack); context.insert("selected_community", &props.community); // return @@ -663,6 +681,28 @@ pub async fn post_request( } } + // check stack + if post.stack != 0 { + let stack = match data.0.get_stack_by_id(post.stack).await { + Ok(c) => c, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }; + + if let Some(ref ua) = user { + if (stack.owner != ua.id) && !stack.users.contains(&ua.id) { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &user).await, + )); + } + } else { + // we MUST be authenticated to view posts in a stack + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &user).await, + )); + } + } + + // ... let community = match data.0.get_community_by_id(post.community).await { Ok(c) => c, Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), diff --git a/crates/app/src/routes/pages/profile.rs b/crates/app/src/routes/pages/profile.rs index 2713b26..cd780e4 100644 --- a/crates/app/src/routes/pages/profile.rs +++ b/crates/app/src/routes/pages/profile.rs @@ -50,7 +50,7 @@ pub async fn settings_request( } }; - let stacks = match data.0.get_stacks_by_owner(profile.id).await { + let stacks = match data.0.get_stacks_by_user(profile.id).await { Ok(ua) => ua, Err(e) => { return Err(Html(render_error(e, &jar, &data, &None).await)); diff --git a/crates/app/src/routes/pages/stacks.rs b/crates/app/src/routes/pages/stacks.rs index 656fee3..822b9b7 100644 --- a/crates/app/src/routes/pages/stacks.rs +++ b/crates/app/src/routes/pages/stacks.rs @@ -25,7 +25,7 @@ pub async fn list_request(jar: CookieJar, Extension(data): Extension) -> } }; - let list = match data.0.get_stacks_by_owner(user.id).await { + let list = match data.0.get_stacks_by_user(user.id).await { Ok(p) => p, Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), }; @@ -63,6 +63,7 @@ pub async fn feed_request( if stack.privacy == StackPrivacy::Private && user.id != stack.owner + && !(stack.mode == StackMode::Circle && stack.users.contains(&user.id)) && !user.permissions.check(FinePermission::MANAGE_STACKS) { return Err(Html( diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 87988b0..1a208f5 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-core" -version = "7.0.0" +version = "8.0.0" edition = "2024" [dependencies] @@ -11,7 +11,7 @@ tetratto-shared = { path = "../shared" } tetratto-l10n = { path = "../l10n" } serde_json = "1.0.140" totp-rs = { version = "5.7.0", features = ["qr", "gen_secret"] } -reqwest = { version = "0.12.19", features = ["json"] } +reqwest = { version = "0.12.20", features = ["json"] } bitflags = "2.9.1" async-recursion = "1.1.1" md-5 = "0.10.6" diff --git a/crates/core/src/database/drafts.rs b/crates/core/src/database/drafts.rs index a573bcc..95c2acf 100644 --- a/crates/core/src/database/drafts.rs +++ b/crates/core/src/database/drafts.rs @@ -95,7 +95,7 @@ impl DataManager { let owner = self.get_user_by_id(data.owner).await?; if !owner.permissions.check(FinePermission::SUPPORTER) { - let stacks = self.get_stacks_by_owner(data.owner).await?; + let stacks = self.get_stacks_by_user(data.owner).await?; if stacks.len() >= Self::MAXIMUM_FREE_DRAFTS { return Err(Error::MiscError( diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index 1853e98..0d3f6dd 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -5,7 +5,7 @@ use crate::model::auth::Notification; use crate::model::communities::{Poll, Question}; use crate::model::communities_permissions::CommunityPermission; use crate::model::moderation::AuditLogEntry; -use crate::model::stacks::StackSort; +use crate::model::stacks::{StackMode, StackSort, UserStack}; use crate::model::{ Error, Result, auth::User, @@ -25,6 +25,7 @@ pub type FullPost = ( Option<(User, Post)>, Option<(Question, User)>, Option<(Poll, bool, bool)>, + Option, ); macro_rules! private_post_replying { @@ -114,7 +115,7 @@ impl DataManager { poll_id: get!(x->13(i64)) as usize, title: get!(x->14(String)), is_open: get!(x->15(i32)) as i8 == 1, - circle: get!(x->16(i64)) as usize, + stack: get!(x->16(i64)) as usize, } } @@ -275,6 +276,39 @@ impl DataManager { } } + /// Get the stack of the given post (if some). + /// + /// # Returns + /// `(can view post, stack)` + pub async fn get_post_stack( + &self, + seen_stacks: &mut HashMap, + post: &Post, + as_user_id: usize, + ) -> (bool, Option) { + if post.stack != 0 { + if let Some(s) = seen_stacks.get(&post.stack) { + ( + (s.owner == as_user_id) | s.users.contains(&as_user_id), + Some(s.to_owned()), + ) + } else { + let s = match self.get_stack_by_id(post.stack).await { + Ok(s) => s, + Err(_) => return (true, None), + }; + + seen_stacks.insert(s.id, s.to_owned()); + ( + (s.owner == as_user_id) | s.users.contains(&as_user_id), + Some(s.to_owned()), + ) + } + } else { + (true, None) + } + } + /// Complete a vector of just posts with their owner as well. pub async fn fill_posts( &self, @@ -288,12 +322,14 @@ impl DataManager { Option<(User, Post)>, Option<(Question, User)>, Option<(Poll, bool, bool)>, + Option, )>, > { let mut out = Vec::new(); let mut users: HashMap = HashMap::new(); let mut seen_user_follow_statuses: HashMap<(usize, usize), bool> = HashMap::new(); + let mut seen_stacks: HashMap = HashMap::new(); let mut replying_posts: HashMap = HashMap::new(); for post in posts { @@ -304,12 +340,25 @@ impl DataManager { let owner = post.owner; if let Some(ua) = users.get(&owner) { + let (can_view, stack) = self + .get_post_stack( + &mut seen_stacks, + &post, + if let Some(ua) = user { ua.id } else { 0 }, + ) + .await; + + if !can_view { + continue; + } + out.push(( post.clone(), ua.clone(), self.get_post_reposting(&post, ignore_users, user).await, self.get_post_question(&post, ignore_users).await?, self.get_post_poll(&post, user).await?, + stack, )); } else { let ua = self.get_user_by_id(owner).await?; @@ -357,6 +406,18 @@ impl DataManager { } } + let (can_view, stack) = self + .get_post_stack( + &mut seen_stacks, + &post, + if let Some(ua) = user { ua.id } else { 0 }, + ) + .await; + + if !can_view { + continue; + } + // ... users.insert(owner, ua.clone()); out.push(( @@ -365,6 +426,7 @@ impl DataManager { self.get_post_reposting(&post, ignore_users, user).await, self.get_post_question(&post, ignore_users).await?, self.get_post_poll(&post, user).await?, + stack, )); } } @@ -384,6 +446,7 @@ impl DataManager { let mut seen_before: HashMap<(usize, usize), (User, Community)> = HashMap::new(); let mut seen_user_follow_statuses: HashMap<(usize, usize), bool> = HashMap::new(); + let mut seen_stacks: HashMap = HashMap::new(); let mut replying_posts: HashMap = HashMap::new(); for post in posts { @@ -395,6 +458,18 @@ impl DataManager { let community = post.community; if let Some((ua, community)) = seen_before.get(&(owner, community)) { + let (can_view, stack) = self + .get_post_stack( + &mut seen_stacks, + &post, + if let Some(ua) = user { ua.id } else { 0 }, + ) + .await; + + if !can_view { + continue; + } + out.push(( post.clone(), ua.clone(), @@ -402,6 +477,7 @@ impl DataManager { self.get_post_reposting(&post, ignore_users, user).await, self.get_post_question(&post, ignore_users).await?, self.get_post_poll(&post, user).await?, + stack, )); } else { let ua = self.get_user_by_id(owner).await?; @@ -440,6 +516,18 @@ impl DataManager { } } + let (can_view, stack) = self + .get_post_stack( + &mut seen_stacks, + &post, + if let Some(ua) = user { ua.id } else { 0 }, + ) + .await; + + if !can_view { + continue; + } + // ... let community = self.get_community_by_id(community).await?; seen_before.insert((owner, community.id), (ua.clone(), community.clone())); @@ -450,6 +538,7 @@ impl DataManager { self.get_post_reposting(&post, ignore_users, user).await, self.get_post_question(&post, ignore_users).await?, self.get_post_poll(&post, user).await?, + stack, )); } } @@ -933,6 +1022,37 @@ impl DataManager { Ok(res.unwrap()) } + /// Get all posts from the given stack (from most recent). + /// + /// # Arguments + /// * `id` - the ID of the stack the requested posts belong to + /// * `batch` - the limit of posts in each page + /// * `page` - the page number + pub async fn get_posts_by_stack( + &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 stack = $1 AND replying_to = 0 AND is_deleted = 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()) + } + /// Get all pinned posts from the given community (from most recent). /// /// # Arguments @@ -1370,7 +1490,30 @@ impl DataManager { } } - let community = self.get_community_by_id(data.community).await?; + // check stack + if data.stack != 0 { + let stack = self.get_stack_by_id(data.stack).await?; + + if stack.mode != StackMode::Circle { + return Err(Error::MiscError( + "You must use a \"Circle\" stack for this".to_string(), + )); + } + + if stack.owner != data.owner && !stack.users.contains(&data.owner) { + return Err(Error::NotAllowed); + } + } + + // ... + let community = if data.stack != 0 { + // if we're posting to a stack, the community should always be the town square + data.community = self.0.0.town_square; + self.get_community_by_id(self.0.0.town_square).await? + } else { + // otherwise, load whatever community the post is requesting + self.get_community_by_id(data.community).await? + }; // check values (if this isn't reposting something else) let is_reposting = if let Some(ref repost) = data.context.repost { @@ -1466,6 +1609,10 @@ impl DataManager { }; if let Some(ref rt) = reposting { + if rt.stack != data.stack && rt.stack != 0 { + return Err(Error::MiscError("Cannot repost out of stack".to_string())); + } + if data.content.is_empty() { // reposting but NOT quoting... we shouldn't be able to repost a direct repost data.context.reposts_enabled = false; @@ -1507,7 +1654,7 @@ impl DataManager { // send notification // this would look better if rustfmt didn't give up on this line - if owner.id != rt.owner && !owner.settings.private_profile { + if owner.id != rt.owner && !owner.settings.private_profile && data.stack == 0 { self.create_notification( Notification::new( format!( @@ -1631,7 +1778,7 @@ impl DataManager { &(data.poll_id as i64), &data.title, &{ if data.is_open { 1 } else { 0 } }, - &(data.circle as i64), + &(data.stack as i64), ] ); @@ -1781,7 +1928,7 @@ impl DataManager { let res = execute!( &conn, "UPDATE posts SET is_deleted = $1 WHERE id = $2", - params![if is_deleted { 1 } else { 0 }, &(id as i64)] + params![&if is_deleted { 1 } else { 0 }, &(id as i64)] ); if let Err(e) = res { @@ -1793,7 +1940,9 @@ impl DataManager { if is_deleted { // decr parent comment count if let Some(replying_to) = y.replying_to { - self.decr_post_comments(replying_to).await.unwrap(); + if replying_to != 0 { + self.decr_post_comments(replying_to).await.unwrap(); + } } // decr user post count @@ -1893,7 +2042,7 @@ impl DataManager { let res = execute!( &conn, "UPDATE posts SET is_open = $1 WHERE id = $2", - params![if is_open { 1 } else { 0 }, &(id as i64)] + params![&if is_open { 1 } else { 0 }, &(id as i64)] ); if let Err(e) = res { @@ -2091,5 +2240,5 @@ impl DataManager { auto_method!(decr_post_dislikes() -> "UPDATE posts SET dislikes = dislikes - 1 WHERE id = $1" --cache-key-tmpl="atto.post:{}" --decr); auto_method!(incr_post_comments() -> "UPDATE posts SET comment_count = comment_count + 1 WHERE id = $1" --cache-key-tmpl="atto.post:{}" --incr); - auto_method!(decr_post_comments() -> "UPDATE posts SET comment_count = comment_count - 1 WHERE id = $1" --cache-key-tmpl="atto.post:{}" --decr); + auto_method!(decr_post_comments()@get_post_by_id -> "UPDATE posts SET comment_count = comment_count - 1 WHERE id = $1" --cache-key-tmpl="atto.post:{}" --decr=comment_count); } diff --git a/crates/core/src/database/stacks.rs b/crates/core/src/database/stacks.rs index 5c92a37..47f5e53 100644 --- a/crates/core/src/database/stacks.rs +++ b/crates/core/src/database/stacks.rs @@ -1,10 +1,12 @@ use oiseau::cache::Cache; -use crate::model::{ - Error, Result, - auth::User, - permissions::FinePermission, - stacks::{StackPrivacy, UserStack, StackMode, StackSort}, - communities::{Community, Poll, Post, Question}, +use crate::{ + database::posts::FullPost, + model::{ + auth::User, + permissions::FinePermission, + stacks::{StackMode, StackPrivacy, StackSort, UserStack}, + Error, Result, + }, }; use crate::{auto_method, DataManager}; @@ -37,16 +39,7 @@ impl DataManager { page: usize, ignore_users: &Vec, user: &Option, - ) -> Result< - Vec<( - Post, - User, - Community, - Option<(User, Post)>, - Option<(Question, User)>, - Option<(Poll, bool, bool)>, - )>, - > { + ) -> Result> { let stack = self.get_stack_by_id(id).await?; Ok(match stack.mode { @@ -89,6 +82,19 @@ impl DataManager { "You should use `get_stack_users` for this type".to_string(), )); } + StackMode::Circle => { + if !stack.users.contains(&as_user_id) && as_user_id != stack.owner { + return Err(Error::NotAllowed); + } + + self.fill_posts_with_community( + self.get_posts_by_stack(stack.id, batch, page).await?, + as_user_id, + &ignore_users, + user, + ) + .await? + } }) } @@ -119,9 +125,11 @@ impl DataManager { /// Get all stacks by user. /// + /// Also pulls stacks that are of "Circle" type AND the user is added to the `users` list. + /// /// # Arguments /// * `id` - the ID of the user to fetch stacks for - pub async fn get_stacks_by_owner(&self, id: usize) -> Result> { + pub async fn get_stacks_by_user(&self, id: usize) -> Result> { let conn = match self.0.connect().await { Ok(c) => c, Err(e) => return Err(Error::DatabaseConnection(e.to_string())), @@ -129,8 +137,8 @@ impl DataManager { let res = query_rows!( &conn, - "SELECT * FROM stacks WHERE owner = $1 ORDER BY name ASC", - &[&(id as i64)], + "SELECT * FROM stacks WHERE owner = $1 OR (mode = '\"Circle\"' AND users LIKE $2) ORDER BY name ASC", + &[&(id as i64), &format!("%{id}%")], |x| { Self::get_stack_from_row(x) } ); @@ -142,6 +150,7 @@ impl DataManager { } const MAXIMUM_FREE_STACKS: usize = 5; + pub const MAXIMUM_FREE_STACK_USERS: usize = 50; /// Create a new stack in the database. /// @@ -159,7 +168,7 @@ impl DataManager { let owner = self.get_user_by_id(data.owner).await?; if !owner.permissions.check(FinePermission::SUPPORTER) { - let stacks = self.get_stacks_by_owner(data.owner).await?; + let stacks = self.get_stacks_by_user(data.owner).await?; if stacks.len() >= Self::MAXIMUM_FREE_STACKS { return Err(Error::MiscError( @@ -216,6 +225,25 @@ impl DataManager { return Err(Error::DatabaseError(e.to_string())); } + // delete stackblocks + let res = execute!( + &conn, + "DELETE FROM stackblocks WHERE stack = $1", + &[&(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // delete posts + let res = execute!(&conn, "DELETE FROM posts WHERE stack = $1", &[&(id as i64)]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // ... self.0.1.remove(format!("atto.stack:{}", id)).await; Ok(()) } diff --git a/crates/core/src/model/communities.rs b/crates/core/src/model/communities.rs index c4419c1..41508ff 100644 --- a/crates/core/src/model/communities.rs +++ b/crates/core/src/model/communities.rs @@ -260,10 +260,10 @@ pub struct Post { pub title: String, /// If the post is "open". Posts can act as tickets in a forge community. pub is_open: bool, - /// The ID of the circle this post belongs to. 0 means no circle is connected. + /// The ID of the stack this post belongs to. 0 means no stack is connected. /// - /// If circle is not 0, community should be 0 (and vice versa). - pub circle: usize, + /// If stack is not 0, community should be 0 (and vice versa). + pub stack: usize, } impl Post { @@ -291,7 +291,7 @@ impl Post { poll_id, title: String::new(), is_open: true, - circle: 0, + stack: 0, } } diff --git a/crates/core/src/model/oauth.rs b/crates/core/src/model/oauth.rs index b2a3798..ea87034 100644 --- a/crates/core/src/model/oauth.rs +++ b/crates/core/src/model/oauth.rs @@ -76,8 +76,6 @@ pub enum AppScope { UserCreateCommunities, /// Create stacks on behalf of the user. UserCreateStacks, - /// Create circles on behalf of the user. - UserCreateCircles, /// Delete posts owned by the user. UserDeletePosts, /// Delete messages owned by the user. @@ -108,8 +106,6 @@ pub enum AppScope { UserManageRequests, /// Manage the user's uploads. UserManageUploads, - /// Manage the user's circles (add/remove users or delete). - UserManageCircles, /// Edit posts created by the user. UserEditPosts, /// Edit drafts created by the user. diff --git a/crates/core/src/model/stacks.rs b/crates/core/src/model/stacks.rs index a2e7487..437f2cc 100644 --- a/crates/core/src/model/stacks.rs +++ b/crates/core/src/model/stacks.rs @@ -1,7 +1,7 @@ use serde::{Serialize, Deserialize}; use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub enum StackPrivacy { /// Can be viewed by anyone. Public, @@ -15,7 +15,7 @@ impl Default for StackPrivacy { } } -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub enum StackMode { /// `users` vec contains ID of users to INCLUDE into the timeline; /// every other user is excluded @@ -28,6 +28,8 @@ pub enum StackMode { /// /// Other users can block the entire list (creating a `StackBlock`, not a `UserBlock`). BlockList, + /// `users` vec contains ID of users who are allowed to view posts posted to the stack. + Circle, } impl Default for StackMode { @@ -36,7 +38,7 @@ impl Default for StackMode { } } -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub enum StackSort { Created, Likes, @@ -48,7 +50,7 @@ impl Default for StackSort { } } -#[derive(Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct UserStack { pub id: usize, pub created: usize, @@ -76,7 +78,7 @@ impl UserStack { } } -#[derive(Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct StackBlock { pub id: usize, pub created: usize, diff --git a/crates/l10n/Cargo.toml b/crates/l10n/Cargo.toml index f3f3e62..3f11dfd 100644 --- a/crates/l10n/Cargo.toml +++ b/crates/l10n/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-l10n" -version = "7.0.0" +version = "8.0.0" edition = "2024" authors.workspace = true repository.workspace = true diff --git a/crates/shared/Cargo.toml b/crates/shared/Cargo.toml index 43caae5..dd48ed3 100644 --- a/crates/shared/Cargo.toml +++ b/crates/shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-shared" -version = "7.0.0" +version = "8.0.0" edition = "2024" authors.workspace = true repository.workspace = true diff --git a/sql_changes/posts_circle.sql b/sql_changes/posts_circle.sql index ad4d620..9d8d312 100644 --- a/sql_changes/posts_circle.sql +++ b/sql_changes/posts_circle.sql @@ -1,2 +1,5 @@ ALTER TABLE posts ADD COLUMN circle BIGINT NOT NULL DEFAULT 0; + +ALTER TABLE posts +ADD COLUMN stack BIGINT NOT NULL DEFAULT 0; From a7c00467622b6545e315fc8728b348cf7557b82e Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 15 Jun 2025 18:22:29 -0400 Subject: [PATCH 05/75] fix: upload only post likes ui --- crates/app/src/public/html/components.lisp | 2 +- sql_changes/{posts_circle.sql => posts_stack.sql} | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename sql_changes/{posts_circle.sql => posts_stack.sql} (64%) diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 1c87a44..7b7efcf 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -289,7 +289,7 @@ ("class" "flex gap-1 reactions_box") ("hook" "check_reactions") ("hook-arg:id" "{{ post.id }}") - (text "{% if post.context.reactions_enabled -%} {% if post.content|length > 0 -%} {{ self::likes(id=post.id, asset_type=\"Post\", likes=post.likes, dislikes=post.dislikes) }} {%- endif %} {%- endif %} {% if post.context.repost and post.context.repost.reposting -%}") + (text "{% if post.context.reactions_enabled -%} {% if post.content|length > 0 or post.uploads|length > 0 -%} {{ self::likes(id=post.id, asset_type=\"Post\", likes=post.likes, dislikes=post.dislikes) }} {%- endif %} {%- endif %} {% if post.context.repost and post.context.repost.reposting -%}") (a ("href" "/post/{{ post.context.repost.reposting }}") ("class" "button small camo") diff --git a/sql_changes/posts_circle.sql b/sql_changes/posts_stack.sql similarity index 64% rename from sql_changes/posts_circle.sql rename to sql_changes/posts_stack.sql index 9d8d312..9cd3474 100644 --- a/sql_changes/posts_circle.sql +++ b/sql_changes/posts_stack.sql @@ -1,5 +1,5 @@ ALTER TABLE posts -ADD COLUMN circle BIGINT NOT NULL DEFAULT 0; +DROP COLUMN circle; ALTER TABLE posts ADD COLUMN stack BIGINT NOT NULL DEFAULT 0; From 0af95e517df5a4ef8fb86892daa4c77337b916be Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 15 Jun 2025 18:38:37 -0400 Subject: [PATCH 06/75] fix: chat stream links --- crates/app/src/public/html/chats/stream.lisp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/app/src/public/html/chats/stream.lisp b/crates/app/src/public/html/chats/stream.lisp index 8a2243a..9b9affa 100644 --- a/crates/app/src/public/html/chats/stream.lisp +++ b/crates/app/src/public/html/chats/stream.lisp @@ -10,7 +10,7 @@ (b (text "{{ text \"chats:label.viewing_old_messages\" }}")) (a - ("href" "/chats/{{ community }}/{{ channel }}/_stream?page={{ page - 1}}") + ("href" "/chats/{{ community }}/{{ channel.id }}/_stream?page={{ page - 1}}") ("class" "button small") ("onclick" "window.CURRENT_PAGE -= 1") (text "{{ text \"chats:label.go_back\" }}"))) @@ -20,7 +20,7 @@ (b (text "{{ text \"chats:label.viewing_single_message\" }}")) (a - ("href" "/chats/{{ community }}/{{ channel }}?page={{ page }}") + ("href" "/chats/{{ community }}/{{ channel.id }}?page={{ page }}") ("class" "button small") ("onclick" "window.VIEWING_SINGLE = false") ("target" "_top") @@ -30,7 +30,7 @@ ("class" "flex gap-2 w-full justify-center") (a ("class" "button") - ("href" "/chats/{{ community }}/{{ channel }}/_stream?page={{ page + 1 }}") + ("href" "/chats/{{ community }}/{{ channel.id }}/_stream?page={{ page + 1 }}") ("onclick" "window.CURRENT_PAGE += 1") (text "{{ icon \"clock\" }}") (span From 9443bfb58d79f8f646bb790cdbd135903bfcb8b2 Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 15 Jun 2025 18:55:19 -0400 Subject: [PATCH 07/75] add: order dms by last message time --- crates/core/src/database/channels.rs | 9 ++++++--- crates/core/src/database/common.rs | 10 +++++++++- .../core/src/database/drivers/sql/create_channels.sql | 3 ++- crates/core/src/database/messages.rs | 4 ++++ crates/core/src/model/channels.rs | 7 ++++++- sql_changes/channels_last_message.sql | 2 ++ 6 files changed, 29 insertions(+), 6 deletions(-) create mode 100644 sql_changes/channels_last_message.sql diff --git a/crates/core/src/database/channels.rs b/crates/core/src/database/channels.rs index ed28323..b3dc31b 100644 --- a/crates/core/src/database/channels.rs +++ b/crates/core/src/database/channels.rs @@ -21,6 +21,7 @@ impl DataManager { position: get!(x->6(i32)) as usize, members: serde_json::from_str(&get!(x->7(String))).unwrap(), title: get!(x->8(String)), + last_message: get!(x->9(i64)) as usize, } } @@ -81,7 +82,7 @@ impl DataManager { let res = query_rows!( &conn, - "SELECT * FROM channels WHERE (owner = $1 OR members LIKE $2) AND community = 0 ORDER BY created DESC", + "SELECT * FROM channels WHERE (owner = $1 OR members LIKE $2) AND community = 0 ORDER BY last_message DESC", params![&(user as i64), &format!("%{user}%")], |x| { Self::get_channel_from_row(x) } ); @@ -162,7 +163,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO channels VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + "INSERT INTO channels VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", params![ &(data.id as i64), &(data.community as i64), @@ -172,7 +173,8 @@ impl DataManager { &(data.minimum_role_write as i32), &(data.position as i32), &serde_json::to_string(&data.members).unwrap(), - &data.title + &data.title, + &(data.last_message as i64) ] ); @@ -320,4 +322,5 @@ impl DataManager { auto_method!(update_channel_minimum_role_read(i32)@get_channel_by_id:MANAGE_CHANNELS -> "UPDATE channels SET minimum_role_read = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}"); auto_method!(update_channel_minimum_role_write(i32)@get_channel_by_id:MANAGE_CHANNELS -> "UPDATE channels SET minimum_role_write = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}"); auto_method!(update_channel_members(Vec)@get_channel_by_id:MANAGE_CHANNELS -> "UPDATE channels SET members = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.channel:{}"); + auto_method!(update_channel_last_message(i64) -> "UPDATE channels SET last_message = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}"); } diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index 8536b88..7e7a7f6 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -164,7 +164,15 @@ macro_rules! auto_method { .get(format!($cache_key_tmpl, selector.to_string())) .await { - return Ok(serde_json::from_str(&cached).unwrap()); + match serde_json::from_str(&cached) { + Ok(x) => return Ok(x), + Err(_) => { + self.0 + .1 + .remove(format!($cache_key_tmpl, selector.to_string())) + .await + } + }; } let conn = match self.0.connect().await { diff --git a/crates/core/src/database/drivers/sql/create_channels.sql b/crates/core/src/database/drivers/sql/create_channels.sql index 6b8a29b..83f7ff6 100644 --- a/crates/core/src/database/drivers/sql/create_channels.sql +++ b/crates/core/src/database/drivers/sql/create_channels.sql @@ -7,5 +7,6 @@ CREATE TABLE IF NOT EXISTS channels ( minimum_role_write INT NOT NULL, position INT NOT NULL, members TEXT NOT NULL, - title TEXT NOT NULL + title TEXT NOT NULL, + last_message BIGINT NOT NULL ) diff --git a/crates/core/src/database/messages.rs b/crates/core/src/database/messages.rs index f5c7024..6c60cd7 100644 --- a/crates/core/src/database/messages.rs +++ b/crates/core/src/database/messages.rs @@ -254,6 +254,10 @@ impl DataManager { return Err(Error::MiscError(e.to_string())); } + // update channel position + self.update_channel_last_message(channel.id, unix_epoch_timestamp() as i64) + .await?; + // ... Ok(()) } diff --git a/crates/core/src/model/channels.rs b/crates/core/src/model/channels.rs index 5b95d2e..b7023d3 100644 --- a/crates/core/src/model/channels.rs +++ b/crates/core/src/model/channels.rs @@ -24,21 +24,26 @@ pub struct Channel { pub members: Vec, /// The title of the channel. pub title: String, + /// The timestamp of the last message in the channel. + pub last_message: usize, } impl Channel { /// Create a new [`Channel`]. pub fn new(community: usize, owner: usize, position: usize, title: String) -> Self { + let created = unix_epoch_timestamp(); + Self { id: Snowflake::new().to_string().parse::().unwrap(), community, owner, - created: unix_epoch_timestamp(), + created, minimum_role_read: (CommunityPermission::DEFAULT | CommunityPermission::MEMBER).bits(), minimum_role_write: (CommunityPermission::DEFAULT | CommunityPermission::MEMBER).bits(), position, members: Vec::new(), title, + last_message: created, } } diff --git a/sql_changes/channels_last_message.sql b/sql_changes/channels_last_message.sql new file mode 100644 index 0000000..9709200 --- /dev/null +++ b/sql_changes/channels_last_message.sql @@ -0,0 +1,2 @@ +ALTER TABLE channels +ADD COLUMN last_message BIGINT NOT NULL DEFAULT '0'; From 8c5d8bf0ba3b752057b3d4557b7ba544ac83df10 Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 15 Jun 2025 19:04:56 -0400 Subject: [PATCH 08/75] fix: circle stack users ui --- crates/app/src/public/html/components.lisp | 5 +++-- crates/app/src/public/html/stacks/feed.lisp | 8 +++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 7b7efcf..284ee21 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -174,10 +174,11 @@ ("style" "color: var(--color-primary)") (text "{{ icon \"square-asterisk\" }}")) (text "{%- endif %} {% if post.stack -%}") - (span + (a ("title" "Posted to a stack you're in") - ("class" "flex items-center") + ("class" "flex items-center flush") ("style" "color: var(--color-primary)") + ("href" "/stacks/{{ post.stack }}") (text "{{ icon \"layers\" }}")) (text "{%- endif %} {% if community and community.is_forge -%} {% if post.is_open -%}") (span diff --git a/crates/app/src/public/html/stacks/feed.lisp b/crates/app/src/public/html/stacks/feed.lisp index 0317469..3997e9c 100644 --- a/crates/app/src/public/html/stacks/feed.lisp +++ b/crates/app/src/public/html/stacks/feed.lisp @@ -65,9 +65,11 @@ (text "{% for user in list %} {{ components::user_plate(user=user, secondary=true) }} {% endfor %}")) (text "{% else %}") ; user icons for circle stack - (text "{% if stack.mode == 'Circle' -%} {% for user in stack.users %}") - (text "{{ components::avatar(username=user, selector_type=\"id\", size=\"24px\") }}") - (text "{% endfor %} {%- endif %}") + (text "{% if stack.mode == 'Circle' -%}") + (div + ("class" "flex w-full gap-2 flex-wrap") + (text "{% for user in stack.users %} {{ components::avatar(username=user, selector_type=\"id\", size=\"24px\") }} {% endfor %}")) + (text "{%- endif %}") ; posts for all stacks except blocklist (text "{% for post in list %} From b7b84d15b760c9cd4408fc6afe7a5846e9194e99 Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 15 Jun 2025 19:19:41 -0400 Subject: [PATCH 09/75] add: style blockquotes --- crates/app/src/public/css/root.css | 6 ++++++ crates/app/src/public/html/stacks/feed.lisp | 7 ++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/app/src/public/css/root.css b/crates/app/src/public/css/root.css index 1614a5e..a0f95f5 100644 --- a/crates/app/src/public/css/root.css +++ b/crates/app/src/public/css/root.css @@ -344,3 +344,9 @@ img.emoji { height: 1em; aspect-ratio: 1 / 1; } + +blockquote { + padding-left: 1rem; + border-left: solid 5px var(--color-super-lowered); + font-style: italic; +} diff --git a/crates/app/src/public/html/stacks/feed.lisp b/crates/app/src/public/html/stacks/feed.lisp index 3997e9c..51f6546 100644 --- a/crates/app/src/public/html/stacks/feed.lisp +++ b/crates/app/src/public/html/stacks/feed.lisp @@ -68,7 +68,12 @@ (text "{% if stack.mode == 'Circle' -%}") (div ("class" "flex w-full gap-2 flex-wrap") - (text "{% for user in stack.users %} {{ components::avatar(username=user, selector_type=\"id\", size=\"24px\") }} {% endfor %}")) + (text "{% for user in stack.users %}") + (a + ("href" "/api/v1/auth/user/find/{{ user }}") + ("class" "flush") + (text "{{ components::avatar(username=user, selector_type=\"id\", size=\"24px\") }}")) + (text "{% endfor %}")) (text "{%- endif %}") ; posts for all stacks except blocklist From a43e586e4c4d3d1cdf775836e4406b7e193f58a3 Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 15 Jun 2025 19:26:52 -0400 Subject: [PATCH 10/75] fix: don't send comment notif if our profile is private and we aren't following post owner --- crates/core/src/database/posts.rs | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index 0d3f6dd..0e76fa1 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -1793,15 +1793,26 @@ impl DataManager { // send notification if data.owner != rt.owner { let owner = self.get_user_by_id(data.owner).await?; - self.create_notification(Notification::new( - "Your post has received a new comment!".to_string(), - format!( - "[@{}](/api/v1/auth/user/find/{}) has commented on your [post](/post/{}).", - owner.username, owner.id, rt.id - ), - rt.owner, - )) - .await?; + + // make sure we're actually following the person we're commenting to + // we shouldn't send the notif if we aren't, because they can't see it + // (only if our profile is private) + if !owner.settings.private_profile + | self + .get_userfollow_by_initiator_receiver(data.owner, rt.owner) + .await + .is_ok() + { + self.create_notification(Notification::new( + "Your post has received a new comment!".to_string(), + format!( + "[@{}](/api/v1/auth/user/find/{}) has commented on your [post](/post/{}).", + owner.username, owner.id, rt.id + ), + rt.owner, + )) + .await?; + } if !rt.context.comments_enabled { return Err(Error::NotAllowed); From 83c6df6f6e98372d578e020359dadfdc98bb5238 Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 15 Jun 2025 23:35:19 -0400 Subject: [PATCH 11/75] fix: use image/avif as default avatar mime fix: disable cross-origin iframes --- crates/app/Cargo.toml | 2 +- crates/app/src/main.rs | 12 ++- crates/app/src/routes/api/v1/auth/images.rs | 96 +++++++++++---------- 3 files changed, 61 insertions(+), 49 deletions(-) diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 706ee8d..41eec67 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -9,7 +9,7 @@ serde = { version = "1.0.219", features = ["derive"] } tera = "1.20.0" tracing = "0.1.41" tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } -tower-http = { version = "0.6.6", features = ["trace", "fs", "catch-panic"] } +tower-http = { version = "0.6.6", features = ["trace", "fs", "catch-panic", "set-header"] } axum = { version = "0.8.4", features = ["macros", "ws"] } tokio = { version = "1.45.1", features = ["macros", "rt-multi-thread"] } axum-extra = { version = "0.10.1", features = ["cookie", "multipart"] } diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index b4f188c..77dff46 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -11,12 +11,16 @@ use assets::{init_dirs, write_assets}; use tetratto_core::model::{permissions::FinePermission, uploads::CustomEmoji}; pub use tetratto_core::*; -use axum::{Extension, Router}; +use axum::{ + http::{HeaderName, HeaderValue}, + Extension, Router, +}; use reqwest::Client; use tera::{Tera, Value}; use tower_http::{ - trace::{self, TraceLayer}, catch_panic::CatchPanicLayer, + set_header::SetResponseHeaderLayer, + trace::{self, TraceLayer}, }; use tracing::{Level, info}; @@ -115,6 +119,10 @@ async fn main() { .make_span_with(trace::DefaultMakeSpan::new().level(Level::INFO)) .on_response(trace::DefaultOnResponse::new().level(Level::INFO)), ) + .layer(SetResponseHeaderLayer::if_not_present( + HeaderName::from_static("X-Frame-Options"), + HeaderValue::from_static("SAMEORIGIN"), + )) .layer(CatchPanicLayer::new()); let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", config.port)) diff --git a/crates/app/src/routes/api/v1/auth/images.rs b/crates/app/src/routes/api/v1/auth/images.rs index 9a67da8..e062be1 100644 --- a/crates/app/src/routes/api/v1/auth/images.rs +++ b/crates/app/src/routes/api/v1/auth/images.rs @@ -82,14 +82,16 @@ pub async fn avatar_request( } }; + let mime = if user.settings.avatar_mime.is_empty() { + "image/avif" + } else { + &user.settings.avatar_mime + }; + let path = PathBufD::current().extend(&[ data.0.0.dirs.media.as_str(), "avatars", - &format!( - "{}.{}", - &(user.id as i64), - user.settings.avatar_mime.replace("image/", "") - ), + &format!("{}.{}", &(user.id as i64), mime.replace("image/", "")), ]); if !exists(&path).unwrap() { @@ -104,10 +106,7 @@ pub async fn avatar_request( } Ok(( - [( - "Content-Type".to_string(), - user.settings.avatar_mime.clone(), - )], + [("Content-Type".to_string(), mime.to_owned())], Body::from(read_image(path)), )) } @@ -134,14 +133,16 @@ pub async fn banner_request( } }; + let mime = if user.settings.banner_mime.is_empty() { + "image/avif" + } else { + &user.settings.banner_mime + }; + let path = PathBufD::current().extend(&[ data.0.0.dirs.media.as_str(), "banners", - &format!( - "{}.{}", - &(user.id as i64), - user.settings.banner_mime.replace("image/", "") - ), + &format!("{}.{}", &(user.id as i64), mime.replace("image/", "")), ]); if !exists(&path).unwrap() { @@ -156,10 +157,7 @@ pub async fn banner_request( } Ok(( - [( - "Content-Type".to_string(), - user.settings.banner_mime.clone(), - )], + [("Content-Type".to_string(), mime.to_owned())], Body::from(read_image(path)), )) } @@ -211,15 +209,6 @@ pub async fn upload_avatar_request( mime.replace("image/", "") ); - // update user settings - auth_user.settings.avatar_mime = mime.to_string(); - if let Err(e) = data - .update_user_settings(auth_user.id, auth_user.settings) - .await - { - return Json(e.into()); - } - // upload image (gif) if mime == "image/gif" { // gif image, don't encode @@ -256,11 +245,23 @@ pub async fn upload_avatar_request( image::ImageFormat::Avif }, ) { - Ok(_) => Json(ApiReturn { - ok: true, - message: "Avatar uploaded. It might take a bit to update".to_string(), - payload: (), - }), + Ok(_) => { + // update user settings + auth_user.settings.avatar_mime = mime.to_string(); + if let Err(e) = data + .update_user_settings(auth_user.id, auth_user.settings) + .await + { + return Json(e.into()); + } + + // ... + Json(ApiReturn { + ok: true, + message: "Avatar uploaded. It might take a bit to update".to_string(), + payload: (), + }) + } Err(e) => Json(Error::MiscError(e.to_string()).into()), } } @@ -309,15 +310,6 @@ pub async fn upload_banner_request( mime.replace("image/", "") ); - // update user settings - auth_user.settings.banner_mime = mime.to_string(); - if let Err(e) = data - .update_user_settings(auth_user.id, auth_user.settings) - .await - { - return Json(e.into()); - } - // upload image (gif) if mime == "image/gif" { // gif image, don't encode @@ -354,11 +346,23 @@ pub async fn upload_banner_request( image::ImageFormat::Avif }, ) { - Ok(_) => Json(ApiReturn { - ok: true, - message: "Banner uploaded. It might take a bit to update".to_string(), - payload: (), - }), + Ok(_) => { + // update user settings + auth_user.settings.banner_mime = mime.to_string(); + if let Err(e) = data + .update_user_settings(auth_user.id, auth_user.settings) + .await + { + return Json(e.into()); + } + + // ... + Json(ApiReturn { + ok: true, + message: "Banner uploaded. It might take a bit to update".to_string(), + payload: (), + }) + } Err(e) => Json(Error::MiscError(e.to_string()).into()), } } From dd8e6561e6a44faebd564aeb51eb834431bd69b7 Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 15 Jun 2025 23:40:36 -0400 Subject: [PATCH 12/75] fix: disable setreponseheaderlayer there appears to be a bug in it possibly --- crates/app/src/main.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 77dff46..09ea802 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -119,10 +119,10 @@ async fn main() { .make_span_with(trace::DefaultMakeSpan::new().level(Level::INFO)) .on_response(trace::DefaultOnResponse::new().level(Level::INFO)), ) - .layer(SetResponseHeaderLayer::if_not_present( - HeaderName::from_static("X-Frame-Options"), - HeaderValue::from_static("SAMEORIGIN"), - )) + // .layer(SetResponseHeaderLayer::if_not_present( + // HeaderName::from_static("X-Frame-Options"), + // HeaderValue::from_static("SAMEORIGIN"), + // )) .layer(CatchPanicLayer::new()); let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", config.port)) From 844e60df3037ebc462f9c00299e6b3f1781be4ac Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 15 Jun 2025 23:52:33 -0400 Subject: [PATCH 13/75] add: serve csp through header --- crates/app/src/main.rs | 8 ++++---- crates/app/src/public/html/root.lisp | 1 - 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 09ea802..152cde1 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -119,10 +119,10 @@ async fn main() { .make_span_with(trace::DefaultMakeSpan::new().level(Level::INFO)) .on_response(trace::DefaultOnResponse::new().level(Level::INFO)), ) - // .layer(SetResponseHeaderLayer::if_not_present( - // HeaderName::from_static("X-Frame-Options"), - // HeaderValue::from_static("SAMEORIGIN"), - // )) + .layer(SetResponseHeaderLayer::if_not_present( + HeaderName::from_static("content-security-policy"), + HeaderValue::from_static("default-src 'self' blob: *.spotify.com musicbrainz.org; frame-ancestors 'self'; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' blob: *; script-src 'self' 'unsafe-inline' blob: *; object-src 'self' blob: *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' blob: data: *"), + )) .layer(CatchPanicLayer::new()); let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", config.port)) diff --git a/crates/app/src/public/html/root.lisp b/crates/app/src/public/html/root.lisp index c7867b1..356e86a 100644 --- a/crates/app/src/public/html/root.lisp +++ b/crates/app/src/public/html/root.lisp @@ -7,7 +7,6 @@ (meta ("charset" "UTF-8")) (meta ("name" "viewport") ("content" "width=device-width, initial-scale=1.0")) (meta ("http-equiv" "X-UA-Compatible") ("content" "ie=edge")) - (meta ("http-equiv" "content-security-policy") ("content" "default-src 'self' blob: *.spotify.com musicbrainz.org; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' blob: *; script-src 'self' 'unsafe-inline' blob: *; object-src 'self' blob: *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' blob: data: *")) (link ("rel" "icon") ("href" "/public/favicon.svg")) (link ("rel" "stylesheet") ("href" "/css/style.css")) From a6aa2488c427605be076b0c08d7dfc01c60d25a5 Mon Sep 17 00:00:00 2001 From: trisua Date: Mon, 16 Jun 2025 18:32:22 -0400 Subject: [PATCH 14/75] add: hide simple reposts you cannot view quotes still show "Could not find original post..." when you cannot view the post that was quoted --- crates/app/src/public/html/body.lisp | 4 ++ crates/app/src/public/html/components.lisp | 2 +- crates/core/src/database/posts.rs | 81 ++++++++++++++++------ 3 files changed, 66 insertions(+), 21 deletions(-) diff --git a/crates/app/src/public/html/body.lisp b/crates/app/src/public/html/body.lisp index 3b51c42..6991899 100644 --- a/crates/app/src/public/html/body.lisp +++ b/crates/app/src/public/html/body.lisp @@ -53,6 +53,10 @@ console.log(\"socket disconnect\"); } } + + if (window.location.pathname.startsWith(\"/reference\")) { + window.location.reload(); + } }); {%- endif %}") diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 284ee21..154621c 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -330,7 +330,7 @@ ("class" "title") (text "{{ text \"general:label.share\" }}")) (button - ("onclick" "trigger('me::repost', ['{{ post.id }}', '', '{{ config.town_square }}'])") + ("onclick" "trigger('me::repost', ['{{ post.id }}', '', '{{ config.town_square }}', true])") (text "{{ icon \"repeat-2\" }}") (span (text "{{ text \"communities:label.repost\" }}"))) diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index 0e76fa1..a02d2d4 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -158,26 +158,26 @@ impl DataManager { post: &Post, ignore_users: &[usize], user: &Option, - ) -> Option<(User, Post)> { + ) -> (bool, Option<(User, Post)>) { if let Some(ref repost) = post.context.repost { if let Some(reposting) = repost.reposting { let mut x = match self.get_post_by_id(reposting).await { Ok(p) => p, - Err(_) => return None, + Err(_) => return (true, None), }; if x.is_deleted { - return None; + return (!post.content.is_empty(), None); } if ignore_users.contains(&x.owner) { - return None; + return (!post.content.is_empty(), None); } // check private profile settings let owner = match self.get_user_by_id(x.owner).await { Ok(ua) => ua, - Err(_) => return None, + Err(_) => return (true, None), }; // TODO: maybe check community membership to see if we can MANAGE_POSTS in community @@ -191,29 +191,32 @@ impl DataManager { .is_err() { // owner isn't following us, we aren't the owner, AND we don't have MANAGE_POSTS permission - return None; + return (!post.content.is_empty(), None); } } } else { // private profile, but we're an unauthenticated user - return None; + return (!post.content.is_empty(), None); } } // ... x.mark_as_repost(); - Some(( - match self.get_user_by_id(x.owner).await { - Ok(ua) => ua, - Err(_) => return None, - }, - x, - )) + ( + true, + Some(( + match self.get_user_by_id(x.owner).await { + Ok(ua) => ua, + Err(_) => return (true, None), + }, + x, + )), + ) } else { - None + (true, None) } } else { - None + (true, None) } } @@ -340,6 +343,7 @@ impl DataManager { let owner = post.owner; if let Some(ua) = users.get(&owner) { + // stack let (can_view, stack) = self .get_post_stack( &mut seen_stacks, @@ -352,10 +356,19 @@ impl DataManager { continue; } + // reposting + let (can_view, reposting) = + self.get_post_reposting(&post, ignore_users, user).await; + + if !can_view { + continue; + } + + // ... out.push(( post.clone(), ua.clone(), - self.get_post_reposting(&post, ignore_users, user).await, + reposting, self.get_post_question(&post, ignore_users).await?, self.get_post_poll(&post, user).await?, stack, @@ -406,6 +419,7 @@ impl DataManager { } } + // stack let (can_view, stack) = self .get_post_stack( &mut seen_stacks, @@ -418,12 +432,20 @@ impl DataManager { continue; } + // reposting + let (can_view, reposting) = + self.get_post_reposting(&post, ignore_users, user).await; + + if !can_view { + continue; + } + // ... users.insert(owner, ua.clone()); out.push(( post.clone(), ua, - self.get_post_reposting(&post, ignore_users, user).await, + reposting, self.get_post_question(&post, ignore_users).await?, self.get_post_poll(&post, user).await?, stack, @@ -458,6 +480,7 @@ impl DataManager { let community = post.community; if let Some((ua, community)) = seen_before.get(&(owner, community)) { + // stack let (can_view, stack) = self .get_post_stack( &mut seen_stacks, @@ -470,11 +493,20 @@ impl DataManager { continue; } + // reposting + let (can_view, reposting) = + self.get_post_reposting(&post, ignore_users, user).await; + + if !can_view { + continue; + } + + // ... out.push(( post.clone(), ua.clone(), community.to_owned(), - self.get_post_reposting(&post, ignore_users, user).await, + reposting, self.get_post_question(&post, ignore_users).await?, self.get_post_poll(&post, user).await?, stack, @@ -516,6 +548,7 @@ impl DataManager { } } + // stack let (can_view, stack) = self .get_post_stack( &mut seen_stacks, @@ -528,6 +561,14 @@ impl DataManager { continue; } + // reposting + let (can_view, reposting) = + self.get_post_reposting(&post, ignore_users, user).await; + + if !can_view { + continue; + } + // ... let community = self.get_community_by_id(community).await?; seen_before.insert((owner, community.id), (ua.clone(), community.clone())); @@ -535,7 +576,7 @@ impl DataManager { post.clone(), ua, community, - self.get_post_reposting(&post, ignore_users, user).await, + reposting, self.get_post_question(&post, ignore_users).await?, self.get_post_poll(&post, user).await?, stack, From c55d8bd38b43cce6c7b3b71f473fe907662b99e7 Mon Sep 17 00:00:00 2001 From: trisua Date: Mon, 16 Jun 2025 19:08:40 -0400 Subject: [PATCH 15/75] fix: post page reposting --- crates/app/src/routes/pages/communities.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/app/src/routes/pages/communities.rs b/crates/app/src/routes/pages/communities.rs index 556728e..626675a 100644 --- a/crates/app/src/routes/pages/communities.rs +++ b/crates/app/src/routes/pages/communities.rs @@ -752,7 +752,7 @@ pub async fn post_request( } // check repost - let reposting = data.0.get_post_reposting(&post, &ignore_users, &user).await; + let (_, reposting) = data.0.get_post_reposting(&post, &ignore_users, &user).await; // check question let question = match data.0.get_post_question(&post, &ignore_users).await { From 822aaed0c8f995818b899d7d87e3fac40e730cec Mon Sep 17 00:00:00 2001 From: trisua Date: Mon, 16 Jun 2025 19:50:10 -0400 Subject: [PATCH 16/75] add: increase image proxy limit for supporters --- .../app/src/public/html/profile/settings.lisp | 4 +++- crates/app/src/routes/api/v1/util.rs | 21 ++++++++++++++++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index a89286f..07d24a1 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -583,7 +583,9 @@ (li (text "Create up to 10 stack blocks")) (li - (text "Add unlimited users to stacks"))) + (text "Add unlimited users to stacks")) + (li + (text "Increased proxied image size"))) (a ("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}") ("class" "button") diff --git a/crates/app/src/routes/api/v1/util.rs b/crates/app/src/routes/api/v1/util.rs index f76060a..8714968 100644 --- a/crates/app/src/routes/api/v1/util.rs +++ b/crates/app/src/routes/api/v1/util.rs @@ -1,5 +1,5 @@ use super::auth::images::read_image; -use crate::State; +use crate::{get_user_from_token, State}; use axum::{ body::Body, extract::Query, @@ -7,10 +7,13 @@ use axum::{ response::IntoResponse, Extension, }; +use axum_extra::extract::CookieJar; use pathbufd::PathBufD; use serde::Deserialize; +use tetratto_core::model::permissions::FinePermission; -pub const MAXIMUM_PROXY_FILE_SIZE: u64 = 4194304; // 4 MiB +pub const MAXIMUM_PROXY_FILE_SIZE: u64 = 4_194_304; // 4 MiB +pub const MAXIMUM_SUPPORTER_PROXY_FILE_SIZE: u64 = 10_485_760; // 4 MiB #[derive(Deserialize)] pub struct ProxyQuery { @@ -19,10 +22,22 @@ pub struct ProxyQuery { /// Proxy an external url pub async fn proxy_request( + jar: CookieJar, Query(props): Query, Extension(data): Extension, ) -> impl IntoResponse { let data = &(data.read().await); + let user = get_user_from_token!(jar, data.0); + let maximum_size = if let Some(ref ua) = user { + if ua.permissions.check(FinePermission::SUPPORTER) { + MAXIMUM_SUPPORTER_PROXY_FILE_SIZE + } else { + MAXIMUM_PROXY_FILE_SIZE + } + } else { + MAXIMUM_PROXY_FILE_SIZE + }; + let http = &data.2; let data = &data.0.0; @@ -60,7 +75,7 @@ pub async fn proxy_request( match http.get(image_url).send().await { Ok(stream) => { let size = stream.content_length(); - if size.unwrap_or_default() > MAXIMUM_PROXY_FILE_SIZE { + if size.unwrap_or_default() > maximum_size { // return defualt image (content too big) return ( [("Content-Type", "image/svg+xml")], From 2b253c811cf94accc9fb058f4091f71513a2b990 Mon Sep 17 00:00:00 2001 From: trisua Date: Tue, 17 Jun 2025 01:52:17 -0400 Subject: [PATCH 17/75] add: infinitely scrolling timelines --- crates/app/src/assets.rs | 2 + crates/app/src/public/html/components.lisp | 5 +- crates/app/src/public/html/root.lisp | 1 + crates/app/src/public/html/timelines/all.lisp | 9 +- .../src/public/html/timelines/following.lisp | 9 +- .../app/src/public/html/timelines/home.lisp | 9 +- .../src/public/html/timelines/popular.lisp | 9 +- .../src/public/html/timelines/swiss_army.lisp | 29 +++++ crates/app/src/public/js/atto.js | 121 ++++++++++++++++++ crates/app/src/routes/api/v1/uploads.rs | 15 ++- crates/app/src/routes/pages/misc.rs | 112 +++++++++++++++- crates/app/src/routes/pages/mod.rs | 4 + 12 files changed, 316 insertions(+), 9 deletions(-) create mode 100644 crates/app/src/public/html/timelines/swiss_army.lisp diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index 03e8db3..bf2a64c 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -97,6 +97,7 @@ pub const TIMELINES_FOLLOWING_QUESTIONS: &str = pub const TIMELINES_ALL_QUESTIONS: &str = include_str!("./public/html/timelines/all_questions.lisp"); pub const TIMELINES_SEARCH: &str = include_str!("./public/html/timelines/search.lisp"); +pub const TIMELINES_SWISS_ARMY: &str = include_str!("./public/html/timelines/swiss_army.lisp"); pub const MOD_AUDIT_LOG: &str = include_str!("./public/html/mod/audit_log.lisp"); pub const MOD_REPORTS: &str = include_str!("./public/html/mod/reports.lisp"); @@ -385,6 +386,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"timelines/following_questions.html"(crate::assets::TIMELINES_FOLLOWING_QUESTIONS) --config=config --lisp plugins); write_template!(html_path->"timelines/all_questions.html"(crate::assets::TIMELINES_ALL_QUESTIONS) --config=config --lisp plugins); write_template!(html_path->"timelines/search.html"(crate::assets::TIMELINES_SEARCH) --config=config --lisp plugins); + write_template!(html_path->"timelines/swiss_army.html"(crate::assets::TIMELINES_SWISS_ARMY) --config=config --lisp plugins); write_template!(html_path->"mod/audit_log.html"(crate::assets::MOD_AUDIT_LOG) -d "mod" --config=config --lisp plugins); write_template!(html_path->"mod/reports.html"(crate::assets::MOD_REPORTS) --config=config --lisp plugins); diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 154621c..b604f2c 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -117,7 +117,7 @@ (text "{{ self::post(post=post, owner=owner, secondary=secondary, community=community, show_community=show_community, can_manage_post=can_manage_post, repost=repost, expect_repost=true) }}")) (text "{%- endmacro %} {% macro post(post, owner, question=false, secondary=false, community=false, show_community=true, can_manage_post=false, repost=false, expect_repost=false, poll=false, dont_show_title=false) -%} {% if community and show_community and community.id != config.town_square or question %}") (div - ("class" "card-nest") + ("class" "card-nest post_outer:{{ post.id }}") (text "{% if question -%} {{ self::question(question=question[0], owner=question[1], profile=owner) }} {% else %}") (div ("class" "card small") @@ -130,8 +130,7 @@ (text "{% if post.context.is_pinned or post.context.is_profile_pinned -%} {{ icon \"pin\" }} {%- endif %}"))) (text "{%- endif %} {%- endif %}") (div - ("class" "card flex flex-col gap-2 {% if secondary -%}secondary{%- endif %}") - ("id" "post:{{ post.id }}") + ("class" "card flex flex-col gap-2 post:{{ post.id }} {% if secondary -%}secondary{%- endif %}") ("data-community" "{{ post.community }}") ("data-ownsup" "{{ owner.permissions|has_supporter }}") ("hook" "verify_emojis") diff --git a/crates/app/src/public/html/root.lisp b/crates/app/src/public/html/root.lisp index 356e86a..a7cfb4a 100644 --- a/crates/app/src/public/html/root.lisp +++ b/crates/app/src/public/html/root.lisp @@ -35,6 +35,7 @@ }; globalThis.no_policy = false; + globalThis.BUILD_CODE = \"{{ random_cache_breaker }}\"; ") (script ("src" "/js/loader.js" )) diff --git a/crates/app/src/public/html/timelines/all.lisp b/crates/app/src/public/html/timelines/all.lisp index ab3e688..9434aab 100644 --- a/crates/app/src/public/html/timelines/all.lisp +++ b/crates/app/src/public/html/timelines/all.lisp @@ -30,6 +30,13 @@ (text "{%- endif %}") (div ("class" "card w-full flex flex-col gap-2") - (text "{% for post in list %} {% 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) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], poll=post[5]) }} {%- endif %} {%- endif %} {% endfor %} {{ components::pagination(page=page, items=list|length) }}"))) + ("ui_ident" "io_data_load") + (text "{% for post in list %} {% 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) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], poll=post[5]) }} {%- endif %} {%- endif %} {% endfor %}") + (div ("ui_ident" "io_data_marker")))) + +(script + (text "setTimeout(() => { + trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=AllPosts&page=\", Number.parseInt(\"{{ page }}\")]); + });")) (text "{% endblock %}") diff --git a/crates/app/src/public/html/timelines/following.lisp b/crates/app/src/public/html/timelines/following.lisp index 642ab63..b36d889 100644 --- a/crates/app/src/public/html/timelines/following.lisp +++ b/crates/app/src/public/html/timelines/following.lisp @@ -8,6 +8,13 @@ (text "{{ macros::timelines_nav(selected=\"following\", posts=\"/following\", questions=\"/following/questions\") }}") (div ("class" "card w-full flex flex-col gap-2") - (text "{% for post in list %} {% 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) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], poll=post[5]) }} {%- endif %} {%- endif %} {% endfor %} {{ components::pagination(page=page, items=list|length) }}"))) + ("ui_ident" "io_data_load") + (text "{% for post in list %} {% 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) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], poll=post[5]) }} {%- endif %} {%- endif %} {% endfor %}") + (div ("ui_ident" "io_data_marker")))) + +(script + (text "setTimeout(() => { + trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=FollowingPosts&page=\", Number.parseInt(\"{{ page }}\")]); + });")) (text "{% endblock %}") diff --git a/crates/app/src/public/html/timelines/home.lisp b/crates/app/src/public/html/timelines/home.lisp index 4d1ce9d..2705641 100644 --- a/crates/app/src/public/html/timelines/home.lisp +++ b/crates/app/src/public/html/timelines/home.lisp @@ -27,7 +27,14 @@ (text "{% else %}") (div ("class" "card w-full flex flex-col gap-2") - (text "{% for post in list %} {% 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) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], poll=post[5]) }} {%- endif %} {% endfor %} {{ components::pagination(page=page, items=list|length) }}")) + ("ui_ident" "io_data_load") + (text "{% for post in list %} {% 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) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], poll=post[5]) }} {%- endif %} {% endfor %}") + (div ("ui_ident" "io_data_marker"))) (text "{%- endif %}")) +(script + (text "setTimeout(() => { + trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=MyCommunities&page=\", Number.parseInt(\"{{ page }}\")]); + });")) + (text "{% endblock %}") diff --git a/crates/app/src/public/html/timelines/popular.lisp b/crates/app/src/public/html/timelines/popular.lisp index dfaef71..85ed6f4 100644 --- a/crates/app/src/public/html/timelines/popular.lisp +++ b/crates/app/src/public/html/timelines/popular.lisp @@ -8,6 +8,13 @@ (text "{{ macros::timelines_nav(selected=\"popular\", posts=\"/popular\", questions=\"/popular/questions\") }}") (div ("class" "card w-full flex flex-col gap-2") - (text "{% for post in list %} {% 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) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], poll=post[5]) }} {%- endif %} {%- endif %} {% endfor %} {{ components::pagination(page=page, items=list|length) }}"))) + ("ui_ident" "io_data_load") + (text "{% for post in list %} {% 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) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], poll=post[5]) }} {%- endif %} {%- endif %} {% endfor %}") + (div ("ui_ident" "io_data_marker")))) + +(script + (text "setTimeout(() => { + trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=PopularPosts&page=\", Number.parseInt(\"{{ page }}\")]); + });")) (text "{% endblock %}") diff --git a/crates/app/src/public/html/timelines/swiss_army.lisp b/crates/app/src/public/html/timelines/swiss_army.lisp new file mode 100644 index 0000000..c8734bc --- /dev/null +++ b/crates/app/src/public/html/timelines/swiss_army.lisp @@ -0,0 +1,29 @@ +(text "{%- import \"components.html\" as components -%} {%- import \"macros.html\" as macros -%}") +(text "{% for post in list %} + {% 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) }} + {% else %} + {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], poll=post[5]) }} + {%- endif %} + {%- endif %} + {% endfor %}") +(datalist + ("ui_ident" "list_posts_{{ page }}") + (text "{% for post in list -%}") + (option ("value" "{{ post[0].id }}")) + (text "{%- endfor %}")) +(text "{% if list|length == 0 -%}") +(div + ("class" "card lowered green flex justify-between items-center gap-2") + (div + ("class" "flex items-center gap-2") + (text "{{ icon \"shell\" }}") + (span + (text "That's a wrap!"))) + (a + ("class" "button") + ("href" "?page=0") + (icon (text "arrow-up")) + (str (text "chats:label.go_back")))) +(text "{%- endif %}") diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js index 28cd3fc..9dd1c0e 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -1119,6 +1119,127 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} document.getElementById("lightbox").classList.add("hidden"); }, 250); }); + + // intersection observer infinite scrolling + self.IO_DATA_OBSERVER = new IntersectionObserver( + async (entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) { + continue; + } + + await self.io_load_data(); + break; + } + }, + { + root: document.body, + rootMargin: "0px", + threshold: 1, + }, + ); + + self.define("io_data_load", (_, tmpl, page) => { + self.IO_DATA_MARKER = document.querySelector( + "[ui_ident=io_data_marker]", + ); + + self.IO_DATA_ELEMENT = document.querySelector( + "[ui_ident=io_data_load]", + ); + + if (!self.IO_DATA_ELEMENT || !self.IO_DATA_MARKER) { + console.warn( + "ui::io_data_load called, but required elements don't exist", + ); + + return; + } + + self.IO_DATA_TMPL = tmpl; + self.IO_DATA_PAGE = page; + self.IO_DATA_SEEN_IDS = []; + + self.IO_DATA_OBSERVER.observe(self.IO_DATA_MARKER); + }); + + self.define("io_load_data", async () => { + self.IO_DATA_PAGE += 1; + console.log("load page", self.IO_DATA_PAGE); + + const text = await ( + await fetch(`${self.IO_DATA_TMPL}${self.IO_DATA_PAGE}`) + ).text(); + + if ( + text.includes( + `That's a wrap!`, + ) + ) { + console.log("io_data_end; disconnect"); + self.IO_DATA_OBSERVER.disconnect(); + self.IO_DATA_ELEMENT.innerHTML += text; + return; + } + + self.IO_DATA_ELEMENT.innerHTML += text; + + setTimeout(() => { + // move marker to bottom of dom hierarchy + self.IO_DATA_ELEMENT.children[ + self.IO_DATA_ELEMENT.children.length - 1 + ].after(self.IO_DATA_MARKER); + + // remove posts we've already seen + function remove_elements(id, outer = false) { + let idx = 0; + for (const element of Array.from( + document.querySelectorAll( + `.post${outer ? "_outer" : ""}\\:${id}`, + ), + )) { + if (idx === 0) { + idx += 1; + continue; + } + + // everything that isn't the first element should be removed + element.remove(); + console.log("removed duplicate post"); + } + } + + for (const id of self.IO_DATA_SEEN_IDS) { + remove_elements(id, false); + remove_elements(id, true); // scoop up questions + } + + // push ids + for (const opt of Array.from( + document.querySelectorAll( + `[ui_ident=list_posts_${self.IO_DATA_PAGE}] option`, + ), + )) { + const v = opt.getAttribute("value"); + + if (!self.IO_DATA_SEEN_IDS[v]) { + self.IO_DATA_SEEN_IDS.push(v); + } + } + }, 150); + + // run hooks + const atto = ns("atto"); + + atto.clean_date_codes(); + atto.clean_poll_date_codes(); + atto.link_filter(); + + atto["hooks::long_text.init"](); + atto["hooks::alt"](); + atto["hooks::online_indicator"](); + atto["hooks::verify_emoji"](); + }); })(); (() => { diff --git a/crates/app/src/routes/api/v1/uploads.rs b/crates/app/src/routes/api/v1/uploads.rs index c90c427..8a6a8bb 100644 --- a/crates/app/src/routes/api/v1/uploads.rs +++ b/crates/app/src/routes/api/v1/uploads.rs @@ -12,7 +12,20 @@ pub async fn get_request( ) -> impl IntoResponse { let data = &(data.read().await).0; - let upload = data.get_upload_by_id(id).await.unwrap(); + let upload = match data.get_upload_by_id(id).await { + Ok(u) => u, + Err(_) => { + return Err(( + [("Content-Type", "image/svg+xml")], + Body::from(read_image(PathBufD::current().extend(&[ + data.0.0.dirs.media.as_str(), + "images", + "default-avatar.svg", + ]))), + )); + } + }; + let path = upload.path(&data.0.0); if !exists(&path).unwrap() { diff --git a/crates/app/src/routes/pages/misc.rs b/crates/app/src/routes/pages/misc.rs index 122d82b..3a6c9f9 100644 --- a/crates/app/src/routes/pages/misc.rs +++ b/crates/app/src/routes/pages/misc.rs @@ -9,7 +9,9 @@ use axum::{ }; use axum_extra::extract::CookieJar; use serde::Deserialize; -use tetratto_core::model::{permissions::FinePermission, requests::ActionType, Error}; +use tetratto_core::model::{ + auth::DefaultTimelineChoice, permissions::FinePermission, requests::ActionType, Error, +}; use std::fs::read_to_string; use pathbufd::PathBufD; @@ -649,3 +651,111 @@ pub async fn search_request( data.1.render("timelines/search.html", &context).unwrap(), )) } + +#[derive(Deserialize)] +pub struct TimelineQuery { + pub tl: DefaultTimelineChoice, + pub page: usize, +} + +/// `/_swiss_army_timeline` +pub async fn swiss_army_timeline_request( + jar: CookieJar, + Extension(data): Extension, + Query(req): Query, +) -> impl IntoResponse { + let data = data.read().await; + let user = get_user_from_token!(jar, data.0); + + let ignore_users = crate::ignore_users_gen!(user, data); + + let list = match match req.tl { + DefaultTimelineChoice::AllPosts => data.0.get_latest_posts(12, req.page).await, + DefaultTimelineChoice::PopularPosts => { + data.0.get_popular_posts(12, req.page, 604_800_000).await + } + DefaultTimelineChoice::FollowingPosts => { + if let Some(ref ua) = user { + data.0 + .get_posts_from_user_following(ua.id, 12, req.page) + .await + } else { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &user).await, + )); + } + } + DefaultTimelineChoice::MyCommunities => { + if let Some(ref ua) = user { + data.0 + .get_posts_from_user_communities(ua.id, 12, req.page) + .await + } else { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &user).await, + )); + } + } + DefaultTimelineChoice::Stack(ref s) => { + data.0 + .get_posts_by_stack( + match s.parse::() { + Ok(s) => s, + Err(_) => { + return Err(Html( + render_error( + Error::MiscError("ID deserialization error".to_string()), + &jar, + &data, + &user, + ) + .await, + )); + } + }, + 12, + req.page, + ) + .await + } + // questions bad + _ => { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &user).await, + )); + } + } { + Ok(l) => match data + .0 + .fill_posts_with_community( + l, + if let Some(ref ua) = user { ua.id } else { 0 }, + &ignore_users, + &user, + ) + .await + { + Ok(l) => data.0.posts_muted_phrase_filter( + &l, + if let Some(ref ua) = user { + Some(&ua.settings.muted) + } else { + None + }, + ), + Err(e) => return Ok(Html(render_error(e, &jar, &data, &user).await)), + }, + Err(e) => return Ok(Html(render_error(e, &jar, &data, &user).await)), + }; + + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0.0, lang, &user).await; + + context.insert("list", &list); + context.insert("page", &req.page); + Ok(Html( + data.1 + .render("timelines/swiss_army.html", &context) + .unwrap(), + )) +} diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index 556b468..2bb9ebf 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -29,6 +29,10 @@ pub fn routes() -> Router { .route("/following", get(misc::following_request)) .route("/all", get(misc::all_request)) .route("/search", get(misc::search_request)) + .route( + "/_swiss_army_timeline", + get(misc::swiss_army_timeline_request), + ) // question timelines .route("/questions", get(misc::index_questions_request)) .route("/popular/questions", get(misc::popular_questions_request)) From 3027b679dbeeb768ded65d7fafebbaa688f83ad6 Mon Sep 17 00:00:00 2001 From: trisua Date: Tue, 17 Jun 2025 14:28:18 -0400 Subject: [PATCH 18/75] add: expand infinite scrolling to stacks and profiles --- crates/app/src/langs/en-US.toml | 3 + crates/app/src/public/css/root.css | 55 +++- crates/app/src/public/css/style.css | 5 +- crates/app/src/public/html/body.lisp | 18 ++ crates/app/src/public/html/components.lisp | 2 +- crates/app/src/public/html/profile/posts.lisp | 12 +- crates/app/src/public/html/stacks/feed.lisp | 23 +- crates/app/src/public/html/timelines/all.lisp | 3 +- .../src/public/html/timelines/following.lisp | 3 +- .../app/src/public/html/timelines/home.lisp | 3 +- .../src/public/html/timelines/popular.lisp | 3 +- .../src/public/html/timelines/swiss_army.lisp | 7 +- crates/app/src/public/js/atto.js | 35 ++- crates/app/src/routes/pages/misc.rs | 256 +++++++----------- crates/app/src/routes/pages/profile.rs | 67 +---- crates/app/src/routes/pages/stacks.rs | 19 -- 16 files changed, 226 insertions(+), 288 deletions(-) diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index 270feec..76e0490 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -38,6 +38,9 @@ version = "1.0.0" "general:label.account_banned" = "Account banned" "general:label.account_banned_body" = "Your account has been banned for violating our policies." "general:label.better_with_account" = "It's better with an account! Login or sign up to explore more." +"general:label.could_not_find_post" = "Could not find original post..." +"general:label.timeline_end" = "That's a wrap!" +"general:label.loading" = "Working on it!" "general:label.supporter_motivation" = "Become a supporter!" "general:action.become_supporter" = "Become supporter" diff --git a/crates/app/src/public/css/root.css b/crates/app/src/public/css/root.css index a0f95f5..41db0d5 100644 --- a/crates/app/src/public/css/root.css +++ b/crates/app/src/public/css/root.css @@ -213,6 +213,14 @@ ol { margin-left: var(--pad-4); } +pre { + padding: var(--pad-4); +} + +code { + padding: var(--pad-1); +} + pre, code { font-family: "Jetbrains Mono", "Fire Code", monospace; @@ -221,18 +229,12 @@ code { overflow: auto; background: var(--color-lowered); border-radius: var(--radius); - padding: var(--pad-1); font-size: 0.8rem; } -pre { - padding: var(--pad-4); -} - svg.icon { stroke: currentColor; width: 18px; - width: 1em; height: 1em; } @@ -263,7 +265,6 @@ code { overflow-wrap: normal; text-wrap: pretty; word-wrap: break-word; - overflow-wrap: anywhere; } h1, @@ -275,7 +276,6 @@ h6 { margin: 0; font-weight: 700; width: -moz-max-content; - width: max-content; position: relative; max-width: 100%; } @@ -350,3 +350,42 @@ blockquote { border-left: solid 5px var(--color-super-lowered); font-style: italic; } + +.skel { + display: block; + border-radius: var(--radius); + background: var(--color-raised); + animation: skel ease-in-out infinite 2s forwards running; + transition: opacity 0.15s; +} + +@keyframes skel { + from { + background: var(--color-raised); + } + + 50% { + background: var(--color-lowered); + } + + to { + background: var(--color-raised); + } +} + +.loader { + animation: spin linear infinite 2s forwards running; + display: flex; + justify-content: center; + align-items: center; +} + +@keyframes spin { + from { + transform: rotateZ(0deg); + } + + to { + transform: rotateZ(360deg); + } +} diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css index fef2659..8e9bbce 100644 --- a/crates/app/src/public/css/style.css +++ b/crates/app/src/public/css/style.css @@ -565,11 +565,9 @@ select:focus { nav { background: var(--color-primary); color: var(--color-text-primary) !important; - color: inherit; width: 100%; display: flex; justify-content: space-between; - color: var(--color-text); position: sticky; top: 0; z-index: 6374; @@ -722,13 +720,12 @@ dialog { position: fixed; bottom: 0; top: 0; - display: flex; + display: none; background: var(--color-surface); border: solid 1px var(--color-super-lowered) !important; border-radius: var(--radius); max-width: 100%; border-style: none; - display: none; margin: auto; color: var(--color-text); animation: popin ease-in-out 1 0.1s forwards running; diff --git a/crates/app/src/public/html/body.lisp b/crates/app/src/public/html/body.lisp index 6991899..16a47d8 100644 --- a/crates/app/src/public/html/body.lisp +++ b/crates/app/src/public/html/body.lisp @@ -1,5 +1,23 @@ (div ("id" "toast_zone")) +; templates +(template + ("id" "loading_skeleton") + (div + ("class" "flex flex-col gap-2") + ("ui_ident" "loading_skel") + (div + ("class" "card lowered green flex items-center gap-2") + (div ("class" "loader") (icon (text "loader-circle"))) + (span (str (text "general:label.loading")))) + (div + ("class" "card secondary flex gap-2") + (div ("class" "skel avatar")) + (div + ("class" "flex flex-col gap-2 w-full") + (div ("class" "skel") ("style" "width: 25%; height: 25px;")) + (div ("class" "skel") ("style" "width: 100%; height: 150px")))))) + ; random js (text "") - (script ("src" "/js/loader.js" )) - (script ("src" "/js/atto.js" )) + (script ("src" "/js/loader.js?v=tetratto-{{ random_cache_breaker }}" )) + (script ("src" "/js/atto.js?v=tetratto-{{ random_cache_breaker }}" )) (meta ("name" "theme-color") ("content" "{{ config.color }}")) (meta ("name" "description") ("content" "{{ config.description }}")) diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index 7e7a7f6..36bbdb7 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -36,6 +36,8 @@ impl DataManager { execute!(&conn, common::CREATE_TABLE_POLLVOTES).unwrap(); execute!(&conn, common::CREATE_TABLE_APPS).unwrap(); execute!(&conn, common::CREATE_TABLE_STACKBLOCKS).unwrap(); + execute!(&conn, common::CREATE_TABLE_JOURNALS).unwrap(); + execute!(&conn, common::CREATE_TABLE_NOTES).unwrap(); self.0 .1 diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs index 94cc123..64a9dfc 100644 --- a/crates/core/src/database/drivers/common.rs +++ b/crates/core/src/database/drivers/common.rs @@ -23,3 +23,5 @@ pub const CREATE_TABLE_POLLS: &str = include_str!("./sql/create_polls.sql"); pub const CREATE_TABLE_POLLVOTES: &str = include_str!("./sql/create_pollvotes.sql"); pub const CREATE_TABLE_APPS: &str = include_str!("./sql/create_apps.sql"); pub const CREATE_TABLE_STACKBLOCKS: &str = include_str!("./sql/create_stackblocks.sql"); +pub const CREATE_TABLE_JOURNALS: &str = include_str!("./sql/create_journals.sql"); +pub const CREATE_TABLE_NOTES: &str = include_str!("./sql/create_notes.sql"); diff --git a/crates/core/src/database/drivers/sql/create_journals.sql b/crates/core/src/database/drivers/sql/create_journals.sql new file mode 100644 index 0000000..01f49e5 --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_journals.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS channels ( + id BIGINT NOT NULL PRIMARY KEY, + created BIGINT NOT NULL, + owner BIGINT NOT NULL, + title TEXT NOT NULL, + view TEXT NOT NULL +) diff --git a/crates/core/src/database/drivers/sql/create_notes.sql b/crates/core/src/database/drivers/sql/create_notes.sql new file mode 100644 index 0000000..0ee4686 --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_notes.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS channels ( + id BIGINT NOT NULL PRIMARY KEY, + created BIGINT NOT NULL, + owner BIGINT NOT NULL, + title TEXT NOT NULL, + journal BIGINT NOT NULL, + content TEXT NOT NULL, + edited BIGINT NOT NULL +) diff --git a/crates/core/src/database/journals.rs b/crates/core/src/database/journals.rs new file mode 100644 index 0000000..0bc5ded --- /dev/null +++ b/crates/core/src/database/journals.rs @@ -0,0 +1,141 @@ +use oiseau::cache::Cache; +use crate::{ + model::{ + auth::User, + permissions::FinePermission, + journals::{Journal, JournalViewPermission}, + Error, Result, + }, +}; +use crate::{auto_method, DataManager}; +use oiseau::{PostgresRow, execute, get, query_rows, params}; + +impl DataManager { + /// Get a [`Journal`] from an SQL row. + pub(crate) fn get_journal_from_row(x: &PostgresRow) -> Journal { + Journal { + id: get!(x->0(i64)) as usize, + created: get!(x->1(i64)) as usize, + owner: get!(x->2(i64)) as usize, + title: get!(x->3(String)), + view: serde_json::from_str(&get!(x->4(String))).unwrap(), + } + } + + auto_method!(get_journal_by_id(usize as i64)@get_journal_from_row -> "SELECT * FROM journals WHERE id = $1" --name="journal" --returns=Journal --cache-key-tmpl="atto.journal:{}"); + + /// Get all journals by user. + /// + /// # Arguments + /// * `id` - the ID of the user to fetch journals for + pub async fn get_journals_by_user(&self, id: 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 journals WHERE owner = $1 ORDER BY name ASC", + &[&(id as i64)], + |x| { Self::get_journal_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("journal".to_string())); + } + + Ok(res.unwrap()) + } + + const MAXIMUM_FREE_JOURNALS: usize = 15; + + /// Create a new journal in the database. + /// + /// # Arguments + /// * `data` - a mock [`Journal`] object to insert + pub async fn create_journal(&self, data: Journal) -> Result { + // check values + if data.title.len() < 2 { + return Err(Error::DataTooShort("title".to_string())); + } else if data.title.len() > 32 { + return Err(Error::DataTooLong("title".to_string())); + } + + // check number of journals + let owner = self.get_user_by_id(data.owner).await?; + + if !owner.permissions.check(FinePermission::SUPPORTER) { + let journals = self.get_journals_by_user(data.owner).await?; + + if journals.len() >= Self::MAXIMUM_FREE_JOURNALS { + return Err(Error::MiscError( + "You already have the maximum number of journals you can have".to_string(), + )); + } + } + + // ... + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "INSERT INTO journals VALUES ($1, $2, $3, $4, $5)", + params![ + &(data.id as i64), + &(data.created as i64), + &(data.owner as i64), + &data.title, + &serde_json::to_string(&data.view).unwrap(), + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(data) + } + + pub async fn delete_journal(&self, id: usize, user: &User) -> Result<()> { + let journal = self.get_journal_by_id(id).await?; + + // check user permission + if user.id != journal.owner && !user.permissions.check(FinePermission::MANAGE_JOURNALS) { + return Err(Error::NotAllowed); + } + + // ... + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!(&conn, "DELETE FROM journals WHERE id = $1", &[&(id as i64)]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // delete notes + let res = execute!( + &conn, + "DELETE FROM notes WHERE journal = $1", + &[&(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // ... + self.0.1.remove(format!("atto.journal:{}", id)).await; + Ok(()) + } + + auto_method!(update_journal_title(&str)@get_journal_by_id:MANAGE_JOURNALS -> "UPDATE journals SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.journal:{}"); + auto_method!(update_journal_view(JournalViewPermission)@get_journal_by_id:MANAGE_JOURNALS -> "UPDATE journals SET privacy = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.journal:{}"); +} diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index b26afbf..e56bc93 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -10,8 +10,10 @@ mod drivers; mod emojis; mod ipbans; mod ipblocks; +mod journals; mod memberships; mod messages; +mod notes; mod notifications; mod polls; mod pollvotes; diff --git a/crates/core/src/database/notes.rs b/crates/core/src/database/notes.rs new file mode 100644 index 0000000..f7afc46 --- /dev/null +++ b/crates/core/src/database/notes.rs @@ -0,0 +1,124 @@ +use oiseau::cache::Cache; +use crate::model::{auth::User, journals::Note, permissions::FinePermission, Error, Result}; +use crate::{auto_method, DataManager}; +use oiseau::{PostgresRow, execute, get, query_rows, params}; + +impl DataManager { + /// Get a [`Note`] from an SQL row. + pub(crate) fn get_note_from_row(x: &PostgresRow) -> Note { + Note { + id: get!(x->0(i64)) as usize, + created: get!(x->1(i64)) as usize, + owner: get!(x->2(i64)) as usize, + title: get!(x->3(String)), + journal: get!(x->4(i64)) as usize, + content: get!(x->5(String)), + edited: get!(x->6(i64)) as usize, + } + } + + auto_method!(get_note_by_id(usize as i64)@get_note_from_row -> "SELECT * FROM notes WHERE id = $1" --name="note" --returns=Note --cache-key-tmpl="atto.note:{}"); + + /// Get all notes by journal. + /// + /// # Arguments + /// * `id` - the ID of the journal to fetch notes for + pub async fn get_notes_by_journal(&self, id: 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 notes WHERE journal = $1 ORDER BY edited", + &[&(id as i64)], + |x| { Self::get_note_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("note".to_string())); + } + + Ok(res.unwrap()) + } + + /// Create a new note in the database. + /// + /// # Arguments + /// * `data` - a mock [`Note`] object to insert + pub async fn create_note(&self, data: Note) -> Result { + // check values + if data.title.len() < 2 { + return Err(Error::DataTooShort("title".to_string())); + } else if data.title.len() > 64 { + return Err(Error::DataTooLong("title".to_string())); + } + + if data.content.len() < 2 { + return Err(Error::DataTooShort("content".to_string())); + } else if data.content.len() > 16384 { + return Err(Error::DataTooLong("content".to_string())); + } + + // ... + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "INSERT INTO notes VALUES ($1, $2, $3, $4, $5, $6, $7)", + params![ + &(data.id as i64), + &(data.created as i64), + &(data.owner as i64), + &data.title, + &(data.journal as i64), + &data.content, + &(data.edited as i64), + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(data) + } + + pub async fn delete_note(&self, id: usize, user: &User) -> Result<()> { + let note = self.get_note_by_id(id).await?; + + // check user permission + if user.id != note.owner && !user.permissions.check(FinePermission::MANAGE_NOTES) { + return Err(Error::NotAllowed); + } + + // ... + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!(&conn, "DELETE FROM notes WHERE id = $1", &[&(id as i64)]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // delete notes + let res = execute!(&conn, "DELETE FROM notes WHERE note = $1", &[&(id as i64)]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // ... + self.0.1.remove(format!("atto.note:{}", id)).await; + Ok(()) + } + + auto_method!(update_note_title(&str)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.note:{}"); +} diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index a02d2d4..29dd75a 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -1620,7 +1620,14 @@ impl DataManager { // create notification for question owner // (if the current user isn't the owner) - if (question.owner != data.owner) && (question.owner != 0) { + if (question.owner != data.owner) + && (question.owner != 0) + && (!owner.settings.private_profile + | self + .get_userfollow_by_initiator_receiver(data.owner, question.owner) + .await + .is_ok()) + { self.create_notification(Notification::new( "Your question has received a new answer!".to_string(), format!( diff --git a/crates/core/src/model/journals.rs b/crates/core/src/model/journals.rs new file mode 100644 index 0000000..9b33bcc --- /dev/null +++ b/crates/core/src/model/journals.rs @@ -0,0 +1,69 @@ +use serde::{Serialize, Deserialize}; +use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum JournalViewPermission { + /// Can be accessed by anyone via link. + Public, + /// Visible only to the journal owner. + Private, +} + +impl Default for JournalViewPermission { + fn default() -> Self { + Self::Private + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Journal { + pub id: usize, + pub created: usize, + pub owner: usize, + pub title: String, + pub view: JournalViewPermission, +} + +impl Journal { + /// Create a new [`Journal`]. + pub fn new(owner: usize, title: String) -> Self { + Self { + id: Snowflake::new().to_string().parse::().unwrap(), + created: unix_epoch_timestamp(), + owner, + title, + view: JournalViewPermission::default(), + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Note { + pub id: usize, + pub created: usize, + pub owner: usize, + pub title: String, + /// The ID of the [`Journal`] this note belongs to. + /// + /// The note is subject to the settings set for the journal it's in. + pub journal: usize, + pub content: String, + pub edited: usize, +} + +impl Note { + /// Create a new [`Note`]. + pub fn new(owner: usize, title: String, journal: usize, content: String) -> Self { + let created = unix_epoch_timestamp(); + + Self { + id: Snowflake::new().to_string().parse::().unwrap(), + created, + owner, + title, + journal, + content, + edited: created, + } + } +} diff --git a/crates/core/src/model/mod.rs b/crates/core/src/model/mod.rs index 8beb286..c50ea7c 100644 --- a/crates/core/src/model/mod.rs +++ b/crates/core/src/model/mod.rs @@ -4,6 +4,7 @@ pub mod auth; pub mod channels; pub mod communities; pub mod communities_permissions; +pub mod journals; pub mod moderation; pub mod oauth; pub mod permissions; diff --git a/crates/core/src/model/permissions.rs b/crates/core/src/model/permissions.rs index c0c3542..9cd6dcb 100644 --- a/crates/core/src/model/permissions.rs +++ b/crates/core/src/model/permissions.rs @@ -37,6 +37,8 @@ bitflags! { const MANAGE_STACKS = 1 << 26; const STAFF_BADGE = 1 << 27; const MANAGE_APPS = 1 << 28; + const MANAGE_JOURNALS = 1 << 29; + const MANAGE_NOTES = 1 << 30; const _ = !0; } From 42421bd9068542c70d28185c9704985a61bfed18 Mon Sep 17 00:00:00 2001 From: trisua Date: Wed, 18 Jun 2025 21:00:07 -0400 Subject: [PATCH 21/75] add: full journals api add: full notes api --- crates/app/src/public/html/components.lisp | 12 ++ crates/app/src/routes/api/v1/journals.rs | 153 +++++++++++++++ crates/app/src/routes/api/v1/mod.rs | 54 ++++++ crates/app/src/routes/api/v1/notes.rs | 182 ++++++++++++++++++ crates/app/src/routes/api/v1/stacks.rs | 48 +++++ .../database/drivers/sql/create_journals.sql | 4 +- .../src/database/drivers/sql/create_notes.sql | 2 +- crates/core/src/database/journals.rs | 10 +- crates/core/src/database/notes.rs | 1 + crates/core/src/model/journals.rs | 8 +- crates/core/src/model/oauth.rs | 14 ++ 11 files changed, 476 insertions(+), 12 deletions(-) create mode 100644 crates/app/src/routes/api/v1/journals.rs create mode 100644 crates/app/src/routes/api/v1/notes.rs diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 8905fe1..02dcc59 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -1019,6 +1019,18 @@ ("data-turbo" "false") (icon (text "rabbit")) (str (text "general:link.reference"))) + + (a + ("href" "{{ config.policies.terms_of_service }}") + ("class" "button") + (icon (text "heart-handshake")) + (text "Terms of service")) + + (a + ("href" "{{ config.policies.privacy }}") + ("class" "button") + (icon (text "cookie")) + (text "Privacy policy")) (b ("class" "title") (str (text "general:label.account"))) (button ("onclick" "trigger('me::switch_account')") diff --git a/crates/app/src/routes/api/v1/journals.rs b/crates/app/src/routes/api/v1/journals.rs new file mode 100644 index 0000000..caa45be --- /dev/null +++ b/crates/app/src/routes/api/v1/journals.rs @@ -0,0 +1,153 @@ +use axum::{ + response::IntoResponse, + extract::{Json, Path}, + Extension, +}; +use axum_extra::extract::CookieJar; +use crate::{ + get_user_from_token, + routes::api::v1::{UpdateJournalView, CreateJournal, UpdateJournalTitle}, + State, +}; +use tetratto_core::model::{ + journals::{Journal, JournalPrivacyPermission}, + oauth, + permissions::FinePermission, + ApiReturn, Error, +}; + +pub async fn get_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::UserReadJournals) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let journal = match data.get_journal_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + if journal.privacy == JournalPrivacyPermission::Private + && user.id != journal.owner + && !user.permissions.contains(FinePermission::MANAGE_JOURNALS) + { + return Json(Error::NotAllowed.into()); + } + + Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(journal), + }) +} + +pub async fn list_request(jar: CookieJar, Extension(data): Extension) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadJournals) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.get_journals_by_user(user.id).await { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(x), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn create_request( + jar: CookieJar, + Extension(data): Extension, + Json(props): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateJournals) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data + .create_journal(Journal::new(user.id, props.title)) + .await + { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Journal created".to_string(), + payload: Some(x), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_title_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, + Json(props): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageJournals) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_journal_title(id, &user, &props.title).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Journal updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_privacy_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, + Json(props): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageJournals) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_journal_privacy(id, &user, props.view).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Journal updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn delete_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::UserManageJournals) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.delete_journal(id, &user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Journal deleted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 80212b1..983d3fe 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -2,6 +2,8 @@ pub mod apps; pub mod auth; pub mod channels; pub mod communities; +pub mod journals; +pub mod notes; pub mod notifications; pub mod reactions; pub mod reports; @@ -22,6 +24,7 @@ use tetratto_core::model::{ PollOption, PostContext, }, communities_permissions::CommunityPermission, + journals::JournalPrivacyPermission, oauth::AppScope, permissions::FinePermission, reactions::AssetType, @@ -530,7 +533,9 @@ pub fn routes() -> Router { delete(communities::emojis::delete_request), ) // stacks + .route("/stacks", get(stacks::list_request)) .route("/stacks", post(stacks::create_request)) + .route("/stacks/{id}", get(stacks::get_request)) .route("/stacks/{id}/name", post(stacks::update_name_request)) .route("/stacks/{id}/privacy", post(stacks::update_privacy_request)) .route("/stacks/{id}/mode", post(stacks::update_mode_request)) @@ -541,6 +546,23 @@ pub fn routes() -> Router { .route("/stacks/{id}/block", post(stacks::block_request)) .route("/stacks/{id}/block", delete(stacks::unblock_request)) .route("/stacks/{id}", delete(stacks::delete_request)) + // journals + .route("/journals", get(journals::list_request)) + .route("/journals", post(journals::create_request)) + .route("/journals/{id}", get(journals::get_request)) + .route("/journals/{id}", delete(journals::delete_request)) + .route("/journals/{id}/title", post(journals::update_title_request)) + .route( + "/journals/{id}/privacy", + post(journals::update_privacy_request), + ) + // notes + .route("/notes", post(notes::create_request)) + .route("/notes/{id}", get(notes::get_request)) + .route("/notes/{id}", delete(notes::delete_request)) + .route("/notes/{id}/title", post(notes::update_title_request)) + .route("/notes/{id}/content", post(notes::update_content_request)) + .route("/notes/from_journal/{id}", get(notes::list_request)) // uploads .route("/uploads/{id}", get(uploads::get_request)) .route("/uploads/{id}", delete(uploads::delete_request)) @@ -846,3 +868,35 @@ pub struct CreateGrant { pub struct RefreshGrantToken { pub verifier: String, } + +#[derive(Deserialize)] +pub struct CreateJournal { + pub title: String, +} + +#[derive(Deserialize)] +pub struct CreateNote { + pub title: String, + pub content: String, + pub journal: String, +} + +#[derive(Deserialize)] +pub struct UpdateJournalTitle { + pub title: String, +} + +#[derive(Deserialize)] +pub struct UpdateJournalView { + pub view: JournalPrivacyPermission, +} + +#[derive(Deserialize)] +pub struct UpdateNoteTitle { + pub title: String, +} + +#[derive(Deserialize)] +pub struct UpdateNoteContent { + pub content: String, +} diff --git a/crates/app/src/routes/api/v1/notes.rs b/crates/app/src/routes/api/v1/notes.rs new file mode 100644 index 0000000..01645aa --- /dev/null +++ b/crates/app/src/routes/api/v1/notes.rs @@ -0,0 +1,182 @@ +use axum::{ + response::IntoResponse, + extract::{Json, Path}, + Extension, +}; +use axum_extra::extract::CookieJar; +use crate::{ + get_user_from_token, + routes::api::v1::{CreateNote, UpdateNoteContent, UpdateNoteTitle}, + State, +}; +use tetratto_core::model::{ + journals::{JournalPrivacyPermission, Note}, + oauth, + permissions::FinePermission, + ApiReturn, Error, +}; + +pub async fn get_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::UserReadJournals) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let note = match data.get_note_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + let journal = match data.get_journal_by_id(note.id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + if journal.privacy == JournalPrivacyPermission::Private + && user.id != journal.owner + && !user.permissions.contains(FinePermission::MANAGE_JOURNALS) + { + return Json(Error::NotAllowed.into()); + } + + Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(note), + }) +} + +pub async fn list_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::UserReadJournals) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let journal = match data.get_journal_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + if journal.privacy == JournalPrivacyPermission::Private + && user.id != journal.owner + && !user.permissions.contains(FinePermission::MANAGE_JOURNALS) + { + return Json(Error::NotAllowed.into()); + } + + match data.get_notes_by_journal(id).await { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(x), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn create_request( + jar: CookieJar, + Extension(data): Extension, + Json(props): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateNotes) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data + .create_note(Note::new( + user.id, + props.title, + match props.journal.parse() { + Ok(x) => x, + Err(_) => return Json(Error::Unknown.into()), + }, + props.content, + )) + .await + { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Note created".to_string(), + payload: Some(x), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_title_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, + Json(props): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_note_title(id, &user, &props.title).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Note updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_content_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, + Json(props): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_note_content(id, &user, &props.content).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Note updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn delete_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::UserManageNotes) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.delete_note(id, &user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Note deleted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/stacks.rs b/crates/app/src/routes/api/v1/stacks.rs index ee4e5b7..d3979a2 100644 --- a/crates/app/src/routes/api/v1/stacks.rs +++ b/crates/app/src/routes/api/v1/stacks.rs @@ -19,6 +19,54 @@ use super::{ UpdateStackSort, }; +pub async fn get_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::UserReadStacks) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let stack = match data.get_stack_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + if stack.privacy == StackPrivacy::Private + && user.id != stack.owner + && ((stack.mode != StackMode::Circle) | stack.users.contains(&user.id)) + && !user.permissions.check(FinePermission::MANAGE_STACKS) + { + return Json(Error::NotAllowed.into()); + } + + Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(stack), + }) +} + +pub async fn list_request(jar: CookieJar, Extension(data): Extension) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadStacks) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.get_stacks_by_user(user.id).await { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(x), + }), + Err(e) => Json(e.into()), + } +} + pub async fn create_request( jar: CookieJar, Extension(data): Extension, diff --git a/crates/core/src/database/drivers/sql/create_journals.sql b/crates/core/src/database/drivers/sql/create_journals.sql index 01f49e5..40eafa4 100644 --- a/crates/core/src/database/drivers/sql/create_journals.sql +++ b/crates/core/src/database/drivers/sql/create_journals.sql @@ -1,7 +1,7 @@ -CREATE TABLE IF NOT EXISTS channels ( +CREATE TABLE IF NOT EXISTS journals ( id BIGINT NOT NULL PRIMARY KEY, created BIGINT NOT NULL, owner BIGINT NOT NULL, title TEXT NOT NULL, - view TEXT NOT NULL + privacy TEXT NOT NULL ) diff --git a/crates/core/src/database/drivers/sql/create_notes.sql b/crates/core/src/database/drivers/sql/create_notes.sql index 0ee4686..87361ad 100644 --- a/crates/core/src/database/drivers/sql/create_notes.sql +++ b/crates/core/src/database/drivers/sql/create_notes.sql @@ -1,4 +1,4 @@ -CREATE TABLE IF NOT EXISTS channels ( +CREATE TABLE IF NOT EXISTS notes ( id BIGINT NOT NULL PRIMARY KEY, created BIGINT NOT NULL, owner BIGINT NOT NULL, diff --git a/crates/core/src/database/journals.rs b/crates/core/src/database/journals.rs index 0bc5ded..ac0a589 100644 --- a/crates/core/src/database/journals.rs +++ b/crates/core/src/database/journals.rs @@ -3,7 +3,7 @@ use crate::{ model::{ auth::User, permissions::FinePermission, - journals::{Journal, JournalViewPermission}, + journals::{Journal, JournalPrivacyPermission}, Error, Result, }, }; @@ -18,7 +18,7 @@ impl DataManager { created: get!(x->1(i64)) as usize, owner: get!(x->2(i64)) as usize, title: get!(x->3(String)), - view: serde_json::from_str(&get!(x->4(String))).unwrap(), + privacy: serde_json::from_str(&get!(x->4(String))).unwrap(), } } @@ -36,7 +36,7 @@ impl DataManager { let res = query_rows!( &conn, - "SELECT * FROM journals WHERE owner = $1 ORDER BY name ASC", + "SELECT * FROM journals WHERE owner = $1 ORDER BY title ASC", &[&(id as i64)], |x| { Self::get_journal_from_row(x) } ); @@ -89,7 +89,7 @@ impl DataManager { &(data.created as i64), &(data.owner as i64), &data.title, - &serde_json::to_string(&data.view).unwrap(), + &serde_json::to_string(&data.privacy).unwrap(), ] ); @@ -137,5 +137,5 @@ impl DataManager { } auto_method!(update_journal_title(&str)@get_journal_by_id:MANAGE_JOURNALS -> "UPDATE journals SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.journal:{}"); - auto_method!(update_journal_view(JournalViewPermission)@get_journal_by_id:MANAGE_JOURNALS -> "UPDATE journals SET privacy = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.journal:{}"); + auto_method!(update_journal_privacy(JournalPrivacyPermission)@get_journal_by_id:MANAGE_JOURNALS -> "UPDATE journals SET privacy = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.journal:{}"); } diff --git a/crates/core/src/database/notes.rs b/crates/core/src/database/notes.rs index f7afc46..78a25d9 100644 --- a/crates/core/src/database/notes.rs +++ b/crates/core/src/database/notes.rs @@ -121,4 +121,5 @@ impl DataManager { } auto_method!(update_note_title(&str)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.note:{}"); + auto_method!(update_note_content(&str)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET content = $1 WHERE id = $2" --cache-key-tmpl="atto.note:{}"); } diff --git a/crates/core/src/model/journals.rs b/crates/core/src/model/journals.rs index 9b33bcc..f67b318 100644 --- a/crates/core/src/model/journals.rs +++ b/crates/core/src/model/journals.rs @@ -2,14 +2,14 @@ use serde::{Serialize, Deserialize}; use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -pub enum JournalViewPermission { +pub enum JournalPrivacyPermission { /// Can be accessed by anyone via link. Public, /// Visible only to the journal owner. Private, } -impl Default for JournalViewPermission { +impl Default for JournalPrivacyPermission { fn default() -> Self { Self::Private } @@ -21,7 +21,7 @@ pub struct Journal { pub created: usize, pub owner: usize, pub title: String, - pub view: JournalViewPermission, + pub privacy: JournalPrivacyPermission, } impl Journal { @@ -32,7 +32,7 @@ impl Journal { created: unix_epoch_timestamp(), owner, title, - view: JournalViewPermission::default(), + privacy: JournalPrivacyPermission::default(), } } } diff --git a/crates/core/src/model/oauth.rs b/crates/core/src/model/oauth.rs index ea87034..df34f3d 100644 --- a/crates/core/src/model/oauth.rs +++ b/crates/core/src/model/oauth.rs @@ -62,6 +62,12 @@ pub enum AppScope { UserReadRequests, /// Read questions as the user. UserReadQuestions, + /// Read the user's stacks. + UserReadStacks, + /// Read the user's journals. + UserReadJournals, + /// Read the user's notes. + UserReadNotes, /// Create posts as the user. UserCreatePosts, /// Create messages as the user. @@ -76,6 +82,10 @@ pub enum AppScope { UserCreateCommunities, /// Create stacks on behalf of the user. UserCreateStacks, + /// Create journals on behalf of the user. + UserCreateJournals, + /// Create notes on behalf of the user. + UserCreateNotes, /// Delete posts owned by the user. UserDeletePosts, /// Delete messages owned by the user. @@ -106,6 +116,10 @@ pub enum AppScope { UserManageRequests, /// Manage the user's uploads. UserManageUploads, + /// Manage the user's journals. + UserManageJournals, + /// Manage the user's notes. + UserManageNotes, /// Edit posts created by the user. UserEditPosts, /// Edit drafts created by the user. From 1aab2f1b97e0b4101f5b22d5a02ac5bbf485050b Mon Sep 17 00:00:00 2001 From: trisua Date: Wed, 18 Jun 2025 21:32:05 -0400 Subject: [PATCH 22/75] add: make hide_dislikes disable post dislikes entirely --- crates/app/src/public/html/components.lisp | 6 +++--- crates/app/src/public/html/profile/settings.lisp | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 02dcc59..d5b6805 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -72,7 +72,7 @@ ("style" "display: contents") (text "{% if user.settings.display_name -%} {{ user.settings.display_name }} {% else %} {{ user.username }} {%- endif %}")) -(text "{%- endmacro %} {% macro likes(id, asset_type, likes=0, dislikes=0, secondary=false) -%}") +(text "{%- endmacro %} {% macro likes(id, asset_type, likes=0, dislikes=0, secondary=false, disable_dislikes=false) -%}") (button ("title" "Like") ("class" "{% if secondary -%}lowered{% else %}camo{%- endif %} small") @@ -83,7 +83,7 @@ (text "{{ likes }}")) (text "{%- endif %}")) -(text "{% if not user or not user.settings.hide_dislikes -%}") +(text "{% if not user or not user.settings.hide_dislikes and not disable_dislikes -%}") (button ("title" "Dislike") ("class" "{% if secondary -%}lowered{% else %}camo{%- endif %} small") @@ -289,7 +289,7 @@ ("class" "flex gap-1 reactions_box") ("hook" "check_reactions") ("hook-arg:id" "{{ post.id }}") - (text "{% if post.context.reactions_enabled -%} {% if post.content|length > 0 or post.uploads|length > 0 -%} {{ self::likes(id=post.id, asset_type=\"Post\", likes=post.likes, dislikes=post.dislikes) }} {%- endif %} {%- endif %} {% if post.context.repost and post.context.repost.reposting -%}") + (text "{% if post.context.reactions_enabled -%} {% if post.content|length > 0 or post.uploads|length > 0 -%} {{ self::likes(id=post.id, asset_type=\"Post\", likes=post.likes, dislikes=post.dislikes, disable_dislikes=owner.settings.hide_dislikes) }} {%- endif %} {%- endif %} {% if post.context.repost and post.context.repost.reposting -%}") (a ("href" "/post/{{ post.context.repost.reposting }}") ("class" "button small camo") diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index bb0277d..8be4836 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -1398,7 +1398,7 @@ ], [ [], - \"Hides dislikes on all posts. Users can still dislike your posts, you just won't be able to see it.\", + \"Hides dislikes on all posts. Users will also no longer be able to dislike your posts.\", \"text\", ], [[], \"Fun\", \"title\"], From c08a26ae8d161bf94bbb5638c6f222edcc1766f8 Mon Sep 17 00:00:00 2001 From: trisua Date: Thu, 19 Jun 2025 00:20:04 -0400 Subject: [PATCH 23/75] fix: color picker setting mirror --- crates/app/src/public/js/atto.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js index 034181e..03bd6b2 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -1019,7 +1019,7 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} window.update_field_with_color = (key, value) => { console.log("sync_color_text", key); document.getElementById(key).value = value; - set_setting_field(key, value); + window.SETTING_SET_FUNCTIONS[0](key, value); preview_color(key, value); }; From c1568ad866de6c88b45733f339c17e2e1d6c495e Mon Sep 17 00:00:00 2001 From: trisua Date: Thu, 19 Jun 2025 15:48:04 -0400 Subject: [PATCH 24/75] add: journals + notes --- Cargo.lock | 8 +- crates/app/Cargo.toml | 2 +- crates/app/src/assets.rs | 5 + crates/app/src/langs/en-US.toml | 11 + crates/app/src/macros.rs | 13 +- crates/app/src/public/css/chats.css | 232 ++++++++ crates/app/src/public/css/root.css | 2 +- crates/app/src/public/css/style.css | 139 ++++- crates/app/src/public/html/chats/app.lisp | 225 +------- crates/app/src/public/html/components.lisp | 110 ++++ crates/app/src/public/html/journals/app.lisp | 543 ++++++++++++++++++ crates/app/src/public/html/root.lisp | 2 +- crates/app/src/public/html/stacks/manage.lisp | 2 +- crates/app/src/public/js/atto.js | 2 + crates/app/src/routes/api/v1/journals.rs | 22 +- crates/app/src/routes/api/v1/mod.rs | 10 +- crates/app/src/routes/api/v1/notes.rs | 50 +- crates/app/src/routes/assets.rs | 1 + crates/app/src/routes/mod.rs | 1 + crates/app/src/routes/pages/journals.rs | 209 +++++++ crates/app/src/routes/pages/mod.rs | 12 + crates/core/Cargo.toml | 2 +- crates/core/src/database/journals.rs | 36 +- crates/core/src/database/notes.rs | 53 +- crates/l10n/Cargo.toml | 2 +- crates/shared/Cargo.toml | 2 +- 26 files changed, 1431 insertions(+), 265 deletions(-) create mode 100644 crates/app/src/public/css/chats.css create mode 100644 crates/app/src/public/html/journals/app.lisp create mode 100644 crates/app/src/routes/pages/journals.rs diff --git a/Cargo.lock b/Cargo.lock index 48e412e..a11c634 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3231,7 +3231,7 @@ dependencies = [ [[package]] name = "tetratto" -version = "8.0.0" +version = "9.0.0" dependencies = [ "ammonia", "async-stripe", @@ -3262,7 +3262,7 @@ dependencies = [ [[package]] name = "tetratto-core" -version = "8.0.0" +version = "9.0.0" dependencies = [ "async-recursion", "base16ct", @@ -3284,7 +3284,7 @@ dependencies = [ [[package]] name = "tetratto-l10n" -version = "8.0.0" +version = "9.0.0" dependencies = [ "pathbufd", "serde", @@ -3293,7 +3293,7 @@ dependencies = [ [[package]] name = "tetratto-shared" -version = "8.0.0" +version = "9.0.0" dependencies = [ "ammonia", "chrono", diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 41eec67..e29dcb9 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto" -version = "8.0.0" +version = "9.0.0" edition = "2024" [dependencies] diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index bf2a64c..a3bb588 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -32,6 +32,7 @@ pub const FAVICON: &str = include_str!("./public/images/favicon.svg"); pub const STYLE_CSS: &str = include_str!("./public/css/style.css"); pub const ROOT_CSS: &str = include_str!("./public/css/root.css"); pub const UTILITY_CSS: &str = include_str!("./public/css/utility.css"); +pub const CHATS_CSS: &str = include_str!("./public/css/chats.css"); // js pub const LOADER_JS: &str = include_str!("./public/js/loader.js"); @@ -125,6 +126,8 @@ pub const DEVELOPER_HOME: &str = include_str!("./public/html/developer/home.lisp pub const DEVELOPER_APP: &str = include_str!("./public/html/developer/app.lisp"); pub const DEVELOPER_LINK: &str = include_str!("./public/html/developer/link.lisp"); +pub const JOURNALS_APP: &str = include_str!("./public/html/journals/app.lisp"); + // langs pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml"); @@ -414,6 +417,8 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"developer/app.html"(crate::assets::DEVELOPER_APP) --config=config --lisp plugins); write_template!(html_path->"developer/link.html"(crate::assets::DEVELOPER_LINK) --config=config --lisp plugins); + write_template!(html_path->"journals/app.html"(crate::assets::JOURNALS_APP) -d "journals" --config=config --lisp plugins); + html_path } diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index 76e0490..b725251 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -16,6 +16,7 @@ version = "1.0.0" "general:link.ip_bans" = "IP bans" "general:link.stats" = "Stats" "general:link.search" = "Search" +"general:link.journals" = "Journals" "general:action.save" = "Save" "general:action.delete" = "Delete" "general:action.purge" = "Purge" @@ -231,3 +232,13 @@ version = "1.0.0" "developer:label.guides_and_help" = "Guides & help" "developer:action.delete" = "Delete app" "developer:action.authorize" = "Authorize" + +"journals:label.my_journals" = "My journals" +"journals:action.create_journal" = "Create journal" +"journals:action.create_note" = "Create note" +"journals:label.welcome" = "Welcome to Journals!" +"journals:label.select_a_journal" = "Select or create a journal to get started." +"journals:label.select_a_note" = "Select or create a note in this journal to get started." +"journals:label.mobile_click_my_journals" = "Click \"My journals\" at the top to open the sidebar." +"journals:label.editor" = "Editor" +"journals:label.preview_pane" = "Preview" diff --git a/crates/app/src/macros.rs b/crates/app/src/macros.rs index 6377581..01406bb 100644 --- a/crates/app/src/macros.rs +++ b/crates/app/src/macros.rs @@ -193,7 +193,10 @@ macro_rules! check_user_blocked_or_private { // check if other user is banned if $other_user.permissions.check_banned() { if let Some(ref ua) = $user { - if !ua.permissions.check(FinePermission::MANAGE_USERS) { + if !ua + .permissions + .check(tetratto_core::model::permissions::FinePermission::MANAGE_USERS) + { $crate::user_banned!($user, $other_user, $data, $jar); } } else { @@ -213,7 +216,9 @@ macro_rules! check_user_blocked_or_private { .get_user_stack_blocked_users($other_user.id) .await .contains(&ua.id)) - && !ua.permissions.check(FinePermission::MANAGE_USERS) + && !ua + .permissions + .check(tetratto_core::model::permissions::FinePermission::MANAGE_USERS) { let lang = get_lang!($jar, $data.0); let mut context = initial_context(&$data.0.0.0, lang, &$user).await; @@ -238,7 +243,9 @@ macro_rules! check_user_blocked_or_private { if $other_user.settings.private_profile { if let Some(ref ua) = $user { if (ua.id != $other_user.id) - && !ua.permissions.check(FinePermission::MANAGE_USERS) + && !ua + .permissions + .check(tetratto_core::model::permissions::FinePermission::MANAGE_USERS) && $data .0 .get_userfollow_by_initiator_receiver($other_user.id, ua.id) diff --git a/crates/app/src/public/css/chats.css b/crates/app/src/public/css/chats.css new file mode 100644 index 0000000..b98db51 --- /dev/null +++ b/crates/app/src/public/css/chats.css @@ -0,0 +1,232 @@ +:root { + --list-bar-width: 64px; + --channels-bar-width: 256px; + --sidebar-height: calc(100dvh - 42px); + --channel-header-height: 48px; +} + +html, +body { + overflow: hidden; +} + +.name.shortest { + max-width: 165px; + overflow-wrap: normal; +} + +.send_button { + width: 48px; + height: 48px; +} + +.send_button .icon { + width: 2em; + height: 2em; +} + +a.channel_icon { + width: 48px; + height: 48px; + min-height: 48px; +} + +a.channel_icon .icon { + min-width: 24px; + height: 24px; +} + +a.channel_icon.small { + width: 24px; + height: 24px; + min-height: 24px; +} + +a.channel_icon.small .icon { + min-width: 12px; + height: 12px; +} + +a.channel_icon:has(img) { + padding: 0; +} + +a.channel_icon img { + min-width: 48px; + min-height: 48px; +} + +a.channel_icon img, +a.channel_icon:has(.icon) { + transition: + outline 0.25s, + background 0.15s !important; +} + +a.channel_icon:not(.selected):hover img, +a.channel_icon:not(.selected):hover:has(.icon) { + outline: solid 1px var(--color-text); +} +a.channel_icon.selected img, +a.channel_icon.selected:has(.icon) { + outline: solid 2px var(--color-text); +} + +nav { + background: var(--color-raised); + color: var(--color-text-raised) !important; + height: 42px; + position: sticky !important; +} + +nav::after { + display: block; + position: absolute; + background: var(--color-super-lowered); + height: 1px; + width: calc(100% - var(--list-bar-width)); + bottom: 0; + left: var(--list-bar-width); + content: ""; +} + +nav .content_container { + max-width: 100% !important; + width: 100%; +} + +.chats_nav { + display: none; + padding: 0; +} + +.chats_nav button { + justify-content: flex-start; + width: 100% !important; + flex-direction: row !important; + font-size: 16px !important; + margin-top: -4px; +} + +.chats_nav button svg { + margin-right: var(--pad-4); +} + +.sidebar { + background: var(--color-raised); + color: var(--color-text-raised); + border-right: solid 1px var(--color-super-lowered); + padding: 0.4rem; + width: max-content; + height: var(--sidebar-height); + overflow: auto; + transition: left 0.15s; + z-index: 2; +} + +.sidebar .title:not(.dropdown *) { + padding: var(--pad-4); + border-bottom: solid 1px var(--color-super-lowered); +} + +.sidebar#channels_list { + width: var(--channels-bar-width); + background: var(--color-surface); + color: var(--color-text); +} + +.sidebar#notes_list { + width: calc(var(--channels-bar-width) + var(--list-bar-width)); + flex: 1 0 auto; +} + +#stream { + width: calc( + 100dvw - var(--list-bar-width) - var(--channels-bar-width) + ) !important; + height: var(--sidebar-height); +} + +.message { + transition: background 0.15s; + box-shadow: none; + position: relative; +} + +.message:hover { + background: var(--color-raised); +} + +.message:hover .hidden, +.message:focus .hidden, +.message:active .hidden { + display: flex !important; +} + +.message.grouped { + padding: var(--pad-1) var(--pad-4) var(--pad-1) + calc(var(--pad-4) + var(--pad-2) + 42px); +} + +turbo-frame { + display: contents; +} + +.channel_header { + height: var(--channel-header-height); +} + +.members_list_half { + padding-top: var(--pad-4); + border-top: solid 1px var(--color-super-lowered); +} + +.channels_list_half:not(.no_members), +.members_list_half { + overflow: auto; + height: calc( + (var(--sidebar-height) - var(--channel-header-height) - 8rem) / 2 + ); +} + +@media screen and (max-width: 900px) { + :root { + --sidebar-height: calc(100dvh - 42px * 2); + } + + .message.grouped { + padding: var(--pad-1) var(--pad-4) var(--pad-1) + calc(var(--pad-4) + var(--pad-2) + 31px); + } + + body:not(.sidebars_shown) .sidebar { + position: absolute; + left: -200%; + } + + body.sidebars_shown .sidebar { + position: absolute; + } + + #stream { + width: 100dvw !important; + height: var(--sidebar-height); + } + + nav::after { + width: 100dvw; + left: 0; + } + + .chats_nav { + display: flex; + } + + nav:has(+ .chats_nav) .dropdown .inner { + top: calc(100% + 44px); + } + + .padded_section { + padding: 0 !important; + } +} diff --git a/crates/app/src/public/css/root.css b/crates/app/src/public/css/root.css index 41db0d5..fbb1d4d 100644 --- a/crates/app/src/public/css/root.css +++ b/crates/app/src/public/css/root.css @@ -116,7 +116,7 @@ article { padding: 0; } - body .card:not(.card *):not(#stream *):not(.user_plate), + body .card:not(.card *):not(.user_plate), body .pillmenu:not(.card *) > a, body .card-nest:not(.card *) > .card, body .banner { diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css index 8e9bbce..f592c77 100644 --- a/crates/app/src/public/css/style.css +++ b/crates/app/src/public/css/style.css @@ -273,6 +273,12 @@ button, font-weight: 600; } +button:disabled, +.button:disabled { + cursor: not-allowed; + opacity: 50%; +} + button.small, .button.small { /* min-height: max-content; */ @@ -714,6 +720,13 @@ nav .button:not(.title):not(.active):hover { border-bottom-right-radius: var(--radius) !important; } +@media screen and (min-width: 900px) { + .mobile_nav:not(.mobile) { + border-radius: var(--radius); + border: solid 1px var(--color-super-lowered); + } +} + /* dialog */ dialog { padding: 0; @@ -1072,7 +1085,7 @@ details[open] summary::after { animation: fadein ease-in-out 1 0.1s forwards running; } -details .card { +details > .card { background: var(--color-super-raised); } @@ -1113,3 +1126,127 @@ details.accordion .inner { border: solid 1px var(--color-super-lowered); border-top: none; } + +/* codemirror */ +.CodeMirror { + color: var(--color-text) !important; +} + +.CodeMirror { + background: transparent !important; + font-family: inherit !important; + height: 10rem !important; + min-height: 100%; + max-height: 100%; + cursor: text; +} + +.CodeMirror-cursor { + border-color: rgb(0, 0, 0) !important; +} + +.CodeMirror-cursor:is(.dark *) { + border-color: rgb(255, 255, 255) !important; +} + +.CodeMirror-cursor { + height: 22px !important; +} + +[role="presentation"]::-moz-selection, +[role="presentation"] *::-moz-selection { + background-color: rgb(191, 219, 254) !important; +} + +[role="presentation"]::selection, +[role="presentation"] *::selection, +.CodeMirror-selected { + background-color: rgb(191, 219, 254) !important; +} + +[role="presentation"]:is(.dark *)::-moz-selection, +[role="presentation"] *:is(.dark *)::-moz-selection { + background-color: rgb(64, 64, 64) !important; +} + +[role="presentation"]:is(.dark *)::selection, +[role="presentation"] *:is(.dark *)::selection, +.CodeMirror-selected:is(.dark *) { + background-color: rgb(64, 64, 64) !important; +} + +.cm-header { + color: inherit !important; +} + +.cm-variable-2, +.cm-quote, +.cm-keyword, +.cm-string, +.cm-atom { + color: rgb(63, 98, 18) !important; +} + +.cm-variable-2:is(.dark *), +.cm-quote:is(.dark *), +.cm-keyword:is(.dark *), +.cm-string:is(.dark *), +.cm-atom:is(.dark *) { + color: rgb(217, 249, 157) !important; +} + +.cm-comment { + color: rgb(153 27 27) !important; +} + +.cm-comment:is(.dark *) { + color: rgb(254, 202, 202) !important; +} + +.cm-comment { + font-family: ui-monospace, monospace; +} + +.cm-link { + color: var(--color-link) !important; +} + +.cm-url, +.cm-property, +.cm-qualifier { + color: rgb(29, 78, 216) !important; +} + +.cm-url:is(.dark *), +.cm-property:is(.dark *), +.cm-qualifier:is(.dark *) { + color: rgb(191, 219, 254) !important; +} + +.cm-variable-3, +.cm-tag, +.cm-def, +.cm-attribute, +.cm-number { + color: rgb(91, 33, 182) !important; +} + +.cm-variable-3:is(.dark *), +.cm-tag:is(.dark *), +.cm-def:is(.dark *), +.cm-attribute:is(.dark *), +.cm-number:is(.dark *) { + color: rgb(221, 214, 254) !important; +} + +.CodeMirror { + height: auto !important; +} + +.CodeMirror-line { + padding-left: 0 !important; +} + +.CodeMirror-focused .CodeMirror-placeholder { + opacity: 50%; +} diff --git a/crates/app/src/public/html/chats/app.lisp b/crates/app/src/public/html/chats/app.lisp index e7cc4ec..a24ca27 100644 --- a/crates/app/src/public/html/chats/app.lisp +++ b/crates/app/src/public/html/chats/app.lisp @@ -1,7 +1,7 @@ (text "{% extends \"root.html\" %} {% block head %}") (title (text "Chats - {{ config.name }}")) - +(link ("rel" "stylesheet") ("data-turbo-temporary" "true") ("href" "/css/chats.css?v=tetratto-{{ random_cache_breaker }}")) (text "{% endblock %} {% block body %} {{ macros::nav(selected=\"chats\", hide_user_menu=true) }}") (nav ("class" "chats_nav") @@ -16,7 +16,6 @@ (b (text "{{ text \"chats:label.my_chats\" }}")) (text "{%- endif %}"))) - (div ("class" "flex") (div @@ -87,7 +86,7 @@ (text "{{ components::user_plate(user=user, show_menu=true) }}")) (text "{% if channel -%}") (div - ("class" "w-full flex flex-col gap-2") + ("class" "w-full flex flex-col gap-2 padded_section") ("id" "stream") ("style" "padding: var(--pad-4)") (turbo-frame @@ -110,225 +109,6 @@ ("title" "Send") (text "{{ icon \"send-horizontal\" }}")))) (text "{%- endif %}") - (style - (text ":root { - --list-bar-width: 64px; - --channels-bar-width: 256px; - --sidebar-height: calc(100dvh - 42px); - --channel-header-height: 48px; - } - - html, - body { - overflow: hidden; - } - - .name.shortest { - max-width: 165px; - overflow-wrap: normal; - } - - .send_button { - width: 48px; - height: 48px; - } - - .send_button .icon { - width: 2em; - height: 2em; - } - - a.channel_icon { - width: 48px; - height: 48px; - min-height: 48px; - } - - a.channel_icon .icon { - min-width: 24px; - height: 24px; - } - - a.channel_icon.small { - width: 24px; - height: 24px; - min-height: 24px; - } - - a.channel_icon.small .icon { - min-width: 12px; - height: 12px; - } - - a.channel_icon:has(img) { - padding: 0; - } - - a.channel_icon img { - min-width: 48px; - min-height: 48px; - } - - a.channel_icon img, - a.channel_icon:has(.icon) { - transition: - outline 0.25s, - background 0.15s !important; - } - - a.channel_icon:not(.selected):hover img, - a.channel_icon:not(.selected):hover:has(.icon) { - outline: solid 1px var(--color-text); - } - a.channel_icon.selected img, - a.channel_icon.selected:has(.icon) { - outline: solid 2px var(--color-text); - } - - nav { - background: var(--color-raised); - color: var(--color-text-raised) !important; - height: 42px; - position: sticky !important; - } - - nav::after { - display: block; - position: absolute; - background: var(--color-super-lowered); - height: 1px; - width: calc(100% - var(--list-bar-width)); - bottom: 0; - left: var(--list-bar-width); - content: \"\"; - } - - nav .content_container { - max-width: 100% !important; - width: 100%; - } - - .chats_nav { - display: none; - padding: 0; - } - - .chats_nav button { - justify-content: flex-start; - width: 100% !important; - flex-direction: row !important; - font-size: 16px !important; - margin-top: -4px; - } - - .chats_nav button svg { - margin-right: var(--pad-4); - } - - .sidebar { - background: var(--color-raised); - color: var(--color-text-raised); - border-right: solid 1px var(--color-super-lowered); - padding: 0.4rem; - width: max-content; - height: var(--sidebar-height); - overflow: auto; - transition: left 0.15s; - z-index: 1; - } - - .sidebar .title:not(.dropdown *) { - padding: var(--pad-4); - border-bottom: solid 1px var(--color-super-lowered); - } - - .sidebar#channels_list { - width: var(--channels-bar-width); - background: var(--color-surface); - color: var(--color-text); - } - - #stream { - width: calc( - 100dvw - var(--list-bar-width) - var(--channels-bar-width) - ) !important; - height: var(--sidebar-height); - } - - .message { - transition: background 0.15s; - box-shadow: none; - position: relative; - } - - .message:hover { - background: var(--color-raised); - } - - .message:hover .hidden, - .message:focus .hidden, - .message:active .hidden { - display: flex !important; - } - - .message.grouped { - padding: var(--pad-1) var(--pad-4) var(--pad-1) calc(var(--pad-4) + var(--pad-2) + 42px); - } - - turbo-frame { - display: contents; - } - - .channel_header { - height: var(--channel-header-height); - } - - .members_list_half { - padding-top: var(--pad-4); - border-top: solid 1px var(--color-super-lowered); - } - - .channels_list_half:not(.no_members), - .members_list_half { - overflow: auto; - height: calc( - (var(--sidebar-height) - var(--channel-header-height) - 8rem) / - 2 - ); - } - - @media screen and (max-width: 900px) { - :root { - --sidebar-height: calc(100dvh - 42px * 2); - } - - .message.grouped { - padding: var(--pad-1) var(--pad-4) var(--pad-1) calc(var(--pad-4) + var(--pad-2) + 31px); - } - - body:not(.sidebars_shown) .sidebar { - position: absolute; - left: -200%; - } - - body.sidebars_shown .sidebar { - position: absolute; - } - - #stream { - width: 100dvw !important; - height: var(--sidebar-height); - } - - nav::after { - width: 100dvw; - left: 0; - } - - .chats_nav { - display: flex; - } - }")) (script (text "window.CURRENT_PAGE = Number.parseInt(\"{{ page }}\"); window.VIEWING_SINGLE = \"{{ message }}\".length > 0; @@ -684,5 +464,4 @@ } }, 100);")) (text "{%- endif %}")) - (text "{% endblock %}") diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index d5b6805..75a24ef 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -976,6 +976,10 @@ (text "{{ icon \"circle-user-round\" }}") (span (text "{{ text \"auth:link.my_profile\" }}"))) + (a + ("href" "/journals/0/0") + (icon (text "notebook")) + (str (text "general:link.journals"))) (a ("href" "/settings") (text "{{ icon \"settings\" }}") @@ -1851,3 +1855,109 @@ (text "{{ stack.created }}")) (text "; {{ stack.privacy }}; {{ stack.users|length }} users"))) (text "{%- endmacro %}") + +(text "{% macro journal_listing(journal, notes, selected_note, selected_journal, view_mode=false, owner=false) -%}") +(text "{% if selected_journal != journal.id -%}") +; not selected +(div + ("class" "flex flex-row gap-1") + (a + ("href" "/journals/{{ journal.id }}/0?view={{ view_mode }}") + ("class" "button justify-start lowered w-full") + (icon (text "notebook")) + (text "{{ journal.title }}")) + + (div + ("class" "dropdown") + (button + ("class" "big_icon lowered") + ("onclick" "trigger('atto::hooks::dropdown', [event])") + ("exclude" "dropdown") + ("style" "width: 32px") + (text "{{ icon \"ellipsis\" }}")) + (div + ("class" "inner") + (button + ("onclick" "delete_journal('{{ journal.id }}')") + ("class" "red") + (text "{{ icon \"trash\" }}") + (span + (text "{{ text \"general:action.delete\" }}")))))) +(text "{% else %}") +; selected +(div + ("class" "flex flex-row gap-1") + (button + ("class" "justify-start lowered w-full") + (icon (text "arrow-down")) + (text "{{ journal.title }}")) + + (text "{% if user and user.id == journal.owner -%}") + (div + ("class" "dropdown") + (button + ("class" "big_icon lowered") + ("onclick" "trigger('atto::hooks::dropdown', [event])") + ("exclude" "dropdown") + ("style" "width: 32px") + (text "{{ icon \"ellipsis\" }}")) + (div + ("class" "inner") + (a + ("class" "button") + ("href" "/journals/{{ journal.id }}/0?view={{ view_mode }}") + (icon (text "house")) + (str (text "general:link.home"))) + (button + ("onclick" "delete_journal('{{ journal.id }}')") + ("class" "red") + (icon (text "trash")) + (str (text "general:action.delete"))))) + (text "{%- endif %}")) + +(div + ("class" "flex flex-col gap-2") + ("style" "margin-left: 10px; padding-left: 5px; border-left: solid 2px var(--color-super-lowered); width: calc(100% - 10px)") + ; create note + (text "{% if user and user.id == journal.owner -%}") + (button + ("class" "lowered justify-start w-full") + ("onclick" "create_note()") + (icon (text "plus")) + (str (text "journals:action.create_note"))) + (text "{%- endif %}") + + ; note listings + (text "{% for note in notes %}") + (div + ("class" "flex flex-row gap-1") + (a + ("href" "{% if owner -%} /@{{ owner.username }}/{{ journal.title }}/{{ note.title }} {%- else -%} /journals/{{ journal.id }}/{{ note.id }} {%- endif %}") + ("class" "button justify-start w-full {% if selected_note == note.id -%} lowered {%- else -%} raised {%- endif %}") + (icon (text "file-text")) + (text "{{ note.title }}")) + + (text "{% if user and user.id == journal.owner -%}") + (div + ("class" "dropdown") + (button + ("class" "big_icon {% if selected_note == note.id -%} lowered {%- else -%} raised {%- endif %}") + ("onclick" "trigger('atto::hooks::dropdown', [event])") + ("exclude" "dropdown") + ("style" "width: 32px") + (text "{{ icon \"ellipsis\" }}")) + (div + ("class" "inner") + (button + ("onclick" "change_note_title('{{ note.id }}')") + (icon (text "pencil")) + (str (text "chats:action.rename"))) + (button + ("onclick" "delete_note('{{ note.id }}')") + ("class" "red") + (icon (text "trash")) + (str (text "general:action.delete"))))) + (text "{%- endif %}")) + (text "{% endfor %}")) +(text "{%- endif %}") +(text "{%- endmacro %}") diff --git a/crates/app/src/public/html/journals/app.lisp b/crates/app/src/public/html/journals/app.lisp new file mode 100644 index 0000000..267541a --- /dev/null +++ b/crates/app/src/public/html/journals/app.lisp @@ -0,0 +1,543 @@ +(text "{% extends \"root.html\" %} {% block head %}") +(text "{% if journal -%}") (title (text "{{ journal.title }}")) (text "{% else %}") (title (text "Journals - {{ config.name }}")) (text "{%- endif %}") +(link ("rel" "stylesheet") ("data-turbo-temporary" "true") ("href" "/css/chats.css?v=tetratto-{{ random_cache_breaker }}")) + +(text "{% if view_mode and journal and is_editor -%} {% if note -%}") +; redirect to note +(meta ("http-equiv" "refresh") ("content" "0; url=/@{{ user.username }}/{{ journal.title }}/{{ note.title }}")) +(text "{% else %}") +; redirect to journal homepage +(meta ("http-equiv" "refresh") ("content" "0; url=/@{{ user.username }}/{{ journal.title }}")) +(text "{%- endif %} {%- endif %}") +(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"journals\") }}") +(text "{% if not view_mode -%}") +(nav + ("class" "chats_nav") + (button + ("class" "flex gap-2 items-center active") + ("onclick" "toggle_sidebars(event)") + (text "{{ icon \"panel-left\" }} {% if community -%}") + (b + ("class" "name shorter") + (text "{% if community.context.display_name -%} {{ community.context.display_name }} {% else %} {{ community.title }} {%- endif %}")) + (text "{% else %}") + (b + (text "{{ text \"journals:label.my_journals\" }}")) + (text "{%- endif %}"))) +(text "{%- endif %}") +(div + ("class" "flex") + ; journals/notes listing + (text "{% if not view_mode -%}") + ; this isn't shown if we're in view mode + (div + ("class" "sidebar flex flex-col gap-2 justify-between") + ("id" "notes_list") + (div + ("class" "flex flex-col gap-2 w-full") + (button + ("class" "lowered justify-start w-full") + ("onclick" "create_journal()") + (icon (text "plus")) + (str (text "journals:action.create_journal"))) + + (text "{% for journal in journals %}") + (text "{{ components::journal_listing(journal=journal, notes=notes, selected_note=selected_note, selected_journal=selected_journal, view_mode=view_mode) }}") + (text "{% endfor %}"))) + (text "{%- endif %}") + ; editor + (div + ("class" "w-full padded_section") + ("id" "editor") + ("style" "padding: var(--pad-4)") + (main + ("class" "flex flex-col gap-2") + ; the journal/note header is always shown + (text "{% if journal -%}") + (div + ("class" "mobile_nav w-full flex items-center justify-between gap-2") + (div + ("class" "flex gap-2 items-center") + (a + ("class" "flex items-center") + ("href" "/api/v1/auth/user/find/{{ journal.owner }}") + (text "{{ components::avatar(username=journal.owner, selector_type=\"id\", size=\"18px\") }}")) + + (a + ("class" "flush") + ("href" "{% if view_mode -%} /@{{ owner.username }}/{{ journal.title }}/index {%- else -%} /journals/{{ journal.id }}/0 {%- endif %}") + (b (text "{{ journal.title }}"))) + + (text "{% if note -%}") + (span (text "/")) + (b (text "{{ note.title }}")) + (text "{%- endif %}")) + + (text "{% if user and user.id == journal.owner -%}") + (div + ("class" "pillmenu") + (a + ("class" "{% if not view_mode -%}active{%- endif %}") + ("href" "/journals/{{ journal.id }}/{% if note -%} {{ note.id }} {%- else -%} 0 {%- endif %}") + ("data-turbo" "false") + (icon (text "pencil"))) + (a + ("class" "{% if view_mode -%}active{%- endif %}") + ("href" "/@{{ user.username }}/{{ journal.title }}/{% if note -%} {{ note.title }} {%- else -%} index {%- endif %}") + (icon (text "eye")))) + (text "{%- endif %}")) + (text "{%- endif %}") + + ; we're going to put some help panes in here if something is 0 + ; ...people got confused on the chats page because they somehow couldn't figure out how to open the sidebar + (text "{% if selected_journal == 0 -%}") + ; no journal selected + (div + ("class" "card w-full flex flex-col gap-2") + (h3 (str (text "journals:label.welcome"))) + (span (str (text "journals:label.select_a_journal"))) + (button + ("onclick" "create_journal()") + (icon (text "plus")) + (str (text "journals:action.create_journal")))) + (text "{% elif selected_note == 0 -%}") + ; journal selected, but no note is selected + (text "{% if not view_mode -%}") + ; we're the journal owner and we're not in view mode + (div + ("class" "card w-full flex flex-col gap-2") + (h3 (text "{{ journal.title }}")) + (span (str (text "journals:label.select_a_note"))) + (button + ("onclick" "create_note()") + (icon (text "plus")) + (str (text "journals:action.create_note")))) + + ; we'll also let users edit the journal's settings here i guess + (details + ("class" "w-full") + (summary + ("class" "button lowered w-full justify-start") + (icon (text "settings")) + (str (text "general:action.manage"))) + + (div + ("class" "card flex flex-col gap-2 lowered") + (div + ("class" "card-nest") + (div + ("class" "card small") + (b + (text "Privacy"))) + (div + ("class" "card") + (select + ("onchange" "change_journal_privacy(event)") + (option + ("value" "Private") + ("selected" "{% if journal.privacy == 'Private' -%}true{% else %}false{%- endif %}") + (text "Private")) + (option + ("value" "Public") + ("selected" "{% if journal.privacy == 'Public' -%}true{% else %}false{%- endif %}") + (text "Public"))))) + + (div + ("class" "card-nest") + (div + ("class" "card small") + (label + ("for" "title") + (b (str (text "communities:label.title"))))) + + (form + ("class" "card flex flex-col gap-2") + ("onsubmit" "change_journal_title(event)") + (div + ("class" "flex flex-col gap-1") + (input + ("type" "text") + ("name" "title") + ("id" "title") + ("placeholder" "title") + ("required" "") + ("minlength" "2"))) + (button + ("class" "primary") + (text "{{ icon \"check\" }}") + (span + (text "{{ text \"general:action.save\" }}"))))))) + (text "{% else %}") + ; we're in view mode; just show journal listing and notes as journal homepage + (div + ("class" "card flex flex-col gap-2") + (text "{{ components::journal_listing(journal=journal, notes=notes, selected_note=selected_note, selected_journal=selected_journal, view_mode=view_mode, owner=owner) }}")) + (text "{%- endif %}") + (text "{% else %}") + ; journal AND note selected + (text "{% if not view_mode -%}") + ; not view mode; show editor + ; import codemirror + (script ("src" "https://unpkg.com/codemirror@5.39.2/lib/codemirror.js") ("data-turbo-temporary" "true")) + (script ("src" "https://unpkg.com/codemirror@5.39.2/addon/display/placeholder.js") ("data-turbo-temporary" "true")) + (script ("src" "https://unpkg.com/codemirror@5.39.2/mode/markdown/markdown.js") ("data-turbo-temporary" "true")) + (link ("rel" "stylesheet") ("href" "https://unpkg.com/codemirror@5.39.2/lib/codemirror.css") ("data-turbo-temporary" "true")) + + ; tab bar + (div + ("class" "pillmenu") + (a + ("href" "#/editor") + ("data-tab-button" "editor") + ("data-turbo" "false") + ("class" "active") + (str (text "journals:label.editor"))) + + (a + ("href" "#/preview") + ("data-tab-button" "preview") + ("data-turbo" "false") + (str (text "journals:label.preview_pane")))) + + ; tabs + (div + ("data-tab" "editor") + ("class" "flex flex-col gap-2 card") + ("style" "animation: fadein ease-in-out 1 0.5s forwards running") + ("id" "editor_tab")) + + (div + ("data-tab" "preview") + ("class" "flex flex-col gap-2 card hidden") + ("style" "animation: fadein ease-in-out 1 0.5s forwards running") + ("id" "preview_tab")) + + (button + ("onclick" "change_note_content('{{ note.id }}')") + (icon (text "check")) + (str (text "general:action.save"))) + + ; init codemirror + (script ("id" "editor_content") ("type" "text/markdown") (text "{{ note.content|remove_script_tags|safe }}")) + (script + (text "setTimeout(() => { + globalThis.editor = CodeMirror(document.getElementById(\"editor_tab\"), { + value: document.getElementById(\"editor_content\").innerHTML, + mode: \"markdown\", + lineWrapping: true, + autoCloseBrackets: true, + autofocus: true, + viewportMargin: Number.POSITIVE_INFINITY, + inputStyle: \"contenteditable\", + highlightFormatting: false, + fencedCodeBlockHighlighting: false, + xml: false, + smartIndent: false, + placeholder: `# {{ note.title }}`, + extraKeys: { + Home: \"goLineLeft\", + End: \"goLineRight\", + Enter: (cm) => { + cm.replaceSelection(\"\\n\"); + }, + }, + }); + + document.querySelector(\"[data-tab-button=editor]\").addEventListener(\"click\", async (e) => { + e.preventDefault(); + trigger(\"atto::hooks::tabs:switch\", [\"editor\"]); + }); + + document.querySelector(\"[data-tab-button=preview]\").addEventListener(\"click\", async (e) => { + e.preventDefault(); + const res = await ( + await fetch(\"/api/v1/notes/preview\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + content: globalThis.editor.getValue(), + }), + }) + ).text(); + + document.getElementById(\"preview_tab\").innerHTML = res; + trigger(\"atto::hooks::tabs:switch\", [\"preview\"]); + }); + }, 150);")) + (text "{% else %}") + ; we're just viewing this note + (div + ("class" "flex flex-col gap-2 card") + (text "{{ note.content|markdown|safe }}")) + + (span (text "Last updated: ") (span ("class" "date") (text "{{ note.edited }}"))) + (text "{%- endif %}") + (text "{%- endif %}"))) + (style + (text "nav::after { + width: 100%; + left: 0; + }")) + (script + (text "window.JOURNAL_PROPS = { + selected_journal: \"{{ selected_journal }}\", + selected_note: \"{{ selected_note }}\", + }; + + // journals/notes + globalThis.create_journal = async () => { + const title = await trigger(\"atto::prompt\", [\"Journal title:\"]); + + if (!title) { + return; + } + + fetch(\"/api/v1/journals\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + title, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + setTimeout(() => { + window.location.href = `/journals/${res.payload}/0`; + }, 100); + } + }); + } + + globalThis.create_note = async () => { + const title = await trigger(\"atto::prompt\", [\"Note title:\"]); + + if (!title) { + return; + } + + fetch(\"/api/v1/notes\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + title, + content: `# ${title}`, + journal: \"{{ selected_journal }}\", + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + setTimeout(() => { + window.location.href = `/journals/{{ selected_journal }}/${res.payload}`; + }, 100); + } + }); + } + + globalThis.delete_journal = async (id) => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + fetch(`/api/v1/journals/${id}`, { + method: \"DELETE\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + setTimeout(() => { + window.location.href = \"/journals\"; + }, 100); + } + }); + } + + globalThis.delete_note = async (id) => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + fetch(`/api/v1/notes/${id}`, { + method: \"DELETE\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + setTimeout(() => { + window.location.href = \"/journals/{{ selected_journal }}/0\"; + }, 100); + } + }); + } + + globalThis.change_journal_title = async (e) => { + e.preventDefault(); + fetch(\"/api/v1/journals/{{ selected_journal }}/title\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + title: e.target.title.value, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + e.reset(); + } + }); + } + + globalThis.change_journal_privacy = async (e) => { + e.preventDefault(); + const selected = event.target.selectedOptions[0]; + fetch(\"/api/v1/journals/{{ selected_journal }}/privacy\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + privacy: selected.value, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + } + + globalThis.change_note_title = async (id) => { + const title = await trigger(\"atto::prompt\", [\"New note title:\"]); + + if (!title) { + return; + } + + fetch(`/api/v1/notes/${id}/title`, { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + title, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + e.reset(); + } + }); + } + + globalThis.change_note_content = async (id) => { + fetch(`/api/v1/notes/${id}/content`, { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + content: globalThis.editor.getValue(), + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + } + + // sidebars + window.SIDEBARS_OPEN = false; + if (new URLSearchParams(window.location.search).get(\"nav\") === \"true\") { + window.SIDEBARS_OPEN = true; + } + + if ( + window.SIDEBARS_OPEN && + !document.body.classList.contains(\"sidebars_shown\") + ) { + toggle_sidebars(); + window.SIDEBARS_OPEN = true; + } + + for (const anchor of document.querySelectorAll(\"[data-turbo=false]\")) { + anchor.href += `?nav=${window.SIDEBARS_OPEN}`; + } + + function toggle_sidebars() { + window.SIDEBARS_OPEN = !window.SIDEBARS_OPEN; + + for (const anchor of document.querySelectorAll( + \"[data-turbo=false]\", + )) { + anchor.href = anchor.href.replace( + `?nav=${!window.SIDEBARS_OPEN}`, + `?nav=${window.SIDEBARS_OPEN}`, + ); + } + + const notes_list = document.getElementById(\"notes_list\"); + + if (document.body.classList.contains(\"sidebars_shown\")) { + // hide + document.body.classList.remove(\"sidebars_shown\"); + notes_list.style.left = \"-200%\"; + } else { + // show + document.body.classList.add(\"sidebars_shown\"); + notes_list.style.left = \"0\"; + } + }"))) +(text "{% endblock %}") diff --git a/crates/app/src/public/html/root.lisp b/crates/app/src/public/html/root.lisp index d126e16..83dd9af 100644 --- a/crates/app/src/public/html/root.lisp +++ b/crates/app/src/public/html/root.lisp @@ -9,7 +9,7 @@ (meta ("http-equiv" "X-UA-Compatible") ("content" "ie=edge")) (link ("rel" "icon") ("href" "/public/favicon.svg")) - (link ("rel" "stylesheet") ("href" "/css/style.css")) + (link ("rel" "stylesheet") ("href" "/css/style.css?v=tetratto-{{ random_cache_breaker }}")) (text "{% if user -%}