diff --git a/Cargo.lock b/Cargo.lock index 38e681e..75260ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3318,7 +3318,7 @@ dependencies = [ [[package]] name = "tetratto" -version = "13.0.0" +version = "14.0.0" dependencies = [ "ammonia", "async-stripe", @@ -3350,7 +3350,7 @@ dependencies = [ [[package]] name = "tetratto-core" -version = "13.0.0" +version = "14.0.0" dependencies = [ "async-recursion", "base16ct", diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 1d402a4..4fb69e9 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto" -version = "13.0.0" +version = "14.0.0" edition = "2024" authors.workspace = true repository.workspace = true diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index 5280133..3ab9d32 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -81,6 +81,8 @@ pub const COMMUNITIES_CREATE_POST: &str = include_str!("./public/html/communities/create_post.lisp"); pub const COMMUNITIES_QUESTION: &str = include_str!("./public/html/communities/question.lisp"); pub const COMMUNITIES_QUESTIONS: &str = include_str!("./public/html/communities/questions.lisp"); +pub const COMMUNITIES_TOPICS: &str = include_str!("./public/html/communities/topics.lisp"); +pub const COMMUNITIES_TOPIC: &str = include_str!("./public/html/communities/topic.lisp"); pub const POST_POST: &str = include_str!("./public/html/post/post.lisp"); pub const POST_REPOSTS: &str = include_str!("./public/html/post/reposts.lisp"); @@ -316,6 +318,8 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"communities/create_post.html"(crate::assets::COMMUNITIES_CREATE_POST) --config=config --lisp plugins); write_template!(html_path->"communities/question.html"(crate::assets::COMMUNITIES_QUESTION) --config=config --lisp plugins); write_template!(html_path->"communities/questions.html"(crate::assets::COMMUNITIES_QUESTIONS) --config=config --lisp plugins); + write_template!(html_path->"communities/topics.html"(crate::assets::COMMUNITIES_TOPICS) --config=config --lisp plugins); + write_template!(html_path->"communities/topic.html"(crate::assets::COMMUNITIES_TOPIC) --config=config --lisp plugins); write_template!(html_path->"post/post.html"(crate::assets::POST_POST) -d "post" --config=config --lisp plugins); write_template!(html_path->"post/reposts.html"(crate::assets::POST_REPOSTS) --config=config --lisp plugins); diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index c04243d..fdd6ffc 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -37,6 +37,7 @@ version = "1.0.0" "general:label.account" = "Account" "general:label.safety" = "Safety" "general:label.share" = "Share" +"general:label.edit" = "Edit" "general:action.add_account" = "Add account" "general:action.switch_account" = "Switch account" "general:label.mod" = "Mod" @@ -102,6 +103,9 @@ version = "1.0.0" "communities:action.select" = "Select" "communities:label.create_new" = "Create new community" "communities:label.name" = "Name" +"communities:label.description" = "Description" +"communities:label.color" = "Color" +"communities:label.position" = "Position" "communities:label.my_communities" = "My communities" "communities:label.popular_communities" = "Popular communities" "communities:action.join" = "Join" @@ -112,6 +116,7 @@ version = "1.0.0" "communities:label.content" = "Content" "communities:label.title" = "Title" "communities:label.posts" = "Posts" +"communities:label.topics" = "Topics" "communities:label.questions" = "Questions" "communities:label.not_allowed_to_read" = "You're not allowed to view this community's posts" "communities:label.might_need_to_join" = "You might need to join this community in order to interact with it!" @@ -129,6 +134,7 @@ version = "1.0.0" "communities:label.change_title" = "Change title" "communities:label.new_title" = "New title" "communities:label.pinned" = "Pinned" +"communities:label.stickied" = "Stickied" "communities:label.edit_content" = "Edit content" "communities:label.repost" = "Repost" "communities:label.quote_post" = "Quote post" @@ -149,6 +155,8 @@ version = "1.0.0" "communities:label.load" = "Load" "communities:action.draw" = "Draw" "communities:action.remove_drawing" = "Remove drawing" +"communities:tab.topics" = "Topics" +"communities:action.create_topic" = "Create topic" "notifs:action.mark_as_read" = "Mark as read" "notifs:action.mark_as_unread" = "Mark as unread" diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css index 9284577..88a1864 100644 --- a/crates/app/src/public/css/style.css +++ b/crates/app/src/public/css/style.css @@ -83,6 +83,10 @@ --size-formula: clamp(24px, calc(var(--size) * 0.75), 64px); } + .smaller_avatar .avatar { + --size-formula: clamp(18px, calc(var(--size) * 0.75), 64px); + } + textarea { min-height: 12rem !important; } diff --git a/crates/app/src/public/html/communities/create_post.lisp b/crates/app/src/public/html/communities/create_post.lisp index d3dd270..09b55f8 100644 --- a/crates/app/src/public/html/communities/create_post.lisp +++ b/crates/app/src/public/html/communities/create_post.lisp @@ -163,7 +163,8 @@ (text "{{ text \"communities:action.create\" }}")))))) (text "{% if not quoting -%}") (script - (text "async function create_post_from_form(e) { + (text "globalThis.SEARCH_PARAMS = new URLSearchParams(window.location.search); + async function create_post_from_form(e) { e.preventDefault(); await trigger(\"atto::debounce\", [\"posts::create\"]); @@ -204,6 +205,7 @@ content: e.target.content.value, community: !is_selected_stack ? selected_community : \"0\", stack: is_selected_stack ? selected_community : \"0\", + topic: !is_selected_stack ? SEARCH_PARAMS.get(\"topic\") || \"0\" : \"0\", poll: poll_data[1], title: e.target.title.value, }), @@ -457,7 +459,7 @@ check_community_supports_title({ target: document.getElementById(\"community_to_post_to\"), }); - }, 150); + }, 250); window.cancel_create_post = async () => { if ( diff --git a/crates/app/src/public/html/communities/feed.lisp b/crates/app/src/public/html/communities/feed.lisp index 9853deb..9c73512 100644 --- a/crates/app/src/public/html/communities/feed.lisp +++ b/crates/app/src/public/html/communities/feed.lisp @@ -23,5 +23,4 @@ (div ("class" "card flex flex_col gap_4") (text "{% for post in feed %} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[2], post=post[0], owner=post[1], secondary=true, show_community=false, can_manage_post=can_manage_posts) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[3], secondary=true, show_community=false, can_manage_post=can_manage_posts, poll=post[4]) }} {%- endif %} {% endfor %} {{ components::pagination(page=page, items=feed|length) }}")))) - (text "{% endblock %}") diff --git a/crates/app/src/public/html/communities/settings.lisp b/crates/app/src/public/html/communities/settings.lisp index 6c096e3..239d3cc 100644 --- a/crates/app/src/public/html/communities/settings.lisp +++ b/crates/app/src/public/html/communities/settings.lisp @@ -6,41 +6,52 @@ (main ("class" "flex flex_col gap_2") (div - ("class" "pillmenu") - (a - ("href" "#/general") - ("data-tab-button" "general") - ("class" "active") - (text "{{ icon \"settings\" }}") - (span - (text "{{ text \"settings:tab.general\" }}"))) - (a - ("href" "#/images") - ("data-tab-button" "images") - (text "{{ icon \"image\" }}") - (span - (text "{{ text \"settings:tab.images\" }}"))) - (a - ("href" "#/members") - ("data-tab-button" "members") - (text "{{ icon \"users-round\" }}") - (span - (text "{{ text \"communities:tab.members\" }}"))) - (text "{% if can_manage_channels -%}") - (a - ("href" "#/channels") - ("data-tab-button" "channels") - (text "{{ icon \"rss\" }}") - (span - (text "{{ text \"communities:tab.channels\" }}"))) - (text "{%- endif %} {% if can_manage_emojis -%}") - (a - ("href" "#/emojis") - ("data-tab-button" "emojis") - (text "{{ icon \"smile\" }}") - (span - (text "{{ text \"communities:tab.emojis\" }}"))) - (text "{%- endif %}")) + ("class" "pillmenu rows w-full") + (div + ("class" "row") + (a + ("href" "#/general") + ("data-tab-button" "general") + ("class" "active") + (text "{{ icon \"settings\" }}") + (span + (text "{{ text \"settings:tab.general\" }}"))) + (a + ("href" "#/images") + ("data-tab-button" "images") + (text "{{ icon \"image\" }}") + (span + (text "{{ text \"settings:tab.images\" }}"))) + (a + ("href" "#/members") + ("data-tab-button" "members") + (text "{{ icon \"users-round\" }}") + (span + (text "{{ text \"communities:tab.members\" }}")))) + (div + ("class" "row") + (text "{% if can_manage_channels -%}") + (a + ("href" "#/channels") + ("data-tab-button" "channels") + (text "{{ icon \"rss\" }}") + (span + (text "{{ text \"communities:tab.channels\" }}"))) + (text "{%- endif %} {% if community.is_forum -%}") + (a + ("href" "#/topics") + ("data-tab-button" "topics") + (icon (text "list")) + (span + (str (text "communities:tab.topics")))) + (text "{%- endif %} {% if can_manage_emojis -%}") + (a + ("href" "#/emojis") + ("data-tab-button" "emojis") + (text "{{ icon \"smile\" }}") + (span + (text "{{ text \"communities:tab.emojis\" }}"))) + (text "{%- endif %}"))) (div ("class" "w_full flex flex_col gap_2") ("data-tab" "general") @@ -564,6 +575,235 @@ ]); }); };")) + (text "{%- endif %}") + + (text "{% if community.is_forum -%}") + (script ("type" "application/json") ("id" "community_topics") (text "{{ community.topics | json_encode() | safe }}")) + (div + ("class" "card lowered w_full hidden flex flex_col gap_2") + ("data-tab" "topics") + (div + ("class" "card_nest") + (div + ("class" "card small") + (b + (str (text "communities:action.create_topic")))) + (form + ("class" "card flex flex_col gap_2") + ("onsubmit" "create_topic_from_form(event)") + (div + ("class" "flex flex_col gap_1") + (label + ("for" "title") + (text "{{ text \"communities:label.name\" }}")) + (input + ("type" "text") + ("name" "title") + ("id" "title") + ("placeholder" "name") + ("required" "") + ("minlength" "2") + ("maxlength" "32"))) + (div + ("class" "flex flex_col gap_1") + (label + ("for" "description") + (str (text "communities:label.description"))) + (input + ("type" "text") + ("name" "description") + ("id" "description") + ("placeholder" "description") + ("required" "") + ("minlength" "2") + ("maxlength" "256"))) + (div + ("class" "flex flex_col gap_1") + (label + ("for" "color") + (str (text "communities:label.color"))) + (input + ("type" "color") + ("name" "color") + ("id" "color") + ("placeholder" "color") + ("required" "") + ("style" "width: 8rem"))) + (div + ("class" "flex flex_col gap_1") + (label + ("for" "position") + (str (text "communities:label.position"))) + (input + ("type" "number") + ("name" "position") + ("id" "position") + ("placeholder" "position") + ("required" "") + ("value" "0") + ("min" "0") + ("max" "256"))) + (button + (text "{{ text \"communities:action.create\" }}")))) + (text "{% for id, topic in community.topics %}") + (div + ("class" "card_nest") + (div + ("class" "card small flex justify_between gap_2") + (div + ("class" "flex gap_2") + (b + (text "{{ topic.position }} ")) + (text "{{ topic.title }}")) + (button + ("class" "red lowered small") + ("onclick" "delete_topic('{{ id }}')") + (icon (text "trash")) + (str (text "general:action.delete")))) + (div + ("class" "card flex flex_col gap_2") + (details + ("class" "accordion") + (summary ("class" "flex items_center gap_2") (icon (text "pencil")) (str (text "general:label.edit"))) + (form + ("class" "inner flex flex_col gap_2") + ("style" "background: var(--color-super-raised)") + ("onsubmit" "update_topic_from_form(event, '{{ id }}')") + (div + ("class" "flex flex_col gap_1") + (label + ("for" "title") + (text "{{ text \"communities:label.name\" }}")) + (input + ("type" "text") + ("name" "title") + ("id" "title") + ("placeholder" "name") + ("value" "{{ topic.title }}") + ("required" "") + ("minlength" "2") + ("maxlength" "32"))) + (div + ("class" "flex flex_col gap_1") + (label + ("for" "description") + (str (text "communities:label.description"))) + (input + ("type" "text") + ("name" "description") + ("id" "description") + ("placeholder" "description") + ("value" "{{ topic.description }}") + ("required" "") + ("minlength" "2") + ("maxlength" "256"))) + (div + ("class" "flex flex_col gap_1") + (label + ("for" "color") + (str (text "communities:label.color"))) + (input + ("type" "color") + ("name" "color") + ("id" "color") + ("placeholder" "color") + ("required" "") + ("value" "{{ topic.color }}") + ("style" "width: 8rem"))) + (div + ("class" "flex flex_col gap_1") + (label + ("for" "position") + (str (text "communities:label.position"))) + (input + ("type" "number") + ("name" "position") + ("id" "position") + ("placeholder" "position") + ("required" "") + ("value" "{{ topic.position }}") + ("min" "0") + ("max" "256"))) + (button + (icon (text "check")) + (str (text "general:action.save"))))))) + (text "{% endfor %}")) + (script + (text "globalThis.delete_topic = async (id) => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + fetch(`/api/v1/communities/{{ community.id }}/topics/${id}`, { + method: \"DELETE\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + }; + + async function create_topic_from_form(e) { + e.preventDefault(); + await trigger(\"atto::debounce\", [\"topics::create\"]); + + fetch(\"/api/v1/communities/{{ community.id }}/topics\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + title: e.target.title.value, + description: e.target.description.value, + color: e.target.color.value, + position: Number.parseInt(e.target.position.value), + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + e.target.reset(); + window.location.reload(); + } + }); + } + + async function update_topic_from_form(e, id) { + e.preventDefault(); + await trigger(\"atto::debounce\", [\"topics::update\"]); + + fetch(`/api/v1/communities/{{ community.id }}/topics/${id}`, { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + title: e.target.title.value, + description: e.target.description.value, + color: e.target.color.value, + position: Number.parseInt(e.target.position.value), + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + }")) (text "{%- endif %}")) (script diff --git a/crates/app/src/public/html/communities/topic.lisp b/crates/app/src/public/html/communities/topic.lisp new file mode 100644 index 0000000..817c1b0 --- /dev/null +++ b/crates/app/src/public/html/communities/topic.lisp @@ -0,0 +1,42 @@ +(text "{% import \"components.html\" as components %} {% extends \"communities/base.html\" %} {% block content %}") +(div + ("class" "flex flex_col gap_4 w_full") + (text "{{ macros::community_nav(community=community, selected=\"posts\") }}") + (div + ("class" "card_nest") + (div + ("class" "card small flex justify_between gap_2") + (text "{{ components::topic_display(id=topic_id, topic=topic, community=community, show_description=false) }}") + (div + ("class" "flex gap_2") + (a + ("href" "/communities/intents/post?community={{ community.id }}&topic={{ topic_id }}") + ("class" "button small lowered") + ("data-turbo" "false") + (icon (text "plus")) + (span + (str (text "general:action.post")))) + (a + ("href" "/community/{{ community.title }}") + ("class" "button lowered small") + (icon (text "arrow-left")) + (str (text "general:action.back"))))) + (div + ("class" "card flex flex_col gap_4") + (span ("class" "no_p_margin") (text "{{ topic.description|markdown|safe }}")) + (hr) + (div + ("class" "w_full") + ("style" "overflow: auto") + (table + ("class" "w_full") + (thead + (th (text "Title")) + (th (text "Replies")) + (th (text "Score")) + (th (text "Created"))) + (tbody + (text "{% for post in pinned %} {{ components::topic_post_display(post=post[0], owner=post[1], is_pinned=true) }} {% endfor %}") + (text "{% for post in feed %} {{ components::topic_post_display(post=post[0], owner=post[1]) }} {% endfor %}")))) + (text "{{ components::pagination(page=page, items=feed|length) }}")))) +(text "{% endblock %}") diff --git a/crates/app/src/public/html/communities/topics.lisp b/crates/app/src/public/html/communities/topics.lisp new file mode 100644 index 0000000..1da085e --- /dev/null +++ b/crates/app/src/public/html/communities/topics.lisp @@ -0,0 +1,19 @@ +(text "{% import \"components.html\" as components %} {% extends \"communities/base.html\" %} {% block content %}") +(div + ("class" "flex flex_col gap_4 w_full") + (text "{{ macros::community_nav(community=community, selected=\"posts\") }}") + (div + ("class" "card_nest") + (div + ("class" "card small flex gap_2 items_center") + (icon (text "list")) + (span + (str (text "communities:label.topics")))) + (div + ("class" "card flex flex_col gap_4") + (text "{% for topic in topics_sorted %}") + (div + ("class" "card lowered w_full flex flex_col gap_2") + (text "{{ components::topic_display(id=topic[0], topic=topic[1], community=community) }}")) + (text "{% endfor %}")))) +(text "{% endblock %}") diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 602347d..55d3bf1 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -543,8 +543,14 @@ (text "{{ self::avatar(username=owner.username, size=\"24px\", selector_type=\"username\") }}")) (span ("class" "name") - (text "{{ self::full_username(user=owner) }}"))) - (text "{{ self::post_info(post=post, community=community) }}")) + (text "{{ self::full_username(user=owner) }}")) + (a + ("href" "/community/{{ community.title }}/topic/{{ post.topic }}") + ("class" "flush flex gap_1 items_center smaller_avatar") + (text "{{ self::community_avatar(id=post.community, community=community, size=\"18px\") }}"))) + (div + ("class" "flex gap_2") + (text "{{ self::post_info(post=post, community=community) }}"))) (div ("class" "card_nest_horizontal") ; author info @@ -2066,6 +2072,7 @@ (text "{{ icon \"message-circle\" }}") (span (text "{{ text \"communities:label.chats\" }}"))) + (text "{% if not community.is_forum -%}") (a ("href" "/communities/intents/post?community={{ community.id }}") ("class" "button lowered") @@ -2073,6 +2080,7 @@ (text "{{ icon \"plus\" }}") (span (text "{{ text \"general:action.post\" }}"))) + (text "{%- endif %}") (text "{%- endif %} {% if can_manage_community or is_manager -%}") (a ("href" "/community/{{ community.id }}/manage") @@ -2606,3 +2614,39 @@ (icon (text "trash"))) (text "{%- endif %}")))) (text "{%- endmacro %}") + +(text "{% macro topic_display(id, topic, community, show_description=true) -%}") +(div + ("class" "flex items_center gap_2") + (svg + ("width" "12") + ("height" "12") + ("viewBox" "0 0 12 12") + ("style" "fill: {% if topic.color == \"#000000\" -%} var(--color-primary) {%- else -%} {{ topic.color }} {%- endif %}; margin-top: 3.5px") + (circle + ("cx" "6") + ("cy" "6") + ("r" "6"))) + (a + ("href" "/community/{{ community.title }}/topic/{{ id }}") + ("class" "flush") + (b (text "{{ topic.title }}")))) +(text "{% if show_description -%}") +(span ("class" "no_p_margin") (text "{{ topic.description|markdown|safe }}")) +(text "{%- endif %}") +(text "{%- endmacro %}") + +(text "{% macro topic_post_display(post, owner, is_pinned=false) -%}") +(tr + (td + ("class" "flex gap_1") + (a + ("href" "/post/{{ post.id }}") + (text "{% if is_pinned -%}Sticky: {% endif %}") + (text "{{ post.title }}")) + (span ("class" "fade") (text "by")) + (text "{{ self::full_username(user=owner) }}")) + (td (text "{{ post.comment_count }}")) + (td (text "{{ ((post.likes + 1) / (post.dislikes + 1))|round(method=\"ceil\", precision=2) }}")) + (td (span ("class" "date") (text "{{ post.created }}")))) +(text "{%- endmacro %}") diff --git a/crates/app/src/public/html/post/post.lisp b/crates/app/src/public/html/post/post.lisp index 518f973..c645ae0 100644 --- a/crates/app/src/public/html/post/post.lisp +++ b/crates/app/src/public/html/post/post.lisp @@ -354,6 +354,7 @@ content: e.target.content.value, community: \"{{ community.id }}\", stack: \"{{ post.stack }}\", + topic: \"{{ post.topic }}\", replying_to: \"{{ post.id }}\", poll: poll_data[1], }), diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js index 7126b84..8791aeb 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -153,7 +153,8 @@ media_theme_pref(); .replaceAll(" months ago", "m") .replaceAll(" month ago", "m") .replaceAll(" years ago", "y") - .replaceAll(" year ago", "y"); + .replaceAll(" year ago", "y") + .replaceAll("just now", "now"); } element.innerText = !pretty ? then.toLocaleDateString() : pretty; @@ -194,7 +195,8 @@ media_theme_pref(); .replaceAll(" month ago", "m") .replaceAll(" years ago", "y") .replaceAll(" year ago", "y") - .replaceAll("Yesterday", "1d") || ""; + .replaceAll("Yesterday", "1d") + .replaceAll("just now", "now") || ""; element.innerText = !pretty ? then.toLocaleDateString() : pretty; element.style.display = "inline-block"; diff --git a/crates/app/src/routes/api/v1/communities/communities.rs b/crates/app/src/routes/api/v1/communities/communities.rs index e749b90..52d83da 100644 --- a/crates/app/src/routes/api/v1/communities/communities.rs +++ b/crates/app/src/routes/api/v1/communities/communities.rs @@ -538,7 +538,7 @@ pub async fn add_topic_request( None => return Json(Error::NotAllowed.into()), }; - let mut community = match data.get_community_by_id(id).await { + let mut community = match data.get_community_by_id_no_void(id).await { Ok(x) => x, Err(e) => return Json(e.into()), }; @@ -547,8 +547,22 @@ pub async fn add_topic_request( return Json(Error::DoesNotSupportField("community".to_string()).into()); } - let (id, topic) = ForumTopic::new(req.title, req.description, req.color); - community.topics.insert(id, topic); + // check lengths + if req.title.len() > 32 { + return Json(Error::DataTooLong("title".to_string()).into()); + } + + if req.title.len() < 2 { + return Json(Error::DataTooShort("title".to_string()).into()); + } + + if req.description.len() > 256 { + return Json(Error::DataTooLong("description".to_string()).into()); + } + + // ... + let (topic_id, topic) = ForumTopic::new(req.title, req.description, req.color, req.position); + community.topics.insert(topic_id, topic); match data .update_community_topics(id, &user, community.topics) @@ -575,7 +589,7 @@ pub async fn update_topic_request( None => return Json(Error::NotAllowed.into()), }; - let mut community = match data.get_community_by_id(id).await { + let mut community = match data.get_community_by_id_no_void(id).await { Ok(x) => x, Err(e) => return Json(e.into()), }; @@ -584,10 +598,25 @@ pub async fn update_topic_request( return Json(Error::DoesNotSupportField("community".to_string()).into()); } + // check lengths + if req.title.len() > 32 { + return Json(Error::DataTooLong("title".to_string()).into()); + } + + if req.title.len() < 2 { + return Json(Error::DataTooShort("title".to_string()).into()); + } + + if req.description.len() > 256 { + return Json(Error::DataTooLong("description".to_string()).into()); + } + + // ... let topic = ForumTopic { title: req.title, description: req.description, color: req.color, + position: req.position, }; community.topics.insert(topic_id, topic); @@ -616,7 +645,7 @@ pub async fn delete_topic_request( None => return Json(Error::NotAllowed.into()), }; - let mut community = match data.get_community_by_id(id).await { + let mut community = match data.get_community_by_id_no_void(id).await { Ok(x) => x, Err(e) => return Json(e.into()), }; @@ -631,11 +660,14 @@ pub async fn delete_topic_request( .update_community_topics(id, &user, community.topics) .await { - Ok(_) => Json(ApiReturn { - ok: true, - message: "Community updated".to_string(), - payload: (), - }), + Ok(_) => match data.delete_topic_posts(id, topic_id).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Community updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + }, Err(e) => Json(e.into()), } } diff --git a/crates/app/src/routes/api/v1/communities/posts.rs b/crates/app/src/routes/api/v1/communities/posts.rs index 2a08f92..05751f1 100644 --- a/crates/app/src/routes/api/v1/communities/posts.rs +++ b/crates/app/src/routes/api/v1/communities/posts.rs @@ -129,6 +129,10 @@ pub async fn create_request( Ok(x) => x, Err(e) => return Json(Error::MiscError(e.to_string()).into()), }; + props.topic = match req.topic.parse::() { + Ok(x) => x, + Err(e) => return Json(Error::MiscError(e.to_string()).into()), + }; } // check sizes diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index ce36f99..5f553a6 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -795,6 +795,8 @@ pub struct AddTopic { pub title: String, pub description: String, pub color: String, + #[serde(default)] + pub position: i32, } #[derive(Deserialize)] @@ -821,6 +823,8 @@ pub struct CreatePost { pub title: String, #[serde(default)] pub stack: String, + #[serde(default)] + pub topic: String, } #[derive(Deserialize)] diff --git a/crates/app/src/routes/pages/communities.rs b/crates/app/src/routes/pages/communities.rs index ec3e7ff..94590f8 100644 --- a/crates/app/src/routes/pages/communities.rs +++ b/crates/app/src/routes/pages/communities.rs @@ -16,7 +16,7 @@ use tera::Context; use tetratto_core::model::{ addr::RemoteAddr, auth::User, - communities::Community, + communities::{Community, ForumTopic}, communities_permissions::CommunityPermission, permissions::FinePermission, stacks::{StackMode, UserStack}, @@ -424,6 +424,47 @@ pub async fn feed_request( // check permissions let (can_read, _) = check_community_permissions!(community, jar, data, user); + // is this is a forum, just show topics + if community.is_forum { + // init context + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0.0, lang, &user).await; + + let ( + is_owner, + is_joined, + is_pending, + can_post, + can_manage_posts, + can_manage_community, + can_manage_roles, + can_manage_questions, + ) = community_context_bools!(data, user, community); + + let mut sorted: Vec<(&usize, &ForumTopic)> = community.topics.iter().collect(); + sorted.sort_by(|x, y| x.1.position.cmp(&y.1.position)); + + context.insert("topics_sorted", &sorted); + community_context( + &mut context, + &community, + is_owner, + is_joined, + is_pending, + can_post, + can_read, + can_manage_posts, + can_manage_community, + can_manage_roles, + can_manage_questions, + ); + + // return + return Ok(Html( + data.1.render("communities/topics.html", &context).unwrap(), + )); + } + // ... let ignore_users = crate::ignore_users_gen!(user, data); @@ -485,6 +526,119 @@ pub async fn feed_request( )) } +/// `/community/{title}/topic/{id}` +pub async fn topic_feed_request( + jar: CookieJar, + Path((title, topic_id)): Path<(String, usize)>, + Query(props): Query, + Extension(data): Extension, +) -> impl IntoResponse { + let data = data.read().await; + let user = get_user_from_token!(jar, data.0); + + let community = match data.0.get_community_by_title(&title.to_lowercase()).await { + Ok(ua) => ua, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }; + + if community.id == 0 { + // don't show page for void community + return Err(Html( + render_error( + Error::GeneralNotFound("community".to_string()), + &jar, + &data, + &user, + ) + .await, + )); + } + + let topic = match community.topics.get(&topic_id) { + Some(x) => x, + None => { + return Err(Html( + render_error( + Error::GeneralNotFound("topic".to_string()), + &jar, + &data, + &user, + ) + .await, + )); + } + }; + + // check permissions + let (can_read, _) = check_community_permissions!(community, jar, data, user); + + // ... + let ignore_users = crate::ignore_users_gen!(user, data); + + let feed = match data + .0 + .get_posts_by_community_topic(community.id, topic_id, 12, props.page, &user) + .await + { + Ok(p) => match data.0.fill_posts(p, &ignore_users, &user).await { + Ok(p) => p, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }; + + let pinned = match data + .0 + .get_pinned_posts_by_community_topic(community.id, topic_id) + .await + { + Ok(p) => match data.0.fill_posts(p, &ignore_users, &user).await { + Ok(p) => p, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }; + + // init context + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0.0, lang, &user).await; + + let ( + is_owner, + is_joined, + is_pending, + can_post, + can_manage_posts, + can_manage_community, + can_manage_roles, + can_manage_questions, + ) = community_context_bools!(data, user, community); + + context.insert("feed", &feed); + context.insert("pinned", &pinned); + context.insert("page", &props.page); + context.insert("topic", &topic); + context.insert("topic_id", &topic_id); + community_context( + &mut context, + &community, + is_owner, + is_joined, + is_pending, + can_post, + can_read, + can_manage_posts, + can_manage_community, + can_manage_roles, + can_manage_questions, + ); + + // return + Ok(Html( + data.1.render("communities/topic.html", &context).unwrap(), + )) +} + /// `/community/{title}/questions` pub async fn questions_request( jar: CookieJar, diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index ebd6b0d..ec993fc 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -99,6 +99,10 @@ pub fn routes() -> Router { get(communities::create_post_request), ) .route("/community/{title}", get(communities::feed_request)) + .route( + "/community/{title}/topic/{id}", + get(communities::topic_feed_request), + ) .route( "/community/{title}/questions", get(communities::questions_request), diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 1fc9ee9..6d4a438 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "tetratto-core" description = "The core behind Tetratto" -version = "13.0.0" +version = "14.0.0" edition = "2024" authors.workspace = true repository.workspace = true @@ -9,7 +9,13 @@ license.workspace = true homepage.workspace = true [features] -database = ["dep:oiseau", "dep:base64", "dep:base16ct", "dep:async-recursion", "dep:md-5"] +database = [ + "dep:oiseau", + "dep:base64", + "dep:base16ct", + "dep:async-recursion", + "dep:md-5", +] types = ["dep:totp-rs", "dep:paste", "dep:bitflags"] sdk = ["types", "dep:reqwest"] default = ["database", "types", "sdk"] @@ -21,8 +27,14 @@ toml = "0.9.4" tetratto-shared = { version = "12.0.6", path = "../shared" } tetratto-l10n = { version = "12.0.0", path = "../l10n" } serde_json = "1.0.142" -totp-rs = { version = "5.7.0", features = ["qr", "gen_secret"], optional = true } -reqwest = { version = "0.12.22", features = ["json", "multipart"], optional = true } +totp-rs = { version = "5.7.0", features = [ + "qr", + "gen_secret", +], optional = true } +reqwest = { version = "0.12.22", features = [ + "json", + "multipart", +], optional = true } bitflags = { version = "2.9.1", optional = true } async-recursion = { version = "1.1.1", optional = true } md-5 = { version = "0.10.6", optional = true } diff --git a/crates/core/src/database/communities.rs b/crates/core/src/database/communities.rs index c67f700..e535d40 100644 --- a/crates/core/src/database/communities.rs +++ b/crates/core/src/database/communities.rs @@ -532,6 +532,25 @@ impl DataManager { Ok(()) } + pub async fn delete_topic_posts(&self, id: usize, topic: usize) -> Result<()> { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "DELETE FROM posts WHERE community = $1 AND topic = $2", + params![&(id as i64), &(topic as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(()) + } + auto_method!(update_community_context(CommunityContext)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET context = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community); auto_method!(update_community_read_access(CommunityReadAccess)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET read_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community); auto_method!(update_community_write_access(CommunityWriteAccess)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET write_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community); diff --git a/crates/core/src/database/drivers/sql/create_posts.sql b/crates/core/src/database/drivers/sql/create_posts.sql index b200901..c75b80b 100644 --- a/crates/core/src/database/drivers/sql/create_posts.sql +++ b/crates/core/src/database/drivers/sql/create_posts.sql @@ -18,5 +18,6 @@ CREATE TABLE IF NOT EXISTS posts ( poll_id BIGINT NOT NULL, title TEXT NOT NULL, is_open INT NOT NULL DEFAULT 1, - circle BIGINT NOT NULL + stack BIGINT NOT NULL, + topic BIGINT NOT NULL ) diff --git a/crates/core/src/database/drivers/sql/version_migrations.sql b/crates/core/src/database/drivers/sql/version_migrations.sql index 46e97a4..cd48530 100644 --- a/crates/core/src/database/drivers/sql/version_migrations.sql +++ b/crates/core/src/database/drivers/sql/version_migrations.sql @@ -21,3 +21,7 @@ ADD COLUMN IF NOT EXISTS is_forum INT DEFAULT 0; -- communities topics ALTER TABLE communities ADD COLUMN IF NOT EXISTS topics TEXT DEFAULT '{}'; + +-- posts topic +ALTER TABLE posts +ADD COLUMN IF NOT EXISTS topic BIGINT DEFAULT 0; diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index 6458b1a..a3e7336 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -116,6 +116,7 @@ impl DataManager { title: get!(x->14(String)), is_open: get!(x->15(i32)) as i8 == 1, stack: get!(x->16(i64)) as usize, + topic: get!(x->17(i64)) as usize, } } @@ -1209,6 +1210,60 @@ impl DataManager { Ok(res.unwrap()) } + /// Get all posts from the given community and topic (from most recent). + /// + /// # Arguments + /// * `id` - the ID of the community the requested posts belong to + /// * `topic` - the ID of the topic the requested posts belong to + /// * `batch` - the limit of posts in each page + /// * `page` - the page number + pub async fn get_posts_by_community_topic( + &self, + id: usize, + topic: usize, + batch: usize, + page: usize, + user: &Option, + ) -> Result> { + 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 community = $1 AND topic = $2 AND replying_to = 0 AND NOT context LIKE '%\"is_pinned\":true%' AND is_deleted = 0 {} ORDER BY created DESC LIMIT $3 OFFSET $4", + if hide_nsfw { + "AND NOT (context::json->>'is_nsfw')::boolean" + } else { + "" + } + ), + &[ + &(id as i64), + &(topic as i64), + &(batch as i64), + &((page * batch) as i64) + ], + |x| { Self::get_post_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("post".to_string())); + } + + Ok(res.unwrap()) + } + /// Get all posts from the given stack (from most recent). /// /// # Arguments @@ -1264,6 +1319,35 @@ impl DataManager { Ok(res.unwrap()) } + /// Get all pinned posts from the given community (from most recent). + /// + /// # Arguments + /// * `id` - the ID of the community the requested posts belong to + /// * `topic` - the ID of the topic the requested posts belong to + pub async fn get_pinned_posts_by_community_topic( + &self, + id: usize, + topic: usize, + ) -> Result> { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_rows!( + &conn, + "SELECT * FROM posts WHERE community = $1 AND topic = $2 AND context LIKE '%\"is_pinned\":true%' ORDER BY created DESC", + &[&(id as i64), &(topic as i64)], + |x| { Self::get_post_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("post".to_string())); + } + + Ok(res.unwrap()) + } + /// Get all pinned posts from the given user (from most recent). /// /// # Arguments @@ -1494,7 +1578,7 @@ impl DataManager { let res = query_rows!( &conn, &format!( - "SELECT * FROM posts WHERE replying_to = 0{}{}{} AND NOT context LIKE '%\"full_unlist\":true%' ORDER BY created DESC LIMIT $1 OFFSET $2", + "SELECT * FROM posts WHERE replying_to = 0{}{}{} AND NOT context LIKE '%\"full_unlist\":true%' AND topic = 0 ORDER BY created DESC LIMIT $1 OFFSET $2", if before_time > 0 { format!(" AND created < {before_time}") } else { @@ -1748,6 +1832,20 @@ impl DataManager { self.get_community_by_id(data.community).await? }; + // check is_forum + if community.is_forum { + if data.topic == 0 { + return Err(Error::MiscError( + "Topic is required for this community".to_string(), + )); + } + + if community.topics.get(&data.topic).is_none() { + return Err(Error::GeneralNotFound("topic".to_string())); + } + } + + // ... let mut owner = self.get_user_by_id(data.owner).await?; // check values (if this isn't reposting something else) @@ -2019,7 +2117,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO posts VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, DEFAULT, $13, $14, $15, $16)", + "INSERT INTO posts VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, DEFAULT, $13, $14, $15, $16, $17)", params![ &(data.id as i64), &(data.created as i64), @@ -2041,6 +2139,7 @@ impl DataManager { &data.title, &{ if data.is_open { 1 } else { 0 } }, &(data.stack as i64), + &(data.topic as i64), ] ); diff --git a/crates/core/src/model/communities.rs b/crates/core/src/model/communities.rs index 8dc9c70..2108847 100644 --- a/crates/core/src/model/communities.rs +++ b/crates/core/src/model/communities.rs @@ -280,6 +280,11 @@ pub struct Post { /// /// If stack is not 0, community should be 0 (and vice versa). pub stack: usize, + /// The ID of the topic this post belongs to. 0 means no topic is connected. + /// + /// This can only be set if the post is created in a community with `is_forum: true`, + /// where this is also a required field. + pub topic: usize, } impl Post { @@ -308,6 +313,7 @@ impl Post { title: String::new(), is_open: true, stack: 0, + topic: 0, } } @@ -534,6 +540,7 @@ pub struct ForumTopic { pub title: String, pub description: String, pub color: String, + pub position: i32, } impl ForumTopic { @@ -542,13 +549,14 @@ impl ForumTopic { /// # Returns /// * ID for [`Community`] hashmap /// * [`ForumTopic`] - pub fn new(title: String, description: String, color: String) -> (usize, Self) { + pub fn new(title: String, description: String, color: String, position: i32) -> (usize, Self) { ( Snowflake::new().to_string().parse::().unwrap(), Self { title, description, color, + position, }, ) }