From a7c00467622b6545e315fc8728b348cf7557b82e Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 15 Jun 2025 18:22:29 -0400 Subject: [PATCH 01/73] 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 02/73] 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 03/73] 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 04/73] 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 05/73] 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 06/73] 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 07/73] 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 08/73] 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 09/73] 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 10/73] 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 11/73] 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 12/73] 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 13/73] 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 14/73] 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 17/73] 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 18/73] 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 19/73] 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 20/73] 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 -%}