From 5b1db42c51bc916b80c91fa726dc4da9716c29bc Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 8 Jun 2025 15:34:29 -0400 Subject: [PATCH] add: post titles --- crates/app/src/langs/en-US.toml | 1 + .../public/html/communities/create_post.lisp | 35 +++++++- .../src/public/html/communities/settings.lisp | 16 ++++ crates/app/src/public/html/components.lisp | 32 ++++++- crates/app/src/public/html/post/likes.lisp | 2 +- crates/app/src/public/html/post/post.lisp | 2 +- crates/app/src/public/html/post/quotes.lisp | 2 +- crates/app/src/public/html/post/reposts.lisp | 2 +- .../routes/api/v1/communities/communities.rs | 26 ++++++ .../src/routes/api/v1/communities/posts.rs | 2 + crates/app/src/routes/api/v1/mod.rs | 6 ++ crates/core/src/database/posts.rs | 86 +++++++++++++++++-- crates/core/src/model/communities.rs | 29 +++++++ sql_changes/posts_title.sql | 2 + 14 files changed, 230 insertions(+), 13 deletions(-) create mode 100644 sql_changes/posts_title.sql diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index 0810267..4d92c56 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -94,6 +94,7 @@ version = "1.0.0" "communities:action.configure" = "Configure" "communities:label.create_post" = "Create post" "communities:label.content" = "Content" +"communities:label.title" = "Title" "communities:label.posts" = "Posts" "communities:label.questions" = "Questions" "communities:label.not_allowed_to_read" = "You're not allowed to view this community's posts" diff --git a/crates/app/src/public/html/communities/create_post.lisp b/crates/app/src/public/html/communities/create_post.lisp index c9f1156..c21799a 100644 --- a/crates/app/src/public/html/communities/create_post.lisp +++ b/crates/app/src/public/html/communities/create_post.lisp @@ -87,7 +87,7 @@ (text "{{ components::avatar(username=user.id, size=\"32px\", selector_type=\"id\") }}") (select ("id" "community_to_post_to") - ("onchange" "update_community_avatar(event)") + ("onchange" "update_community_avatar(event); check_community_supports_title(event)") (option ("value" "{{ config.town_square }}") ("selected" "{% if not selected_community -%}true{% else %}false{%- endif %}") @@ -102,6 +102,19 @@ ("class" "card flex flex-col gap-2") ("id" "create_form") ("onsubmit" "create_post_from_form(event)") + (div + ("class" "flex flex-col gap-1 hidden") + ("id" "title_field") + (label + ("for" "content") + (text "{{ text \"communities:label.title\" }}")) + (input + ("type" "text") + ("name" "title") + ("id" "title") + ("placeholder" "title") + ("minlength" "2") + ("maxlength" "128"))) (div ("class" "flex flex-col gap-1") (label @@ -179,6 +192,7 @@ \"community_to_post_to\", ).selectedOptions[0].value, poll: poll_data[1], + title: e.target.title.value, }), ); @@ -397,10 +411,29 @@ } } + function check_community_supports_title(e) { + const element = document.getElementById(\"title_field\"); + const id = e.target.selectedOptions[0].value; + + fetch(`/api/v1/communities/${id}/supports_titles`) + .then((res) => res.json()) + .then((res) => { + if (res.message === \"yes\") { + element.classList.remove(\"hidden\"); + } else { + element.classList.add(\"hidden\"); + } + }); + } + setTimeout(() => { update_community_avatar({ target: document.getElementById(\"community_to_post_to\"), }); + + check_community_supports_title({ + target: document.getElementById(\"community_to_post_to\"), + }); }, 150); async function cancel_create_post() { diff --git a/crates/app/src/public/html/communities/settings.lisp b/crates/app/src/public/html/communities/settings.lisp index a1ffc3b..4fc15d0 100644 --- a/crates/app/src/public/html/communities/settings.lisp +++ b/crates/app/src/public/html/communities/settings.lisp @@ -922,6 +922,22 @@ \"{{ community.context.enable_questions }}\", \"checkbox\", ], + [ + [ + \"enable_titles\", + \"Allow users to attach a title to their posts\", + ], + \"{{ community.context.enable_titles }}\", + \"checkbox\", + ], + [ + [ + \"require_titles\", + \"Require users to attach a title to their posts\", + ], + \"{{ community.context.require_titles }}\", + \"checkbox\", + ], ], settings, ); diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 9107706..827bef7 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -111,7 +111,7 @@ ("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) }}")) -(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) -%} {% if community and show_community and community.id != config.town_square or question %}") +(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") (text "{% if question -%} {{ self::question(question=question[0], owner=question[1], profile=owner) }} {% else %}") @@ -188,11 +188,32 @@ ("style" "color: var(--color-primary)") (text "{{ icon \"trash-2\" }}")) (text "{%- endif %}")) + (text "{% if not dont_show_title and post.title and community and community.context.enable_titles -%}") + ; post has a title AND whatever is rendering this component wants to see it + (a + ("class" "flush") + ("href" "/post/{{ post.id }}") + (h2 + ("id" "post-content:{{ post.id }}") + ("class" "no_p_margin post_content") + ("hook" "long") + (text "{{ post.title }}")) + + (button ("class" "small quaternary") (icon (text "ellipsis")))) + (text "{% else %}") (text "{% if not post.context.content_warning -%}") (span ("id" "post-content:{{ post.id }}") ("class" "no_p_margin post_content") ("hook" "long") + + ; title + (text "{% if post.title and community and community.context.enable_titles -%}") + (h2 (text "{{ post.title }}")) + (hr ("class" "margin")) + (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 %}") (div ("class" "card tertiary red flex items-center gap-2") @@ -213,6 +234,13 @@ ("id" "post-content:{{ post.id }}") ("class" "no_p_margin post_content") ("hook" "long") + + ; title + (text "{% if post.title and community and community.settings.enable_titles %}") + (h2 (text "{{ post.title }}")) + (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 %}") (div ("class" "card tertiary red flex items-center gap-2") @@ -221,7 +249,7 @@ (text "Could not find original post..."))) (text "{%- endif %} {%- endif %}")) (text "{{ self::post_media(upload_ids=post.uploads) }}"))) - (text "{%- endif %}") + (text "{%- endif %} {%- endif %}") (text "{% if poll -%} {{ self::poll(post=post, poll=poll) }} {%- endif %}") diff --git a/crates/app/src/public/html/post/likes.lisp b/crates/app/src/public/html/post/likes.lisp index 1e13aff..576b942 100644 --- a/crates/app/src/public/html/post/likes.lisp +++ b/crates/app/src/public/html/post/likes.lisp @@ -15,7 +15,7 @@ (text "{%- endif %}") (div ("style" "display: contents;") - (text "{% if post.context.repost and post.context.repost.reposting -%} {{ components::repost(repost=reposting, post=post, owner=owner, community=community, show_community=true, can_manage_post=can_manage_posts) }} {% else %} {{ components::post(post=post, owner=owner, question=question, community=community, show_community=true, can_manage_post=can_manage_posts) }} {%- endif %}")) + (text "{% if post.context.repost and post.context.repost.reposting -%} {{ components::repost(repost=reposting, post=post, owner=owner, community=community, show_community=true, can_manage_post=can_manage_posts) }} {% else %} {{ components::post(post=post, owner=owner, question=question, community=community, show_community=true, can_manage_post=can_manage_posts, dont_show_title=true) }} {%- endif %}")) (div ("class" "pillmenu") (a diff --git a/crates/app/src/public/html/post/post.lisp b/crates/app/src/public/html/post/post.lisp index 3c56a56..2c9a113 100644 --- a/crates/app/src/public/html/post/post.lisp +++ b/crates/app/src/public/html/post/post.lisp @@ -15,7 +15,7 @@ (text "{%- endif %}") (div ("style" "display: contents;") - (text "{% if post.context.repost and post.context.repost.reposting -%} {{ components::repost(repost=reposting, post=post, owner=owner, community=community, show_community=true, can_manage_post=can_manage_posts) }} {% else %} {{ components::post(post=post, owner=owner, question=question, community=community, show_community=true, can_manage_post=can_manage_posts, poll=poll) }} {%- endif %}")) + (text "{% if post.context.repost and post.context.repost.reposting -%} {{ components::repost(repost=reposting, post=post, owner=owner, community=community, show_community=true, can_manage_post=can_manage_posts) }} {% else %} {{ components::post(post=post, owner=owner, question=question, community=community, show_community=true, can_manage_post=can_manage_posts, poll=poll, dont_show_title=true) }} {%- endif %}")) (text "{% if user and post.context.comments_enabled -%}") (div ("class" "card-nest") diff --git a/crates/app/src/public/html/post/quotes.lisp b/crates/app/src/public/html/post/quotes.lisp index 6b9f8c9..45cdd9f 100644 --- a/crates/app/src/public/html/post/quotes.lisp +++ b/crates/app/src/public/html/post/quotes.lisp @@ -15,7 +15,7 @@ (text "{%- endif %}") (div ("style" "display: contents;") - (text "{% if post.context.repost and post.context.repost.reposting -%} {{ components::repost(repost=reposting, post=post, owner=owner, community=community, show_community=true, can_manage_post=can_manage_posts) }} {% else %} {{ components::post(post=post, owner=owner, question=question, community=community, show_community=true, can_manage_post=can_manage_posts) }} {%- endif %}")) + (text "{% if post.context.repost and post.context.repost.reposting -%} {{ components::repost(repost=reposting, post=post, owner=owner, community=community, show_community=true, can_manage_post=can_manage_posts) }} {% else %} {{ components::post(post=post, owner=owner, question=question, community=community, show_community=true, can_manage_post=can_manage_posts, dont_show_title=true) }} {%- endif %}")) (div ("class" "pillmenu") (a diff --git a/crates/app/src/public/html/post/reposts.lisp b/crates/app/src/public/html/post/reposts.lisp index c13ab4c..128fade 100644 --- a/crates/app/src/public/html/post/reposts.lisp +++ b/crates/app/src/public/html/post/reposts.lisp @@ -15,7 +15,7 @@ (text "{%- endif %}") (div ("style" "display: contents;") - (text "{% if post.context.repost and post.context.repost.reposting -%} {{ components::repost(repost=reposting, post=post, owner=owner, community=community, show_community=true, can_manage_post=can_manage_posts) }} {% else %} {{ components::post(post=post, owner=owner, question=question, community=community, show_community=true, can_manage_post=can_manage_posts) }} {%- endif %}")) + (text "{% if post.context.repost and post.context.repost.reposting -%} {{ components::repost(repost=reposting, post=post, owner=owner, community=community, show_community=true, can_manage_post=can_manage_posts) }} {% else %} {{ components::post(post=post, owner=owner, question=question, community=community, show_community=true, can_manage_post=can_manage_posts, dont_show_title=true) }} {%- endif %}")) (div ("class" "pillmenu") (a diff --git a/crates/app/src/routes/api/v1/communities/communities.rs b/crates/app/src/routes/api/v1/communities/communities.rs index 5f8688a..ac50939 100644 --- a/crates/app/src/routes/api/v1/communities/communities.rs +++ b/crates/app/src/routes/api/v1/communities/communities.rs @@ -427,3 +427,29 @@ pub async fn update_membership_role( Err(e) => Json(e.into()), } } + +pub async fn supports_titles_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + if get_user_from_token!(jar, data).is_none() { + return Json(Error::NotAllowed.into()); + } + + let community = match data.get_community_by_id(id).await { + Ok(c) => c, + Err(e) => return Json(e.into()), + }; + + Json(ApiReturn { + ok: true, + message: if community.context.enable_titles { + "yes".to_string() + } else { + "no".to_string() + }, + payload: (), + }) +} diff --git a/crates/app/src/routes/api/v1/communities/posts.rs b/crates/app/src/routes/api/v1/communities/posts.rs index 2ad333c..f8d3351 100644 --- a/crates/app/src/routes/api/v1/communities/posts.rs +++ b/crates/app/src/routes/api/v1/communities/posts.rs @@ -115,6 +115,8 @@ pub async fn create_request( Ok(x) => x, Err(e) => return Json(Error::MiscError(e.to_string()).into()), }; + } else { + props.title = req.title; } // check sizes diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index a0aab34..8101b0c 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -90,6 +90,10 @@ pub fn routes() -> Router { "/communities/{id}/banner", get(communities::images::banner_request), ) + .route( + "/communities/{id}/supports_titles", + get(communities::communities::supports_titles_request), + ) // posts .route("/posts", post(communities::posts::create_request)) .route("/posts/{id}", delete(communities::posts::delete_request)) @@ -447,6 +451,8 @@ pub struct CreatePost { pub answering: String, #[serde(default)] pub poll: Option, + #[serde(default)] + pub title: String, } #[derive(Deserialize)] diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index 08de8ab..8418361 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -111,6 +111,7 @@ impl DataManager { is_deleted: get!(x->11(i32)) as i8 == 1, // SKIP tsvector (12) poll_id: get!(x->13(i64)) as usize, + title: get!(x->14(String)), } } @@ -1212,6 +1213,8 @@ impl DataManager { } } + let community = self.get_community_by_id(data.community).await?; + // check values (if this isn't reposting something else) let is_reposting = if let Some(ref repost) = data.context.repost { repost.reposting.is_some() @@ -1225,14 +1228,24 @@ impl DataManager { } else if data.content.len() > 4096 { return Err(Error::DataTooLong("content".to_string())); } + + // check title + if !community.context.enable_titles { + if !data.title.is_empty() { + return Err(Error::MiscError( + "Community does not allow titles".to_string(), + )); + } + } else { + if data.title.len() < 2 && community.context.require_titles { + return Err(Error::DataTooShort("title".to_string())); + } else if data.title.len() > 128 { + return Err(Error::DataTooLong("title".to_string())); + } + } } // check permission in community - let community = match self.get_community_by_id(data.community).await { - Ok(p) => p, - Err(e) => return Err(e), - }; - if !self.check_can_post(&community, data.owner).await { return Err(Error::NotAllowed); } @@ -1428,7 +1441,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO posts VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, DEFAULT, $13)", + "INSERT INTO posts VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, DEFAULT, $13, $14)", params![ &(data.id as i64), &(data.created as i64), @@ -1447,6 +1460,7 @@ impl DataManager { &serde_json::to_string(&data.uploads).unwrap(), &{ if data.is_deleted { 1 } else { 0 } }, &(data.poll_id as i64), + &data.title ] ); @@ -1743,6 +1757,13 @@ impl DataManager { } } + // check length + if x.len() < 2 { + return Err(Error::DataTooShort("content".to_string())); + } else if x.len() > 4096 { + return Err(Error::DataTooLong("content".to_string())); + } + // ... let conn = match self.0.connect().await { Ok(c) => c, @@ -1767,6 +1788,59 @@ impl DataManager { Ok(()) } + pub async fn update_post_title(&self, id: usize, user: User, x: String) -> Result<()> { + let mut y = self.get_post_by_id(id).await?; + + if user.id != y.owner { + if !user.permissions.check(FinePermission::MANAGE_POSTS) { + return Err(Error::NotAllowed); + } else { + self.create_audit_log_entry(AuditLogEntry::new( + user.id, + format!("invoked `update_post_title` with x value `{id}`"), + )) + .await? + } + } + + let community = self.get_community_by_id(y.community).await?; + + if !community.context.enable_titles { + return Err(Error::MiscError( + "Community does not allow titles".to_string(), + )); + } + + if x.len() < 2 && community.context.require_titles { + return Err(Error::DataTooShort("title".to_string())); + } else if x.len() > 128 { + return Err(Error::DataTooLong("title".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 posts SET title = $1 WHERE id = $2", + params![&x, &(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // update context + y.context.edited = unix_epoch_timestamp() as usize; + self.update_post_context(id, user, y.context).await?; + + // return + Ok(()) + } + auto_method!(incr_post_likes() -> "UPDATE posts SET likes = likes + 1 WHERE id = $1" --cache-key-tmpl="atto.post:{}" --incr); auto_method!(incr_post_dislikes() -> "UPDATE posts SET dislikes = dislikes + 1 WHERE id = $1" --cache-key-tmpl="atto.post:{}" --incr); auto_method!(decr_post_likes() -> "UPDATE posts SET likes = likes - 1 WHERE id = $1" --cache-key-tmpl="atto.post:{}" --decr); diff --git a/crates/core/src/model/communities.rs b/crates/core/src/model/communities.rs index df72d9d..efc96aa 100644 --- a/crates/core/src/model/communities.rs +++ b/crates/core/src/model/communities.rs @@ -76,6 +76,17 @@ pub struct CommunityContext { pub is_nsfw: bool, #[serde(default)] pub enable_questions: bool, + /// If posts are allowed to set a `title` field. + #[serde(default)] + pub enable_titles: bool, + /// If posts are required to set a `title` field. + /// + /// `enable_titles` is required for this setting to work. + #[serde(default)] + pub require_titles: bool, + /// The community's layout in the UI. + #[serde(default)] + pub layout: CommunityLayout, } /// Who can read a [`Community`]. @@ -129,6 +140,21 @@ impl Default for CommunityJoinAccess { } } +/// The layout of the [`Community`]'s UI. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum CommunityLayout { + /// The classic timeline-like layout. + Classic, + /// A GitHub-esque bug tracker layout. + BugTracker, +} + +impl Default for CommunityLayout { + fn default() -> Self { + Self::Classic + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CommunityMembership { pub id: usize, @@ -242,6 +268,8 @@ pub struct Post { pub is_deleted: bool, /// The ID of the poll associated with this post. 0 means no poll is connected. pub poll_id: usize, + /// The title of the post (in communities where titles are enabled). + pub title: String, } impl Post { @@ -267,6 +295,7 @@ impl Post { uploads: Vec::new(), is_deleted: false, poll_id, + title: String::new(), } } diff --git a/sql_changes/posts_title.sql b/sql_changes/posts_title.sql new file mode 100644 index 0000000..0da0d65 --- /dev/null +++ b/sql_changes/posts_title.sql @@ -0,0 +1,2 @@ +ALTER TABLE posts +ADD COLUMN title TEXT NOT NULL DEFAULT '';