add: post titles

This commit is contained in:
trisua 2025-06-08 15:34:29 -04:00
parent 1279536609
commit 5b1db42c51
14 changed files with 230 additions and 13 deletions

View file

@ -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"

View file

@ -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() {

View file

@ -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,
);

View file

@ -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 %}")

View file

@ -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

View file

@ -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")

View file

@ -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

View file

@ -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

View file

@ -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<State>,
Path(id): Path<usize>,
) -> 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: (),
})
}

View file

@ -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

View file

@ -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<CreatePostPoll>,
#[serde(default)]
pub title: String,
}
#[derive(Deserialize)]

View file

@ -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);

View file

@ -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(),
}
}

View file

@ -0,0 +1,2 @@
ALTER TABLE posts
ADD COLUMN title TEXT NOT NULL DEFAULT '';