From a7c00467622b6545e315fc8728b348cf7557b82e Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Sun, 15 Jun 2025 18:22:29 -0400
Subject: [PATCH 01/71] 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 <me@trisua.com>
Date: Sun, 15 Jun 2025 18:38:37 -0400
Subject: [PATCH 02/71] 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 <me@trisua.com>
Date: Sun, 15 Jun 2025 18:55:19 -0400
Subject: [PATCH 03/71] 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<usize>)@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<usize>,
     /// 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::<usize>().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 <me@trisua.com>
Date: Sun, 15 Jun 2025 19:04:56 -0400
Subject: [PATCH 04/71] 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 <me@trisua.com>
Date: Sun, 15 Jun 2025 19:19:41 -0400
Subject: [PATCH 05/71] 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 <me@trisua.com>
Date: Sun, 15 Jun 2025 19:26:52 -0400
Subject: [PATCH 06/71] 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 <me@trisua.com>
Date: Sun, 15 Jun 2025 23:35:19 -0400
Subject: [PATCH 07/71] 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 <me@trisua.com>
Date: Sun, 15 Jun 2025 23:40:36 -0400
Subject: [PATCH 08/71] 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 <me@trisua.com>
Date: Sun, 15 Jun 2025 23:52:33 -0400
Subject: [PATCH 09/71] 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 <me@trisua.com>
Date: Mon, 16 Jun 2025 18:32:22 -0400
Subject: [PATCH 10/71] 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();
+        }
     });
 </script>
 {%- 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<User>,
-    ) -> 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 <me@trisua.com>
Date: Mon, 16 Jun 2025 19:08:40 -0400
Subject: [PATCH 11/71] 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 <me@trisua.com>
Date: Mon, 16 Jun 2025 19:50:10 -0400
Subject: [PATCH 12/71] 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<ProxyQuery>,
     Extension(data): Extension<State>,
 ) -> 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 <me@trisua.com>
Date: Tue, 17 Jun 2025 01:52:17 -0400
Subject: [PATCH 13/71] 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>")
 
         (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!<!-- observer_disconnect_{{ random_cache_breaker }} -->")))
+    (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}</textarea>` : ""}
             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!<!-- observer_disconnect_${window.BUILD_CODE} -->`,
+            )
+        ) {
+            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<State>,
+    Query(req): Query<TimelineQuery>,
+) -> 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::<usize>() {
+                        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 <me@trisua.com>
Date: Tue, 17 Jun 2025 14:28:18 -0400
Subject: [PATCH 14/71] 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 data-turbo-permanent=\"true\" id=\"init-script\">
     document.documentElement.addEventListener(\"turbo:load\", () => {
diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp
index b604f2c..8905fe1 100644
--- a/crates/app/src/public/html/components.lisp
+++ b/crates/app/src/public/html/components.lisp
@@ -240,7 +240,7 @@
                         ("class" "card lowered red flex items-center gap-2")
                         (text "{{ icon \"frown\" }}")
                         (span
-                            (text "Could not find original post...")))
+                            (str (text "general:label.could_not_find_post"))))
                     (text "{%- endif %} {%- endif %}"))
                 (text "{{ self::post_media(upload_ids=post.uploads) }} {% else %}")
                 (details
diff --git a/crates/app/src/public/html/profile/posts.lisp b/crates/app/src/public/html/profile/posts.lisp
index 325f3ac..0c9d79a 100644
--- a/crates/app/src/public/html/profile/posts.lisp
+++ b/crates/app/src/public/html/profile/posts.lisp
@@ -27,7 +27,7 @@
                 (text "{{ text \"auth:label.recent_posts\" }}"))
             (text "{% else %} {{ icon \"tag\" }}")
             (span
-                (text "{{ text \"auth:label.recent_with_tag\" }}:")
+                (text "{{ text \"auth:label.recent_with_tag\" }}: ")
                 (b
                     (text "{{ tag }}")))
             (text "{%- endif %}"))
@@ -40,7 +40,13 @@
                 (text "{{ text \"general:link.search\" }}")))
         (text "{%- endif %}"))
     (div
-        ("class" "card flex flex-col gap-4")
-        (text "{% for post in posts %} {% if post[2].read_access == \"Everybody\" -%} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true, can_manage_post=is_self) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], can_manage_post=is_self, poll=post[5]) }} {%- endif %} {%- endif %} {% endfor %}  {{ components::pagination(page=page, items=posts|length, key=\"&tag=\", value=tag) }}")))
+        ("class" "card w-full flex flex-col gap-2")
+        ("ui_ident" "io_data_load")
+        (div ("ui_ident" "io_data_marker"))))
+
+(script
+    (text "setTimeout(() => {
+        trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?user_id={{ profile.id }}&tag={{ tag }}&page=\", Number.parseInt(\"{{ page }}\") - 1]);
+    });"))
 
 (text "{% endblock %}")
diff --git a/crates/app/src/public/html/stacks/feed.lisp b/crates/app/src/public/html/stacks/feed.lisp
index 51f6546..5698065 100644
--- a/crates/app/src/public/html/stacks/feed.lisp
+++ b/crates/app/src/public/html/stacks/feed.lisp
@@ -40,9 +40,9 @@
                 (text "{%- endif %}")))
         (div
             ("class" "card w-full flex flex-col gap-2")
-            (text "{% if list|length == 0 -%}")
+            (text "{% if stack.users|length == 0 -%}")
             (p
-                (text "No items yet! Maybe ")
+                (text "No users included yet! Maybe ")
                 (a
                     ("href" "/stacks/{{ stack.id }}/manage#/users")
                     (text "add a user to this stack"))
@@ -63,6 +63,7 @@
             (div
                 ("class" "flex gap-2 flex-wrap w-full")
                 (text "{% for user in list %} {{ components::user_plate(user=user, secondary=true) }} {% endfor %}"))
+            (text "{{ components::pagination(page=page, items=list|length) }}")
             (text "{% else %}")
             ; user icons for circle stack
             (text "{% if stack.mode == 'Circle' -%}")
@@ -77,14 +78,16 @@
             (text "{%- endif %}")
 
             ; posts for all stacks except blocklist
-            (text "{% for post in list %}
-                {% if post[2].read_access == \"Everybody\" -%}
-                {% if post[0].context.repost and post[0].context.repost.reposting -%}
-                    {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true) }}
-                {% else %}
-                    {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], poll=post[5]) }}
-                {%- endif %} {%- endif %} {% endfor %}")
-            (text "{%- endif %} {{ components::pagination(page=page, items=list|length) }}"))))
+            (div
+                ("class" "w-full flex flex-col gap-2")
+                ("ui_ident" "io_data_load")
+                (div ("ui_ident" "io_data_marker")))
+
+            (script
+                (text "setTimeout(() => {
+                    trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?stack_id={{ stack.id }}&page=\", Number.parseInt(\"{{ page }}\") - 1]);
+                });"))
+            (text "{%- endif %}"))))
 
 (script
     (text "async function block_all(block = true) {
diff --git a/crates/app/src/public/html/timelines/all.lisp b/crates/app/src/public/html/timelines/all.lisp
index 9434aab..c38dd88 100644
--- a/crates/app/src/public/html/timelines/all.lisp
+++ b/crates/app/src/public/html/timelines/all.lisp
@@ -31,12 +31,11 @@
     (div
         ("class" "card w-full flex flex-col gap-2")
         ("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 }}\")]);
+        trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=AllPosts&page=\", Number.parseInt(\"{{ page }}\") - 1]);
     });"))
 
 (text "{% endblock %}")
diff --git a/crates/app/src/public/html/timelines/following.lisp b/crates/app/src/public/html/timelines/following.lisp
index b36d889..b1759e4 100644
--- a/crates/app/src/public/html/timelines/following.lisp
+++ b/crates/app/src/public/html/timelines/following.lisp
@@ -9,12 +9,11 @@
     (div
         ("class" "card w-full flex flex-col gap-2")
         ("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 }}\")]);
+        trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=FollowingPosts&page=\", Number.parseInt(\"{{ page }}\") - 1]);
     });"))
 
 (text "{% endblock %}")
diff --git a/crates/app/src/public/html/timelines/home.lisp b/crates/app/src/public/html/timelines/home.lisp
index 2705641..e398615 100644
--- a/crates/app/src/public/html/timelines/home.lisp
+++ b/crates/app/src/public/html/timelines/home.lisp
@@ -28,13 +28,12 @@
     (div
         ("class" "card w-full flex flex-col gap-2")
         ("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 }}\")]);
+        trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=MyCommunities&page=\", Number.parseInt(\"{{ page }}\") - 1]);
     });"))
 
 (text "{% endblock %}")
diff --git a/crates/app/src/public/html/timelines/popular.lisp b/crates/app/src/public/html/timelines/popular.lisp
index 85ed6f4..6d26f3d 100644
--- a/crates/app/src/public/html/timelines/popular.lisp
+++ b/crates/app/src/public/html/timelines/popular.lisp
@@ -9,12 +9,11 @@
     (div
         ("class" "card w-full flex flex-col gap-2")
         ("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 }}\")]);
+        trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=PopularPosts&page=\", Number.parseInt(\"{{ page }}\") - 1]);
     });"))
 
 (text "{% endblock %}")
diff --git a/crates/app/src/public/html/timelines/swiss_army.lisp b/crates/app/src/public/html/timelines/swiss_army.lisp
index c8734bc..eb722c9 100644
--- a/crates/app/src/public/html/timelines/swiss_army.lisp
+++ b/crates/app/src/public/html/timelines/swiss_army.lisp
@@ -20,10 +20,13 @@
         ("class" "flex items-center gap-2")
         (text "{{ icon \"shell\" }}")
         (span
-            (text "That's a wrap!<!-- observer_disconnect_{{ random_cache_breaker }} -->")))
+            (str (text "general:label.timeline_end"))
+            (text "<!-- observer_disconnect_{{ random_cache_breaker }} -->")))
+    (text "{% if page > 0 -%}")
     (a
         ("class" "button")
         ("href" "?page=0")
         (icon (text "arrow-up"))
-        (str (text "chats:label.go_back"))))
+        (str (text "chats:label.go_back")))
+    (text "{%- endif %}"))
 (text "{%- endif %}")
diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js
index 9dd1c0e..940c259 100644
--- a/crates/app/src/public/js/atto.js
+++ b/crates/app/src/public/js/atto.js
@@ -25,10 +25,10 @@ function media_theme_pref() {
     }
 }
 
-function set_theme(theme) {
+window.set_theme = (theme) => {
     window.localStorage.setItem("tetratto:theme", theme);
     document.documentElement.className = theme;
-}
+};
 
 media_theme_pref();
 
@@ -91,7 +91,7 @@ media_theme_pref();
 
     self.define("rel_date", (_, date) => {
         // stolen and slightly modified because js dates suck
-        const diff = Math.abs((new Date().getTime() - date.getTime()) / 1000);
+        const diff = Math.abs((Date.now() - date.getTime()) / 1000);
         const day_diff = Math.floor(diff / 86400);
 
         if (Number.isNaN(day_diff) || day_diff < 0 || day_diff >= 31) {
@@ -396,7 +396,7 @@ media_theme_pref();
         counter.innerText = `${target.value.length}/${target.getAttribute("maxlength")}`;
     });
 
-    self.define("hooks::character_counter.init", (_, event) => {
+    self.define("hooks::character_counter.init", (_) => {
         for (const element of Array.from(
             document.querySelectorAll("[hook=counter]") || [],
         )) {
@@ -413,7 +413,7 @@ media_theme_pref();
         element.innerHTML = full_text;
     });
 
-    self.define("hooks::long_text.init", (_, event) => {
+    self.define("hooks::long_text.init", (_) => {
         for (const element of Array.from(
             document.querySelectorAll("[hook=long]") || [],
         )) {
@@ -493,13 +493,13 @@ media_theme_pref();
     });
 
     self.define("last_seen_just_now", (_, last_seen) => {
-        const now = new Date().getTime();
+        const now = Date.now();
         const maximum_time_to_be_considered_online = 60000 * 2; // 2 minutes
         return now - last_seen <= maximum_time_to_be_considered_online;
     });
 
     self.define("last_seen_recently", (_, last_seen) => {
-        const now = new Date().getTime();
+        const now = Date.now();
         const maximum_time_to_be_considered_idle = 60000 * 5; // 5 minutes
         return now - last_seen <= maximum_time_to_be_considered_idle;
     });
@@ -585,8 +585,8 @@ media_theme_pref();
 
     self.define(
         "hooks::attach_to_partial",
-        ({ $ }, partial, full, attach, wrapper, page, run_on_load) => {
-            return new Promise((resolve, reject) => {
+        ({ $ }, partial, full, attach, wrapper, page) => {
+            return new Promise((resolve, _) => {
                 async function load_partial() {
                     const url = `${partial}${partial.includes("?") ? "&" : "?"}page=${page}`;
                     history.replaceState(
@@ -1148,6 +1148,8 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
             "[ui_ident=io_data_load]",
         );
 
+        self.IO_HTML_TMPL = document.getElementById("loading_skeleton");
+
         if (!self.IO_DATA_ELEMENT || !self.IO_DATA_MARKER) {
             console.warn(
                 "ui::io_data_load called, but required elements don't exist",
@@ -1167,14 +1169,19 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
         self.IO_DATA_PAGE += 1;
         console.log("load page", self.IO_DATA_PAGE);
 
+        // show loading component
+        const loading = self.IO_HTML_TMPL.content.cloneNode(true);
+        self.IO_DATA_ELEMENT.appendChild(loading);
+
+        // ...
         const text = await (
             await fetch(`${self.IO_DATA_TMPL}${self.IO_DATA_PAGE}`)
         ).text();
 
+        self.IO_DATA_ELEMENT.querySelector("[ui_ident=loading_skel]").remove();
+
         if (
-            text.includes(
-                `That's a wrap!<!-- observer_disconnect_${window.BUILD_CODE} -->`,
-            )
+            text.includes(`!<!-- observer_disconnect_${window.BUILD_CODE} -->`)
         ) {
             console.log("io_data_end; disconnect");
             self.IO_DATA_OBSERVER.disconnect();
@@ -1251,7 +1258,7 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
 
     self.define(
         "open",
-        async ({ $ }, warning_id, warning_hash, warning_page = "") => {
+        async (_, warning_id, warning_hash, warning_page = "") => {
             // check localStorage for this warning_id
             if (accepted_warnings[warning_id] !== undefined) {
                 // check hash
@@ -1272,7 +1279,7 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
         },
     );
 
-    self.define("accept", ({ _ }, warning_id, warning_hash) => {
+    self.define("accept", (_, warning_id, warning_hash) => {
         accepted_warnings[warning_id] = warning_hash;
 
         window.localStorage.setItem(
diff --git a/crates/app/src/routes/pages/misc.rs b/crates/app/src/routes/pages/misc.rs
index 3a6c9f9..3ff3f0d 100644
--- a/crates/app/src/routes/pages/misc.rs
+++ b/crates/app/src/routes/pages/misc.rs
@@ -42,22 +42,9 @@ pub async fn index_request(
             return {
                 // all timeline for unauthenticated users
                 // i'm only changing this for stripe
-                let list = match data.0.get_latest_posts(12, req.page).await {
-                    Ok(l) => match data
-                        .0
-                        .fill_posts_with_community(l, 0, &Vec::new(), &None)
-                        .await
-                    {
-                        Ok(l) => l,
-                        Err(e) => return Html(render_error(e, &jar, &data, &None).await),
-                    },
-                    Err(e) => return Html(render_error(e, &jar, &data, &None).await),
-                };
-
                 let lang = get_lang!(jar, data.0);
                 let mut context = initial_context(&data.0.0.0, lang, &None).await;
 
-                context.insert("list", &list);
                 context.insert("page", &req.page);
                 Html(data.1.render("timelines/all.html", &context).unwrap())
             };
@@ -101,36 +88,9 @@ pub async fn popular_request(
     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 data.0.get_popular_posts(12, req.page, 604_800_000).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 Html(render_error(e, &jar, &data, &user).await),
-        },
-        Err(e) => return 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);
     Html(data.1.render("timelines/popular.html", &context).unwrap())
 }
@@ -151,30 +111,9 @@ pub async fn following_request(
         }
     };
 
-    let ignore_users = crate::ignore_users_gen!(user!, data);
-
-    let list = match data
-        .0
-        .get_posts_from_user_following(user.id, 12, req.page)
-        .await
-    {
-        Ok(l) => match data
-            .0
-            .fill_posts_with_community(l, user.id, &ignore_users, &Some(user.clone()))
-            .await
-        {
-            Ok(l) => data
-                .0
-                .posts_muted_phrase_filter(&l, Some(&user.settings.muted)),
-            Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
-        },
-        Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
-    };
-
     let lang = get_lang!(jar, data.0);
     let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
 
-    context.insert("list", &list);
     context.insert("page", &req.page);
     Ok(Html(
         data.1.render("timelines/following.html", &context).unwrap(),
@@ -190,36 +129,9 @@ pub async fn all_request(
     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 data.0.get_latest_posts(12, req.page).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 Html(render_error(e, &jar, &data, &user).await),
-        },
-        Err(e) => return 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);
     Html(data.1.render("timelines/all.html", &context).unwrap())
 }
@@ -654,8 +566,16 @@ pub async fn search_request(
 
 #[derive(Deserialize)]
 pub struct TimelineQuery {
+    #[serde(default)]
     pub tl: DefaultTimelineChoice,
+    #[serde(default)]
     pub page: usize,
+    #[serde(default)]
+    pub stack_id: usize,
+    #[serde(default)]
+    pub user_id: usize,
+    #[serde(default)]
+    pub tag: String,
 }
 
 /// `/_swiss_army_timeline`
@@ -669,83 +589,107 @@ pub async fn swiss_army_timeline_request(
 
     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::<usize>() {
-                        Ok(s) => s,
-                        Err(_) => {
-                            return Err(Html(
-                                render_error(
-                                    Error::MiscError("ID deserialization error".to_string()),
-                                    &jar,
-                                    &data,
-                                    &user,
-                                )
-                                .await,
-                            ));
-                        }
-                    },
+    let list = if req.stack_id != 0 {
+        // stacks
+        if let Some(ref ua) = user {
+            match data
+                .0
+                .get_stack_posts(
+                    ua.id,
+                    req.stack_id,
                     12,
                     req.page,
+                    &ignore_users,
+                    &Some(ua.to_owned()),
                 )
                 .await
-        }
-        // questions bad
-        _ => {
+            {
+                Ok(l) => l,
+                Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
+            }
+        } else {
             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
-                },
-            ),
+    } else {
+        match if req.user_id != 0 {
+            // users
+            let other_user = match data.0.get_user_by_id(req.user_id).await {
+                Ok(ua) => ua,
+                Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
+            };
+
+            check_user_blocked_or_private!(user, other_user, data, jar);
+
+            if req.tag.is_empty() {
+                data.0
+                    .get_posts_by_user(req.user_id, 12, req.page, &user)
+                    .await
+            } else {
+                data.0
+                    .get_posts_by_user_tag(req.user_id, &req.tag, 12, req.page, &user)
+                    .await
+            }
+        } else {
+            // everything else
+            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,
+                        ));
+                    }
+                }
+                // 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)),
-        },
-        Err(e) => return Ok(Html(render_error(e, &jar, &data, &user).await)),
+        }
     };
 
     let lang = get_lang!(jar, data.0);
diff --git a/crates/app/src/routes/pages/profile.rs b/crates/app/src/routes/pages/profile.rs
index cd780e4..89a4026 100644
--- a/crates/app/src/routes/pages/profile.rs
+++ b/crates/app/src/routes/pages/profile.rs
@@ -1,6 +1,7 @@
 use super::{render_error, PaginatedQuery, ProfileQuery};
 use crate::{
-    assets::initial_context, check_user_blocked_or_private, get_lang, get_user_from_token, State,
+    assets::initial_context, check_user_blocked_or_private, get_lang, get_user_from_token,
+    ignore_users_gen, State,
 };
 use axum::{
     Extension,
@@ -241,67 +242,8 @@ pub async fn posts_request(
         ));
     }
 
-    // fetch data
-    let ignore_users = crate::ignore_users_gen!(user, data);
-
-    let posts = if props.tag.is_empty() {
-        match data
-            .0
-            .get_posts_by_user(other_user.id, 12, props.page, &user)
-            .await
-        {
-            Ok(p) => match data
-                .0
-                .fill_posts_with_community(
-                    p,
-                    if let Some(ref ua) = user { ua.id } else { 0 },
-                    &ignore_users,
-                    &user,
-                )
-                .await
-            {
-                Ok(p) => data.0.posts_muted_phrase_filter(
-                    &p,
-                    if let Some(ref ua) = user {
-                        Some(&ua.settings.muted)
-                    } else {
-                        None
-                    },
-                ),
-                Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
-            },
-            Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
-        }
-    } else {
-        match data
-            .0
-            .get_posts_by_user_tag(other_user.id, &props.tag, 12, props.page, &user)
-            .await
-        {
-            Ok(p) => match data
-                .0
-                .fill_posts_with_community(
-                    p,
-                    if let Some(ref ua) = user { ua.id } else { 0 },
-                    &ignore_users,
-                    &user,
-                )
-                .await
-            {
-                Ok(p) => data.0.posts_muted_phrase_filter(
-                    &p,
-                    if let Some(ref ua) = user {
-                        Some(&ua.settings.muted)
-                    } else {
-                        None
-                    },
-                ),
-                Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
-            },
-            Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
-        }
-    };
-
+    // fetch pinned
+    let ignore_users = ignore_users_gen!(user, data);
     let pinned = if props.tag.is_empty() {
         match data.0.get_pinned_posts_by_user(other_user.id).await {
             Ok(p) => match data
@@ -375,7 +317,6 @@ pub async fn posts_request(
         false
     };
 
-    context.insert("posts", &posts);
     context.insert("pinned", &pinned);
     context.insert("page", &props.page);
     context.insert("tag", &props.tag);
diff --git a/crates/app/src/routes/pages/stacks.rs b/crates/app/src/routes/pages/stacks.rs
index 822b9b7..a7b33d8 100644
--- a/crates/app/src/routes/pages/stacks.rs
+++ b/crates/app/src/routes/pages/stacks.rs
@@ -92,25 +92,6 @@ pub async fn feed_request(
                 .await
                 .is_ok(),
         );
-    } else {
-        let ignore_users = crate::ignore_users_gen!(user!, data);
-        let list = match data
-            .0
-            .get_stack_posts(
-                user.id,
-                stack.id,
-                12,
-                req.page,
-                &ignore_users,
-                &Some(user.clone()),
-            )
-            .await
-        {
-            Ok(l) => l,
-            Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
-        };
-
-        context.insert("list", &list);
     }
 
     // return

From 0f48a46c400737672d6c9aa1d4285f255bf18c4b Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Tue, 17 Jun 2025 16:37:47 -0400
Subject: [PATCH 15/71] fix: infinite scrolling likes

---
 crates/app/src/public/js/atto.js | 1 +
 1 file changed, 1 insertion(+)

diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js
index 940c259..034181e 100644
--- a/crates/app/src/public/js/atto.js
+++ b/crates/app/src/public/js/atto.js
@@ -1246,6 +1246,7 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
         atto["hooks::alt"]();
         atto["hooks::online_indicator"]();
         atto["hooks::verify_emoji"]();
+        atto["hooks::check_reactions"]();
     });
 })();
 

From 102ea0ee3538e352ad8e2feadd6d3fa10c3e42af Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Wed, 18 Jun 2025 19:21:01 -0400
Subject: [PATCH 16/71] add: journals/notes database interfaces

---
 crates/app/src/public/html/macros.lisp        |  14 +-
 .../app/src/public/html/profile/settings.lisp |   4 +-
 crates/app/src/public/html/root.lisp          |   6 +-
 crates/core/src/database/common.rs            |   2 +
 crates/core/src/database/drivers/common.rs    |   2 +
 .../database/drivers/sql/create_journals.sql  |   7 +
 .../src/database/drivers/sql/create_notes.sql |   9 ++
 crates/core/src/database/journals.rs          | 141 ++++++++++++++++++
 crates/core/src/database/mod.rs               |   2 +
 crates/core/src/database/notes.rs             | 124 +++++++++++++++
 crates/core/src/database/posts.rs             |   9 +-
 crates/core/src/model/journals.rs             |  69 +++++++++
 crates/core/src/model/mod.rs                  |   1 +
 crates/core/src/model/permissions.rs          |   2 +
 14 files changed, 386 insertions(+), 6 deletions(-)
 create mode 100644 crates/core/src/database/drivers/sql/create_journals.sql
 create mode 100644 crates/core/src/database/drivers/sql/create_notes.sql
 create mode 100644 crates/core/src/database/journals.rs
 create mode 100644 crates/core/src/database/notes.rs
 create mode 100644 crates/core/src/model/journals.rs

diff --git a/crates/app/src/public/html/macros.lisp b/crates/app/src/public/html/macros.lisp
index 80ed3ba..18820ce 100644
--- a/crates/app/src/public/html/macros.lisp
+++ b/crates/app/src/public/html/macros.lisp
@@ -112,7 +112,19 @@
                         ("class" "button")
                         ("data-turbo" "false")
                         (icon (text "rabbit"))
-                        (str (text "general:link.reference"))))))
+                        (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")))))
             (text "{%- endif %}")))
 (text "{%- endmacro %}")
 
diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp
index 07d24a1..bb0277d 100644
--- a/crates/app/src/public/html/profile/settings.lisp
+++ b/crates/app/src/public/html/profile/settings.lisp
@@ -585,7 +585,9 @@
                                 (li
                                     (text "Add unlimited users to stacks"))
                                 (li
-                                    (text "Increased proxied image size")))
+                                    (text "Increased proxied image size"))
+                                (li
+                                    (text "Create infinite journals")))
                             (a
                                 ("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}")
                                 ("class" "button")
diff --git a/crates/app/src/public/html/root.lisp b/crates/app/src/public/html/root.lisp
index a7cfb4a..d126e16 100644
--- a/crates/app/src/public/html/root.lisp
+++ b/crates/app/src/public/html/root.lisp
@@ -25,7 +25,7 @@
             globalThis.ns_config = {
                 root: \"/js/\",
                 verbose: globalThis.ns_verbose,
-                version: \"cache-breaker-{{ random_cache_breaker }}\",
+                version: \"tetratto-{{ random_cache_breaker }}\",
             };
 
             globalThis._app_base = {
@@ -38,8 +38,8 @@
             globalThis.BUILD_CODE = \"{{ random_cache_breaker }}\";
         </script>")
 
-        (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<Vec<Journal>> {
+        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<Journal> {
+        // 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<Vec<Note>> {
+        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<Note> {
+        // 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::<usize>().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::<usize>().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 <me@trisua.com>
Date: Wed, 18 Jun 2025 21:00:07 -0400
Subject: [PATCH 17/71] 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<usize>,
+    Extension(data): Extension<State>,
+) -> 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<State>) -> 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<State>,
+    Json(props): Json<CreateJournal>,
+) -> 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<usize>,
+    Extension(data): Extension<State>,
+    Json(props): Json<UpdateJournalTitle>,
+) -> 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<usize>,
+    Extension(data): Extension<State>,
+    Json(props): Json<UpdateJournalView>,
+) -> 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<usize>,
+    Extension(data): Extension<State>,
+) -> 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<usize>,
+    Extension(data): Extension<State>,
+) -> 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<usize>,
+    Extension(data): Extension<State>,
+) -> 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<State>,
+    Json(props): Json<CreateNote>,
+) -> 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<usize>,
+    Extension(data): Extension<State>,
+    Json(props): Json<UpdateNoteTitle>,
+) -> 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<usize>,
+    Extension(data): Extension<State>,
+    Json(props): Json<UpdateNoteContent>,
+) -> 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<usize>,
+    Extension(data): Extension<State>,
+) -> 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<usize>,
+    Extension(data): Extension<State>,
+) -> 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<State>) -> 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<State>,
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 <me@trisua.com>
Date: Wed, 18 Jun 2025 21:32:05 -0400
Subject: [PATCH 18/71] 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 <me@trisua.com>
Date: Thu, 19 Jun 2025 00:20:04 -0400
Subject: [PATCH 19/71] 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}</textarea>` : ""}
             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 <me@trisua.com>
Date: Thu, 19 Jun 2025 15:48:04 -0400
Subject: [PATCH 20/71] 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 -%}
         <script>
diff --git a/crates/app/src/public/html/stacks/manage.lisp b/crates/app/src/public/html/stacks/manage.lisp
index 95f8545..85c4251 100644
--- a/crates/app/src/public/html/stacks/manage.lisp
+++ b/crates/app/src/public/html/stacks/manage.lisp
@@ -104,7 +104,7 @@
                     (div
                         ("class" "flex flex-col gap-1")
                         (label
-                            ("for" "new_title")
+                            ("for" "name")
                             (text "{{ text \"communities:label.name\" }}"))
                         (input
                             ("type" "text")
diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js
index 03bd6b2..6c30428 100644
--- a/crates/app/src/public/js/atto.js
+++ b/crates/app/src/public/js/atto.js
@@ -688,6 +688,8 @@ media_theme_pref();
     });
 
     self.define("hooks::tabs:switch", (_, tab) => {
+        tab = tab.split("?")[0];
+
         // tab
         for (const element of Array.from(
             document.querySelectorAll("[data-tab]"),
diff --git a/crates/app/src/routes/api/v1/journals.rs b/crates/app/src/routes/api/v1/journals.rs
index caa45be..97f8c9b 100644
--- a/crates/app/src/routes/api/v1/journals.rs
+++ b/crates/app/src/routes/api/v1/journals.rs
@@ -6,7 +6,7 @@ use axum::{
 use axum_extra::extract::CookieJar;
 use crate::{
     get_user_from_token,
-    routes::api::v1::{UpdateJournalView, CreateJournal, UpdateJournalTitle},
+    routes::api::v1::{UpdateJournalPrivacy, CreateJournal, UpdateJournalTitle},
     State,
 };
 use tetratto_core::model::{
@@ -81,7 +81,7 @@ pub async fn create_request(
         Ok(x) => Json(ApiReturn {
             ok: true,
             message: "Journal created".to_string(),
-            payload: Some(x),
+            payload: Some(x.id.to_string()),
         }),
         Err(e) => Json(e.into()),
     }
@@ -91,7 +91,7 @@ pub async fn update_title_request(
     jar: CookieJar,
     Path(id): Path<usize>,
     Extension(data): Extension<State>,
-    Json(props): Json<UpdateJournalTitle>,
+    Json(mut props): Json<UpdateJournalTitle>,
 ) -> impl IntoResponse {
     let data = &(data.read().await).0;
     let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageJournals) {
@@ -99,6 +99,18 @@ pub async fn update_title_request(
         None => return Json(Error::NotAllowed.into()),
     };
 
+    props.title = props.title.replace(" ", "_");
+
+    // make sure this title isn't already in use
+    if data
+        .get_journal_by_owner_title(user.id, &props.title)
+        .await
+        .is_ok()
+    {
+        return Json(Error::TitleInUse.into());
+    }
+
+    // ...
     match data.update_journal_title(id, &user, &props.title).await {
         Ok(_) => Json(ApiReturn {
             ok: true,
@@ -113,7 +125,7 @@ pub async fn update_privacy_request(
     jar: CookieJar,
     Path(id): Path<usize>,
     Extension(data): Extension<State>,
-    Json(props): Json<UpdateJournalView>,
+    Json(props): Json<UpdateJournalPrivacy>,
 ) -> impl IntoResponse {
     let data = &(data.read().await).0;
     let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageJournals) {
@@ -121,7 +133,7 @@ pub async fn update_privacy_request(
         None => return Json(Error::NotAllowed.into()),
     };
 
-    match data.update_journal_privacy(id, &user, props.view).await {
+    match data.update_journal_privacy(id, &user, props.privacy).await {
         Ok(_) => Json(ApiReturn {
             ok: true,
             message: "Journal updated".to_string(),
diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs
index 983d3fe..f16b1ed 100644
--- a/crates/app/src/routes/api/v1/mod.rs
+++ b/crates/app/src/routes/api/v1/mod.rs
@@ -563,6 +563,7 @@ pub fn routes() -> Router {
         .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))
+        .route("/notes/preview", post(notes::render_markdown_request))
         // uploads
         .route("/uploads/{id}", get(uploads::get_request))
         .route("/uploads/{id}", delete(uploads::delete_request))
@@ -887,8 +888,8 @@ pub struct UpdateJournalTitle {
 }
 
 #[derive(Deserialize)]
-pub struct UpdateJournalView {
-    pub view: JournalPrivacyPermission,
+pub struct UpdateJournalPrivacy {
+    pub privacy: JournalPrivacyPermission,
 }
 
 #[derive(Deserialize)]
@@ -900,3 +901,8 @@ pub struct UpdateNoteTitle {
 pub struct UpdateNoteContent {
     pub content: String,
 }
+
+#[derive(Deserialize)]
+pub struct RenderMarkdown {
+    pub content: String,
+}
diff --git a/crates/app/src/routes/api/v1/notes.rs b/crates/app/src/routes/api/v1/notes.rs
index 01645aa..45c4a74 100644
--- a/crates/app/src/routes/api/v1/notes.rs
+++ b/crates/app/src/routes/api/v1/notes.rs
@@ -4,15 +4,17 @@ use axum::{
     Extension,
 };
 use axum_extra::extract::CookieJar;
+use tetratto_shared::unix_epoch_timestamp;
 use crate::{
     get_user_from_token,
-    routes::api::v1::{CreateNote, UpdateNoteContent, UpdateNoteTitle},
+    routes::api::v1::{CreateNote, RenderMarkdown, UpdateNoteContent, UpdateNoteTitle},
     State,
 };
 use tetratto_core::model::{
     journals::{JournalPrivacyPermission, Note},
     oauth,
     permissions::FinePermission,
+    uploads::CustomEmoji,
     ApiReturn, Error,
 };
 
@@ -110,7 +112,7 @@ pub async fn create_request(
         Ok(x) => Json(ApiReturn {
             ok: true,
             message: "Note created".to_string(),
-            payload: Some(x),
+            payload: Some(x.id.to_string()),
         }),
         Err(e) => Json(e.into()),
     }
@@ -120,7 +122,7 @@ pub async fn update_title_request(
     jar: CookieJar,
     Path(id): Path<usize>,
     Extension(data): Extension<State>,
-    Json(props): Json<UpdateNoteTitle>,
+    Json(mut props): Json<UpdateNoteTitle>,
 ) -> impl IntoResponse {
     let data = &(data.read().await).0;
     let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) {
@@ -128,6 +130,23 @@ pub async fn update_title_request(
         None => return Json(Error::NotAllowed.into()),
     };
 
+    let note = match data.get_note_by_id(id).await {
+        Ok(n) => n,
+        Err(e) => return Json(e.into()),
+    };
+
+    props.title = props.title.replace(" ", "_");
+
+    // make sure this title isn't already in use
+    if data
+        .get_note_by_journal_title(note.journal, &props.title)
+        .await
+        .is_ok()
+    {
+        return Json(Error::TitleInUse.into());
+    }
+
+    // ...
     match data.update_note_title(id, &user, &props.title).await {
         Ok(_) => Json(ApiReturn {
             ok: true,
@@ -151,11 +170,20 @@ pub async fn update_content_request(
     };
 
     match data.update_note_content(id, &user, &props.content).await {
-        Ok(_) => Json(ApiReturn {
-            ok: true,
-            message: "Note updated".to_string(),
-            payload: (),
-        }),
+        Ok(_) => {
+            if let Err(e) = data
+                .update_note_edited(id, unix_epoch_timestamp() as i64)
+                .await
+            {
+                return Json(e.into());
+            }
+
+            Json(ApiReturn {
+                ok: true,
+                message: "Note updated".to_string(),
+                payload: (),
+            })
+        }
         Err(e) => Json(e.into()),
     }
 }
@@ -180,3 +208,9 @@ pub async fn delete_request(
         Err(e) => Json(e.into()),
     }
 }
+
+pub async fn render_markdown_request(Json(req): Json<RenderMarkdown>) -> impl IntoResponse {
+    tetratto_shared::markdown::render_markdown(&CustomEmoji::replace(&req.content))
+        .replace("\\@", "@")
+        .replace("%5C@", "@")
+}
diff --git a/crates/app/src/routes/assets.rs b/crates/app/src/routes/assets.rs
index 82a759e..f721eb3 100644
--- a/crates/app/src/routes/assets.rs
+++ b/crates/app/src/routes/assets.rs
@@ -12,6 +12,7 @@ serve_asset!(favicon_request: FAVICON("image/svg+xml"));
 serve_asset!(style_css_request: STYLE_CSS("text/css"));
 serve_asset!(root_css_request: ROOT_CSS("text/css"));
 serve_asset!(utility_css_request: UTILITY_CSS("text/css"));
+serve_asset!(chats_css_request: CHATS_CSS("text/css"));
 
 serve_asset!(loader_js_request: LOADER_JS("text/javascript"));
 serve_asset!(atto_js_request: ATTO_JS("text/javascript"));
diff --git a/crates/app/src/routes/mod.rs b/crates/app/src/routes/mod.rs
index 9538133..123f29a 100644
--- a/crates/app/src/routes/mod.rs
+++ b/crates/app/src/routes/mod.rs
@@ -14,6 +14,7 @@ pub fn routes(config: &Config) -> Router {
         .route("/css/style.css", get(assets::style_css_request))
         .route("/css/root.css", get(assets::root_css_request))
         .route("/css/utility.css", get(assets::utility_css_request))
+        .route("/css/chats.css", get(assets::chats_css_request))
         .route("/js/loader.js", get(assets::loader_js_request))
         .route("/js/atto.js", get(assets::atto_js_request))
         .route("/js/me.js", get(assets::me_js_request))
diff --git a/crates/app/src/routes/pages/journals.rs b/crates/app/src/routes/pages/journals.rs
new file mode 100644
index 0000000..f631826
--- /dev/null
+++ b/crates/app/src/routes/pages/journals.rs
@@ -0,0 +1,209 @@
+use axum::{
+    extract::{Path, Query},
+    response::{Html, IntoResponse, Redirect},
+    Extension,
+};
+use axum_extra::extract::CookieJar;
+use crate::{
+    assets::initial_context,
+    check_user_blocked_or_private, get_lang, get_user_from_token,
+    routes::pages::{render_error, JournalsAppQuery},
+    State,
+};
+use tetratto_core::model::{journals::JournalPrivacyPermission, Error};
+
+pub async fn redirect_request() -> impl IntoResponse {
+    Redirect::to("/journals/0/0")
+}
+
+/// `/journals/{journal}/{note}`
+pub async fn app_request(
+    jar: CookieJar,
+    Extension(data): Extension<State>,
+    Path((selected_journal, selected_note)): Path<(usize, usize)>,
+    Query(props): Query<JournalsAppQuery>,
+) -> impl IntoResponse {
+    let data = data.read().await;
+    let user = match get_user_from_token!(jar, data.0) {
+        Some(ua) => ua,
+        None => {
+            return Err(Html(
+                render_error(Error::NotAllowed, &jar, &data, &None).await,
+            ));
+        }
+    };
+
+    let journals = match data.0.get_journals_by_user(user.id).await {
+        Ok(p) => Some(p),
+        Err(e) => {
+            return Err(Html(
+                render_error(e, &jar, &data, &Some(user.to_owned())).await,
+            ));
+        }
+    };
+
+    let notes = match data.0.get_notes_by_journal(selected_journal).await {
+        Ok(p) => Some(p),
+        Err(e) => {
+            return Err(Html(render_error(e, &jar, &data, &Some(user)).await));
+        }
+    };
+
+    // get journal and check privacy settings
+    let journal = if selected_journal != 0 {
+        match data.0.get_journal_by_id(selected_journal).await {
+            Ok(p) => Some(p),
+            Err(e) => {
+                return Err(Html(render_error(e, &jar, &data, &Some(user)).await));
+            }
+        }
+    } else {
+        None
+    };
+
+    if let Some(ref j) = journal {
+        // if we're not the owner, we shouldn't be viewing this journal from this endpoint
+        if user.id != j.owner {
+            return Err(Html(
+                render_error(Error::NotAllowed, &jar, &data, &Some(user.to_owned())).await,
+            ));
+        }
+    }
+
+    // ...
+    let note = if selected_note != 0 {
+        match data.0.get_note_by_id(selected_note).await {
+            Ok(p) => Some(p),
+            Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
+        }
+    } else {
+        None
+    };
+
+    let lang = get_lang!(jar, data.0);
+    let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
+
+    context.insert("selected_journal", &selected_journal);
+    context.insert("selected_note", &selected_note);
+
+    context.insert("journal", &journal);
+    context.insert("note", &note);
+
+    context.insert("journals", &journals);
+    context.insert("notes", &notes);
+
+    context.insert("view_mode", &props.view);
+    context.insert("is_editor", &true);
+
+    // return
+    Ok(Html(data.1.render("journals/app.html", &context).unwrap()))
+}
+
+/// `/@{owner}/{journal}/{note}`
+pub async fn view_request(
+    jar: CookieJar,
+    Extension(data): Extension<State>,
+    Path((owner, selected_journal, mut selected_note)): Path<(String, String, String)>,
+) -> impl IntoResponse {
+    let data = data.read().await;
+    let user = match get_user_from_token!(jar, data.0) {
+        Some(ua) => Some(ua),
+        None => None,
+    };
+
+    if selected_note == "index" {
+        selected_note = String::new();
+    }
+
+    // if we don't have a selected journal, we shouldn't be here probably
+    if selected_journal.is_empty() {
+        return Err(Html(
+            render_error(Error::NotAllowed, &jar, &data, &user).await,
+        ));
+    }
+
+    // get owner
+    let owner = match data.0.get_user_by_username(&owner).await {
+        Ok(ua) => ua,
+        Err(e) => {
+            return Err(Html(render_error(e, &jar, &data, &user).await));
+        }
+    };
+
+    check_user_blocked_or_private!(user, owner, data, jar);
+
+    // get journal and check privacy settings
+    let journal = match data
+        .0
+        .get_journal_by_owner_title(owner.id, &selected_journal)
+        .await
+    {
+        Ok(p) => p,
+        Err(e) => {
+            return Err(Html(render_error(e, &jar, &data, &user).await));
+        }
+    };
+
+    if journal.privacy == JournalPrivacyPermission::Private {
+        if let Some(ref user) = user {
+            if user.id != journal.owner {
+                return Err(Html(
+                    render_error(Error::NotAllowed, &jar, &data, &Some(user.to_owned())).await,
+                ));
+            }
+        } else {
+            return Err(Html(
+                render_error(Error::NotAllowed, &jar, &data, &user).await,
+            ));
+        }
+    }
+
+    // ...
+    let notes = match data.0.get_notes_by_journal(journal.id).await {
+        Ok(p) => Some(p),
+        Err(e) => {
+            return Err(Html(render_error(e, &jar, &data, &user).await));
+        }
+    };
+
+    // ...
+    let note = if !selected_note.is_empty() {
+        match data
+            .0
+            .get_note_by_journal_title(journal.id, &selected_note)
+            .await
+        {
+            Ok(p) => Some(p),
+            Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
+        }
+    } else {
+        None
+    };
+
+    let lang = get_lang!(jar, data.0);
+    let mut context = initial_context(&data.0.0.0, lang, &user).await;
+
+    if selected_journal.is_empty() {
+        context.insert("selected_journal", &0);
+    } else {
+        context.insert("selected_journal", &selected_journal);
+    }
+
+    if selected_note.is_empty() {
+        context.insert("selected_note", &0);
+    } else {
+        context.insert("selected_note", &selected_note);
+    }
+
+    context.insert("journal", &journal);
+    context.insert("note", &note);
+
+    context.insert("owner", &owner);
+    context.insert("notes", &notes);
+
+    context.insert("view_mode", &true);
+    context.insert("is_editor", &false);
+
+    // return
+    Ok(Html(data.1.render("journals/app.html", &context).unwrap()))
+}
diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs
index 2bb9ebf..2177d94 100644
--- a/crates/app/src/routes/pages/mod.rs
+++ b/crates/app/src/routes/pages/mod.rs
@@ -3,6 +3,7 @@ pub mod chats;
 pub mod communities;
 pub mod developer;
 pub mod forge;
+pub mod journals;
 pub mod misc;
 pub mod mod_panel;
 pub mod profile;
@@ -130,6 +131,11 @@ pub fn routes() -> Router {
         .route("/stacks", get(stacks::list_request))
         .route("/stacks/{id}", get(stacks::feed_request))
         .route("/stacks/{id}/manage", get(stacks::manage_request))
+        // journals
+        .route("/journals", get(journals::redirect_request))
+        .route("/journals/{journal}/{note}", get(journals::app_request))
+        .route("/@{owner}/{journal}", get(journals::view_request))
+        .route("/@{owner}/{journal}/{note}", get(journals::view_request))
 }
 
 pub async fn render_error(
@@ -185,3 +191,9 @@ pub struct RepostsQuery {
     #[serde(default)]
     pub page: usize,
 }
+
+#[derive(Deserialize)]
+pub struct JournalsAppQuery {
+    #[serde(default)]
+    pub view: bool,
+}
diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml
index 1a208f5..b2fd135 100644
--- a/crates/core/Cargo.toml
+++ b/crates/core/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "tetratto-core"
-version = "8.0.0"
+version = "9.0.0"
 edition = "2024"
 
 [dependencies]
diff --git a/crates/core/src/database/journals.rs b/crates/core/src/database/journals.rs
index ac0a589..4602b6b 100644
--- a/crates/core/src/database/journals.rs
+++ b/crates/core/src/database/journals.rs
@@ -1,4 +1,4 @@
-use oiseau::cache::Cache;
+use oiseau::{cache::Cache, query_row};
 use crate::{
     model::{
         auth::User,
@@ -24,6 +24,27 @@ impl DataManager {
 
     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 a journal by `owner` and `title`.
+    pub async fn get_journal_by_owner_title(&self, owner: usize, title: &str) -> Result<Journal> {
+        let conn = match self.0.connect().await {
+            Ok(c) => c,
+            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
+        };
+
+        let res = query_row!(
+            &conn,
+            "SELECT * FROM journals WHERE owner = $1 AND title = $2",
+            params![&(owner as i64), &title],
+            |x| { Ok(Self::get_journal_from_row(x)) }
+        );
+
+        if res.is_err() {
+            return Err(Error::GeneralNotFound("journal".to_string()));
+        }
+
+        Ok(res.unwrap())
+    }
+
     /// Get all journals by user.
     ///
     /// # Arguments
@@ -54,7 +75,7 @@ impl DataManager {
     ///
     /// # Arguments
     /// * `data` - a mock [`Journal`] object to insert
-    pub async fn create_journal(&self, data: Journal) -> Result<Journal> {
+    pub async fn create_journal(&self, mut data: Journal) -> Result<Journal> {
         // check values
         if data.title.len() < 2 {
             return Err(Error::DataTooShort("title".to_string()));
@@ -62,6 +83,17 @@ impl DataManager {
             return Err(Error::DataTooLong("title".to_string()));
         }
 
+        data.title = data.title.replace(" ", "_");
+
+        // make sure this title isn't already in use
+        if self
+            .get_journal_by_owner_title(data.owner, &data.title)
+            .await
+            .is_ok()
+        {
+            return Err(Error::TitleInUse);
+        }
+
         // check number of journals
         let owner = self.get_user_by_id(data.owner).await?;
 
diff --git a/crates/core/src/database/notes.rs b/crates/core/src/database/notes.rs
index 78a25d9..d3fd0b7 100644
--- a/crates/core/src/database/notes.rs
+++ b/crates/core/src/database/notes.rs
@@ -1,7 +1,7 @@
 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};
+use oiseau::{execute, get, params, query_row, query_rows, PostgresRow};
 
 impl DataManager {
     /// Get a [`Note`] from an SQL row.
@@ -19,6 +19,27 @@ impl DataManager {
 
     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 a note by `journal` and `title`.
+    pub async fn get_note_by_journal_title(&self, journal: usize, title: &str) -> Result<Note> {
+        let conn = match self.0.connect().await {
+            Ok(c) => c,
+            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
+        };
+
+        let res = query_row!(
+            &conn,
+            "SELECT * FROM notes WHERE journal = $1 AND title = $2",
+            params![&(journal as i64), &title],
+            |x| { Ok(Self::get_note_from_row(x)) }
+        );
+
+        if res.is_err() {
+            return Err(Error::GeneralNotFound("note".to_string()));
+        }
+
+        Ok(res.unwrap())
+    }
+
     /// Get all notes by journal.
     ///
     /// # Arguments
@@ -31,7 +52,7 @@ impl DataManager {
 
         let res = query_rows!(
             &conn,
-            "SELECT * FROM notes WHERE journal = $1 ORDER BY edited",
+            "SELECT * FROM notes WHERE journal = $1 ORDER BY edited DESC",
             &[&(id as i64)],
             |x| { Self::get_note_from_row(x) }
         );
@@ -47,7 +68,7 @@ impl DataManager {
     ///
     /// # Arguments
     /// * `data` - a mock [`Note`] object to insert
-    pub async fn create_note(&self, data: Note) -> Result<Note> {
+    pub async fn create_note(&self, mut data: Note) -> Result<Note> {
         // check values
         if data.title.len() < 2 {
             return Err(Error::DataTooShort("title".to_string()));
@@ -61,6 +82,24 @@ impl DataManager {
             return Err(Error::DataTooLong("content".to_string()));
         }
 
+        data.title = data.title.replace(" ", "_");
+
+        // make sure this title isn't already in use
+        if self
+            .get_note_by_journal_title(data.journal, &data.title)
+            .await
+            .is_ok()
+        {
+            return Err(Error::TitleInUse);
+        }
+
+        // check permission
+        let journal = self.get_journal_by_id(data.journal).await?;
+
+        if data.owner != journal.owner {
+            return Err(Error::NotAllowed);
+        }
+
         // ...
         let conn = match self.0.connect().await {
             Ok(c) => c,
@@ -108,13 +147,6 @@ impl DataManager {
             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(())
@@ -122,4 +154,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:{}");
+    auto_method!(update_note_edited(i64) -> "UPDATE notes SET edited = $1 WHERE id = $2" --cache-key-tmpl="atto.note:{}");
 }
diff --git a/crates/l10n/Cargo.toml b/crates/l10n/Cargo.toml
index 3f11dfd..c50b714 100644
--- a/crates/l10n/Cargo.toml
+++ b/crates/l10n/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "tetratto-l10n"
-version = "8.0.0"
+version = "9.0.0"
 edition = "2024"
 authors.workspace = true
 repository.workspace = true
diff --git a/crates/shared/Cargo.toml b/crates/shared/Cargo.toml
index dd48ed3..8d48901 100644
--- a/crates/shared/Cargo.toml
+++ b/crates/shared/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "tetratto-shared"
-version = "8.0.0"
+version = "9.0.0"
 edition = "2024"
 authors.workspace = true
 repository.workspace = true

From 57a69eea50a5cb0daf3437ad60af1faa10fe0467 Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Thu, 19 Jun 2025 15:52:46 -0400
Subject: [PATCH 21/71] add: increase note character limit (16384 (*16)->
 262144)

---
 crates/core/src/database/notes.rs | 2 +-
 crates/core/src/database/posts.rs | 4 ++--
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/crates/core/src/database/notes.rs b/crates/core/src/database/notes.rs
index d3fd0b7..d46394b 100644
--- a/crates/core/src/database/notes.rs
+++ b/crates/core/src/database/notes.rs
@@ -78,7 +78,7 @@ impl DataManager {
 
         if data.content.len() < 2 {
             return Err(Error::DataTooShort("content".to_string()));
-        } else if data.content.len() > 16384 {
+        } else if data.content.len() > 262144 {
             return Err(Error::DataTooLong("content".to_string()));
         }
 
diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs
index 29dd75a..eeef882 100644
--- a/crates/core/src/database/posts.rs
+++ b/crates/core/src/database/posts.rs
@@ -713,10 +713,10 @@ impl DataManager {
     /// To be considered a "great post", a post must have a score ((likes - dislikes) / (likes + dislikes))
     /// of at least 0.6.
     ///
-    /// GPA is calculated based on the user's last 250 posts.
+    /// GPA is calculated based on the user's last 48 posts.
     pub async fn calculate_user_gpa(&self, id: usize) -> f32 {
         // just for note, this is SUPER bad for performance... which is why we
-        // only calculate this when it expires in the cache (every week)
+        // only calculate this when it expires in the cache (every day)
         if let Some(cached) = self.0.1.get(format!("atto.user.gpa:{}", id)).await {
             if let Ok(c) = cached.parse() {
                 return c;

From 97b7e873eddafe1938c72d71d8ccf4e67eb70b7b Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Thu, 19 Jun 2025 16:19:57 -0400
Subject: [PATCH 22/71] fix: journal privacy

---
 crates/app/src/public/html/journals/app.lisp | 33 ++++++++++++++++++--
 crates/app/src/routes/api/v1/journals.rs     | 23 +++++++++++---
 crates/app/src/routes/api/v1/notes.rs        | 25 +++++++++++----
 crates/core/src/database/journals.rs         | 15 ++++++++-
 crates/core/src/database/mod.rs              |  1 +
 crates/core/src/database/notes.rs            | 13 ++++++++
 6 files changed, 95 insertions(+), 15 deletions(-)

diff --git a/crates/app/src/public/html/journals/app.lisp b/crates/app/src/public/html/journals/app.lisp
index 267541a..4a3ccbd 100644
--- a/crates/app/src/public/html/journals/app.lisp
+++ b/crates/app/src/public/html/journals/app.lisp
@@ -272,7 +272,34 @@
                 ("class" "flex flex-col gap-2 card")
                 (text "{{ note.content|markdown|safe }}"))
 
-            (span (text "Last updated: ") (span ("class" "date") (text "{{ note.edited }}")))
+            (div
+                ("class" "flex w-full justify-between gap-2")
+                (span (text "Last updated: ") (span ("class" "date") (text "{{ note.edited }}")))
+                (text "{% if user and user.id == owner.id -%}")
+                (button
+                    ("class" "small")
+                    ("onclick" "{% if journal.privacy == \"Public\" -%}
+                        trigger('atto::copy_text', ['{{ config.host }}/@{{ owner.username }}/{{ journal.title }}/{{ note.title }}'])
+                    {%- else -%}
+                        prompt_make_public();
+                        trigger('atto::copy_text', ['{{ config.host }}/@{{ owner.username }}/{{ journal.title }}/{{ note.title }}'])
+                    {%- endif %}")
+                    (icon (text "share"))
+                    (str (text "general:label.share")))
+
+                (script
+                    (text "globalThis.prompt_make_public = async () => {
+                        if (
+                            !(await trigger(\"atto::confirm\", [
+                                \"Would you like to make this journal public? This is required for others to view this note.\",
+                            ]))
+                        ) {
+                            return;
+                        }
+
+                        change_journal_privacy({ target: { selectedOptions: [{ value: \"Public\" }] }, preventDefault: () => {} });
+                    }"))
+                (text "{%- endif %}"))
             (text "{%- endif %}")
             (text "{%- endif %}")))
     (style
@@ -431,8 +458,8 @@
 
         globalThis.change_journal_privacy = async (e) => {
             e.preventDefault();
-            const selected = event.target.selectedOptions[0];
-            fetch(\"/api/v1/journals/{{ selected_journal }}/privacy\", {
+            const selected = e.target.selectedOptions[0];
+            fetch(\"/api/v1/journals/{% if journal -%} {{ journal.id }} {%- else -%} {{ selected_journal }} {%- endif %}/privacy\", {
                 method: \"POST\",
                 headers: {
                     \"Content-Type\": \"application/json\",
diff --git a/crates/app/src/routes/api/v1/journals.rs b/crates/app/src/routes/api/v1/journals.rs
index 97f8c9b..0cf3617 100644
--- a/crates/app/src/routes/api/v1/journals.rs
+++ b/crates/app/src/routes/api/v1/journals.rs
@@ -9,11 +9,14 @@ use crate::{
     routes::api::v1::{UpdateJournalPrivacy, CreateJournal, UpdateJournalTitle},
     State,
 };
-use tetratto_core::model::{
-    journals::{Journal, JournalPrivacyPermission},
-    oauth,
-    permissions::FinePermission,
-    ApiReturn, Error,
+use tetratto_core::{
+    database::NAME_REGEX,
+    model::{
+        journals::{Journal, JournalPrivacyPermission},
+        oauth,
+        permissions::FinePermission,
+        ApiReturn, Error,
+    },
 };
 
 pub async fn get_request(
@@ -101,6 +104,16 @@ pub async fn update_title_request(
 
     props.title = props.title.replace(" ", "_");
 
+    // check name
+    let regex = regex::RegexBuilder::new(NAME_REGEX)
+        .multi_line(true)
+        .build()
+        .unwrap();
+
+    if regex.captures(&props.title).is_some() {
+        return Json(Error::MiscError("This title contains invalid characters".to_string()).into());
+    }
+
     // make sure this title isn't already in use
     if data
         .get_journal_by_owner_title(user.id, &props.title)
diff --git a/crates/app/src/routes/api/v1/notes.rs b/crates/app/src/routes/api/v1/notes.rs
index 45c4a74..41ab1f9 100644
--- a/crates/app/src/routes/api/v1/notes.rs
+++ b/crates/app/src/routes/api/v1/notes.rs
@@ -10,12 +10,15 @@ use crate::{
     routes::api::v1::{CreateNote, RenderMarkdown, UpdateNoteContent, UpdateNoteTitle},
     State,
 };
-use tetratto_core::model::{
-    journals::{JournalPrivacyPermission, Note},
-    oauth,
-    permissions::FinePermission,
-    uploads::CustomEmoji,
-    ApiReturn, Error,
+use tetratto_core::{
+    database::NAME_REGEX,
+    model::{
+        journals::{JournalPrivacyPermission, Note},
+        oauth,
+        permissions::FinePermission,
+        uploads::CustomEmoji,
+        ApiReturn, Error,
+    },
 };
 
 pub async fn get_request(
@@ -137,6 +140,16 @@ pub async fn update_title_request(
 
     props.title = props.title.replace(" ", "_");
 
+    // check name
+    let regex = regex::RegexBuilder::new(NAME_REGEX)
+        .multi_line(true)
+        .build()
+        .unwrap();
+
+    if regex.captures(&props.title).is_some() {
+        return Json(Error::MiscError("This title contains invalid characters".to_string()).into());
+    }
+
     // make sure this title isn't already in use
     if data
         .get_note_by_journal_title(note.journal, &props.title)
diff --git a/crates/core/src/database/journals.rs b/crates/core/src/database/journals.rs
index 4602b6b..5979347 100644
--- a/crates/core/src/database/journals.rs
+++ b/crates/core/src/database/journals.rs
@@ -1,9 +1,10 @@
 use oiseau::{cache::Cache, query_row};
 use crate::{
+    database::common::NAME_REGEX,
     model::{
         auth::User,
-        permissions::FinePermission,
         journals::{Journal, JournalPrivacyPermission},
+        permissions::FinePermission,
         Error, Result,
     },
 };
@@ -85,6 +86,18 @@ impl DataManager {
 
         data.title = data.title.replace(" ", "_");
 
+        // check name
+        let regex = regex::RegexBuilder::new(NAME_REGEX)
+            .multi_line(true)
+            .build()
+            .unwrap();
+
+        if regex.captures(&data.title).is_some() {
+            return Err(Error::MiscError(
+                "This title contains invalid characters".to_string(),
+            ));
+        }
+
         // make sure this title isn't already in use
         if self
             .get_journal_by_owner_title(data.owner, &data.title)
diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs
index e56bc93..a00fde9 100644
--- a/crates/core/src/database/mod.rs
+++ b/crates/core/src/database/mod.rs
@@ -30,3 +30,4 @@ mod userblocks;
 mod userfollows;
 
 pub use drivers::DataManager;
+pub use common::NAME_REGEX;
diff --git a/crates/core/src/database/notes.rs b/crates/core/src/database/notes.rs
index d46394b..377fa6e 100644
--- a/crates/core/src/database/notes.rs
+++ b/crates/core/src/database/notes.rs
@@ -1,4 +1,5 @@
 use oiseau::cache::Cache;
+use crate::database::common::NAME_REGEX;
 use crate::model::{auth::User, journals::Note, permissions::FinePermission, Error, Result};
 use crate::{auto_method, DataManager};
 use oiseau::{execute, get, params, query_row, query_rows, PostgresRow};
@@ -84,6 +85,18 @@ impl DataManager {
 
         data.title = data.title.replace(" ", "_");
 
+        // check name
+        let regex = regex::RegexBuilder::new(NAME_REGEX)
+            .multi_line(true)
+            .build()
+            .unwrap();
+
+        if regex.captures(&data.title).is_some() {
+            return Err(Error::MiscError(
+                "This title contains invalid characters".to_string(),
+            ));
+        }
+
         // make sure this title isn't already in use
         if self
             .get_note_by_journal_title(data.journal, &data.title)

From 1b1c1c0beaad68d4090daa3868a907641084cf76 Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Thu, 19 Jun 2025 16:23:33 -0400
Subject: [PATCH 23/71] fix: make forward slash escape mentions parser

---
 crates/core/src/model/auth.rs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs
index 8c12f76..a5714c3 100644
--- a/crates/core/src/model/auth.rs
+++ b/crates/core/src/model/auth.rs
@@ -332,7 +332,7 @@ impl User {
 
         // parse
         for char in input.chars() {
-            if (char == '\\') && !escape {
+            if ((char == '\\') | (char == '/')) && !escape {
                 escape = true;
                 continue;
             }

From eb5a0d146f5f55145a49098fb274e6d4aa571328 Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Thu, 19 Jun 2025 16:34:08 -0400
Subject: [PATCH 24/71] fix: make journal and note titles lowercase add: remove
 journal index route

---
 crates/app/src/public/html/journals/app.lisp |  4 +-
 crates/app/src/routes/api/v1/journals.rs     |  2 +-
 crates/app/src/routes/api/v1/notes.rs        |  2 +-
 crates/app/src/routes/pages/journals.rs      | 78 ++++++++++++++++++++
 crates/app/src/routes/pages/mod.rs           |  2 +-
 crates/core/src/database/journals.rs         |  2 +-
 crates/core/src/database/notes.rs            |  2 +-
 7 files changed, 85 insertions(+), 7 deletions(-)

diff --git a/crates/app/src/public/html/journals/app.lisp b/crates/app/src/public/html/journals/app.lisp
index 4a3ccbd..6fbcea2 100644
--- a/crates/app/src/public/html/journals/app.lisp
+++ b/crates/app/src/public/html/journals/app.lisp
@@ -65,7 +65,7 @@
 
                     (a
                         ("class" "flush")
-                        ("href" "{% if view_mode -%} /@{{ owner.username }}/{{ journal.title }}/index {%- else -%} /journals/{{ journal.id }}/0 {%- endif %}")
+                        ("href" "{% if view_mode -%} /@{{ owner.username }}/{{ journal.title }} {%- else -%} /journals/{{ journal.id }}/0 {%- endif %}")
                         (b (text "{{ journal.title }}")))
 
                     (text "{% if note -%}")
@@ -83,7 +83,7 @@
                         (icon (text "pencil")))
                     (a
                         ("class" "{% if view_mode -%}active{%- endif %}")
-                        ("href" "/@{{ user.username }}/{{ journal.title }}/{% if note -%} {{ note.title }} {%- else -%} index {%- endif %}")
+                        ("href" "/@{{ user.username }}/{{ journal.title }}{% if note -%} /{{ note.title }} {%- endif %}")
                         (icon (text "eye"))))
                 (text "{%- endif %}"))
             (text "{%- endif %}")
diff --git a/crates/app/src/routes/api/v1/journals.rs b/crates/app/src/routes/api/v1/journals.rs
index 0cf3617..d501f08 100644
--- a/crates/app/src/routes/api/v1/journals.rs
+++ b/crates/app/src/routes/api/v1/journals.rs
@@ -102,7 +102,7 @@ pub async fn update_title_request(
         None => return Json(Error::NotAllowed.into()),
     };
 
-    props.title = props.title.replace(" ", "_");
+    props.title = props.title.replace(" ", "_").to_lowercase();
 
     // check name
     let regex = regex::RegexBuilder::new(NAME_REGEX)
diff --git a/crates/app/src/routes/api/v1/notes.rs b/crates/app/src/routes/api/v1/notes.rs
index 41ab1f9..faf1bec 100644
--- a/crates/app/src/routes/api/v1/notes.rs
+++ b/crates/app/src/routes/api/v1/notes.rs
@@ -138,7 +138,7 @@ pub async fn update_title_request(
         Err(e) => return Json(e.into()),
     };
 
-    props.title = props.title.replace(" ", "_");
+    props.title = props.title.replace(" ", "_").to_lowercase();
 
     // check name
     let regex = regex::RegexBuilder::new(NAME_REGEX)
diff --git a/crates/app/src/routes/pages/journals.rs b/crates/app/src/routes/pages/journals.rs
index f631826..397b4cd 100644
--- a/crates/app/src/routes/pages/journals.rs
+++ b/crates/app/src/routes/pages/journals.rs
@@ -207,3 +207,81 @@ pub async fn view_request(
     // return
     Ok(Html(data.1.render("journals/app.html", &context).unwrap()))
 }
+
+/// `/@{owner}/{journal}`
+pub async fn index_view_request(
+    jar: CookieJar,
+    Extension(data): Extension<State>,
+    Path((owner, selected_journal)): Path<(String, String)>,
+) -> impl IntoResponse {
+    let data = data.read().await;
+    let user = match get_user_from_token!(jar, data.0) {
+        Some(ua) => Some(ua),
+        None => None,
+    };
+
+    // get owner
+    let owner = match data.0.get_user_by_username(&owner).await {
+        Ok(ua) => ua,
+        Err(e) => {
+            return Err(Html(render_error(e, &jar, &data, &user).await));
+        }
+    };
+
+    check_user_blocked_or_private!(user, owner, data, jar);
+
+    // get journal and check privacy settings
+    let journal = match data
+        .0
+        .get_journal_by_owner_title(owner.id, &selected_journal)
+        .await
+    {
+        Ok(p) => p,
+        Err(e) => {
+            return Err(Html(render_error(e, &jar, &data, &user).await));
+        }
+    };
+
+    if journal.privacy == JournalPrivacyPermission::Private {
+        if let Some(ref user) = user {
+            if user.id != journal.owner {
+                return Err(Html(
+                    render_error(Error::NotAllowed, &jar, &data, &Some(user.to_owned())).await,
+                ));
+            }
+        } else {
+            return Err(Html(
+                render_error(Error::NotAllowed, &jar, &data, &user).await,
+            ));
+        }
+    }
+
+    // ...
+    let notes = match data.0.get_notes_by_journal(journal.id).await {
+        Ok(p) => Some(p),
+        Err(e) => {
+            return Err(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;
+
+    if selected_journal.is_empty() {
+        context.insert("selected_journal", &0);
+    } else {
+        context.insert("selected_journal", &selected_journal);
+    }
+
+    context.insert("selected_note", &0);
+    context.insert("journal", &journal);
+
+    context.insert("owner", &owner);
+    context.insert("notes", &notes);
+
+    context.insert("view_mode", &true);
+    context.insert("is_editor", &false);
+
+    // return
+    Ok(Html(data.1.render("journals/app.html", &context).unwrap()))
+}
diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs
index 2177d94..2eaeca2 100644
--- a/crates/app/src/routes/pages/mod.rs
+++ b/crates/app/src/routes/pages/mod.rs
@@ -134,7 +134,7 @@ pub fn routes() -> Router {
         // journals
         .route("/journals", get(journals::redirect_request))
         .route("/journals/{journal}/{note}", get(journals::app_request))
-        .route("/@{owner}/{journal}", get(journals::view_request))
+        .route("/@{owner}/{journal}", get(journals::index_view_request))
         .route("/@{owner}/{journal}/{note}", get(journals::view_request))
 }
 
diff --git a/crates/core/src/database/journals.rs b/crates/core/src/database/journals.rs
index 5979347..8c2a637 100644
--- a/crates/core/src/database/journals.rs
+++ b/crates/core/src/database/journals.rs
@@ -84,7 +84,7 @@ impl DataManager {
             return Err(Error::DataTooLong("title".to_string()));
         }
 
-        data.title = data.title.replace(" ", "_");
+        data.title = data.title.replace(" ", "_").to_lowercase();
 
         // check name
         let regex = regex::RegexBuilder::new(NAME_REGEX)
diff --git a/crates/core/src/database/notes.rs b/crates/core/src/database/notes.rs
index 377fa6e..e3fcdab 100644
--- a/crates/core/src/database/notes.rs
+++ b/crates/core/src/database/notes.rs
@@ -83,7 +83,7 @@ impl DataManager {
             return Err(Error::DataTooLong("content".to_string()));
         }
 
-        data.title = data.title.replace(" ", "_");
+        data.title = data.title.replace(" ", "_").to_lowercase();
 
         // check name
         let regex = regex::RegexBuilder::new(NAME_REGEX)

From f0d1a1e8e4790a474cf9ce657242ea8255d49236 Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Thu, 19 Jun 2025 16:37:11 -0400
Subject: [PATCH 25/71] add: show mobile help text on journals homepage

---
 crates/app/src/public/html/journals/app.lisp | 1 +
 1 file changed, 1 insertion(+)

diff --git a/crates/app/src/public/html/journals/app.lisp b/crates/app/src/public/html/journals/app.lisp
index 6fbcea2..d148a0f 100644
--- a/crates/app/src/public/html/journals/app.lisp
+++ b/crates/app/src/public/html/journals/app.lisp
@@ -96,6 +96,7 @@
                 ("class" "card w-full flex flex-col gap-2")
                 (h3 (str (text "journals:label.welcome")))
                 (span (str (text "journals:label.select_a_journal")))
+                (span ("class" "mobile") (str (text "journals:label.mobile_click_my_journals")))
                 (button
                     ("onclick" "create_journal()")
                     (icon (text "plus"))

From dc50f3a8afcdb8ee4a72820b224541fa91260750 Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Thu, 19 Jun 2025 19:13:07 -0400
Subject: [PATCH 26/71] add: journal.css special note

---
 crates/app/src/public/css/style.css           | 29 +++++++++
 crates/app/src/public/html/journals/app.lisp  | 62 +++++++++++++++++--
 .../app/src/public/html/profile/settings.lisp | 12 ++--
 crates/app/src/routes/api/v1/journals.rs      | 14 +++++
 crates/app/src/routes/api/v1/mod.rs           |  1 +
 crates/app/src/routes/pages/journals.rs       |  2 +-
 crates/core/src/database/journals.rs          |  2 +-
 crates/core/src/database/notes.rs             | 16 +++++
 8 files changed, 125 insertions(+), 13 deletions(-)

diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css
index f592c77..0603ee1 100644
--- a/crates/app/src/public/css/style.css
+++ b/crates/app/src/public/css/style.css
@@ -1250,3 +1250,32 @@ details.accordion .inner {
 .CodeMirror-focused .CodeMirror-placeholder {
     opacity: 50%;
 }
+
+.CodeMirror-gutters {
+    border-color: var(--color-super-lowered) !important;
+    background-color: var(--color-lowered) !important;
+}
+
+.CodeMirror-hints {
+    background: var(--color-raised) !important;
+    box-shadow: var(--shadow-x-offset) var(--shadow-y-offset) var(--shadow-size)
+        var(--color-shadow);
+    border-radius: var(--radius) !important;
+    padding: var(--pad-1) !important;
+    border-color: var(--color-super-lowered) !important;
+}
+
+.CodeMirror-hints li {
+    color: var(--color-text-raised) !important;
+    border-radius: var(--radius) !important;
+    transition:
+        background 0.15s,
+        color 0.15s;
+    font-size: 10px;
+    padding: calc(var(--pad-1) / 2) var(--pad-2);
+}
+
+.CodeMirror-hints li.CodeMirror-hint-active {
+    background-color: var(--color-primary) !important;
+    color: var(--color-text-primary) !important;
+}
diff --git a/crates/app/src/public/html/journals/app.lisp b/crates/app/src/public/html/journals/app.lisp
index d148a0f..d65163d 100644
--- a/crates/app/src/public/html/journals/app.lisp
+++ b/crates/app/src/public/html/journals/app.lisp
@@ -9,6 +9,11 @@
 ; redirect to journal homepage
 (meta ("http-equiv" "refresh") ("content" "0; url=/@{{ user.username }}/{{ journal.title }}"))
 (text "{%- endif %} {%- endif %}")
+
+(text "{% if view_mode and journal -%}")
+; add journal css
+(link ("rel" "stylesheet") ("data-turbo-temporary" "true") ("href" "/api/v1/journals/{{ journal.id }}/journal.css?v=tetratto-{{ random_cache_breaker }}"))
+(text "{%- endif %}")
 (text "{% endblock %} {% block body %} {{ macros::nav(selected=\"journals\") }}")
 (text "{% if not view_mode -%}")
 (nav
@@ -73,7 +78,7 @@
                     (b (text "{{ note.title }}"))
                     (text "{%- endif %}"))
 
-                (text "{% if user and user.id == journal.owner -%}")
+                (text "{% if user and user.id == journal.owner and (not note or note.title != \"journal.css\") -%}")
                 (div
                     ("class" "pillmenu")
                     (a
@@ -181,10 +186,36 @@
             ; 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"))
 
+            (text "{% if note.title == \"journal.css\" -%}")
+            ; css editor
+            (script ("src" "https://unpkg.com/codemirror@5.39.2/addon/edit/closebrackets.js") ("data-turbo-temporary" "true"))
+            (script ("src" "https://unpkg.com/codemirror@5.39.2/addon/hint/css-hint.js") ("data-turbo-temporary" "true"))
+            (script ("src" "https://unpkg.com/codemirror@5.39.2/addon/hint/show-hint.js") ("data-turbo-temporary" "true"))
+            (script ("src" "https://unpkg.com/codemirror@5.39.2/addon/lint/css-lint.js") ("data-turbo-temporary" "true"))
+            (script ("src" "https://unpkg.com/codemirror@5.39.2/mode/css/css.js") ("data-turbo-temporary" "true"))
+            (link ("rel" "stylesheet") ("href" "https://unpkg.com/codemirror@5.39.2/addon/hint/show-hint.css") ("data-turbo-temporary" "true"))
+            (link ("rel" "stylesheet") ("href" "https://unpkg.com/codemirror@5.39.2/addon/lint/lint.css") ("data-turbo-temporary" "true"))
+
+            (style
+                (text ".CodeMirror {
+                    font-family: monospace !important;
+                    font-size: 16px;
+                    border: solid 1px var(--color-super-lowered);
+                    border-radius: var(--radius);
+                }
+
+                .CodeMirror-line {
+                    padding-left: 5px !important;
+                }"))
+            (text "{% else %}")
+            ; markdown editor
+            (script ("src" "https://unpkg.com/codemirror@5.39.2/mode/markdown/markdown.js") ("data-turbo-temporary" "true"))
+            (text "{%- endif %}")
+
             ; tab bar
+            (text "{% if note.title != \"journal.css\" -%}")
             (div
                 ("class" "pillmenu")
                 (a
@@ -199,6 +230,7 @@
                     ("data-tab-button" "preview")
                     ("data-turbo" "false")
                     (str (text "journals:label.preview_pane"))))
+            (text "{%- endif %}")
 
             ; tabs
             (div
@@ -222,10 +254,15 @@
             (script ("id" "editor_content") ("type" "text/markdown") (text "{{ note.content|remove_script_tags|safe }}"))
             (script
                 (text "setTimeout(() => {
+                    if (!document.getElementById(\"preview_tab\").shadowRoot) {
+                        document.getElementById(\"preview_tab\").attachShadow({ mode: \"open\" });
+                    }
+
                     globalThis.editor = CodeMirror(document.getElementById(\"editor_tab\"), {
                         value: document.getElementById(\"editor_content\").innerHTML,
-                        mode: \"markdown\",
+                        mode: \"{% if note.title == 'journal.css' -%} css {%- else -%} markdown {%- endif %}\",
                         lineWrapping: true,
+                        lineNumbers: \"{{ note.title }}\" === \"journal.css\",
                         autoCloseBrackets: true,
                         autofocus: true,
                         viewportMargin: Number.POSITIVE_INFINITY,
@@ -233,7 +270,8 @@
                         highlightFormatting: false,
                         fencedCodeBlockHighlighting: false,
                         xml: false,
-                        smartIndent: false,
+                        smartIndent: true,
+                        indentUnit: 4,
                         placeholder: `# {{ note.title }}`,
                         extraKeys: {
                             Home: \"goLineLeft\",
@@ -244,6 +282,15 @@
                         },
                     });
 
+                    editor.on(\"keydown\", (cm, e) => {
+                        if (e.key.length > 1) {
+                            // ignore all keys that aren't a letter
+                            return;
+                        }
+
+                        CodeMirror.showHint(cm, CodeMirror.hint.css);
+                    });
+
                     document.querySelector(\"[data-tab-button=editor]\").addEventListener(\"click\", async (e) => {
                         e.preventDefault();
                         trigger(\"atto::hooks::tabs:switch\", [\"editor\"]);
@@ -263,7 +310,10 @@
                             })
                         ).text();
 
-                        document.getElementById(\"preview_tab\").innerHTML = res;
+                        const preview_token = window.crypto.randomUUID();
+                        document.getElementById(\"preview_tab\").shadowRoot.innerHTML = `${res}<style>
+                            @import url(\"/api/v1/journals/{{ journal.id }}/journal.css?v=preview-${preview_token}\");
+                        </style>`;
                         trigger(\"atto::hooks::tabs:switch\", [\"preview\"]);
                     });
                 }, 150);"))
@@ -360,7 +410,7 @@
                 },
                 body: JSON.stringify({
                     title,
-                    content: `# ${title}`,
+                    content: title === \"journal.css\" ? `/* ${title} */\\n` : `# ${title}`,
                     journal: \"{{ selected_journal }}\",
                 }),
             })
diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp
index 8be4836..88c6d59 100644
--- a/crates/app/src/public/html/profile/settings.lisp
+++ b/crates/app/src/public/html/profile/settings.lisp
@@ -564,14 +564,14 @@
                                 (li
                                     (text "Use custom CSS on your profile"))
                                 (li
-                                    (text "Ability to use community emojis outside of
+                                    (text "Use community emojis outside of
                                     their community"))
                                 (li
-                                    (text "Ability to upload and use gif emojis"))
+                                    (text "Upload and use gif emojis"))
                                 (li
                                     (text "Create infinite stack timelines"))
                                 (li
-                                    (text "Ability to upload images to posts"))
+                                    (text "Upload images to posts"))
                                 (li
                                     (text "Save infinite post drafts"))
                                 (li
@@ -579,7 +579,7 @@
                                 (li
                                     (text "Ability to create forges"))
                                 (li
-                                    (text "Ability to create more than 1 app"))
+                                    (text "Create more than 1 app"))
                                 (li
                                     (text "Create up to 10 stack blocks"))
                                 (li
@@ -587,7 +587,9 @@
                                 (li
                                     (text "Increased proxied image size"))
                                 (li
-                                    (text "Create infinite journals")))
+                                    (text "Create infinite journals"))
+                                (li
+                                    (text "Create infinite notes in each journal")))
                             (a
                                 ("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}")
                                 ("class" "button")
diff --git a/crates/app/src/routes/api/v1/journals.rs b/crates/app/src/routes/api/v1/journals.rs
index d501f08..de6d501 100644
--- a/crates/app/src/routes/api/v1/journals.rs
+++ b/crates/app/src/routes/api/v1/journals.rs
@@ -49,6 +49,20 @@ pub async fn get_request(
     })
 }
 
+pub async fn get_css_request(
+    Path(id): Path<usize>,
+    Extension(data): Extension<State>,
+) -> impl IntoResponse {
+    let data = &(data.read().await).0;
+
+    let note = match data.get_note_by_journal_title(id, "journal.css").await {
+        Ok(x) => x,
+        Err(e) => return ([("Content-Type", "text/plain")], format!("/* {e} */")),
+    };
+
+    ([("Content-Type", "text/css")], note.content)
+}
+
 pub async fn list_request(jar: CookieJar, Extension(data): Extension<State>) -> impl IntoResponse {
     let data = &(data.read().await).0;
     let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadJournals) {
diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs
index f16b1ed..9217437 100644
--- a/crates/app/src/routes/api/v1/mod.rs
+++ b/crates/app/src/routes/api/v1/mod.rs
@@ -551,6 +551,7 @@ pub fn routes() -> Router {
         .route("/journals", post(journals::create_request))
         .route("/journals/{id}", get(journals::get_request))
         .route("/journals/{id}", delete(journals::delete_request))
+        .route("/journals/{id}/journal.css", get(journals::get_css_request))
         .route("/journals/{id}/title", post(journals::update_title_request))
         .route(
             "/journals/{id}/privacy",
diff --git a/crates/app/src/routes/pages/journals.rs b/crates/app/src/routes/pages/journals.rs
index 397b4cd..1f03dd7 100644
--- a/crates/app/src/routes/pages/journals.rs
+++ b/crates/app/src/routes/pages/journals.rs
@@ -116,7 +116,7 @@ pub async fn view_request(
     }
 
     // if we don't have a selected journal, we shouldn't be here probably
-    if selected_journal.is_empty() {
+    if selected_journal.is_empty() | (selected_note == "journal.css") {
         return Err(Html(
             render_error(Error::NotAllowed, &jar, &data, &user).await,
         ));
diff --git a/crates/core/src/database/journals.rs b/crates/core/src/database/journals.rs
index 8c2a637..a4a0d00 100644
--- a/crates/core/src/database/journals.rs
+++ b/crates/core/src/database/journals.rs
@@ -70,7 +70,7 @@ impl DataManager {
         Ok(res.unwrap())
     }
 
-    const MAXIMUM_FREE_JOURNALS: usize = 15;
+    const MAXIMUM_FREE_JOURNALS: usize = 5;
 
     /// Create a new journal in the database.
     ///
diff --git a/crates/core/src/database/notes.rs b/crates/core/src/database/notes.rs
index e3fcdab..ea7da45 100644
--- a/crates/core/src/database/notes.rs
+++ b/crates/core/src/database/notes.rs
@@ -65,6 +65,8 @@ impl DataManager {
         Ok(res.unwrap())
     }
 
+    const MAXIMUM_FREE_NOTES_PER_JOURNAL: usize = 10;
+
     /// Create a new note in the database.
     ///
     /// # Arguments
@@ -85,6 +87,20 @@ impl DataManager {
 
         data.title = data.title.replace(" ", "_").to_lowercase();
 
+        // check number of notes
+        let owner = self.get_user_by_id(data.owner).await?;
+
+        if !owner.permissions.check(FinePermission::SUPPORTER) {
+            let journals = self.get_notes_by_journal(data.owner).await?;
+
+            if journals.len() >= Self::MAXIMUM_FREE_NOTES_PER_JOURNAL {
+                return Err(Error::MiscError(
+                    "You already have the maximum number of notes you can have in this journal"
+                        .to_string(),
+                ));
+            }
+        }
+
         // check name
         let regex = regex::RegexBuilder::new(NAME_REGEX)
             .multi_line(true)

From fa72d6a59dbc7d77ea5d1d27484b1b3db79a1200 Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Thu, 19 Jun 2025 19:27:42 -0400
Subject: [PATCH 27/71] fix: journals ui panic

---
 crates/app/src/public/html/components.lisp   | 4 ++--
 crates/app/src/public/html/journals/app.lisp | 2 ++
 2 files changed, 4 insertions(+), 2 deletions(-)

diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp
index 75a24ef..2726b26 100644
--- a/crates/app/src/public/html/components.lisp
+++ b/crates/app/src/public/html/components.lisp
@@ -1928,7 +1928,7 @@
     (text "{%- endif %}")
 
     ; note listings
-    (text "{% for note in notes %}")
+    (text "{% for note in notes %} {% if not view_mode or note.title != \"journal.css\" -%}")
     (div
         ("class" "flex flex-row gap-1")
         (a
@@ -1958,6 +1958,6 @@
                     (icon (text "trash"))
                     (str (text "general:action.delete")))))
         (text "{%- endif %}"))
-    (text "{% endfor %}"))
+    (text "{%- endif %} {% 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
index d65163d..a5136ff 100644
--- a/crates/app/src/public/html/journals/app.lisp
+++ b/crates/app/src/public/html/journals/app.lisp
@@ -68,10 +68,12 @@
                         ("href" "/api/v1/auth/user/find/{{ journal.owner }}")
                         (text "{{ components::avatar(username=journal.owner, selector_type=\"id\", size=\"18px\") }}"))
 
+                    (text "{% if (view_mode and owner) or not view_mode -%}")
                     (a
                         ("class" "flush")
                         ("href" "{% if view_mode -%} /@{{ owner.username }}/{{ journal.title }} {%- else -%} /journals/{{ journal.id }}/0 {%- endif %}")
                         (b (text "{{ journal.title }}")))
+                    (text "{%- endif %}")
 
                     (text "{% if note -%}")
                     (span (text "/"))

From ffdf320c14ca5873b376def07435c6323986397d Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Thu, 19 Jun 2025 22:10:17 -0400
Subject: [PATCH 28/71] add: ability to enable pages instead of infinite
 scrolling

---
 crates/app/src/public/html/profile/posts.lisp       |  3 ++-
 crates/app/src/public/html/profile/settings.lisp    |  5 +++++
 crates/app/src/public/html/stacks/feed.lisp         |  3 ++-
 crates/app/src/public/html/timelines/all.lisp       |  3 ++-
 crates/app/src/public/html/timelines/following.lisp |  3 ++-
 crates/app/src/public/html/timelines/home.lisp      |  3 ++-
 crates/app/src/public/html/timelines/popular.lisp   |  3 ++-
 .../app/src/public/html/timelines/swiss_army.lisp   |  4 ++++
 crates/app/src/public/js/atto.js                    | 13 +++++++++++--
 crates/app/src/routes/api/v1/auth/images.rs         |  8 ++++----
 crates/app/src/routes/api/v1/communities/images.rs  |  4 ++--
 crates/app/src/routes/api/v1/communities/posts.rs   |  2 +-
 crates/app/src/routes/pages/misc.rs                 |  3 +++
 crates/core/src/model/auth.rs                       |  3 +++
 crates/core/src/model/mod.rs                        |  2 ++
 15 files changed, 47 insertions(+), 15 deletions(-)

diff --git a/crates/app/src/public/html/profile/posts.lisp b/crates/app/src/public/html/profile/posts.lisp
index 0c9d79a..06aca2f 100644
--- a/crates/app/src/public/html/profile/posts.lisp
+++ b/crates/app/src/public/html/profile/posts.lisp
@@ -44,9 +44,10 @@
         ("ui_ident" "io_data_load")
         (div ("ui_ident" "io_data_marker"))))
 
+(text "{% set paged = user and user.settings.paged_timelines %}")
 (script
     (text "setTimeout(() => {
-        trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?user_id={{ profile.id }}&tag={{ tag }}&page=\", Number.parseInt(\"{{ page }}\") - 1]);
+        trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?user_id={{ profile.id }}&tag={{ tag }}&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
     });"))
 
 (text "{% endblock %}")
diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp
index 88c6d59..268dfef 100644
--- a/crates/app/src/public/html/profile/settings.lisp
+++ b/crates/app/src/public/html/profile/settings.lisp
@@ -1403,6 +1403,11 @@
                         \"Hides dislikes on all posts. Users will also no longer be able to dislike your posts.\",
                         \"text\",
                     ],
+                    [
+                        [\"paged_timelines\", \"Make timelines paged instead of infinitely scrolled\"],
+                        \"{{ profile.settings.paged_timelines }}\",
+                        \"checkbox\",
+                    ],
                     [[], \"Fun\", \"title\"],
                     [
                         [\"disable_gpa_fun\", \"Disable GPA\"],
diff --git a/crates/app/src/public/html/stacks/feed.lisp b/crates/app/src/public/html/stacks/feed.lisp
index 5698065..5002856 100644
--- a/crates/app/src/public/html/stacks/feed.lisp
+++ b/crates/app/src/public/html/stacks/feed.lisp
@@ -83,9 +83,10 @@
                 ("ui_ident" "io_data_load")
                 (div ("ui_ident" "io_data_marker")))
 
+            (text "{% set paged = user and user.settings.paged_timelines %}")
             (script
                 (text "setTimeout(() => {
-                    trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?stack_id={{ stack.id }}&page=\", Number.parseInt(\"{{ page }}\") - 1]);
+                    trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?stack_id={{ stack.id }}&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
                 });"))
             (text "{%- endif %}"))))
 
diff --git a/crates/app/src/public/html/timelines/all.lisp b/crates/app/src/public/html/timelines/all.lisp
index c38dd88..7cced78 100644
--- a/crates/app/src/public/html/timelines/all.lisp
+++ b/crates/app/src/public/html/timelines/all.lisp
@@ -33,9 +33,10 @@
         ("ui_ident" "io_data_load")
         (div ("ui_ident" "io_data_marker"))))
 
+(text "{% set paged = user and user.settings.paged_timelines %}")
 (script
     (text "setTimeout(() => {
-        trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=AllPosts&page=\", Number.parseInt(\"{{ page }}\") - 1]);
+        trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=AllPosts&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
     });"))
 
 (text "{% endblock %}")
diff --git a/crates/app/src/public/html/timelines/following.lisp b/crates/app/src/public/html/timelines/following.lisp
index b1759e4..ef23a55 100644
--- a/crates/app/src/public/html/timelines/following.lisp
+++ b/crates/app/src/public/html/timelines/following.lisp
@@ -11,9 +11,10 @@
         ("ui_ident" "io_data_load")
         (div ("ui_ident" "io_data_marker"))))
 
+(text "{% set paged = user and user.settings.paged_timelines %}")
 (script
     (text "setTimeout(() => {
-        trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=FollowingPosts&page=\", Number.parseInt(\"{{ page }}\") - 1]);
+        trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=FollowingPosts&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
     });"))
 
 (text "{% endblock %}")
diff --git a/crates/app/src/public/html/timelines/home.lisp b/crates/app/src/public/html/timelines/home.lisp
index e398615..5a5658b 100644
--- a/crates/app/src/public/html/timelines/home.lisp
+++ b/crates/app/src/public/html/timelines/home.lisp
@@ -31,9 +31,10 @@
         (div ("ui_ident" "io_data_marker")))
     (text "{%- endif %}"))
 
+(text "{% set paged = user and user.settings.paged_timelines %}")
 (script
     (text "setTimeout(() => {
-        trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=MyCommunities&page=\", Number.parseInt(\"{{ page }}\") - 1]);
+        trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=MyCommunities&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
     });"))
 
 (text "{% endblock %}")
diff --git a/crates/app/src/public/html/timelines/popular.lisp b/crates/app/src/public/html/timelines/popular.lisp
index 6d26f3d..d0223df 100644
--- a/crates/app/src/public/html/timelines/popular.lisp
+++ b/crates/app/src/public/html/timelines/popular.lisp
@@ -11,9 +11,10 @@
         ("ui_ident" "io_data_load")
         (div ("ui_ident" "io_data_marker"))))
 
+(text "{% set paged = user and user.settings.paged_timelines %}")
 (script
     (text "setTimeout(() => {
-        trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=PopularPosts&page=\", Number.parseInt(\"{{ page }}\") - 1]);
+        trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=PopularPosts&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
     });"))
 
 (text "{% endblock %}")
diff --git a/crates/app/src/public/html/timelines/swiss_army.lisp b/crates/app/src/public/html/timelines/swiss_army.lisp
index eb722c9..23243ce 100644
--- a/crates/app/src/public/html/timelines/swiss_army.lisp
+++ b/crates/app/src/public/html/timelines/swiss_army.lisp
@@ -30,3 +30,7 @@
         (str (text "chats:label.go_back")))
     (text "{%- endif %}"))
 (text "{%- endif %}")
+
+(text "{% if paginated -%}")
+(text "{{ components::pagination(page=page, items=list|length) }}")
+(text "{%- endif %}")
diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js
index 6c30428..835f76e 100644
--- a/crates/app/src/public/js/atto.js
+++ b/crates/app/src/public/js/atto.js
@@ -1141,7 +1141,7 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
         },
     );
 
-    self.define("io_data_load", (_, tmpl, page) => {
+    self.define("io_data_load", (_, tmpl, page, paginated_mode = false) => {
         self.IO_DATA_MARKER = document.querySelector(
             "[ui_ident=io_data_marker]",
         );
@@ -1164,7 +1164,16 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
         self.IO_DATA_PAGE = page;
         self.IO_DATA_SEEN_IDS = [];
 
-        self.IO_DATA_OBSERVER.observe(self.IO_DATA_MARKER);
+        if (!paginated_mode) {
+            self.IO_DATA_OBSERVER.observe(self.IO_DATA_MARKER);
+        } else {
+            // immediately load first page
+            self.IO_DATA_TMPL = self.IO_DATA_TMPL.replace("&page=", "");
+            self.IO_DATA_TMPL += `&paginated=true&page=`;
+            self.io_load_data();
+        }
+
+        self.IO_PAGINATED = paginated_mode;
     });
 
     self.define("io_load_data", async () => {
diff --git a/crates/app/src/routes/api/v1/auth/images.rs b/crates/app/src/routes/api/v1/auth/images.rs
index e062be1..e177db7 100644
--- a/crates/app/src/routes/api/v1/auth/images.rs
+++ b/crates/app/src/routes/api/v1/auth/images.rs
@@ -213,7 +213,7 @@ pub async fn upload_avatar_request(
     if mime == "image/gif" {
         // gif image, don't encode
         if img.0.len() > MAXIMUM_GIF_FILE_SIZE {
-            return Json(Error::DataTooLong("gif".to_string()).into());
+            return Json(Error::FileTooLarge.into());
         }
 
         std::fs::write(&path, img.0).unwrap();
@@ -226,7 +226,7 @@ pub async fn upload_avatar_request(
 
     // check file size
     if img.0.len() > MAXIMUM_FILE_SIZE {
-        return Json(Error::DataTooLong("image".to_string()).into());
+        return Json(Error::FileTooLarge.into());
     }
 
     // upload image
@@ -314,7 +314,7 @@ pub async fn upload_banner_request(
     if mime == "image/gif" {
         // gif image, don't encode
         if img.0.len() > MAXIMUM_GIF_FILE_SIZE {
-            return Json(Error::DataTooLong("gif".to_string()).into());
+            return Json(Error::FileTooLarge.into());
         }
 
         std::fs::write(&path, img.0).unwrap();
@@ -327,7 +327,7 @@ pub async fn upload_banner_request(
 
     // check file size
     if img.0.len() > MAXIMUM_FILE_SIZE {
-        return Json(Error::DataTooLong("image".to_string()).into());
+        return Json(Error::FileTooLarge.into());
     }
 
     // upload image
diff --git a/crates/app/src/routes/api/v1/communities/images.rs b/crates/app/src/routes/api/v1/communities/images.rs
index 464dede..3ddee00 100644
--- a/crates/app/src/routes/api/v1/communities/images.rs
+++ b/crates/app/src/routes/api/v1/communities/images.rs
@@ -136,7 +136,7 @@ pub async fn upload_avatar_request(
 
     // check file size
     if img.0.len() > MAXIMUM_FILE_SIZE {
-        return Json(Error::DataTooLong("image".to_string()).into());
+        return Json(Error::FileTooLarge.into());
     }
 
     // upload image
@@ -191,7 +191,7 @@ pub async fn upload_banner_request(
 
     // check file size
     if img.0.len() > MAXIMUM_FILE_SIZE {
-        return Json(Error::DataTooLong("image".to_string()).into());
+        return Json(Error::FileTooLarge.into());
     }
 
     // upload image
diff --git a/crates/app/src/routes/api/v1/communities/posts.rs b/crates/app/src/routes/api/v1/communities/posts.rs
index 81a1fae..7bf4bf3 100644
--- a/crates/app/src/routes/api/v1/communities/posts.rs
+++ b/crates/app/src/routes/api/v1/communities/posts.rs
@@ -133,7 +133,7 @@ pub async fn create_request(
     // check sizes
     for img in &images {
         if img.len() > MAXIMUM_FILE_SIZE {
-            return Json(Error::DataTooLong("image".to_string()).into());
+            return Json(Error::FileTooLarge.into());
         }
     }
 
diff --git a/crates/app/src/routes/pages/misc.rs b/crates/app/src/routes/pages/misc.rs
index 3ff3f0d..8b76292 100644
--- a/crates/app/src/routes/pages/misc.rs
+++ b/crates/app/src/routes/pages/misc.rs
@@ -576,6 +576,8 @@ pub struct TimelineQuery {
     pub user_id: usize,
     #[serde(default)]
     pub tag: String,
+    #[serde(default)]
+    pub paginated: bool,
 }
 
 /// `/_swiss_army_timeline`
@@ -697,6 +699,7 @@ pub async fn swiss_army_timeline_request(
 
     context.insert("list", &list);
     context.insert("page", &req.page);
+    context.insert("paginated", &req.paginated);
     Ok(Html(
         data.1
             .render("timelines/swiss_army.html", &context)
diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs
index a5714c3..bc8b13f 100644
--- a/crates/core/src/model/auth.rs
+++ b/crates/core/src/model/auth.rs
@@ -231,6 +231,9 @@ pub struct UserSettings {
     /// A list of strings the user has muted.
     #[serde(default)]
     pub muted: Vec<String>,
+    /// If timelines are paged instead of infinitely scrolled.
+    #[serde(default)]
+    pub paged_timelines: bool,
 }
 
 fn mime_avif() -> String {
diff --git a/crates/core/src/model/mod.rs b/crates/core/src/model/mod.rs
index c50ea7c..62f26a3 100644
--- a/crates/core/src/model/mod.rs
+++ b/crates/core/src/model/mod.rs
@@ -41,6 +41,7 @@ pub enum Error {
     AlreadyAuthenticated,
     DataTooLong(String),
     DataTooShort(String),
+    FileTooLarge,
     UsernameInUse,
     TitleInUse,
     QuestionsDisabled,
@@ -62,6 +63,7 @@ impl Display for Error {
             Self::AlreadyAuthenticated => "Already authenticated".to_string(),
             Self::DataTooLong(name) => format!("Given {name} is too long!"),
             Self::DataTooShort(name) => format!("Given {name} is too short!"),
+            Self::FileTooLarge => "Given file is too large".to_string(),
             Self::UsernameInUse => "Username in use".to_string(),
             Self::TitleInUse => "Title in use".to_string(),
             Self::QuestionsDisabled => "You are not allowed to ask questions there".to_string(),

From 6be729de50c3f5c74965fbcfa077cf86a2c6ebc3 Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Thu, 19 Jun 2025 22:37:49 -0400
Subject: [PATCH 29/71] fix: journals scrolling

---
 crates/app/src/public/html/journals/app.lisp | 21 ++++++++++++++++++++
 1 file changed, 21 insertions(+)

diff --git a/crates/app/src/public/html/journals/app.lisp b/crates/app/src/public/html/journals/app.lisp
index a5136ff..012909d 100644
--- a/crates/app/src/public/html/journals/app.lisp
+++ b/crates/app/src/public/html/journals/app.lisp
@@ -2,6 +2,27 @@
 (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 }}"))
 
+(style
+    (text "html, body {
+        overflow: hidden auto !important;
+    }
+
+    .sidebar {
+        position: sticky;
+        top: 42px;
+    }
+
+    @media screen and (max-width: 900px) {
+        .sidebar {
+            position: absolute;
+            top: unset;
+        }
+
+        body.sidebars_shown {
+            overflow: hidden !important;
+        }
+    }"))
+
 (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 }}"))

From 16843a6ab8fd6620c04320d2a71ecdb4171396bf Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Fri, 20 Jun 2025 17:40:55 -0400
Subject: [PATCH 30/71] add: drawings in questions

---
 crates/app/src/assets.rs                      |   1 +
 crates/app/src/langs/en-US.toml               |   2 +
 crates/app/src/public/css/root.css            |   8 +
 crates/app/src/public/html/body.lisp          |  36 +
 crates/app/src/public/html/components.lisp    |  94 ++-
 crates/app/src/public/html/journals/app.lisp  |   3 +-
 crates/app/src/public/html/profile/media.lisp |   2 +-
 .../app/src/public/html/profile/outbox.lisp   |   2 +-
 crates/app/src/public/html/profile/posts.lisp |   2 +-
 .../app/src/public/html/profile/replies.lisp  |   2 +-
 .../app/src/public/html/profile/settings.lisp |   8 +
 crates/app/src/public/js/atto.js              |   1 +
 crates/app/src/public/js/carp.js              | 624 ++++++++++++++++++
 crates/app/src/public/js/loader.js            |   2 +-
 .../routes/api/v1/communities/questions.rs    |   8 +-
 crates/app/src/routes/api/v1/journals.rs      |  12 +-
 crates/app/src/routes/api/v1/uploads.rs       |  17 +-
 crates/app/src/routes/assets.rs               |   1 +
 crates/app/src/routes/mod.rs                  |   1 +
 crates/core/src/database/auth.rs              |   2 +-
 crates/core/src/database/posts.rs             |  18 +
 crates/core/src/database/questions.rs         |  65 +-
 crates/core/src/model/auth.rs                 |   3 +
 crates/core/src/model/carp.rs                 | 285 ++++++++
 crates/core/src/model/communities.rs          |   4 +
 crates/core/src/model/mod.rs                  |   3 +
 crates/core/src/model/uploads.rs              |   5 +-
 sql_changes/questions_drawings.sql            |   2 +
 28 files changed, 1181 insertions(+), 32 deletions(-)
 create mode 100644 crates/app/src/public/js/carp.js
 create mode 100644 crates/core/src/model/carp.rs
 create mode 100644 sql_changes/questions_drawings.sql

diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs
index a3bb588..5533a77 100644
--- a/crates/app/src/assets.rs
+++ b/crates/app/src/assets.rs
@@ -39,6 +39,7 @@ pub const LOADER_JS: &str = include_str!("./public/js/loader.js");
 pub const ATTO_JS: &str = include_str!("./public/js/atto.js");
 pub const ME_JS: &str = include_str!("./public/js/me.js");
 pub const STREAMS_JS: &str = include_str!("./public/js/streams.js");
+pub const CARP_JS: &str = include_str!("./public/js/carp.js");
 
 // html
 pub const BODY: &str = include_str!("./public/html/body.lisp");
diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml
index b725251..11491fc 100644
--- a/crates/app/src/langs/en-US.toml
+++ b/crates/app/src/langs/en-US.toml
@@ -135,6 +135,8 @@ version = "1.0.0"
 "communities:label.file" = "File"
 "communities:label.drafts" = "Drafts"
 "communities:label.load" = "Load"
+"communities:action.draw" = "Draw"
+"communities:action.remove_drawing" = "Remove drawing"
 
 "notifs:action.mark_as_read" = "Mark as read"
 "notifs:action.mark_as_unread" = "Mark as unread"
diff --git a/crates/app/src/public/css/root.css b/crates/app/src/public/css/root.css
index fbb1d4d..3d7dd62 100644
--- a/crates/app/src/public/css/root.css
+++ b/crates/app/src/public/css/root.css
@@ -389,3 +389,11 @@ blockquote {
         transform: rotateZ(360deg);
     }
 }
+
+canvas {
+    border-radius: var(--radius);
+    border: solid 5px var(--color-primary);
+    background: white;
+    box-shadow: var(--shadow-x-offset) var(--shadow-y-offset) var(--shadow-size)
+        var(--color-shadow);
+}
diff --git a/crates/app/src/public/html/body.lisp b/crates/app/src/public/html/body.lisp
index 16a47d8..a8e398b 100644
--- a/crates/app/src/public/html/body.lisp
+++ b/crates/app/src/public/html/body.lisp
@@ -18,6 +18,42 @@
                 (div ("class" "skel") ("style" "width: 25%; height: 25px;"))
                 (div ("class" "skel") ("style" "width: 100%; height: 150px"))))))
 
+(template
+    ("id" "carp_canvas")
+    (div
+        ("class" "flex flex-col gap-2")
+        (div ("ui_ident" "canvas_loc"))
+        (div
+            ("class" "flex justify-between gap-2")
+            (div
+                ("class" "flex gap-2")
+                (input
+                    ("type" "color")
+                    ("style" "width: 5rem")
+                    ("ui_ident" "color_picker"))
+
+                (input
+                    ("type" "range")
+                    ("min" "1")
+                    ("max" "25")
+                    ("step" "1")
+                    ("value" "2")
+                    ("ui_ident" "stroke_range")))
+
+            (div
+                ("class" "flex  gap-2")
+                (button
+                    ("title" "Undo")
+                    ("ui_ident" "undo")
+                    ("type" "button")
+                    (icon (text "undo")))
+
+                (button
+                    ("title" "Redo")
+                    ("ui_ident" "redo")
+                    ("type" "button")
+                    (icon (text "redo")))))))
+
 ; random js
 (text "<script data-turbo-permanent=\"true\" id=\"init-script\">
     document.documentElement.addEventListener(\"turbo:load\", () => {
diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp
index 2726b26..a9f6142 100644
--- a/crates/app/src/public/html/components.lisp
+++ b/crates/app/src/public/html/components.lisp
@@ -405,7 +405,7 @@
                 (text "{%- endif %}"))))
     (text "{% if community and show_community and community.id != config.town_square or question %}"))
 
-(text "{%- endif %} {%- endmacro %} {% macro post_media(upload_ids) -%} {% if upload_ids|length > 0%}")
+(text "{%- endif %} {%- endmacro %} {% macro post_media(upload_ids) -%} {% if upload_ids|length > 0 -%}")
 (div
     ("class" "media_gallery gap-2")
     (text "{% for upload in upload_ids %}")
@@ -677,6 +677,8 @@
             ("class" "no_p_margin")
             ("style" "font-weight: 500")
             (text "{{ question.content|markdown|safe }}"))
+        ; question drawings
+        (text "{{ self::post_media(upload_ids=question.drawings) }}")
         ; anonymous user ip thing
         ; this is only shown if the post author is anonymous AND we are a helper
         (text "{% if is_helper and owner.id == 0 %}")
@@ -693,7 +695,7 @@
         (div
             ("class" "flex gap-2 items-center justify-between"))))
 
-(text "{%- endmacro %} {% macro create_question_form(receiver=\"0\", community=\"\", header=\"\", is_global=false) -%}")
+(text "{%- endmacro %} {% macro create_question_form(receiver=\"0\", community=\"\", header=\"\", is_global=false, drawing_enabled=false) -%}")
 (div
     ("class" "card-nest")
     (div
@@ -707,6 +709,12 @@
         ("onsubmit" "create_question_from_form(event)")
         (div
             ("class" "flex flex-col gap-1")
+            ; carp canvas
+            (text "{% if drawing_enabled -%}")
+            (div ("ui_ident" "carp_canvas_field"))
+            (text "{%- endif %}")
+
+            ; form
             (label
                 ("for" "content")
                 (text "{{ text \"communities:label.content\" }}"))
@@ -718,25 +726,83 @@
                 ("required" "")
                 ("minlength" "2")
                 ("maxlength" "4096")))
-        (button
-            ("class" "primary")
-            (text "{{ text \"communities:action.create\" }}"))))
+        (div
+            ("class" "flex gap-2")
+            (button
+                ("class" "primary")
+                (text "{{ text \"communities:action.create\" }}"))
+
+            (text "{% if drawing_enabled -%}")
+            (button
+                ("class" "lowered")
+                ("ui_ident" "add_drawing")
+                ("onclick" "attach_drawing()")
+                ("type" "button")
+                (text "{{ text \"communities:action.draw\" }}"))
+
+            (button
+                ("class" "lowered red hidden")
+                ("ui_ident" "remove_drawing")
+                ("onclick" "remove_drawing()")
+                ("type" "button")
+                (text "{{ text \"communities:action.remove_drawing\" }}"))
+
+            (script
+                (text "globalThis.attach_drawing = () => {
+                    globalThis.gerald = trigger(\"carp::new\", [document.querySelector(\"[ui_ident=carp_canvas_field]\")]);
+                    globalThis.gerald.create_canvas();
+
+                    document.querySelector(\"[ui_ident=add_drawing]\").classList.add(\"hidden\");
+                    document.querySelector(\"[ui_ident=remove_drawing]\").classList.remove(\"hidden\");
+                }
+
+                globalThis.remove_drawing = async () => {
+                    if (
+                        !(await trigger(\"atto::confirm\", [
+                            \"Are you sure you would like to do this?\",
+                        ]))
+                    ) {
+                        return;
+                    }
+
+                    document.querySelector(\"[ui_ident=carp_canvas_field]\").innerHTML = \"\";
+                    globalThis.gerald = null;
+
+                    document.querySelector(\"[ui_ident=add_drawing]\").classList.remove(\"hidden\");
+                    document.querySelector(\"[ui_ident=remove_drawing]\").classList.add(\"hidden\");
+                }"))
+            (text "{%- endif %}"))))
 
 (script
-    (text "async function create_question_from_form(e) {
+    (text "globalThis.gerald = null;
+    async function create_question_from_form(e) {
         e.preventDefault();
         await trigger(\"atto::debounce\", [\"questions::create\"]);
-        fetch(\"/api/v1/questions\", {
-            method: \"POST\",
-            headers: {
-                \"Content-Type\": \"application/json\",
-            },
-            body: JSON.stringify({
+
+        // create body
+        const body = new FormData();
+
+        if (globalThis.gerald) {
+            body.append(\"drawing.carpgraph\", new Blob([new Uint8Array(globalThis.gerald.as_carp2())], {
+                type: \"application/octet-stream\"
+            }));
+        }
+
+
+        body.append(
+            \"body\",
+            JSON.stringify({
                 content: e.target.content.value,
                 receiver: \"{{ receiver }}\",
                 community: \"{{ community }}\",
                 is_global: \"{{ is_global }}\" == \"true\",
             }),
+        );
+
+        // ...
+        fetch(\"/api/v1/questions\", {
+            method: \"POST\",
+            body,
         })
             .then((res) => res.json())
             .then((res) => {
@@ -747,6 +813,10 @@
 
                 if (res.ok) {
                     e.target.reset();
+
+                    if (globalThis.gerald) {
+                        globalThis.gerald.clear();
+                    }
                 }
             });
     }"))
diff --git a/crates/app/src/public/html/journals/app.lisp b/crates/app/src/public/html/journals/app.lisp
index 012909d..54a5415 100644
--- a/crates/app/src/public/html/journals/app.lisp
+++ b/crates/app/src/public/html/journals/app.lisp
@@ -276,7 +276,7 @@
             ; init codemirror
             (script ("id" "editor_content") ("type" "text/markdown") (text "{{ note.content|remove_script_tags|safe }}"))
             (script
-                (text "setTimeout(() => {
+                (text "setTimeout(async () => {
                     if (!document.getElementById(\"preview_tab\").shadowRoot) {
                         document.getElementById(\"preview_tab\").attachShadow({ mode: \"open\" });
                     }
@@ -335,6 +335,7 @@
 
                         const preview_token = window.crypto.randomUUID();
                         document.getElementById(\"preview_tab\").shadowRoot.innerHTML = `${res}<style>
+                            @import url(\"/css/style.css\");
                             @import url(\"/api/v1/journals/{{ journal.id }}/journal.css?v=preview-${preview_token}\");
                         </style>`;
                         trigger(\"atto::hooks::tabs:switch\", [\"preview\"]);
diff --git a/crates/app/src/public/html/profile/media.lisp b/crates/app/src/public/html/profile/media.lisp
index a276c90..a3888dc 100644
--- a/crates/app/src/public/html/profile/media.lisp
+++ b/crates/app/src/public/html/profile/media.lisp
@@ -1,7 +1,7 @@
 (text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}")
 (div
     ("style" "display: contents")
-    (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header) }}"))
+    (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings) }}"))
 
 (text "{%- endif %} {{ macros::profile_nav(selected=\"media\") }}")
 (div
diff --git a/crates/app/src/public/html/profile/outbox.lisp b/crates/app/src/public/html/profile/outbox.lisp
index 316aa62..d77d314 100644
--- a/crates/app/src/public/html/profile/outbox.lisp
+++ b/crates/app/src/public/html/profile/outbox.lisp
@@ -1,7 +1,7 @@
 (text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}")
 (div
     ("style" "display: contents")
-    (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header) }}"))
+    (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings) }}"))
 
 (text "{%- endif %} {{ macros::profile_nav(selected=\"outbox\") }}")
 (div
diff --git a/crates/app/src/public/html/profile/posts.lisp b/crates/app/src/public/html/profile/posts.lisp
index 06aca2f..bbdd6a5 100644
--- a/crates/app/src/public/html/profile/posts.lisp
+++ b/crates/app/src/public/html/profile/posts.lisp
@@ -1,7 +1,7 @@
 (text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}")
 (div
     ("style" "display: contents")
-    (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header) }}"))
+    (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings) }}"))
 
 (text "{%- endif %} {% if not tag and pinned|length != 0 -%}")
 (div
diff --git a/crates/app/src/public/html/profile/replies.lisp b/crates/app/src/public/html/profile/replies.lisp
index ff54816..4afe348 100644
--- a/crates/app/src/public/html/profile/replies.lisp
+++ b/crates/app/src/public/html/profile/replies.lisp
@@ -1,7 +1,7 @@
 (text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}")
 (div
     ("style" "display: contents")
-    (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header) }}"))
+    (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings) }}"))
 
 (text "{%- endif %} {{ macros::profile_nav(selected=\"replies\") }}")
 (div
diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp
index 268dfef..31856fb 100644
--- a/crates/app/src/public/html/profile/settings.lisp
+++ b/crates/app/src/public/html/profile/settings.lisp
@@ -1376,6 +1376,14 @@
                         \"{{ profile.settings.allow_anonymous_questions }}\",
                         \"checkbox\",
                     ],
+                    [
+                        [
+                            \"enable_drawings\",
+                            \"Allow users to create drawings and submit them with questions\",
+                        ],
+                        \"{{ profile.settings.enable_drawings }}\",
+                        \"checkbox\",
+                    ],
                     [
                         [\"motivational_header\", \"Motivational header\"],
                         settings.motivational_header,
diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js
index 835f76e..a417a46 100644
--- a/crates/app/src/public/js/atto.js
+++ b/crates/app/src/public/js/atto.js
@@ -39,6 +39,7 @@ media_theme_pref();
     // init
     use("me", () => {});
     use("streams", () => {});
+    use("carp", () => {});
 
     // env
     self.DEBOUNCE = [];
diff --git a/crates/app/src/public/js/carp.js b/crates/app/src/public/js/carp.js
new file mode 100644
index 0000000..dbef3ed
--- /dev/null
+++ b/crates/app/src/public/js/carp.js
@@ -0,0 +1,624 @@
+(() => {
+    const self = reg_ns("carp");
+
+    const END_OF_HEADER = 0x1a;
+    const COLOR = 0x1b;
+    const SIZE = 0x2b;
+    const LINE = 0x3b;
+    const POINT = 0x4b;
+    const EOF = 0x1f;
+
+    function enc(s, as = "guess") {
+        if ((as === "guess" && typeof s === "number") || as === "u32") {
+            // encode u32
+            const view = new DataView(new ArrayBuffer(16));
+            view.setUint32(0, s);
+            return new Uint8Array(view.buffer).slice(0, 4);
+        }
+
+        if (as === "u16") {
+            // encode u16
+            const view = new DataView(new ArrayBuffer(16));
+            view.setUint16(0, s);
+            return new Uint8Array(view.buffer).slice(0, 2);
+        }
+
+        // encode string
+        const encoder = new TextEncoder();
+        return encoder.encode(s);
+    }
+
+    function dec(as, from) {
+        if (as === "u32") {
+            // decode u32
+            const view = new DataView(new Uint8Array(from).buffer);
+            return view.getUint32(0);
+        }
+
+        if (as === "u16") {
+            // decode u16
+            const view = new DataView(new Uint8Array(from).buffer);
+            return view.getUint16(0);
+        }
+
+        // decode string
+        const decoder = new TextDecoder();
+        return decoder.decode(from);
+    }
+
+    function lpad(size, input) {
+        if (input.length === size) {
+            return input;
+        }
+
+        for (let i = 0; i < size - (input.length - 1); i++) {
+            input = [0, ...input];
+        }
+
+        return input;
+    }
+
+    self.enc = enc;
+    self.dec = dec;
+    self.lpad = lpad;
+
+    self.CARPS = {};
+    self.define("new", function ({ $ }, bind_to, read_only = false) {
+        const canvas = new CarpCanvas(bind_to, read_only);
+        $.CARPS[bind_to.getAttribute("ui_ident")] = canvas;
+        return canvas;
+    });
+
+    class CarpCanvas {
+        #element; // HTMLElement
+        #ctx; // CanvasRenderingContext2D
+        #pos = { x: 0, y: 0 }; // Vec2
+
+        STROKE_SIZE = 2;
+        #stroke_size_old = 2;
+        COLOR = "#000000";
+        #color_old = "#000000";
+
+        COMMANDS = [];
+        HISTORY = [];
+        HISTORY_IDX = 0;
+        #cmd_store = [];
+        #undo_clear_future = false; // if we should clear to HISTORY_IDX on next draw
+
+        onedit;
+        read_only;
+
+        /// Create a new [`CarpCanvas`]
+        constructor(element, read_only) {
+            this.#element = element;
+            this.read_only = read_only;
+        }
+
+        /// Push #line_store to LINES
+        push_state() {
+            this.COMMANDS = [...this.COMMANDS, ...this.#cmd_store];
+            this.#cmd_store = [];
+
+            this.HISTORY.push(this.COMMANDS);
+            this.HISTORY_IDX += 1;
+
+            if (this.#undo_clear_future) {
+                this.HISTORY = this.HISTORY.slice(0, this.HISTORY_IDX);
+                this.#undo_clear_future = false;
+            }
+
+            if (this.onedit) {
+                this.onedit(this.as_string());
+            }
+        }
+
+        /// Read current position in history and draw it.
+        draw_from_history() {
+            this.COMMANDS = this.HISTORY[this.HISTORY_IDX];
+            const bytes = this.as_carp2();
+            this.from_bytes(bytes); // draw
+        }
+
+        /// Undo changes.
+        undo() {
+            if (this.HISTORY_IDX === 0) {
+                // cannot undo
+                return;
+            }
+
+            this.HISTORY_IDX -= 1;
+            this.draw_from_history();
+            this.#undo_clear_future = false;
+        }
+
+        /// Redo changes.
+        redo() {
+            if (this.HISTORY_IDX === this.HISTORY.length - 1) {
+                // cannot redo
+                return;
+            }
+
+            this.HISTORY_IDX += 1;
+            this.draw_from_history();
+        }
+
+        /// Create canvas and init context
+        async create_canvas() {
+            const canvas = document.createElement("canvas");
+
+            canvas.width = "300";
+            canvas.height = "200";
+
+            this.#ctx = canvas.getContext("2d");
+
+            if (!this.read_only) {
+                // desktop
+                canvas.addEventListener(
+                    "mousemove",
+                    (e) => {
+                        this.draw_event(e);
+                    },
+                    false,
+                );
+
+                canvas.addEventListener(
+                    "mouseup",
+                    (e) => {
+                        this.push_state();
+                    },
+                    false,
+                );
+
+                canvas.addEventListener(
+                    "mousedown",
+                    (e) => {
+                        this.#cmd_store.push({
+                            type: "Line",
+                            data: [],
+                        });
+
+                        this.move_event(e);
+                    },
+                    false,
+                );
+
+                canvas.addEventListener(
+                    "mouseenter",
+                    (e) => {
+                        this.move_event(e);
+                    },
+                    false,
+                );
+
+                // mobile
+                canvas.addEventListener(
+                    "touchmove",
+                    (e) => {
+                        e.preventDefault();
+
+                        e.clientX = e.changedTouches[0].clientX;
+                        e.clientY = e.changedTouches[0].clientY;
+
+                        this.draw_event(e, true);
+                    },
+                    false,
+                );
+
+                canvas.addEventListener(
+                    "touchstart",
+                    (e) => {
+                        e.preventDefault();
+
+                        e.clientX = e.changedTouches[0].clientX;
+                        e.clientY = e.changedTouches[0].clientY;
+
+                        this.#cmd_store.push({
+                            type: "Line",
+                            data: [],
+                        });
+
+                        this.move_event(e);
+                    },
+                    false,
+                );
+
+                canvas.addEventListener(
+                    "touchend",
+                    (e) => {
+                        e.preventDefault();
+
+                        e.clientX = e.changedTouches[0].clientX;
+                        e.clientY = e.changedTouches[0].clientY;
+
+                        this.push_state();
+                        this.move_event(e);
+                    },
+                    false,
+                );
+
+                // add controls
+                const controls_tmpl = document
+                    .getElementById("carp_canvas")
+                    .content.cloneNode(true);
+                this.#element.appendChild(controls_tmpl);
+
+                const canvas_loc = this.#element.querySelector(
+                    "[ui_ident=canvas_loc]",
+                );
+                canvas_loc.appendChild(canvas);
+
+                const color_picker = this.#element.querySelector(
+                    "[ui_ident=color_picker]",
+                );
+                color_picker.addEventListener("change", (e) => {
+                    this.set_old_color(this.COLOR);
+                    this.COLOR = e.target.value;
+                });
+
+                const stroke_range = this.#element.querySelector(
+                    "[ui_ident=stroke_range]",
+                );
+                stroke_range.addEventListener("change", (e) => {
+                    this.set_old_stroke_size(this.STROKE_SIZE);
+                    this.STROKE_SIZE = e.target.value;
+                });
+
+                const undo = this.#element.querySelector("[ui_ident=undo]");
+                undo.addEventListener("click", () => {
+                    this.undo();
+                });
+
+                const redo = this.#element.querySelector("[ui_ident=redo]");
+                redo.addEventListener("click", () => {
+                    this.redo();
+                });
+            }
+        }
+
+        /// Resize the canvas
+        resize(size) {
+            this.#ctx.canvas.width = size.x;
+            this.#ctx.canvas.height = size.y;
+        }
+
+        /// Clear the canvas
+        clear() {
+            const canvas = this.#ctx.canvas;
+            this.#ctx.clearRect(0, 0, canvas.width, canvas.height);
+        }
+
+        /// Set the old color
+        set_old_color(value) {
+            this.#color_old = value;
+        }
+
+        /// Set the old stroke_size
+        set_old_stroke_size(value) {
+            this.#stroke_size_old = value;
+        }
+
+        /// Update position (from event)
+        move_event(e) {
+            const rect = this.#ctx.canvas.getBoundingClientRect();
+
+            const x = e.clientX - rect.left;
+            const y = e.clientY - rect.top;
+
+            this.move({ x, y });
+        }
+
+        /// Update position
+        move(pos) {
+            this.#pos.x = pos.x;
+            this.#pos.y = pos.y;
+        }
+
+        /// Draw on the canvas (from event)
+        draw_event(e, mobile = false) {
+            if (e.buttons !== 1 && mobile === false) return;
+            const rect = this.#ctx.canvas.getBoundingClientRect();
+
+            const x = e.clientX - rect.left;
+            const y = e.clientY - rect.top;
+
+            this.draw({ x, y });
+        }
+
+        /// Draw on the canvas
+        draw(pos, skip_line_store = false) {
+            this.#ctx.beginPath();
+
+            this.#ctx.lineWidth = this.STROKE_SIZE;
+            this.#ctx.strokeStyle = this.COLOR;
+            this.#ctx.lineCap = "round";
+
+            this.#ctx.moveTo(this.#pos.x, this.#pos.y);
+            this.move(pos);
+            this.#ctx.lineTo(this.#pos.x, this.#pos.y);
+
+            if (!skip_line_store) {
+                // yes flooring the values will make the image SLIGHTLY different,
+                // but it also saves THOUSANDS of characters
+                const point = [
+                    Math.floor(this.#pos.x),
+                    Math.floor(this.#pos.y),
+                ];
+
+                if (this.#color_old !== this.COLOR) {
+                    this.#cmd_store.push({
+                        type: "Color",
+                        data: enc(this.COLOR.replace("#", "")),
+                    });
+                }
+
+                if (this.#stroke_size_old !== this.STROKE_SIZE) {
+                    this.#cmd_store.push({
+                        type: "Size",
+                        data: lpad(2, enc(this.STROKE_SIZE, "u16")), // u16
+                    });
+                }
+
+                this.#cmd_store.push({
+                    type: "Point",
+                    data: [
+                        // u32
+                        ...lpad(4, enc(point[0])),
+                        ...lpad(4, enc(point[1])),
+                    ],
+                });
+
+                if (this.#color_old !== this.COLOR) {
+                    // we've already seen it once, time to update it
+                    this.set_old_color(this.COLOR);
+                }
+
+                if (this.#stroke_size_old !== this.STROKE_SIZE) {
+                    this.set_old_stroke_size(this.STROKE_SIZE);
+                }
+            }
+
+            this.#ctx.stroke();
+        }
+
+        /// Create blob and get URL
+        as_blob() {
+            const blob = this.#ctx.canvas.toBlob();
+            return URL.createObjectURL(blob);
+        }
+
+        /// Create Carp2 representation of the graph
+        as_carp2() {
+            // most stuff should have an lpad of 4 to make sure it's a u32 (4 bytes)
+            const header = [
+                ...enc("CG"),
+                ...enc("02"),
+                ...lpad(4, enc(this.#ctx.canvas.width)),
+                ...lpad(4, enc(this.#ctx.canvas.height)),
+                END_OF_HEADER,
+            ];
+
+            // build commands
+            const commands = [];
+            commands.push(COLOR);
+            commands.push(...enc("000000"));
+            commands.push(SIZE);
+            commands.push(...lpad(4, enc(2)).slice(2));
+
+            for (const command of this.COMMANDS) {
+                // this is `impl Into<Vec<u8>> for Command`
+                switch (command.type) {
+                    case "Point":
+                        commands.push(POINT);
+                        break;
+
+                    case "Line":
+                        commands.push(LINE);
+                        break;
+
+                    case "Color":
+                        commands.push(COLOR);
+                        break;
+
+                    case "Size":
+                        commands.push(SIZE);
+                        break;
+                }
+
+                commands.push(...command.data);
+            }
+
+            // this is so fucking stupid the fact that arraybuffers send as a fucking
+            // concatenated string of the NUMBERS of the bytes is so stupid this is
+            // actually crazy what the fuck is this shit
+            //
+            // didn't expect i'd have to do this shit myself considering it's done
+            // for you with File prototypes from a file input
+            const bin = [...header, ...commands, EOF];
+            let bin_str = "";
+
+            for (const byte of bin) {
+                bin_str += String.fromCharCode(byte);
+            }
+
+            // return
+            return bin;
+        }
+
+        /// Export lines as string
+        as_string() {
+            return JSON.stringify(this.COMMANDS);
+        }
+
+        /// From an array of bytes
+        from_bytes(input) {
+            this.clear();
+
+            let idx = -1;
+            function next() {
+                idx += 1;
+                return [idx, input[idx]];
+            }
+
+            function select_bytes(count) {
+                // select_bytes! macro
+                const data = [];
+                let seen_bytes = 0;
+
+                let [_, byte] = next();
+                while (byte !== undefined) {
+                    seen_bytes += 1;
+                    data.push(byte);
+
+                    if (seen_bytes === count) {
+                        break;
+                    }
+
+                    [_, byte] = next();
+                }
+
+                return data;
+            }
+
+            // everything past this is just a reverse implementation of carp2.rs in js
+            const commands = [];
+            const dimensions = { x: 0, y: 0 };
+            let in_header = true;
+            let seen_point = false;
+            let byte_buffer = [];
+
+            let [i, byte] = next();
+            while (byte !== undefined) {
+                switch (byte) {
+                    case END_OF_HEADER:
+                        in_header = false;
+                        break;
+
+                    case COLOR:
+                        {
+                            const data = select_bytes(6);
+                            commands.push({
+                                type: "Color",
+                                data,
+                            });
+                            this.COLOR = `#${dec("string", new Uint8Array(data))}`;
+                        }
+                        break;
+
+                    case SIZE:
+                        {
+                            const data = select_bytes(2);
+                            commands.push({
+                                type: "Size",
+                                data,
+                            });
+                            this.STROKE_SIZE = dec("u16", data);
+                        }
+                        break;
+
+                    case POINT:
+                        {
+                            const data = select_bytes(8);
+                            commands.push({
+                                type: "Point",
+                                data,
+                            });
+
+                            const point = {
+                                x: dec("u32", data.slice(0, 4)),
+                                y: dec("u32", data.slice(4, 8)),
+                            };
+
+                            if (!seen_point) {
+                                // this is the FIRST POINT that has been seen...
+                                // we need to start drawing from here to avoid a line
+                                // from 0,0 to the point
+                                this.move(point);
+                                seen_point = true;
+                            }
+
+                            this.draw(point, true);
+                        }
+                        break;
+
+                    case LINE:
+                        // each line starts at a new place (probably)
+                        seen_point = false;
+                        break;
+
+                    case EOF:
+                        break;
+
+                    default:
+                        if (in_header) {
+                            if (0 <= i < 2) {
+                                // tag
+                            } else if (2 <= i < 4) {
+                                //version
+                            } else if (4 <= i < 8) {
+                                // width
+                                byte_buffer.push(byte);
+
+                                if (i === 7) {
+                                    dimensions.x = dec("u32", byte_buffer);
+                                    byte_buffer = [];
+                                }
+                            } else if (8 <= i < 12) {
+                                // height
+                                byte_buffer.push(byte);
+
+                                if (i === 7) {
+                                    dimensions.y = dec("u32", byte_buffer);
+                                    byte_buffer = [];
+                                    this.resize(dimensions); // update canvas
+                                }
+                            }
+                        } else {
+                            // misc byte
+                            console.log(`extraneous byte at ${i}`);
+                        }
+
+                        break;
+                }
+
+                // ...
+                [i, byte] = next();
+            }
+
+            return commands;
+        }
+
+        /// Download image as `.carpgraph`
+        download() {
+            const blob = new Blob([new Uint8Array(this.as_carp2())], {
+                type: "image/carpgraph",
+            });
+
+            const url = URL.createObjectURL(blob);
+
+            const anchor = document.createElement("a");
+            anchor.href = url;
+            anchor.setAttribute("download", "image.carpgraph");
+            document.body.appendChild(anchor);
+            anchor.click();
+            anchor.remove();
+
+            URL.revokeObjectURL(url);
+        }
+
+        /// Download image as `.carpgraph1`
+        download_json() {
+            const string = this.as_string();
+            const blob = new Blob([string], { type: "application/json" });
+            const url = URL.createObjectURL(blob);
+
+            const anchor = document.createElement("a");
+            anchor.href = url;
+            anchor.setAttribute("download", "image.carpgraph_json");
+            document.body.appendChild(anchor);
+            anchor.click();
+            anchor.remove();
+
+            URL.revokeObjectURL(url);
+        }
+    }
+})();
diff --git a/crates/app/src/public/js/loader.js b/crates/app/src/public/js/loader.js
index 558a9a7..fd1d1df 100644
--- a/crates/app/src/public/js/loader.js
+++ b/crates/app/src/public/js/loader.js
@@ -24,7 +24,7 @@ globalThis.ns = (ns) => {
 
     if (!res) {
         return console.error(
-            "namespace does not exist, please use one of the following:",
+            `namespace "${ns}" does not exist, please use one of the following:`,
             Object.keys(globalThis._app_base.ns_store),
         );
     }
diff --git a/crates/app/src/routes/api/v1/communities/questions.rs b/crates/app/src/routes/api/v1/communities/questions.rs
index 270197f..5e9de0d 100644
--- a/crates/app/src/routes/api/v1/communities/questions.rs
+++ b/crates/app/src/routes/api/v1/communities/questions.rs
@@ -15,6 +15,7 @@ use tetratto_core::model::{
 };
 use crate::{
     get_user_from_token,
+    image::JsonMultipart,
     routes::{api::v1::CreateQuestion, pages::PaginatedQuery},
     State,
 };
@@ -23,7 +24,7 @@ pub async fn create_request(
     jar: CookieJar,
     headers: HeaderMap,
     Extension(data): Extension<State>,
-    Json(req): Json<CreateQuestion>,
+    JsonMultipart(drawings, req): JsonMultipart<CreateQuestion>,
 ) -> impl IntoResponse {
     let data = &(data.read().await).0;
     let user = get_user_from_token!(jar, data, oauth::AppScope::UserCreateQuestions);
@@ -70,7 +71,10 @@ pub async fn create_request(
         }
     }
 
-    match data.create_question(props).await {
+    match data
+        .create_question(props, drawings.iter().map(|x| x.to_vec()).collect())
+        .await
+    {
         Ok(id) => Json(ApiReturn {
             ok: true,
             message: "Question created".to_string(),
diff --git a/crates/app/src/routes/api/v1/journals.rs b/crates/app/src/routes/api/v1/journals.rs
index de6d501..19944c6 100644
--- a/crates/app/src/routes/api/v1/journals.rs
+++ b/crates/app/src/routes/api/v1/journals.rs
@@ -57,10 +57,18 @@ pub async fn get_css_request(
 
     let note = match data.get_note_by_journal_title(id, "journal.css").await {
         Ok(x) => x,
-        Err(e) => return ([("Content-Type", "text/plain")], format!("/* {e} */")),
+        Err(e) => {
+            return (
+                [("Content-Type", "text/css"), ("Cache-Control", "no-cache")],
+                format!("/* {e} */"),
+            );
+        }
     };
 
-    ([("Content-Type", "text/css")], note.content)
+    (
+        [("Content-Type", "text/css"), ("Cache-Control", "no-cache")],
+        note.content,
+    )
 }
 
 pub async fn list_request(jar: CookieJar, Extension(data): Extension<State>) -> impl IntoResponse {
diff --git a/crates/app/src/routes/api/v1/uploads.rs b/crates/app/src/routes/api/v1/uploads.rs
index 8a6a8bb..0e7d6ab 100644
--- a/crates/app/src/routes/api/v1/uploads.rs
+++ b/crates/app/src/routes/api/v1/uploads.rs
@@ -4,7 +4,7 @@ use axum_extra::extract::CookieJar;
 use pathbufd::PathBufD;
 use crate::{get_user_from_token, State};
 use super::auth::images::read_image;
-use tetratto_core::model::{oauth, ApiReturn, Error};
+use tetratto_core::model::{carp::CarpGraph, oauth, uploads::MediaType, ApiReturn, Error};
 
 pub async fn get_request(
     Path(id): Path<usize>,
@@ -39,10 +39,17 @@ pub async fn get_request(
         ));
     }
 
-    Ok((
-        [("Content-Type", upload.what.mime())],
-        Body::from(read_image(path)),
-    ))
+    let bytes = read_image(path);
+
+    if upload.what == MediaType::Carpgraph {
+        // conver to svg and return
+        return Ok((
+            [("Content-Type", "image/svg+xml".to_string())],
+            Body::from(CarpGraph::from_bytes(bytes).to_svg()),
+        ));
+    }
+
+    Ok(([("Content-Type", upload.what.mime())], Body::from(bytes)))
 }
 
 pub async fn delete_request(
diff --git a/crates/app/src/routes/assets.rs b/crates/app/src/routes/assets.rs
index f721eb3..4a450c5 100644
--- a/crates/app/src/routes/assets.rs
+++ b/crates/app/src/routes/assets.rs
@@ -18,3 +18,4 @@ serve_asset!(loader_js_request: LOADER_JS("text/javascript"));
 serve_asset!(atto_js_request: ATTO_JS("text/javascript"));
 serve_asset!(me_js_request: ME_JS("text/javascript"));
 serve_asset!(streams_js_request: STREAMS_JS("text/javascript"));
+serve_asset!(carp_js_request: CARP_JS("text/javascript"));
diff --git a/crates/app/src/routes/mod.rs b/crates/app/src/routes/mod.rs
index 123f29a..de1b240 100644
--- a/crates/app/src/routes/mod.rs
+++ b/crates/app/src/routes/mod.rs
@@ -19,6 +19,7 @@ pub fn routes(config: &Config) -> Router {
         .route("/js/atto.js", get(assets::atto_js_request))
         .route("/js/me.js", get(assets::me_js_request))
         .route("/js/streams.js", get(assets::streams_js_request))
+        .route("/js/carp.js", get(assets::carp_js_request))
         .nest_service(
             "/public",
             get_service(tower_http::services::ServeDir::new(&config.dirs.assets)),
diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs
index ba07543..1c1bda2 100644
--- a/crates/core/src/database/auth.rs
+++ b/crates/core/src/database/auth.rs
@@ -216,7 +216,7 @@ impl DataManager {
                 &0_i32,
                 &(data.last_seen as i64),
                 &String::new(),
-                &"[]",
+                "[]",
                 &0_i32,
                 &0_i32,
                 &serde_json::to_string(&data.connections).unwrap(),
diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs
index eeef882..72b4f5b 100644
--- a/crates/core/src/database/posts.rs
+++ b/crates/core/src/database/posts.rs
@@ -1952,6 +1952,15 @@ impl DataManager {
             self.delete_poll(y.poll_id, &user).await?;
         }
 
+        // delete question (if not global question)
+        if y.context.answering != 0 {
+            let question = self.get_question_by_id(y.context.answering).await?;
+
+            if !question.is_global {
+                self.delete_question(question.id, &user).await?;
+            }
+        }
+
         // return
         Ok(())
     }
@@ -2031,6 +2040,15 @@ impl DataManager {
             for upload in y.uploads {
                 self.delete_upload(upload).await?;
             }
+
+            // delete question (if not global question)
+            if y.context.answering != 0 {
+                let question = self.get_question_by_id(y.context.answering).await?;
+
+                if !question.is_global {
+                    self.delete_question(question.id, &user).await?;
+                }
+            }
         } else {
             // incr parent comment count
             if let Some(replying_to) = y.replying_to {
diff --git a/crates/core/src/database/questions.rs b/crates/core/src/database/questions.rs
index d88fb44..0a6965c 100644
--- a/crates/core/src/database/questions.rs
+++ b/crates/core/src/database/questions.rs
@@ -2,6 +2,7 @@ use std::collections::HashMap;
 use oiseau::cache::Cache;
 use tetratto_shared::unix_epoch_timestamp;
 use crate::model::communities_permissions::CommunityPermission;
+use crate::model::uploads::{MediaType, MediaUpload};
 use crate::model::{
     Error, Result,
     communities::Question,
@@ -33,6 +34,7 @@ impl DataManager {
             // ...
             context: serde_json::from_str(&get!(x->10(String))).unwrap(),
             ip: get!(x->11(String)),
+            drawings: serde_json::from_str(&get!(x->12(String))).unwrap(),
         }
     }
 
@@ -333,13 +335,20 @@ impl DataManager {
         Ok(res.unwrap())
     }
 
+    const MAXIMUM_DRAWING_SIZE: usize = 32768; // 32 KiB
+
     /// Create a new question in the database.
     ///
     /// # Arguments
     /// * `data` - a mock [`Question`] object to insert
-    pub async fn create_question(&self, mut data: Question) -> Result<usize> {
+    pub async fn create_question(
+        &self,
+        mut data: Question,
+        drawings: Vec<Vec<u8>>,
+    ) -> Result<usize> {
         // check if we can post this
         if data.is_global {
+            // global
             if data.community > 0 {
                 // posting to community
                 data.receiver = 0;
@@ -370,6 +379,7 @@ impl DataManager {
                 }
             }
         } else {
+            // single
             let receiver = self.get_user_by_id(data.receiver).await?;
 
             if !receiver.settings.enable_questions {
@@ -380,6 +390,10 @@ impl DataManager {
                 return Err(Error::NotAllowed);
             }
 
+            if !receiver.settings.enable_drawings && drawings.len() > 0 {
+                return Err(Error::DrawingsDisabled);
+            }
+
             // check for ip block
             if self
                 .get_ipblock_by_initiator_receiver(receiver.id, &data.ip)
@@ -390,6 +404,28 @@ impl DataManager {
             }
         }
 
+        // create uploads
+        if drawings.len() > 2 {
+            return Err(Error::MiscError(
+                "Too many uploads. Please use a maximum of 2".to_string(),
+            ));
+        }
+
+        for drawing in &drawings {
+            // this is the initial iter to check sizes, we'll do uploads after
+            if drawing.len() > Self::MAXIMUM_DRAWING_SIZE {
+                return Err(Error::FileTooLarge);
+            }
+        }
+
+        for _ in 0..drawings.len() {
+            data.drawings.push(
+                self.create_upload(MediaUpload::new(MediaType::Carpgraph, data.owner))
+                    .await?
+                    .id,
+            );
+        }
+
         // ...
         let conn = match self.0.connect().await {
             Ok(c) => c,
@@ -398,7 +434,7 @@ impl DataManager {
 
         let res = execute!(
             &conn,
-            "INSERT INTO questions VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)",
+            "INSERT INTO questions VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)",
             params![
                 &(data.id as i64),
                 &(data.created as i64),
@@ -411,7 +447,8 @@ impl DataManager {
                 &0_i32,
                 &0_i32,
                 &serde_json::to_string(&data.context).unwrap(),
-                &data.ip
+                &data.ip,
+                &serde_json::to_string(&data.drawings).unwrap(),
             ]
         );
 
@@ -430,6 +467,23 @@ impl DataManager {
             .await?;
         }
 
+        // write to uploads
+        for (i, drawing_id) in data.drawings.iter().enumerate() {
+            let drawing = match drawings.get(i) {
+                Some(d) => d,
+                None => {
+                    self.delete_upload(*drawing_id).await?;
+                    continue;
+                }
+            };
+
+            let upload = self.get_upload_by_id(*drawing_id).await?;
+
+            if let Err(e) = std::fs::write(&upload.path(&self.0.0).to_string(), drawing.to_vec()) {
+                return Err(Error::MiscError(e.to_string()));
+            }
+        }
+
         // return
         Ok(data.id)
     }
@@ -495,6 +549,11 @@ impl DataManager {
             return Err(Error::DatabaseError(e.to_string()));
         }
 
+        // delete uploads
+        for upload in y.drawings {
+            self.delete_upload(upload).await?;
+        }
+
         // return
         Ok(())
     }
diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs
index bc8b13f..171c881 100644
--- a/crates/core/src/model/auth.rs
+++ b/crates/core/src/model/auth.rs
@@ -234,6 +234,9 @@ pub struct UserSettings {
     /// If timelines are paged instead of infinitely scrolled.
     #[serde(default)]
     pub paged_timelines: bool,
+    /// If drawings are enabled for questions sent to the user.
+    #[serde(default)]
+    pub enable_drawings: bool,
 }
 
 fn mime_avif() -> String {
diff --git a/crates/core/src/model/carp.rs b/crates/core/src/model/carp.rs
new file mode 100644
index 0000000..40876fa
--- /dev/null
+++ b/crates/core/src/model/carp.rs
@@ -0,0 +1,285 @@
+use serde::{Serialize, Deserialize};
+
+/// Starting at the beginning of the file, the header details specific information
+/// about the file.
+///
+/// 1. `CG` tag (2 bytes)
+/// 2. version number (2 bytes)
+/// 3. width of graph (4 bytes)
+/// 4. height of graph (4 bytes)
+/// 5. `END_OF_HEADER`
+///
+/// The header has a total of 13 bytes. (12 of info, 1 of `END_OF_HEADER)
+///
+/// Everything after `END_OF_HEADER` should be another command and its parameters.
+pub const END_OF_HEADER: u8 = 0x1a;
+/// The color command marks the beginning of a hex-encoded color **string**.
+///
+/// The hastag character should **not** be included.
+pub const COLOR: u8 = 0x1b;
+/// The size command marks the beginning of a integer brush size.
+pub const SIZE: u8 = 0x2b;
+/// Marks the beginning of a new line.
+pub const LINE: u8 = 0x3b;
+/// A point marks the coordinates (relative to the previous `DELTA_ORIGIN`, or `(0, 0)`)
+/// in which a point should be drawn.
+///
+/// The size and color are that of the previous `COLOR` and `SIZE` commands.
+///
+/// Points are two `u32`s (or 8 bytes in length).
+pub const POINT: u8 = 0x4b;
+/// An end-of-file marker.
+pub const EOF: u8 = 0x1f;
+
+/// A type of [`Command`].
+#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
+#[repr(u8)]
+pub enum CommandType {
+    /// [`END_OF_HEADER`]
+    EndOfHeader = END_OF_HEADER,
+    /// [`COLOR`]
+    Color = COLOR,
+    /// [`SIZE`]
+    Size = SIZE,
+    /// [`LINE`]
+    Line = LINE,
+    /// [`POINT`]
+    Point = POINT,
+    /// [`EOF`]
+    Eof = EOF,
+}
+
+#[derive(Serialize, Deserialize, Debug, Clone)]
+pub struct Command {
+    /// The type of the command.
+    pub r#type: CommandType,
+    /// Raw data as bytes.
+    pub data: Vec<u8>,
+}
+
+impl From<Command> for Vec<u8> {
+    fn from(val: Command) -> Self {
+        let mut d = val.data;
+        d.insert(0, val.r#type as u8);
+        d
+    }
+}
+
+/// A graph is CarpGraph's representation of an image. It's essentially just a
+/// reproducable series of commands which a renderer can traverse to reconstruct
+/// an image.
+#[derive(Serialize, Deserialize, Debug)]
+pub struct CarpGraph {
+    pub header: Vec<u8>,
+    pub dimensions: (u32, u32),
+    pub commands: Vec<Command>,
+}
+
+macro_rules! select_bytes {
+    ($count:literal, $from:ident) => {{
+        let mut data: Vec<u8> = Vec::new();
+        let mut seen_bytes = 0;
+
+        while let Some((_, byte)) = $from.next() {
+            seen_bytes += 1;
+            data.push(byte.to_owned());
+
+            if seen_bytes == $count {
+                // we only need <count> bytes, stop just before we eat the next byte
+                break;
+            }
+        }
+
+        data
+    }};
+}
+
+macro_rules! spread {
+    ($into:ident, $from:expr) => {
+        for byte in &$from {
+            $into.push(byte.to_owned())
+        }
+    };
+}
+
+impl CarpGraph {
+    pub fn to_bytes(&self) -> Vec<u8> {
+        let mut out: Vec<u8> = Vec::new();
+
+        // reconstruct header
+        spread!(out, self.header);
+        spread!(out, self.dimensions.0.to_be_bytes()); // width
+        spread!(out, self.dimensions.1.to_be_bytes()); // height
+        out.push(END_OF_HEADER);
+
+        // reconstruct commands
+        for command in &self.commands {
+            out.push(command.r#type as u8);
+            spread!(out, command.data);
+        }
+
+        // ...
+        out.push(EOF);
+        out
+    }
+
+    pub fn from_bytes(bytes: Vec<u8>) -> Self {
+        let mut header: Vec<u8> = Vec::new();
+        let mut dimensions: (u32, u32) = (0, 0);
+        let mut commands: Vec<Command> = Vec::new();
+
+        let mut in_header: bool = true;
+        let mut byte_buffer: Vec<u8> = Vec::new(); // storage for bytes which need to construct a bigger type (like `u32`)
+
+        let mut bytes_iter = bytes.iter().enumerate();
+        while let Some((i, byte)) = bytes_iter.next() {
+            let byte = byte.to_owned();
+            match byte {
+                END_OF_HEADER => in_header = false,
+                COLOR => {
+                    let data = select_bytes!(6, bytes_iter);
+                    commands.push(Command {
+                        r#type: CommandType::Color,
+                        data,
+                    });
+                }
+                SIZE => {
+                    let data = select_bytes!(2, bytes_iter);
+                    commands.push(Command {
+                        r#type: CommandType::Size,
+                        data,
+                    });
+                }
+                POINT => {
+                    let data = select_bytes!(8, bytes_iter);
+                    commands.push(Command {
+                        r#type: CommandType::Point,
+                        data,
+                    });
+                }
+                LINE => commands.push(Command {
+                    r#type: CommandType::Line,
+                    data: Vec::new(),
+                }),
+                EOF => break,
+                _ => {
+                    if in_header {
+                        if (0..2).contains(&i) {
+                            // tag
+                            header.push(byte);
+                        } else if (2..4).contains(&i) {
+                            // version
+                            header.push(byte);
+                        } else if (4..8).contains(&i) {
+                            // width
+                            byte_buffer.push(byte);
+
+                            if i == 7 {
+                                // end, construct from byte buffer
+                                let (bytes, _) = byte_buffer.split_at(size_of::<u32>());
+                                dimensions.0 = u32::from_be_bytes(bytes.try_into().unwrap());
+                                byte_buffer = Vec::new();
+                            }
+                        } else if (8..12).contains(&i) {
+                            // height
+                            byte_buffer.push(byte);
+
+                            if i == 11 {
+                                // end, construct from byte buffer
+                                let (bytes, _) = byte_buffer.split_at(size_of::<u32>());
+                                dimensions.1 = u32::from_be_bytes(bytes.try_into().unwrap());
+                                byte_buffer = Vec::new();
+                            }
+                        }
+                    } else {
+                        // misc byte
+                        println!("extraneous byte at {i}");
+                    }
+                }
+            }
+        }
+
+        Self {
+            header,
+            dimensions,
+            commands,
+        }
+    }
+
+    pub fn to_svg(&self) -> String {
+        let mut out: String = String::new();
+        out.push_str(&format!(
+            "<svg viewBox=\"0 0 {} {}\" xmlns=\"http://www.w3.org/2000/svg\" width=\"{}\" height=\"{}\" style=\"background: white; width: {}px; height: {}px\" class=\"carpgraph\">",
+            self.dimensions.0, self.dimensions.1, self.dimensions.0, self.dimensions.1, self.dimensions.0, self.dimensions.1
+        ));
+
+        // add lines
+        let mut stroke_size: u16 = 2;
+        let mut stroke_color: String = "000000".to_string();
+
+        let mut previous_x_y: Option<(u32, u32)> = None;
+        let mut line_path = String::new();
+
+        for command in &self.commands {
+            match command.r#type {
+                CommandType::Size => {
+                    let (bytes, _) = command.data.split_at(size_of::<u16>());
+                    stroke_size = u16::from_be_bytes(bytes.try_into().unwrap_or([0, 0]));
+                }
+                CommandType::Color => {
+                    stroke_color =
+                        String::from_utf8(command.data.to_owned()).unwrap_or("#000000".to_string())
+                }
+                CommandType::Line => {
+                    if !line_path.is_empty() {
+                        out.push_str(&format!(
+                            "<path d=\"{line_path}\" stroke=\"#{stroke_color}\" stroke-width=\"{stroke_size}\" />"
+                        ));
+                    }
+
+                    previous_x_y = None;
+                    line_path = String::new();
+                }
+                CommandType::Point => {
+                    let (x, y) = command.data.split_at(size_of::<u32>());
+                    let point = ({ u32::from_be_bytes(x.try_into().unwrap()) }, {
+                        u32::from_be_bytes(y.try_into().unwrap())
+                    });
+
+                    // add to path string
+                    line_path.push_str(&format!(
+                        " M{} {}{}",
+                        point.0,
+                        point.1,
+                        if let Some(pxy) = previous_x_y {
+                            // line to there
+                            format!(" L{} {}", pxy.0, pxy.1)
+                        } else {
+                            String::new()
+                        }
+                    ));
+
+                    previous_x_y = Some((point.0, point.1));
+
+                    // add circular point
+                    out.push_str(&format!(
+                        "<circle cx=\"{}\" cy=\"{}\" r=\"{}\" fill=\"#{stroke_color}\" />",
+                        point.0,
+                        point.1,
+                        stroke_size / 2 // the size is technically the diameter of the circle
+                    ));
+                }
+                _ => unreachable!("never pushed to commands"),
+            }
+        }
+
+        if !line_path.is_empty() {
+            out.push_str(&format!(
+                "<path d=\"{line_path}\" stroke=\"#{stroke_color}\" stroke-width=\"{stroke_size}\" />"
+            ));
+        }
+
+        // return
+        format!("{out}</svg>")
+    }
+}
diff --git a/crates/core/src/model/communities.rs b/crates/core/src/model/communities.rs
index 41508ff..7b1957f 100644
--- a/crates/core/src/model/communities.rs
+++ b/crates/core/src/model/communities.rs
@@ -345,6 +345,9 @@ pub struct Question {
     /// The IP of the question creator for IP blocking and identifying anonymous users.
     #[serde(default)]
     pub ip: String,
+    /// The IDs of all uploads which hold this question's drawings.
+    #[serde(default)]
+    pub drawings: Vec<usize>,
 }
 
 impl Question {
@@ -369,6 +372,7 @@ impl Question {
             dislikes: 0,
             context: QuestionContext::default(),
             ip,
+            drawings: Vec::new(),
         }
     }
 }
diff --git a/crates/core/src/model/mod.rs b/crates/core/src/model/mod.rs
index 62f26a3..ed2da9e 100644
--- a/crates/core/src/model/mod.rs
+++ b/crates/core/src/model/mod.rs
@@ -1,6 +1,7 @@
 pub mod addr;
 pub mod apps;
 pub mod auth;
+pub mod carp;
 pub mod channels;
 pub mod communities;
 pub mod communities_permissions;
@@ -46,6 +47,7 @@ pub enum Error {
     TitleInUse,
     QuestionsDisabled,
     RequiresSupporter,
+    DrawingsDisabled,
     Unknown,
 }
 
@@ -68,6 +70,7 @@ impl Display for Error {
             Self::TitleInUse => "Title in use".to_string(),
             Self::QuestionsDisabled => "You are not allowed to ask questions there".to_string(),
             Self::RequiresSupporter => "Only site supporters can do this".to_string(),
+            Self::DrawingsDisabled => "You are not allowed to submit drawings there".to_string(),
             _ => format!("An unknown error as occurred: ({:?})", self),
         })
     }
diff --git a/crates/core/src/model/uploads.rs b/crates/core/src/model/uploads.rs
index d502697..35165c6 100644
--- a/crates/core/src/model/uploads.rs
+++ b/crates/core/src/model/uploads.rs
@@ -5,7 +5,7 @@ use crate::config::Config;
 use std::fs::{write, exists, remove_file};
 use super::{Error, Result};
 
-#[derive(Serialize, Deserialize)]
+#[derive(Serialize, Deserialize, PartialEq, Eq)]
 pub enum MediaType {
     #[serde(alias = "image/webp")]
     Webp,
@@ -17,6 +17,8 @@ pub enum MediaType {
     Jpg,
     #[serde(alias = "image/gif")]
     Gif,
+    #[serde(alias = "image/carpgraph")]
+    Carpgraph,
 }
 
 impl MediaType {
@@ -27,6 +29,7 @@ impl MediaType {
             Self::Png => "png",
             Self::Jpg => "jpg",
             Self::Gif => "gif",
+            Self::Carpgraph => "carpgraph",
         }
     }
 
diff --git a/sql_changes/questions_drawings.sql b/sql_changes/questions_drawings.sql
new file mode 100644
index 0000000..f45e50b
--- /dev/null
+++ b/sql_changes/questions_drawings.sql
@@ -0,0 +1,2 @@
+ALTER TABLE questions
+ADD COLUMN drawings TEXT NOT NULL DEFAULT '[]';

From a4298f95f6c35e073a21b294ded5740a7f64257c Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Fri, 20 Jun 2025 19:27:12 -0400
Subject: [PATCH 31/71] fix: don't allow empty drawings to be uploaded

---
 crates/core/src/database/questions.rs | 3 +++
 crates/core/src/model/mod.rs          | 2 ++
 2 files changed, 5 insertions(+)

diff --git a/crates/core/src/database/questions.rs b/crates/core/src/database/questions.rs
index 0a6965c..8cfd09c 100644
--- a/crates/core/src/database/questions.rs
+++ b/crates/core/src/database/questions.rs
@@ -415,6 +415,9 @@ impl DataManager {
             // this is the initial iter to check sizes, we'll do uploads after
             if drawing.len() > Self::MAXIMUM_DRAWING_SIZE {
                 return Err(Error::FileTooLarge);
+            } else if drawing.len() < 25 {
+                // if we have less than 25 bytes in a drawing, the drawing is likely blank
+                return Err(Error::FileTooSmall);
             }
         }
 
diff --git a/crates/core/src/model/mod.rs b/crates/core/src/model/mod.rs
index ed2da9e..839310f 100644
--- a/crates/core/src/model/mod.rs
+++ b/crates/core/src/model/mod.rs
@@ -43,6 +43,7 @@ pub enum Error {
     DataTooLong(String),
     DataTooShort(String),
     FileTooLarge,
+    FileTooSmall,
     UsernameInUse,
     TitleInUse,
     QuestionsDisabled,
@@ -66,6 +67,7 @@ impl Display for Error {
             Self::DataTooLong(name) => format!("Given {name} is too long!"),
             Self::DataTooShort(name) => format!("Given {name} is too short!"),
             Self::FileTooLarge => "Given file is too large".to_string(),
+            Self::FileTooSmall => "Given file is too small".to_string(),
             Self::UsernameInUse => "Username in use".to_string(),
             Self::TitleInUse => "Title in use".to_string(),
             Self::QuestionsDisabled => "You are not allowed to ask questions there".to_string(),

From a37312fecfad0625fd8c7e72e28f20a1b6cd174d Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Sat, 21 Jun 2025 03:11:29 -0400
Subject: [PATCH 32/71] add: chat message reactions

---
 crates/app/src/main.rs                        |   5 +
 crates/app/src/public/html/chats/app.lisp     |  18 ++
 crates/app/src/public/html/components.lisp    |  75 +++++--
 crates/app/src/public/js/atto.js              |  30 +++
 crates/app/src/public/js/me.js                |  41 ++++
 .../api/v1/channels/message_reactions.rs      | 103 ++++++++++
 crates/app/src/routes/api/v1/channels/mod.rs  |   1 +
 .../src/routes/api/v1/communities/emojis.rs   |  14 +-
 crates/app/src/routes/api/v1/mod.rs           |  19 ++
 crates/core/src/database/auth.rs              |  40 ++++
 crates/core/src/database/common.rs            |   1 +
 crates/core/src/database/drivers/common.rs    |   1 +
 .../drivers/sql/create_message_reactions.sql  |   8 +
 .../database/drivers/sql/create_messages.sql  |   3 +-
 .../database/drivers/sql/create_questions.sql |   3 +-
 crates/core/src/database/message_reactions.rs | 183 ++++++++++++++++++
 crates/core/src/database/messages.rs          |   8 +-
 crates/core/src/database/mod.rs               |   1 +
 crates/core/src/model/channels.rs             |  26 +++
 sql_changes/messages_reactions.sql            |   2 +
 20 files changed, 557 insertions(+), 25 deletions(-)
 create mode 100644 crates/app/src/routes/api/v1/channels/message_reactions.rs
 create mode 100644 crates/core/src/database/drivers/sql/create_message_reactions.sql
 create mode 100644 crates/core/src/database/message_reactions.rs
 create mode 100644 sql_changes/messages_reactions.sql

diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs
index 152cde1..1eafa54 100644
--- a/crates/app/src/main.rs
+++ b/crates/app/src/main.rs
@@ -38,6 +38,10 @@ fn render_markdown(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Va
     )
 }
 
+fn render_emojis(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
+    Ok(CustomEmoji::replace(value.as_str().unwrap()).into())
+}
+
 fn color_escape(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
     Ok(sanitize::color_escape(value.as_str().unwrap()).into())
 }
@@ -102,6 +106,7 @@ async fn main() {
     tera.register_filter("has_staff_badge", check_staff_badge);
     tera.register_filter("has_banned", check_banned);
     tera.register_filter("remove_script_tags", remove_script_tags);
+    tera.register_filter("emojis", render_emojis);
 
     let client = Client::new();
 
diff --git a/crates/app/src/public/html/chats/app.lisp b/crates/app/src/public/html/chats/app.lisp
index a24ca27..0dc16c3 100644
--- a/crates/app/src/public/html/chats/app.lisp
+++ b/crates/app/src/public/html/chats/app.lisp
@@ -109,6 +109,23 @@
                 ("title" "Send")
                 (text "{{ icon \"send-horizontal\" }}"))))
     (text "{%- endif %}")
+
+    ; emoji picker
+    (text "{{ components::emoji_picker(element_id=\"\", render_dialog=true, render_button=false) }}")
+    (input ("id" "react_emoji_picker_field") ("class" "hidden") ("type" "hidden"))
+
+    (script
+        (text "window.EMOJI_PICKER_MODE = \"replace\";
+        document.getElementById(\"react_emoji_picker_field\").addEventListener(\"change\", (e) => {
+            if (!EMOJI_PICKER_REACTION_MESSAGE_ID) {
+                return;
+            }
+
+            const emoji = e.target.value === \"::\" ? \":heart:\" : e.target.value;
+            trigger(\"me::message_react\", [document.getElementById(`message-${EMOJI_PICKER_REACTION_MESSAGE_ID}`), EMOJI_PICKER_REACTION_MESSAGE_ID, emoji]);
+        });"))
+
+    ; ...
     (script
         (text "window.CURRENT_PAGE = Number.parseInt(\"{{ page }}\");
         window.VIEWING_SINGLE = \"{{ message }}\".length > 0;
@@ -434,6 +451,7 @@
                 const clean_text = () => {
                     trigger(\"atto::clean_date_codes\");
                     trigger(\"atto::hooks::online_indicator\");
+                    trigger(\"atto::hooks::check_message_reactions\");
                 };
 
                 document.addEventListener(
diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp
index a9f6142..251359d 100644
--- a/crates/app/src/public/html/components.lisp
+++ b/crates/app/src/public/html/components.lisp
@@ -1026,9 +1026,29 @@
         (text "{%- endif %}")
         (div
             ("class" "flex w-full gap-2 justify-between")
-            (span
-                ("class" "no_p_margin")
-                (text "{{ message.content|markdown|safe }}"))
+            (div
+                ("class" "flex flex-col gap-2")
+                (span
+                    ("class" "no_p_margin")
+                    (text "{{ message.content|markdown|safe }}"))
+
+                (div
+                    ("class" "flex w-full gap-1 flex-wrap")
+                    ("onclick" "window.EMOJI_PICKER_REACTION_MESSAGE_ID = '{{ message.id }}'")
+                    ("hook" "check_message_reactions")
+                    ("hook-arg:id" "{{ message.id }}")
+
+                    (text "{% for emoji,num in message.reactions -%}")
+                    (button
+                        ("class" "small lowered")
+                        ("ui_ident" "emoji_{{ emoji }}")
+                        ("onclick" "trigger('me::message_react', [event.target.parentElement.parentElement, '{{ message.id }}', '{{ emoji }}'])")
+                        (span (text "{{ emoji|emojis|safe }} {{ num }}")))
+                    (text "{%- endfor %}")
+
+                    (div
+                        ("class" "hidden")
+                        (text "{{ self::emoji_picker(element_id=\"react_emoji_picker_field\", render_dialog=false, render_button=true, small=true) }}"))))
             (text "{% if grouped -%}")
             (div
                 ("class" "hidden")
@@ -1185,13 +1205,15 @@
                     (text "{{ text \"chats:action.kick_member\" }}")))))
     (text "{%- endif %}"))
 
-(text "{%- endmacro %} {% macro emoji_picker(element_id, render_dialog=false) -%}")
+(text "{%- endmacro %} {% macro emoji_picker(element_id, render_dialog=false, render_button=true, small=false) -%}")
+(text "{% if render_button -%}")
 (button
-    ("class" "button small square lowered")
+    ("class" "button small {% if not small -%} square {%- endif %} lowered")
     ("onclick" "window.EMOJI_PICKER_TEXT_ID = '{{ element_id }}'; document.getElementById('emoji_dialog').showModal()")
     ("title" "Emojis")
     ("type" "button")
     (text "{{ icon \"smile-plus\" }}"))
+(text "{%- endif %}")
 
 (text "{% if render_dialog -%}")
 (dialog
@@ -1237,20 +1259,41 @@
                     }
 
                     if (event.detail.unicode) {
-                        document.getElementById(
-                            window.EMOJI_PICKER_TEXT_ID,
-                        ).value += ` :${await (
-                            await fetch(\"/api/v1/lookup_emoji\", {
-                                method: \"POST\",
-                                body: event.detail.unicode,
-                            })
-                        ).text()}:`;
+                        if (window.EMOJI_PICKER_MODE === \"replace\") {
+                            document.getElementById(
+                                window.EMOJI_PICKER_TEXT_ID,
+                            ).value = `:${await (
+                                await fetch(\"/api/v1/lookup_emoji\", {
+                                    method: \"POST\",
+                                    body: event.detail.unicode,
+                                })
+                            ).text()}:`;
+                        } else {
+                            document.getElementById(
+                                window.EMOJI_PICKER_TEXT_ID,
+                            ).value += ` :${await (
+                                await fetch(\"/api/v1/lookup_emoji\", {
+                                    method: \"POST\",
+                                    body: event.detail.unicode,
+                                })
+                            ).text()}:`;
+                        }
                     } else {
-                        document.getElementById(
-                            window.EMOJI_PICKER_TEXT_ID,
-                        ).value += ` :${event.detail.emoji.shortcodes[0]}:`;
+                        if (window.EMOJI_PICKER_MODE === \"replace\") {
+                            document.getElementById(
+                                window.EMOJI_PICKER_TEXT_ID,
+                            ).value = `:${event.detail.emoji.shortcodes[0]}:`;
+                        } else {
+                            document.getElementById(
+                                window.EMOJI_PICKER_TEXT_ID,
+                            ).value += ` :${event.detail.emoji.shortcodes[0]}:`;
+                        }
                     }
 
+                    document.getElementById(
+                        window.EMOJI_PICKER_TEXT_ID,
+                    ).dispatchEvent(new Event(\"change\"));
+
                     document.getElementById(\"emoji_dialog\").close();
                 });"))
         (div
diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js
index a417a46..9c9d71d 100644
--- a/crates/app/src/public/js/atto.js
+++ b/crates/app/src/public/js/atto.js
@@ -688,6 +688,36 @@ media_theme_pref();
         $.OBSERVERS.push(observer);
     });
 
+    self.define("hooks::check_message_reactions", async ({ $ }) => {
+        const observer = $.offload_work_to_client_when_in_view(
+            async (element) => {
+                const reactions = await (
+                    await fetch(
+                        `/api/v1/message_reactions/${element.getAttribute("hook-arg:id")}`,
+                    )
+                ).json();
+
+                if (reactions.ok) {
+                    for (const reaction of reactions.payload) {
+                        element
+                            .querySelector(
+                                `[ui_ident=emoji_${reaction.emoji.replaceAll(":", "\\:")}]`,
+                            )
+                            .classList.remove("lowered");
+                    }
+                }
+            },
+        );
+
+        for (const element of Array.from(
+            document.querySelectorAll("[hook=check_message_reactions]") || [],
+        )) {
+            observer.observe(element);
+        }
+
+        $.OBSERVERS.push(observer);
+    });
+
     self.define("hooks::tabs:switch", (_, tab) => {
         tab = tab.split("?")[0];
 
diff --git a/crates/app/src/public/js/me.js b/crates/app/src/public/js/me.js
index e8f4ae2..96ac95f 100644
--- a/crates/app/src/public/js/me.js
+++ b/crates/app/src/public/js/me.js
@@ -204,6 +204,47 @@
             });
     });
 
+    self.define("message_react", async (_, element, message, emoji) => {
+        await trigger("atto::debounce", ["reactions::toggle"]);
+        fetch("/api/v1/message_reactions", {
+            method: "POST",
+            headers: {
+                "Content-Type": "application/json",
+            },
+            body: JSON.stringify({
+                message,
+                emoji,
+            }),
+        })
+            .then((res) => res.json())
+            .then((res) => {
+                trigger("atto::toast", [
+                    res.ok ? "success" : "error",
+                    res.message,
+                ]);
+
+                if (res.ok) {
+                    if (res.message.includes("created")) {
+                        const x = element.querySelector(
+                            `[ui_ident=emoji_${emoji.replaceAll(":", "\\:")}]`,
+                        );
+
+                        if (x) {
+                            x.classList.remove("lowered");
+                        }
+                    } else {
+                        const x = element.querySelector(
+                            `[ui_ident=emoji_${emoji.replaceAll(":", "\\:")}]`,
+                        );
+
+                        if (x) {
+                            x.classList.add("lowered");
+                        }
+                    }
+                }
+            });
+    });
+
     self.define("remove_notification", (_, id) => {
         fetch(`/api/v1/notifications/${id}`, {
             method: "DELETE",
diff --git a/crates/app/src/routes/api/v1/channels/message_reactions.rs b/crates/app/src/routes/api/v1/channels/message_reactions.rs
new file mode 100644
index 0000000..b9ccb53
--- /dev/null
+++ b/crates/app/src/routes/api/v1/channels/message_reactions.rs
@@ -0,0 +1,103 @@
+use crate::{get_user_from_token, routes::api::v1::CreateMessageReaction, State};
+use axum::{Extension, Json, extract::Path, response::IntoResponse};
+use axum_extra::extract::CookieJar;
+use tetratto_core::model::{channels::MessageReaction, oauth, ApiReturn, Error};
+
+pub async fn get_request(
+    jar: CookieJar,
+    Extension(data): Extension<State>,
+    Path(id): Path<usize>,
+) -> impl IntoResponse {
+    let data = &(data.read().await).0;
+    let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReact) {
+        Some(ua) => ua,
+        None => return Json(Error::NotAllowed.into()),
+    };
+
+    match data
+        .get_message_reactions_by_owner_message(user.id, id)
+        .await
+    {
+        Ok(r) => Json(ApiReturn {
+            ok: true,
+            message: "Reactions exists".to_string(),
+            payload: Some(r),
+        }),
+        Err(e) => Json(e.into()),
+    }
+}
+
+pub async fn create_request(
+    jar: CookieJar,
+    Extension(data): Extension<State>,
+    Json(req): Json<CreateMessageReaction>,
+) -> impl IntoResponse {
+    let data = &(data.read().await).0;
+    let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReact) {
+        Some(ua) => ua,
+        None => return Json(Error::NotAllowed.into()),
+    };
+
+    let message_id = match req.message.parse::<usize>() {
+        Ok(n) => n,
+        Err(e) => return Json(Error::MiscError(e.to_string()).into()),
+    };
+
+    // check for existing reaction
+    if let Ok(r) = data
+        .get_message_reaction_by_owner_message_emoji(user.id, message_id, &req.emoji)
+        .await
+    {
+        if let Err(e) = data.delete_message_reaction(r.id, &user).await {
+            return Json(e.into());
+        } else {
+            return Json(ApiReturn {
+                ok: true,
+                message: "Reaction removed".to_string(),
+                payload: (),
+            });
+        }
+    }
+
+    // create reaction
+    match data
+        .create_message_reaction(MessageReaction::new(user.id, message_id, req.emoji), &user)
+        .await
+    {
+        Ok(_) => Json(ApiReturn {
+            ok: true,
+            message: "Reaction created".to_string(),
+            payload: (),
+        }),
+        Err(e) => Json(e.into()),
+    }
+}
+
+pub async fn delete_request(
+    jar: CookieJar,
+    Extension(data): Extension<State>,
+    Path((id, emoji)): Path<(usize, String)>,
+) -> impl IntoResponse {
+    let data = &(data.read().await).0;
+    let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReact) {
+        Some(ua) => ua,
+        None => return Json(Error::NotAllowed.into()),
+    };
+
+    let reaction = match data
+        .get_message_reaction_by_owner_message_emoji(user.id, id, &emoji)
+        .await
+    {
+        Ok(r) => r,
+        Err(e) => return Json(e.into()),
+    };
+
+    match data.delete_message_reaction(reaction.id, &user).await {
+        Ok(_) => Json(ApiReturn {
+            ok: true,
+            message: "Reaction deleted".to_string(),
+            payload: (),
+        }),
+        Err(e) => Json(e.into()),
+    }
+}
diff --git a/crates/app/src/routes/api/v1/channels/mod.rs b/crates/app/src/routes/api/v1/channels/mod.rs
index 345897d..33792c3 100644
--- a/crates/app/src/routes/api/v1/channels/mod.rs
+++ b/crates/app/src/routes/api/v1/channels/mod.rs
@@ -1,2 +1,3 @@
 pub mod channels;
+pub mod message_reactions;
 pub mod messages;
diff --git a/crates/app/src/routes/api/v1/communities/emojis.rs b/crates/app/src/routes/api/v1/communities/emojis.rs
index 6f0d037..1db4c0c 100644
--- a/crates/app/src/routes/api/v1/communities/emojis.rs
+++ b/crates/app/src/routes/api/v1/communities/emojis.rs
@@ -16,12 +16,16 @@ use tetratto_core::model::{
 
 /// Expand a unicode emoji into its Gemoji shortcode.
 pub async fn get_emoji_shortcode(emoji: String) -> impl IntoResponse {
-    match emojis::get(&emoji) {
-        Some(e) => match e.shortcode() {
-            Some(s) => s.to_string(),
-            None => e.name().replace(" ", "-"),
+    match emoji.as_str() {
+        "👍" => "thumbs_up".to_string(),
+        "👎" => "thumbs_down".to_string(),
+        _ => match emojis::get(&emoji) {
+            Some(e) => match e.shortcode() {
+                Some(s) => s.to_string(),
+                None => e.name().replace(" ", "-"),
+            },
+            None => String::new(),
         },
-        None => String::new(),
     }
 }
 
diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs
index 9217437..d529c60 100644
--- a/crates/app/src/routes/api/v1/mod.rs
+++ b/crates/app/src/routes/api/v1/mod.rs
@@ -41,6 +41,19 @@ pub fn routes() -> Router {
         .route("/reactions", post(reactions::create_request))
         .route("/reactions/{id}", get(reactions::get_request))
         .route("/reactions/{id}", delete(reactions::delete_request))
+        // message reactions
+        .route(
+            "/message_reactions",
+            post(channels::message_reactions::create_request),
+        )
+        .route(
+            "/message_reactions/{id}",
+            get(channels::message_reactions::get_request),
+        )
+        .route(
+            "/message_reactions/{id}/{emoji}",
+            delete(channels::message_reactions::delete_request),
+        )
         // communities
         .route(
             "/communities/find/{id}",
@@ -907,3 +920,9 @@ pub struct UpdateNoteContent {
 pub struct RenderMarkdown {
     pub content: String,
 }
+
+#[derive(Deserialize)]
+pub struct CreateMessageReaction {
+    pub message: String,
+    pub emoji: String,
+}
diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs
index 1c1bda2..3c22a3e 100644
--- a/crates/core/src/database/auth.rs
+++ b/crates/core/src/database/auth.rs
@@ -389,6 +389,46 @@ impl DataManager {
             return Err(Error::DatabaseError(e.to_string()));
         }
 
+        // delete stackblocks
+        let res = execute!(
+            &conn,
+            "DELETE FROM stackblocks WHERE owner = $1",
+            &[&(id as i64)]
+        );
+
+        if let Err(e) = res {
+            return Err(Error::DatabaseError(e.to_string()));
+        }
+
+        // delete journals
+        let res = execute!(
+            &conn,
+            "DELETE FROM journals WHERE owner = $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 owner = $1", &[&(id as i64)]);
+
+        if let Err(e) = res {
+            return Err(Error::DatabaseError(e.to_string()));
+        }
+
+        // delete message reactions
+        let res = execute!(
+            &conn,
+            "DELETE FROM message_reactions WHERE owner = $1",
+            &[&(id as i64)]
+        );
+
+        if let Err(e) = res {
+            return Err(Error::DatabaseError(e.to_string()));
+        }
+
         // delete user follows... individually since it requires updating user counts
         for follow in self.get_userfollows_by_receiver_all(id).await? {
             self.delete_userfollow(follow.id, &user, true).await?;
diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs
index 36bbdb7..0841e15 100644
--- a/crates/core/src/database/common.rs
+++ b/crates/core/src/database/common.rs
@@ -38,6 +38,7 @@ impl DataManager {
         execute!(&conn, common::CREATE_TABLE_STACKBLOCKS).unwrap();
         execute!(&conn, common::CREATE_TABLE_JOURNALS).unwrap();
         execute!(&conn, common::CREATE_TABLE_NOTES).unwrap();
+        execute!(&conn, common::CREATE_TABLE_MESSAGE_REACTIONS).unwrap();
 
         self.0
             .1
diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs
index 64a9dfc..6b7902e 100644
--- a/crates/core/src/database/drivers/common.rs
+++ b/crates/core/src/database/drivers/common.rs
@@ -25,3 +25,4 @@ 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");
+pub const CREATE_TABLE_MESSAGE_REACTIONS: &str = include_str!("./sql/create_message_reactions.sql");
diff --git a/crates/core/src/database/drivers/sql/create_message_reactions.sql b/crates/core/src/database/drivers/sql/create_message_reactions.sql
new file mode 100644
index 0000000..f13a033
--- /dev/null
+++ b/crates/core/src/database/drivers/sql/create_message_reactions.sql
@@ -0,0 +1,8 @@
+CREATE TABLE IF NOT EXISTS message_reactions (
+    id BIGINT NOT NULL PRIMARY KEY,
+    created BIGINT NOT NULL,
+    owner BIGINT NOT NULL,
+    message BIGINT NOT NULL,
+    emoji TEXT NOT NULL,
+    UNIQUE (owner, message, emoji)
+)
diff --git a/crates/core/src/database/drivers/sql/create_messages.sql b/crates/core/src/database/drivers/sql/create_messages.sql
index 24096b6..235d8dc 100644
--- a/crates/core/src/database/drivers/sql/create_messages.sql
+++ b/crates/core/src/database/drivers/sql/create_messages.sql
@@ -5,5 +5,6 @@ CREATE TABLE IF NOT EXISTS messages (
     created BIGINT NOT NULL,
     edited BIGINT NOT NULL,
     content TEXT NOT NULL,
-    context TEXT NOT NULL
+    context TEXT NOT NULL,
+    reactions TEXT NOT NULL
 )
diff --git a/crates/core/src/database/drivers/sql/create_questions.sql b/crates/core/src/database/drivers/sql/create_questions.sql
index ab23661..d314664 100644
--- a/crates/core/src/database/drivers/sql/create_questions.sql
+++ b/crates/core/src/database/drivers/sql/create_questions.sql
@@ -12,5 +12,6 @@ CREATE TABLE IF NOT EXISTS questions (
     dislikes INT NOT NULL,
     -- ...
     context TEXT NOT NULL,
-    ip TEXT NOT NULL
+    ip TEXT NOT NULL,
+    drawings TEXT NOT NULL
 )
diff --git a/crates/core/src/database/message_reactions.rs b/crates/core/src/database/message_reactions.rs
new file mode 100644
index 0000000..4134049
--- /dev/null
+++ b/crates/core/src/database/message_reactions.rs
@@ -0,0 +1,183 @@
+use oiseau::{cache::Cache, query_rows};
+use crate::model::{
+    Error, Result,
+    auth::{Notification, User},
+    permissions::FinePermission,
+    channels::MessageReaction,
+};
+use crate::{auto_method, DataManager};
+
+use oiseau::{PostgresRow, execute, get, query_row, params};
+
+impl DataManager {
+    /// Get a [`MessageReaction`] from an SQL row.
+    pub(crate) fn get_message_reaction_from_row(x: &PostgresRow) -> MessageReaction {
+        MessageReaction {
+            id: get!(x->0(i64)) as usize,
+            created: get!(x->1(i64)) as usize,
+            owner: get!(x->2(i64)) as usize,
+            message: get!(x->3(i64)) as usize,
+            emoji: get!(x->4(String)),
+        }
+    }
+
+    auto_method!(get_message_reaction_by_id()@get_message_reaction_from_row -> "SELECT * FROM message_reactions WHERE id = $1" --name="message_reaction" --returns=MessageReaction --cache-key-tmpl="atto.message_reaction:{}");
+
+    /// Get message_reactions by `owner` and `message`.
+    pub async fn get_message_reactions_by_owner_message(
+        &self,
+        owner: usize,
+        message: usize,
+    ) -> Result<Vec<MessageReaction>> {
+        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 message_reactions WHERE owner = $1 AND message = $2",
+            &[&(owner as i64), &(message as i64)],
+            |x| { Self::get_message_reaction_from_row(x) }
+        );
+
+        if res.is_err() {
+            return Err(Error::GeneralNotFound("message_reaction".to_string()));
+        }
+
+        Ok(res.unwrap())
+    }
+
+    /// Get a message_reaction by `owner`, `message`, and `emoji`.
+    pub async fn get_message_reaction_by_owner_message_emoji(
+        &self,
+        owner: usize,
+        message: usize,
+        emoji: &str,
+    ) -> Result<MessageReaction> {
+        let conn = match self.0.connect().await {
+            Ok(c) => c,
+            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
+        };
+
+        let res = query_row!(
+            &conn,
+            "SELECT * FROM message_reactions WHERE owner = $1 AND message = $2 AND emoji = $3",
+            params![&(owner as i64), &(message as i64), &emoji],
+            |x| { Ok(Self::get_message_reaction_from_row(x)) }
+        );
+
+        if res.is_err() {
+            return Err(Error::GeneralNotFound("message_reaction".to_string()));
+        }
+
+        Ok(res.unwrap())
+    }
+
+    /// Create a new message_reaction in the database.
+    ///
+    /// # Arguments
+    /// * `data` - a mock [`MessageReaction`] object to insert
+    pub async fn create_message_reaction(&self, data: MessageReaction, user: &User) -> Result<()> {
+        let conn = match self.0.connect().await {
+            Ok(c) => c,
+            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
+        };
+
+        let mut message = self.get_message_by_id(data.message).await?;
+        let channel = self.get_channel_by_id(message.channel).await?;
+
+        // ...
+        let res = execute!(
+            &conn,
+            "INSERT INTO message_reactions VALUES ($1, $2, $3, $4, $5)",
+            params![
+                &(data.id as i64),
+                &(data.created as i64),
+                &(data.owner as i64),
+                &(data.message as i64),
+                &data.emoji
+            ]
+        );
+
+        if let Err(e) = res {
+            return Err(Error::DatabaseError(e.to_string()));
+        }
+
+        // incr corresponding
+        if let Some(x) = message.reactions.get(&data.emoji) {
+            message.reactions.insert(data.emoji.clone(), x + 1);
+        } else {
+            message.reactions.insert(data.emoji.clone(), 1);
+        }
+
+        self.update_message_reactions(message.id, message.reactions)
+            .await?;
+
+        // send notif
+        if message.owner != user.id {
+            self
+                .create_notification(Notification::new(
+                    "Your message has received a reaction!".to_string(),
+                    format!(
+                        "[@{}](/api/v1/auth/user/find/{}) has reacted \"{}\" to your [message](/chats/{}/{}?message={})!",
+                        user.username, user.id, data.emoji, channel.community, channel.id, message.id
+                    ),
+                    message.owner,
+                ))
+                .await?;
+        }
+
+        // return
+        Ok(())
+    }
+
+    pub async fn delete_message_reaction(&self, id: usize, user: &User) -> Result<()> {
+        let message_reaction = self.get_message_reaction_by_id(id).await?;
+
+        if user.id != message_reaction.owner
+            && !user.permissions.check(FinePermission::MANAGE_REACTIONS)
+        {
+            return Err(Error::NotAllowed);
+        }
+
+        let mut message = self.get_message_by_id(message_reaction.message).await?;
+
+        let conn = match self.0.connect().await {
+            Ok(c) => c,
+            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
+        };
+
+        let res = execute!(
+            &conn,
+            "DELETE FROM message_reactions WHERE id = $1",
+            &[&(id as i64)]
+        );
+
+        if let Err(e) = res {
+            return Err(Error::DatabaseError(e.to_string()));
+        }
+
+        self.0
+            .1
+            .remove(format!("atto.message_reaction:{}", id))
+            .await;
+
+        // decr message reaction count
+        if let Some(x) = message.reactions.get(&message_reaction.emoji) {
+            if *x == 1 {
+                // there are no 0 of this reaction
+                message.reactions.remove(&message_reaction.emoji);
+            } else {
+                // decr 1
+                message.reactions.insert(message_reaction.emoji, x - 1);
+            }
+        }
+
+        self.update_message_reactions(message.id, message.reactions)
+            .await?;
+
+        // return
+        Ok(())
+    }
+}
diff --git a/crates/core/src/database/messages.rs b/crates/core/src/database/messages.rs
index 6c60cd7..64157f0 100644
--- a/crates/core/src/database/messages.rs
+++ b/crates/core/src/database/messages.rs
@@ -31,6 +31,7 @@ impl DataManager {
             edited: get!(x->4(i64)) as usize,
             content: get!(x->5(String)),
             context: serde_json::from_str(&get!(x->6(String))).unwrap(),
+            reactions: serde_json::from_str(&get!(x->7(String))).unwrap(),
         }
     }
 
@@ -218,7 +219,7 @@ impl DataManager {
 
         let res = execute!(
             &conn,
-            "INSERT INTO messages VALUES ($1, $2, $3, $4, $5, $6, $7)",
+            "INSERT INTO messages VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
             params![
                 &(data.id as i64),
                 &(data.channel as i64),
@@ -226,7 +227,8 @@ impl DataManager {
                 &(data.created as i64),
                 &(data.edited as i64),
                 &data.content,
-                &serde_json::to_string(&data.context).unwrap()
+                &serde_json::to_string(&data.context).unwrap(),
+                &serde_json::to_string(&data.reactions).unwrap(),
             ]
         );
 
@@ -357,4 +359,6 @@ impl DataManager {
         // return
         Ok(())
     }
+
+    auto_method!(update_message_reactions(HashMap<String, usize>) -> "UPDATE messages SET reactions = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.message:{}");
 }
diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs
index a00fde9..774b345 100644
--- a/crates/core/src/database/mod.rs
+++ b/crates/core/src/database/mod.rs
@@ -12,6 +12,7 @@ mod ipbans;
 mod ipblocks;
 mod journals;
 mod memberships;
+mod message_reactions;
 mod messages;
 mod notes;
 mod notifications;
diff --git a/crates/core/src/model/channels.rs b/crates/core/src/model/channels.rs
index b7023d3..84180c4 100644
--- a/crates/core/src/model/channels.rs
+++ b/crates/core/src/model/channels.rs
@@ -1,3 +1,5 @@
+use std::collections::HashMap;
+
 use serde::{Serialize, Deserialize};
 use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp};
 
@@ -79,6 +81,7 @@ pub struct Message {
     pub edited: usize,
     pub content: String,
     pub context: MessageContext,
+    pub reactions: HashMap<String, usize>,
 }
 
 impl Message {
@@ -93,6 +96,7 @@ impl Message {
             edited: now,
             content,
             context: MessageContext,
+            reactions: HashMap::new(),
         }
     }
 }
@@ -105,3 +109,25 @@ impl Default for MessageContext {
         Self
     }
 }
+
+#[derive(Clone, Serialize, Deserialize)]
+pub struct MessageReaction {
+    pub id: usize,
+    pub created: usize,
+    pub owner: usize,
+    pub message: usize,
+    pub emoji: String,
+}
+
+impl MessageReaction {
+    /// Create a new [`MessageReaction`].
+    pub fn new(owner: usize, message: usize, emoji: String) -> Self {
+        Self {
+            id: Snowflake::new().to_string().parse::<usize>().unwrap(),
+            created: unix_epoch_timestamp(),
+            owner,
+            message,
+            emoji,
+        }
+    }
+}
diff --git a/sql_changes/messages_reactions.sql b/sql_changes/messages_reactions.sql
new file mode 100644
index 0000000..684e890
--- /dev/null
+++ b/sql_changes/messages_reactions.sql
@@ -0,0 +1,2 @@
+ALTER TABLE messages
+ADD COLUMN reactions TEXT NOT NULL DEFAULT '{}';

From af6fbdf04ed6ef5f8b55a5e3793c3e3633f81ab7 Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Sat, 21 Jun 2025 19:44:28 -0400
Subject: [PATCH 33/71] add: journal note tags and directories

---
 crates/app/src/langs/en-US.toml               |   6 +
 crates/app/src/public/css/style.css           |   8 +-
 crates/app/src/public/html/components.lisp    | 197 +++++++++++---
 crates/app/src/public/html/journals/app.lisp  | 243 ++++++++++++++++--
 crates/app/src/routes/api/v1/journals.rs      |  88 ++++++-
 crates/app/src/routes/api/v1/mod.rs           |  29 +++
 crates/app/src/routes/api/v1/notes.rs         |  93 ++++++-
 crates/app/src/routes/pages/journals.rs       |  33 ++-
 crates/app/src/routes/pages/mod.rs            |   2 +
 .../database/drivers/sql/create_journals.sql  |   3 +-
 .../src/database/drivers/sql/create_notes.sql |   4 +-
 crates/core/src/database/journals.rs          |   5 +-
 crates/core/src/database/notes.rs             |  69 ++++-
 crates/core/src/model/journals.rs             |  13 +
 sql_changes/journals_dirs.sql                 |   2 +
 sql_changes/notes_dir_tags.sql                |   5 +
 16 files changed, 722 insertions(+), 78 deletions(-)
 create mode 100644 sql_changes/journals_dirs.sql
 create mode 100644 sql_changes/notes_dir_tags.sql

diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml
index 11491fc..5bd2fc8 100644
--- a/crates/app/src/langs/en-US.toml
+++ b/crates/app/src/langs/en-US.toml
@@ -244,3 +244,9 @@ version = "1.0.0"
 "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"
+"journals:action.edit_tags" = "Edit tags"
+"journals:action.tags" = "Tags"
+"journals:label.directories" = "Directories"
+"journals:action.create_subdir" = "Create subdirectory"
+"journals:action.create_root_dir" = "Create root directory"
+"journals:action.move" = "Move"
diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css
index 0603ee1..5e2094b 100644
--- a/crates/app/src/public/css/style.css
+++ b/crates/app/src/public/css/style.css
@@ -1065,14 +1065,14 @@ details summary::-webkit-details-marker {
     display: none;
 }
 
-details[open] summary {
+details[open] > summary {
     position: relative;
-    color: var(--color-primary);
-    background: var(--color-super-lowered);
+    color: var(--color-text-lowered) !important;
+    background: var(--color-super-lowered) !important;
     margin-bottom: var(--pad-1);
 }
 
-details[open] summary::after {
+details[open] > summary::after {
     top: 0;
     left: 0;
     width: 5px;
diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp
index 251359d..c7c36d3 100644
--- a/crates/app/src/public/html/components.lisp
+++ b/crates/app/src/public/html/components.lisp
@@ -2028,6 +2028,22 @@
                 (str (text "general:action.delete")))))
     (text "{%- endif %}"))
 
+(text "{% if selected_note -%}")
+; open all details elements above the selected note
+(script
+    ("defer" "true")
+    (text "setTimeout(() => {
+        let cursor = document.querySelector(\"[ui_ident=active_note]\");
+        while (cursor) {
+            if (cursor.nodeName === \"DETAILS\") {
+                cursor.setAttribute(\"open\", \"true\");
+            }
+
+            cursor = cursor.parentElement;
+        }
+    }, 150);"))
+(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)")
@@ -2041,36 +2057,155 @@
     (text "{%- endif %}")
 
     ; note listings
-    (text "{% for note in notes %} {% if not view_mode or note.title != \"journal.css\" -%}")
-    (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 "{%- endif %} {% endfor %}"))
+    (text "{{ self::notes_list_dir_listing_inner(dir=[0, 0, \"root\"], dirs=journal.dirs, notes=notes, owner=owner, journal=journal, view_mode=view_mode) }}"))
 (text "{%- endif %}")
 (text "{%- endmacro %}")
+
+(text "{% macro notes_list_dir_listing(dir, dirs, notes, owner, journal, view_mode=false) -%}")
+(details
+    (summary
+        ("class" "button w-full justify-start raised w-full")
+        (icon (text "folder"))
+        (text "{{ dir[2] }}"))
+
+    (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)")
+        (text "{{ self::notes_list_dir_listing_inner(dir=dir, dirs=dirs, notes=notes, owner=owner, journal=journal, view_mode=view_mode) }}")))
+(text "{%- endmacro %}")
+
+(text "{% macro notes_list_dir_listing_inner(dir, dirs, notes, owner, journal, view_mode=false) -%}")
+; child dirs
+(text "{% for subdir in dirs %} {% if subdir[1] == dir[0] -%}")
+    (text "{{ self::notes_list_dir_listing(dir=subdir, dirs=dirs, notes=notes, owner=owner, journal=journal) }}")
+(text "{%- endif %} {% endfor %}")
+
+; child notes
+(text "{% for note in notes %} {% if note.dir == dir[0] -%} {% if not view_mode or note.title != \"journal.css\" -%}")
+    (text "{{ self::notes_list_note_listing(note=note, owner=owner, journal=journal) }}")
+(text "{%- endif %} {%- endif %} {% endfor %}")
+(text "{%- endmacro %}")
+
+(text "{% macro notes_list_note_listing(owner, journal, note) -%}")
+(div
+    ("class" "flex flex-row gap-1")
+    ("ui_ident" "{% if selected_note == note.id -%} active_note {%- else -%} inactive_note {%- endif %}")
+    (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")))
+            (a
+                ("class" "button")
+                ("href" "/journals/{{ journal.id }}/{{ note.id }}#/tags")
+                (icon (text "tag"))
+                (str (text "journals:action.edit_tags")))
+            (button
+                ("class" "button")
+                ("onclick" "window.NOTE_MOVER_NOTE_ID = '{{ note.id }}'; document.getElementById('note_mover_dialog').showModal()")
+                (icon (text "brush-cleaning"))
+                (str (text "journals:action.move")))
+            (button
+                ("onclick" "delete_note('{{ note.id }}')")
+                ("class" "red")
+                (icon (text "trash"))
+                (str (text "general:action.delete")))))
+    (text "{%- endif %}"))
+(text "{%- endmacro %}")
+
+(text "{% macro note_tags(note) -%} {% if note and note.tags|length > 0 -%}")
+(div
+    ("class" "flex gap-1 flex-wrap")
+    (text "{% for tag in note.tags %}")
+    (a
+        ("href" "{% if view_mode -%} /@{{ owner.username }} {%- else -%} /@{{ user.username }} {%- endif -%} /{{ journal.title }}?tag={{ tag }}")
+        ("class" "notification chip")
+        (span (text "{{ tag }}")))
+    (text "{% endfor %}"))
+(text "{%- endif %} {%- endmacro %}")
+
+(text "{% macro directories_editor(dirs) -%}")
+(button
+    ("onclick" "create_directory('0')")
+    (icon (text "plus"))
+    (str (text "journals:action.create_root_dir")))
+
+(text "{% for dir in dirs %} {% if dir[1] == 0 -%}")
+    (text "{{ self::directories_editor_listing(dir=dir, dirs=dirs) }}")
+(text "{%- endif %} {% endfor %}")
+(text "{%- endmacro %}")
+
+(text "{% macro directories_editor_listing(dir, dirs) -%}")
+(div
+    ("class" "flex flex-row gap-1")
+    (button
+        ("class" "justify-start lowered w-full")
+        (icon (text "folder-open"))
+        (text "{{ dir[2] }}"))
+
+    (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" "create_directory('{{ dir[0] }}')")
+                (icon (text "plus"))
+                (str (text "journals:action.create_subdir")))
+            (button
+                ("onclick" "delete_directory('{{ dir[0] }}')")
+                ("class" "red")
+                (icon (text "trash"))
+                (str (text "general:action.delete"))))))
+
+(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)")
+    ; subdir listings
+    (text "{% for subdir in dirs %} {% if subdir[1] == dir[0] -%}")
+        (text "{{ self::directories_editor_listing(dir=subdir, dirs=dirs) }}")
+    (text "{%- endif %} {% endfor %}"))
+(text "{%- endmacro %}")
+
+(text "{% macro note_mover_dirs(dirs) -%}")
+(text "{% for dir in dirs %} {% if dir[1] == 0 -%}")
+    (text "{{ self::note_mover_dirs_listing(dir=dir, dirs=dirs) }}")
+(text "{%- endif %} {% endfor %}")
+(text "{%- endmacro %}")
+
+(text "{% macro note_mover_dirs_listing(dir, dirs) -%}")
+(button
+    ("onclick" "move_note_dir(window.NOTE_MOVER_NOTE_ID, '{{ dir[0] }}'); document.getElementById('note_mover_dialog').close()")
+    ("class" "justify-start lowered w-full")
+    (icon (text "folder-open"))
+    (text "{{ dir[2] }}"))
+
+(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)")
+    ; subdir listings
+    (text "{% for subdir in dirs %} {% if subdir[1] == dir[0] -%}")
+        (text "{{ self::note_mover_dirs_listing(dir=subdir, dirs=dirs) }}")
+    (text "{%- endif %} {% endfor %}"))
+(text "{%- endmacro %}")
diff --git a/crates/app/src/public/html/journals/app.lisp b/crates/app/src/public/html/journals/app.lisp
index 54a5415..697c466 100644
--- a/crates/app/src/public/html/journals/app.lisp
+++ b/crates/app/src/public/html/journals/app.lisp
@@ -83,23 +83,25 @@
             (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\") }}"))
+                    ("class" "flex flex-col 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\") }}"))
 
-                    (text "{% if (view_mode and owner) or not view_mode -%}")
-                    (a
-                        ("class" "flush")
-                        ("href" "{% if view_mode -%} /@{{ owner.username }}/{{ journal.title }} {%- else -%} /journals/{{ journal.id }}/0 {%- endif %}")
-                        (b (text "{{ journal.title }}")))
-                    (text "{%- endif %}")
+                        (text "{% if (view_mode and owner) or not view_mode -%}")
+                        (a
+                            ("class" "flush")
+                            ("href" "{% if view_mode -%} /@{{ owner.username }}/{{ journal.title }} {%- else -%} /journals/{{ journal.id }}/0 {%- endif %}")
+                            (b (text "{{ journal.title }}")))
+                        (text "{%- endif %}")
 
-                    (text "{% if note -%}")
-                    (span (text "/"))
-                    (b (text "{{ note.title }}"))
-                    (text "{%- endif %}"))
+                        (text "{% if note -%}")
+                        (span (text "/"))
+                        (b (text "{{ note.title }}"))
+                        (text "{%- endif %}")))
 
                 (text "{% if user and user.id == journal.owner and (not note or note.title != \"journal.css\") -%}")
                 (div
@@ -196,10 +198,29 @@
                                 (text "{{ icon \"check\" }}")
                                 (span
                                     (text "{{ text \"general:action.save\" }}")))))))
+
+            ; users should also be able to manage the journal's sub directories here
+            (details
+                ("class" "w-full")
+                (summary
+                    ("class" "button lowered w-full justify-start")
+                    (icon (text "folders"))
+                    (str (text "journals:label.directories")))
+
+                (div
+                    ("class" "card flex flex-col gap-2 lowered")
+                    (text "{{ components::directories_editor(dirs=journal.dirs) }}")))
             (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 "{% if tag|length > 0 -%}")
+                (a
+                    ("href" "?")
+                    ("class" "notification chip w-content")
+                    (text "{{ tag }}"))
+                (text "{%- endif %}")
+
                 (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 %}")
@@ -252,15 +273,30 @@
                     ("href" "#/preview")
                     ("data-tab-button" "preview")
                     ("data-turbo" "false")
-                    (str (text "journals:label.preview_pane"))))
+                    (str (text "journals:label.preview_pane")))
+
+                (a
+                    ("href" "#/tags")
+                    ("data-tab-button" "tags")
+                    ("data-turbo" "false")
+                    ("class" "hidden")
+                    (str (text "journals:action.edit_tags"))))
             (text "{%- endif %}")
 
             ; tabs
+            (text "{{ components::note_tags(note=note) }}")
+
             (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
+                    ("class" "flex flex-col gap-2 card")
+                    ("style" "animation: fadein ease-in-out 1 0.5s forwards running")
+                    ("id" "editor_tab"))
+
+                (button
+                    ("onclick" "change_note_content('{{ note.id }}')")
+                    (icon (text "check"))
+                    (str (text "general:action.save"))))
 
             (div
                 ("data-tab" "preview")
@@ -268,10 +304,51 @@
                 ("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")))
+            (div
+                ("data-tab" "tags")
+                ("class" "flex flex-col gap-2 card hidden")
+                ("style" "animation: fadein ease-in-out 1 0.5s forwards running")
+                (form
+                    ("onsubmit" "save_tags(event)")
+                    ("class" "flex flex-col gap-1")
+                    (label
+                        ("for" "tags")
+                        (str (text "journals:action.tags"))
+                    (textarea
+                        ("type" "text")
+                        ("name" "tags")
+                        ("id" "tags")
+                        ("placeholder" "tags")
+                        ("required" "")
+                        ("minlength" "2")
+                        ("maxlength" "128")
+                        (text "{% for tag in note.tags -%} {{ tag }}, {% endfor %}"))
+                    (span ("class" "fade") (text "Tags should be separated by a comma.")))
+
+                    (button
+                        (icon (text "check"))
+                        (str (text "general:action.save"))))
+
+                (script
+                    (text "globalThis.save_tags = (e) => {
+                        event.preventDefault();
+                        fetch(\"/api/v1/notes/{{ selected_note }}/tags\", {
+                            method: \"POST\",
+                            headers: {
+                                \"Content-Type\": \"application/json\",
+                            },
+                            body: JSON.stringify({
+                                tags: e.target.tags.value.split(\",\").map(t => t.trim()).filter(t => t),
+                            }),
+                        })
+                            .then((res) => res.json())
+                            .then((res) => {
+                                trigger(\"atto::toast\", [
+                                    res.ok ? \"success\" : \"error\",
+                                    res.message,
+                                ]);
+                            });
+                    }")))
 
             ; init codemirror
             (script ("id" "editor_content") ("type" "text/markdown") (text "{{ note.content|remove_script_tags|safe }}"))
@@ -281,6 +358,7 @@
                         document.getElementById(\"preview_tab\").attachShadow({ mode: \"open\" });
                     }
 
+                    document.getElementById(\"editor_tab\").innerHTML = \"\";
                     globalThis.editor = CodeMirror(document.getElementById(\"editor_tab\"), {
                         value: document.getElementById(\"editor_content\").innerHTML,
                         mode: \"{% if note.title == 'journal.css' -%} css {%- else -%} markdown {%- endif %}\",
@@ -349,7 +427,11 @@
 
             (div
                 ("class" "flex w-full justify-between gap-2")
-                (span (text "Last updated: ") (span ("class" "date") (text "{{ note.edited }}")))
+                (div
+                    ("class" "flex flex-col gap-2")
+                    (span (text "Last updated: ") (span ("class" "date") (text "{{ note.edited }}")))
+                    (text "{{ components::note_tags(note=note) }}"))
+
                 (text "{% if user and user.id == owner.id -%}")
                 (button
                     ("class" "small")
@@ -600,6 +682,99 @@
                 });
         }
 
+        globalThis.create_directory = async (parent) => {
+            const name = await trigger(\"atto::prompt\", [\"Directory name:\"]);
+
+            if (!name) {
+                return;
+            }
+
+            fetch(\"/api/v1/journals/{{ selected_journal }}/dirs\", {
+                method: \"POST\",
+                headers: {
+                    \"Content-Type\": \"application/json\",
+                },
+                body: JSON.stringify({
+                    name,
+                    parent,
+                }),
+            })
+                .then((res) => res.json())
+                .then((res) => {
+                    trigger(\"atto::toast\", [
+                        res.ok ? \"success\" : \"error\",
+                        res.message,
+                    ]);
+
+                    if (res.ok) {
+                        window.location.reload();
+                    }
+                });
+        }
+
+        globalThis.delete_directory = async (id) => {
+            if (
+                !(await trigger(\"atto::confirm\", [
+                    \"Are you sure you would like to do this? This will delete all notes within this directory.\",
+                ]))
+            ) {
+                return;
+            }
+
+            fetch(\"/api/v1/journals/{{ selected_journal }}/dirs\", {
+                method: \"DELETE\",
+                headers: {
+                    \"Content-Type\": \"application/json\",
+                },
+                body: JSON.stringify({
+                    id,
+                }),
+            })
+                .then((res) => res.json())
+                .then((res) => {
+                    trigger(\"atto::toast\", [
+                        res.ok ? \"success\" : \"error\",
+                        res.message,
+                    ]);
+
+                    if (res.ok) {
+                        fetch(`/api/v1/notes/{{ selected_journal }}/dir/${id}`, {
+                            method: \"DELETE\",
+                        })
+                            .then((res) => res.json())
+                            .then((res) => {
+                                trigger(\"atto::toast\", [
+                                    res.ok ? \"success\" : \"error\",
+                                    res.message,
+                                ]);
+                            });
+                    }
+                });
+        }
+
+        globalThis.move_note_dir = async (id, dir) => {
+            fetch(`/api/v1/notes/${id}/dir`, {
+                method: \"POST\",
+                headers: {
+                    \"Content-Type\": \"application/json\",
+                },
+                body: JSON.stringify({
+                    dir,
+                }),
+            })
+                .then((res) => res.json())
+                .then((res) => {
+                    trigger(\"atto::toast\", [
+                        res.ok ? \"success\" : \"error\",
+                        res.message,
+                    ]);
+
+                    if (res.ok) {
+                        window.location.reload();
+                    }
+                });
+        }
+
         // sidebars
         window.SIDEBARS_OPEN = false;
         if (new URLSearchParams(window.location.search).get(\"nav\") === \"true\") {
@@ -642,4 +817,24 @@
                 notes_list.style.left = \"0\";
             }
         }")))
+
+(text "{% if journal -%}")
+; note mover
+(dialog
+    ("id" "note_mover_dialog")
+    (div
+        ("class" "inner flex flex-col gap-2")
+        (p (text "Select a directory to move this note into:"))
+        (text "{{ components::note_mover_dirs(dirs=journal.dirs) }}")
+        (div
+            ("class" "flex justify-between")
+            (div)
+            (div
+                ("class" "flex gap-2")
+                (button
+                    ("class" "bold red lowered")
+                    ("onclick" "document.getElementById('note_mover_dialog').close()")
+                    ("type" "button")
+                    (text "{{ icon \"x\" }} {{ text \"dialog:action.close\" }}"))))))
+(text "{%- endif %}")
 (text "{% endblock %}")
diff --git a/crates/app/src/routes/api/v1/journals.rs b/crates/app/src/routes/api/v1/journals.rs
index 19944c6..45ac04f 100644
--- a/crates/app/src/routes/api/v1/journals.rs
+++ b/crates/app/src/routes/api/v1/journals.rs
@@ -4,9 +4,12 @@ use axum::{
     Extension,
 };
 use axum_extra::extract::CookieJar;
+use tetratto_shared::snow::Snowflake;
 use crate::{
     get_user_from_token,
-    routes::api::v1::{UpdateJournalPrivacy, CreateJournal, UpdateJournalTitle},
+    routes::api::v1::{
+        AddJournalDir, CreateJournal, RemoveJournalDir, UpdateJournalPrivacy, UpdateJournalTitle,
+    },
     State,
 };
 use tetratto_core::{
@@ -198,3 +201,86 @@ pub async fn delete_request(
         Err(e) => Json(e.into()),
     }
 }
+
+pub async fn add_dir_request(
+    jar: CookieJar,
+    Path(id): Path<usize>,
+    Extension(data): Extension<State>,
+    Json(props): Json<AddJournalDir>,
+) -> 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()),
+    };
+
+    if props.name.len() > 32 {
+        return Json(Error::DataTooLong("name".to_string()).into());
+    }
+
+    let mut journal = match data.get_journal_by_id(id).await {
+        Ok(x) => x,
+        Err(e) => return Json(e.into()),
+    };
+
+    // add dir
+    journal.dirs.push((
+        Snowflake::new().to_string().parse::<usize>().unwrap(),
+        match props.parent.parse() {
+            Ok(p) => p,
+            Err(_) => return Json(Error::Unknown.into()),
+        },
+        props.name,
+    ));
+
+    // ...
+    match data.update_journal_dirs(id, &user, journal.dirs).await {
+        Ok(_) => Json(ApiReturn {
+            ok: true,
+            message: "Journal updated".to_string(),
+            payload: (),
+        }),
+        Err(e) => Json(e.into()),
+    }
+}
+
+pub async fn remove_dir_request(
+    jar: CookieJar,
+    Path(id): Path<usize>,
+    Extension(data): Extension<State>,
+    Json(props): Json<RemoveJournalDir>,
+) -> 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()),
+    };
+
+    let mut journal = match data.get_journal_by_id(id).await {
+        Ok(x) => x,
+        Err(e) => return Json(e.into()),
+    };
+
+    // add dir
+    let dir_id: usize = match props.dir.parse() {
+        Ok(x) => x,
+        Err(_) => return Json(Error::Unknown.into()),
+    };
+
+    journal
+        .dirs
+        .remove(match journal.dirs.iter().position(|x| x.0 == dir_id) {
+            Some(idx) => idx,
+            None => return Json(Error::GeneralNotFound("directory".to_string()).into()),
+        });
+
+    // ...
+    match data.update_journal_dirs(id, &user, journal.dirs).await {
+        Ok(_) => Json(ApiReturn {
+            ok: true,
+            message: "Journal updated".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 d529c60..44467ba 100644
--- a/crates/app/src/routes/api/v1/mod.rs
+++ b/crates/app/src/routes/api/v1/mod.rs
@@ -570,14 +570,22 @@ pub fn routes() -> Router {
             "/journals/{id}/privacy",
             post(journals::update_privacy_request),
         )
+        .route("/journals/{id}/dirs", post(journals::add_dir_request))
+        .route("/journals/{id}/dirs", delete(journals::remove_dir_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/{id}/dir", post(notes::update_dir_request))
+        .route("/notes/{id}/tags", post(notes::update_tags_request))
         .route("/notes/from_journal/{id}", get(notes::list_request))
         .route("/notes/preview", post(notes::render_markdown_request))
+        .route(
+            "/notes/{journal}/dir/{dir}",
+            delete(notes::delete_by_dir_request),
+        )
         // uploads
         .route("/uploads/{id}", get(uploads::get_request))
         .route("/uploads/{id}", delete(uploads::delete_request))
@@ -926,3 +934,24 @@ pub struct CreateMessageReaction {
     pub message: String,
     pub emoji: String,
 }
+
+#[derive(Deserialize)]
+pub struct UpdateNoteDir {
+    pub dir: String,
+}
+
+#[derive(Deserialize)]
+pub struct AddJournalDir {
+    pub name: String,
+    #[serde(default)]
+    pub parent: String,
+}
+
+#[derive(Deserialize)]
+pub struct RemoveJournalDir {
+    pub dir: String,
+}
+#[derive(Deserialize)]
+pub struct UpdateNoteTags {
+    pub tags: Vec<String>,
+}
diff --git a/crates/app/src/routes/api/v1/notes.rs b/crates/app/src/routes/api/v1/notes.rs
index faf1bec..9a18559 100644
--- a/crates/app/src/routes/api/v1/notes.rs
+++ b/crates/app/src/routes/api/v1/notes.rs
@@ -7,7 +7,10 @@ use axum_extra::extract::CookieJar;
 use tetratto_shared::unix_epoch_timestamp;
 use crate::{
     get_user_from_token,
-    routes::api::v1::{CreateNote, RenderMarkdown, UpdateNoteContent, UpdateNoteTitle},
+    routes::api::v1::{
+        CreateNote, RenderMarkdown, UpdateNoteContent, UpdateNoteDir, UpdateNoteTags,
+        UpdateNoteTitle,
+    },
     State,
 };
 use tetratto_core::{
@@ -222,8 +225,96 @@ pub async fn delete_request(
     }
 }
 
+pub async fn delete_by_dir_request(
+    jar: CookieJar,
+    Path((journal, id)): Path<(usize, usize)>,
+    Extension(data): Extension<State>,
+) -> 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_notes_by_journal_dir(journal, id, &user).await {
+        Ok(_) => Json(ApiReturn {
+            ok: true,
+            message: "Notes deleted".to_string(),
+            payload: (),
+        }),
+        Err(e) => Json(e.into()),
+    }
+}
+
 pub async fn render_markdown_request(Json(req): Json<RenderMarkdown>) -> impl IntoResponse {
     tetratto_shared::markdown::render_markdown(&CustomEmoji::replace(&req.content))
         .replace("\\@", "@")
         .replace("%5C@", "@")
 }
+
+pub async fn update_dir_request(
+    jar: CookieJar,
+    Path(id): Path<usize>,
+    Extension(data): Extension<State>,
+    Json(props): Json<UpdateNoteDir>,
+) -> 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()),
+    };
+
+    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.journal).await {
+        Ok(x) => x,
+        Err(e) => return Json(e.into()),
+    };
+
+    // make sure dir exists
+    let dir = match props.dir.parse::<usize>() {
+        Ok(d) => d,
+        Err(_) => return Json(Error::Unknown.into()),
+    };
+
+    if dir != 0 {
+        if journal.dirs.iter().find(|x| x.0 == dir).is_none() {
+            return Json(Error::GeneralNotFound("directory".to_string()).into());
+        }
+    }
+
+    // ...
+    match data.update_note_dir(id, &user, dir as i64).await {
+        Ok(_) => Json(ApiReturn {
+            ok: true,
+            message: "Note updated".to_string(),
+            payload: (),
+        }),
+        Err(e) => Json(e.into()),
+    }
+}
+
+pub async fn update_tags_request(
+    jar: CookieJar,
+    Path(id): Path<usize>,
+    Extension(data): Extension<State>,
+    Json(props): Json<UpdateNoteTags>,
+) -> 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_tags(id, &user, props.tags).await {
+        Ok(_) => Json(ApiReturn {
+            ok: true,
+            message: "Note updated".to_string(),
+            payload: (),
+        }),
+        Err(e) => Json(e.into()),
+    }
+}
diff --git a/crates/app/src/routes/pages/journals.rs b/crates/app/src/routes/pages/journals.rs
index 1f03dd7..434c819 100644
--- a/crates/app/src/routes/pages/journals.rs
+++ b/crates/app/src/routes/pages/journals.rs
@@ -158,14 +158,6 @@ pub async fn view_request(
         }
     }
 
-    // ...
-    let notes = match data.0.get_notes_by_journal(journal.id).await {
-        Ok(p) => Some(p),
-        Err(e) => {
-            return Err(Html(render_error(e, &jar, &data, &user).await));
-        }
-    };
-
     // ...
     let note = if !selected_note.is_empty() {
         match data
@@ -199,7 +191,7 @@ pub async fn view_request(
     context.insert("note", &note);
 
     context.insert("owner", &owner);
-    context.insert("notes", &notes);
+    context.insert::<[i8; 0], &str>("notes", &[]);
 
     context.insert("view_mode", &true);
     context.insert("is_editor", &false);
@@ -213,6 +205,7 @@ pub async fn index_view_request(
     jar: CookieJar,
     Extension(data): Extension<State>,
     Path((owner, selected_journal)): Path<(String, String)>,
+    Query(props): Query<JournalsAppQuery>,
 ) -> impl IntoResponse {
     let data = data.read().await;
     let user = match get_user_from_token!(jar, data.0) {
@@ -257,10 +250,23 @@ pub async fn index_view_request(
     }
 
     // ...
-    let notes = match data.0.get_notes_by_journal(journal.id).await {
-        Ok(p) => Some(p),
-        Err(e) => {
-            return Err(Html(render_error(e, &jar, &data, &user).await));
+    let notes = if props.tag.is_empty() {
+        match data.0.get_notes_by_journal(journal.id).await {
+            Ok(p) => Some(p),
+            Err(e) => {
+                return Err(Html(render_error(e, &jar, &data, &user).await));
+            }
+        }
+    } else {
+        match data
+            .0
+            .get_notes_by_journal_tag(journal.id, &props.tag)
+            .await
+        {
+            Ok(p) => Some(p),
+            Err(e) => {
+                return Err(Html(render_error(e, &jar, &data, &user).await));
+            }
         }
     };
 
@@ -281,6 +287,7 @@ pub async fn index_view_request(
 
     context.insert("view_mode", &true);
     context.insert("is_editor", &false);
+    context.insert("tag", &props.tag);
 
     // return
     Ok(Html(data.1.render("journals/app.html", &context).unwrap()))
diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs
index 2eaeca2..9115f65 100644
--- a/crates/app/src/routes/pages/mod.rs
+++ b/crates/app/src/routes/pages/mod.rs
@@ -196,4 +196,6 @@ pub struct RepostsQuery {
 pub struct JournalsAppQuery {
     #[serde(default)]
     pub view: bool,
+    #[serde(default)]
+    pub tag: String,
 }
diff --git a/crates/core/src/database/drivers/sql/create_journals.sql b/crates/core/src/database/drivers/sql/create_journals.sql
index 40eafa4..47f4a6e 100644
--- a/crates/core/src/database/drivers/sql/create_journals.sql
+++ b/crates/core/src/database/drivers/sql/create_journals.sql
@@ -3,5 +3,6 @@ CREATE TABLE IF NOT EXISTS journals (
     created BIGINT NOT NULL,
     owner BIGINT NOT NULL,
     title TEXT NOT NULL,
-    privacy TEXT NOT NULL
+    privacy TEXT NOT NULL,
+    dirs 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 87361ad..2c85588 100644
--- a/crates/core/src/database/drivers/sql/create_notes.sql
+++ b/crates/core/src/database/drivers/sql/create_notes.sql
@@ -5,5 +5,7 @@ CREATE TABLE IF NOT EXISTS notes (
     title TEXT NOT NULL,
     journal BIGINT NOT NULL,
     content TEXT NOT NULL,
-    edited BIGINT NOT NULL
+    edited BIGINT NOT NULL,
+    dir BIGINT NOT NULL,
+    tags TEXT NOT NULL
 )
diff --git a/crates/core/src/database/journals.rs b/crates/core/src/database/journals.rs
index a4a0d00..2ad4078 100644
--- a/crates/core/src/database/journals.rs
+++ b/crates/core/src/database/journals.rs
@@ -20,6 +20,7 @@ impl DataManager {
             owner: get!(x->2(i64)) as usize,
             title: get!(x->3(String)),
             privacy: serde_json::from_str(&get!(x->4(String))).unwrap(),
+            dirs: serde_json::from_str(&get!(x->5(String))).unwrap(),
         }
     }
 
@@ -128,13 +129,14 @@ impl DataManager {
 
         let res = execute!(
             &conn,
-            "INSERT INTO journals VALUES ($1, $2, $3, $4, $5)",
+            "INSERT INTO journals VALUES ($1, $2, $3, $4, $5, $6)",
             params![
                 &(data.id as i64),
                 &(data.created as i64),
                 &(data.owner as i64),
                 &data.title,
                 &serde_json::to_string(&data.privacy).unwrap(),
+                &serde_json::to_string(&data.dirs).unwrap(),
             ]
         );
 
@@ -183,4 +185,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_privacy(JournalPrivacyPermission)@get_journal_by_id:MANAGE_JOURNALS -> "UPDATE journals SET privacy = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.journal:{}");
+    auto_method!(update_journal_dirs(Vec<(usize, usize, String)>)@get_journal_by_id:MANAGE_JOURNALS -> "UPDATE journals SET dirs = $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 ea7da45..364a540 100644
--- a/crates/core/src/database/notes.rs
+++ b/crates/core/src/database/notes.rs
@@ -15,6 +15,8 @@ impl DataManager {
             journal: get!(x->4(i64)) as usize,
             content: get!(x->5(String)),
             edited: get!(x->6(i64)) as usize,
+            dir: get!(x->7(i64)) as usize,
+            tags: serde_json::from_str(&get!(x->8(String))).unwrap(),
         }
     }
 
@@ -65,6 +67,31 @@ impl DataManager {
         Ok(res.unwrap())
     }
 
+    /// Get all notes by journal with the given tag.
+    ///
+    /// # Arguments
+    /// * `id` - the ID of the journal to fetch notes for
+    /// * `tag`
+    pub async fn get_notes_by_journal_tag(&self, id: usize, tag: &str) -> Result<Vec<Note>> {
+        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 AND tags::jsonb ? $2 ORDER BY edited DESC",
+            params![&(id as i64), tag],
+            |x| { Self::get_note_from_row(x) }
+        );
+
+        if res.is_err() {
+            return Err(Error::GeneralNotFound("note".to_string()));
+        }
+
+        Ok(res.unwrap())
+    }
+
     const MAXIMUM_FREE_NOTES_PER_JOURNAL: usize = 10;
 
     /// Create a new note in the database.
@@ -137,7 +164,7 @@ impl DataManager {
 
         let res = execute!(
             &conn,
-            "INSERT INTO notes VALUES ($1, $2, $3, $4, $5, $6, $7)",
+            "INSERT INTO notes VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
             params![
                 &(data.id as i64),
                 &(data.created as i64),
@@ -146,6 +173,8 @@ impl DataManager {
                 &(data.journal as i64),
                 &data.content,
                 &(data.edited as i64),
+                &(data.dir as i64),
+                &serde_json::to_string(&data.tags).unwrap(),
             ]
         );
 
@@ -181,7 +210,45 @@ impl DataManager {
         Ok(())
     }
 
+    /// Delete all notes by dir ID.
+    ///
+    /// # Arguments
+    /// * `journal`
+    /// * `dir`
+    pub async fn delete_notes_by_journal_dir(
+        &self,
+        journal: usize,
+        dir: usize,
+        user: &User,
+    ) -> Result<()> {
+        let journal = self.get_journal_by_id(journal).await?;
+
+        if journal.owner != user.id && !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 dir = $1 AND journal = $2 ORDER BY edited DESC",
+            &[&(dir as i64), &(journal.id as i64)]
+        );
+
+        if let Err(e) = res {
+            return Err(Error::DatabaseError(e.to_string()));
+        }
+
+        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:{}");
     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:{}");
+    auto_method!(update_note_dir(i64)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET dir = $1 WHERE id = $2" --cache-key-tmpl="atto.note:{}");
+    auto_method!(update_note_tags(Vec<String>)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET tags = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.note:{}");
     auto_method!(update_note_edited(i64) -> "UPDATE notes SET edited = $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 f67b318..3d70ce0 100644
--- a/crates/core/src/model/journals.rs
+++ b/crates/core/src/model/journals.rs
@@ -22,6 +22,10 @@ pub struct Journal {
     pub owner: usize,
     pub title: String,
     pub privacy: JournalPrivacyPermission,
+    /// An array of directories notes can be placed in.
+    ///
+    /// `Vec<(id, parent id, name)>`
+    pub dirs: Vec<(usize, usize, String)>,
 }
 
 impl Journal {
@@ -33,6 +37,7 @@ impl Journal {
             owner,
             title,
             privacy: JournalPrivacyPermission::default(),
+            dirs: Vec::new(),
         }
     }
 }
@@ -49,6 +54,12 @@ pub struct Note {
     pub journal: usize,
     pub content: String,
     pub edited: usize,
+    /// The "id" of the directoryy this note is in.
+    ///
+    /// Directories are held in the journal in the `dirs` column.
+    pub dir: usize,
+    /// An array of tags associated with the note.
+    pub tags: Vec<String>,
 }
 
 impl Note {
@@ -64,6 +75,8 @@ impl Note {
             journal,
             content,
             edited: created,
+            dir: 0,
+            tags: Vec::new(),
         }
     }
 }
diff --git a/sql_changes/journals_dirs.sql b/sql_changes/journals_dirs.sql
new file mode 100644
index 0000000..72d1aaf
--- /dev/null
+++ b/sql_changes/journals_dirs.sql
@@ -0,0 +1,2 @@
+ALTER TABLE journals
+ADD COLUMN dirs TEXT NOT NULL DEFAULT '[]';
diff --git a/sql_changes/notes_dir_tags.sql b/sql_changes/notes_dir_tags.sql
new file mode 100644
index 0000000..0bf24d1
--- /dev/null
+++ b/sql_changes/notes_dir_tags.sql
@@ -0,0 +1,5 @@
+ALTER TABLE notes
+ADD COLUMN dir BIGINT NOT NULL DEFAULT 0;
+
+ALTER TABLE notes
+ADD COLUMN tags TEXT NOT NULL DEFAULT '[]';

From 0c509b7001a9717f74776f9be39514b895749a3b Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Sat, 21 Jun 2025 21:32:51 -0400
Subject: [PATCH 34/71] add: open graph tags for posts and notes

---
 crates/app/src/public/html/journals/app.lisp | 38 +++++++++++++++++++
 crates/app/src/public/html/macros.lisp       |  4 +-
 crates/app/src/public/html/post/post.lisp    | 35 +++++++++++++++++
 crates/core/src/database/notifications.rs    | 40 +++++++++++---------
 4 files changed, 97 insertions(+), 20 deletions(-)

diff --git a/crates/app/src/public/html/journals/app.lisp b/crates/app/src/public/html/journals/app.lisp
index 697c466..cae5082 100644
--- a/crates/app/src/public/html/journals/app.lisp
+++ b/crates/app/src/public/html/journals/app.lisp
@@ -1,5 +1,43 @@
 (text "{% extends \"root.html\" %} {% block head %}")
 (text "{% if journal -%}") (title (text "{{ journal.title }}")) (text "{% else %}") (title (text "Journals - {{ config.name }}")) (text "{%- endif %}")
+
+(text "{% if note and journal and owner -%}")
+(meta
+    ("name" "og:title")
+    ("content" "{{ note.title }}"))
+
+(meta
+    ("name" "description")
+    ("content" "View this note from the {{ journal.title }} journal on {{ config.name }}!"))
+
+(meta
+    ("name" "og:description")
+    ("content" "View this note from the {{ journal.title }} journal on {{ config.name }}!"))
+
+(meta
+    ("property" "og:type")
+    ("content" "website"))
+
+(meta
+    ("name" "og:image")
+    ("content" "{{ config.host|safe }}/api/v1/auth/user/{{ owner.username }}/avatar?selector_type=username"))
+
+(meta
+    ("name" "twitter:image")
+    ("content" "{{ config.host|safe }}/api/v1/auth/user/{{ owner.username }}/avatar?selector_type=usernamev"))
+
+(meta
+    ("name" "twitter:card")
+    ("content" "summary"))
+
+(meta
+    ("name" "twitter:title")
+    ("content" "{{ note.title }}"))
+
+(meta
+    ("name" "twitter:description")
+    ("content" "View this note from the {{ journal.title }} journal on {{ config.name }}!"))
+(text "{%- endif %}")
 (link ("rel" "stylesheet") ("data-turbo-temporary" "true") ("href" "/css/chats.css?v=tetratto-{{ random_cache_breaker }}"))
 
 (style
diff --git a/crates/app/src/public/html/macros.lisp b/crates/app/src/public/html/macros.lisp
index 18820ce..7691ee9 100644
--- a/crates/app/src/public/html/macros.lisp
+++ b/crates/app/src/public/html/macros.lisp
@@ -48,7 +48,7 @@
             (a
                 ("href" "/requests")
                 ("class" "button {% if selected == 'requests' -%}active{%- endif %}")
-                ("title" "Chats")
+                ("title" "Requests")
                 (icon (text "inbox"))
                 (span
                     ("class" "notification tr {% if user.request_count <= 0 -%}hidden{%- endif %}")
@@ -58,7 +58,7 @@
             (a
                 ("href" "/notifs")
                 ("class" "button {% if selected == 'notifications' -%}active{%- endif %}")
-                ("title" "Chats")
+                ("title" "Notifications")
                 (icon (text "bell"))
                 (span
                     ("class" "notification tr {% if user.notification_count <= 0 -%}hidden{%- endif %}")
diff --git a/crates/app/src/public/html/post/post.lisp b/crates/app/src/public/html/post/post.lisp
index 22f64e9..fc40c2d 100644
--- a/crates/app/src/public/html/post/post.lisp
+++ b/crates/app/src/public/html/post/post.lisp
@@ -2,6 +2,41 @@
 (title
     (text "Post - {{ config.name }}"))
 
+(meta
+    ("name" "og:title")
+    ("content" "{% if owner.settings.display_name -%} {{ owner.settings.display_name }} {%- else -%} {{ owner.username }} {%- endif %}'s post"))
+
+(meta
+    ("name" "description")
+    ("content" "View this post from @{{ owner.username }} on {{ config.name }}!"))
+
+(meta
+    ("name" "og:description")
+    ("content" "View this post from @{{ owner.username }} on {{ config.name }}!"))
+
+(meta
+    ("property" "og:type")
+    ("content" "website"))
+
+(meta
+    ("name" "og:image")
+    ("content" "{{ config.host|safe }}/api/v1/auth/user/{{ owner.username }}/avatar?selector_type=username"))
+
+(meta
+    ("name" "twitter:image")
+    ("content" "{{ config.host|safe }}/api/v1/auth/user/{{ owner.username }}/avatar?selector_type=usernamev"))
+
+(meta
+    ("name" "twitter:card")
+    ("content" "summary"))
+
+(meta
+    ("name" "twitter:title")
+    ("content" "{% if owner.settings.display_name -%} {{ owner.settings.display_name }} {%- else -%} {{ owner.username }} {%- endif %}'s post"))
+
+(meta
+    ("name" "twitter:description")
+    ("content" "View this post from @{{ owner.username }} on {{ config.name }}!"))
 (text "{% endblock %} {% block body %} {{ macros::nav() }}")
 (main
     ("class" "flex flex-col gap-2")
diff --git a/crates/core/src/database/notifications.rs b/crates/core/src/database/notifications.rs
index 620434b..080e182 100644
--- a/crates/core/src/database/notifications.rs
+++ b/crates/core/src/database/notifications.rs
@@ -194,34 +194,38 @@ impl DataManager {
     }
 
     pub async fn delete_all_notifications(&self, user: &User) -> Result<()> {
-        let notifications = self.get_notifications_by_owner(user.id).await?;
+        let conn = match self.0.connect().await {
+            Ok(c) => c,
+            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
+        };
 
-        for notification in notifications {
-            if user.id != notification.owner
-                && !user.permissions.check(FinePermission::MANAGE_NOTIFICATIONS)
-            {
-                return Err(Error::NotAllowed);
-            }
+        let res = execute!(
+            &conn,
+            "DELETE FROM notifications WHERE owner = $1",
+            &[&(user.id as i64)]
+        );
 
-            self.delete_notification(notification.id, user).await?
+        if let Err(e) = res {
+            return Err(Error::DatabaseError(e.to_string()));
         }
 
-        self.update_user_notification_count(user.id, 0).await?;
-
         Ok(())
     }
 
     pub async fn delete_all_notifications_by_tag(&self, user: &User, tag: &str) -> Result<()> {
-        let notifications = self.get_notifications_by_tag(tag).await?;
+        let conn = match self.0.connect().await {
+            Ok(c) => c,
+            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
+        };
 
-        for notification in notifications {
-            if user.id != notification.owner
-                && !user.permissions.check(FinePermission::MANAGE_NOTIFICATIONS)
-            {
-                return Err(Error::NotAllowed);
-            }
+        let res = execute!(
+            &conn,
+            "DELETE FROM notifications WHERE owner = $1 AND tag = $2",
+            params![&(user.id as i64), tag]
+        );
 
-            self.delete_notification(notification.id, user).await?
+        if let Err(e) = res {
+            return Err(Error::DatabaseError(e.to_string()));
         }
 
         Ok(())

From d67bf26955310411d1a4d922e450ac7bc6558f3e Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Sat, 21 Jun 2025 21:40:41 -0400
Subject: [PATCH 35/71] fix: user notification count when clearing
 notifications

---
 crates/core/src/database/notifications.rs | 1 +
 1 file changed, 1 insertion(+)

diff --git a/crates/core/src/database/notifications.rs b/crates/core/src/database/notifications.rs
index 080e182..75d7d34 100644
--- a/crates/core/src/database/notifications.rs
+++ b/crates/core/src/database/notifications.rs
@@ -209,6 +209,7 @@ impl DataManager {
             return Err(Error::DatabaseError(e.to_string()));
         }
 
+        self.update_user_notification_count(user.id, 0).await?;
         Ok(())
     }
 

From 52c89836346a8f7c98f7f95bb8c04ec587b138bc Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Sat, 21 Jun 2025 22:22:20 -0400
Subject: [PATCH 36/71] add: utility classes for posts and questions

---
 crates/app/src/public/html/components.lisp | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp
index c7c36d3..44771d3 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 post_outer:{{ post.id }}")
+    ("class" "card-nest post_outer:{{ post.id }} post_outer")
     (text "{% if question -%} {{ self::question(question=question[0], owner=question[1], profile=owner) }} {% else %}")
     (div
         ("class" "card small")
@@ -130,7 +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 post:{{ post.id }} {% if secondary -%}secondary{%- endif %}")
+        ("class" "card flex flex-col post gap-2 post:{{ post.id }} {% if secondary -%}secondary{%- endif %}")
         ("data-community" "{{ post.community }}")
         ("data-ownsup" "{{ owner.permissions|has_supporter }}")
         ("hook" "verify_emojis")
@@ -612,7 +612,7 @@
 
 (text "{%- endif %} {%- endmacro %} {% macro question(question, owner, show_community=true, secondary=false, profile=false) -%}")
 (div
-    ("class" "card {% if secondary -%}secondary{%- endif %} flex gap-2")
+    ("class" "card question {% if secondary -%}secondary{%- endif %} flex gap-2")
     (text "{% if owner.id == 0 -%}")
     (span
         (text "{% if profile and profile.settings.anonymous_avatar_url -%}")

From 5961999ce4648981b31d5cf28d4227a3d22ac5ec Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Sun, 22 Jun 2025 00:04:32 -0400
Subject: [PATCH 37/71] add: PORT env var

---
 README.md                                     |  2 ++
 crates/app/src/main.rs                        |  6 +++-
 crates/app/src/public/html/components.lisp    |  5 ++--
 crates/app/src/public/html/journals/app.lisp  |  1 +
 .../app/src/public/html/profile/settings.lisp |  5 ++++
 crates/app/src/public/js/atto.js              |  7 +++++
 crates/core/src/database/posts.rs             |  5 ++++
 crates/core/src/model/auth.rs                 |  3 ++
 example/nginx/sites-enabled/tetratto.conf     | 28 +++++++++++++++++++
 9 files changed, 58 insertions(+), 4 deletions(-)
 create mode 100644 example/nginx/sites-enabled/tetratto.conf

diff --git a/README.md b/README.md
index 050f8ce..dec9f1e 100644
--- a/README.md
+++ b/README.md
@@ -33,6 +33,8 @@ Tetratto **requires** Cloudflare Turnstile for registrations. Testing keys are l
 
 A `docs` directory will be generated in the same directory that you ran the `tetratto` binary in. **Markdown** files placed here will be served at `/doc/{*file_name}`. For other types of assets, you can place them in the generated `public` directory. This directory serves everything at `/public/{*file_name}`.
 
+You can configure your port through the `port` key of the configuration file. You can also run the server with the `PORT` environment variable, which will override whatever is set in the configuration file.
+
 ## Usage (as a user)
 
 Tetratto is very simple once you get the hang of it! At the top of the page (or bottom if you're on mobile), you'll see the navigation bar. Once logged in, you'll be able to access "Home", "Popular", and "Communities" from there! You can also press your profile picture (on the right) to view your own profile, settings, or log out!
diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs
index 1eafa54..52b35be 100644
--- a/crates/app/src/main.rs
+++ b/crates/app/src/main.rs
@@ -82,7 +82,11 @@ async fn main() {
         .compact()
         .init();
 
-    let config = config::Config::get_config();
+    let mut config = config::Config::get_config();
+    if let Ok(port) = var("PORT") {
+        let port = port.parse::<u16>().expect("port should be a u16");
+        config.port = port;
+    }
 
     // init
     init_dirs(&config).await;
diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp
index 44771d3..99bfc87 100644
--- a/crates/app/src/public/html/components.lisp
+++ b/crates/app/src/public/html/components.lisp
@@ -94,7 +94,7 @@
         (text "{{ dislikes }}"))
     (text "{%- endif %}"))
 
-(text "{%- endif %} {%- endmacro %} {% macro full_username(user) -%}")
+(text "{%- endif %} {%- endmacro %} {% macro full_username(user) -%} {% if user and user.username -%}")
 (div
     ("class" "flex items-center")
     (a
@@ -110,8 +110,7 @@
         ("class" "flex items-center")
         (text "{{ icon \"badge-check\" }}"))
     (text "{%- endif %}"))
-
-(text "{%- endmacro %} {% macro repost(repost, post, owner, secondary=false, community=false, show_community=true, can_manage_post=false) -%}")
+(text "{%- endif %} {%- endmacro %} {% macro repost(repost, post, owner, secondary=false, community=false, show_community=true, can_manage_post=false) -%}")
 (div
     ("style" "display: contents")
     (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) }}"))
diff --git a/crates/app/src/public/html/journals/app.lisp b/crates/app/src/public/html/journals/app.lisp
index cae5082..98779b7 100644
--- a/crates/app/src/public/html/journals/app.lisp
+++ b/crates/app/src/public/html/journals/app.lisp
@@ -326,6 +326,7 @@
 
             (div
                 ("data-tab" "editor")
+                ("class" "flex flex-col gap-2")
                 (div
                     ("class" "flex flex-col gap-2 card")
                     ("style" "animation: fadein ease-in-out 1 0.5s forwards running")
diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp
index 31856fb..8c67bdd 100644
--- a/crates/app/src/public/html/profile/settings.lisp
+++ b/crates/app/src/public/html/profile/settings.lisp
@@ -1359,6 +1359,11 @@
                         \"{{ profile.settings.show_nsfw }}\",
                         \"checkbox\",
                     ],
+                    [
+                        [\"auto_unlist\", \"Automatically mark my posts as NSFW\"],
+                        \"{{ profile.settings.auto_unlist }}\",
+                        \"checkbox\",
+                    ],
                     [[], \"Questions\", \"title\"],
                     [
                         [
diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js
index 9c9d71d..56ac698 100644
--- a/crates/app/src/public/js/atto.js
+++ b/crates/app/src/public/js/atto.js
@@ -1194,6 +1194,7 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
         self.IO_DATA_TMPL = tmpl;
         self.IO_DATA_PAGE = page;
         self.IO_DATA_SEEN_IDS = [];
+        self.IO_DATA_WAITING = false;
 
         if (!paginated_mode) {
             self.IO_DATA_OBSERVER.observe(self.IO_DATA_MARKER);
@@ -1208,6 +1209,11 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
     });
 
     self.define("io_load_data", async () => {
+        if (self.IO_DATA_WAITING) {
+            return;
+        }
+
+        self.IO_DATA_WAITING = true;
         self.IO_DATA_PAGE += 1;
         console.log("load page", self.IO_DATA_PAGE);
 
@@ -1220,6 +1226,7 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
             await fetch(`${self.IO_DATA_TMPL}${self.IO_DATA_PAGE}`)
         ).text();
 
+        self.IO_DATA_WAITING = false;
         self.IO_DATA_ELEMENT.querySelector("[ui_ident=loading_skel]").remove();
 
         if (
diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs
index 72b4f5b..4390f26 100644
--- a/crates/core/src/database/posts.rs
+++ b/crates/core/src/database/posts.rs
@@ -1795,6 +1795,11 @@ impl DataManager {
             );
         }
 
+        // auto unlist
+        if owner.settings.auto_unlist {
+            data.context.is_nsfw = true;
+        }
+
         // ...
         let conn = match self.0.connect().await {
             Ok(c) => c,
diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs
index 171c881..5ff9c49 100644
--- a/crates/core/src/model/auth.rs
+++ b/crates/core/src/model/auth.rs
@@ -237,6 +237,9 @@ pub struct UserSettings {
     /// If drawings are enabled for questions sent to the user.
     #[serde(default)]
     pub enable_drawings: bool,
+    /// Automatically unlist posts from timelines.
+    #[serde(default)]
+    pub auto_unlist: bool,
 }
 
 fn mime_avif() -> String {
diff --git a/example/nginx/sites-enabled/tetratto.conf b/example/nginx/sites-enabled/tetratto.conf
new file mode 100644
index 0000000..52aa6a4
--- /dev/null
+++ b/example/nginx/sites-enabled/tetratto.conf
@@ -0,0 +1,28 @@
+# servers can be uncommented to add load balancing
+upstream tetratto {
+    least_conn;
+    server localhost:4118;
+    # server localhost:5118;
+}
+
+server {
+    listen 80 default_server;
+    listen [::]:80 default_server;
+
+    server_name tetratto;
+
+    # main service stuff
+    location / {
+        proxy_pass http://tetratto;
+        proxy_pass_header CF-Connecting-IP;
+        proxy_pass_request_headers on;
+    }
+
+    # websocket forwarding stuff
+    location ~ /_connect/ {
+        proxy_pass http://tetratto;
+        proxy_http_version 1.1;
+        proxy_set_header Upgrade $http_upgrade;
+        proxy_set_header Connection "upgrade";
+    }
+}

From 612fbf5eb4f30b25fa2b8bbe400973e9830dc0e6 Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Sun, 22 Jun 2025 00:45:05 -0400
Subject: [PATCH 38/71] add: option to hide posts answering questions from
 "All" timeline

---
 .../app/src/public/html/profile/settings.lisp |  5 +++++
 .../src/routes/api/v1/communities/posts.rs    |  5 ++++-
 crates/app/src/routes/pages/misc.rs           |  4 +++-
 crates/core/src/database/posts.rs             | 22 +++++++++++++++++--
 crates/core/src/database/stacks.rs            |  2 +-
 crates/core/src/model/auth.rs                 |  3 +++
 6 files changed, 36 insertions(+), 5 deletions(-)

diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp
index 8c67bdd..50c0c60 100644
--- a/crates/app/src/public/html/profile/settings.lisp
+++ b/crates/app/src/public/html/profile/settings.lisp
@@ -1364,6 +1364,11 @@
                         \"{{ profile.settings.auto_unlist }}\",
                         \"checkbox\",
                     ],
+                    [
+                        [\"all_timeline_hide_answers\", 'Hide posts answering questions from the \"All\" timeline'],
+                        \"{{ profile.settings.all_timeline_hide_answers }}\",
+                        \"checkbox\",
+                    ],
                     [[], \"Questions\", \"title\"],
                     [
                         [
diff --git a/crates/app/src/routes/api/v1/communities/posts.rs b/crates/app/src/routes/api/v1/communities/posts.rs
index 7bf4bf3..9e35fd9 100644
--- a/crates/app/src/routes/api/v1/communities/posts.rs
+++ b/crates/app/src/routes/api/v1/communities/posts.rs
@@ -792,7 +792,10 @@ pub async fn all_request(
         None => return Json(Error::NotAllowed.into()),
     };
 
-    match data.get_latest_posts(12, props.page).await {
+    match data
+        .get_latest_posts(12, props.page, &Some(user.clone()))
+        .await
+    {
         Ok(posts) => {
             let ignore_users = crate::ignore_users_gen!(user!, #data);
             Json(ApiReturn {
diff --git a/crates/app/src/routes/pages/misc.rs b/crates/app/src/routes/pages/misc.rs
index 8b76292..8d0d8be 100644
--- a/crates/app/src/routes/pages/misc.rs
+++ b/crates/app/src/routes/pages/misc.rs
@@ -636,7 +636,9 @@ pub async fn swiss_army_timeline_request(
         } else {
             // everything else
             match req.tl {
-                DefaultTimelineChoice::AllPosts => data.0.get_latest_posts(12, req.page).await,
+                DefaultTimelineChoice::AllPosts => {
+                    data.0.get_latest_posts(12, req.page, &user).await
+                }
                 DefaultTimelineChoice::PopularPosts => {
                     data.0.get_popular_posts(12, req.page, 604_800_000).await
                 }
diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs
index 4390f26..e52490a 100644
--- a/crates/core/src/database/posts.rs
+++ b/crates/core/src/database/posts.rs
@@ -1319,7 +1319,18 @@ impl DataManager {
     /// # Arguments
     /// * `batch` - the limit of posts in each page
     /// * `page` - the page number
-    pub async fn get_latest_posts(&self, batch: usize, page: usize) -> Result<Vec<Post>> {
+    pub async fn get_latest_posts(
+        &self,
+        batch: usize,
+        page: usize,
+        as_user: &Option<User>,
+    ) -> Result<Vec<Post>> {
+        let hide_answers: bool = if let Some(user) = as_user {
+            user.settings.all_timeline_hide_answers
+        } else {
+            false
+        };
+
         let conn = match self.0.connect().await {
             Ok(c) => c,
             Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
@@ -1327,7 +1338,14 @@ impl DataManager {
 
         let res = query_rows!(
             &conn,
-            "SELECT * FROM posts WHERE replying_to = 0 AND NOT context LIKE '%\"is_nsfw\":true%' ORDER BY created DESC LIMIT $1 OFFSET $2",
+            &format!(
+                "SELECT * FROM posts WHERE replying_to = 0 AND NOT context LIKE '%\"is_nsfw\":true%'{} ORDER BY created DESC LIMIT $1 OFFSET $2",
+                if hide_answers {
+                    " AND context::jsonb->>'answering' = '0'"
+                } else {
+                    ""
+                }
+            ),
             &[&(batch as i64), &((page * batch) as i64)],
             |x| { Self::get_post_from_row(x) }
         );
diff --git a/crates/core/src/database/stacks.rs b/crates/core/src/database/stacks.rs
index 47f5e53..a42c4a0 100644
--- a/crates/core/src/database/stacks.rs
+++ b/crates/core/src/database/stacks.rs
@@ -59,7 +59,7 @@ impl DataManager {
                 match stack.sort {
                     StackSort::Created => {
                         self.fill_posts_with_community(
-                            self.get_latest_posts(batch, page).await?,
+                            self.get_latest_posts(batch, page, &user).await?,
                             as_user_id,
                             &ignore_users,
                             user,
diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs
index 5ff9c49..ff910f5 100644
--- a/crates/core/src/model/auth.rs
+++ b/crates/core/src/model/auth.rs
@@ -240,6 +240,9 @@ pub struct UserSettings {
     /// Automatically unlist posts from timelines.
     #[serde(default)]
     pub auto_unlist: bool,
+    /// Hide posts that are answering a question on the "All" timeline.
+    #[serde(default)]
+    pub all_timeline_hide_answers: bool,
 }
 
 fn mime_avif() -> String {

From 958979cfa117718d3aef7c8da9cb929b9d445713 Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Sun, 22 Jun 2025 02:25:41 -0400
Subject: [PATCH 39/71] fix: check user show_nsfw in community timeline

---
 .../app/src/routes/api/v1/communities/posts.rs |  5 ++++-
 crates/app/src/routes/pages/communities.rs     |  2 +-
 crates/app/src/routes/pages/forge.rs           |  2 +-
 crates/core/src/database/posts.rs              | 18 +++++++++++++++++-
 4 files changed, 23 insertions(+), 4 deletions(-)

diff --git a/crates/app/src/routes/api/v1/communities/posts.rs b/crates/app/src/routes/api/v1/communities/posts.rs
index 9e35fd9..5737fc5 100644
--- a/crates/app/src/routes/api/v1/communities/posts.rs
+++ b/crates/app/src/routes/api/v1/communities/posts.rs
@@ -478,7 +478,10 @@ pub async fn community_posts_request(
         None => return Json(Error::NotAllowed.into()),
     };
 
-    match data.get_posts_by_community(id, 12, props.page).await {
+    match data
+        .get_posts_by_community(id, 12, props.page, &Some(user.clone()))
+        .await
+    {
         Ok(posts) => {
             let ignore_users = crate::ignore_users_gen!(user!, #data);
             Json(ApiReturn {
diff --git a/crates/app/src/routes/pages/communities.rs b/crates/app/src/routes/pages/communities.rs
index 626675a..e11b685 100644
--- a/crates/app/src/routes/pages/communities.rs
+++ b/crates/app/src/routes/pages/communities.rs
@@ -417,7 +417,7 @@ pub async fn feed_request(
 
     let feed = match data
         .0
-        .get_posts_by_community(community.id, 12, props.page)
+        .get_posts_by_community(community.id, 12, props.page, &user)
         .await
     {
         Ok(p) => match data.0.fill_posts(p, &ignore_users, &user).await {
diff --git a/crates/app/src/routes/pages/forge.rs b/crates/app/src/routes/pages/forge.rs
index 404455c..be1769c 100644
--- a/crates/app/src/routes/pages/forge.rs
+++ b/crates/app/src/routes/pages/forge.rs
@@ -161,7 +161,7 @@ pub async fn tickets_request(
 
     let feed = match data
         .0
-        .get_posts_by_community(community.id, 12, props.page)
+        .get_posts_by_community(community.id, 12, props.page, &user)
         .await
     {
         Ok(p) => match data.0.fill_posts(p, &ignore_users, &user).await {
diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs
index e52490a..656b30e 100644
--- a/crates/core/src/database/posts.rs
+++ b/crates/core/src/database/posts.rs
@@ -1043,15 +1043,31 @@ impl DataManager {
         id: usize,
         batch: usize,
         page: usize,
+        user: &Option<User>,
     ) -> Result<Vec<Post>> {
         let conn = match self.0.connect().await {
             Ok(c) => c,
             Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
         };
 
+        // check if we should hide nsfw posts
+        let mut hide_nsfw: bool = true;
+
+        if let Some(ua) = user {
+            hide_nsfw = !ua.settings.show_nsfw;
+        }
+
+        // ...
         let res = query_rows!(
             &conn,
-            "SELECT * FROM posts WHERE community = $1 AND replying_to = 0 AND NOT context LIKE '%\"is_pinned\":true%' AND is_deleted = 0 ORDER BY created DESC LIMIT $2 OFFSET $3",
+            &format!(
+                "SELECT * FROM posts WHERE community = $1 AND replying_to = 0 AND NOT context LIKE '%\"is_pinned\":true%' AND is_deleted = 0 {} ORDER BY created DESC LIMIT $2 OFFSET $3",
+                if hide_nsfw {
+                    "AND NOT (context::json->>'is_nsfw')::boolean"
+                } else {
+                    ""
+                }
+            ),
             &[&(id as i64), &(batch as i64), &((page * batch) as i64)],
             |x| { Self::get_post_from_row(x) }
         );

From d1a074eaeb28d75f349e5299258e312187274162 Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Sun, 22 Jun 2025 03:02:44 -0400
Subject: [PATCH 40/71] add: increase IPV6_PREFIX_BYTES (8 -> 16)

---
 crates/core/src/database/questions.rs | 5 +----
 crates/core/src/model/addr.rs         | 2 +-
 2 files changed, 2 insertions(+), 5 deletions(-)

diff --git a/crates/core/src/database/questions.rs b/crates/core/src/database/questions.rs
index 8cfd09c..a6ca60a 100644
--- a/crates/core/src/database/questions.rs
+++ b/crates/core/src/database/questions.rs
@@ -11,10 +11,7 @@ use crate::model::{
     permissions::FinePermission,
 };
 use crate::{auto_method, DataManager};
-
-use oiseau::PostgresRow;
-
-use oiseau::{execute, get, query_rows, params};
+use oiseau::{execute, get, query_rows, params, PostgresRow};
 
 impl DataManager {
     /// Get a [`Question`] from an SQL row.
diff --git a/crates/core/src/model/addr.rs b/crates/core/src/model/addr.rs
index 42e6721..61174fb 100644
--- a/crates/core/src/model/addr.rs
+++ b/crates/core/src/model/addr.rs
@@ -1,7 +1,7 @@
 use std::net::SocketAddr;
 
 /// How many bytes should be taken as the prefix (from the begining of the address).
-pub(crate) const IPV6_PREFIX_BYTES: usize = 8;
+pub(crate) const IPV6_PREFIX_BYTES: usize = 16;
 
 /// The protocol of a [`RemoteAddr`].
 #[derive(Clone, Debug, PartialEq, Eq)]

From 626c6711efb53ff6fb070ac700362e30fe5abfa7 Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Sun, 22 Jun 2025 13:03:02 -0400
Subject: [PATCH 41/71] add: invite codes

---
 crates/app/src/langs/en-US.toml               |   2 +
 crates/app/src/public/html/auth/register.lisp |  17 +-
 .../app/src/public/html/profile/settings.lisp |  88 +++++++++-
 crates/app/src/routes/api/v1/auth/mod.rs      |  46 ++++-
 crates/app/src/routes/api/v1/auth/profile.rs  |  32 +++-
 crates/app/src/routes/api/v1/mod.rs           |   3 +
 crates/app/src/routes/pages/profile.rs        |  13 ++
 crates/core/src/config.rs                     |   9 +
 crates/core/src/database/auth.rs              |   6 +-
 crates/core/src/database/common.rs            |   4 +-
 crates/core/src/database/drivers/common.rs    |   1 +
 .../drivers/sql/create_invite_codes.sql       |   7 +
 crates/core/src/database/invite_codes.rs      | 159 ++++++++++++++++++
 crates/core/src/database/mod.rs               |   1 +
 crates/core/src/model/addr.rs                 |   2 +-
 crates/core/src/model/auth.rs                 |  26 +++
 crates/core/src/model/permissions.rs          |   1 +
 example/tetratto.toml                         |   1 +
 sql_changes/users_invite_code.sql             |   2 +
 19 files changed, 410 insertions(+), 10 deletions(-)
 create mode 100644 crates/core/src/database/drivers/sql/create_invite_codes.sql
 create mode 100644 crates/core/src/database/invite_codes.rs
 create mode 100644 sql_changes/users_invite_code.sql

diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml
index 5bd2fc8..27c4b8f 100644
--- a/crates/app/src/langs/en-US.toml
+++ b/crates/app/src/langs/en-US.toml
@@ -168,10 +168,12 @@ version = "1.0.0"
 "settings:label.export" = "Export"
 "settings:label.manage_blocks" = "Manage blocks"
 "settings:label.users" = "Users"
+"settings:label.generate_invite" = "Generate invite"
 "settings:tab.security" = "Security"
 "settings:tab.blocks" = "Blocks"
 "settings:tab.billing" = "Billing"
 "settings:tab.uploads" = "Uploads"
+"settings:tab.invites" = "Invites"
 
 "mod_panel:label.open_reported_content" = "Open reported content"
 "mod_panel:label.manage_profile" = "Manage profile"
diff --git a/crates/app/src/public/html/auth/register.lisp b/crates/app/src/public/html/auth/register.lisp
index 116cdcf..739e538 100644
--- a/crates/app/src/public/html/auth/register.lisp
+++ b/crates/app/src/public/html/auth/register.lisp
@@ -25,7 +25,7 @@
     (div
         ("class" "flex flex-col gap-1")
         (label
-            ("for" "username")
+            ("for" "password")
             (b
                 (text "Password")))
         (input
@@ -34,6 +34,20 @@
             ("required" "")
             ("name" "password")
             ("id" "password")))
+    (text "{% if config.security.enable_invite_codes -%}")
+    (div
+        ("class" "flex flex-col gap-1")
+        (label
+            ("for" "invite_code")
+            (b
+                (text "Invite code")))
+        (input
+            ("type" "text")
+            ("placeholder" "invite code")
+            ("required" "")
+            ("name" "invite_code")
+            ("id" "invite_code")))
+    (text "{%- endif %}")
     (hr)
     (div
         ("class" "card-nest w-full")
@@ -89,6 +103,7 @@
                 captcha_response: e.target.querySelector(
                     \"[name=cf-turnstile-response]\",
                 ).value,
+                invite_code: (e.target.invite_code || { value: \"\" }).value,
             }),
         })
             .then((res) => res.json())
diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp
index 50c0c60..3c76c1d 100644
--- a/crates/app/src/public/html/profile/settings.lisp
+++ b/crates/app/src/public/html/profile/settings.lisp
@@ -61,21 +61,35 @@
                     ("href" "#/account/blocks")
                     (text "{{ icon \"shield\" }}")
                     (span
-                        (text "{{ text \"settings:tab.blocks\" }}")))
+                        (text "{{ text \"settings:tab.blocks\" }}"))))
+
+            (text "{% if config.stripe -%}")
+            ; stripe menu
+            (div
+                ("class" "pillmenu")
+                ("ui_ident" "account_settings_tabs")
                 (a
                     ("data-tab-button" "account/uploads")
                     ("href" "?page=0#/account/uploads")
                     (text "{{ icon \"image-up\" }}")
                     (span
                         (text "{{ text \"settings:tab.uploads\" }}")))
-                (text "{% if config.stripe -%}")
+                (text "{% if config.security.enable_invite_codes -%}")
+                (a
+                    ("data-tab-button" "account/invites")
+                    ("href" "#/account/invites")
+                    (text "{{ icon \"ticket\" }}")
+                    (span
+                        (text "{{ text \"settings:tab.invites\" }}")))
+                (text "{%- endif %}")
                 (a
                     ("data-tab-button" "account/billing")
                     ("href" "#/account/billing")
                     (text "{{ icon \"credit-card\" }}")
                     (span
-                        (text "{{ text \"settings:tab.billing\" }}")))
-                (text "{%- endif %}"))
+                        (text "{{ text \"settings:tab.billing\" }}"))))
+            (text "{%- endif %}")
+
             (div
                 ("class" "card-nest")
                 ("ui_ident" "home_timeline")
@@ -495,6 +509,72 @@
                                     ]);
                                 });
                         };"))))))
+
+    (text "{% if config.security.enable_invite_codes -%}")
+    (div
+        ("class" "w-full flex flex-col gap-2 hidden")
+        ("data-tab" "account/invites")
+        (div
+            ("class" "card lowered flex flex-col gap-2")
+            (a
+                ("href" "#/account")
+                ("class" "button secondary")
+                (text "{{ icon \"arrow-left\" }}")
+                (span
+                    (text "{{ text \"general:action.back\" }}")))
+            (div
+                ("class" "card-nest")
+                (div
+                    ("class" "card flex items-center gap-2 small")
+                    (text "{{ icon \"ticket\" }}")
+                    (span
+                        (text "{{ text \"settings:tab.invites\" }}")))
+                (div
+                    ("class" "card flex flex-col gap-2 secondary")
+                    (button
+                        ("onclick" "generate_invite_code()")
+                        (icon (text "plus"))
+                        (str (text "settings:label.generate_invite")))
+
+                    (text "{{ components::supporter_ad(body=\"Become a supporter to generate invite codes!\") }} {% for code in invites %}")
+                    (div
+                        ("class" "card flex flex-col gap-2")
+                        (text "{% if code[1].is_used -%}")
+                        ; used
+                        (b ("class" "{% if code[1].is_used -%} fade {%- endif %}") (s (text "{{ code[1].code }}")))
+                        (text "{{ components::full_username(user=code[0]) }}")
+                        (text "{% else %}")
+                        ; unused
+                        (b (text "{{ code[1].code }}"))
+                        (text "{%- endif %}"))
+                    (text "{% endfor %}")
+                    (script
+                        (text "globalThis.generate_invite_code = async () => {
+                            if (
+                                !(await trigger(\"atto::confirm\", [
+                                    \"Are you sure you would like to do this? This action is permanent.\",
+                                ]))
+                            ) {
+                                return;
+                            }
+
+                            fetch(`/api/v1/invite`, {
+                                method: \"POST\",
+                            })
+                                .then((res) => res.json())
+                                .then((res) => {
+                                    trigger(\"atto::toast\", [
+                                        res.ok ? \"success\" : \"error\",
+                                        res.message,
+                                    ]);
+
+                                    if (res.ok) {
+                                        alert(res.payload);
+                                    }
+                                });
+                        };"))))))
+    (text "{%- endif %}")
+
     (div
         ("class" "w-full flex flex-col gap-2 hidden")
         ("data-tab" "account/billing")
diff --git a/crates/app/src/routes/api/v1/auth/mod.rs b/crates/app/src/routes/api/v1/auth/mod.rs
index f8e5b83..f5d17f1 100644
--- a/crates/app/src/routes/api/v1/auth/mod.rs
+++ b/crates/app/src/routes/api/v1/auth/mod.rs
@@ -18,7 +18,7 @@ use axum::{
 };
 use axum_extra::extract::CookieJar;
 use serde::Deserialize;
-use tetratto_core::model::addr::RemoteAddr;
+use tetratto_core::model::{addr::RemoteAddr, permissions::FinePermission};
 use tetratto_shared::hash::hash;
 
 use cf_turnstile::{SiteVerifyRequest, TurnstileClient};
@@ -86,6 +86,50 @@ pub async fn register_request(
     let mut user = User::new(props.username.to_lowercase(), props.password);
     user.settings.policy_consent = true;
 
+    // check invite code
+    if data.0.0.security.enable_invite_codes {
+        if props.invite_code.is_empty() {
+            return (
+                None,
+                Json(Error::MiscError("Missing invite code".to_string()).into()),
+            );
+        }
+
+        let invite_code = match data.get_invite_code_by_code(&props.invite_code).await {
+            Ok(c) => c,
+            Err(e) => return (None, Json(e.into())),
+        };
+
+        if invite_code.is_used {
+            return (
+                None,
+                Json(Error::MiscError("This code has already been used".to_string()).into()),
+            );
+        }
+
+        let owner = match data.get_user_by_id(invite_code.owner).await {
+            Ok(u) => u,
+            Err(e) => return (None, Json(e.into())),
+        };
+
+        if !owner.permissions.check(FinePermission::SUPPORTER) {
+            return (
+                None,
+                Json(
+                    Error::MiscError("Invite code owner must be an active supporter".to_string())
+                        .into(),
+                ),
+            );
+        }
+
+        user.invite_code = invite_code.id;
+
+        if let Err(e) = data.update_invite_code_is_used(invite_code.id, true).await {
+            return (None, Json(e.into()));
+        }
+    }
+
+    // push initial token
     let (initial_token, t) = User::create_token(&real_ip);
     user.tokens.push(t);
 
diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs
index 05eefb4..834f317 100644
--- a/crates/app/src/routes/api/v1/auth/profile.rs
+++ b/crates/app/src/routes/api/v1/auth/profile.rs
@@ -21,7 +21,7 @@ use futures_util::{sink::SinkExt, stream::StreamExt};
 use tetratto_core::{
     cache::Cache,
     model::{
-        auth::{Token, UserSettings},
+        auth::{InviteCode, Token, UserSettings},
         oauth,
         permissions::FinePermission,
         socket::{PacketType, SocketMessage, SocketMethod},
@@ -817,3 +817,33 @@ pub async fn refresh_grant_request(
         Err(e) => Json(e.into()),
     }
 }
+
+/// Generate an invite code.
+///
+/// Does not support third-party grants.
+pub async fn generate_invite_code_request(
+    jar: CookieJar,
+    Extension(data): Extension<State>,
+) -> impl IntoResponse {
+    let data = &(data.read().await).0;
+    let user = match get_user_from_token!(jar, data) {
+        Some(ua) => ua,
+        None => return Json(Error::NotAllowed.into()),
+    };
+
+    if !data.0.0.security.enable_invite_codes {
+        return Json(Error::NotAllowed.into());
+    }
+
+    match data
+        .create_invite_code(InviteCode::new(user.id), &user)
+        .await
+    {
+        Ok(x) => Json(ApiReturn {
+            ok: true,
+            message: "Code generated".to_string(),
+            payload: Some(x.code),
+        }),
+        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 44467ba..74571af 100644
--- a/crates/app/src/routes/api/v1/mod.rs
+++ b/crates/app/src/routes/api/v1/mod.rs
@@ -37,6 +37,7 @@ pub fn routes() -> Router {
         .route("/util/proxy", get(util::proxy_request))
         .route("/util/lang", get(util::set_langfile_request))
         .route("/util/ip", get(util::ip_test_request))
+        .route("/invite", post(auth::profile::generate_invite_code_request))
         // reactions
         .route("/reactions", post(reactions::create_request))
         .route("/reactions/{id}", get(reactions::get_request))
@@ -605,6 +606,8 @@ pub struct RegisterProps {
     pub password: String,
     pub policy_consent: bool,
     pub captcha_response: String,
+    #[serde(default)]
+    pub invite_code: String,
 }
 
 #[derive(Deserialize)]
diff --git a/crates/app/src/routes/pages/profile.rs b/crates/app/src/routes/pages/profile.rs
index 89a4026..78b2d22 100644
--- a/crates/app/src/routes/pages/profile.rs
+++ b/crates/app/src/routes/pages/profile.rs
@@ -101,6 +101,18 @@ pub async fn settings_request(
         }
     };
 
+    let invites = match data.0.get_invite_codes_by_owner(profile.id).await {
+        Ok(l) => match data.0.fill_invite_codes(l).await {
+            Ok(l) => l,
+            Err(e) => {
+                return Err(Html(render_error(e, &jar, &data, &None).await));
+            }
+        },
+        Err(e) => {
+            return Err(Html(render_error(e, &jar, &data, &None).await));
+        }
+    };
+
     let tokens = profile.tokens.clone();
 
     let lang = get_lang!(jar, data.0);
@@ -113,6 +125,7 @@ pub async fn settings_request(
     context.insert("following", &following);
     context.insert("blocks", &blocks);
     context.insert("stackblocks", &stackblocks);
+    context.insert("invites", &invites);
     context.insert(
         "user_tokens_serde",
         &serde_json::to_string(&tokens)
diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs
index 7e2713b..810f989 100644
--- a/crates/core/src/config.rs
+++ b/crates/core/src/config.rs
@@ -13,6 +13,9 @@ pub struct SecurityConfig {
     /// The name of the header which will contain the real IP of the connecting user.
     #[serde(default = "default_real_ip_header")]
     pub real_ip_header: String,
+    /// If users require an invite code to register. Invite codes can be generated by supporters.
+    #[serde(default = "default_enable_invite_codes")]
+    pub enable_invite_codes: bool,
 }
 
 fn default_security_registration_enabled() -> bool {
@@ -23,11 +26,16 @@ fn default_real_ip_header() -> String {
     "CF-Connecting-IP".to_string()
 }
 
+fn default_enable_invite_codes() -> bool {
+    false
+}
+
 impl Default for SecurityConfig {
     fn default() -> Self {
         Self {
             registration_enabled: default_security_registration_enabled(),
             real_ip_header: default_real_ip_header(),
+            enable_invite_codes: default_enable_invite_codes(),
         }
     }
 }
@@ -341,6 +349,7 @@ fn default_banned_usernames() -> Vec<String> {
         "stacks".to_string(),
         "stack".to_string(),
         "search".to_string(),
+        "journals".to_string(),
     ]
 }
 
diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs
index 3c22a3e..927b7fb 100644
--- a/crates/core/src/database/auth.rs
+++ b/crates/core/src/database/auth.rs
@@ -45,6 +45,7 @@ impl DataManager {
             stripe_id: get!(x->18(String)),
             grants: serde_json::from_str(&get!(x->19(String)).to_string()).unwrap(),
             associated: serde_json::from_str(&get!(x->20(String)).to_string()).unwrap(),
+            invite_code: get!(x->21(i64)) as usize,
         }
     }
 
@@ -200,7 +201,7 @@ impl DataManager {
 
         let res = execute!(
             &conn,
-            "INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)",
+            "INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)",
             params![
                 &(data.id as i64),
                 &(data.created as i64),
@@ -223,6 +224,7 @@ impl DataManager {
                 &"",
                 &serde_json::to_string(&data.grants).unwrap(),
                 &serde_json::to_string(&data.associated).unwrap(),
+                &(data.invite_code as i64)
             ]
         );
 
@@ -842,4 +844,6 @@ impl DataManager {
     auto_method!(update_user_request_count(i32)@get_user_by_id -> "UPDATE users SET request_count = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user);
     auto_method!(incr_user_request_count()@get_user_by_id -> "UPDATE users SET request_count = request_count + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_user --incr);
     auto_method!(decr_user_request_count()@get_user_by_id -> "UPDATE users SET request_count = request_count - 1 WHERE id = $1" --cache-key-tmpl=cache_clear_user --decr=request_count);
+
+    auto_method!(get_user_by_invite_code(i64)@get_user_from_row -> "SELECT * FROM users WHERE invite_code = $1" --name="user" --returns=User);
 }
diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs
index 0841e15..a82e389 100644
--- a/crates/core/src/database/common.rs
+++ b/crates/core/src/database/common.rs
@@ -39,6 +39,7 @@ impl DataManager {
         execute!(&conn, common::CREATE_TABLE_JOURNALS).unwrap();
         execute!(&conn, common::CREATE_TABLE_NOTES).unwrap();
         execute!(&conn, common::CREATE_TABLE_MESSAGE_REACTIONS).unwrap();
+        execute!(&conn, common::CREATE_TABLE_INVITE_CODES).unwrap();
 
         self.0
             .1
@@ -115,7 +116,8 @@ macro_rules! auto_method {
                 Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
             };
 
-            let res = query_row!(&conn, $query, &[&selector], |x| { Ok(Self::$select_fn(x)) });
+            let res =
+                oiseau::query_row!(&conn, $query, &[&selector], |x| { Ok(Self::$select_fn(x)) });
 
             if res.is_err() {
                 return Err(Error::GeneralNotFound($name_.to_string()));
diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs
index 6b7902e..e1cfad7 100644
--- a/crates/core/src/database/drivers/common.rs
+++ b/crates/core/src/database/drivers/common.rs
@@ -26,3 +26,4 @@ pub const CREATE_TABLE_STACKBLOCKS: &str = include_str!("./sql/create_stackblock
 pub const CREATE_TABLE_JOURNALS: &str = include_str!("./sql/create_journals.sql");
 pub const CREATE_TABLE_NOTES: &str = include_str!("./sql/create_notes.sql");
 pub const CREATE_TABLE_MESSAGE_REACTIONS: &str = include_str!("./sql/create_message_reactions.sql");
+pub const CREATE_TABLE_INVITE_CODES: &str = include_str!("./sql/create_invite_codes.sql");
diff --git a/crates/core/src/database/drivers/sql/create_invite_codes.sql b/crates/core/src/database/drivers/sql/create_invite_codes.sql
new file mode 100644
index 0000000..5272745
--- /dev/null
+++ b/crates/core/src/database/drivers/sql/create_invite_codes.sql
@@ -0,0 +1,7 @@
+CREATE TABLE IF NOT EXISTS invite_codes (
+    id BIGINT NOT NULL PRIMARY KEY,
+    created BIGINT NOT NULL,
+    owner BIGINT NOT NULL,
+    code TEXT NOT NULL,
+    is_used INT NOT NULL
+)
diff --git a/crates/core/src/database/invite_codes.rs b/crates/core/src/database/invite_codes.rs
new file mode 100644
index 0000000..672e5a9
--- /dev/null
+++ b/crates/core/src/database/invite_codes.rs
@@ -0,0 +1,159 @@
+use oiseau::{cache::Cache, query_rows};
+use crate::model::{
+    Error, Result,
+    auth::{User, InviteCode},
+    permissions::FinePermission,
+};
+use crate::{auto_method, DataManager};
+use oiseau::{PostgresRow, execute, get, params};
+
+impl DataManager {
+    /// Get a [`InviteCode`] from an SQL row.
+    pub(crate) fn get_invite_code_from_row(x: &PostgresRow) -> InviteCode {
+        InviteCode {
+            id: get!(x->0(i64)) as usize,
+            created: get!(x->1(i64)) as usize,
+            owner: get!(x->2(i64)) as usize,
+            code: get!(x->3(String)),
+            is_used: get!(x->4(i32)) as i8 == 1,
+        }
+    }
+
+    auto_method!(get_invite_code_by_id()@get_invite_code_from_row -> "SELECT * FROM invite_codes WHERE id = $1" --name="invite_code" --returns=InviteCode --cache-key-tmpl="atto.invite_code:{}");
+    auto_method!(get_invite_code_by_code(&str)@get_invite_code_from_row -> "SELECT * FROM invite_codes WHERE code = $1" --name="invite_code" --returns=InviteCode);
+
+    /// Get invite_codes by `owner`.
+    pub async fn get_invite_codes_by_owner(&self, owner: usize) -> Result<Vec<InviteCode>> {
+        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 invite_codes WHERE owner = $1",
+            &[&(owner as i64)],
+            |x| { Self::get_invite_code_from_row(x) }
+        );
+
+        if res.is_err() {
+            return Err(Error::GeneralNotFound("invite_code".to_string()));
+        }
+
+        Ok(res.unwrap())
+    }
+
+    /// Fill a vector of invite codes with the user that used them.
+    pub async fn fill_invite_codes(
+        &self,
+        codes: Vec<InviteCode>,
+    ) -> Result<Vec<(Option<User>, InviteCode)>> {
+        let mut out = Vec::new();
+
+        for code in codes {
+            if code.is_used {
+                out.push((
+                    match self.get_user_by_invite_code(code.id as i64).await {
+                        Ok(u) => Some(u),
+                        Err(_) => None,
+                    },
+                    code,
+                ))
+            } else {
+                out.push((None, code))
+            }
+        }
+
+        Ok(out)
+    }
+
+    const MAXIMUM_SUPPORTER_INVITE_CODES: usize = 15;
+
+    /// Create a new invite_code in the database.
+    ///
+    /// # Arguments
+    /// * `data` - a mock [`InviteCode`] object to insert
+    pub async fn create_invite_code(&self, data: InviteCode, user: &User) -> Result<InviteCode> {
+        if !user.permissions.check(FinePermission::SUPPORTER) {
+            return Err(Error::RequiresSupporter);
+        } else {
+            // check count
+            if self.get_invite_codes_by_owner(user.id).await?.len()
+                >= Self::MAXIMUM_SUPPORTER_INVITE_CODES
+            {
+                return Err(Error::MiscError(
+                    "You already have the maximum number of invite codes you can create"
+                        .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 invite_codes VALUES ($1, $2, $3, $4, $5)",
+            params![
+                &(data.id as i64),
+                &(data.created as i64),
+                &(data.owner as i64),
+                &data.code,
+                &{ if data.is_used { 1 } else { 0 } }
+            ]
+        );
+
+        if let Err(e) = res {
+            return Err(Error::DatabaseError(e.to_string()));
+        }
+
+        Ok(data)
+    }
+
+    pub async fn delete_invite_code(&self, id: usize, user: &User) -> Result<()> {
+        if !user.permissions.check(FinePermission::MANAGE_INVITES) {
+            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 invite_codes WHERE id = $1",
+            &[&(id as i64)]
+        );
+
+        if let Err(e) = res {
+            return Err(Error::DatabaseError(e.to_string()));
+        }
+
+        self.0.1.remove(format!("atto.invite_code:{}", id)).await;
+
+        Ok(())
+    }
+
+    pub async fn update_invite_code_is_used(&self, id: usize, new_is_used: bool) -> Result<()> {
+        let conn = match self.0.connect().await {
+            Ok(c) => c,
+            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
+        };
+
+        let res = execute!(
+            &conn,
+            "UPDATE invite_codes SET is_used = $1 WHERE id = $2",
+            params![&{ if new_is_used { 1 } else { 0 } }, &(id as i64)]
+        );
+
+        if let Err(e) = res {
+            return Err(Error::DatabaseError(e.to_string()));
+        }
+
+        self.0.1.remove(format!("atto.invite_code:{}", id)).await;
+        Ok(())
+    }
+}
diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs
index 774b345..5f81259 100644
--- a/crates/core/src/database/mod.rs
+++ b/crates/core/src/database/mod.rs
@@ -8,6 +8,7 @@ pub mod connections;
 mod drafts;
 mod drivers;
 mod emojis;
+mod invite_codes;
 mod ipbans;
 mod ipblocks;
 mod journals;
diff --git a/crates/core/src/model/addr.rs b/crates/core/src/model/addr.rs
index 61174fb..ffca6aa 100644
--- a/crates/core/src/model/addr.rs
+++ b/crates/core/src/model/addr.rs
@@ -1,7 +1,7 @@
 use std::net::SocketAddr;
 
 /// How many bytes should be taken as the prefix (from the begining of the address).
-pub(crate) const IPV6_PREFIX_BYTES: usize = 16;
+pub(crate) const IPV6_PREFIX_BYTES: usize = 11;
 
 /// The protocol of a [`RemoteAddr`].
 #[derive(Clone, Debug, PartialEq, Eq)]
diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs
index ff910f5..f1ed465 100644
--- a/crates/core/src/model/auth.rs
+++ b/crates/core/src/model/auth.rs
@@ -49,6 +49,9 @@ pub struct User {
     /// A list of the IDs of all accounts the user has signed into through the UI.
     #[serde(default)]
     pub associated: Vec<usize>,
+    /// The ID of the [`InviteCode`] this user provided during registration.
+    #[serde(default)]
+    pub invite_code: usize,
 }
 
 pub type UserConnections =
@@ -283,6 +286,7 @@ impl User {
             stripe_id: String::new(),
             grants: Vec::new(),
             associated: Vec::new(),
+            invite_code: 0,
         }
     }
 
@@ -591,3 +595,25 @@ impl UserWarning {
         }
     }
 }
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct InviteCode {
+    pub id: usize,
+    pub created: usize,
+    pub owner: usize,
+    pub code: String,
+    pub is_used: bool,
+}
+
+impl InviteCode {
+    /// Create a new [`InviteCode`].
+    pub fn new(owner: usize) -> Self {
+        Self {
+            id: Snowflake::new().to_string().parse::<usize>().unwrap(),
+            created: unix_epoch_timestamp(),
+            owner,
+            code: salt(),
+            is_used: false,
+        }
+    }
+}
diff --git a/crates/core/src/model/permissions.rs b/crates/core/src/model/permissions.rs
index 9cd6dcb..97c0c3f 100644
--- a/crates/core/src/model/permissions.rs
+++ b/crates/core/src/model/permissions.rs
@@ -39,6 +39,7 @@ bitflags! {
         const MANAGE_APPS = 1 << 28;
         const MANAGE_JOURNALS = 1 << 29;
         const MANAGE_NOTES = 1 << 30;
+        const MANAGE_INVITES = 1 << 31;
 
         const _ = !0;
     }
diff --git a/example/tetratto.toml b/example/tetratto.toml
index 37119a4..488bc89 100644
--- a/example/tetratto.toml
+++ b/example/tetratto.toml
@@ -23,6 +23,7 @@ html_footer_path = "public/footer.html"
 [security]
 registration_enabled = true
 real_ip_header = "CF-Connecting-IP"
+enable_invite_codes = false
 
 [dirs]
 templates = "html"
diff --git a/sql_changes/users_invite_code.sql b/sql_changes/users_invite_code.sql
new file mode 100644
index 0000000..2e97a8d
--- /dev/null
+++ b/sql_changes/users_invite_code.sql
@@ -0,0 +1,2 @@
+ALTER TABLE users
+ADD COLUMN invite_code BIGINT NOT NULL DEFAULT 0;

From 2f83497f98f3d7ec96fba8401c1426bbca408bc9 Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Sun, 22 Jun 2025 13:50:12 -0400
Subject: [PATCH 42/71] add: allow free users to create 2 invites

---
 .../app/src/public/html/profile/settings.lisp |  9 ++++--
 crates/app/src/routes/api/v1/auth/mod.rs      | 28 +++++++++----------
 crates/core/src/database/auth.rs              | 21 ++++++++++++++
 crates/core/src/database/invite_codes.rs      | 27 ++++++++++++++++--
 4 files changed, 66 insertions(+), 19 deletions(-)

diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp
index 3c76c1d..598b0ec 100644
--- a/crates/app/src/public/html/profile/settings.lisp
+++ b/crates/app/src/public/html/profile/settings.lisp
@@ -536,7 +536,7 @@
                         (icon (text "plus"))
                         (str (text "settings:label.generate_invite")))
 
-                    (text "{{ components::supporter_ad(body=\"Become a supporter to generate invite codes!\") }} {% for code in invites %}")
+                    (text "{{ components::supporter_ad(body=\"Become a supporter to generate up to 24 invite codes! You can currently have 2 maximum.\") }} {% for code in invites %}")
                     (div
                         ("class" "card flex flex-col gap-2")
                         (text "{% if code[1].is_used -%}")
@@ -669,7 +669,12 @@
                                 (li
                                     (text "Create infinite journals"))
                                 (li
-                                    (text "Create infinite notes in each journal")))
+                                    (text "Create infinite notes in each journal"))
+
+                                (text "{% if config.security.enable_invite_codes -%}")
+                                (li
+                                    (text "Create up to 24 invite codes"))
+                                (text "{%- endif %}"))
                             (a
                                 ("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}")
                                 ("class" "button")
diff --git a/crates/app/src/routes/api/v1/auth/mod.rs b/crates/app/src/routes/api/v1/auth/mod.rs
index f5d17f1..cff7d20 100644
--- a/crates/app/src/routes/api/v1/auth/mod.rs
+++ b/crates/app/src/routes/api/v1/auth/mod.rs
@@ -18,7 +18,7 @@ use axum::{
 };
 use axum_extra::extract::CookieJar;
 use serde::Deserialize;
-use tetratto_core::model::{addr::RemoteAddr, permissions::FinePermission};
+use tetratto_core::model::addr::RemoteAddr;
 use tetratto_shared::hash::hash;
 
 use cf_turnstile::{SiteVerifyRequest, TurnstileClient};
@@ -107,20 +107,20 @@ pub async fn register_request(
             );
         }
 
-        let owner = match data.get_user_by_id(invite_code.owner).await {
-            Ok(u) => u,
-            Err(e) => return (None, Json(e.into())),
-        };
+        // let owner = match data.get_user_by_id(invite_code.owner).await {
+        //     Ok(u) => u,
+        //     Err(e) => return (None, Json(e.into())),
+        // };
 
-        if !owner.permissions.check(FinePermission::SUPPORTER) {
-            return (
-                None,
-                Json(
-                    Error::MiscError("Invite code owner must be an active supporter".to_string())
-                        .into(),
-                ),
-            );
-        }
+        // if !owner.permissions.check(FinePermission::SUPPORTER) {
+        //     return (
+        //         None,
+        //         Json(
+        //             Error::MiscError("Invite code owner must be an active supporter".to_string())
+        //                 .into(),
+        //         ),
+        //     );
+        // }
 
         user.invite_code = invite_code.id;
 
diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs
index 927b7fb..49f2713 100644
--- a/crates/core/src/database/auth.rs
+++ b/crates/core/src/database/auth.rs
@@ -420,6 +420,17 @@ impl DataManager {
             return Err(Error::DatabaseError(e.to_string()));
         }
 
+        // delete invite codes
+        let res = execute!(
+            &conn,
+            "DELETE FROM invite_codes WHERE owner = $1",
+            &[&(id as i64)]
+        );
+
+        if let Err(e) = res {
+            return Err(Error::DatabaseError(e.to_string()));
+        }
+
         // delete message reactions
         let res = execute!(
             &conn,
@@ -479,6 +490,16 @@ impl DataManager {
             self.delete_poll(poll.id, &user).await?;
         }
 
+        // free up invite code
+        if self.0.0.security.enable_invite_codes {
+            if user.invite_code != 0 && self.get_invite_code_by_id(user.invite_code).await.is_ok() {
+                // we're checking if the code is ok because the owner might've deleted their account,
+                // deleting all of their invite codes as well
+                self.update_invite_code_is_used(user.invite_code, false)
+                    .await?;
+            }
+        }
+
         // ...
         Ok(())
     }
diff --git a/crates/core/src/database/invite_codes.rs b/crates/core/src/database/invite_codes.rs
index 672e5a9..386149a 100644
--- a/crates/core/src/database/invite_codes.rs
+++ b/crates/core/src/database/invite_codes.rs
@@ -1,4 +1,5 @@
 use oiseau::{cache::Cache, query_rows};
+use tetratto_shared::unix_epoch_timestamp;
 use crate::model::{
     Error, Result,
     auth::{User, InviteCode},
@@ -67,7 +68,9 @@ impl DataManager {
         Ok(out)
     }
 
-    const MAXIMUM_SUPPORTER_INVITE_CODES: usize = 15;
+    const MAXIMUM_FREE_INVITE_CODES: usize = 2;
+    const MAXIMUM_SUPPORTER_INVITE_CODES: usize = 24;
+    const MINIMUM_ACCOUNT_AGE_FOR_FREE_INVITE_CODES: usize = 2_629_800_000; // 1mo
 
     /// Create a new invite_code in the database.
     ///
@@ -75,8 +78,26 @@ impl DataManager {
     /// * `data` - a mock [`InviteCode`] object to insert
     pub async fn create_invite_code(&self, data: InviteCode, user: &User) -> Result<InviteCode> {
         if !user.permissions.check(FinePermission::SUPPORTER) {
-            return Err(Error::RequiresSupporter);
-        } else {
+            // check account creation date
+            if unix_epoch_timestamp() - user.created
+                < Self::MINIMUM_ACCOUNT_AGE_FOR_FREE_INVITE_CODES
+            {
+                return Err(Error::MiscError(
+                    "Your account is too young to do this".to_string(),
+                ));
+            }
+
+            // our account is old enough, but we need to make sure we don't already have
+            // 2 invite codes
+            if self.get_invite_codes_by_owner(user.id).await?.len()
+                >= Self::MAXIMUM_FREE_INVITE_CODES
+            {
+                return Err(Error::MiscError(
+                    "You already have the maximum number of invite codes you can create"
+                        .to_string(),
+                ));
+            }
+        } else if !user.permissions.check(FinePermission::MANAGE_INVITES) {
             // check count
             if self.get_invite_codes_by_owner(user.id).await?.len()
                 >= Self::MAXIMUM_SUPPORTER_INVITE_CODES

From efd4ac8104bbb17390b245b52b24f9ae0bc195df Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Sun, 22 Jun 2025 14:11:15 -0400
Subject: [PATCH 43/71] fix: spotify connection

---
 crates/app/src/public/html/auth/connection.lisp | 4 +++-
 crates/app/src/public/html/profile/base.lisp    | 2 ++
 2 files changed, 5 insertions(+), 1 deletion(-)

diff --git a/crates/app/src/public/html/auth/connection.lisp b/crates/app/src/public/html/auth/connection.lisp
index 905b215..8c4fcef 100644
--- a/crates/app/src/public/html/auth/connection.lisp
+++ b/crates/app/src/public/html/auth/connection.lisp
@@ -12,6 +12,7 @@
 
 (text "{% if connection_type == \"Spotify\" and user and user.connections.Spotify and config.connections.spotify_client_id %}")
 (script
+    ("defer" "true")
     (text "setTimeout(async () => {
         const code = new URLSearchParams(window.location.search).get(\"code\");
         const client_id = \"{{ config.connections.spotify_client_id }}\";
@@ -46,10 +47,11 @@
         setTimeout(() => {
             window.location.href = \"/settings#/connections\";
         }, 500);
-    }, 150);"))
+    }, 1000);"))
 
 (text "{% elif connection_type == \"LastFm\" and user and user.connections.LastFm and config.connections.last_fm_key %}")
 (script
+    ("defer" "true")
     (text "setTimeout(async () => {
         const token = new URLSearchParams(window.location.search).get(\"token\");
         const api_key = \"{{ config.connections.last_fm_key }}\";
diff --git a/crates/app/src/public/html/profile/base.lisp b/crates/app/src/public/html/profile/base.lisp
index 5e846ff..7718eea 100644
--- a/crates/app/src/public/html/profile/base.lisp
+++ b/crates/app/src/public/html/profile/base.lisp
@@ -132,9 +132,11 @@
                         (text "{{ profile.settings.biography|markdown|safe }}"))
                     (div
                         ("class" "card flex flex-col gap-2")
+                        (text "{% if user -%}")
                         (div
                             ("style" "display: contents;")
                             (text "{% if profile.connections.Spotify and profile.connections.Spotify[0].data.name -%} {{ components::spotify_playing(state=profile.connections.Spotify[1]) }} {% elif profile.connections.LastFm and profile.connections.LastFm[0].data.name %} {{ components::last_fm_playing(state=profile.connections.LastFm[1]) }} {%- endif %}"))
+                        (text "{%- endif %}")
                         (div
                             ("class" "w-full flex justify-between items-center")
                             (span

From 38ddf6cde191f83776b71ff1f24b33d42736621a Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Sun, 22 Jun 2025 14:21:38 -0400
Subject: [PATCH 44/71] fix: spotify state push

---
 crates/app/src/public/html/body.lisp | 11 +++++++++++
 1 file changed, 11 insertions(+)

diff --git a/crates/app/src/public/html/body.lisp b/crates/app/src/public/html/body.lisp
index a8e398b..2551635 100644
--- a/crates/app/src/public/html/body.lisp
+++ b/crates/app/src/public/html/body.lisp
@@ -286,6 +286,17 @@
             return;
         }
 
+        while (!ns(\"spotify\")) {
+            console.log(\"still no spotify\");
+            await (() => {
+                return new Promise((resolve) => {
+                    setTimeout(() => {
+                        resolve();
+                    }, 500);
+                });
+            })();
+        }
+
         window.spotify_init = true;
         const client_id = \"{{ config.connections.spotify_client_id }}\";
         let token = \"{{ user.connections.Spotify[0].data.token }}\";

From dc74c5d63c04f2f7b9e4b39344b8b22e07d78001 Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Sun, 22 Jun 2025 15:06:21 -0400
Subject: [PATCH 45/71] add: increase invite code limits

---
 crates/app/src/public/html/mod/profile.lisp      | 3 +++
 crates/app/src/public/html/profile/settings.lisp | 4 ++--
 crates/core/src/database/invite_codes.rs         | 4 ++--
 3 files changed, 7 insertions(+), 4 deletions(-)

diff --git a/crates/app/src/public/html/mod/profile.lisp b/crates/app/src/public/html/mod/profile.lisp
index 65813b2..b036e23 100644
--- a/crates/app/src/public/html/mod/profile.lisp
+++ b/crates/app/src/public/html/mod/profile.lisp
@@ -256,6 +256,9 @@
                         MANAGE_STACKS: 1 << 26,
                         STAFF_BADGE: 1 << 27,
                         MANAGE_APPS: 1 << 28,
+                        MANAGE_JOURNALS: 1 << 29,
+                        MANAGE_NOTES: 1 << 30,
+                        MANAGE_INVITES: 1 << 31,
                     },
                 ],
             );
diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp
index 598b0ec..6bba68e 100644
--- a/crates/app/src/public/html/profile/settings.lisp
+++ b/crates/app/src/public/html/profile/settings.lisp
@@ -536,7 +536,7 @@
                         (icon (text "plus"))
                         (str (text "settings:label.generate_invite")))
 
-                    (text "{{ components::supporter_ad(body=\"Become a supporter to generate up to 24 invite codes! You can currently have 2 maximum.\") }} {% for code in invites %}")
+                    (text "{{ components::supporter_ad(body=\"Become a supporter to generate up to 48 invite codes! You can currently have 2 maximum.\") }} {% for code in invites %}")
                     (div
                         ("class" "card flex flex-col gap-2")
                         (text "{% if code[1].is_used -%}")
@@ -673,7 +673,7 @@
 
                                 (text "{% if config.security.enable_invite_codes -%}")
                                 (li
-                                    (text "Create up to 24 invite codes"))
+                                    (text "Create up to 48 invite codes"))
                                 (text "{%- endif %}"))
                             (a
                                 ("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}")
diff --git a/crates/core/src/database/invite_codes.rs b/crates/core/src/database/invite_codes.rs
index 386149a..18e4aec 100644
--- a/crates/core/src/database/invite_codes.rs
+++ b/crates/core/src/database/invite_codes.rs
@@ -68,8 +68,8 @@ impl DataManager {
         Ok(out)
     }
 
-    const MAXIMUM_FREE_INVITE_CODES: usize = 2;
-    const MAXIMUM_SUPPORTER_INVITE_CODES: usize = 24;
+    const MAXIMUM_FREE_INVITE_CODES: usize = 4;
+    const MAXIMUM_SUPPORTER_INVITE_CODES: usize = 48;
     const MINIMUM_ACCOUNT_AGE_FOR_FREE_INVITE_CODES: usize = 2_629_800_000; // 1mo
 
     /// Create a new invite_code in the database.

From 69fc3ca490ee43c429c3a50fe964fa4b344111d5 Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Sun, 22 Jun 2025 15:15:39 -0400
Subject: [PATCH 46/71] fix: remove MANAGE_INVITES (overflow)

---
 crates/app/src/public/html/mod/profile.lisp | 1 -
 crates/core/src/database/invite_codes.rs    | 6 +++---
 crates/core/src/model/permissions.rs        | 1 -
 3 files changed, 3 insertions(+), 5 deletions(-)

diff --git a/crates/app/src/public/html/mod/profile.lisp b/crates/app/src/public/html/mod/profile.lisp
index b036e23..28ef09d 100644
--- a/crates/app/src/public/html/mod/profile.lisp
+++ b/crates/app/src/public/html/mod/profile.lisp
@@ -258,7 +258,6 @@
                         MANAGE_APPS: 1 << 28,
                         MANAGE_JOURNALS: 1 << 29,
                         MANAGE_NOTES: 1 << 30,
-                        MANAGE_INVITES: 1 << 31,
                     },
                 ],
             );
diff --git a/crates/core/src/database/invite_codes.rs b/crates/core/src/database/invite_codes.rs
index 18e4aec..760b469 100644
--- a/crates/core/src/database/invite_codes.rs
+++ b/crates/core/src/database/invite_codes.rs
@@ -97,8 +97,8 @@ impl DataManager {
                         .to_string(),
                 ));
             }
-        } else if !user.permissions.check(FinePermission::MANAGE_INVITES) {
-            // check count
+        } else if !user.permissions.check(FinePermission::MANAGE_USERS) {
+            // check count since we're also not a moderator with MANAGE_USERS
             if self.get_invite_codes_by_owner(user.id).await?.len()
                 >= Self::MAXIMUM_SUPPORTER_INVITE_CODES
             {
@@ -134,7 +134,7 @@ impl DataManager {
     }
 
     pub async fn delete_invite_code(&self, id: usize, user: &User) -> Result<()> {
-        if !user.permissions.check(FinePermission::MANAGE_INVITES) {
+        if !user.permissions.check(FinePermission::MANAGE_USERS) {
             return Err(Error::NotAllowed);
         }
 
diff --git a/crates/core/src/model/permissions.rs b/crates/core/src/model/permissions.rs
index 97c0c3f..9cd6dcb 100644
--- a/crates/core/src/model/permissions.rs
+++ b/crates/core/src/model/permissions.rs
@@ -39,7 +39,6 @@ bitflags! {
         const MANAGE_APPS = 1 << 28;
         const MANAGE_JOURNALS = 1 << 29;
         const MANAGE_NOTES = 1 << 30;
-        const MANAGE_INVITES = 1 << 31;
 
         const _ = !0;
     }

From aceb51c21c17c032cd57068bde0b77beb81467f2 Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Sun, 22 Jun 2025 18:53:02 -0400
Subject: [PATCH 47/71] add: CACHE_BREAKER env var

---
 README.md                                        | 2 +-
 crates/app/src/assets.rs                         | 9 ++++++++-
 crates/app/src/public/html/body.lisp             | 1 +
 crates/app/src/public/html/macros.lisp           | 2 +-
 crates/app/src/public/html/profile/settings.lisp | 2 +-
 crates/app/src/public/js/me.js                   | 5 +++--
 crates/app/src/routes/api/v1/auth/profile.rs     | 6 +++---
 7 files changed, 18 insertions(+), 9 deletions(-)

diff --git a/README.md b/README.md
index dec9f1e..e1ac999 100644
--- a/README.md
+++ b/README.md
@@ -33,7 +33,7 @@ Tetratto **requires** Cloudflare Turnstile for registrations. Testing keys are l
 
 A `docs` directory will be generated in the same directory that you ran the `tetratto` binary in. **Markdown** files placed here will be served at `/doc/{*file_name}`. For other types of assets, you can place them in the generated `public` directory. This directory serves everything at `/public/{*file_name}`.
 
-You can configure your port through the `port` key of the configuration file. You can also run the server with the `PORT` environment variable, which will override whatever is set in the configuration file.
+You can configure your port through the `port` key of the configuration file. You can also run the server with the `PORT` environment variable, which will override whatever is set in the configuration file. You can also use the `CACHE_BREAKER` environment variable to specify a version number to be used in static asset links in order to break cache entries.
 
 ## Usage (as a user)
 
diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs
index 5533a77..130b4fc 100644
--- a/crates/app/src/assets.rs
+++ b/crates/app/src/assets.rs
@@ -495,6 +495,13 @@ pub(crate) async fn initial_context(
     }
 
     ctx.insert("lang", &lang.data);
-    ctx.insert("random_cache_breaker", &CACHE_BREAKER.clone());
+    ctx.insert(
+        "random_cache_breaker",
+        &if let Ok(c) = std::env::var("CACHE_BREAKER") {
+            c
+        } else {
+            CACHE_BREAKER.clone()
+        },
+    );
     ctx
 }
diff --git a/crates/app/src/public/html/body.lisp b/crates/app/src/public/html/body.lisp
index 2551635..370a608 100644
--- a/crates/app/src/public/html/body.lisp
+++ b/crates/app/src/public/html/body.lisp
@@ -322,6 +322,7 @@
                         \"Spotify\",
                         {
                             token: new_token,
+                            refresh_token: new_refresh_token,
                             expires_in: expires_in.toString(),
                             name: profile.display_name,
                         },
diff --git a/crates/app/src/public/html/macros.lisp b/crates/app/src/public/html/macros.lisp
index 7691ee9..b2e8863 100644
--- a/crates/app/src/public/html/macros.lisp
+++ b/crates/app/src/public/html/macros.lisp
@@ -136,7 +136,7 @@
         ("class" "dropdown")
         ("style" "width: max-content")
         (button
-            ("class" "camo raised small")
+            ("class" "raised small")
             ("onclick" "trigger('atto::hooks::dropdown', [event])")
             ("exclude" "dropdown")
             (icon (text "sliders-horizontal"))
diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp
index 6bba68e..489805c 100644
--- a/crates/app/src/public/html/profile/settings.lisp
+++ b/crates/app/src/public/html/profile/settings.lisp
@@ -20,7 +20,7 @@
             ("class" "dropdown")
             ("style" "width: max-content")
             (button
-                ("class" "camo raised small")
+                ("class" "raised small")
                 ("onclick" "trigger('atto::hooks::dropdown', [event])")
                 ("exclude" "dropdown")
                 (icon (text "sliders-horizontal"))
diff --git a/crates/app/src/public/js/me.js b/crates/app/src/public/js/me.js
index 96ac95f..fc99248 100644
--- a/crates/app/src/public/js/me.js
+++ b/crates/app/src/public/js/me.js
@@ -300,7 +300,7 @@
 
     self.define(
         "repost",
-        (
+        async (
             _,
             id,
             content,
@@ -308,6 +308,7 @@
             do_not_redirect = false,
             is_stack = false,
         ) => {
+            await trigger("atto::debounce", ["posts::create"]);
             return new Promise((resolve, _) => {
                 fetch(`/api/v1/posts/${id}/repost`, {
                     method: "POST",
@@ -892,7 +893,7 @@
                 return;
             }
 
-            const now = new Date().getTime();
+            const now = Date.now();
             const updated = Number.parseInt(updated_) + 8000;
 
             let elapsed_since_update = now - updated;
diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs
index 834f317..de177fa 100644
--- a/crates/app/src/routes/api/v1/auth/profile.rs
+++ b/crates/app/src/routes/api/v1/auth/profile.rs
@@ -30,7 +30,7 @@ use tetratto_core::{
 };
 use tetratto_core::cache::redis::Commands;
 use tetratto_shared::{
-    hash::{self, random_id},
+    hash::{hash, salt, random_id},
     unix_epoch_timestamp,
 };
 
@@ -185,7 +185,7 @@ pub async fn append_associations_request(
 
     // resolve tokens
     for token in req.tokens {
-        let hashed = hash::hash(token);
+        let hashed = hash(token);
         let user_from_token = match data.get_user_by_token(&hashed).await {
             Ok(ua) => ua,
             Err(_) => continue,
@@ -556,7 +556,7 @@ pub async fn subscription_handler(
 
 pub async fn handle_socket(socket: WebSocket, db: DataManager, user_id: String, stream_id: String) {
     let (mut sink, mut stream) = socket.split();
-    let socket_id = tetratto_shared::hash::salt();
+    let socket_id = salt();
     db.0.1
         .incr("atto.active_connections:users".to_string())
         .await;

From 8c969cd56f20f547c202e803293f548ef0cde338 Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Sun, 22 Jun 2025 19:21:30 -0400
Subject: [PATCH 48/71] fix: user delete audit log

---
 crates/app/src/routes/api/v1/auth/profile.rs | 11 +++++++++++
 crates/core/src/database/auth.rs             |  2 +-
 crates/core/src/database/posts.rs            | 17 +++++++++++++++++
 3 files changed, 29 insertions(+), 1 deletion(-)

diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs
index de177fa..419e864 100644
--- a/crates/app/src/routes/api/v1/auth/profile.rs
+++ b/crates/app/src/routes/api/v1/auth/profile.rs
@@ -22,6 +22,7 @@ use tetratto_core::{
     cache::Cache,
     model::{
         auth::{InviteCode, Token, UserSettings},
+        moderation::AuditLogEntry,
         oauth,
         permissions::FinePermission,
         socket::{PacketType, SocketMessage, SocketMethod},
@@ -393,6 +394,16 @@ pub async fn delete_user_request(
 
     if user.id != id && !user.permissions.check(FinePermission::MANAGE_USERS) {
         return Json(Error::NotAllowed.into());
+    } else if user.permissions.check(FinePermission::MANAGE_USERS) {
+        if let Err(e) = data
+            .create_audit_log_entry(AuditLogEntry::new(
+                user.id,
+                format!("invoked `delete_user` with x value `{id}`"),
+            ))
+            .await
+        {
+            return Json(e.into());
+        }
     }
 
     match data
diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs
index 49f2713..7117f69 100644
--- a/crates/core/src/database/auth.rs
+++ b/crates/core/src/database/auth.rs
@@ -394,7 +394,7 @@ impl DataManager {
         // delete stackblocks
         let res = execute!(
             &conn,
-            "DELETE FROM stackblocks WHERE owner = $1",
+            "DELETE FROM stackblocks WHERE initiator = $1",
             &[&(id as i64)]
         );
 
diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs
index 656b30e..ba4bc5e 100644
--- a/crates/core/src/database/posts.rs
+++ b/crates/core/src/database/posts.rs
@@ -343,6 +343,11 @@ impl DataManager {
             let owner = post.owner;
 
             if let Some(ua) = users.get(&owner) {
+                // check if owner requires an account to view their posts (and if we have one)
+                if ua.settings.require_account && user.is_none() {
+                    continue;
+                }
+
                 // stack
                 let (can_view, stack) = self
                     .get_post_stack(
@@ -376,6 +381,10 @@ impl DataManager {
             } else {
                 let ua = self.get_user_by_id(owner).await?;
 
+                if ua.settings.require_account && user.is_none() {
+                    continue;
+                }
+
                 if ua.permissions.check_banned() | ignore_users.contains(&owner)
                     && !ua.permissions.check(FinePermission::MANAGE_POSTS)
                 {
@@ -480,6 +489,10 @@ impl DataManager {
             let community = post.community;
 
             if let Some((ua, community)) = seen_before.get(&(owner, community)) {
+                if ua.settings.require_account && user.is_none() {
+                    continue;
+                }
+
                 // stack
                 let (can_view, stack) = self
                     .get_post_stack(
@@ -514,6 +527,10 @@ impl DataManager {
             } else {
                 let ua = self.get_user_by_id(owner).await?;
 
+                if ua.settings.require_account && user.is_none() {
+                    continue;
+                }
+
                 if ua.permissions.check_banned() | ignore_users.contains(&owner)
                     && !ua.permissions.check(FinePermission::MANAGE_POSTS)
                 {

From 2a77c61bf235689390f8aa530c906d93a8997634 Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Sun, 22 Jun 2025 21:07:35 -0400
Subject: [PATCH 49/71] add: ability to add user to stack through block list ui

---
 crates/app/src/assets.rs                      |  2 +
 crates/app/src/langs/en-US.toml               |  1 +
 crates/app/src/public/html/body.lisp          | 15 +-----
 .../app/src/public/html/profile/settings.lisp | 19 ++++---
 .../app/src/public/html/stacks/add_user.lisp  | 49 +++++++++++++++++++
 crates/app/src/public/html/stacks/manage.lisp |  2 +-
 crates/app/src/public/js/me.js                | 19 +++++++
 crates/app/src/routes/api/v1/stacks.rs        |  4 ++
 crates/app/src/routes/pages/mod.rs            |  1 +
 crates/app/src/routes/pages/stacks.rs         | 38 ++++++++++++++
 10 files changed, 130 insertions(+), 20 deletions(-)
 create mode 100644 crates/app/src/public/html/stacks/add_user.lisp

diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs
index 130b4fc..a556acc 100644
--- a/crates/app/src/assets.rs
+++ b/crates/app/src/assets.rs
@@ -117,6 +117,7 @@ pub const CHATS_CHANNELS: &str = include_str!("./public/html/chats/channels.lisp
 pub const STACKS_LIST: &str = include_str!("./public/html/stacks/list.lisp");
 pub const STACKS_FEED: &str = include_str!("./public/html/stacks/feed.lisp");
 pub const STACKS_MANAGE: &str = include_str!("./public/html/stacks/manage.lisp");
+pub const STACKS_ADD_USER: &str = include_str!("./public/html/stacks/add_user.lisp");
 
 pub const FORGE_HOME: &str = include_str!("./public/html/forge/home.lisp");
 pub const FORGE_BASE: &str = include_str!("./public/html/forge/base.lisp");
@@ -408,6 +409,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD {
     write_template!(html_path->"stacks/list.html"(crate::assets::STACKS_LIST) -d "stacks" --config=config --lisp plugins);
     write_template!(html_path->"stacks/feed.html"(crate::assets::STACKS_FEED) --config=config --lisp plugins);
     write_template!(html_path->"stacks/manage.html"(crate::assets::STACKS_MANAGE) --config=config --lisp plugins);
+    write_template!(html_path->"stacks/add_user.html"(crate::assets::STACKS_ADD_USER) --config=config --lisp plugins);
 
     write_template!(html_path->"forge/home.html"(crate::assets::FORGE_HOME) -d "forge" --config=config --lisp plugins);
     write_template!(html_path->"forge/base.html"(crate::assets::FORGE_BASE) --config=config --lisp plugins);
diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml
index 27c4b8f..2ad3e9d 100644
--- a/crates/app/src/langs/en-US.toml
+++ b/crates/app/src/langs/en-US.toml
@@ -169,6 +169,7 @@ version = "1.0.0"
 "settings:label.manage_blocks" = "Manage blocks"
 "settings:label.users" = "Users"
 "settings:label.generate_invite" = "Generate invite"
+"settings:label.add_to_stack" = "Add to stack"
 "settings:tab.security" = "Security"
 "settings:tab.blocks" = "Blocks"
 "settings:tab.billing" = "Billing"
diff --git a/crates/app/src/public/html/body.lisp b/crates/app/src/public/html/body.lisp
index 370a608..1aeddea 100644
--- a/crates/app/src/public/html/body.lisp
+++ b/crates/app/src/public/html/body.lisp
@@ -312,22 +312,11 @@
 
                 if (playing.error) {
                     // refresh token
-                    const [new_token, new_refresh_token, expires_in] =
-                        await trigger(\"spotify::refresh_token\", [
-                            client_id,
+                    const [new_token, new_refresh_token] =
+                        await trigger(\"spotify::refresh\", [
                             refresh_token,
                         ]);
 
-                    await trigger(\"connections::push_con_data\", [
-                        \"Spotify\",
-                        {
-                            token: new_token,
-                            refresh_token: new_refresh_token,
-                            expires_in: expires_in.toString(),
-                            name: profile.display_name,
-                        },
-                    ]);
-
                     token = new_token;
                     refresh_token = new_refresh_token;
                     return;
diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp
index 489805c..b0db773 100644
--- a/crates/app/src/public/html/profile/settings.lisp
+++ b/crates/app/src/public/html/profile/settings.lisp
@@ -433,12 +433,19 @@
                         (div
                             ("class" "flex gap-2")
                             (text "{{ components::avatar(username=user.username) }} {{ components::full_username(user=user) }}"))
-                        (a
-                            ("href" "/@{{ user.username }}")
-                            ("class" "button lowered small")
-                            (text "{{ icon \"external-link\" }}")
-                            (span
-                                (text "{{ text \"requests:action.view_profile\" }}"))))
+                        (div
+                            ("class" "flex gap-2")
+                            (a
+                                ("href" "/stacks/add_user/{{ user.id }}")
+                                ("target" "_blank")
+                                ("class" "button lowered small")
+                                (icon (text "plus"))
+                                (span (str (text "settings:label.add_to_stack"))))
+                            (a
+                                ("href" "/@{{ user.username }}")
+                                ("class" "button lowered small")
+                                (icon (text "external-link"))
+                                (span (str (text "requests:action.view_profile"))))))
                     (text "{% endfor %}")))))
     (div
         ("class" "w-full flex flex-col gap-2 hidden")
diff --git a/crates/app/src/public/html/stacks/add_user.lisp b/crates/app/src/public/html/stacks/add_user.lisp
new file mode 100644
index 0000000..214decd
--- /dev/null
+++ b/crates/app/src/public/html/stacks/add_user.lisp
@@ -0,0 +1,49 @@
+(text "{% extends \"root.html\" %} {% block head %}")
+(title
+    (text "Add user to stack - {{ config.name }}"))
+
+(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"\") }}")
+(main
+    ("class" "flex flex-col gap-2")
+    (div
+        ("class" "card-nest")
+        (div
+            ("class" "card small flex items-center gap-2")
+            (text "{{ components::avatar(username=add_user.username, size=\"24px\") }}")
+            (text "{{ components::full_username(user=add_user) }}"))
+        (div
+            ("class" "card flex flex-col gap-2")
+            (span (text "Select a stack to add this user to:"))
+            (text "{% for stack in stacks %}")
+            (button
+                ("class" "justify-start lowered w-full")
+                ("onclick" "choose_stack('{{ stack.id }}')")
+                (icon (text "layers"))
+                (text "{{ stack.name }}"))
+            (text "{% endfor %}"))))
+
+(script
+    (text "function choose_stack(id) {
+        fetch(`/api/v1/stacks/${id}/users`, {
+            method: \"POST\",
+            headers: {
+                \"Content-Type\": \"application/json\",
+            },
+            body: JSON.stringify({
+                username: \"{{ add_user.username }}\",
+            }),
+        })
+            .then((res) => res.json())
+            .then((res) => {
+                trigger(\"atto::toast\", [
+                    res.ok ? \"success\" : \"error\",
+                    res.message,
+                ]);
+
+                if (res.ok) {
+                    window.close();
+                }
+            });
+    }"))
+
+(text "{% endblock %}")
diff --git a/crates/app/src/public/html/stacks/manage.lisp b/crates/app/src/public/html/stacks/manage.lisp
index 85c4251..450c027 100644
--- a/crates/app/src/public/html/stacks/manage.lisp
+++ b/crates/app/src/public/html/stacks/manage.lisp
@@ -173,7 +173,7 @@
             return;
         }
 
-        fetch(`/api/v1/stacks/{{ stack.id }}/users`, {
+        fetch(\"/api/v1/stacks/{{ stack.id }}/users\", {
             method: \"POST\",
             headers: {
                 \"Content-Type\": \"application/json\",
diff --git a/crates/app/src/public/js/me.js b/crates/app/src/public/js/me.js
index fc99248..5dbc322 100644
--- a/crates/app/src/public/js/me.js
+++ b/crates/app/src/public/js/me.js
@@ -806,6 +806,25 @@
         return [access_token, refresh_token, expires_in];
     });
 
+    self.define("refresh", async (_, refresh_token) => {
+        const [new_token, new_refresh_token, expires_in] = await trigger(
+            "spotify::refresh_token",
+            [client_id, refresh_token],
+        );
+
+        await trigger("connections::push_con_data", [
+            "Spotify",
+            {
+                token: new_token,
+                refresh_token: new_refresh_token,
+                expires_in: expires_in.toString(),
+                name: profile.display_name,
+            },
+        ]);
+
+        return [new_token, refresh_token];
+    });
+
     self.define("profile", async (_, token) => {
         return await (
             await fetch("https://api.spotify.com/v1/me", {
diff --git a/crates/app/src/routes/api/v1/stacks.rs b/crates/app/src/routes/api/v1/stacks.rs
index d3979a2..1fe5c87 100644
--- a/crates/app/src/routes/api/v1/stacks.rs
+++ b/crates/app/src/routes/api/v1/stacks.rs
@@ -211,6 +211,10 @@ pub async fn add_user_request(
         Err(e) => return Json(e.into()),
     };
 
+    if stack.users.contains(&other_user.id) {
+        return Json(Error::MiscError("This user is already in this stack".to_string()).into());
+    }
+
     stack.users.push(other_user.id);
 
     // check number of stacks
diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs
index 9115f65..b59dce8 100644
--- a/crates/app/src/routes/pages/mod.rs
+++ b/crates/app/src/routes/pages/mod.rs
@@ -131,6 +131,7 @@ pub fn routes() -> Router {
         .route("/stacks", get(stacks::list_request))
         .route("/stacks/{id}", get(stacks::feed_request))
         .route("/stacks/{id}/manage", get(stacks::manage_request))
+        .route("/stacks/add_user/{id}", get(stacks::add_user_request))
         // journals
         .route("/journals", get(journals::redirect_request))
         .route("/journals/{journal}/{note}", get(journals::app_request))
diff --git a/crates/app/src/routes/pages/stacks.rs b/crates/app/src/routes/pages/stacks.rs
index a7b33d8..e8285e9 100644
--- a/crates/app/src/routes/pages/stacks.rs
+++ b/crates/app/src/routes/pages/stacks.rs
@@ -157,3 +157,41 @@ pub async fn manage_request(
     // return
     Ok(Html(data.1.render("stacks/manage.html", &context).unwrap()))
 }
+
+/// `/stacks/add_user`
+pub async fn add_user_request(
+    jar: CookieJar,
+    Path(id): Path<usize>,
+    Extension(data): Extension<State>,
+) -> impl IntoResponse {
+    let data = data.read().await;
+    let user = match get_user_from_token!(jar, data.0) {
+        Some(ua) => ua,
+        None => {
+            return Err(Html(
+                render_error(Error::NotAllowed, &jar, &data, &None).await,
+            ));
+        }
+    };
+
+    let add_user = match data.0.get_user_by_id(id).await {
+        Ok(ua) => ua,
+        Err(e) => return Err(Html(render_error(e, &jar, &data, &None).await)),
+    };
+
+    let stacks = match data.0.get_stacks_by_user(user.id).await {
+        Ok(p) => p,
+        Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
+    };
+
+    let lang = get_lang!(jar, data.0);
+    let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
+
+    context.insert("stacks", &stacks);
+    context.insert("add_user", &add_user);
+
+    // return
+    Ok(Html(
+        data.1.render("stacks/add_user.html", &context).unwrap(),
+    ))
+}

From 4843688fcfafa45fe7c5fe56f9451852c01472cd Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Mon, 23 Jun 2025 13:48:16 -0400
Subject: [PATCH 50/71] add: ability to generate invite codes in bulk add:
 better mark as nsfw ui

---
 crates/app/src/langs/en-US.toml               |  2 +-
 crates/app/src/public/css/root.css            |  2 +-
 .../public/html/communities/create_post.lisp  |  2 +-
 crates/app/src/public/html/components.lisp    | 27 +++++++++----
 crates/app/src/public/html/misc/requests.lisp | 29 +++++++-------
 .../app/src/public/html/profile/settings.lisp | 25 +++++++++---
 crates/app/src/routes/api/v1/auth/profile.rs  | 33 ++++++++++------
 .../src/routes/api/v1/communities/posts.rs    |  5 +--
 crates/app/src/routes/api/v1/mod.rs           |  5 ++-
 crates/app/src/routes/pages/misc.rs           |  6 +--
 crates/app/src/routes/pages/profile.rs        |  6 ++-
 crates/core/src/database/invite_codes.rs      | 38 ++++++++++++++++---
 crates/core/src/database/posts.rs             | 36 +-----------------
 13 files changed, 126 insertions(+), 90 deletions(-)

diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml
index 2ad3e9d..4dacfb5 100644
--- a/crates/app/src/langs/en-US.toml
+++ b/crates/app/src/langs/en-US.toml
@@ -168,7 +168,7 @@ version = "1.0.0"
 "settings:label.export" = "Export"
 "settings:label.manage_blocks" = "Manage blocks"
 "settings:label.users" = "Users"
-"settings:label.generate_invite" = "Generate invite"
+"settings:label.generate_invites" = "Generate invites"
 "settings:label.add_to_stack" = "Add to stack"
 "settings:tab.security" = "Security"
 "settings:tab.blocks" = "Blocks"
diff --git a/crates/app/src/public/css/root.css b/crates/app/src/public/css/root.css
index 3d7dd62..3de8708 100644
--- a/crates/app/src/public/css/root.css
+++ b/crates/app/src/public/css/root.css
@@ -218,7 +218,7 @@ pre {
 }
 
 code {
-    padding: var(--pad-1);
+    padding: 0;
 }
 
 pre,
diff --git a/crates/app/src/public/html/communities/create_post.lisp b/crates/app/src/public/html/communities/create_post.lisp
index 754e915..0b7cf19 100644
--- a/crates/app/src/public/html/communities/create_post.lisp
+++ b/crates/app/src/public/html/communities/create_post.lisp
@@ -139,7 +139,7 @@
                         ("id" "files_list")
                         ("class" "flex gap-2 flex-wrap"))
                     (div
-                        ("class" "flex justify-between gap-2")
+                        ("class" "flex justify-between flex-collapse gap-2")
                         (text "{{ components::create_post_options() }}")
                         (div
                             ("class" "flex gap-2")
diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp
index 99bfc87..2d27d6d 100644
--- a/crates/app/src/public/html/components.lisp
+++ b/crates/app/src/public/html/components.lisp
@@ -1399,7 +1399,7 @@
 
 (text "{%- endif %} {%- endmacro %} {% macro create_post_options() -%}")
 (div
-    ("class" "flex gap-2")
+    ("class" "flex gap-2 flex-wrap")
     (text "{{ components::emoji_picker(element_id=\"content\", render_dialog=true) }}  {% if not quoting -%} {% if is_supporter -%} {{ components::file_picker(files_list_id=\"files_list\") }} {%- endif %} {%- endif %}")
 
     (button
@@ -1414,7 +1414,20 @@
         ("title" "More options")
         ("onclick" "document.getElementById('post_options_dialog').showModal()")
         ("type" "button")
-        (text "{{ icon \"ellipsis\" }}")))
+        (text "{{ icon \"ellipsis\" }}"))
+
+    (label
+        ("class" "flex items-center gap-1 button lowered")
+        ("title" "Mark as NSFW/hide from public timelines")
+        ("for" "is_nsfw")
+        (input
+            ("type" "checkbox")
+            ("name" "is_nsfw")
+            ("id" "is_nsfw")
+            ("checked" "{{ user.settings.auto_unlist }}")
+            ("onchange" "POST_INITIAL_SETTINGS['is_nsfw'] = event.target.checked"))
+
+        (span (icon (text "eye-closed")))))
 
 (dialog
     ("id" "post_options_dialog")
@@ -1474,11 +1487,11 @@
                         window.POST_INITIAL_SETTINGS.reactions_enabled.toString(),
                         \"checkbox\",
                     ],
-                    [
-                        [\"is_nsfw\", \"Hide from public timelines\"],
-                        window.POST_INITIAL_SETTINGS.is_nsfw.toString(),
-                        \"checkbox\",
-                    ],
+                    // [
+                    //     [\"is_nsfw\", \"Hide from public timelines\"],
+                    //     window.POST_INITIAL_SETTINGS.is_nsfw.toString(),
+                    //     \"checkbox\",
+                    // ],
                     [
                         [\"content_warning\", \"Content warning\"],
                         window.POST_INITIAL_SETTINGS.content_warning,
diff --git a/crates/app/src/public/html/misc/requests.lisp b/crates/app/src/public/html/misc/requests.lisp
index 5655e16..9ba68d2 100644
--- a/crates/app/src/public/html/misc/requests.lisp
+++ b/crates/app/src/public/html/misc/requests.lisp
@@ -113,21 +113,24 @@
                         ("id" "files_list")
                         ("class" "flex gap-2 flex-wrap"))
                     (div
-                        ("class" "flex flex-wrap w-full gap-2")
-                        (text "{{ components::create_post_options() }}")
+                        ("class" "flex w-full justify-between flex-collapse gap-2")
+                        (div
+                            ("class" "flex flex-wrap w-full gap-2")
+                            (text "{{ components::create_post_options() }}")
+                            (button
+                                ("type" "button")
+                                ("class" "red lowered")
+                                ("onclick" "trigger('me::remove_question', ['{{ question[0].id }}'])")
+                                (text "{{ text \"general:action.delete\" }}"))
+                            (button
+                                ("type" "button")
+                                ("class" "red lowered")
+                                ("onclick" "trigger('me::ip_block_question', ['{{ question[0].id }}'])")
+                                (text "{{ text \"auth:action.ip_block\" }}")))
+
                         (button
                             ("class" "primary")
-                            (text "{{ text \"requests:label.answer\" }}"))
-                        (button
-                            ("type" "button")
-                            ("class" "red lowered")
-                            ("onclick" "trigger('me::remove_question', ['{{ question[0].id }}'])")
-                            (text "{{ text \"general:action.delete\" }}"))
-                        (button
-                            ("type" "button")
-                            ("class" "red lowered")
-                            ("onclick" "trigger('me::ip_block_question', ['{{ question[0].id }}'])")
-                            (text "{{ text \"auth:action.ip_block\" }}")))))
+                            (text "{{ text \"requests:label.answer\" }}")))))
             (text "{% endfor %}")))
 
     (text "{{ components::pagination(page=page, items=requests|length, key=\"&id=\", value=profile.id) }}"))
diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp
index b0db773..85777e5 100644
--- a/crates/app/src/public/html/profile/settings.lisp
+++ b/crates/app/src/public/html/profile/settings.lisp
@@ -77,7 +77,7 @@
                 (text "{% if config.security.enable_invite_codes -%}")
                 (a
                     ("data-tab-button" "account/invites")
-                    ("href" "#/account/invites")
+                    ("href" "?page=0#/account/invites")
                     (text "{{ icon \"ticket\" }}")
                     (span
                         (text "{{ text \"settings:tab.invites\" }}")))
@@ -538,10 +538,12 @@
                         (text "{{ text \"settings:tab.invites\" }}")))
                 (div
                     ("class" "card flex flex-col gap-2 secondary")
+                    (pre ("id" "invite_codes_output") ("class" "hidden") (code))
+
                     (button
-                        ("onclick" "generate_invite_code()")
+                        ("onclick" "generate_invite_codes()")
                         (icon (text "plus"))
-                        (str (text "settings:label.generate_invite")))
+                        (str (text "settings:label.generate_invites")))
 
                     (text "{{ components::supporter_ad(body=\"Become a supporter to generate up to 48 invite codes! You can currently have 2 maximum.\") }} {% for code in invites %}")
                     (div
@@ -555,8 +557,10 @@
                         (b (text "{{ code[1].code }}"))
                         (text "{%- endif %}"))
                     (text "{% endfor %}")
+                    (text "{{ components::pagination(page=page, items=invites|length, key=\"#/account/invites\") }}")
                     (script
-                        (text "globalThis.generate_invite_code = async () => {
+                        (text "globalThis.generate_invite_codes = async () => {
+                            await trigger(\"atto::debounce\", [\"invites::create\"]);
                             if (
                                 !(await trigger(\"atto::confirm\", [
                                     \"Are you sure you would like to do this? This action is permanent.\",
@@ -565,7 +569,16 @@
                                 return;
                             }
 
-                            fetch(`/api/v1/invite`, {
+                            const count = Number.parseInt(await trigger(\"atto::prompt\", [\"Count (1-48):\"]));
+
+                            if (!count) {
+                                return;
+                            }
+
+                            document.getElementById(\"invite_codes_output\").classList.remove(\"hidden\");
+                            document.getElementById(\"invite_codes_output\").children[0].innerText = \"Working...\";
+
+                            fetch(`/api/v1/invites/${count}`, {
                                 method: \"POST\",
                             })
                                 .then((res) => res.json())
@@ -576,7 +589,7 @@
                                     ]);
 
                                     if (res.ok) {
-                                        alert(res.payload);
+                                        document.getElementById(\"invite_codes_output\").children[0].innerText = res.payload;
                                     }
                                 });
                         };"))))))
diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs
index 419e864..f3b0396 100644
--- a/crates/app/src/routes/api/v1/auth/profile.rs
+++ b/crates/app/src/routes/api/v1/auth/profile.rs
@@ -832,8 +832,9 @@ pub async fn refresh_grant_request(
 /// Generate an invite code.
 ///
 /// Does not support third-party grants.
-pub async fn generate_invite_code_request(
+pub async fn generate_invite_codes_request(
     jar: CookieJar,
+    Path(count): Path<usize>,
     Extension(data): Extension<State>,
 ) -> impl IntoResponse {
     let data = &(data.read().await).0;
@@ -846,15 +847,25 @@ pub async fn generate_invite_code_request(
         return Json(Error::NotAllowed.into());
     }
 
-    match data
-        .create_invite_code(InviteCode::new(user.id), &user)
-        .await
-    {
-        Ok(x) => Json(ApiReturn {
-            ok: true,
-            message: "Code generated".to_string(),
-            payload: Some(x.code),
-        }),
-        Err(e) => Json(e.into()),
+    if count > 48 {
+        return Json(Error::DataTooLong("count".to_string()).into());
     }
+
+    let mut out_string = String::new();
+
+    for _ in 0..count {
+        match data
+            .create_invite_code(InviteCode::new(user.id), &user)
+            .await
+        {
+            Ok(x) => out_string += &(x.code + "\n"),
+            Err(_) => break,
+        }
+    }
+
+    Json(ApiReturn {
+        ok: true,
+        message: "Success".to_string(),
+        payload: Some(out_string),
+    })
 }
diff --git a/crates/app/src/routes/api/v1/communities/posts.rs b/crates/app/src/routes/api/v1/communities/posts.rs
index 5737fc5..70529ed 100644
--- a/crates/app/src/routes/api/v1/communities/posts.rs
+++ b/crates/app/src/routes/api/v1/communities/posts.rs
@@ -441,10 +441,7 @@ pub async fn posts_request(
     };
 
     check_user_blocked_or_private!(Some(&user), other_user, data, @api);
-    match data
-        .get_posts_by_user(id, 12, props.page, &Some(user.clone()))
-        .await
-    {
+    match data.get_posts_by_user(id, 12, props.page).await {
         Ok(posts) => {
             let ignore_users = crate::ignore_users_gen!(user!, #data);
             Json(ApiReturn {
diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs
index 74571af..ea45ffd 100644
--- a/crates/app/src/routes/api/v1/mod.rs
+++ b/crates/app/src/routes/api/v1/mod.rs
@@ -37,7 +37,10 @@ pub fn routes() -> Router {
         .route("/util/proxy", get(util::proxy_request))
         .route("/util/lang", get(util::set_langfile_request))
         .route("/util/ip", get(util::ip_test_request))
-        .route("/invite", post(auth::profile::generate_invite_code_request))
+        .route(
+            "/invites/{count}",
+            post(auth::profile::generate_invite_codes_request),
+        )
         // reactions
         .route("/reactions", post(reactions::create_request))
         .route("/reactions/{id}", get(reactions::get_request))
diff --git a/crates/app/src/routes/pages/misc.rs b/crates/app/src/routes/pages/misc.rs
index 8d0d8be..d83a695 100644
--- a/crates/app/src/routes/pages/misc.rs
+++ b/crates/app/src/routes/pages/misc.rs
@@ -625,12 +625,10 @@ pub async fn swiss_army_timeline_request(
             check_user_blocked_or_private!(user, other_user, data, jar);
 
             if req.tag.is_empty() {
-                data.0
-                    .get_posts_by_user(req.user_id, 12, req.page, &user)
-                    .await
+                data.0.get_posts_by_user(req.user_id, 12, req.page).await
             } else {
                 data.0
-                    .get_posts_by_user_tag(req.user_id, &req.tag, 12, req.page, &user)
+                    .get_posts_by_user_tag(req.user_id, &req.tag, 12, req.page)
                     .await
             }
         } else {
diff --git a/crates/app/src/routes/pages/profile.rs b/crates/app/src/routes/pages/profile.rs
index 78b2d22..17d0d1f 100644
--- a/crates/app/src/routes/pages/profile.rs
+++ b/crates/app/src/routes/pages/profile.rs
@@ -101,7 +101,11 @@ pub async fn settings_request(
         }
     };
 
-    let invites = match data.0.get_invite_codes_by_owner(profile.id).await {
+    let invites = match data
+        .0
+        .get_invite_codes_by_owner(profile.id, 12, req.page)
+        .await
+    {
         Ok(l) => match data.0.fill_invite_codes(l).await {
             Ok(l) => l,
             Err(e) => {
diff --git a/crates/core/src/database/invite_codes.rs b/crates/core/src/database/invite_codes.rs
index 760b469..2c6d950 100644
--- a/crates/core/src/database/invite_codes.rs
+++ b/crates/core/src/database/invite_codes.rs
@@ -1,4 +1,4 @@
-use oiseau::{cache::Cache, query_rows};
+use oiseau::{cache::Cache, query_row, query_rows};
 use tetratto_shared::unix_epoch_timestamp;
 use crate::model::{
     Error, Result,
@@ -24,7 +24,12 @@ impl DataManager {
     auto_method!(get_invite_code_by_code(&str)@get_invite_code_from_row -> "SELECT * FROM invite_codes WHERE code = $1" --name="invite_code" --returns=InviteCode);
 
     /// Get invite_codes by `owner`.
-    pub async fn get_invite_codes_by_owner(&self, owner: usize) -> Result<Vec<InviteCode>> {
+    pub async fn get_invite_codes_by_owner(
+        &self,
+        owner: usize,
+        batch: usize,
+        page: usize,
+    ) -> Result<Vec<InviteCode>> {
         let conn = match self.0.connect().await {
             Ok(c) => c,
             Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
@@ -32,8 +37,8 @@ impl DataManager {
 
         let res = query_rows!(
             &conn,
-            "SELECT * FROM invite_codes WHERE owner = $1",
-            &[&(owner as i64)],
+            "SELECT * FROM invite_codes WHERE owner = $1 ORDER BY created DESC LIMIT $2 OFFSET $3",
+            &[&(owner as i64), &(batch as i64), &((page * batch) as i64)],
             |x| { Self::get_invite_code_from_row(x) }
         );
 
@@ -44,6 +49,27 @@ impl DataManager {
         Ok(res.unwrap())
     }
 
+    /// Get invite_codes by `owner`.
+    pub async fn get_invite_codes_by_owner_count(&self, owner: usize) -> Result<i32> {
+        let conn = match self.0.connect().await {
+            Ok(c) => c,
+            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
+        };
+
+        let res = query_row!(
+            &conn,
+            "SELECT COUNT(*)::int FROM invite_codes WHERE owner = $1",
+            &[&(owner as i64)],
+            |x| Ok(x.get::<usize, i32>(0))
+        );
+
+        if res.is_err() {
+            return Err(Error::GeneralNotFound("invite_code".to_string()));
+        }
+
+        Ok(res.unwrap())
+    }
+
     /// Fill a vector of invite codes with the user that used them.
     pub async fn fill_invite_codes(
         &self,
@@ -89,7 +115,7 @@ impl DataManager {
 
             // our account is old enough, but we need to make sure we don't already have
             // 2 invite codes
-            if self.get_invite_codes_by_owner(user.id).await?.len()
+            if (self.get_invite_codes_by_owner_count(user.id).await? as usize)
                 >= Self::MAXIMUM_FREE_INVITE_CODES
             {
                 return Err(Error::MiscError(
@@ -99,7 +125,7 @@ impl DataManager {
             }
         } else if !user.permissions.check(FinePermission::MANAGE_USERS) {
             // check count since we're also not a moderator with MANAGE_USERS
-            if self.get_invite_codes_by_owner(user.id).await?.len()
+            if (self.get_invite_codes_by_owner_count(user.id).await? as usize)
                 >= Self::MAXIMUM_SUPPORTER_INVITE_CODES
             {
                 return Err(Error::MiscError(
diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs
index ba4bc5e..c1f1dca 100644
--- a/crates/core/src/database/posts.rs
+++ b/crates/core/src/database/posts.rs
@@ -689,31 +689,15 @@ impl DataManager {
         id: usize,
         batch: usize,
         page: usize,
-        user: &Option<User>,
     ) -> Result<Vec<Post>> {
         let conn = match self.0.connect().await {
             Ok(c) => c,
             Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
         };
 
-        // check if we should hide nsfw posts
-        let mut hide_nsfw: bool = true;
-
-        if let Some(ua) = user {
-            hide_nsfw = !ua.settings.show_nsfw;
-        }
-
-        // ...
         let res = query_rows!(
             &conn,
-            &format!(
-                "SELECT * FROM posts WHERE owner = $1 AND replying_to = 0 AND NOT (context::json->>'is_profile_pinned')::boolean AND is_deleted = 0 {} ORDER BY created DESC LIMIT $2 OFFSET $3",
-                if hide_nsfw {
-                    "AND NOT (context::json->>'is_nsfw')::boolean"
-                } else {
-                    ""
-                }
-            ),
+            "SELECT * FROM posts WHERE owner = $1 AND replying_to = 0 AND NOT (context::json->>'is_profile_pinned')::boolean AND is_deleted = 0 ORDER BY created DESC LIMIT $2 OFFSET $3",
             &[&(id as i64), &(batch as i64), &((page * batch) as i64)],
             |x| { Self::get_post_from_row(x) }
         );
@@ -1008,31 +992,15 @@ impl DataManager {
         tag: &str,
         batch: usize,
         page: usize,
-        user: &Option<User>,
     ) -> Result<Vec<Post>> {
         let conn = match self.0.connect().await {
             Ok(c) => c,
             Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
         };
 
-        // check if we should hide nsfw posts
-        let mut hide_nsfw: bool = true;
-
-        if let Some(ua) = user {
-            hide_nsfw = !ua.settings.show_nsfw;
-        }
-
-        // ...
         let res = query_rows!(
             &conn,
-            &format!(
-                "SELECT * FROM posts WHERE owner = $1 AND context::json->>'tags' LIKE $2 AND is_deleted = 0 {} ORDER BY created DESC LIMIT $3 OFFSET $4",
-                if hide_nsfw {
-                    "AND NOT (context::json->>'is_nsfw')::boolean"
-                } else {
-                    ""
-                }
-            ),
+            "SELECT * FROM posts WHERE owner = $1 AND context::json->>'tags' LIKE $2 AND is_deleted = 0 ORDER BY created DESC LIMIT $3 OFFSET $4",
             params![
                 &(id as i64),
                 &format!("%\"{tag}\"%"),

From 253f11b00c2fe0a5a7fa116d02e74f4a6e177ab7 Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Mon, 23 Jun 2025 14:07:15 -0400
Subject: [PATCH 51/71] add: send invite code generation errors to client

---
 crates/app/src/public/html/profile/settings.lisp | 5 ++++-
 crates/app/src/routes/api/v1/auth/profile.rs     | 8 ++++++--
 2 files changed, 10 insertions(+), 3 deletions(-)

diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp
index 85777e5..feee345 100644
--- a/crates/app/src/public/html/profile/settings.lisp
+++ b/crates/app/src/public/html/profile/settings.lisp
@@ -539,6 +539,7 @@
                 (div
                     ("class" "card flex flex-col gap-2 secondary")
                     (pre ("id" "invite_codes_output") ("class" "hidden") (code))
+                    (pre ("id" "invite_codes_error_output") ("class" "hidden") (code ("class" "red")))
 
                     (button
                         ("onclick" "generate_invite_codes()")
@@ -576,6 +577,7 @@
                             }
 
                             document.getElementById(\"invite_codes_output\").classList.remove(\"hidden\");
+                            document.getElementById(\"invite_codes_error_output\").classList.remove(\"hidden\");
                             document.getElementById(\"invite_codes_output\").children[0].innerText = \"Working...\";
 
                             fetch(`/api/v1/invites/${count}`, {
@@ -589,7 +591,8 @@
                                     ]);
 
                                     if (res.ok) {
-                                        document.getElementById(\"invite_codes_output\").children[0].innerText = res.payload;
+                                        document.getElementById(\"invite_codes_output\").children[0].innerText = res.payload[0];
+                                        document.getElementById(\"invite_codes_error_output\").children[0].innerText = res.payload[1];
                                     }
                                 });
                         };"))))))
diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs
index f3b0396..f8691bb 100644
--- a/crates/app/src/routes/api/v1/auth/profile.rs
+++ b/crates/app/src/routes/api/v1/auth/profile.rs
@@ -852,6 +852,7 @@ pub async fn generate_invite_codes_request(
     }
 
     let mut out_string = String::new();
+    let mut errors_string = String::new();
 
     for _ in 0..count {
         match data
@@ -859,13 +860,16 @@ pub async fn generate_invite_codes_request(
             .await
         {
             Ok(x) => out_string += &(x.code + "\n"),
-            Err(_) => break,
+            Err(e) => {
+                errors_string = e.to_string();
+                break;
+            }
         }
     }
 
     Json(ApiReturn {
         ok: true,
         message: "Success".to_string(),
-        payload: Some(out_string),
+        payload: Some((out_string, errors_string)),
     })
 }

From 339aa594346f649cca4b523f421df363d8244a3d Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Mon, 23 Jun 2025 14:17:01 -0400
Subject: [PATCH 52/71] fix: invite code snowflake id collisions

---
 crates/app/src/public/html/profile/settings.lisp | 2 +-
 crates/app/src/routes/api/v1/auth/profile.rs     | 2 ++
 2 files changed, 3 insertions(+), 1 deletion(-)

diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp
index feee345..e55662d 100644
--- a/crates/app/src/public/html/profile/settings.lisp
+++ b/crates/app/src/public/html/profile/settings.lisp
@@ -578,7 +578,7 @@
 
                             document.getElementById(\"invite_codes_output\").classList.remove(\"hidden\");
                             document.getElementById(\"invite_codes_error_output\").classList.remove(\"hidden\");
-                            document.getElementById(\"invite_codes_output\").children[0].innerText = \"Working...\";
+                            document.getElementById(\"invite_codes_output\").children[0].innerText = \"Working... expect to wait 50ms per invite code\";
 
                             fetch(`/api/v1/invites/${count}`, {
                                 method: \"POST\",
diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs
index f8691bb..be550e2 100644
--- a/crates/app/src/routes/api/v1/auth/profile.rs
+++ b/crates/app/src/routes/api/v1/auth/profile.rs
@@ -855,6 +855,8 @@ pub async fn generate_invite_codes_request(
     let mut errors_string = String::new();
 
     for _ in 0..count {
+        // ids will quickly collide, so we need to wait a bit so timestamps are different
+        tokio::time::sleep(Duration::from_millis(50)).await;
         match data
             .create_invite_code(InviteCode::new(user.id), &user)
             .await

From 9528d71b2aa53fde10a3c890fd6996b57c73331e Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Mon, 23 Jun 2025 19:42:02 -0400
Subject: [PATCH 53/71] add: user secondary permission

---
 crates/app/src/public/html/journals/app.lisp  |   7 +-
 crates/core/src/database/auth.rs              |   5 +-
 .../src/database/drivers/sql/create_users.sql |   3 +-
 crates/core/src/model/auth.rs                 |   9 +-
 crates/core/src/model/permissions.rs          | 194 ++++++++++--------
 crates/shared/src/markdown.rs                 |   5 +-
 6 files changed, 128 insertions(+), 95 deletions(-)

diff --git a/crates/app/src/public/html/journals/app.lisp b/crates/app/src/public/html/journals/app.lisp
index 98779b7..39c6699 100644
--- a/crates/app/src/public/html/journals/app.lisp
+++ b/crates/app/src/public/html/journals/app.lisp
@@ -393,10 +393,6 @@
             (script ("id" "editor_content") ("type" "text/markdown") (text "{{ note.content|remove_script_tags|safe }}"))
             (script
                 (text "setTimeout(async () => {
-                    if (!document.getElementById(\"preview_tab\").shadowRoot) {
-                        document.getElementById(\"preview_tab\").attachShadow({ mode: \"open\" });
-                    }
-
                     document.getElementById(\"editor_tab\").innerHTML = \"\";
                     globalThis.editor = CodeMirror(document.getElementById(\"editor_tab\"), {
                         value: document.getElementById(\"editor_content\").innerHTML,
@@ -451,8 +447,7 @@
                         ).text();
 
                         const preview_token = window.crypto.randomUUID();
-                        document.getElementById(\"preview_tab\").shadowRoot.innerHTML = `${res}<style>
-                            @import url(\"/css/style.css\");
+                        document.getElementById(\"preview_tab\").innerHTML = `${res}<style>
                             @import url(\"/api/v1/journals/{{ journal.id }}/journal.css?v=preview-${preview_token}\");
                         </style>`;
                         trigger(\"atto::hooks::tabs:switch\", [\"preview\"]);
diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs
index 7117f69..b7bdd77 100644
--- a/crates/core/src/database/auth.rs
+++ b/crates/core/src/database/auth.rs
@@ -3,6 +3,7 @@ use oiseau::cache::Cache;
 use crate::model::auth::UserConnections;
 use crate::model::moderation::AuditLogEntry;
 use crate::model::oauth::AuthGrant;
+use crate::model::permissions::SecondaryPermission;
 use crate::model::{
     Error, Result,
     auth::{Token, User, UserSettings},
@@ -46,6 +47,7 @@ impl DataManager {
             grants: serde_json::from_str(&get!(x->19(String)).to_string()).unwrap(),
             associated: serde_json::from_str(&get!(x->20(String)).to_string()).unwrap(),
             invite_code: get!(x->21(i64)) as usize,
+            secondary_permissions: SecondaryPermission::from_bits(get!(x->7(i32)) as u32).unwrap(),
         }
     }
 
@@ -224,7 +226,8 @@ impl DataManager {
                 &"",
                 &serde_json::to_string(&data.grants).unwrap(),
                 &serde_json::to_string(&data.associated).unwrap(),
-                &(data.invite_code as i64)
+                &(data.invite_code as i64),
+                &(SecondaryPermission::DEFAULT.bits() as i32),
             ]
         );
 
diff --git a/crates/core/src/database/drivers/sql/create_users.sql b/crates/core/src/database/drivers/sql/create_users.sql
index fa95be5..467d00a 100644
--- a/crates/core/src/database/drivers/sql/create_users.sql
+++ b/crates/core/src/database/drivers/sql/create_users.sql
@@ -19,5 +19,6 @@ CREATE TABLE IF NOT EXISTS users (
     connections TEXT NOT NULL,
     stripe_id TEXT NOT NULL,
     grants TEXT NOT NULL,
-    associated TEXT NOT NULL
+    associated TEXT NOT NULL,
+    secondary_permissions INT NOT NULL
 )
diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs
index f1ed465..64ebc62 100644
--- a/crates/core/src/model/auth.rs
+++ b/crates/core/src/model/auth.rs
@@ -1,6 +1,9 @@
 use std::collections::HashMap;
 
-use super::{oauth::AuthGrant, permissions::FinePermission};
+use super::{
+    oauth::AuthGrant,
+    permissions::{FinePermission, SecondaryPermission},
+};
 use serde::{Deserialize, Serialize};
 use totp_rs::TOTP;
 use tetratto_shared::{
@@ -52,6 +55,9 @@ pub struct User {
     /// The ID of the [`InviteCode`] this user provided during registration.
     #[serde(default)]
     pub invite_code: usize,
+    /// Secondary permissions because the regular permissions struct ran out of possible bits.
+    #[serde(default)]
+    pub secondary_permissions: SecondaryPermission,
 }
 
 pub type UserConnections =
@@ -287,6 +293,7 @@ impl User {
             grants: Vec::new(),
             associated: Vec::new(),
             invite_code: 0,
+            secondary_permissions: SecondaryPermission::DEFAULT,
         }
     }
 
diff --git a/crates/core/src/model/permissions.rs b/crates/core/src/model/permissions.rs
index 9cd6dcb..1584083 100644
--- a/crates/core/src/model/permissions.rs
+++ b/crates/core/src/model/permissions.rs
@@ -44,86 +44,110 @@ bitflags! {
     }
 }
 
-impl Serialize for FinePermission {
-    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
-    where
-        S: serde::Serializer,
-    {
-        serializer.serialize_u32(self.bits())
-    }
+macro_rules! user_permission {
+    ($struct:ident, $visitor:ident, $banned_check:ident) => {
+        impl Serialize for $struct {
+            fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+            where
+                S: serde::Serializer,
+            {
+                serializer.serialize_u32(self.bits())
+            }
+        }
+
+        struct $visitor;
+        impl Visitor<'_> for $visitor {
+            type Value = $struct;
+
+            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
+                formatter.write_str("u32")
+            }
+
+            fn visit_u32<E>(self, value: u32) -> Result<Self::Value, E>
+            where
+                E: DeError,
+            {
+                if let Some(permission) = $struct::from_bits(value) {
+                    Ok(permission)
+                } else {
+                    Ok($struct::from_bits_retain(value))
+                }
+            }
+
+            fn visit_i32<E>(self, value: i32) -> Result<Self::Value, E>
+            where
+                E: DeError,
+            {
+                if let Some(permission) = $struct::from_bits(value as u32) {
+                    Ok(permission)
+                } else {
+                    Ok($struct::from_bits_retain(value as u32))
+                }
+            }
+
+            fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
+            where
+                E: DeError,
+            {
+                if let Some(permission) = $struct::from_bits(value as u32) {
+                    Ok(permission)
+                } else {
+                    Ok($struct::from_bits_retain(value as u32))
+                }
+            }
+        }
+
+        impl<'de> Deserialize<'de> for $struct {
+            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+            where
+                D: Deserializer<'de>,
+            {
+                deserializer.deserialize_any($visitor)
+            }
+        }
+
+        impl $struct {
+            /// Join two permissions into a single `u32`.
+            pub fn join(lhs: $struct, rhs: $struct) -> Self {
+                lhs | rhs
+            }
+
+            /// Check if the given `input` contains the given permission.
+            pub fn check(self, permission: $struct) -> bool {
+                if (self & $struct::ADMINISTRATOR) == $struct::ADMINISTRATOR {
+                    // has administrator permission, meaning everything else is automatically true
+                    return true;
+                } else if self.$banned_check() {
+                    // has banned permission, meaning everything else is automatically false
+                    return false;
+                }
+
+                (self & permission) == permission
+            }
+
+            /// Sink for checking if the permission is banned.
+            pub fn sink(&self) -> bool {
+                false
+            }
+        }
+
+        impl Default for $struct {
+            fn default() -> Self {
+                Self::DEFAULT
+            }
+        }
+    };
 }
 
-struct FinePermissionVisitor;
-impl Visitor<'_> for FinePermissionVisitor {
-    type Value = FinePermission;
-
-    fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
-        formatter.write_str("u32")
-    }
-
-    fn visit_u32<E>(self, value: u32) -> Result<Self::Value, E>
-    where
-        E: DeError,
-    {
-        if let Some(permission) = FinePermission::from_bits(value) {
-            Ok(permission)
-        } else {
-            Ok(FinePermission::from_bits_retain(value))
-        }
-    }
-
-    fn visit_i32<E>(self, value: i32) -> Result<Self::Value, E>
-    where
-        E: DeError,
-    {
-        if let Some(permission) = FinePermission::from_bits(value as u32) {
-            Ok(permission)
-        } else {
-            Ok(FinePermission::from_bits_retain(value as u32))
-        }
-    }
-
-    fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
-    where
-        E: DeError,
-    {
-        if let Some(permission) = FinePermission::from_bits(value as u32) {
-            Ok(permission)
-        } else {
-            Ok(FinePermission::from_bits_retain(value as u32))
-        }
-    }
-}
-
-impl<'de> Deserialize<'de> for FinePermission {
-    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
-    where
-        D: Deserializer<'de>,
-    {
-        deserializer.deserialize_any(FinePermissionVisitor)
-    }
-}
+user_permission!(FinePermission, FinePermissionVisitor, check_banned);
 
 impl FinePermission {
-    /// Join two [`FinePermission`]s into a single `u32`.
-    pub fn join(lhs: FinePermission, rhs: FinePermission) -> FinePermission {
-        lhs | rhs
+    /// Check if the given permission qualifies as "Banned" status.
+    pub fn check_banned(self) -> bool {
+        (self & FinePermission::BANNED) == FinePermission::BANNED
     }
 
-    /// Check if the given `input` contains the given [`FinePermission`].
-    pub fn check(self, permission: FinePermission) -> bool {
-        if (self & FinePermission::ADMINISTRATOR) == FinePermission::ADMINISTRATOR {
-            // has administrator permission, meaning everything else is automatically true
-            return true;
-        } else if self.check_banned() {
-            // has banned permission, meaning everything else is automatically false
-            return false;
-        }
-
-        (self & permission) == permission
-    }
-
-    /// Check if the given [`FinePermission`] qualifies as "Helper" status.
+    /// Check if the given permission qualifies as "Helper" status.
     pub fn check_helper(self) -> bool {
         self.check(FinePermission::MANAGE_COMMUNITIES)
             && self.check(FinePermission::MANAGE_POSTS)
@@ -133,24 +157,26 @@ impl FinePermission {
             && self.check(FinePermission::VIEW_AUDIT_LOG)
     }
 
-    /// Check if the given [`FinePermission`] qualifies as "Manager" status.
+    /// Check if the given permission qualifies as "Manager" status.
     pub fn check_manager(self) -> bool {
         self.check_helper() && self.check(FinePermission::MANAGE_USERS)
     }
 
-    /// Check if the given [`FinePermission`] qualifies as "Administrator" status.
+    /// Check if the given permission qualifies as "Administrator" status.
     pub fn check_admin(self) -> bool {
         self.check_manager() && self.check(FinePermission::ADMINISTRATOR)
     }
+}
 
-    /// Check if the given [`FinePermission`] qualifies as "Banned" status.
-    pub fn check_banned(self) -> bool {
-        (self & FinePermission::BANNED) == FinePermission::BANNED
+bitflags! {
+    /// Fine-grained permissions built using bitwise operations. Second permission value.
+    #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
+    pub struct SecondaryPermission: u32 {
+        const DEFAULT = 1 << 0;
+        const ADMINISTRATOR = 1 << 1;
+
+        const _ = !0;
     }
 }
 
-impl Default for FinePermission {
-    fn default() -> Self {
-        Self::DEFAULT
-    }
-}
+user_permission!(SecondaryPermission, SecondaryPermissionVisitor, sink);
diff --git a/crates/shared/src/markdown.rs b/crates/shared/src/markdown.rs
index c4b51da..1ae665b 100644
--- a/crates/shared/src/markdown.rs
+++ b/crates/shared/src/markdown.rs
@@ -14,8 +14,9 @@ pub fn render_markdown(input: &str) -> String {
         },
         parse: ParseOptions {
             constructs: Constructs {
-                gfm_autolink_literal: true,
-                ..Default::default()
+                math_flow: true,
+                math_text: true,
+                ..Constructs::gfm()
             },
             gfm_strikethrough_single_tilde: false,
             math_text_single_dollar: false,

From 0ae64de9898e0946ac82ce3c96bc45a4807d4150 Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Mon, 23 Jun 2025 19:49:52 -0400
Subject: [PATCH 54/71] add: update user secondary role api

---
 crates/app/src/routes/api/v1/auth/profile.rs |  32 ++++-
 crates/app/src/routes/api/v1/mod.rs          |  11 +-
 crates/core/src/database/auth.rs             | 137 ++++++++++---------
 3 files changed, 113 insertions(+), 67 deletions(-)

diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs
index be550e2..816a82b 100644
--- a/crates/app/src/routes/api/v1/auth/profile.rs
+++ b/crates/app/src/routes/api/v1/auth/profile.rs
@@ -3,8 +3,8 @@ use crate::{
     get_user_from_token,
     model::{ApiReturn, Error},
     routes::api::v1::{
-        AppendAssociations, DeleteUser, DisableTotp, RefreshGrantToken, UpdateUserIsVerified,
-        UpdateUserPassword, UpdateUserRole, UpdateUserUsername,
+        AppendAssociations, DeleteUser, DisableTotp, RefreshGrantToken, UpdateSecondaryUserRole,
+        UpdateUserIsVerified, UpdateUserPassword, UpdateUserRole, UpdateUserUsername,
     },
     State,
 };
@@ -359,6 +359,34 @@ pub async fn update_user_role_request(
     }
 }
 
+/// Update the role of the given user.
+///
+/// Does not support third-party grants.
+pub async fn update_user_secondary_role_request(
+    jar: CookieJar,
+    Path(id): Path<usize>,
+    Extension(data): Extension<State>,
+    Json(req): Json<UpdateSecondaryUserRole>,
+) -> impl IntoResponse {
+    let data = &(data.read().await).0;
+    let user = match get_user_from_token!(jar, data) {
+        Some(ua) => ua,
+        None => return Json(Error::NotAllowed.into()),
+    };
+
+    match data
+        .update_user_secondary_role(id, req.role, user, false)
+        .await
+    {
+        Ok(_) => Json(ApiReturn {
+            ok: true,
+            message: "User updated".to_string(),
+            payload: (),
+        }),
+        Err(e) => Json(e.into()),
+    }
+}
+
 /// Update the current user's last seen value.
 pub async fn seen_request(jar: CookieJar, Extension(data): Extension<State>) -> impl IntoResponse {
     let data = &(data.read().await).0;
diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs
index ea45ffd..d235b68 100644
--- a/crates/app/src/routes/api/v1/mod.rs
+++ b/crates/app/src/routes/api/v1/mod.rs
@@ -26,7 +26,7 @@ use tetratto_core::model::{
     communities_permissions::CommunityPermission,
     journals::JournalPrivacyPermission,
     oauth::AppScope,
-    permissions::FinePermission,
+    permissions::{FinePermission, SecondaryPermission},
     reactions::AssetType,
     stacks::{StackMode, StackPrivacy, StackSort},
 };
@@ -296,6 +296,10 @@ pub fn routes() -> Router {
             "/auth/user/{id}/role",
             post(auth::profile::update_user_role_request),
         )
+        .route(
+            "/auth/user/{id}/role/2",
+            post(auth::profile::update_user_secondary_role_request),
+        )
         .route(
             "/auth/user/{id}",
             delete(auth::profile::delete_user_request),
@@ -738,6 +742,11 @@ pub struct UpdateUserRole {
     pub role: FinePermission,
 }
 
+#[derive(Deserialize)]
+pub struct UpdateSecondaryUserRole {
+    pub role: SecondaryPermission,
+}
+
 #[derive(Deserialize)]
 pub struct DeleteUser {
     pub password: String,
diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs
index b7bdd77..4fb78f7 100644
--- a/crates/core/src/database/auth.rs
+++ b/crates/core/src/database/auth.rs
@@ -16,10 +16,73 @@ use tetratto_shared::{
     unix_epoch_timestamp,
 };
 use crate::{auto_method, DataManager};
+use oiseau::{PostgresRow, execute, get, query_row, params};
 
-use oiseau::PostgresRow;
+macro_rules! update_role_fn {
+    ($name:ident, $role_ty:ty, $col:literal) => {
+        pub async fn $name(
+            &self,
+            id: usize,
+            role: $role_ty,
+            user: User,
+            force: bool,
+        ) -> Result<()> {
+            let other_user = self.get_user_by_id(id).await?;
 
-use oiseau::{execute, get, query_row, params};
+            if !force {
+                // check permission
+                if !user.permissions.check(FinePermission::MANAGE_USERS) {
+                    return Err(Error::NotAllowed);
+                }
+
+                if other_user.permissions.check_manager() && !user.permissions.check_admin() {
+                    return Err(Error::MiscError(
+                        "Cannot manage the role of other managers".to_string(),
+                    ));
+                }
+
+                if other_user.permissions == user.permissions {
+                    return Err(Error::MiscError(
+                        "Cannot manage users of equal level to you".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,
+                &format!("UPDATE users SET {} = $1 WHERE id = $2", $col),
+                params![&(role.bits() as i32), &(id as i64)]
+            );
+
+            if let Err(e) = res {
+                return Err(Error::DatabaseError(e.to_string()));
+            }
+
+            self.cache_clear_user(&other_user).await;
+
+            // create audit log entry
+            self.create_audit_log_entry(AuditLogEntry::new(
+                user.id,
+                format!(
+                    "invoked `{}` with x value `{}` and y value `{}`",
+                    $col,
+                    other_user.id,
+                    role.bits()
+                ),
+            ))
+            .await?;
+
+            // ...
+            Ok(())
+        }
+    };
+}
 
 impl DataManager {
     /// Get a [`User`] from an SQL row.
@@ -47,7 +110,7 @@ impl DataManager {
             grants: serde_json::from_str(&get!(x->19(String)).to_string()).unwrap(),
             associated: serde_json::from_str(&get!(x->20(String)).to_string()).unwrap(),
             invite_code: get!(x->21(i64)) as usize,
-            secondary_permissions: SecondaryPermission::from_bits(get!(x->7(i32)) as u32).unwrap(),
+            secondary_permissions: SecondaryPermission::from_bits(get!(x->22(i32)) as u32).unwrap(),
         }
     }
 
@@ -623,67 +686,6 @@ impl DataManager {
         Ok(())
     }
 
-    pub async fn update_user_role(
-        &self,
-        id: usize,
-        role: FinePermission,
-        user: User,
-        force: bool,
-    ) -> Result<()> {
-        let other_user = self.get_user_by_id(id).await?;
-
-        if !force {
-            // check permission
-            if !user.permissions.check(FinePermission::MANAGE_USERS) {
-                return Err(Error::NotAllowed);
-            }
-
-            if other_user.permissions.check_manager() && !user.permissions.check_admin() {
-                return Err(Error::MiscError(
-                    "Cannot manage the role of other managers".to_string(),
-                ));
-            }
-
-            if other_user.permissions == user.permissions {
-                return Err(Error::MiscError(
-                    "Cannot manage users of equal level to you".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,
-            "UPDATE users SET permissions = $1 WHERE id = $2",
-            params![&(role.bits() as i32), &(id as i64)]
-        );
-
-        if let Err(e) = res {
-            return Err(Error::DatabaseError(e.to_string()));
-        }
-
-        self.cache_clear_user(&other_user).await;
-
-        // create audit log entry
-        self.create_audit_log_entry(AuditLogEntry::new(
-            user.id,
-            format!(
-                "invoked `update_user_role` with x value `{}` and y value `{}`",
-                other_user.id,
-                role.bits()
-            ),
-        ))
-        .await?;
-
-        // ...
-        Ok(())
-    }
-
     pub async fn seen_user(&self, user: &User) -> Result<()> {
         let conn = match self.0.connect().await {
             Ok(c) => c,
@@ -843,6 +845,13 @@ impl DataManager {
             .await;
     }
 
+    update_role_fn!(update_user_role, FinePermission, "permissions");
+    update_role_fn!(
+        update_user_secondary_role,
+        SecondaryPermission,
+        "secondary_permissions"
+    );
+
     auto_method!(update_user_tokens(Vec<Token>)@get_user_by_id -> "UPDATE users SET tokens = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user);
     auto_method!(update_user_grants(Vec<AuthGrant>)@get_user_by_id -> "UPDATE users SET grants = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user);
     auto_method!(update_user_settings(UserSettings)@get_user_by_id -> "UPDATE users SET settings = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user);

From 5fbf454b522fe4a3293d72c967446c0f63604a10 Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Mon, 23 Jun 2025 21:20:12 -0400
Subject: [PATCH 55/71] add: better checkboxes

---
 crates/app/src/assets.rs                    |  2 +-
 crates/app/src/public/css/style.css         | 37 +++++++++++++++++++++
 crates/app/src/public/js/atto.js            |  2 +-
 crates/app/src/routes/mod.rs                |  4 +++
 sql_changes/users_secondary_permissions.sql |  2 ++
 5 files changed, 45 insertions(+), 2 deletions(-)
 create mode 100644 sql_changes/users_secondary_permissions.sql

diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs
index a556acc..08a328d 100644
--- a/crates/app/src/assets.rs
+++ b/crates/app/src/assets.rs
@@ -155,7 +155,7 @@ pub(crate) async fn pull_icon(icon: &str, icons_dir: &str) {
         "https://raw.githubusercontent.com/lucide-icons/lucide/refs/heads/main/icons/{icon}.svg"
     );
 
-    let file_path = PathBufD::current().extend(&[icons_dir, icon]);
+    let file_path = PathBufD::current().extend(&[icons_dir, &format!("{icon}.svg")]);
 
     if exists(&file_path).unwrap() {
         writer.insert(icon.to_string(), read_to_string(&file_path).unwrap());
diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css
index 5e2094b..e41edac 100644
--- a/crates/app/src/public/css/style.css
+++ b/crates/app/src/public/css/style.css
@@ -413,6 +413,43 @@ select:focus {
     overflow-wrap: anywhere;
 }
 
+input[type="checkbox"] {
+    --color: #c9b1bc;
+    appearance: none;
+    border-radius: var(--radius);
+    transition:
+        box-shadow 0.15s,
+        background 0.15s;
+    background-color: var(--color-super-raised);
+    background-position: center;
+    background-origin: padding-box;
+    background-size: 14px;
+    background-repeat: no-repeat;
+    width: 1em !important;
+    height: 1em;
+    outline: none;
+    border: solid 1px var(--color-super-lowered);
+    padding: 0;
+    cursor: pointer;
+    color: var(--color-text-primary);
+}
+
+input[type="checkbox"]:hover {
+    box-shadow: var(--shadow-x-offset) var(--shadow-y-offset) var(--shadow-size)
+        var(--color-shadow);
+}
+
+input[type="checkbox"]:focus {
+    outline: solid 2px var(--color);
+    outline-offset: 2px;
+}
+
+input[type="checkbox"]:checked {
+    border-color: var(--color);
+    background-color: var(--color);
+    background-image: url("/icons/check.svg");
+}
+
 /* pillmenu */
 .pillmenu {
     display: flex;
diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js
index 56ac698..56b85f8 100644
--- a/crates/app/src/public/js/atto.js
+++ b/crates/app/src/public/js/atto.js
@@ -919,7 +919,7 @@ media_theme_pref();
             }
 
             if (option.input_element_type === "checkbox") {
-                into_element.innerHTML += `<div class="card flex gap-2">
+                into_element.innerHTML += `<div class="card flex items-center gap-2">
                 <input
                     type="checkbox"
                     onchange="window.set_setting_field${id_key}('${option.key}', event.target.checked)"
diff --git a/crates/app/src/routes/mod.rs b/crates/app/src/routes/mod.rs
index de1b240..d67dc0c 100644
--- a/crates/app/src/routes/mod.rs
+++ b/crates/app/src/routes/mod.rs
@@ -24,6 +24,10 @@ pub fn routes(config: &Config) -> Router {
             "/public",
             get_service(tower_http::services::ServeDir::new(&config.dirs.assets)),
         )
+        .nest_service(
+            "/icons",
+            get_service(tower_http::services::ServeDir::new(&config.dirs.icons)),
+        )
         .nest_service(
             "/reference",
             get_service(tower_http::services::ServeDir::new(&config.dirs.rustdoc)),
diff --git a/sql_changes/users_secondary_permissions.sql b/sql_changes/users_secondary_permissions.sql
new file mode 100644
index 0000000..7172889
--- /dev/null
+++ b/sql_changes/users_secondary_permissions.sql
@@ -0,0 +1,2 @@
+ALTER TABLE users
+ADD COLUMN secondary_permissions INT NOT NULL DEFAULT 1;

From 66beef6b1db82237a60640b6d984a222fc562c01 Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Mon, 23 Jun 2025 22:31:14 -0400
Subject: [PATCH 56/71] fix: invite codes fix: missing icons

---
 crates/app/src/assets.rs                      |  3 +-
 crates/app/src/public/css/style.css           |  1 +
 crates/app/src/public/html/auth/register.lisp |  2 +-
 crates/app/src/public/html/developer/app.lisp |  2 +-
 .../app/src/public/html/developer/home.lisp   |  2 +-
 .../app/src/public/html/timelines/search.lisp |  2 +-
 crates/app/src/routes/api/v1/auth/mod.rs      | 49 ++++++++++++-------
 crates/core/src/database/auth.rs              |  2 +-
 8 files changed, 38 insertions(+), 25 deletions(-)

diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs
index 08a328d..120f7e9 100644
--- a/crates/app/src/assets.rs
+++ b/crates/app/src/assets.rs
@@ -180,7 +180,8 @@ macro_rules! vendor_icon {
         let writer = &mut ICONS.write().await;
         writer.insert($name.to_string(), $icon.to_string());
 
-        let file_path = PathBufD::current().extend(&[$icons_dir.clone(), $name.to_string()]);
+        let file_path =
+            PathBufD::current().extend(&[$icons_dir.clone(), format!("{}.svg", $name.to_string())]);
         std::fs::write(file_path, $icon).unwrap();
     }};
 }
diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css
index e41edac..5533a96 100644
--- a/crates/app/src/public/css/style.css
+++ b/crates/app/src/public/css/style.css
@@ -427,6 +427,7 @@ input[type="checkbox"] {
     background-repeat: no-repeat;
     width: 1em !important;
     height: 1em;
+    min-width: 1em;
     outline: none;
     border: solid 1px var(--color-super-lowered);
     padding: 0;
diff --git a/crates/app/src/public/html/auth/register.lisp b/crates/app/src/public/html/auth/register.lisp
index 739e538..5cedb18 100644
--- a/crates/app/src/public/html/auth/register.lisp
+++ b/crates/app/src/public/html/auth/register.lisp
@@ -70,7 +70,7 @@
                         ("href" "{{ config.policies.privacy }}")
                         (text "Privacy policy"))))
             (div
-                ("class" "flex gap-2")
+                ("class" "flex items-center gap-2")
                 (input
                     ("type" "checkbox")
                     ("name" "policy_consent")
diff --git a/crates/app/src/public/html/developer/app.lisp b/crates/app/src/public/html/developer/app.lisp
index b9012ec..2850ef5 100644
--- a/crates/app/src/public/html/developer/app.lisp
+++ b/crates/app/src/public/html/developer/app.lisp
@@ -129,7 +129,7 @@
                     (pre ("class" "hidden red w-full") (code ("id" "scope_error_message") ("style" "white-space: pre-wrap")))
 
                     (details
-                        (summary ("class" "button lowered small") (icon (text "circle-help")) (text "Help"))
+                        (summary ("class" "button lowered small") (icon (text "circle-question-mark")) (text "Help"))
                         (div
                             ("class" "card flex flex-col gap-1")
                             (span ("class" "fade") (text "Scopes should be separated by a single space."))
diff --git a/crates/app/src/public/html/developer/home.lisp b/crates/app/src/public/html/developer/home.lisp
index 3a9543f..aefd55d 100644
--- a/crates/app/src/public/html/developer/home.lisp
+++ b/crates/app/src/public/html/developer/home.lisp
@@ -92,7 +92,7 @@
         ("class" "card-nest")
         (div
             ("class" "card small flex items-center gap-2")
-            (icon (text "circle-help"))
+            (icon (text "circle-question-mark"))
             (str (text "developer:label.guides_and_help")))
 
         (div
diff --git a/crates/app/src/public/html/timelines/search.lisp b/crates/app/src/public/html/timelines/search.lisp
index dc6c42d..ef739fe 100644
--- a/crates/app/src/public/html/timelines/search.lisp
+++ b/crates/app/src/public/html/timelines/search.lisp
@@ -53,7 +53,7 @@
                             ("title" "Search help")
                             ("href" "{{ config.manuals.search_help }}")
                             ("target" "_blank")
-                            (text "{{ icon \"circle-help\" }}"))
+                            (text "{{ icon \"circle-question-mark\" }}"))
                         (text "{%- endif %}"))))
             (text "{%- endif %}")
             (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 %}  {% if profile -%} {{ components::pagination(page=page, items=list|length, key=\"&profile=\" ~ profile.id, value=\"&query=\" ~ query) }} {% else %} {{ components::pagination(page=page, items=list|length, key=\"&query=\" ~ query) }} {%- endif %}"))))
diff --git a/crates/app/src/routes/api/v1/auth/mod.rs b/crates/app/src/routes/api/v1/auth/mod.rs
index cff7d20..a332dd8 100644
--- a/crates/app/src/routes/api/v1/auth/mod.rs
+++ b/crates/app/src/routes/api/v1/auth/mod.rs
@@ -123,10 +123,6 @@ pub async fn register_request(
         // }
 
         user.invite_code = invite_code.id;
-
-        if let Err(e) = data.update_invite_code_is_used(invite_code.id, true).await {
-            return (None, Json(e.into()));
-        }
     }
 
     // push initial token
@@ -135,21 +131,36 @@ pub async fn register_request(
 
     // return
     match data.create_user(user).await {
-        Ok(_) => (
-            Some([(
-                "Set-Cookie",
-                format!(
-                    "__Secure-atto-token={}; SameSite=Lax; Secure; Path=/; HostOnly=true; HttpOnly=true; Max-Age={}",
-                    initial_token,
-                    60 * 60 * 24 * 365
-                ),
-            )]),
-            Json(ApiReturn {
-                ok: true,
-                message: initial_token,
-                payload: (),
-            }),
-        ),
+        Ok(_) => {
+            // mark invite as used
+            if data.0.0.security.enable_invite_codes {
+                let invite_code = match data.get_invite_code_by_code(&props.invite_code).await {
+                    Ok(c) => c,
+                    Err(e) => return (None, Json(e.into())),
+                };
+
+                if let Err(e) = data.update_invite_code_is_used(invite_code.id, true).await {
+                    return (None, Json(e.into()));
+                }
+            }
+
+            // ...
+            (
+                Some([(
+                    "Set-Cookie",
+                    format!(
+                        "__Secure-atto-token={}; SameSite=Lax; Secure; Path=/; HostOnly=true; HttpOnly=true; Max-Age={}",
+                        initial_token,
+                        60 * 60 * 24 * 365
+                    ),
+                )]),
+                Json(ApiReturn {
+                    ok: true,
+                    message: initial_token,
+                    payload: (),
+                }),
+            )
+        }
         Err(e) => (None, Json(e.into())),
     }
 }
diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs
index 4fb78f7..4037db7 100644
--- a/crates/core/src/database/auth.rs
+++ b/crates/core/src/database/auth.rs
@@ -266,7 +266,7 @@ impl DataManager {
 
         let res = execute!(
             &conn,
-            "INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)",
+            "INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23)",
             params![
                 &(data.id as i64),
                 &(data.created as i64),

From 2676340fbab166afcbe9dcb31e6aed3b50794f5a Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Tue, 24 Jun 2025 13:18:52 -0400
Subject: [PATCH 57/71] add: more mod panel stats add: show user invite in mod
 panel add: ability to share to twitter/bluesky

---
 crates/app/src/langs/en-US.toml             |  1 +
 crates/app/src/public/html/components.lisp  | 52 +++++++++------
 crates/app/src/public/html/mod/profile.lisp | 14 ++++
 crates/app/src/public/html/mod/stats.lisp   | 11 +++-
 crates/app/src/public/js/atto.js            |  9 ++-
 crates/app/src/public/js/me.js              | 72 +++++++++++++++++++++
 crates/app/src/routes/pages/mod_panel.rs    | 42 ++++++++++++
 crates/core/src/database/common.rs          | 22 ++++++-
 8 files changed, 201 insertions(+), 22 deletions(-)

diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml
index 4dacfb5..a83886f 100644
--- a/crates/app/src/langs/en-US.toml
+++ b/crates/app/src/langs/en-US.toml
@@ -182,6 +182,7 @@ version = "1.0.0"
 "mod_panel:label.warnings" = "Warnings"
 "mod_panel:label.create_warning" = "Create warning"
 "mod_panel:label.associations" = "Associations"
+"mod_panel:label.invited_by" = "Invited by"
 
 "requests:label.requests" = "Requests"
 "requests:label.community_join_request" = "Community join request"
diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp
index 2d27d6d..fb62310 100644
--- a/crates/app/src/public/html/components.lisp
+++ b/crates/app/src/public/html/components.lisp
@@ -132,6 +132,7 @@
         ("class" "card flex flex-col post gap-2 post:{{ post.id }} {% if secondary -%}secondary{%- endif %}")
         ("data-community" "{{ post.community }}")
         ("data-ownsup" "{{ owner.permissions|has_supporter }}")
+        ("data-id" "{{ post.id }}")
         ("hook" "verify_emojis")
         (div
             ("class" "w-full flex gap-2")
@@ -214,7 +215,7 @@
                     ("class" "flush")
                     ("href" "/post/{{ post.id }}")
                     (h2
-                        ("id" "post-content:{{ post.id }}")
+                        ("id" "post_content:{{ post.id }}")
                         ("class" "no_p_margin post_content")
                         ("hook" "long")
                         (text "{{ post.title }}"))
@@ -223,7 +224,6 @@
                 (text "{% else %}")
                 (text "{% if not post.context.content_warning -%}")
                 (span
-                    ("id" "post-content:{{ post.id }}")
                     ("class" "no_p_margin post_content")
                     ("hook" "long")
 
@@ -234,7 +234,8 @@
                     (text "{%- endif %}")
 
                     ; content
-                    (text "{{ post.content|markdown|safe }}  {% if expect_repost -%} {% if repost -%} {{ self::post(post=repost[1], owner=repost[0], secondary=not secondary, community=false, show_community=false, can_manage_post=false) }} {% else %}")
+                    (span ("id" "post_content:{{ post.id }}") (text "{{ post.content|markdown|safe }}"))
+                    (text "{% if expect_repost -%} {% if repost -%} {{ self::post(post=repost[1], owner=repost[0], secondary=not secondary, community=false, show_community=false, can_manage_post=false) }} {% else %}")
                     (div
                         ("class" "card lowered red flex items-center gap-2")
                         (text "{{ icon \"frown\" }}")
@@ -251,7 +252,6 @@
                     (div
                         ("class" "flex flex-col gap-2")
                         (span
-                            ("id" "post-content:{{ post.id }}")
                             ("class" "no_p_margin post_content")
                             ("hook" "long")
 
@@ -261,7 +261,8 @@
                             (text "{% endif %}")
 
                             ; content
-                            (text "{{ post.content|markdown|safe }}  {% if expect_repost -%} {% if repost -%} {{ self::post(post=repost[1], owner=repost[0], secondary=not secondary, community=false, show_community=false, can_manage_post=false) }} {% else %}")
+                            (span ("id" "post_content:{{ post.id }}") (text "{{ post.content|markdown|safe }}"))
+                            (text "{% if expect_repost -%} {% if repost -%} {{ self::post(post=repost[1], owner=repost[0], secondary=not secondary, community=false, show_community=false, can_manage_post=false) }} {% else %}")
                             (div
                                 ("class" "card lowered red flex items-center gap-2")
                                 (text "{{ icon \"frown\" }}")
@@ -338,7 +339,32 @@
                             (text "{{ icon \"quote\" }}")
                             (span
                                 (text "{{ text \"communities:label.quote_post\" }}")))
+                        (button
+                            ("onclick" "trigger('me::intent_twitter', [trigger('me::gen_share', [{ q: '{{ post.context.answering }}', p: '{{ post.id }}' }, 280, true])])")
+                            (icon (text "bird"))
+                            (span
+                                (text "Twitter")))
+                        (button
+                            ("onclick" "trigger('me::intent_bluesky', [trigger('me::gen_share', [{ q: '{{ post.context.answering }}', p: '{{ post.id }}' }, 280, true])])")
+                            (icon (text "cloud"))
+                            (span
+                                (text "BlueSky")))
                         (text "{%- endif %}")
+                        (text "{% if user.id != post.owner -%}")
+                        (b
+                            ("class" "title")
+                            (text "{{ text \"general:label.safety\" }}"))
+                        (button
+                            ("class" "red")
+                            ("onclick" "trigger('me::report', ['{{ post.id }}', 'post'])")
+                            (text "{{ icon \"flag\" }}")
+                            (span
+                                (text "{{ text \"general:action.report\" }}")))
+                        (text "{%- endif %} {% if (user.id == post.owner) or is_helper or can_manage_post %}")
+                        (b
+                            ("class" "title")
+                            (text "{{ text \"general:action.manage\" }}"))
+                        ; forge stuff
                         (text "{% if community and community.is_forge -%} {% if post.is_open -%}")
                         (button
                             ("class" "green")
@@ -354,20 +380,7 @@
                             (span
                                 (text "{{ text \"forge:action.reopen\" }}")))
                         (text "{%- endif %} {%- endif %}")
-                        (text "{% if user.id != post.owner -%}")
-                        (b
-                            ("class" "title")
-                            (text "{{ text \"general:label.safety\" }}"))
-                        (button
-                            ("class" "red")
-                            ("onclick" "trigger('me::report', ['{{ post.id }}', 'post'])")
-                            (text "{{ icon \"flag\" }}")
-                            (span
-                                (text "{{ text \"general:action.report\" }}")))
-                        (text "{%- endif %} {% if (user.id == post.owner) or is_helper or can_manage_post %}")
-                        (b
-                            ("class" "title")
-                            (text "{{ text \"general:action.manage\" }}"))
+                        ; owner stuff
                         (text "{% if user.id == post.owner -%}")
                         (a
                             ("href" "/post/{{ post.id }}#/edit")
@@ -675,6 +688,7 @@
         (span
             ("class" "no_p_margin")
             ("style" "font-weight: 500")
+            ("id" "question_content:{{ question.id }}")
             (text "{{ question.content|markdown|safe }}"))
         ; question drawings
         (text "{{ self::post_media(upload_ids=question.drawings) }}")
diff --git a/crates/app/src/public/html/mod/profile.lisp b/crates/app/src/public/html/mod/profile.lisp
index 28ef09d..8f5e4c6 100644
--- a/crates/app/src/public/html/mod/profile.lisp
+++ b/crates/app/src/public/html/mod/profile.lisp
@@ -202,6 +202,20 @@
             (text "{% for user in associations -%}")
             (text "{{ components::user_plate(user=user, show_menu=false) }}")
             (text "{%- endfor %}")))
+    (text "{% if invite -%}")
+    (div
+        ("class" "card-nest w-full")
+        (div
+            ("class" "card small flex items-center justify-between gap-2")
+            (div
+                ("class" "flex items-center gap-2")
+                (text "{{ icon \"ticket\" }}")
+                (span
+                    (text "{{ text \"mod_panel:label.invited_by\" }}"))))
+        (div
+            ("class" "card lowered flex flex-wrap gap-2")
+            (text "{{ components::user_plate(user=invite[0], show_menu=false) }}")))
+    (text "{%- endif %}")
     (div
         ("class" "card-nest w-full")
         (div
diff --git a/crates/app/src/public/html/mod/stats.lisp b/crates/app/src/public/html/mod/stats.lisp
index 65e895d..d9ac9f5 100644
--- a/crates/app/src/public/html/mod/stats.lisp
+++ b/crates/app/src/public/html/mod/stats.lisp
@@ -29,6 +29,15 @@
                     (b
                         (text "Socket tasks: "))
                     (span
-                        (text "{{ (active_users_chats + active_users) * 3 }}")))))))
+                        (text "{{ (active_users_chats + active_users) * 3 }}"))))
+
+            (hr)
+            (ul
+                (li (b (text "Users: ")) (span (text "{{ table_users }}")))
+                (li (b (text "IP bans: ")) (span (text "{{ table_ipbans }}")))
+                (li (b (text "Invite codes: ")) (span (text "{{ table_invite_codes }}")))
+                (li (b (text "Posts: ")) (span (text "{{ table_posts }}")))
+                (li (b (text "Uploads: ")) (span (text "{{ table_uploads }}")))
+                (li (b (text "Communities: ")) (span (text "{{ table_communities }}")))))))
 
 (text "{% endblock %}")
diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js
index 56b85f8..5cf338b 100644
--- a/crates/app/src/public/js/atto.js
+++ b/crates/app/src/public/js/atto.js
@@ -1227,7 +1227,14 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
         ).text();
 
         self.IO_DATA_WAITING = false;
-        self.IO_DATA_ELEMENT.querySelector("[ui_ident=loading_skel]").remove();
+
+        const loading_skel = self.IO_DATA_ELEMENT.querySelector(
+            "[ui_ident=loading_skel]",
+        );
+
+        if (loading_skel) {
+            loading_skel.remove();
+        }
 
         if (
             text.includes(`!<!-- observer_disconnect_${window.BUILD_CODE} -->`)
diff --git a/crates/app/src/public/js/me.js b/crates/app/src/public/js/me.js
index 5dbc322..4eb0f69 100644
--- a/crates/app/src/public/js/me.js
+++ b/crates/app/src/public/js/me.js
@@ -531,6 +531,78 @@
         return out;
     });
 
+    // share intents
+    self.define(
+        "gen_share",
+        (
+            _,
+            ids = { q: "0", p: "0" },
+            target_length = 280,
+            include_link = true,
+        ) => {
+            const part_1 = (
+                document.getElementById(`question_content:${ids.q}`) || {
+                    innerText: "",
+                }
+            ).innerText;
+
+            const part_2 = document.getElementById(
+                `post_content:${ids.p}`,
+            ).innerText;
+
+            // ...
+            const link =
+                include_link !== false
+                    ? `${window.location.origin}/post/${ids.p}`
+                    : "";
+
+            const link_size = link.length;
+            target_length -= link_size;
+
+            let out = "";
+            const separator = " — ";
+
+            const part_2_size = target_length / 2 - 1;
+            const sep_size = separator.length;
+            const part_1_size = target_length / 2 - sep_size;
+
+            if (part_1 !== "") {
+                out +=
+                    part_1_size > part_1.length
+                        ? part_1
+                        : part_1.substring(0, part_1_size);
+
+                out += separator;
+            }
+
+            if (part_2 !== "") {
+                out +=
+                    part_2_size > part_2.length
+                        ? part_2
+                        : part_2.substring(0, part_2_size);
+            }
+
+            out += ` ${link}`;
+            return out;
+        },
+    );
+
+    self.define("intent_twitter", (_, text) => {
+        window.open(
+            `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}`,
+        );
+
+        trigger("atto::toast", ["success", "Opened intent!"]);
+    });
+
+    self.define("intent_bluesky", (_, text) => {
+        window.open(
+            `https://bsky.app/intent/compose?text=${encodeURIComponent(text)}`,
+        );
+
+        trigger("atto::toast", ["success", "Opened intent!"]);
+    });
+
     // token switcher
     self.define("append_associations", (_, tokens) => {
         fetch("/api/v1/auth/user/me/append_associations", {
diff --git a/crates/app/src/routes/pages/mod_panel.rs b/crates/app/src/routes/pages/mod_panel.rs
index 860bf0e..7a9b6f7 100644
--- a/crates/app/src/routes/pages/mod_panel.rs
+++ b/crates/app/src/routes/pages/mod_panel.rs
@@ -194,10 +194,23 @@ pub async fn manage_profile_request(
         out
     };
 
+    let invite_code = if profile.invite_code != 0 {
+        match data.0.get_invite_code_by_id(profile.invite_code).await {
+            Ok(i) => match data.0.get_user_by_id(i.owner).await {
+                Ok(u) => Some((u, i)),
+                Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
+            },
+            Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
+        }
+    } else {
+        None
+    };
+
     let lang = get_lang!(jar, data.0);
     let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
 
     context.insert("profile", &profile);
+    context.insert("invite", &invite_code);
     context.insert("associations", &associations);
 
     // return
@@ -298,6 +311,35 @@ pub async fn stats_request(jar: CookieJar, Extension(data): Extension<State>) ->
             .unwrap(),
     );
 
+    context.insert(
+        "table_users",
+        &data.0.get_table_row_count("users").await.unwrap_or(0),
+    );
+    context.insert(
+        "table_posts",
+        &data.0.get_table_row_count("posts").await.unwrap_or(0),
+    );
+    context.insert(
+        "table_invite_codes",
+        &data
+            .0
+            .get_table_row_count("invite_codes")
+            .await
+            .unwrap_or(0),
+    );
+    context.insert(
+        "table_uploads",
+        &data.0.get_table_row_count("uploads").await.unwrap_or(0),
+    );
+    context.insert(
+        "table_communities",
+        &data.0.get_table_row_count("communities").await.unwrap_or(0),
+    );
+    context.insert(
+        "table_ipbans",
+        &data.0.get_table_row_count("ipbans").await.unwrap_or(0),
+    );
+
     // return
     Ok(Html(data.1.render("mod/stats.html", &context).unwrap()))
 }
diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs
index a82e389..45111db 100644
--- a/crates/core/src/database/common.rs
+++ b/crates/core/src/database/common.rs
@@ -1,6 +1,6 @@
 use crate::model::{Error, Result};
 use super::{DataManager, drivers::common};
-use oiseau::{cache::Cache, execute};
+use oiseau::{cache::Cache, execute, query_row, params};
 
 pub const NAME_REGEX: &str = r"[^\w_\-\.,!]+";
 
@@ -52,6 +52,26 @@ impl DataManager {
 
         Ok(())
     }
+
+    pub async fn get_table_row_count(&self, table: &str) -> Result<i32> {
+        let conn = match self.0.connect().await {
+            Ok(c) => c,
+            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
+        };
+
+        let res = query_row!(
+            &conn,
+            &format!("SELECT COUNT(*)::int FROM {}", table),
+            params![],
+            |x| Ok(x.get::<usize, i32>(0))
+        );
+
+        if let Err(e) = res {
+            return Err(Error::DatabaseError(e.to_string()));
+        }
+
+        Ok(res.unwrap())
+    }
 }
 
 #[macro_export]

From c2dbe2f1145e595758db58ce912f6bdad48dfd00 Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Tue, 24 Jun 2025 14:18:19 -0400
Subject: [PATCH 58/71] fix: gif image uploading

---
 crates/app/src/routes/api/v1/auth/images.rs | 22 +++++++++++++++++++++
 1 file changed, 22 insertions(+)

diff --git a/crates/app/src/routes/api/v1/auth/images.rs b/crates/app/src/routes/api/v1/auth/images.rs
index e177db7..1b9907f 100644
--- a/crates/app/src/routes/api/v1/auth/images.rs
+++ b/crates/app/src/routes/api/v1/auth/images.rs
@@ -217,6 +217,17 @@ pub async fn upload_avatar_request(
         }
 
         std::fs::write(&path, img.0).unwrap();
+
+        // 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());
+        }
+
+        // ...
         return Json(ApiReturn {
             ok: true,
             message: "Avatar uploaded. It might take a bit to update".to_string(),
@@ -318,6 +329,17 @@ pub async fn upload_banner_request(
         }
 
         std::fs::write(&path, img.0).unwrap();
+
+        // 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());
+        }
+
+        // ...
         return Json(ApiReturn {
             ok: true,
             message: "Banner uploaded. It might take a bit to update".to_string(),

From ffdb76751810e50f06822468effd231aa53cb7c1 Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Tue, 24 Jun 2025 16:34:55 -0400
Subject: [PATCH 59/71] add: full links api

---
 crates/app/src/routes/api/v1/auth/links.rs    | 285 ++++++++++++++++++
 crates/app/src/routes/api/v1/auth/mod.rs      |   1 +
 crates/app/src/routes/api/v1/mod.rs           |  38 +++
 crates/core/src/database/common.rs            |   1 +
 crates/core/src/database/drivers/common.rs    |   1 +
 .../src/database/drivers/sql/create_links.sql |  10 +
 crates/core/src/database/links.rs             | 146 +++++++++
 crates/core/src/database/mod.rs               |   1 +
 crates/core/src/database/stacks.rs            |   5 +-
 crates/core/src/model/links.rs                |  41 +++
 crates/core/src/model/mod.rs                  |   1 +
 crates/core/src/model/oauth.rs                |   6 +
 12 files changed, 532 insertions(+), 4 deletions(-)
 create mode 100644 crates/app/src/routes/api/v1/auth/links.rs
 create mode 100644 crates/core/src/database/drivers/sql/create_links.sql
 create mode 100644 crates/core/src/database/links.rs
 create mode 100644 crates/core/src/model/links.rs

diff --git a/crates/app/src/routes/api/v1/auth/links.rs b/crates/app/src/routes/api/v1/auth/links.rs
new file mode 100644
index 0000000..ecc921b
--- /dev/null
+++ b/crates/app/src/routes/api/v1/auth/links.rs
@@ -0,0 +1,285 @@
+use axum::{
+    response::IntoResponse,
+    extract::{Json, Path},
+    Extension,
+};
+use axum_extra::extract::CookieJar;
+use crate::{
+    get_user_from_token,
+    image::{save_webp_buffer, JsonMultipart},
+    routes::api::v1::{
+        CreateLink, UpdateLinkHref, UpdateLinkLabel, UpdateLinkPosition, UploadLinkIcon,
+    },
+    State,
+};
+use tetratto_core::model::{
+    links::Link,
+    oauth,
+    uploads::{MediaType, MediaUpload},
+    ApiReturn, Error,
+};
+
+pub async fn get_request(
+    jar: CookieJar,
+    Path(id): Path<usize>,
+    Extension(data): Extension<State>,
+) -> impl IntoResponse {
+    let data = &(data.read().await).0;
+    if get_user_from_token!(jar, data, oauth::AppScope::UserReadLinks).is_none() {
+        return Json(Error::NotAllowed.into());
+    };
+
+    let link = match data.get_link_by_id(id).await {
+        Ok(x) => x,
+        Err(e) => return Json(e.into()),
+    };
+
+    Json(ApiReturn {
+        ok: true,
+        message: "Success".to_string(),
+        payload: Some(link),
+    })
+}
+
+pub async fn list_request(jar: CookieJar, Extension(data): Extension<State>) -> impl IntoResponse {
+    let data = &(data.read().await).0;
+    let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadLinks) {
+        Some(ua) => ua,
+        None => return Json(Error::NotAllowed.into()),
+    };
+
+    match data.get_links_by_owner(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<State>,
+    Json(props): Json<CreateLink>,
+) -> impl IntoResponse {
+    let data = &(data.read().await).0;
+    let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateLinks) {
+        Some(ua) => ua,
+        None => return Json(Error::NotAllowed.into()),
+    };
+
+    match data
+        .create_link(
+            Link::new(
+                user.id,
+                props.label,
+                props.href,
+                match data.get_links_by_owner_count(user.id).await {
+                    Ok(c) => (c + 1) as usize,
+                    Err(e) => return Json(e.into()),
+                },
+            ),
+            &user,
+        )
+        .await
+    {
+        Ok(x) => Json(ApiReturn {
+            ok: true,
+            message: "Link created".to_string(),
+            payload: Some(x.id.to_string()),
+        }),
+        Err(e) => Json(e.into()),
+    }
+}
+
+pub async fn update_label_request(
+    jar: CookieJar,
+    Path(id): Path<usize>,
+    Extension(data): Extension<State>,
+    Json(props): Json<UpdateLinkLabel>,
+) -> 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()),
+    };
+
+    let link = match data.get_link_by_id(id).await {
+        Ok(n) => n,
+        Err(e) => return Json(e.into()),
+    };
+
+    if link.owner != user.id {
+        return Json(Error::NotAllowed.into());
+    }
+
+    // ...
+    match data.update_link_label(id, &props.label).await {
+        Ok(_) => Json(ApiReturn {
+            ok: true,
+            message: "Link updated".to_string(),
+            payload: (),
+        }),
+        Err(e) => Json(e.into()),
+    }
+}
+
+pub async fn update_href_request(
+    jar: CookieJar,
+    Path(id): Path<usize>,
+    Extension(data): Extension<State>,
+    Json(props): Json<UpdateLinkHref>,
+) -> 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()),
+    };
+
+    let link = match data.get_link_by_id(id).await {
+        Ok(n) => n,
+        Err(e) => return Json(e.into()),
+    };
+
+    if link.owner != user.id {
+        return Json(Error::NotAllowed.into());
+    }
+
+    // ...
+    match data.update_link_href(id, &props.href).await {
+        Ok(_) => Json(ApiReturn {
+            ok: true,
+            message: "Link updated".to_string(),
+            payload: (),
+        }),
+        Err(e) => Json(e.into()),
+    }
+}
+
+pub async fn update_position_request(
+    jar: CookieJar,
+    Path(id): Path<usize>,
+    Extension(data): Extension<State>,
+    Json(props): Json<UpdateLinkPosition>,
+) -> 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()),
+    };
+
+    let link = match data.get_link_by_id(id).await {
+        Ok(n) => n,
+        Err(e) => return Json(e.into()),
+    };
+
+    if link.owner != user.id {
+        return Json(Error::NotAllowed.into());
+    }
+
+    if props.position < 0 {
+        return Json(Error::MiscError("Position must be an unsigned integer".to_string()).into());
+    }
+
+    // ...
+    match data.update_link_position(id, props.position).await {
+        Ok(_) => Json(ApiReturn {
+            ok: true,
+            message: "Link updated".to_string(),
+            payload: (),
+        }),
+        Err(e) => Json(e.into()),
+    }
+}
+
+pub const MAXIMUM_FILE_SIZE: usize = 131072; // 128 KiB
+
+pub async fn upload_icon_request(
+    jar: CookieJar,
+    Extension(data): Extension<State>,
+    JsonMultipart(images, props): JsonMultipart<UploadLinkIcon>,
+) -> 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()),
+    };
+
+    let id = match props.id.parse::<usize>() {
+        Ok(i) => i,
+        Err(_) => return Json(Error::Unknown.into()),
+    };
+
+    let link = match data.get_link_by_id(id).await {
+        Ok(n) => n,
+        Err(e) => return Json(e.into()),
+    };
+
+    if link.owner != user.id {
+        return Json(Error::NotAllowed.into());
+    }
+
+    // create upload
+    let upload = match data
+        .create_upload(MediaUpload::new(MediaType::Webp, user.id))
+        .await
+    {
+        Ok(n) => n,
+        Err(e) => return Json(e.into()),
+    };
+
+    let image = match images.get(0) {
+        Some(i) => i,
+        None => return Json(Error::MiscError("Missing file".to_string()).into()),
+    };
+
+    if image.len() > MAXIMUM_FILE_SIZE {
+        return Json(Error::FileTooLarge.into());
+    }
+
+    // upload
+    if let Err(e) = save_webp_buffer(&upload.path(&data.0.0).to_string(), image.to_vec(), None) {
+        return Json(Error::MiscError(e.to_string()).into());
+    }
+
+    // ...
+    match data.update_link_upload_id(id, upload.id as i64).await {
+        Ok(_) => Json(ApiReturn {
+            ok: true,
+            message: "Link updated".to_string(),
+            payload: (),
+        }),
+        Err(e) => Json(e.into()),
+    }
+}
+
+pub async fn delete_request(
+    jar: CookieJar,
+    Extension(data): Extension<State>,
+    Path(id): Path<usize>,
+) -> impl IntoResponse {
+    let data = &(data.read().await).0;
+    let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageStacks) {
+        Some(ua) => ua,
+        None => return Json(Error::NotAllowed.into()),
+    };
+
+    let link = match data.get_link_by_id(id).await {
+        Ok(n) => n,
+        Err(e) => return Json(e.into()),
+    };
+
+    if link.owner != user.id {
+        return Json(Error::NotAllowed.into());
+    }
+
+    match data.delete_link(id).await {
+        Ok(_) => Json(ApiReturn {
+            ok: true,
+            message: "Link deleted".to_string(),
+            payload: (),
+        }),
+        Err(e) => Json(e.into()),
+    }
+}
diff --git a/crates/app/src/routes/api/v1/auth/mod.rs b/crates/app/src/routes/api/v1/auth/mod.rs
index a332dd8..670a44f 100644
--- a/crates/app/src/routes/api/v1/auth/mod.rs
+++ b/crates/app/src/routes/api/v1/auth/mod.rs
@@ -1,6 +1,7 @@
 pub mod connections;
 pub mod images;
 pub mod ipbans;
+pub mod links;
 pub mod profile;
 pub mod social;
 pub mod user_warnings;
diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs
index d235b68..e7b9f46 100644
--- a/crates/app/src/routes/api/v1/mod.rs
+++ b/crates/app/src/routes/api/v1/mod.rs
@@ -597,6 +597,18 @@ pub fn routes() -> Router {
         // uploads
         .route("/uploads/{id}", get(uploads::get_request))
         .route("/uploads/{id}", delete(uploads::delete_request))
+        // links
+        .route("/links", get(auth::links::list_request))
+        .route("/links", post(auth::links::create_request))
+        .route("/links/{id}", get(auth::links::get_request))
+        .route("/links/{id}", delete(auth::links::delete_request))
+        .route("/links/icon", post(auth::links::upload_icon_request))
+        .route("/links/{id}/label", post(auth::links::update_label_request))
+        .route("/links/{id}/href", post(auth::links::update_href_request))
+        .route(
+            "/links/{id}/position",
+            post(auth::links::update_position_request),
+        )
 }
 
 #[derive(Deserialize)]
@@ -970,3 +982,29 @@ pub struct RemoveJournalDir {
 pub struct UpdateNoteTags {
     pub tags: Vec<String>,
 }
+
+#[derive(Deserialize)]
+pub struct CreateLink {
+    pub label: String,
+    pub href: String,
+}
+
+#[derive(Deserialize)]
+pub struct UpdateLinkLabel {
+    pub label: String,
+}
+
+#[derive(Deserialize)]
+pub struct UpdateLinkHref {
+    pub href: String,
+}
+
+#[derive(Deserialize)]
+pub struct UpdateLinkPosition {
+    pub position: i32,
+}
+
+#[derive(Deserialize)]
+pub struct UploadLinkIcon {
+    pub id: String,
+}
diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs
index 45111db..b3695b7 100644
--- a/crates/core/src/database/common.rs
+++ b/crates/core/src/database/common.rs
@@ -40,6 +40,7 @@ impl DataManager {
         execute!(&conn, common::CREATE_TABLE_NOTES).unwrap();
         execute!(&conn, common::CREATE_TABLE_MESSAGE_REACTIONS).unwrap();
         execute!(&conn, common::CREATE_TABLE_INVITE_CODES).unwrap();
+        execute!(&conn, common::CREATE_TABLE_LINKS).unwrap();
 
         self.0
             .1
diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs
index e1cfad7..04417c2 100644
--- a/crates/core/src/database/drivers/common.rs
+++ b/crates/core/src/database/drivers/common.rs
@@ -27,3 +27,4 @@ pub const CREATE_TABLE_JOURNALS: &str = include_str!("./sql/create_journals.sql"
 pub const CREATE_TABLE_NOTES: &str = include_str!("./sql/create_notes.sql");
 pub const CREATE_TABLE_MESSAGE_REACTIONS: &str = include_str!("./sql/create_message_reactions.sql");
 pub const CREATE_TABLE_INVITE_CODES: &str = include_str!("./sql/create_invite_codes.sql");
+pub const CREATE_TABLE_LINKS: &str = include_str!("./sql/create_links.sql");
diff --git a/crates/core/src/database/drivers/sql/create_links.sql b/crates/core/src/database/drivers/sql/create_links.sql
new file mode 100644
index 0000000..0c1fc25
--- /dev/null
+++ b/crates/core/src/database/drivers/sql/create_links.sql
@@ -0,0 +1,10 @@
+CREATE TABLE IF NOT EXISTS links (
+    id BIGINT NOT NULL PRIMARY KEY,
+    created BIGINT NOT NULL,
+    owner BIGINT NOT NULL,
+    label TEXT NOT NULL,
+    href TEXT NOT NULL,
+    upload_id BIGINT NOT NULL,
+    clicks INT NOT NULL,
+    position INT NOT NULL
+)
diff --git a/crates/core/src/database/links.rs b/crates/core/src/database/links.rs
new file mode 100644
index 0000000..c70f256
--- /dev/null
+++ b/crates/core/src/database/links.rs
@@ -0,0 +1,146 @@
+use oiseau::{cache::Cache, query_row, query_rows};
+use crate::model::{auth::User, links::Link, permissions::FinePermission, Error, Result};
+use crate::{auto_method, DataManager};
+use oiseau::{PostgresRow, execute, get, params};
+
+impl DataManager {
+    /// Get a [`Link`] from an SQL row.
+    pub(crate) fn get_link_from_row(x: &PostgresRow) -> Link {
+        Link {
+            id: get!(x->0(i64)) as usize,
+            created: get!(x->1(i64)) as usize,
+            owner: get!(x->2(i64)) as usize,
+            label: get!(x->3(String)),
+            href: get!(x->4(String)),
+            upload_id: get!(x->5(i64)) as usize,
+            clicks: get!(x->6(i32)) as usize,
+            position: get!(x->7(i32)) as usize,
+        }
+    }
+
+    auto_method!(get_link_by_id()@get_link_from_row -> "SELECT * FROM links WHERE id = $1" --name="link" --returns=Link --cache-key-tmpl="atto.link:{}");
+
+    /// Get links by `owner`.
+    pub async fn get_links_by_owner(&self, owner: usize) -> Result<Vec<Link>> {
+        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 links WHERE owner = $1 ORDER BY position DESC",
+            &[&(owner as i64)],
+            |x| { Self::get_link_from_row(x) }
+        );
+
+        if res.is_err() {
+            return Err(Error::GeneralNotFound("link".to_string()));
+        }
+
+        Ok(res.unwrap())
+    }
+
+    /// Get links by `owner`.
+    pub async fn get_links_by_owner_count(&self, owner: usize) -> Result<i32> {
+        let conn = match self.0.connect().await {
+            Ok(c) => c,
+            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
+        };
+
+        let res = query_row!(
+            &conn,
+            "SELECT COUNT(*)::int FROM links WHERE owner = $1",
+            &[&(owner as i64)],
+            |x| Ok(x.get::<usize, i32>(0))
+        );
+
+        if res.is_err() {
+            return Err(Error::GeneralNotFound("link".to_string()));
+        }
+
+        Ok(res.unwrap())
+    }
+
+    const MAXIMUM_FREE_LINKS: usize = 10;
+    const MAXIMUM_SUPPORTER_LINKS: usize = 20;
+
+    /// Create a new link in the database.
+    ///
+    /// # Arguments
+    /// * `data` - a mock [`Link`] object to insert
+    pub async fn create_link(&self, data: Link, user: &User) -> Result<Link> {
+        if !user.permissions.check(FinePermission::SUPPORTER) {
+            if (self.get_links_by_owner_count(user.id).await? as usize) >= Self::MAXIMUM_FREE_LINKS
+            {
+                return Err(Error::MiscError(
+                    "You already have the maximum number of links you can create".to_string(),
+                ));
+            }
+        } else if !user.permissions.check(FinePermission::MANAGE_USERS) {
+            if (self.get_links_by_owner_count(user.id).await? as usize)
+                >= Self::MAXIMUM_SUPPORTER_LINKS
+            {
+                return Err(Error::MiscError(
+                    "You already have the maximum number of links you can create".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 links VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
+            params![
+                &(data.id as i64),
+                &(data.created as i64),
+                &(data.owner as i64),
+                &data.label,
+                &data.href,
+                &(data.upload_id as i64),
+                &(data.clicks as i32),
+                &(data.position as i32),
+            ]
+        );
+
+        if let Err(e) = res {
+            return Err(Error::DatabaseError(e.to_string()));
+        }
+
+        Ok(data)
+    }
+
+    pub async fn delete_link(&self, id: usize) -> Result<()> {
+        let y = self.get_link_by_id(id).await?;
+
+        let conn = match self.0.connect().await {
+            Ok(c) => c,
+            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
+        };
+
+        let res = execute!(&conn, "DELETE FROM links WHERE id = $1", &[&(id as i64)]);
+
+        if let Err(e) = res {
+            return Err(Error::DatabaseError(e.to_string()));
+        }
+
+        // delete upload
+        if y.upload_id != 0 {
+            self.delete_upload(id).await?;
+        }
+
+        // ...
+        self.0.1.remove(format!("atto.link:{}", id)).await;
+        Ok(())
+    }
+
+    auto_method!(update_link_label(&str) -> "UPDATE links SET label = $1 WHERE id = $2" --cache-key-tmpl="atto.link:{}");
+    auto_method!(update_link_href(&str) -> "UPDATE links SET href = $1 WHERE id = $2" --cache-key-tmpl="atto.link:{}");
+    auto_method!(update_link_upload_id(i64) -> "UPDATE links SET upload_id = $1 WHERE id = $2" --cache-key-tmpl="atto.link:{}");
+    auto_method!(update_link_clicks(i32) -> "UPDATE links SET clicks = $1 WHERE id = $2" --cache-key-tmpl="atto.link:{}");
+    auto_method!(update_link_position(i32) -> "UPDATE links SET position = $1 WHERE id = $2" --cache-key-tmpl="atto.link:{}");
+}
diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs
index 5f81259..20575e0 100644
--- a/crates/core/src/database/mod.rs
+++ b/crates/core/src/database/mod.rs
@@ -12,6 +12,7 @@ mod invite_codes;
 mod ipbans;
 mod ipblocks;
 mod journals;
+mod links;
 mod memberships;
 mod message_reactions;
 mod messages;
diff --git a/crates/core/src/database/stacks.rs b/crates/core/src/database/stacks.rs
index a42c4a0..46a3e30 100644
--- a/crates/core/src/database/stacks.rs
+++ b/crates/core/src/database/stacks.rs
@@ -9,10 +9,7 @@ use crate::{
     },
 };
 use crate::{auto_method, DataManager};
-
-use oiseau::PostgresRow;
-
-use oiseau::{execute, get, query_rows, params};
+use oiseau::{PostgresRow, execute, get, query_rows, params};
 
 impl DataManager {
     /// Get a [`UserStack`] from an SQL row.
diff --git a/crates/core/src/model/links.rs b/crates/core/src/model/links.rs
new file mode 100644
index 0000000..19b77f2
--- /dev/null
+++ b/crates/core/src/model/links.rs
@@ -0,0 +1,41 @@
+use serde::{Serialize, Deserialize};
+use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp};
+
+#[derive(Serialize, Deserialize, Clone, Debug)]
+pub struct Link {
+    pub id: usize,
+    pub created: usize,
+    /// Links should be selected by their owner, not their link list ID.
+    /// This is why we do not store link list ID.
+    pub owner: usize,
+    pub label: String,
+    pub href: String,
+    /// As link icons are optional, `upload_id` is allowed to be 0.
+    pub upload_id: usize,
+    /// Clicks are tracked for supporters only.
+    ///
+    /// When a user clicks on a link through the UI, they'll be redirect to
+    /// `/links/{id}`. If the link's owner is a supporter, the link's clicks will
+    /// be incremented.
+    ///
+    /// The page should just serve a simple HTML document with a meta tag to redirect.
+    /// We only really care about knowing they clicked it, so an automatic redirect will do.
+    pub clicks: usize,
+    pub position: usize,
+}
+
+impl Link {
+    /// Create a new [`Link`].
+    pub fn new(owner: usize, label: String, href: String, position: usize) -> Self {
+        Self {
+            id: Snowflake::new().to_string().parse::<usize>().unwrap(),
+            created: unix_epoch_timestamp(),
+            owner,
+            label,
+            href,
+            upload_id: 0,
+            clicks: 0,
+            position,
+        }
+    }
+}
diff --git a/crates/core/src/model/mod.rs b/crates/core/src/model/mod.rs
index 839310f..5a6933b 100644
--- a/crates/core/src/model/mod.rs
+++ b/crates/core/src/model/mod.rs
@@ -6,6 +6,7 @@ pub mod channels;
 pub mod communities;
 pub mod communities_permissions;
 pub mod journals;
+pub mod links;
 pub mod moderation;
 pub mod oauth;
 pub mod permissions;
diff --git a/crates/core/src/model/oauth.rs b/crates/core/src/model/oauth.rs
index df34f3d..e783a1e 100644
--- a/crates/core/src/model/oauth.rs
+++ b/crates/core/src/model/oauth.rs
@@ -68,6 +68,8 @@ pub enum AppScope {
     UserReadJournals,
     /// Read the user's notes.
     UserReadNotes,
+    /// Read the user's links.
+    UserReadLinks,
     /// Create posts as the user.
     UserCreatePosts,
     /// Create messages as the user.
@@ -86,6 +88,8 @@ pub enum AppScope {
     UserCreateJournals,
     /// Create notes on behalf of the user.
     UserCreateNotes,
+    /// Create links on behalf of the user.
+    UserCreateLinks,
     /// Delete posts owned by the user.
     UserDeletePosts,
     /// Delete messages owned by the user.
@@ -120,6 +124,8 @@ pub enum AppScope {
     UserManageJournals,
     /// Manage the user's notes.
     UserManageNotes,
+    /// Manage the user's links.
+    UserManageLinks,
     /// Edit posts created by the user.
     UserEditPosts,
     /// Edit drafts created by the user.

From 6e0f2985b92951f36e23d27c28720e1083213af4 Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Wed, 25 Jun 2025 23:15:24 -0400
Subject: [PATCH 60/71] fix: user avatar mime change from gif to avif

---
 crates/app/src/langs/en-US.toml               |  2 +
 crates/app/src/public/html/auth/login.lisp    |  4 +-
 crates/app/src/public/html/auth/register.lisp |  4 +-
 crates/app/src/public/html/body.lisp          |  4 +-
 .../src/public/html/communities/settings.lisp | 12 +--
 crates/app/src/public/html/components.lisp    |  4 +-
 crates/app/src/public/html/mod/profile.lisp   | 54 +++++++++++-
 crates/app/src/public/html/post/post.lisp     |  4 +-
 .../app/src/public/html/profile/settings.lisp |  4 +-
 crates/app/src/public/html/root.lisp          |  3 +-
 crates/app/src/public/js/atto.js              |  9 +-
 crates/app/src/public/js/loader.js            | 44 ++++++----
 crates/app/src/public/js/streams.js           | 32 ++++++-
 crates/app/src/routes/api/v1/auth/images.rs   | 86 +++++++------------
 crates/app/src/routes/api/v1/auth/profile.rs  |  2 +-
 crates/app/src/routes/pages/links.rs          | 47 ++++++++++
 crates/app/src/routes/pages/mod.rs            |  3 +
 crates/core/src/config.rs                     |  1 +
 crates/core/src/database/links.rs             |  2 +-
 crates/core/src/model/socket.rs               |  2 +
 20 files changed, 219 insertions(+), 104 deletions(-)
 create mode 100644 crates/app/src/routes/pages/links.rs

diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml
index a83886f..f6ad996 100644
--- a/crates/app/src/langs/en-US.toml
+++ b/crates/app/src/langs/en-US.toml
@@ -183,6 +183,8 @@ version = "1.0.0"
 "mod_panel:label.create_warning" = "Create warning"
 "mod_panel:label.associations" = "Associations"
 "mod_panel:label.invited_by" = "Invited by"
+"mod_panel:label.send_debug_payload" = "Send debug payload"
+"mod_panel:action.send" = "Send"
 
 "requests:label.requests" = "Requests"
 "requests:label.community_join_request" = "Community join request"
diff --git a/crates/app/src/public/html/auth/login.lisp b/crates/app/src/public/html/auth/login.lisp
index 82ce3b4..cb8bfff 100644
--- a/crates/app/src/public/html/auth/login.lisp
+++ b/crates/app/src/public/html/auth/login.lisp
@@ -90,7 +90,7 @@
             }),
         })
             .then((res) => res.json())
-            .then((res) => {
+            .then(async (res) => {
                 trigger(\"atto::toast\", [
                     res.ok ? \"success\" : \"error\",
                     res.message,
@@ -98,7 +98,7 @@
 
                 if (res.ok) {
                     // update tokens
-                    const new_tokens = ns(\"me\").LOGIN_ACCOUNT_TOKENS;
+                    const new_tokens = (await ns(\"me\")).LOGIN_ACCOUNT_TOKENS;
                     new_tokens[e.target.username.value] = res.message;
                     trigger(\"me::set_login_account_tokens\", [new_tokens]);
 
diff --git a/crates/app/src/public/html/auth/register.lisp b/crates/app/src/public/html/auth/register.lisp
index 5cedb18..9e6c22b 100644
--- a/crates/app/src/public/html/auth/register.lisp
+++ b/crates/app/src/public/html/auth/register.lisp
@@ -107,7 +107,7 @@
             }),
         })
             .then((res) => res.json())
-            .then((res) => {
+            .then(async (res) => {
                 trigger(\"atto::toast\", [
                     res.ok ? \"success\" : \"error\",
                     res.message,
@@ -115,7 +115,7 @@
 
                 if (res.ok) {
                     // update tokens
-                    const new_tokens = ns(\"me\").LOGIN_ACCOUNT_TOKENS;
+                    const new_tokens = (await ns(\"me\")).LOGIN_ACCOUNT_TOKENS;
                     new_tokens[e.target.username.value] = res.message;
                     trigger(\"me::set_login_account_tokens\", [new_tokens]);
 
diff --git a/crates/app/src/public/html/body.lisp b/crates/app/src/public/html/body.lisp
index 1aeddea..82e5fe9 100644
--- a/crates/app/src/public/html/body.lisp
+++ b/crates/app/src/public/html/body.lisp
@@ -56,8 +56,8 @@
 
 ; random js
 (text "<script data-turbo-permanent=\"true\" id=\"init-script\">
-    document.documentElement.addEventListener(\"turbo:load\", () => {
-        const atto = ns(\"atto\");
+    document.documentElement.addEventListener(\"turbo:load\", async () => {
+        const atto = await ns(\"atto\");
 
         if (!atto) {
             window.location.reload();
diff --git a/crates/app/src/public/html/communities/settings.lisp b/crates/app/src/public/html/communities/settings.lisp
index 3027aee..4213cb9 100644
--- a/crates/app/src/public/html/communities/settings.lisp
+++ b/crates/app/src/public/html/communities/settings.lisp
@@ -572,9 +572,9 @@
     (text "{%- endif %}"))
 
 (script
-    (text "setTimeout(() => {
+    (text "setTimeout(async () => {
         const element = document.getElementById(\"membership_info\");
-        const ui = ns(\"ui\");
+        const ui = await ns(\"ui\");
 
         const uid = new URLSearchParams(window.location.search).get(\"uid\");
         if (uid) {
@@ -665,7 +665,7 @@
                 `/api/v1/communities/{{ community.id }}/memberships/${e.target.uid.value}`,
             )
                 .then((res) => res.json())
-                .then((res) => {
+                .then(async (res) => {
                     trigger(\"atto::toast\", [
                         res.ok ? \"success\" : \"error\",
                         res.message,
@@ -676,7 +676,7 @@
                     }
 
                     // permissions manager
-                    const get_permissions_html = trigger(
+                    const get_permissions_html = await trigger(
                         \"ui::generate_permissions_ui\",
                         [
                             {
@@ -750,8 +750,8 @@
     (text "{{ community.context|json_encode()|safe }}"))
 
 (script
-    (text "setTimeout(() => {
-        const ui = ns(\"ui\");
+    (text "setTimeout(async () => {
+        const ui = await ns(\"ui\");
         const settings = JSON.parse(
             document.getElementById(\"settings_json\").innerHTML,
         );
diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp
index fb62310..9ad6f19 100644
--- a/crates/app/src/public/html/components.lisp
+++ b/crates/app/src/public/html/components.lisp
@@ -761,8 +761,8 @@
                 (text "{{ text \"communities:action.remove_drawing\" }}"))
 
             (script
-                (text "globalThis.attach_drawing = () => {
-                    globalThis.gerald = trigger(\"carp::new\", [document.querySelector(\"[ui_ident=carp_canvas_field]\")]);
+                (text "globalThis.attach_drawing = async () => {
+                    globalThis.gerald = await trigger(\"carp::new\", [document.querySelector(\"[ui_ident=carp_canvas_field]\")]);
                     globalThis.gerald.create_canvas();
 
                     document.querySelector(\"[ui_ident=add_drawing]\").classList.add(\"hidden\");
diff --git a/crates/app/src/public/html/mod/profile.lisp b/crates/app/src/public/html/mod/profile.lisp
index 8f5e4c6..e3b3829 100644
--- a/crates/app/src/public/html/mod/profile.lisp
+++ b/crates/app/src/public/html/mod/profile.lisp
@@ -68,8 +68,8 @@
                         (text "Unban"))
                     (text "{%- endif %}")))
             (script
-                (text "setTimeout(() => {
-                    const ui = ns(\"ui\");
+                (text "setTimeout(async () => {
+                    const ui = await ns(\"ui\");
                     const element = document.getElementById(\"mod_options\");
 
                     async function profile_request(do_confirm, path, body) {
@@ -216,6 +216,52 @@
             ("class" "card lowered flex flex-wrap gap-2")
             (text "{{ components::user_plate(user=invite[0], show_menu=false) }}")))
     (text "{%- endif %}")
+    (div
+        ("class" "card-nest w-full")
+        (div
+            ("class" "card small flex items-center justify-between gap-2")
+            (div
+                ("class" "flex items-center gap-2")
+                (text "{{ icon \"square-function\" }}")
+                (span
+                    (text "{{ text \"mod_panel:label.send_debug_payload\" }}"))))
+        (form
+            ("class" "card flex flex-col gap-2")
+            ("onsubmit" "send_debug_payload(event)")
+            (div
+                ("class" "flex flex-col gap-1")
+                (label
+                    ("for" "title")
+                    (text "{{ text \"communities:label.content\" }}"))
+                (textarea
+                    ("type" "text")
+                    ("name" "content")
+                    ("id" "content")
+                    ("placeholder" "javascript content")
+                    ("required" "")
+                    ("minlength" "2")))
+            (button
+                ("class" "primary")
+                (text "{{ text \"mod_panel:action.send\" }}"))))
+    (script
+        (text "globalThis.send_debug_payload = async (e) => {
+            e.preventDefault();
+
+            if (
+                !(await trigger(\"atto::confirm\", [
+                    \"Are you sure you would like to do this? This will only work if the user is online.\",
+                ]))
+            ) {
+                return;
+            }
+
+            const res = await trigger(
+                \"streams::send_packet_to\",
+                [\"{{ profile.id }}\", \"notifs\", { Forward: \"Javascript\" }, { js: e.target.content.value }]
+            );
+
+            trigger(\"atto::toast\", [res.ok ? \"success\" : \"error\", res.message]);
+        }"))
     (div
         ("class" "card-nest w-full")
         (div
@@ -235,8 +281,8 @@
             ("class" "card lowered flex flex-col gap-2")
             ("id" "permission_builder")))
     (script
-        (text "setTimeout(() => {
-            const get_permissions_html = trigger(
+        (text "setTimeout(async () => {
+            const get_permissions_html = await trigger(
                 \"ui::generate_permissions_ui\",
                 [
                     {
diff --git a/crates/app/src/public/html/post/post.lisp b/crates/app/src/public/html/post/post.lisp
index fc40c2d..11a5156 100644
--- a/crates/app/src/public/html/post/post.lisp
+++ b/crates/app/src/public/html/post/post.lisp
@@ -145,8 +145,8 @@
             (span
                 (text "{{ text \"general:action.save\" }}")))
         (script
-            (text "setTimeout(() => {
-                const ui = ns(\"ui\");
+            (text "setTimeout(async () => {
+                const ui = await ns(\"ui\");
                 const element = document.getElementById(\"post_context\");
                 const settings = JSON.parse(\"{{ post_context_serde|safe }}\");
 
diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp
index e55662d..a25f833 100644
--- a/crates/app/src/public/html/profile/settings.lisp
+++ b/crates/app/src/public/html/profile/settings.lisp
@@ -1035,8 +1035,8 @@
         ("id" "settings_json")
         (text "{{ profile.settings|json_encode()|remove_script_tags|safe }}"))
     (script
-        (text "setTimeout(() => {
-            const ui = ns(\"ui\");
+        (text "setTimeout(async () => {
+            const ui = await ns(\"ui\");
             const settings = JSON.parse(
                 document.getElementById(\"settings_json\").innerHTML,
             );
diff --git a/crates/app/src/public/html/root.lisp b/crates/app/src/public/html/root.lisp
index 83dd9af..a4288b3 100644
--- a/crates/app/src/public/html/root.lisp
+++ b/crates/app/src/public/html/root.lisp
@@ -21,10 +21,9 @@
         {%- endif %}")
 
         (text "<script>
-            globalThis.ns_verbose = false;
             globalThis.ns_config = {
                 root: \"/js/\",
-                verbose: globalThis.ns_verbose,
+                verbose: false,
                 version: \"tetratto-{{ random_cache_breaker }}\",
             };
 
diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js
index 5cf338b..7624c76 100644
--- a/crates/app/src/public/js/atto.js
+++ b/crates/app/src/public/js/atto.js
@@ -117,7 +117,7 @@ media_theme_pref();
         );
     });
 
-    self.define("clean_date_codes", ({ $ }) => {
+    self.define("clean_date_codes", async ({ $ }) => {
         for (const element of Array.from(document.querySelectorAll(".date"))) {
             if (element.getAttribute("data-unix")) {
                 // this allows us to run the function twice on the same page
@@ -134,7 +134,7 @@ media_theme_pref();
 
             element.setAttribute("title", then.toLocaleString());
 
-            let pretty = $.rel_date(then) || "";
+            let pretty = (await $.rel_date(then)) || "";
 
             if (
                 (screen.width < 900 && pretty !== undefined) |
@@ -619,7 +619,6 @@ media_theme_pref();
                         .catch(() => {
                             // done scrolling, no more pages (http error)
                             wrapper.removeEventListener("scroll", event);
-
                             resolve();
                         });
                 }
@@ -652,7 +651,7 @@ media_theme_pref();
     );
 
     self.define("hooks::check_reactions", async ({ $ }) => {
-        const observer = $.offload_work_to_client_when_in_view(
+        const observer = await $.offload_work_to_client_when_in_view(
             async (element) => {
                 const like = element.querySelector(
                     '[hook_element="reaction.like"]',
@@ -1292,7 +1291,7 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
         }, 150);
 
         // run hooks
-        const atto = ns("atto");
+        const atto = await ns("atto");
 
         atto.clean_date_codes();
         atto.clean_poll_date_codes();
diff --git a/crates/app/src/public/js/loader.js b/crates/app/src/public/js/loader.js
index fd1d1df..affff76 100644
--- a/crates/app/src/public/js/loader.js
+++ b/crates/app/src/public/js/loader.js
@@ -16,19 +16,32 @@ function regns_log(level, ...args) {
 }
 
 /// Query an existing namespace
-globalThis.ns = (ns) => {
+globalThis.ns = async (ns) => {
     regns_log("info", "namespace query:", ns);
 
     // get namespace from app base
-    const res = globalThis._app_base.ns_store[`$${ns}`];
+    let res = globalThis._app_base.ns_store[`$${ns}`];
+    let tries = 0;
 
-    if (!res) {
-        return console.error(
-            `namespace "${ns}" does not exist, please use one of the following:`,
-            Object.keys(globalThis._app_base.ns_store),
-        );
+    while (!res) {
+        if (tries >= 5) {
+            return console.error(
+                `namespace "${ns}" does not exist, please use one of the following:`,
+                Object.keys(globalThis._app_base.ns_store),
+            );
+        }
+
+        tries += 1;
+        res = globalThis._app_base.ns_store[`$${ns}`];
+
+        await new Promise((resolve) => {
+            setTimeout(() => {
+                resolve();
+            }, 500);
+        });
     }
 
+    regns_log("info", `found ns "${ns}" after ${tries} tries`);
     return res;
 };
 
@@ -51,12 +64,12 @@ globalThis.reg_ns = (ns, deps) => {
         _ident: ns,
         _deps: deps || [],
         /// Pull dependencies (other namespaces) as listed in the given `deps` argument
-        _get_deps: () => {
+        _get_deps: async () => {
             const self = globalThis._app_base.ns_store[`$${ns}`];
             const deps = {};
 
             for (const dep of self._deps) {
-                const res = globalThis.ns(dep);
+                const res = await globalThis.ns(dep);
 
                 if (!res) {
                     regns_log("warn", "failed to pull dependency:", dep);
@@ -72,16 +85,15 @@ globalThis.reg_ns = (ns, deps) => {
         /// Store the real versions of functions
         _fn_store: {},
         /// Call a function in a namespace and load namespace dependencies
-        define: (name, func, types) => {
-            const self = globalThis.ns(ns);
+        define: async (name, func, types) => {
+            const self = await globalThis.ns(ns);
             self._fn_store[name] = func; // store real function
-            self[name] = function (...args) {
+            self[name] = async (...args) => {
                 regns_log("info", "namespace call:", ns, name);
 
                 // js doesn't provide type checking, we do
                 if (types) {
                     for (const i in args) {
-                        // biome-ignore lint: this is incorrect, you do not need a string literal to use typeof
                         if (types[i] && typeof args[i] !== types[i]) {
                             return console.error(
                                 "argument does not pass type check:",
@@ -94,7 +106,7 @@ globalThis.reg_ns = (ns, deps) => {
 
                 // ...
                 // we MUST return here, otherwise nothing will work in workers
-                return self._fn_store[name](self._get_deps(), ...args); // call with deps and arguments
+                return self._fn_store[name](await self._get_deps(), ...args); // call with deps and arguments
             };
         },
     };
@@ -104,11 +116,11 @@ globalThis.reg_ns = (ns, deps) => {
 };
 
 /// Call a namespace function quickly
-globalThis.trigger = (id, args) => {
+globalThis.trigger = async (id, args) => {
     // get namespace
     const s = id.split("::");
     const [namespace, func] = [s[0], s.slice(1, s.length).join("::")];
-    const self = ns(namespace);
+    const self = await ns(namespace);
 
     if (!self) {
         return console.error("namespace does not exist:", namespace);
diff --git a/crates/app/src/public/js/streams.js b/crates/app/src/public/js/streams.js
index 1285d06..f8af522 100644
--- a/crates/app/src/public/js/streams.js
+++ b/crates/app/src/public/js/streams.js
@@ -52,6 +52,17 @@
             if (data.method.Forward === "Key") {
                 $.STREAMS[stream].id = data.data;
                 return console.info(`${stream} ${data.data}`);
+            } else if (data.method.Forward === "Javascript") {
+                const s = document.createElement("script");
+                s.setAttribute("type", "module");
+                s.setAttribute("data-received", Date.now().toString());
+
+                s.text = JSON.parse(data.data).js;
+                document.body.appendChild(s).parentNode.removeChild(s);
+
+                return console.info(
+                    `${stream} received Forward(PacketType::Javascript) payload of ${data.data.length} bytes`,
+                );
             }
 
             return $.sock(stream).events.message(data);
@@ -72,8 +83,8 @@
         socket.socket.close();
     });
 
-    self.define("event", ({ $ }, stream, event, handler) => {
-        const socket = $.sock(stream);
+    self.define("event", async ({ $ }, stream, event, handler) => {
+        const socket = await $.sock(stream);
 
         if (!socket) {
             console.warn("no such stream to add event to");
@@ -84,7 +95,7 @@
     });
 
     self.define("send_packet", async ({ $ }, stream, method, data) => {
-        await (
+        return await (
             await fetch(`/api/v1/auth/user/${$.USER}/_connect/${stream}/send`, {
                 method: "POST",
                 headers: {
@@ -97,4 +108,19 @@
             })
         ).json();
     });
+
+    self.define("send_packet_to", async (_, user, stream, method, data) => {
+        return await (
+            await fetch(`/api/v1/auth/user/${user}/_connect/${stream}/send`, {
+                method: "POST",
+                headers: {
+                    "Content-Type": "application/json",
+                },
+                body: JSON.stringify({
+                    method,
+                    data: JSON.stringify(data),
+                }),
+            })
+        ).json();
+    });
 })();
diff --git a/crates/app/src/routes/api/v1/auth/images.rs b/crates/app/src/routes/api/v1/auth/images.rs
index 1b9907f..4619a80 100644
--- a/crates/app/src/routes/api/v1/auth/images.rs
+++ b/crates/app/src/routes/api/v1/auth/images.rs
@@ -219,7 +219,7 @@ pub async fn upload_avatar_request(
         std::fs::write(&path, img.0).unwrap();
 
         // update user settings
-        auth_user.settings.avatar_mime = mime.to_string();
+        auth_user.settings.avatar_mime = "image/gif".to_string();
         if let Err(e) = data
             .update_user_settings(auth_user.id, auth_user.settings)
             .await
@@ -240,6 +240,15 @@ pub async fn upload_avatar_request(
         return Json(Error::FileTooLarge.into());
     }
 
+    // update user settings
+    auth_user.settings.avatar_mime = "image/avif".to_string();
+    if let Err(e) = data
+        .update_user_settings(auth_user.id, auth_user.settings)
+        .await
+    {
+        return Json(e.into());
+    }
+
     // upload image
     let mut bytes = Vec::new();
 
@@ -247,32 +256,12 @@ pub async fn upload_avatar_request(
         bytes.push(byte);
     }
 
-    match save_buffer(
-        &path,
-        bytes,
-        if mime == "image/gif" {
-            image::ImageFormat::Gif
-        } else {
-            image::ImageFormat::Avif
-        },
-    ) {
-        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: (),
-            })
-        }
+    match save_buffer(&path, bytes, image::ImageFormat::Avif) {
+        Ok(_) => 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()),
     }
 }
@@ -331,7 +320,7 @@ pub async fn upload_banner_request(
         std::fs::write(&path, img.0).unwrap();
 
         // update user settings
-        auth_user.settings.banner_mime = mime.to_string();
+        auth_user.settings.banner_mime = "image/gif".to_string();
         if let Err(e) = data
             .update_user_settings(auth_user.id, auth_user.settings)
             .await
@@ -352,6 +341,15 @@ pub async fn upload_banner_request(
         return Json(Error::FileTooLarge.into());
     }
 
+    // update user settings
+    auth_user.settings.avatar_mime = "image/avif".to_string();
+    if let Err(e) = data
+        .update_user_settings(auth_user.id, auth_user.settings)
+        .await
+    {
+        return Json(e.into());
+    }
+
     // upload image
     let mut bytes = Vec::new();
 
@@ -359,32 +357,12 @@ pub async fn upload_banner_request(
         bytes.push(byte);
     }
 
-    match save_buffer(
-        &path,
-        bytes,
-        if mime == "image/gif" {
-            image::ImageFormat::Gif
-        } else {
-            image::ImageFormat::Avif
-        },
-    ) {
-        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: (),
-            })
-        }
+    match save_buffer(&path, bytes, image::ImageFormat::Avif) {
+        Ok(_) => 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()),
     }
 }
diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs
index 816a82b..3ee8b54 100644
--- a/crates/app/src/routes/api/v1/auth/profile.rs
+++ b/crates/app/src/routes/api/v1/auth/profile.rs
@@ -708,7 +708,7 @@ pub async fn post_to_socket_request(
         None => return Json(Error::NotAllowed.into()),
     };
 
-    if user.id.to_string() != user_id {
+    if user.id.to_string() != user_id && !user.permissions.check(FinePermission::MANAGE_USERS) {
         return Json(Error::NotAllowed.into());
     }
 
diff --git a/crates/app/src/routes/pages/links.rs b/crates/app/src/routes/pages/links.rs
new file mode 100644
index 0000000..3cec4bd
--- /dev/null
+++ b/crates/app/src/routes/pages/links.rs
@@ -0,0 +1,47 @@
+use axum::{
+    extract::Path,
+    response::{Html, IntoResponse},
+    Extension,
+};
+use axum_extra::extract::CookieJar;
+use tetratto_core::model::{permissions::FinePermission, Error};
+use crate::{get_user_from_token, State};
+use super::render_error;
+
+/// `/links/{id}`
+pub async fn navigate_request(
+    jar: CookieJar,
+    Path(id): Path<usize>,
+    Extension(data): Extension<State>,
+) -> impl IntoResponse {
+    let data = data.read().await;
+    let user = match get_user_from_token!(jar, data.0) {
+        Some(ua) => ua,
+        None => {
+            return Err(Html(
+                render_error(Error::NotAllowed, &jar, &data, &None).await,
+            ));
+        }
+    };
+
+    let link = match data.0.get_link_by_id(id).await {
+        Ok(x) => x,
+        Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
+    };
+
+    let owner = match data.0.get_user_by_id(link.owner).await {
+        Ok(x) => x,
+        Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
+    };
+
+    if owner.permissions.check(FinePermission::SUPPORTER) {
+        if let Err(e) = data.0.incr_link_clicks(link.id).await {
+            return Err(Html(render_error(e, &jar, &data, &Some(user)).await));
+        }
+    }
+
+    Ok(Html(format!(
+        "<!doctype html /><html><head><meta http-equiv=\"refresh\" content=\"0; url={}\" /></head><body>Navigating...</body></html>",
+        link.href
+    )))
+}
diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs
index b59dce8..f527f2a 100644
--- a/crates/app/src/routes/pages/mod.rs
+++ b/crates/app/src/routes/pages/mod.rs
@@ -4,6 +4,7 @@ pub mod communities;
 pub mod developer;
 pub mod forge;
 pub mod journals;
+pub mod links;
 pub mod misc;
 pub mod mod_panel;
 pub mod profile;
@@ -137,6 +138,8 @@ pub fn routes() -> Router {
         .route("/journals/{journal}/{note}", get(journals::app_request))
         .route("/@{owner}/{journal}", get(journals::index_view_request))
         .route("/@{owner}/{journal}/{note}", get(journals::view_request))
+        // links
+        .route("/links/{id}", get(links::navigate_request))
 }
 
 pub async fn render_error(
diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs
index 810f989..7de4cfb 100644
--- a/crates/core/src/config.rs
+++ b/crates/core/src/config.rs
@@ -350,6 +350,7 @@ fn default_banned_usernames() -> Vec<String> {
         "stack".to_string(),
         "search".to_string(),
         "journals".to_string(),
+        "links".to_string(),
     ]
 }
 
diff --git a/crates/core/src/database/links.rs b/crates/core/src/database/links.rs
index c70f256..cb00c93 100644
--- a/crates/core/src/database/links.rs
+++ b/crates/core/src/database/links.rs
@@ -141,6 +141,6 @@ impl DataManager {
     auto_method!(update_link_label(&str) -> "UPDATE links SET label = $1 WHERE id = $2" --cache-key-tmpl="atto.link:{}");
     auto_method!(update_link_href(&str) -> "UPDATE links SET href = $1 WHERE id = $2" --cache-key-tmpl="atto.link:{}");
     auto_method!(update_link_upload_id(i64) -> "UPDATE links SET upload_id = $1 WHERE id = $2" --cache-key-tmpl="atto.link:{}");
-    auto_method!(update_link_clicks(i32) -> "UPDATE links SET clicks = $1 WHERE id = $2" --cache-key-tmpl="atto.link:{}");
     auto_method!(update_link_position(i32) -> "UPDATE links SET position = $1 WHERE id = $2" --cache-key-tmpl="atto.link:{}");
+    auto_method!(incr_link_clicks() -> "UPDATE links SET clicks = $1 WHERE id = $2" --cache-key-tmpl="atto.link:{}" --incr);
 }
diff --git a/crates/core/src/model/socket.rs b/crates/core/src/model/socket.rs
index 08f8c96..638f26d 100644
--- a/crates/core/src/model/socket.rs
+++ b/crates/core/src/model/socket.rs
@@ -16,6 +16,8 @@ pub enum PacketType {
     Crud(CrudMessageType),
     /// A text key which identifies the socket.
     Key,
+    /// JavaScript text.
+    Javascript,
 }
 
 #[derive(Serialize, Deserialize, PartialEq, Eq)]

From 59581f69c9911a79b81e6b1b92c871eb6f96c682 Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Wed, 25 Jun 2025 23:40:12 -0400
Subject: [PATCH 61/71] remove: PacketType::Javascript

---
 crates/app/src/public/html/mod/profile.lisp | 46 ---------------------
 crates/app/src/public/js/streams.js         | 11 -----
 crates/core/src/model/socket.rs             |  2 -
 3 files changed, 59 deletions(-)

diff --git a/crates/app/src/public/html/mod/profile.lisp b/crates/app/src/public/html/mod/profile.lisp
index e3b3829..e64ec63 100644
--- a/crates/app/src/public/html/mod/profile.lisp
+++ b/crates/app/src/public/html/mod/profile.lisp
@@ -216,52 +216,6 @@
             ("class" "card lowered flex flex-wrap gap-2")
             (text "{{ components::user_plate(user=invite[0], show_menu=false) }}")))
     (text "{%- endif %}")
-    (div
-        ("class" "card-nest w-full")
-        (div
-            ("class" "card small flex items-center justify-between gap-2")
-            (div
-                ("class" "flex items-center gap-2")
-                (text "{{ icon \"square-function\" }}")
-                (span
-                    (text "{{ text \"mod_panel:label.send_debug_payload\" }}"))))
-        (form
-            ("class" "card flex flex-col gap-2")
-            ("onsubmit" "send_debug_payload(event)")
-            (div
-                ("class" "flex flex-col gap-1")
-                (label
-                    ("for" "title")
-                    (text "{{ text \"communities:label.content\" }}"))
-                (textarea
-                    ("type" "text")
-                    ("name" "content")
-                    ("id" "content")
-                    ("placeholder" "javascript content")
-                    ("required" "")
-                    ("minlength" "2")))
-            (button
-                ("class" "primary")
-                (text "{{ text \"mod_panel:action.send\" }}"))))
-    (script
-        (text "globalThis.send_debug_payload = async (e) => {
-            e.preventDefault();
-
-            if (
-                !(await trigger(\"atto::confirm\", [
-                    \"Are you sure you would like to do this? This will only work if the user is online.\",
-                ]))
-            ) {
-                return;
-            }
-
-            const res = await trigger(
-                \"streams::send_packet_to\",
-                [\"{{ profile.id }}\", \"notifs\", { Forward: \"Javascript\" }, { js: e.target.content.value }]
-            );
-
-            trigger(\"atto::toast\", [res.ok ? \"success\" : \"error\", res.message]);
-        }"))
     (div
         ("class" "card-nest w-full")
         (div
diff --git a/crates/app/src/public/js/streams.js b/crates/app/src/public/js/streams.js
index f8af522..88f4f7d 100644
--- a/crates/app/src/public/js/streams.js
+++ b/crates/app/src/public/js/streams.js
@@ -52,17 +52,6 @@
             if (data.method.Forward === "Key") {
                 $.STREAMS[stream].id = data.data;
                 return console.info(`${stream} ${data.data}`);
-            } else if (data.method.Forward === "Javascript") {
-                const s = document.createElement("script");
-                s.setAttribute("type", "module");
-                s.setAttribute("data-received", Date.now().toString());
-
-                s.text = JSON.parse(data.data).js;
-                document.body.appendChild(s).parentNode.removeChild(s);
-
-                return console.info(
-                    `${stream} received Forward(PacketType::Javascript) payload of ${data.data.length} bytes`,
-                );
             }
 
             return $.sock(stream).events.message(data);
diff --git a/crates/core/src/model/socket.rs b/crates/core/src/model/socket.rs
index 638f26d..08f8c96 100644
--- a/crates/core/src/model/socket.rs
+++ b/crates/core/src/model/socket.rs
@@ -16,8 +16,6 @@ pub enum PacketType {
     Crud(CrudMessageType),
     /// A text key which identifies the socket.
     Key,
-    /// JavaScript text.
-    Javascript,
 }
 
 #[derive(Serialize, Deserialize, PartialEq, Eq)]

From 2cd04b0db0de9393eaf5f0e2850528bb6599c592 Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Thu, 26 Jun 2025 02:56:22 -0400
Subject: [PATCH 62/71] add: global notes

---
 crates/app/src/langs/en-US.toml               |   3 +
 crates/app/src/public/html/components.lisp    |  21 +-
 crates/app/src/public/html/journals/app.lisp  | 117 ++++++-
 .../app/src/public/html/profile/settings.lisp |   2 +
 crates/app/src/public/js/streams.js           |   8 +-
 crates/app/src/routes/api/v1/auth/links.rs    | 285 ------------------
 crates/app/src/routes/api/v1/auth/mod.rs      |   1 -
 crates/app/src/routes/api/v1/mod.rs           |  40 +--
 crates/app/src/routes/api/v1/notes.rs         | 109 ++++++-
 crates/app/src/routes/pages/journals.rs       |  74 ++++-
 crates/app/src/routes/pages/links.rs          |  47 ---
 crates/app/src/routes/pages/mod.rs            |   4 +-
 crates/core/src/database/common.rs            |   1 -
 crates/core/src/database/drivers/common.rs    |   1 -
 .../src/database/drivers/sql/create_links.sql |  10 -
 .../src/database/drivers/sql/create_notes.sql |   3 +-
 crates/core/src/database/links.rs             | 146 ---------
 crates/core/src/database/mod.rs               |   1 -
 crates/core/src/database/notes.rs             |  58 +++-
 crates/core/src/database/reactions.rs         |   2 +-
 crates/core/src/model/journals.rs             |   2 +
 crates/core/src/model/links.rs                |  41 ---
 crates/core/src/model/mod.rs                  |   1 -
 sql_changes/notes_is_global.sql               |   2 +
 24 files changed, 371 insertions(+), 608 deletions(-)
 delete mode 100644 crates/app/src/routes/api/v1/auth/links.rs
 delete mode 100644 crates/app/src/routes/pages/links.rs
 delete mode 100644 crates/core/src/database/drivers/sql/create_links.sql
 delete mode 100644 crates/core/src/database/links.rs
 delete mode 100644 crates/core/src/model/links.rs
 create mode 100644 sql_changes/notes_is_global.sql

diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml
index f6ad996..bab0525 100644
--- a/crates/app/src/langs/en-US.toml
+++ b/crates/app/src/langs/en-US.toml
@@ -256,3 +256,6 @@ version = "1.0.0"
 "journals:action.create_subdir" = "Create subdirectory"
 "journals:action.create_root_dir" = "Create root directory"
 "journals:action.move" = "Move"
+"journals:action.publish" = "Publish"
+"journals:action.unpublish" = "Unpublish"
+"journals:action.view" = "View"
diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp
index 9ad6f19..bcdb77d 100644
--- a/crates/app/src/public/html/components.lisp
+++ b/crates/app/src/public/html/components.lisp
@@ -2138,15 +2138,32 @@
                 (icon (text "pencil"))
                 (str (text "chats:action.rename")))
             (a
-                ("class" "button")
                 ("href" "/journals/{{ journal.id }}/{{ note.id }}#/tags")
                 (icon (text "tag"))
                 (str (text "journals:action.edit_tags")))
             (button
-                ("class" "button")
                 ("onclick" "window.NOTE_MOVER_NOTE_ID = '{{ note.id }}'; document.getElementById('note_mover_dialog').showModal()")
                 (icon (text "brush-cleaning"))
                 (str (text "journals:action.move")))
+            (text "{% if note.is_global -%}")
+            (a
+                ("class" "button")
+                ("href" "/x/{{ note.title }}")
+                (icon (text "eye"))
+                (str (text "journals:action.view")))
+
+            (button
+                ("class" "purple")
+                ("onclick" "unpublish_note('{{ note.id }}')")
+                (icon (text "globe-lock"))
+                (str (text "journals:action.unpublish")))
+            (text "{% elif note.title != 'journal.css' %}")
+            (button
+                ("class" "green")
+                ("onclick" "publish_note('{{ note.id }}')")
+                (icon (text "globe"))
+                (str (text "journals:action.publish")))
+            (text "{%- endif %}")
             (button
                 ("onclick" "delete_note('{{ note.id }}')")
                 ("class" "red")
diff --git a/crates/app/src/public/html/journals/app.lisp b/crates/app/src/public/html/journals/app.lisp
index 39c6699..255b2ec 100644
--- a/crates/app/src/public/html/journals/app.lisp
+++ b/crates/app/src/public/html/journals/app.lisp
@@ -1,11 +1,19 @@
 (text "{% extends \"root.html\" %} {% block head %}")
-(text "{% if journal -%}") (title (text "{{ journal.title }}")) (text "{% else %}") (title (text "Journals - {{ config.name }}")) (text "{%- endif %}")
+
+(text "{% if journal -%} {% if note -%}")
+(title (text "{{ note.title }}"))
+(text "{% else %}")
+(title (text "{{ journal.title }}"))
+(text "{%- endif %} {% else %}")
+(title (text "Journals - {{ config.name }}"))
+(text "{%- endif %}")
 
 (text "{% if note and journal and owner -%}")
 (meta
     ("name" "og:title")
     ("content" "{{ note.title }}"))
 
+(text "{% if not global_mode -%}")
 (meta
     ("name" "description")
     ("content" "View this note from the {{ journal.title }} journal on {{ config.name }}!"))
@@ -14,6 +22,23 @@
     ("name" "og:description")
     ("content" "View this note from the {{ journal.title }} journal on {{ config.name }}!"))
 
+(meta
+    ("name" "twitter:description")
+    ("content" "View this note from the {{ journal.title }} journal on {{ config.name }}!"))
+(text "{% else %}")
+(meta
+    ("name" "description")
+    ("content" "View this note on {{ config.name }}!"))
+
+(meta
+    ("name" "og:description")
+    ("content" "View this note on {{ config.name }}!"))
+
+(meta
+    ("name" "twitter:description")
+    ("content" "View this note on {{ config.name }}!"))
+(text "{%- endif %}")
+
 (meta
     ("property" "og:type")
     ("content" "website"))
@@ -33,10 +58,6 @@
 (meta
     ("name" "twitter:title")
     ("content" "{{ note.title }}"))
-
-(meta
-    ("name" "twitter:description")
-    ("content" "View this note from the {{ journal.title }} journal on {{ config.name }}!"))
 (text "{%- endif %}")
 (link ("rel" "stylesheet") ("data-turbo-temporary" "true") ("href" "/css/chats.css?v=tetratto-{{ random_cache_breaker }}"))
 
@@ -73,7 +94,7 @@
 ; add journal css
 (link ("rel" "stylesheet") ("data-turbo-temporary" "true") ("href" "/api/v1/journals/{{ journal.id }}/journal.css?v=tetratto-{{ random_cache_breaker }}"))
 (text "{%- endif %}")
-(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"journals\") }}")
+(text "{% endblock %} {% block body %} {% if not global_mode -%} {{ macros::nav(selected=\"journals\") }} {%- endif %}")
 (text "{% if not view_mode -%}")
 (nav
     ("class" "chats_nav")
@@ -117,7 +138,7 @@
         (main
             ("class" "flex flex-col gap-2")
             ; the journal/note header is always shown
-            (text "{% if journal -%}")
+            (text "{% if journal and not global_mode -%}")
             (div
                 ("class" "mobile_nav w-full flex items-center justify-between gap-2")
                 (div
@@ -126,8 +147,8 @@
                         ("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\") }}"))
+                            ("href" "/@{{ owner.username }}")
+                            (text "{{ components::avatar(username=owner.username, selector_type=\"username\", size=\"18px\") }}"))
 
                         (text "{% if (view_mode and owner) or not view_mode -%}")
                         (a
@@ -462,19 +483,35 @@
             (div
                 ("class" "flex w-full justify-between gap-2")
                 (div
-                    ("class" "flex flex-col gap-2")
+                    ("class" "flex flex-col gap-2 fade")
                     (span (text "Last updated: ") (span ("class" "date") (text "{{ note.edited }}")))
+
+                    (text "{% if global_mode -%}")
+                    (span ("class" "flex gap-1") (text "Created by: ") (text "{{ components::full_username(user=owner) }}"))
+                    (span (text "Views: {{ redis_views }}"))
+                    (text "{% elif note.is_global -%}")
+                    ; globsl note, but we aren't viewing globally...
+                    (a
+                        ("href" "/x/{{ note.title }}")
+                        ("class" "button lowered small green")
+                        (icon (text "globe"))
+                        (text "View as global"))
+                    (text "{%- endif %}")
+
                     (text "{{ components::note_tags(note=note) }}"))
 
                 (text "{% if user and user.id == owner.id -%}")
                 (button
                     ("class" "small")
-                    ("onclick" "{% if journal.privacy == \"Public\" -%}
+                    ("onclick" "{% if note.is_global -%}
+                        trigger('atto::copy_text', ['{{ config.host }}/x/{{ note.title }}'])
+                    {%- else -%}
+                    {% if journal.privacy == \"Public\" -%}
                         trigger('atto::copy_text', ['{{ config.host }}/@{{ owner.username }}/{{ journal.title }}/{{ note.title }}'])
                     {%- else -%}
                         prompt_make_public();
                         trigger('atto::copy_text', ['{{ config.host }}/@{{ owner.username }}/{{ journal.title }}/{{ note.title }}'])
-                    {%- endif %}")
+                    {%- endif -%} {%- endif %}")
                     (icon (text "share"))
                     (str (text "general:label.share")))
 
@@ -809,6 +846,62 @@
                 });
         }
 
+        globalThis.publish_note = async (id) => {
+            if (
+                !(await trigger(\"atto::confirm\", [
+                    `Are you sure you would like to do this? The note will be public at '/x/name', even if the journal is private.
+
+Publishing your note is specifically for making the note accessible through the global endpoint. The note will be public under your username as long as the journal is public.`,
+                ]))
+            ) {
+                return;
+            }
+
+            fetch(`/api/v1/notes/${id}/global`, {
+                method: \"POST\",
+            })
+                .then((res) => res.json())
+                .then((res) => {
+                    trigger(\"atto::toast\", [
+                        res.ok ? \"success\" : \"error\",
+                        res.message,
+                    ]);
+
+                    if (res.ok) {
+                        setTimeout(() => {
+                            window.location.reload();
+                        }, 100);
+                    }
+                });
+        }
+
+        globalThis.unpublish_note = async (id) => {
+            if (
+                !(await trigger(\"atto::confirm\", [
+                    \"Are you sure you would like to do this? This global note name will be made available.\",
+                ]))
+            ) {
+                return;
+            }
+
+            fetch(`/api/v1/notes/${id}/global`, {
+                method: \"DELETE\",
+            })
+                .then((res) => res.json())
+                .then((res) => {
+                    trigger(\"atto::toast\", [
+                        res.ok ? \"success\" : \"error\",
+                        res.message,
+                    ]);
+
+                    if (res.ok) {
+                        setTimeout(() => {
+                            window.location.reload();
+                        }, 100);
+                    }
+                });
+        }
+
         // sidebars
         window.SIDEBARS_OPEN = false;
         if (new URLSearchParams(window.location.search).get(\"nav\") === \"true\") {
diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp
index a25f833..0867dda 100644
--- a/crates/app/src/public/html/profile/settings.lisp
+++ b/crates/app/src/public/html/profile/settings.lisp
@@ -693,6 +693,8 @@
                                     (text "Create infinite journals"))
                                 (li
                                     (text "Create infinite notes in each journal"))
+                                (li
+                                    (text "Publish up to 50 notes"))
 
                                 (text "{% if config.security.enable_invite_codes -%}")
                                 (li
diff --git a/crates/app/src/public/js/streams.js b/crates/app/src/public/js/streams.js
index 88f4f7d..8b9954d 100644
--- a/crates/app/src/public/js/streams.js
+++ b/crates/app/src/public/js/streams.js
@@ -42,7 +42,7 @@
             },
         };
 
-        socket.addEventListener("message", (event) => {
+        socket.addEventListener("message", async (event) => {
             if (event.data === "Ping") {
                 return socket.send("Pong");
             }
@@ -54,14 +54,14 @@
                 return console.info(`${stream} ${data.data}`);
             }
 
-            return $.sock(stream).events.message(data);
+            return (await $.sock(stream)).events.message(data);
         });
 
         return $.STREAMS[stream];
     });
 
-    self.define("close", ({ $ }, stream) => {
-        const socket = $.sock(stream);
+    self.define("close", async ({ $ }, stream) => {
+        const socket = await $.sock(stream);
 
         if (!socket) {
             console.warn("no such stream to close");
diff --git a/crates/app/src/routes/api/v1/auth/links.rs b/crates/app/src/routes/api/v1/auth/links.rs
deleted file mode 100644
index ecc921b..0000000
--- a/crates/app/src/routes/api/v1/auth/links.rs
+++ /dev/null
@@ -1,285 +0,0 @@
-use axum::{
-    response::IntoResponse,
-    extract::{Json, Path},
-    Extension,
-};
-use axum_extra::extract::CookieJar;
-use crate::{
-    get_user_from_token,
-    image::{save_webp_buffer, JsonMultipart},
-    routes::api::v1::{
-        CreateLink, UpdateLinkHref, UpdateLinkLabel, UpdateLinkPosition, UploadLinkIcon,
-    },
-    State,
-};
-use tetratto_core::model::{
-    links::Link,
-    oauth,
-    uploads::{MediaType, MediaUpload},
-    ApiReturn, Error,
-};
-
-pub async fn get_request(
-    jar: CookieJar,
-    Path(id): Path<usize>,
-    Extension(data): Extension<State>,
-) -> impl IntoResponse {
-    let data = &(data.read().await).0;
-    if get_user_from_token!(jar, data, oauth::AppScope::UserReadLinks).is_none() {
-        return Json(Error::NotAllowed.into());
-    };
-
-    let link = match data.get_link_by_id(id).await {
-        Ok(x) => x,
-        Err(e) => return Json(e.into()),
-    };
-
-    Json(ApiReturn {
-        ok: true,
-        message: "Success".to_string(),
-        payload: Some(link),
-    })
-}
-
-pub async fn list_request(jar: CookieJar, Extension(data): Extension<State>) -> impl IntoResponse {
-    let data = &(data.read().await).0;
-    let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadLinks) {
-        Some(ua) => ua,
-        None => return Json(Error::NotAllowed.into()),
-    };
-
-    match data.get_links_by_owner(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<State>,
-    Json(props): Json<CreateLink>,
-) -> impl IntoResponse {
-    let data = &(data.read().await).0;
-    let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateLinks) {
-        Some(ua) => ua,
-        None => return Json(Error::NotAllowed.into()),
-    };
-
-    match data
-        .create_link(
-            Link::new(
-                user.id,
-                props.label,
-                props.href,
-                match data.get_links_by_owner_count(user.id).await {
-                    Ok(c) => (c + 1) as usize,
-                    Err(e) => return Json(e.into()),
-                },
-            ),
-            &user,
-        )
-        .await
-    {
-        Ok(x) => Json(ApiReturn {
-            ok: true,
-            message: "Link created".to_string(),
-            payload: Some(x.id.to_string()),
-        }),
-        Err(e) => Json(e.into()),
-    }
-}
-
-pub async fn update_label_request(
-    jar: CookieJar,
-    Path(id): Path<usize>,
-    Extension(data): Extension<State>,
-    Json(props): Json<UpdateLinkLabel>,
-) -> 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()),
-    };
-
-    let link = match data.get_link_by_id(id).await {
-        Ok(n) => n,
-        Err(e) => return Json(e.into()),
-    };
-
-    if link.owner != user.id {
-        return Json(Error::NotAllowed.into());
-    }
-
-    // ...
-    match data.update_link_label(id, &props.label).await {
-        Ok(_) => Json(ApiReturn {
-            ok: true,
-            message: "Link updated".to_string(),
-            payload: (),
-        }),
-        Err(e) => Json(e.into()),
-    }
-}
-
-pub async fn update_href_request(
-    jar: CookieJar,
-    Path(id): Path<usize>,
-    Extension(data): Extension<State>,
-    Json(props): Json<UpdateLinkHref>,
-) -> 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()),
-    };
-
-    let link = match data.get_link_by_id(id).await {
-        Ok(n) => n,
-        Err(e) => return Json(e.into()),
-    };
-
-    if link.owner != user.id {
-        return Json(Error::NotAllowed.into());
-    }
-
-    // ...
-    match data.update_link_href(id, &props.href).await {
-        Ok(_) => Json(ApiReturn {
-            ok: true,
-            message: "Link updated".to_string(),
-            payload: (),
-        }),
-        Err(e) => Json(e.into()),
-    }
-}
-
-pub async fn update_position_request(
-    jar: CookieJar,
-    Path(id): Path<usize>,
-    Extension(data): Extension<State>,
-    Json(props): Json<UpdateLinkPosition>,
-) -> 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()),
-    };
-
-    let link = match data.get_link_by_id(id).await {
-        Ok(n) => n,
-        Err(e) => return Json(e.into()),
-    };
-
-    if link.owner != user.id {
-        return Json(Error::NotAllowed.into());
-    }
-
-    if props.position < 0 {
-        return Json(Error::MiscError("Position must be an unsigned integer".to_string()).into());
-    }
-
-    // ...
-    match data.update_link_position(id, props.position).await {
-        Ok(_) => Json(ApiReturn {
-            ok: true,
-            message: "Link updated".to_string(),
-            payload: (),
-        }),
-        Err(e) => Json(e.into()),
-    }
-}
-
-pub const MAXIMUM_FILE_SIZE: usize = 131072; // 128 KiB
-
-pub async fn upload_icon_request(
-    jar: CookieJar,
-    Extension(data): Extension<State>,
-    JsonMultipart(images, props): JsonMultipart<UploadLinkIcon>,
-) -> 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()),
-    };
-
-    let id = match props.id.parse::<usize>() {
-        Ok(i) => i,
-        Err(_) => return Json(Error::Unknown.into()),
-    };
-
-    let link = match data.get_link_by_id(id).await {
-        Ok(n) => n,
-        Err(e) => return Json(e.into()),
-    };
-
-    if link.owner != user.id {
-        return Json(Error::NotAllowed.into());
-    }
-
-    // create upload
-    let upload = match data
-        .create_upload(MediaUpload::new(MediaType::Webp, user.id))
-        .await
-    {
-        Ok(n) => n,
-        Err(e) => return Json(e.into()),
-    };
-
-    let image = match images.get(0) {
-        Some(i) => i,
-        None => return Json(Error::MiscError("Missing file".to_string()).into()),
-    };
-
-    if image.len() > MAXIMUM_FILE_SIZE {
-        return Json(Error::FileTooLarge.into());
-    }
-
-    // upload
-    if let Err(e) = save_webp_buffer(&upload.path(&data.0.0).to_string(), image.to_vec(), None) {
-        return Json(Error::MiscError(e.to_string()).into());
-    }
-
-    // ...
-    match data.update_link_upload_id(id, upload.id as i64).await {
-        Ok(_) => Json(ApiReturn {
-            ok: true,
-            message: "Link updated".to_string(),
-            payload: (),
-        }),
-        Err(e) => Json(e.into()),
-    }
-}
-
-pub async fn delete_request(
-    jar: CookieJar,
-    Extension(data): Extension<State>,
-    Path(id): Path<usize>,
-) -> impl IntoResponse {
-    let data = &(data.read().await).0;
-    let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageStacks) {
-        Some(ua) => ua,
-        None => return Json(Error::NotAllowed.into()),
-    };
-
-    let link = match data.get_link_by_id(id).await {
-        Ok(n) => n,
-        Err(e) => return Json(e.into()),
-    };
-
-    if link.owner != user.id {
-        return Json(Error::NotAllowed.into());
-    }
-
-    match data.delete_link(id).await {
-        Ok(_) => Json(ApiReturn {
-            ok: true,
-            message: "Link deleted".to_string(),
-            payload: (),
-        }),
-        Err(e) => Json(e.into()),
-    }
-}
diff --git a/crates/app/src/routes/api/v1/auth/mod.rs b/crates/app/src/routes/api/v1/auth/mod.rs
index 670a44f..a332dd8 100644
--- a/crates/app/src/routes/api/v1/auth/mod.rs
+++ b/crates/app/src/routes/api/v1/auth/mod.rs
@@ -1,7 +1,6 @@
 pub mod connections;
 pub mod images;
 pub mod ipbans;
-pub mod links;
 pub mod profile;
 pub mod social;
 pub mod user_warnings;
diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs
index e7b9f46..9f850af 100644
--- a/crates/app/src/routes/api/v1/mod.rs
+++ b/crates/app/src/routes/api/v1/mod.rs
@@ -588,6 +588,8 @@ pub fn routes() -> Router {
         .route("/notes/{id}/content", post(notes::update_content_request))
         .route("/notes/{id}/dir", post(notes::update_dir_request))
         .route("/notes/{id}/tags", post(notes::update_tags_request))
+        .route("/notes/{id}/global", post(notes::publish_request))
+        .route("/notes/{id}/global", delete(notes::unpublish_request))
         .route("/notes/from_journal/{id}", get(notes::list_request))
         .route("/notes/preview", post(notes::render_markdown_request))
         .route(
@@ -597,18 +599,6 @@ pub fn routes() -> Router {
         // uploads
         .route("/uploads/{id}", get(uploads::get_request))
         .route("/uploads/{id}", delete(uploads::delete_request))
-        // links
-        .route("/links", get(auth::links::list_request))
-        .route("/links", post(auth::links::create_request))
-        .route("/links/{id}", get(auth::links::get_request))
-        .route("/links/{id}", delete(auth::links::delete_request))
-        .route("/links/icon", post(auth::links::upload_icon_request))
-        .route("/links/{id}/label", post(auth::links::update_label_request))
-        .route("/links/{id}/href", post(auth::links::update_href_request))
-        .route(
-            "/links/{id}/position",
-            post(auth::links::update_position_request),
-        )
 }
 
 #[derive(Deserialize)]
@@ -982,29 +972,3 @@ pub struct RemoveJournalDir {
 pub struct UpdateNoteTags {
     pub tags: Vec<String>,
 }
-
-#[derive(Deserialize)]
-pub struct CreateLink {
-    pub label: String,
-    pub href: String,
-}
-
-#[derive(Deserialize)]
-pub struct UpdateLinkLabel {
-    pub label: String,
-}
-
-#[derive(Deserialize)]
-pub struct UpdateLinkHref {
-    pub href: String,
-}
-
-#[derive(Deserialize)]
-pub struct UpdateLinkPosition {
-    pub position: i32,
-}
-
-#[derive(Deserialize)]
-pub struct UploadLinkIcon {
-    pub id: String,
-}
diff --git a/crates/app/src/routes/api/v1/notes.rs b/crates/app/src/routes/api/v1/notes.rs
index 9a18559..b6bc986 100644
--- a/crates/app/src/routes/api/v1/notes.rs
+++ b/crates/app/src/routes/api/v1/notes.rs
@@ -164,11 +164,21 @@ pub async fn update_title_request(
 
     // ...
     match data.update_note_title(id, &user, &props.title).await {
-        Ok(_) => Json(ApiReturn {
-            ok: true,
-            message: "Note updated".to_string(),
-            payload: (),
-        }),
+        Ok(_) => {
+            // update note global status
+            if note.is_global {
+                if let Err(e) = data.update_note_is_global(id, 0).await {
+                    return Json(e.into());
+                }
+            }
+
+            // ...
+            Json(ApiReturn {
+                ok: true,
+                message: "Note updated".to_string(),
+                payload: (),
+            })
+        }
         Err(e) => Json(e.into()),
     }
 }
@@ -318,3 +328,92 @@ pub async fn update_tags_request(
         Err(e) => Json(e.into()),
     }
 }
+
+pub async fn publish_request(
+    jar: CookieJar,
+    Path(id): Path<usize>,
+    Extension(data): Extension<State>,
+) -> 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()),
+    };
+
+    let note = match data.get_note_by_id(id).await {
+        Ok(n) => n,
+        Err(e) => return Json(e.into()),
+    };
+
+    if user.id != note.owner {
+        return Json(Error::NotAllowed.into());
+    }
+
+    // check count
+    if data.get_user_global_notes_count(user.id).await.unwrap_or(0)
+        >= if user.permissions.check(FinePermission::SUPPORTER) {
+            10
+        } else {
+            5
+        }
+    {
+        return Json(
+            Error::MiscError(
+                "You already have the maximum number of global notes you can have".to_string(),
+            )
+            .into(),
+        );
+    }
+
+    // make sure note doesn't already exist globally
+    if data.get_global_note_by_title(&note.title).await.is_ok() {
+        return Json(
+            Error::MiscError(
+                "Note name is already in use globally. Please change the name and try again"
+                    .to_string(),
+            )
+            .into(),
+        );
+    }
+
+    // ...
+    match data.update_note_is_global(id, 1).await {
+        Ok(_) => Json(ApiReturn {
+            ok: true,
+            message: "Note updated".to_string(),
+            payload: (),
+        }),
+        Err(e) => Json(e.into()),
+    }
+}
+
+pub async fn unpublish_request(
+    jar: CookieJar,
+    Path(id): Path<usize>,
+    Extension(data): Extension<State>,
+) -> 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()),
+    };
+
+    let note = match data.get_note_by_id(id).await {
+        Ok(n) => n,
+        Err(e) => return Json(e.into()),
+    };
+
+    if user.id != note.owner {
+        return Json(Error::NotAllowed.into());
+    }
+
+    // ...
+    match data.update_note_is_global(id, 0).await {
+        Ok(_) => Json(ApiReturn {
+            ok: true,
+            message: "Note updated".to_string(),
+            payload: (),
+        }),
+        Err(e) => Json(e.into()),
+    }
+}
diff --git a/crates/app/src/routes/pages/journals.rs b/crates/app/src/routes/pages/journals.rs
index 434c819..0c35e04 100644
--- a/crates/app/src/routes/pages/journals.rs
+++ b/crates/app/src/routes/pages/journals.rs
@@ -81,7 +81,7 @@ pub async fn app_request(
     };
 
     let lang = get_lang!(jar, data.0);
-    let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
+    let mut context = initial_context(&data.0.0.0, lang, &Some(user.clone())).await;
 
     context.insert("selected_journal", &selected_journal);
     context.insert("selected_note", &selected_note);
@@ -89,6 +89,7 @@ pub async fn app_request(
     context.insert("journal", &journal);
     context.insert("note", &note);
 
+    context.insert("owner", &user);
     context.insert("journals", &journals);
     context.insert("notes", &notes);
 
@@ -185,6 +186,10 @@ pub async fn view_request(
         context.insert("selected_note", &0);
     } else {
         context.insert("selected_note", &selected_note);
+        context.insert(
+            "redis_views",
+            &data.0.get_note_views(note.as_ref().unwrap().id).await,
+        );
     }
 
     context.insert("journal", &journal);
@@ -292,3 +297,70 @@ pub async fn index_view_request(
     // return
     Ok(Html(data.1.render("journals/app.html", &context).unwrap()))
 }
+
+/// `/x/{note}`
+pub async fn global_view_request(
+    jar: CookieJar,
+    Extension(data): Extension<State>,
+    Path(mut selected_note): Path<String>,
+) -> impl IntoResponse {
+    let data = data.read().await;
+    let user = match get_user_from_token!(jar, data.0) {
+        Some(ua) => Some(ua),
+        None => None,
+    };
+
+    if selected_note == "index" {
+        selected_note = String::new();
+    }
+
+    // if we don't have a selected journal, we shouldn't be here probably
+    if selected_note == "journal.css" {
+        return Err(Html(
+            render_error(Error::NotAllowed, &jar, &data, &user).await,
+        ));
+    }
+
+    // ...
+    let note = match data.0.get_global_note_by_title(&selected_note).await {
+        Ok(x) => x,
+        Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
+    };
+
+    let journal = match data.0.get_journal_by_id(note.journal).await {
+        Ok(x) => x,
+        Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
+    };
+
+    // get owner
+    let owner = match data.0.get_user_by_id(note.owner).await {
+        Ok(ua) => ua,
+        Err(e) => {
+            return Err(Html(render_error(e, &jar, &data, &user).await));
+        }
+    };
+
+    check_user_blocked_or_private!(user, owner, data, jar);
+
+    // ...
+    let lang = get_lang!(jar, data.0);
+    let mut context = initial_context(&data.0.0.0, lang, &user).await;
+    data.0.incr_note_views(note.id).await;
+
+    context.insert("selected_journal", &note.journal);
+    context.insert("selected_note", &selected_note);
+    context.insert("redis_views", &data.0.get_note_views(note.id).await);
+
+    context.insert("journal", &journal);
+    context.insert("note", &note);
+
+    context.insert("owner", &owner);
+    context.insert::<[i8; 0], &str>("notes", &[]);
+
+    context.insert("view_mode", &true);
+    context.insert("is_editor", &false);
+    context.insert("global_mode", &true);
+
+    // return
+    Ok(Html(data.1.render("journals/app.html", &context).unwrap()))
+}
diff --git a/crates/app/src/routes/pages/links.rs b/crates/app/src/routes/pages/links.rs
deleted file mode 100644
index 3cec4bd..0000000
--- a/crates/app/src/routes/pages/links.rs
+++ /dev/null
@@ -1,47 +0,0 @@
-use axum::{
-    extract::Path,
-    response::{Html, IntoResponse},
-    Extension,
-};
-use axum_extra::extract::CookieJar;
-use tetratto_core::model::{permissions::FinePermission, Error};
-use crate::{get_user_from_token, State};
-use super::render_error;
-
-/// `/links/{id}`
-pub async fn navigate_request(
-    jar: CookieJar,
-    Path(id): Path<usize>,
-    Extension(data): Extension<State>,
-) -> impl IntoResponse {
-    let data = data.read().await;
-    let user = match get_user_from_token!(jar, data.0) {
-        Some(ua) => ua,
-        None => {
-            return Err(Html(
-                render_error(Error::NotAllowed, &jar, &data, &None).await,
-            ));
-        }
-    };
-
-    let link = match data.0.get_link_by_id(id).await {
-        Ok(x) => x,
-        Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
-    };
-
-    let owner = match data.0.get_user_by_id(link.owner).await {
-        Ok(x) => x,
-        Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
-    };
-
-    if owner.permissions.check(FinePermission::SUPPORTER) {
-        if let Err(e) = data.0.incr_link_clicks(link.id).await {
-            return Err(Html(render_error(e, &jar, &data, &Some(user)).await));
-        }
-    }
-
-    Ok(Html(format!(
-        "<!doctype html /><html><head><meta http-equiv=\"refresh\" content=\"0; url={}\" /></head><body>Navigating...</body></html>",
-        link.href
-    )))
-}
diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs
index f527f2a..a2ca470 100644
--- a/crates/app/src/routes/pages/mod.rs
+++ b/crates/app/src/routes/pages/mod.rs
@@ -4,7 +4,6 @@ pub mod communities;
 pub mod developer;
 pub mod forge;
 pub mod journals;
-pub mod links;
 pub mod misc;
 pub mod mod_panel;
 pub mod profile;
@@ -138,8 +137,7 @@ pub fn routes() -> Router {
         .route("/journals/{journal}/{note}", get(journals::app_request))
         .route("/@{owner}/{journal}", get(journals::index_view_request))
         .route("/@{owner}/{journal}/{note}", get(journals::view_request))
-        // links
-        .route("/links/{id}", get(links::navigate_request))
+        .route("/x/{note}", get(journals::global_view_request))
 }
 
 pub async fn render_error(
diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs
index b3695b7..45111db 100644
--- a/crates/core/src/database/common.rs
+++ b/crates/core/src/database/common.rs
@@ -40,7 +40,6 @@ impl DataManager {
         execute!(&conn, common::CREATE_TABLE_NOTES).unwrap();
         execute!(&conn, common::CREATE_TABLE_MESSAGE_REACTIONS).unwrap();
         execute!(&conn, common::CREATE_TABLE_INVITE_CODES).unwrap();
-        execute!(&conn, common::CREATE_TABLE_LINKS).unwrap();
 
         self.0
             .1
diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs
index 04417c2..e1cfad7 100644
--- a/crates/core/src/database/drivers/common.rs
+++ b/crates/core/src/database/drivers/common.rs
@@ -27,4 +27,3 @@ pub const CREATE_TABLE_JOURNALS: &str = include_str!("./sql/create_journals.sql"
 pub const CREATE_TABLE_NOTES: &str = include_str!("./sql/create_notes.sql");
 pub const CREATE_TABLE_MESSAGE_REACTIONS: &str = include_str!("./sql/create_message_reactions.sql");
 pub const CREATE_TABLE_INVITE_CODES: &str = include_str!("./sql/create_invite_codes.sql");
-pub const CREATE_TABLE_LINKS: &str = include_str!("./sql/create_links.sql");
diff --git a/crates/core/src/database/drivers/sql/create_links.sql b/crates/core/src/database/drivers/sql/create_links.sql
deleted file mode 100644
index 0c1fc25..0000000
--- a/crates/core/src/database/drivers/sql/create_links.sql
+++ /dev/null
@@ -1,10 +0,0 @@
-CREATE TABLE IF NOT EXISTS links (
-    id BIGINT NOT NULL PRIMARY KEY,
-    created BIGINT NOT NULL,
-    owner BIGINT NOT NULL,
-    label TEXT NOT NULL,
-    href TEXT NOT NULL,
-    upload_id BIGINT NOT NULL,
-    clicks INT NOT NULL,
-    position INT 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 2c85588..5018f91 100644
--- a/crates/core/src/database/drivers/sql/create_notes.sql
+++ b/crates/core/src/database/drivers/sql/create_notes.sql
@@ -7,5 +7,6 @@ CREATE TABLE IF NOT EXISTS notes (
     content TEXT NOT NULL,
     edited BIGINT NOT NULL,
     dir BIGINT NOT NULL,
-    tags TEXT NOT NULL
+    tags TEXT NOT NULL,
+    is_global INT NOT NULL
 )
diff --git a/crates/core/src/database/links.rs b/crates/core/src/database/links.rs
deleted file mode 100644
index cb00c93..0000000
--- a/crates/core/src/database/links.rs
+++ /dev/null
@@ -1,146 +0,0 @@
-use oiseau::{cache::Cache, query_row, query_rows};
-use crate::model::{auth::User, links::Link, permissions::FinePermission, Error, Result};
-use crate::{auto_method, DataManager};
-use oiseau::{PostgresRow, execute, get, params};
-
-impl DataManager {
-    /// Get a [`Link`] from an SQL row.
-    pub(crate) fn get_link_from_row(x: &PostgresRow) -> Link {
-        Link {
-            id: get!(x->0(i64)) as usize,
-            created: get!(x->1(i64)) as usize,
-            owner: get!(x->2(i64)) as usize,
-            label: get!(x->3(String)),
-            href: get!(x->4(String)),
-            upload_id: get!(x->5(i64)) as usize,
-            clicks: get!(x->6(i32)) as usize,
-            position: get!(x->7(i32)) as usize,
-        }
-    }
-
-    auto_method!(get_link_by_id()@get_link_from_row -> "SELECT * FROM links WHERE id = $1" --name="link" --returns=Link --cache-key-tmpl="atto.link:{}");
-
-    /// Get links by `owner`.
-    pub async fn get_links_by_owner(&self, owner: usize) -> Result<Vec<Link>> {
-        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 links WHERE owner = $1 ORDER BY position DESC",
-            &[&(owner as i64)],
-            |x| { Self::get_link_from_row(x) }
-        );
-
-        if res.is_err() {
-            return Err(Error::GeneralNotFound("link".to_string()));
-        }
-
-        Ok(res.unwrap())
-    }
-
-    /// Get links by `owner`.
-    pub async fn get_links_by_owner_count(&self, owner: usize) -> Result<i32> {
-        let conn = match self.0.connect().await {
-            Ok(c) => c,
-            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
-        };
-
-        let res = query_row!(
-            &conn,
-            "SELECT COUNT(*)::int FROM links WHERE owner = $1",
-            &[&(owner as i64)],
-            |x| Ok(x.get::<usize, i32>(0))
-        );
-
-        if res.is_err() {
-            return Err(Error::GeneralNotFound("link".to_string()));
-        }
-
-        Ok(res.unwrap())
-    }
-
-    const MAXIMUM_FREE_LINKS: usize = 10;
-    const MAXIMUM_SUPPORTER_LINKS: usize = 20;
-
-    /// Create a new link in the database.
-    ///
-    /// # Arguments
-    /// * `data` - a mock [`Link`] object to insert
-    pub async fn create_link(&self, data: Link, user: &User) -> Result<Link> {
-        if !user.permissions.check(FinePermission::SUPPORTER) {
-            if (self.get_links_by_owner_count(user.id).await? as usize) >= Self::MAXIMUM_FREE_LINKS
-            {
-                return Err(Error::MiscError(
-                    "You already have the maximum number of links you can create".to_string(),
-                ));
-            }
-        } else if !user.permissions.check(FinePermission::MANAGE_USERS) {
-            if (self.get_links_by_owner_count(user.id).await? as usize)
-                >= Self::MAXIMUM_SUPPORTER_LINKS
-            {
-                return Err(Error::MiscError(
-                    "You already have the maximum number of links you can create".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 links VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
-            params![
-                &(data.id as i64),
-                &(data.created as i64),
-                &(data.owner as i64),
-                &data.label,
-                &data.href,
-                &(data.upload_id as i64),
-                &(data.clicks as i32),
-                &(data.position as i32),
-            ]
-        );
-
-        if let Err(e) = res {
-            return Err(Error::DatabaseError(e.to_string()));
-        }
-
-        Ok(data)
-    }
-
-    pub async fn delete_link(&self, id: usize) -> Result<()> {
-        let y = self.get_link_by_id(id).await?;
-
-        let conn = match self.0.connect().await {
-            Ok(c) => c,
-            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
-        };
-
-        let res = execute!(&conn, "DELETE FROM links WHERE id = $1", &[&(id as i64)]);
-
-        if let Err(e) = res {
-            return Err(Error::DatabaseError(e.to_string()));
-        }
-
-        // delete upload
-        if y.upload_id != 0 {
-            self.delete_upload(id).await?;
-        }
-
-        // ...
-        self.0.1.remove(format!("atto.link:{}", id)).await;
-        Ok(())
-    }
-
-    auto_method!(update_link_label(&str) -> "UPDATE links SET label = $1 WHERE id = $2" --cache-key-tmpl="atto.link:{}");
-    auto_method!(update_link_href(&str) -> "UPDATE links SET href = $1 WHERE id = $2" --cache-key-tmpl="atto.link:{}");
-    auto_method!(update_link_upload_id(i64) -> "UPDATE links SET upload_id = $1 WHERE id = $2" --cache-key-tmpl="atto.link:{}");
-    auto_method!(update_link_position(i32) -> "UPDATE links SET position = $1 WHERE id = $2" --cache-key-tmpl="atto.link:{}");
-    auto_method!(incr_link_clicks() -> "UPDATE links SET clicks = $1 WHERE id = $2" --cache-key-tmpl="atto.link:{}" --incr);
-}
diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs
index 20575e0..5f81259 100644
--- a/crates/core/src/database/mod.rs
+++ b/crates/core/src/database/mod.rs
@@ -12,7 +12,6 @@ mod invite_codes;
 mod ipbans;
 mod ipblocks;
 mod journals;
-mod links;
 mod memberships;
 mod message_reactions;
 mod messages;
diff --git a/crates/core/src/database/notes.rs b/crates/core/src/database/notes.rs
index 364a540..2754baf 100644
--- a/crates/core/src/database/notes.rs
+++ b/crates/core/src/database/notes.rs
@@ -17,10 +17,33 @@ impl DataManager {
             edited: get!(x->6(i64)) as usize,
             dir: get!(x->7(i64)) as usize,
             tags: serde_json::from_str(&get!(x->8(String))).unwrap(),
+            is_global: get!(x->9(i32)) as i8 == 1,
         }
     }
 
     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:{}");
+    auto_method!(get_global_note_by_title(&str)@get_note_from_row -> "SELECT * FROM notes WHERE title = $1 AND is_global = 1" --name="note" --returns=Note --cache-key-tmpl="atto.note:{}");
+
+    /// Get the number of global notes a user has.
+    pub async fn get_user_global_notes_count(&self, owner: usize) -> Result<i32> {
+        let conn = match self.0.connect().await {
+            Ok(c) => c,
+            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
+        };
+
+        let res = query_row!(
+            &conn,
+            "SELECT COUNT(*)::int FROM notes WHERE owner = $1 AND is_global = 1",
+            &[&(owner as i64)],
+            |x| Ok(x.get::<usize, i32>(0))
+        );
+
+        if let Err(e) = res {
+            return Err(Error::DatabaseError(e.to_string()));
+        }
+
+        Ok(res.unwrap())
+    }
 
     /// Get a note by `journal` and `title`.
     pub async fn get_note_by_journal_title(&self, journal: usize, title: &str) -> Result<Note> {
@@ -94,6 +117,9 @@ impl DataManager {
 
     const MAXIMUM_FREE_NOTES_PER_JOURNAL: usize = 10;
 
+    pub const MAXIMUM_FREE_GLOBAL_NOTES: usize = 10;
+    pub const MAXIMUM_SUPPORTER_GLOBAL_NOTES: usize = 50;
+
     /// Create a new note in the database.
     ///
     /// # Arguments
@@ -164,7 +190,7 @@ impl DataManager {
 
         let res = execute!(
             &conn,
-            "INSERT INTO notes VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
+            "INSERT INTO notes VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
             params![
                 &(data.id as i64),
                 &(data.created as i64),
@@ -175,6 +201,7 @@ impl DataManager {
                 &(data.edited as i64),
                 &(data.dir as i64),
                 &serde_json::to_string(&data.tags).unwrap(),
+                &if data.is_global { 1 } else { 0 }
             ]
         );
 
@@ -206,7 +233,7 @@ impl DataManager {
         }
 
         // ...
-        self.0.1.remove(format!("atto.note:{}", id)).await;
+        self.cache_clear_note(&note).await;
         Ok(())
     }
 
@@ -246,9 +273,26 @@ impl DataManager {
         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:{}");
-    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:{}");
-    auto_method!(update_note_dir(i64)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET dir = $1 WHERE id = $2" --cache-key-tmpl="atto.note:{}");
-    auto_method!(update_note_tags(Vec<String>)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET tags = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.note:{}");
-    auto_method!(update_note_edited(i64) -> "UPDATE notes SET edited = $1 WHERE id = $2" --cache-key-tmpl="atto.note:{}");
+    /// Incremenet note views. Views are only stored in the cache.
+    ///
+    /// This should only be done for global notes.
+    pub async fn incr_note_views(&self, id: usize) {
+        self.0.1.incr(format!("atto.note:{id}/views")).await;
+    }
+
+    pub async fn get_note_views(&self, id: usize) -> Option<String> {
+        self.0.1.get(format!("atto.note:{id}/views")).await
+    }
+
+    pub async fn cache_clear_note(&self, x: &Note) {
+        self.0.1.remove(format!("atto.note:{}", x.id)).await;
+        self.0.1.remove(format!("atto.note:{}", x.title)).await;
+    }
+
+    auto_method!(update_note_title(&str)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET title = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_note);
+    auto_method!(update_note_content(&str)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET content = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_note);
+    auto_method!(update_note_dir(i64)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET dir = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_note);
+    auto_method!(update_note_tags(Vec<String>)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET tags = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_note);
+    auto_method!(update_note_edited(i64)@get_note_by_id -> "UPDATE notes SET edited = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_note);
+    auto_method!(update_note_is_global(i32)@get_note_by_id -> "UPDATE notes SET is_global = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_note);
 }
diff --git a/crates/core/src/database/reactions.rs b/crates/core/src/database/reactions.rs
index 72ef2bc..347548f 100644
--- a/crates/core/src/database/reactions.rs
+++ b/crates/core/src/database/reactions.rs
@@ -175,7 +175,7 @@ impl DataManager {
                 &(data.owner as i64),
                 &(data.asset as i64),
                 &serde_json::to_string(&data.asset_type).unwrap().as_str(),
-                &{ if data.is_like { 1 } else { 0 } }
+                &if data.is_like { 1 } else { 0 }
             ]
         );
 
diff --git a/crates/core/src/model/journals.rs b/crates/core/src/model/journals.rs
index 3d70ce0..8c2491d 100644
--- a/crates/core/src/model/journals.rs
+++ b/crates/core/src/model/journals.rs
@@ -60,6 +60,7 @@ pub struct Note {
     pub dir: usize,
     /// An array of tags associated with the note.
     pub tags: Vec<String>,
+    pub is_global: bool,
 }
 
 impl Note {
@@ -77,6 +78,7 @@ impl Note {
             edited: created,
             dir: 0,
             tags: Vec::new(),
+            is_global: false,
         }
     }
 }
diff --git a/crates/core/src/model/links.rs b/crates/core/src/model/links.rs
deleted file mode 100644
index 19b77f2..0000000
--- a/crates/core/src/model/links.rs
+++ /dev/null
@@ -1,41 +0,0 @@
-use serde::{Serialize, Deserialize};
-use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp};
-
-#[derive(Serialize, Deserialize, Clone, Debug)]
-pub struct Link {
-    pub id: usize,
-    pub created: usize,
-    /// Links should be selected by their owner, not their link list ID.
-    /// This is why we do not store link list ID.
-    pub owner: usize,
-    pub label: String,
-    pub href: String,
-    /// As link icons are optional, `upload_id` is allowed to be 0.
-    pub upload_id: usize,
-    /// Clicks are tracked for supporters only.
-    ///
-    /// When a user clicks on a link through the UI, they'll be redirect to
-    /// `/links/{id}`. If the link's owner is a supporter, the link's clicks will
-    /// be incremented.
-    ///
-    /// The page should just serve a simple HTML document with a meta tag to redirect.
-    /// We only really care about knowing they clicked it, so an automatic redirect will do.
-    pub clicks: usize,
-    pub position: usize,
-}
-
-impl Link {
-    /// Create a new [`Link`].
-    pub fn new(owner: usize, label: String, href: String, position: usize) -> Self {
-        Self {
-            id: Snowflake::new().to_string().parse::<usize>().unwrap(),
-            created: unix_epoch_timestamp(),
-            owner,
-            label,
-            href,
-            upload_id: 0,
-            clicks: 0,
-            position,
-        }
-    }
-}
diff --git a/crates/core/src/model/mod.rs b/crates/core/src/model/mod.rs
index 5a6933b..839310f 100644
--- a/crates/core/src/model/mod.rs
+++ b/crates/core/src/model/mod.rs
@@ -6,7 +6,6 @@ pub mod channels;
 pub mod communities;
 pub mod communities_permissions;
 pub mod journals;
-pub mod links;
 pub mod moderation;
 pub mod oauth;
 pub mod permissions;
diff --git a/sql_changes/notes_is_global.sql b/sql_changes/notes_is_global.sql
new file mode 100644
index 0000000..eeea878
--- /dev/null
+++ b/sql_changes/notes_is_global.sql
@@ -0,0 +1,2 @@
+ALTER TABLE notes
+ADD COLUMN is_global INT NOT NULL DEFAULT 0;

From aeaa230162cd330320d4741f1a4e7d365e7e6cc5 Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Thu, 26 Jun 2025 03:07:27 -0400
Subject: [PATCH 63/71] fix: make sure timeline loads data

---
 crates/app/src/public/js/atto.js | 8 ++++++++
 1 file changed, 8 insertions(+)

diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js
index 7624c76..852e6d6 100644
--- a/crates/app/src/public/js/atto.js
+++ b/crates/app/src/public/js/atto.js
@@ -1194,6 +1194,7 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
         self.IO_DATA_PAGE = page;
         self.IO_DATA_SEEN_IDS = [];
         self.IO_DATA_WAITING = false;
+        self.IO_HAS_LOADED_AT_LEAST_ONCE = false;
 
         if (!paginated_mode) {
             self.IO_DATA_OBSERVER.observe(self.IO_DATA_MARKER);
@@ -1204,6 +1205,12 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
             self.io_load_data();
         }
 
+        setTimeout(() => {
+            if (!self.IO_HAS_LOADED_AT_LEAST_ONCE) {
+                self.io_load_data();
+            }
+        }, 1500);
+
         self.IO_PAGINATED = paginated_mode;
     });
 
@@ -1212,6 +1219,7 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
             return;
         }
 
+        self.IO_HAS_LOADED_AT_LEAST_ONCE = true;
         self.IO_DATA_WAITING = true;
         self.IO_DATA_PAGE += 1;
         console.log("load page", self.IO_DATA_PAGE);

From 87b61d7717559f8290a653b31eda23a0742bf407 Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Thu, 26 Jun 2025 13:41:08 -0400
Subject: [PATCH 64/71] fix: various infinite timeline issues

---
 crates/app/src/public/js/atto.js | 15 +++++++++++----
 1 file changed, 11 insertions(+), 4 deletions(-)

diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js
index 852e6d6..0ff868f 100644
--- a/crates/app/src/public/js/atto.js
+++ b/crates/app/src/public/js/atto.js
@@ -288,7 +288,7 @@ media_theme_pref();
         const goals = [150, 250, 500, 1000];
 
         track_element.setAttribute("data-scroll", "0");
-        scroll_element.addEventListener("scroll", (e) => {
+        scroll_element.addEventListener("scroll", (_) => {
             track_element.setAttribute("data-scroll", scroll_element.scrollTop);
 
             for (const goal of goals) {
@@ -635,7 +635,6 @@ media_theme_pref();
                                     return;
                                 }
 
-                                // biome-ignore lint/style/noParameterAssign: no it isn't
                                 page += 1;
                                 await load_partial();
                             })
@@ -837,7 +836,6 @@ media_theme_pref();
         }, time_until_remove * 1000);
 
         const count_interval = setInterval(() => {
-            // biome-ignore lint/style/noParameterAssign: no it isn't
             time_until_remove -= 1;
             timer.innerText = time_until_remove;
         }, 1000);
@@ -1195,6 +1193,7 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
         self.IO_DATA_SEEN_IDS = [];
         self.IO_DATA_WAITING = false;
         self.IO_HAS_LOADED_AT_LEAST_ONCE = false;
+        self.IO_DATA_DISCONNECTED = false;
 
         if (!paginated_mode) {
             self.IO_DATA_OBSERVER.observe(self.IO_DATA_MARKER);
@@ -1207,7 +1206,10 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
 
         setTimeout(() => {
             if (!self.IO_HAS_LOADED_AT_LEAST_ONCE) {
-                self.io_load_data();
+                // reload
+                Turbo.visit(window.location.href);
+                self.IO_DATA_OBSERVER.disconnect();
+                console.log("timeline load fail :(");
             }
         }, 1500);
 
@@ -1243,12 +1245,17 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
             loading_skel.remove();
         }
 
+        if (self.IO_DATA_DISCONNECTED) {
+            return;
+        }
+
         if (
             text.includes(`!<!-- observer_disconnect_${window.BUILD_CODE} -->`)
         ) {
             console.log("io_data_end; disconnect");
             self.IO_DATA_OBSERVER.disconnect();
             self.IO_DATA_ELEMENT.innerHTML += text;
+            self.IO_DATA_DISCONNECTED = true;
             return;
         }
 

From f622fb1125a0f53ac72462c1eff01c534f6ce023 Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Thu, 26 Jun 2025 13:46:37 -0400
Subject: [PATCH 65/71] fix: user connection song duration ui

---
 crates/app/src/public/js/atto.js | 12 ++++++------
 crates/app/src/public/js/me.js   |  2 +-
 2 files changed, 7 insertions(+), 7 deletions(-)

diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js
index 0ff868f..0fd18fb 100644
--- a/crates/app/src/public/js/atto.js
+++ b/crates/app/src/public/js/atto.js
@@ -454,18 +454,18 @@ media_theme_pref();
         }
     });
 
-    self.define("hooks::spotify_time_text", (_) => {
+    self.define("hooks::spotify_time_text", async (_) => {
         for (const element of Array.from(
             document.querySelectorAll("[hook=spotify_time_text]") || [],
         )) {
-            function render() {
+            async function render() {
                 const updated = element.getAttribute("hook-arg:updated");
                 const progress = element.getAttribute("hook-arg:progress");
                 const duration = element.getAttribute("hook-arg:duration");
                 const display =
                     element.getAttribute("hook-arg:display") || "full";
 
-                element.innerHTML = trigger("spotify::timestamp", [
+                element.innerHTML = await trigger("spotify::timestamp", [
                     updated,
                     progress,
                     duration,
@@ -473,7 +473,7 @@ media_theme_pref();
                 ]);
             }
 
-            setInterval(() => {
+            setInterval(async () => {
                 element.setAttribute(
                     "hook-arg:updated",
                     Number.parseInt(element.getAttribute("hook-arg:updated")) +
@@ -486,10 +486,10 @@ media_theme_pref();
                         1000,
                 );
 
-                render();
+                await render();
             }, 1000);
 
-            render();
+            await render();
         }
     });
 
diff --git a/crates/app/src/public/js/me.js b/crates/app/src/public/js/me.js
index 4eb0f69..ed8eeb0 100644
--- a/crates/app/src/public/js/me.js
+++ b/crates/app/src/public/js/me.js
@@ -1134,7 +1134,7 @@
                     artist: playing.artist.name,
                     album: playing.album["#text"],
                     // times
-                    timestamp: new Date().getTime().toString(),
+                    timestamp: Date.now().toString(),
                     duration_ms: (mb_info.length || 0).toString(),
                 },
             },

From 5bfbd4e110d4ab792db4fd4caab3f7dc68f16dc7 Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Thu, 26 Jun 2025 13:49:21 -0400
Subject: [PATCH 66/71] fix: timeline infinite reload

---
 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 0fd18fb..480713f 100644
--- a/crates/app/src/public/js/atto.js
+++ b/crates/app/src/public/js/atto.js
@@ -1207,9 +1207,9 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
         setTimeout(() => {
             if (!self.IO_HAS_LOADED_AT_LEAST_ONCE) {
                 // reload
-                Turbo.visit(window.location.href);
                 self.IO_DATA_OBSERVER.disconnect();
                 console.log("timeline load fail :(");
+                window.location.reload();
             }
         }, 1500);
 

From 904944f5d35d7786ecd6ca00f5bc5aad3cb710f1 Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Thu, 26 Jun 2025 13:53:19 -0400
Subject: [PATCH 67/71] fix: client share intents

---
 crates/app/src/public/js/me.js | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/crates/app/src/public/js/me.js b/crates/app/src/public/js/me.js
index ed8eeb0..31290c9 100644
--- a/crates/app/src/public/js/me.js
+++ b/crates/app/src/public/js/me.js
@@ -587,17 +587,17 @@
         },
     );
 
-    self.define("intent_twitter", (_, text) => {
+    self.define("intent_twitter", async (_, text_promise) => {
         window.open(
-            `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}`,
+            `https://twitter.com/intent/tweet?text=${encodeURIComponent(await text_promise)}`,
         );
 
         trigger("atto::toast", ["success", "Opened intent!"]);
     });
 
-    self.define("intent_bluesky", (_, text) => {
+    self.define("intent_bluesky", async (_, text_promise) => {
         window.open(
-            `https://bsky.app/intent/compose?text=${encodeURIComponent(text)}`,
+            `https://bsky.app/intent/compose?text=${encodeURIComponent(await text_promise)}`,
         );
 
         trigger("atto::toast", ["success", "Opened intent!"]);

From 4b7808e70b0c62c10096366f4949f63de45d390c Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Thu, 26 Jun 2025 13:58:10 -0400
Subject: [PATCH 68/71] fix: client timeline load disconnect issue

---
 crates/app/src/public/js/atto.js | 8 +++++++-
 1 file changed, 7 insertions(+), 1 deletion(-)

diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js
index 480713f..93e4bea 100644
--- a/crates/app/src/public/js/atto.js
+++ b/crates/app/src/public/js/atto.js
@@ -878,7 +878,7 @@ media_theme_pref();
 })();
 
 // ui ns
-(() => {
+(async () => {
     const self = reg_ns("ui");
     window.SETTING_SET_FUNCTIONS = [];
 
@@ -1151,6 +1151,12 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
     });
 
     // intersection observer infinite scrolling
+    const obs = (await ns("ui")).IO_DATA_OBSERVER;
+    if (obs) {
+        console.log("get lost old observer");
+        obs.disconnect();
+    }
+
     self.IO_DATA_OBSERVER = new IntersectionObserver(
         async (entries) => {
             for (const entry of entries) {

From 45ea91a768f49eb66053f8cb8c6106b4ed9dad24 Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Thu, 26 Jun 2025 14:30:15 -0400
Subject: [PATCH 69/71] fix: profile infinite reload

---
 crates/app/src/public/html/profile/posts.lisp | 5 +++--
 crates/app/src/public/js/atto.js              | 6 ++++++
 2 files changed, 9 insertions(+), 2 deletions(-)

diff --git a/crates/app/src/public/html/profile/posts.lisp b/crates/app/src/public/html/profile/posts.lisp
index bbdd6a5..e2454b0 100644
--- a/crates/app/src/public/html/profile/posts.lisp
+++ b/crates/app/src/public/html/profile/posts.lisp
@@ -46,8 +46,9 @@
 
 (text "{% set paged = user and user.settings.paged_timelines %}")
 (script
-    (text "setTimeout(() => {
-        trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?user_id={{ profile.id }}&tag={{ tag }}&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
+    (text "setTimeout(async () => {
+        await trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?user_id={{ profile.id }}&tag={{ tag }}&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
+        (await ns(\"ui\")).IO_DATA_DISABLE_RELOAD = true;
     });"))
 
 (text "{% endblock %}")
diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js
index 93e4bea..0bd8d62 100644
--- a/crates/app/src/public/js/atto.js
+++ b/crates/app/src/public/js/atto.js
@@ -1200,6 +1200,7 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
         self.IO_DATA_WAITING = false;
         self.IO_HAS_LOADED_AT_LEAST_ONCE = false;
         self.IO_DATA_DISCONNECTED = false;
+        self.IO_DATA_DISABLE_RELOAD = false;
 
         if (!paginated_mode) {
             self.IO_DATA_OBSERVER.observe(self.IO_DATA_MARKER);
@@ -1211,6 +1212,11 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
         }
 
         setTimeout(() => {
+            if (self.IO_DATA_DISABLE_RELOAD) {
+                console.log("missing data reload disabled");
+                return;
+            }
+
             if (!self.IO_HAS_LOADED_AT_LEAST_ONCE) {
                 // reload
                 self.IO_DATA_OBSERVER.disconnect();

From e7c4cf14aa3d53de7c467077be491e1063ee3bc8 Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Fri, 27 Jun 2025 01:38:35 -0400
Subject: [PATCH 70/71] add: option to clear all notifications when you open
 the page

---
 crates/app/src/public/html/profile/posts.lisp           | 2 +-
 crates/app/src/public/html/profile/settings.lisp        | 5 +++++
 crates/app/src/routes/api/v1/auth/connections/stripe.rs | 4 ++--
 crates/app/src/routes/pages/misc.rs                     | 8 ++++++++
 crates/core/src/model/auth.rs                           | 3 +++
 5 files changed, 19 insertions(+), 3 deletions(-)

diff --git a/crates/app/src/public/html/profile/posts.lisp b/crates/app/src/public/html/profile/posts.lisp
index e2454b0..6c417a6 100644
--- a/crates/app/src/public/html/profile/posts.lisp
+++ b/crates/app/src/public/html/profile/posts.lisp
@@ -49,6 +49,6 @@
     (text "setTimeout(async () => {
         await trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?user_id={{ profile.id }}&tag={{ tag }}&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
         (await ns(\"ui\")).IO_DATA_DISABLE_RELOAD = true;
-    });"))
+    }, 500);"))
 
 (text "{% endblock %}")
diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp
index 0867dda..2b12aa0 100644
--- a/crates/app/src/public/html/profile/settings.lisp
+++ b/crates/app/src/public/html/profile/settings.lisp
@@ -1536,6 +1536,11 @@
                         \"{{ profile.settings.paged_timelines }}\",
                         \"checkbox\",
                     ],
+                    [
+                        [\"auto_clear_notifs\", \"Automatically clear all notifications when you open the notifications page\"],
+                        \"{{ profile.settings.auto_clear_notifs }}\",
+                        \"checkbox\",
+                    ],
                     [[], \"Fun\", \"title\"],
                     [
                         [\"disable_gpa_fun\", \"Disable GPA\"],
diff --git a/crates/app/src/routes/api/v1/auth/connections/stripe.rs b/crates/app/src/routes/api/v1/auth/connections/stripe.rs
index b0eaee6..6ef6fcd 100644
--- a/crates/app/src/routes/api/v1/auth/connections/stripe.rs
+++ b/crates/app/src/routes/api/v1/auth/connections/stripe.rs
@@ -81,7 +81,7 @@ pub async fn stripe_webhook(
 
             loop {
                 if retries >= 5 {
-                    // we've already tried 5 times (10 seconds of waiting)... it's not
+                    // we've already tried 5 times (25 seconds of waiting)... it's not
                     // going to happen
                     //
                     // we're going to report this error to the audit log so someone can
@@ -111,7 +111,7 @@ pub async fn stripe_webhook(
                     Err(_) => {
                         tracing::info!("checkout session not stored in db yet");
                         retries += 1;
-                        tokio::time::sleep(Duration::from_secs(2)).await;
+                        tokio::time::sleep(Duration::from_secs(5)).await;
                         continue;
                     }
                 }
diff --git a/crates/app/src/routes/pages/misc.rs b/crates/app/src/routes/pages/misc.rs
index d83a695..f11c116 100644
--- a/crates/app/src/routes/pages/misc.rs
+++ b/crates/app/src/routes/pages/misc.rs
@@ -331,6 +331,14 @@ pub async fn notifications_request(
         Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
     };
 
+    // check and clear
+    if profile.settings.auto_clear_notifs {
+        if let Err(e) = data.0.delete_all_notifications(&user).await {
+            return Err(Html(render_error(e, &jar, &data, &Some(user)).await));
+        }
+    }
+
+    // ...
     let lang = get_lang!(jar, data.0);
     let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
 
diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs
index 64ebc62..d38a576 100644
--- a/crates/core/src/model/auth.rs
+++ b/crates/core/src/model/auth.rs
@@ -252,6 +252,9 @@ pub struct UserSettings {
     /// Hide posts that are answering a question on the "All" timeline.
     #[serde(default)]
     pub all_timeline_hide_answers: bool,
+    /// Automatically clear all notifications when notifications are viewed.
+    #[serde(default)]
+    pub auto_clear_notifs: bool,
 }
 
 fn mime_avif() -> String {

From b860f741243ddc7e652b191180ab4d7a83f09857 Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Fri, 27 Jun 2025 03:45:50 -0400
Subject: [PATCH 71/71] add: user achievements

---
 crates/app/src/assets.rs                      |  2 +
 crates/app/src/langs/en-US.toml               |  1 +
 crates/app/src/public/html/components.lisp    |  4 +
 .../src/public/html/misc/achievements.lisp    | 42 +++++++++
 crates/app/src/routes/api/v1/auth/profile.rs  | 10 ++-
 crates/app/src/routes/api/v1/auth/social.rs   |  9 +-
 .../src/routes/api/v1/communities/posts.rs    | 36 ++++++++
 .../routes/api/v1/communities/questions.rs    | 12 ++-
 crates/app/src/routes/api/v1/journals.rs      | 22 +++--
 crates/app/src/routes/pages/misc.rs           | 28 ++++++
 crates/app/src/routes/pages/mod.rs            |  1 +
 crates/core/src/database/auth.rs              | 67 +++++++++++++-
 .../src/database/drivers/sql/create_users.sql |  3 +-
 crates/core/src/model/auth.rs                 | 90 +++++++++++++++++++
 sql_changes/users_achievements.sql            |  2 +
 15 files changed, 318 insertions(+), 11 deletions(-)
 create mode 100644 crates/app/src/public/html/misc/achievements.lisp
 create mode 100644 sql_changes/users_achievements.sql

diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs
index 120f7e9..3958f09 100644
--- a/crates/app/src/assets.rs
+++ b/crates/app/src/assets.rs
@@ -51,6 +51,7 @@ pub const MISC_ERROR: &str = include_str!("./public/html/misc/error.lisp");
 pub const MISC_NOTIFICATIONS: &str = include_str!("./public/html/misc/notifications.lisp");
 pub const MISC_MARKDOWN: &str = include_str!("./public/html/misc/markdown.lisp");
 pub const MISC_REQUESTS: &str = include_str!("./public/html/misc/requests.lisp");
+pub const MISC_ACHIEVEMENTS: &str = include_str!("./public/html/misc/achievements.lisp");
 
 pub const AUTH_BASE: &str = include_str!("./public/html/auth/base.lisp");
 pub const AUTH_LOGIN: &str = include_str!("./public/html/auth/login.lisp");
@@ -349,6 +350,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD {
     write_template!(html_path->"misc/notifications.html"(crate::assets::MISC_NOTIFICATIONS) --config=config --lisp plugins);
     write_template!(html_path->"misc/markdown.html"(crate::assets::MISC_MARKDOWN) --config=config --lisp plugins);
     write_template!(html_path->"misc/requests.html"(crate::assets::MISC_REQUESTS) --config=config --lisp plugins);
+    write_template!(html_path->"misc/achievements.html"(crate::assets::MISC_ACHIEVEMENTS) --config=config --lisp plugins);
 
     write_template!(html_path->"auth/base.html"(crate::assets::AUTH_BASE) -d "auth" --config=config --lisp plugins);
     write_template!(html_path->"auth/login.html"(crate::assets::AUTH_LOGIN) --config=config --lisp plugins);
diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml
index bab0525..13b6b64 100644
--- a/crates/app/src/langs/en-US.toml
+++ b/crates/app/src/langs/en-US.toml
@@ -17,6 +17,7 @@ version = "1.0.0"
 "general:link.stats" = "Stats"
 "general:link.search" = "Search"
 "general:link.journals" = "Journals"
+"general:link.achievements" = "Achievements"
 "general:action.save" = "Save"
 "general:action.delete" = "Delete"
 "general:action.purge" = "Purge"
diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp
index bcdb77d..75f9620 100644
--- a/crates/app/src/public/html/components.lisp
+++ b/crates/app/src/public/html/components.lisp
@@ -1083,6 +1083,10 @@
         ("href" "/journals/0/0")
         (icon (text "notebook"))
         (str (text "general:link.journals")))
+    (a
+        ("href" "/achievements")
+        (icon (text "award"))
+        (str (text "general:link.achievements")))
     (a
         ("href" "/settings")
         (text "{{ icon \"settings\" }}")
diff --git a/crates/app/src/public/html/misc/achievements.lisp b/crates/app/src/public/html/misc/achievements.lisp
new file mode 100644
index 0000000..4b21b5d
--- /dev/null
+++ b/crates/app/src/public/html/misc/achievements.lisp
@@ -0,0 +1,42 @@
+(text "{% extends \"root.html\" %} {% block head %}")
+(title
+    (text "Achievements - {{ config.name }}"))
+
+(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"achievements\") }}")
+(main
+    ("class" "flex flex-col gap-2")
+    (div
+        ("class" "card-nest")
+        (div
+            ("class" "card small flex items-center gap-2")
+            (icon (text "coffee"))
+            (span (text "Welcome to {{ config.name }}!")))
+        (div
+            ("class" "card no_p_margin")
+            (p (text "To help you move in, you'll be rewarded with small achievements for completing simple tasks on {{ config.name }}!"))
+            (p (text "You'll find out what each achievement is when you get it, so look around!"))))
+
+    (div
+        ("class" "card-nest")
+        (div
+            ("class" "card small flex items-center justify-between gap-2")
+            (span
+                ("class" "flex items-center gap-2")
+                (icon (text "award"))
+                (span (str (text "general:link.achievements")))))
+        (div
+            ("class" "card lowered flex flex-col gap-4")
+            (text "{% for achievement in achievements %}")
+            (div
+                ("class" "w-full card-nest")
+                (div
+                    ("class" "card small flex items-center gap-2 {% if achievement[2] == 'Uncommon' -%} green {%- elif achievement[2] == 'Rare' -%} purple {%- endif %}")
+                    (icon (text "award"))
+                    (text "{{ achievement[0] }}"))
+                (div
+                    ("class" "card flex flex-col gap-2")
+                    (span ("class" "no_p_margin") (text "{{ achievement[1]|markdown|safe }}"))
+                    (hr)
+                    (span ("class" "fade") (text "Unlocked: ") (span ("class" "date") (text "{{ achievement[3].unlocked }}")))))
+            (text "{% endfor %}"))))
+(text "{% endblock %}")
diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs
index 3ee8b54..baf2f54 100644
--- a/crates/app/src/routes/api/v1/auth/profile.rs
+++ b/crates/app/src/routes/api/v1/auth/profile.rs
@@ -21,7 +21,7 @@ use futures_util::{sink::SinkExt, stream::StreamExt};
 use tetratto_core::{
     cache::Cache,
     model::{
-        auth::{InviteCode, Token, UserSettings},
+        auth::{AchievementName, InviteCode, Token, UserSettings},
         moderation::AuditLogEntry,
         oauth,
         permissions::FinePermission,
@@ -151,6 +151,14 @@ pub async fn update_user_settings_request(
         req.theme_lit = format!("{}%", req.theme_lit)
     }
 
+    // award achievement
+    if let Err(e) = data
+        .add_achievement(&user, AchievementName::EditSettings.into())
+        .await
+    {
+        return Json(e.into());
+    }
+
     // ...
     match data.update_user_settings(id, req).await {
         Ok(_) => Json(ApiReturn {
diff --git a/crates/app/src/routes/api/v1/auth/social.rs b/crates/app/src/routes/api/v1/auth/social.rs
index cef005c..a507e49 100644
--- a/crates/app/src/routes/api/v1/auth/social.rs
+++ b/crates/app/src/routes/api/v1/auth/social.rs
@@ -11,7 +11,7 @@ use axum::{
 };
 use axum_extra::extract::CookieJar;
 use tetratto_core::model::{
-    auth::{FollowResult, IpBlock, Notification, UserBlock, UserFollow},
+    auth::{AchievementName, FollowResult, IpBlock, Notification, UserBlock, UserFollow},
     oauth,
 };
 
@@ -59,6 +59,13 @@ pub async fn follow_request(
                         return Json(e.into());
                     };
 
+                    if let Err(e) = data
+                        .add_achievement(&user, AchievementName::FollowUser.into())
+                        .await
+                    {
+                        return Json(e.into());
+                    }
+
                     Json(ApiReturn {
                         ok: true,
                         message: "User followed".to_string(),
diff --git a/crates/app/src/routes/api/v1/communities/posts.rs b/crates/app/src/routes/api/v1/communities/posts.rs
index 70529ed..e1fb89e 100644
--- a/crates/app/src/routes/api/v1/communities/posts.rs
+++ b/crates/app/src/routes/api/v1/communities/posts.rs
@@ -7,6 +7,7 @@ use axum::{
 use axum_extra::extract::CookieJar;
 use tetratto_core::model::{
     addr::RemoteAddr,
+    auth::AchievementName,
     communities::{Poll, PollVote, Post},
     oauth,
     permissions::FinePermission,
@@ -178,6 +179,41 @@ pub async fn create_request(
                 }
             }
 
+            // achievements
+            if let Err(e) = data
+                .add_achievement(&user, AchievementName::CreatePost.into())
+                .await
+            {
+                return Json(e.into());
+            }
+
+            if user.post_count >= 49 {
+                if let Err(e) = data
+                    .add_achievement(&user, AchievementName::Create50Posts.into())
+                    .await
+                {
+                    return Json(e.into());
+                }
+            }
+
+            if user.post_count >= 99 {
+                if let Err(e) = data
+                    .add_achievement(&user, AchievementName::Create100Posts.into())
+                    .await
+                {
+                    return Json(e.into());
+                }
+            }
+
+            if user.post_count >= 999 {
+                if let Err(e) = data
+                    .add_achievement(&user, AchievementName::Create1000Posts.into())
+                    .await
+                {
+                    return Json(e.into());
+                }
+            }
+
             // return
             Json(ApiReturn {
                 ok: true,
diff --git a/crates/app/src/routes/api/v1/communities/questions.rs b/crates/app/src/routes/api/v1/communities/questions.rs
index 5e9de0d..c469512 100644
--- a/crates/app/src/routes/api/v1/communities/questions.rs
+++ b/crates/app/src/routes/api/v1/communities/questions.rs
@@ -7,7 +7,7 @@ use axum::{
 use axum_extra::extract::CookieJar;
 use tetratto_core::model::{
     addr::RemoteAddr,
-    auth::IpBlock,
+    auth::{AchievementName, IpBlock},
     communities::{CommunityReadAccess, Question},
     oauth,
     permissions::FinePermission,
@@ -50,6 +50,16 @@ pub async fn create_request(
         return Json(Error::NotAllowed.into());
     }
 
+    // award achievement
+    if let Some(ref user) = user {
+        if let Err(e) = data
+            .add_achievement(user, AchievementName::CreateQuestion.into())
+            .await
+        {
+            return Json(e.into());
+        }
+    }
+
     // ...
     let mut props = Question::new(
         if let Some(ref ua) = user { ua.id } else { 0 },
diff --git a/crates/app/src/routes/api/v1/journals.rs b/crates/app/src/routes/api/v1/journals.rs
index 45ac04f..e2f9404 100644
--- a/crates/app/src/routes/api/v1/journals.rs
+++ b/crates/app/src/routes/api/v1/journals.rs
@@ -15,6 +15,7 @@ use crate::{
 use tetratto_core::{
     database::NAME_REGEX,
     model::{
+        auth::AchievementName,
         journals::{Journal, JournalPrivacyPermission},
         oauth,
         permissions::FinePermission,
@@ -106,11 +107,22 @@ pub async fn create_request(
         .create_journal(Journal::new(user.id, props.title))
         .await
     {
-        Ok(x) => Json(ApiReturn {
-            ok: true,
-            message: "Journal created".to_string(),
-            payload: Some(x.id.to_string()),
-        }),
+        Ok(x) => {
+            // award achievement
+            if let Err(e) = data
+                .add_achievement(&user, AchievementName::CreateJournal.into())
+                .await
+            {
+                return Json(e.into());
+            }
+
+            // ...
+            Json(ApiReturn {
+                ok: true,
+                message: "Journal created".to_string(),
+                payload: Some(x.id.to_string()),
+            })
+        }
         Err(e) => Json(e.into()),
     }
 }
diff --git a/crates/app/src/routes/pages/misc.rs b/crates/app/src/routes/pages/misc.rs
index f11c116..44f54aa 100644
--- a/crates/app/src/routes/pages/misc.rs
+++ b/crates/app/src/routes/pages/misc.rs
@@ -441,6 +441,34 @@ pub async fn requests_request(
     Ok(Html(data.1.render("misc/requests.html", &context).unwrap()))
 }
 
+/// `/achievements`
+pub async fn achievements_request(
+    jar: CookieJar,
+    Extension(data): Extension<State>,
+) -> impl IntoResponse {
+    let data = data.read().await;
+    let user = match get_user_from_token!(jar, data.0) {
+        Some(ua) => ua,
+        None => {
+            return Err(Html(
+                render_error(Error::NotAllowed, &jar, &data, &None).await,
+            ));
+        }
+    };
+
+    let achievements = data.0.fill_achievements(user.achievements.clone());
+
+    // ...
+    let lang = get_lang!(jar, data.0);
+    let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
+    context.insert("achievements", &achievements);
+
+    // return
+    Ok(Html(
+        data.1.render("misc/achievements.html", &context).unwrap(),
+    ))
+}
+
 /// `/doc/{file_name}`
 pub async fn markdown_document_request(
     jar: CookieJar,
diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs
index a2ca470..909fa2d 100644
--- a/crates/app/src/routes/pages/mod.rs
+++ b/crates/app/src/routes/pages/mod.rs
@@ -45,6 +45,7 @@ pub fn routes() -> Router {
         // misc
         .route("/notifs", get(misc::notifications_request))
         .route("/requests", get(misc::requests_request))
+        .route("/achievements", get(misc::achievements_request))
         .route("/doc/{*file_name}", get(misc::markdown_document_request))
         .fallback_service(get(misc::not_found))
         // mod
diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs
index 4037db7..4baeef0 100644
--- a/crates/core/src/database/auth.rs
+++ b/crates/core/src/database/auth.rs
@@ -1,6 +1,6 @@
 use super::common::NAME_REGEX;
 use oiseau::cache::Cache;
-use crate::model::auth::UserConnections;
+use crate::model::auth::{Achievement, AchievementRarity, Notification, UserConnections};
 use crate::model::moderation::AuditLogEntry;
 use crate::model::oauth::AuthGrant;
 use crate::model::permissions::SecondaryPermission;
@@ -111,6 +111,7 @@ impl DataManager {
             associated: serde_json::from_str(&get!(x->20(String)).to_string()).unwrap(),
             invite_code: get!(x->21(i64)) as usize,
             secondary_permissions: SecondaryPermission::from_bits(get!(x->22(i32)) as u32).unwrap(),
+            achievements: serde_json::from_str(&get!(x->23(String)).to_string()).unwrap(),
         }
     }
 
@@ -266,7 +267,7 @@ impl DataManager {
 
         let res = execute!(
             &conn,
-            "INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23)",
+            "INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)",
             params![
                 &(data.id as i64),
                 &(data.created as i64),
@@ -291,6 +292,7 @@ impl DataManager {
                 &serde_json::to_string(&data.associated).unwrap(),
                 &(data.invite_code as i64),
                 &(SecondaryPermission::DEFAULT.bits() as i32),
+                &serde_json::to_string(&data.achievements).unwrap(),
             ]
         );
 
@@ -707,6 +709,66 @@ impl DataManager {
         Ok(())
     }
 
+    /// Add an achievement to a user.
+    ///
+    /// Still returns `Ok` if the user already has the achievement.
+    pub async fn add_achievement(&self, user: &User, achievement: Achievement) -> Result<()> {
+        if user
+            .achievements
+            .iter()
+            .find(|x| x.name == achievement.name)
+            .is_some()
+        {
+            return Ok(());
+        }
+
+        // send notif
+        self.create_notification(Notification::new(
+            "You've earned a new achievement!".to_string(),
+            format!(
+                "You've earned the \"{}\" [achievement](/achievements)!",
+                achievement.name.title()
+            ),
+            user.id,
+        ))
+        .await?;
+
+        // add achievement
+        let mut user = user.clone();
+        user.achievements.push(achievement);
+        self.update_user_achievements(user.id, user.achievements)
+            .await?;
+
+        Ok(())
+    }
+
+    /// Fill achievements with their title and description.
+    ///
+    /// # Returns
+    /// `(name, description, rarity, achievement)`
+    pub fn fill_achievements(
+        &self,
+        mut list: Vec<Achievement>,
+    ) -> Vec<(String, String, AchievementRarity, Achievement)> {
+        let mut out = Vec::new();
+
+        // sort by unlocked desc
+        list.sort_by(|a, b| a.unlocked.cmp(&b.unlocked));
+        list.reverse();
+
+        // ...
+        for x in list {
+            out.push((
+                x.name.title().to_string(),
+                x.name.description().to_string(),
+                x.name.rarity(),
+                x,
+            ))
+        }
+
+        out
+    }
+
     /// Validate a given TOTP code for the given profile.
     pub fn check_totp(&self, ua: &User, code: &str) -> bool {
         let totp = ua.totp(Some(
@@ -857,6 +919,7 @@ impl DataManager {
     auto_method!(update_user_settings(UserSettings)@get_user_by_id -> "UPDATE users SET settings = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user);
     auto_method!(update_user_connections(UserConnections)@get_user_by_id -> "UPDATE users SET connections = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user);
     auto_method!(update_user_associated(Vec<usize>)@get_user_by_id -> "UPDATE users SET associated = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user);
+    auto_method!(update_user_achievements(Vec<Achievement>)@get_user_by_id -> "UPDATE users SET achievements = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user);
 
     auto_method!(get_user_by_stripe_id(&str)@get_user_from_row -> "SELECT * FROM users WHERE stripe_id = $1" --name="user" --returns=User);
     auto_method!(update_user_stripe_id(&str)@get_user_by_id -> "UPDATE users SET stripe_id = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user);
diff --git a/crates/core/src/database/drivers/sql/create_users.sql b/crates/core/src/database/drivers/sql/create_users.sql
index 467d00a..9cb0851 100644
--- a/crates/core/src/database/drivers/sql/create_users.sql
+++ b/crates/core/src/database/drivers/sql/create_users.sql
@@ -20,5 +20,6 @@ CREATE TABLE IF NOT EXISTS users (
     stripe_id TEXT NOT NULL,
     grants TEXT NOT NULL,
     associated TEXT NOT NULL,
-    secondary_permissions INT NOT NULL
+    secondary_permissions INT NOT NULL,
+    achievements TEXT NOT NULL
 )
diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs
index d38a576..4617770 100644
--- a/crates/core/src/model/auth.rs
+++ b/crates/core/src/model/auth.rs
@@ -58,6 +58,9 @@ pub struct User {
     /// Secondary permissions because the regular permissions struct ran out of possible bits.
     #[serde(default)]
     pub secondary_permissions: SecondaryPermission,
+    /// Users collect achievements through little actions across the site.
+    #[serde(default)]
+    pub achievements: Vec<Achievement>,
 }
 
 pub type UserConnections =
@@ -297,6 +300,7 @@ impl User {
             associated: Vec::new(),
             invite_code: 0,
             secondary_permissions: SecondaryPermission::DEFAULT,
+            achievements: Vec::new(),
         }
     }
 
@@ -470,6 +474,92 @@ pub struct ExternalConnectionData {
     pub data: HashMap<String, String>,
 }
 
+/// The total number of achievements needed to 100% Tetratto!
+pub const ACHIEVEMENTS: usize = 8;
+
+#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
+pub enum AchievementName {
+    /// Create your first post.
+    CreatePost,
+    /// Follow somebody.
+    FollowUser,
+    /// Create your 50th post.
+    Create50Posts,
+    /// Create your 100th post.
+    Create100Posts,
+    /// Create your 1000th post.
+    Create1000Posts,
+    /// Ask your first question.
+    CreateQuestion,
+    /// Edit your settings.
+    EditSettings,
+    /// Create your first journal.
+    CreateJournal,
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
+pub enum AchievementRarity {
+    Common,
+    Uncommon,
+    Rare,
+}
+
+impl AchievementName {
+    pub fn title(&self) -> &str {
+        match self {
+            Self::CreatePost => "Dear friends,",
+            Self::FollowUser => "Virtual connections...",
+            Self::Create50Posts => "Hello, world!",
+            Self::Create100Posts => "It's my world",
+            Self::Create1000Posts => "Timeline domination",
+            Self::CreateQuestion => "Big questions...",
+            Self::EditSettings => "Just how I like it!",
+            Self::CreateJournal => "Dear diary...",
+        }
+    }
+
+    pub fn description(&self) -> &str {
+        match self {
+            Self::CreatePost => "Create your first post!",
+            Self::FollowUser => "Follow somebody!",
+            Self::Create50Posts => "Create your 50th post.",
+            Self::Create100Posts => "Create your 100th post.",
+            Self::Create1000Posts => "Create your 1000th post.",
+            Self::CreateQuestion => "Ask your first question!",
+            Self::EditSettings => "Edit your settings.",
+            Self::CreateJournal => "Create your first journal.",
+        }
+    }
+
+    pub fn rarity(&self) -> AchievementRarity {
+        match self {
+            Self::CreatePost => AchievementRarity::Common,
+            Self::FollowUser => AchievementRarity::Common,
+            Self::Create50Posts => AchievementRarity::Uncommon,
+            Self::Create100Posts => AchievementRarity::Uncommon,
+            Self::Create1000Posts => AchievementRarity::Rare,
+            Self::CreateQuestion => AchievementRarity::Common,
+            Self::EditSettings => AchievementRarity::Common,
+            Self::CreateJournal => AchievementRarity::Uncommon,
+        }
+    }
+}
+
+impl Into<Achievement> for AchievementName {
+    fn into(self) -> Achievement {
+        Achievement {
+            name: self,
+            unlocked: unix_epoch_timestamp(),
+        }
+    }
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct Achievement {
+    pub name: AchievementName,
+    pub unlocked: usize,
+}
+
 #[derive(Debug, Serialize, Deserialize)]
 pub struct Notification {
     pub id: usize,
diff --git a/sql_changes/users_achievements.sql b/sql_changes/users_achievements.sql
new file mode 100644
index 0000000..2eadcf4
--- /dev/null
+++ b/sql_changes/users_achievements.sql
@@ -0,0 +1,2 @@
+ALTER TABLE users
+ADD COLUMN achievements TEXT NOT NULL DEFAULT '[]';