diff --git a/Cargo.lock b/Cargo.lock index c93a829..e7589f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1603,7 +1603,6 @@ checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" dependencies = [ "equivalent", "hashbrown", - "serde", ] [[package]] @@ -1668,15 +1667,6 @@ dependencies = [ "either", ] -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - [[package]] name = "itoa" version = "1.0.15" @@ -2389,27 +2379,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" -[[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "proc-macro-error2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" -dependencies = [ - "proc-macro-error-attr2", - "proc-macro2", - "quote", -] - [[package]] name = "proc-macro2" version = "1.0.95" @@ -2618,7 +2587,7 @@ dependencies = [ "built", "cfg-if", "interpolate_name", - "itertools 0.12.1", + "itertools", "libc", "libfuzzer-sys", "log", @@ -3059,51 +3028,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_valid" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b615bed66931a7a9809b273937adc8a402d038b1e509d027fcaf62f084d33d1" -dependencies = [ - "indexmap", - "itertools 0.13.0", - "num-traits", - "once_cell", - "paste", - "regex", - "serde", - "serde_json", - "serde_valid_derive", - "serde_valid_literal", - "thiserror 1.0.69", - "unicode-segmentation", -] - -[[package]] -name = "serde_valid_derive" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fa1a5a21ea5aab06d2e6a6b59837d450fb2be9695be97735a711edfbe79ea07" -dependencies = [ - "itertools 0.13.0", - "paste", - "proc-macro-error2", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.104", -] - -[[package]] -name = "serde_valid_literal" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd07331596ea967dccf9a35bde71ecd757490e09827b938a5c6226c648e3a25e" -dependencies = [ - "paste", - "regex", -] - [[package]] name = "sha1" version = "0.10.6" @@ -3290,12 +3214,6 @@ dependencies = [ "unicode-properties", ] -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - [[package]] name = "subtle" version = "2.6.1" @@ -3452,7 +3370,7 @@ dependencies = [ "serde", "serde_json", "tera", - "tetratto-core 16.0.3", + "tetratto-core 16.0.0", "tetratto-l10n 12.0.0", "tetratto-shared 12.0.6", "tokio", @@ -3489,7 +3407,7 @@ dependencies = [ [[package]] name = "tetratto-core" -version = "16.0.3" +version = "16.0.0" dependencies = [ "async-recursion", "base16ct", @@ -3505,7 +3423,6 @@ dependencies = [ "reqwest", "serde", "serde_json", - "serde_valid", "tetratto-l10n 12.0.0", "tetratto-shared 12.0.6", "tokio", @@ -4110,12 +4027,6 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" -[[package]] -name = "unicode-segmentation" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" - [[package]] name = "unicode-width" version = "0.2.1" diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index 449dedd..393fe54 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -117,6 +117,11 @@ pub const MOD_PROFILE: &str = include_str!("./public/html/mod/profile.lisp"); pub const MOD_WARNINGS: &str = include_str!("./public/html/mod/warnings.lisp"); pub const MOD_STATS: &str = include_str!("./public/html/mod/stats.lisp"); +pub const CHATS_APP: &str = include_str!("./public/html/chats/app.lisp"); +pub const CHATS_STREAM: &str = include_str!("./public/html/chats/stream.lisp"); +pub const CHATS_MESSAGE: &str = include_str!("./public/html/chats/message.lisp"); +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"); @@ -352,6 +357,11 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"mod/warnings.html"(crate::assets::MOD_WARNINGS) --config=config --lisp plugins); write_template!(html_path->"mod/stats.html"(crate::assets::MOD_STATS) --config=config --lisp plugins); + write_template!(html_path->"chats/app.html"(crate::assets::CHATS_APP) -d "chats" --config=config --lisp plugins); + write_template!(html_path->"chats/stream.html"(crate::assets::CHATS_STREAM) --config=config --lisp plugins); + write_template!(html_path->"chats/message.html"(crate::assets::CHATS_MESSAGE) --config=config --lisp plugins); + write_template!(html_path->"chats/channels.html"(crate::assets::CHATS_CHANNELS) --config=config --lisp plugins); + 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); diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index 01943c0..77e351e 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -175,7 +175,6 @@ version = "1.0.0" "settings:tab.general" = "General" "settings:tab.account" = "Account" "settings:tab.profile" = "Profile" -"settings:tab.experience" = "Experience" "settings:tab.theme" = "Theme" "settings:tab.sessions" = "Sessions" "settings:tab.grants" = "Grants" diff --git a/crates/app/src/public/css/root.css b/crates/app/src/public/css/root.css index 144dd9a..c38372d 100644 --- a/crates/app/src/public/css/root.css +++ b/crates/app/src/public/css/root.css @@ -1,5 +1,4 @@ @import url("utility.css"); -@import url("https://repodelivery.tetratto.com/tetratto-aux/lexend.css"); :root { color-scheme: light dark; @@ -86,6 +85,12 @@ box-sizing: border-box; } +@font-face { + font-family: "Lexend"; + src: url("https://repodelivery.tetratto.com/fonts/lexend_variable.woff2") + format("woff2"); +} + html, body { line-height: 1.5; @@ -188,6 +193,15 @@ p { margin-bottom: var(--pad-4); } +body:not(.use_system_font) { + & p:not(b *), + & span:not(.notification, .name, b *, button *, a *, .dropdown *, nav *), + & input, + & textarea { + font-variation-settings: "wght" 325; + } +} + .no_p_margin p:last-child { margin-bottom: 0; } diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css index 12f83b1..bec7d76 100644 --- a/crates/app/src/public/css/style.css +++ b/crates/app/src/public/css/style.css @@ -1256,11 +1256,6 @@ details summary::-webkit-details-marker { display: none; } -details summary.button { - height: max-content; - justify-content: start; -} - details[open] > summary { position: relative; color: var(--color-text-lowered) !important; @@ -1293,7 +1288,7 @@ details.accordion { details.accordion summary { background: var(--color-lowered); border-radius: var(--radius); - padding: var(--pad-3) var(--pad-4) !important; + padding: var(--pad-3) var(--pad-4); margin: 0; width: 100%; user-select: none; diff --git a/crates/app/src/public/html/auth/register.lisp b/crates/app/src/public/html/auth/register.lisp index 1fa5da1..0fd9ae9 100644 --- a/crates/app/src/public/html/auth/register.lisp +++ b/crates/app/src/public/html/auth/register.lisp @@ -174,5 +174,5 @@ (text "Or, ") (a ("href" "/auth/login") - (text "log in"))) + (text "login"))) (text "{% endblock %}") diff --git a/crates/app/src/public/html/body.lisp b/crates/app/src/public/html/body.lisp index 890b60e..599106e 100644 --- a/crates/app/src/public/html/body.lisp +++ b/crates/app/src/public/html/body.lisp @@ -101,9 +101,6 @@ atto[\"hooks::spotify_time_text\"](); // spotify durations atto[\"hooks::verify_emoji\"](); - globalThis.CURRENT_USER = \"{% if user -%} {{ user.username }} {%- endif %}\"; - trigger(\"me::token_links\"); - fix_atto_links(); if (document.getElementById(\"tokens\")) { @@ -129,6 +126,14 @@ trigger(\"me::seen\"); trigger(\"streams::user\", [\"{{ user.id }}\"]); + if (!window.location.pathname.startsWith(\"/chats/\")) { + if (window.socket) { + window.socket.send(\"Close\"); + window.socket = undefined; + console.log(\"socket disconnect\"); + } + } + if (window.location.pathname.startsWith(\"/reference\")) { window.location.reload(); } diff --git a/crates/app/src/public/html/chats/app.lisp b/crates/app/src/public/html/chats/app.lisp new file mode 100644 index 0000000..11ab239 --- /dev/null +++ b/crates/app/src/public/html/chats/app.lisp @@ -0,0 +1,509 @@ +(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") + (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 \"chats:label.my_chats\" }}")) + (text "{%- endif %}"))) +(div + ("class" "flex") + (div + ("class" "sidebar flex flex_col items_center gap_2") + ("id" "community_list") + ("style" "width: var(--list-bar-width)") + (a + ("href" "/chats/0/0") + ("class" "button lowered channel_icon {% if selected_community == 0 -%}selected{%- endif %}") + ("data-turbo" "false") + (text "{{ icon \"message-circle\" }}")) + (text "{% for community in communities %} {% if community.id != 0 -%}") + (a + ("href" "/chats/{{ community.id }}/0") + ("class" "button lowered channel_icon {% if selected_community == community.id -%}selected{%- endif %}") + ("data-turbo" "false") + (text "{{ components::community_avatar(id=community.id, community=community, size=\"48px\") }}")) + (text "{%- endif %} {% endfor %}")) + (div + ("class" "sidebar flex flex_col gap_2 justify_between") + ("id" "channels_list") + (div + ("class" "flex flex_col gap_2 w_full") + (div + ("class" "title flex items_center justify_between channel_header") + (text "{% 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 \"chats:label.my_chats\" }}")) + (text "{%- endif %} {% if selected_community != 0 -%}") + (div + ("class" "dropdown") + (button + ("class" "camo small") + ("onclick" "trigger('atto::hooks::dropdown', [event])") + ("exclude" "dropdown") + (text "{{ icon \"ellipsis\" }}")) + (div + ("class" "inner") + (a + ("href" "/community/{{ selected_community }}") + (text "{{ icon \"book-heart\" }}") + (span + (text "{{ text \"communities:label.show_community\" }}"))) + (text "{% if can_manage_channels -%}") + (a + ("href" "/community/{{ selected_community }}/manage") + (text "{{ icon \"settings\" }}") + (span + (text "{{ text \"general:action.manage\" }}"))) + (text "{%- endif %}"))) + (text "{%- endif %}")) + (text "{% if can_manage_channels -%}") + (a + ("class" "button w_full justify_start lowered") + ("href" "/community/{{ selected_community }}/manage#/channels") + (text "{{ icon \"plus\" }}") + (span + (text "{{ text \"communities:action.create_channel\" }}"))) + (text "{%- endif %}") + (turbo-frame + ("id" "channels_list_frame") + ("src" "/chats/{{ selected_community }}/{{ selected_channel }}/_channels") + ("target" "_top"))) + (text "{{ components::user_plate(user=user, show_menu=true) }}")) + (text "{% if channel -%}") + (div + ("class" "w_full flex flex_col gap_2 padded_section") + ("id" "stream") + ("style" "padding: var(--pad-4)") + (turbo-frame + ("id" "stream_body_frame") + ("src" "/chats/{{ selected_community }}/{{ selected_channel }}/_stream?page={{ page }}&message={{ message }}")) + (form + ("class" "card flex flex_row gap_2") + ("onsubmit" "create_message_from_form(event)") + (textarea + ("type" "text") + ("name" "content") + ("id" "content") + ("placeholder" "message {{ channel.title }}") + ("required" "") + ("minlength" "2") + ("maxlength" "2048") + ("style" "min-height: 48px !important; height: 48px")) + (button + ("class" "camo send_button") + ("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; + window.CHAT_PROPS = { + selected_community: \"{{ selected_community }}\", + selected_channel: \"{{ selected_channel }}\", + membership_role: Number.parseInt(\"{{ membership_role }}\"), + }; + + 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 mention_user(username) { + document.getElementById(\"content\").value += ` @${username} `; + } + + 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 community_list = document.getElementById(\"community_list\"); + const channels_list = document.getElementById(\"channels_list\"); + + if (document.body.classList.contains(\"sidebars_shown\")) { + // hide + document.body.classList.remove(\"sidebars_shown\"); + community_list.style.left = \"-200%\"; + channels_list.style.left = \"-200%\"; + } else { + // show + document.body.classList.add(\"sidebars_shown\"); + community_list.style.left = \"0\"; + channels_list.style.left = \"var(--list-bar-width)\"; + } + } + + globalThis.add_member = async (id) => { + await trigger(\"atto::debounce\", [\"channels::add_member\"]); + const member = await trigger(\"atto::prompt\", [\"Member username:\"]); + + if (!member) { + return; + } + + fetch(`/api/v1/channels/${id}/add`, { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + member, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + }; + + globalThis.mute_channel = async (id, mute = true) => { + await trigger(\"atto::debounce\", [\"channels::mute\"]); + fetch(`/api/v1/channels/${id}/mute`, { + method: mute ? \"POST\" : \"DELETE\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + if (mute) { + document.querySelector(`[ui_ident=channel\\\\.mute\\\\:${id}]`).classList.add(\"hidden\"); + document.querySelector(`[ui_ident=channel\\\\.unmute\\\\:${id}]`).classList.remove(\"hidden\"); + } else { + document.querySelector(`[ui_ident=channel\\\\.mute\\\\:${id}]`).classList.remove(\"hidden\"); + document.querySelector(`[ui_ident=channel\\\\.unmute\\\\:${id}]`).classList.add(\"hidden\"); + } + } + }); + }; + + globalThis.update_channel_title = async (id) => { + await trigger(\"atto::debounce\", [\"channels::update_title\"]); + const title = await trigger(\"atto::prompt\", [\"New channel title:\"]); + + if (!title) { + return; + } + + fetch(`/api/v1/channels/${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, + ]); + }); + }; + + globalThis.kick_member = async (cid, uid) => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + fetch(`/api/v1/channels/${cid}/kick`, { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + member: uid, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + }; + + globalThis.delete_channel = async (id) => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + fetch(`/api/v1/channels/${id}`, { + method: \"DELETE\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + };")) + (script + ("id" "socket_init") + ("data-turbo-permanent" "true") + (text "globalThis.socket_init = () => { + if (window.socket) { + window.socket.send(\"Close\"); + window.socket.close(); + window.socket = undefined; + console.log(\"closed lingering\"); + } + + if (window.CHAT_PROPS.selected_community !== \"0\") { + const endpoint = `${window.location.origin.replace(\"http\", \"ws\")}/api/v1/_connect/${window.CHAT_PROPS.selected_community}`; + const socket = new WebSocket(endpoint); + window.socket = socket; + window.socket_id = window.CHAT_PROPS.selected_community; + } else { + const endpoint = `${window.location.origin.replace(\"http\", \"ws\")}/api/v1/_connect/${window.CHAT_PROPS.selected_channel}`; + const socket = new WebSocket(endpoint); + window.socket = socket; + window.socket_id = window.CHAT_PROPS.selected_channel; + } + + if (window.CHANNEL_NOTIFS_INTERVAL) { + window.clearInterval(window.CHANNEL_NOTIFS_INTERVAL); + } + + window.CHANNEL_NOTIFS_INTERVAL = setInterval(() => { + if (!window.CHAT_PROPS.selected_channel) { + return; + } + + if (!window.location.href.includes(\"{{ selected_channel }}\")) { + window.clearInterval(window.CHANNEL_NOTIFS_INTERVAL); + return; + } + + fetch( + `/api/v1/notifications/tag/chats/${window.CHAT_PROPS.selected_channel}`, + { method: \"DELETE\" }, + ); + }, 10000); + + window.socket.addEventListener(\"open\", () => { + // auth + window.socket.send( + JSON.stringify({ + method: \"Headers\", + data: JSON.stringify({ + // SocketHeaders + is_channel: window.SUBSCRIBE_CHANNEL, + }), + }), + ); + }); + + setTimeout(() => { + window.LAST_MESSAGE_AUTHOR_ID = null; + window.socket.addEventListener(\"message\", async (event) => { + if (event.data === \"Ping\") { + return socket.send(\"Pong\"); + } + + const msg = JSON.parse(event.data); + + if ( + msg.method === \"Message\" && + window.CURRENT_PAGE === 0 && + window.VIEWING_SINGLE + ) { + const [channel_id, data] = JSON.parse(msg.data); + if (channel_id !== window.CHAT_PROPS.selected_channel) { + // message not for us... maybe send notification later + // something like /api/v1/messages/{id}/mark_unread + return; + } + + if (document.getElementById(\"stream_body\")) { + const element = document.createElement(\"div\"); + element.style.display = \"contents\"; + + const message_owner = JSON.parse(msg.data)[1].owner; + element.innerHTML = await ( + await fetch( + `/chats/${window.CHAT_PROPS.selected_community}/${window.CHAT_PROPS.selected_channel}/_render`, + { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + data: msg.data, + grouped: + message_owner === + window.LAST_MESSAGE_AUTHOR_ID, + }), + }, + ) + ).text(); + + document + .getElementById(\"stream_body\") + .prepend(element); + clean_text(); + + window.LAST_MESSAGE_AUTHOR_ID = message_owner; + } else { + console.log(\"abandoned remote\"); + socket.close(); + } + } else if (msg.method === \"Delete\") { + const data = JSON.parse(msg.data); + if (document.getElementById(`message-${data.id}`)) { + document + .getElementById(`message-${data.id}`) + .remove(); + } + } + }); + + globalThis.create_message_from_form = async (e) => { + e.preventDefault(); + await trigger(\"atto::debounce\", [\"messages::create\"]); + + fetch(\"/api/v1/messages\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + content: e.target.content.value.trim(), + channel: window.CHAT_PROPS.selected_channel, + }), + }) + .then((res) => res.json()) + .then((res) => { + if (!res.ok) { + trigger(\"atto::toast\", [\"error\", res.message]); + } + + e.target.reset(); + }); + }; + + globalThis.delete_message = async (id) => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + fetch(`/api/v1/messages/${id}`, { + method: \"DELETE\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + }; + + const clean_text = () => { + trigger(\"atto::clean_date_codes\"); + trigger(\"atto::hooks::online_indicator\"); + trigger(\"atto::hooks::check_message_reactions\"); + }; + + document.addEventListener( + \"turbo:before-frame-render\", + (event) => { + setTimeout(clean_text, 50); + }, + ); + + setTimeout(clean_text, 150); + }, 250); + };")) + (text "{% if selected_channel -%}") + (script + (text "window.SUBSCRIBE_CHANNEL = \"{{ selected_community }}\" === \"0\"; + + setTimeout(() => { + if (!window.SUBSCRIBE_CHANNEL) { + // sub community + if (window.socket_id !== \"{{ selected_community }}\") { + socket_init(); + } + } else { + // sub channel + if (window.socket_id !== \"{{ selected_channel }}\") { + socket_init(); + } + } + }, 100);")) + (text "{%- endif %}")) +(text "{% endblock %}") diff --git a/crates/app/src/public/html/chats/channels.lisp b/crates/app/src/public/html/chats/channels.lisp new file mode 100644 index 0000000..ced2f93 --- /dev/null +++ b/crates/app/src/public/html/chats/channels.lisp @@ -0,0 +1,80 @@ +(text "{%- import \"components.html\" as components -%}") +(turbo-frame + ("id" "channels_list_frame") + (div + ("class" "channels_list_half flex flex_col gap_2 {% if selected_community != 0 or selected_channel == 0%}no_members{%- endif -%}") + (text "{% for channel in channels %}") + (div + ("class" "flex flex_row gap_1") + (a + ("class" "w_full justify_start button {% if selected_channel == channel.id -%}lowered{% else %}camo{%- endif %}") + ("href" "/chats/{{ selected_community }}/{{ channel.id }}") + ("data-turbo" "{{ selected_community == '0' }}") + (text "{{ icon \"rss\" }}") + (b + ("class" "name shortest") + (text "{{ channel.title }}"))) + (div + ("class" "dropdown") + (button + ("class" "big_icon {% if selected_channel == channel.id -%}lowered{% else %}camo{%- endif %}") + ("onclick" "trigger('atto::hooks::dropdown', [event])") + ("exclude" "dropdown") + ("style" "width: 32px") + (text "{{ icon \"ellipsis\" }}")) + (div + ("class" "inner") + (button + ("onclick" "trigger('atto::copy_text', ['{{ channel.id }}'])") + (icon (text "copy")) + (str (text "general:action.copy_id"))) + (text "{% if user.id == channel.owner or can_manage_channels -%}") + ; owner/manager controls + (button + ("onclick" "add_member('{{ channel.id }}')") + (text "{{ icon \"user-plus\" }}") + (span + (text "{{ text \"chats:action.add_someone\" }}"))) + (button + ("onclick" "update_channel_title('{{ channel.id }}')") + (text "{{ icon \"pencil\" }}") + (span + (text "{{ text \"chats:action.rename\" }}"))) + (button + ("onclick" "delete_channel('{{ channel.id }}')") + ("class" "red") + (text "{{ icon \"trash\" }}") + (span + (text "{{ text \"general:action.delete\" }}"))) + (text "{%- endif %} {% if selected_community == 0 %}") + ; mute/unmute + (button + ("class" "{% if channel.id in user.channel_mutes -%} hidden {%- endif %}") + ("ui_ident" "channel.mute:{{ channel.id }}") + ("onclick" "mute_channel('{{ channel.id }}')") + (icon (text "bell-off")) + (span + (str (text "chats:action.mute")))) + (button + ("class" "{% if not channel.id in user.channel_mutes -%} hidden {%- endif %}") + ("ui_ident" "channel.unmute:{{ channel.id }}") + ("onclick" "mute_channel('{{ channel.id }}', false)") + (icon (text "bell-ring")) + (span + (str (text "chats:action.unmute")))) + ; ... + (text "{% if user.id != channel.owner -%}") + ; group chat member controls + (button + ("onclick" "kick_member('{{ channel.id }}', '{{ user.id }}')") + ("class" "red") + (text "{{ icon \"door-open\" }}") + (span + (text "{{ text \"chats:action.leave\" }}"))) + (text "{%- endif %} {%- endif %}")))) + (text "{% endfor %}")) + (text "{% if selected_community == 0 and selected_channel -%}") + (div + ("class" "members_list_half flex flex_col gap_2") + (text "{% for member in members %} {{ components::user_plate(user=member, show_kick=user.id == channel.owner) }} {% endfor %}")) + (text "{%- endif %}")) diff --git a/crates/app/src/public/html/chats/message.lisp b/crates/app/src/public/html/chats/message.lisp new file mode 100644 index 0000000..4d5946c --- /dev/null +++ b/crates/app/src/public/html/chats/message.lisp @@ -0,0 +1 @@ +(text "{%- import \"components.html\" as components -%} {{ components::message(user=user, message=message, grouped=grouped) }}") diff --git a/crates/app/src/public/html/chats/stream.lisp b/crates/app/src/public/html/chats/stream.lisp new file mode 100644 index 0000000..af891e5 --- /dev/null +++ b/crates/app/src/public/html/chats/stream.lisp @@ -0,0 +1,55 @@ +(text "{%- import \"components.html\" as components -%}") +(turbo-frame + ("id" "stream_body_frame") + (div + ("class" "gap_2") + ("id" "stream_body") + (text "{% if page != 0 -%}") + (div + ("class" "card flex gap_2 small lowered flex_wrap") + (b + (text "{{ text \"chats:label.viewing_old_messages\" }}")) + (a + ("href" "/chats/{{ community }}/{{ channel.id }}/_stream?page={{ page - 1}}") + ("class" "button small") + ("onclick" "window.CURRENT_PAGE -= 1") + (text "{{ text \"chats:label.go_back\" }}"))) + (text "{%- endif %} {% if message -%}") + (div + ("class" "card flex gap_2 small lowered flex_wrap") + (b + (text "{{ text \"chats:label.viewing_single_message\" }}")) + (a + ("href" "/chats/{{ community }}/{{ channel.id }}?page={{ page }}") + ("class" "button small") + ("onclick" "window.VIEWING_SINGLE = false") + ("target" "_top") + (text "{{ text \"chats:label.go_back\" }}"))) + (text "{{ components::message(user=message_owner, message=message, grouped=false) }} {% else %} {% for message in messages %} {{ components::message(user=message[1], message=message[0], grouped=message[2]) }} {% endfor %} {%- endif %} {% if messages|length > 0 -%}") + (div + ("class" "flex gap_2 w_full justify_center") + (a + ("class" "button") + ("href" "/chats/{{ community }}/{{ channel.id }}/_stream?page={{ page + 1 }}") + ("onclick" "window.CURRENT_PAGE += 1") + (text "{{ icon \"clock\" }}") + (span + (text "{{ text \"chats:label.view_older\" }}"))) + (text "{% if page != 0 -%}") + (a + ("class" "button lowered") + ("href" "/chats/{{ community }}/{{ channel }}/_stream?page={{ page - 1 }}") + ("onclick" "window.CURRENT_PAGE -= 1") + (text "{{ icon \"rewind\" }}") + (span + (text "{{ text \"chats:label.view_more_recent\" }}"))) + (text "{%- endif %}")) + (text "{%- endif %}")) + (style + (text "#stream_body { + height: 100%; + display: flex; + justify-content: flex-start; + flex-direction: column-reverse; + overflow: auto; + }"))) diff --git a/crates/app/src/public/html/communities/create_post.lisp b/crates/app/src/public/html/communities/create_post.lisp index 938f019..7e3be37 100644 --- a/crates/app/src/public/html/communities/create_post.lisp +++ b/crates/app/src/public/html/communities/create_post.lisp @@ -361,7 +361,6 @@ false, document.getElementById(\"community_to_post_to\") .selectedOptions[0].getAttribute(\"is_stack\") === \"true\", - e.target.file_picker ? e.target.file_picker.files : [], ]); // update settings diff --git a/crates/app/src/public/html/communities/settings.lisp b/crates/app/src/public/html/communities/settings.lisp index a32a573..f95a900 100644 --- a/crates/app/src/public/html/communities/settings.lisp +++ b/crates/app/src/public/html/communities/settings.lisp @@ -29,6 +29,14 @@ (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 %}") (a ("href" "#/topics") ("data-tab-button" "topics") @@ -139,7 +147,7 @@ (button (icon (text "check")) (span - (str (text "general:action.save"))))))) + (text "{{ text \"general:action.save\" }}")))))) (div ("class" "card_nest") ("ui_ident" "danger_zone") @@ -162,7 +170,7 @@ ("onclick" "save_context()") (icon (text "check")) (span - (str (text "general:action.save")))) + (text "{{ text \"general:action.save\" }}"))) (a ("href" "/community/{{ community.title }}") ("class" "button secondary") @@ -265,11 +273,167 @@ ("onclick" "update_user_role(document.getElementById('uid').value, document.getElementById('role').value)") (icon (text "check")) (span - (str (text "general:action.save"))))) + (text "{{ text \"general:action.save\" }}")))) (div ("class" "card flex flex_col gap_2") ("id" "permission_builder")))) - (text "{% if can_manage_emojis -%}") + (text "{% if can_manage_channels -%}") + (div + ("class" "card lowered w_full hidden flex flex_col gap_2") + ("data-tab" "channels") + (div + ("class" "card_nest") + (div + ("class" "card small") + (b + (text "{{ text \"communities:action.create_channel\" }}"))) + (form + ("class" "card flex flex_col gap_2") + ("onsubmit" "create_channel_from_form(event)") + (div + ("class" "flex flex_col gap_1") + (label + ("for" "title") + (str (text "communities:label.name"))) + (input + ("type" "text") + ("name" "title") + ("id" "title") + ("placeholder" "name") + ("required" "") + ("minlength" "2") + ("maxlength" "32"))) + (button + (str (text "communities:action.create"))))) + (text "{% for channel in channels %}") + (div + ("class" "card_nest") + (div + ("class" "card small") + (b + (text "{{ channel.position }} ")) + (text "{{ channel.title }}")) + (div + ("class" "card flex gap_2") + (button + ("class" "red lowered small") + ("onclick" "delete_channel('{{ channel.id }}')") + (text "{{ text \"general:action.delete\" }}")) + (button + ("class" "lowered small") + ("onclick" "update_channel_position('{{ channel.id }}')") + (text "{{ text \"chats:action.move\" }}")) + (button + ("class" "lowered small") + ("onclick" "update_channel_title('{{ channel.id }}')") + (text "{{ text \"chats:action.rename\" }}")))) + (text "{% endfor %}")) + (script + (text "globalThis.delete_channel = async (id) => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + fetch(`/api/v1/channels/${id}`, { + method: \"DELETE\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + }; + + globalThis.update_channel_position = async (id) => { + await trigger(\"atto::debounce\", [\"channels::move\"]); + + const position = Number.parseInt( + await trigger(\"atto::prompt\", [ + \"New channel position (number):\", + ]), + ); + + if (!position && position !== 0) { + return alert(\"Must be a number!\"); + } + + fetch(`/api/v1/channels/${id}/move`, { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + position, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + }; + + globalThis.update_channel_title = async (id) => { + await trigger(\"atto::debounce\", [\"channels::update_title\"]); + const title = await trigger(\"atto::prompt\", [\"New channel title:\"]); + + if (!title) { + return; + } + + fetch(`/api/v1/channels/${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, + ]); + }); + }; + + async function create_channel_from_form(e) { + e.preventDefault(); + await trigger(\"atto::debounce\", [\"channels::create\"]); + + fetch(\"/api/v1/channels\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + title: e.target.title.value, + community: \"{{ community.id }}\", + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + window.location.reload(); + } + }); + }")) + (text "{%- endif %} {% if can_manage_emojis -%}") (div ("class" "card lowered w_full hidden flex flex_col gap_2") ("data-tab" "emojis") @@ -823,7 +987,7 @@ MANAGE_PINS: 1 << 7, MANAGE_COMMUNITY: 1 << 8, MANAGE_QUESTIONS: 1 << 9, - UNUSED_0: 1 << 10, + MANAGE_CHANNELS: 1 << 10, MANAGE_MESSAGES: 1 << 11, MANAGE_EMOJIS: 1 << 12, }, diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 53e316d..9dea998 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -706,9 +706,7 @@ ("cy" "12") ("r" "6")))) -(text "{%- endif %} {%- endmacro %}") - -(text "{% macro theme(user, theme_preference) -%} {% if user %} {% if user.settings.theme_hue -%}") +(text "{%- endif %} {%- endmacro %} {% macro theme(user, theme_preference) -%} {% if user %} {% if user.settings.theme_hue -%}") (style (text ":root, * { --hue: {{ user.settings.theme_hue }} !important; @@ -741,6 +739,7 @@ setTimeout(() => { match_user_theme(); }, 150);")) + (text "{%- endif %}") (div ("style" "display: none;") @@ -749,6 +748,7 @@ (style (text "{{ user.settings.theme_custom_css|remove_script_tags|safe }}")) (text "{%- endif %}")) + (text "{%- endif %} {%- endmacro %} {% macro theme_color(color, css) -%} {% if color -%}") (style (text ":root, @@ -756,9 +756,7 @@ --{{ css }}: {{ color|color }} !important; }")) -(text "{%- endif %} {%- endmacro %}") - -(text "{% macro question(question, owner, asking_about=false, show_community=true, secondary=false, profile=false) -%}") +(text "{%- endif %} {%- endmacro %} {% macro question(question, owner, asking_about=false, show_community=true, secondary=false, profile=false) -%}") (div ("class" "card question {% if secondary -%}secondary{%- endif %} flex gap_2") (text "{% if owner.id == 0 or question.context.mask_owner -%}") @@ -862,8 +860,8 @@ (div ("class" "card_nest") (div - ("class" "card small flex items_center gap_2 flex_wrap") - (icon (text "message-circle-heart")) + ("class" "card small flex items_center gap_2") + (text "{{ icon \"message-circle-heart\" }}") (span ("class" "no_p_margin") (text "{% if header -%} {{ header|markdown|safe }} {% else %} {{ text \"requests:label.ask_question\" }} {%- endif %}"))) @@ -1145,8 +1143,106 @@ (div ("style" "display: contents;") (text "{% if key == \"Spotify\" -%} {{ icon \"spotify\" }} {% elif key == \"LastFm\" %} {{ icon \"last_fm\" }} {%- endif %}")) -(text "{%- endmacro %} {% macro connection_url(key, value) -%} {% if value[0].data.url %} {{ value[0].data.url }} {% elif key == \"LastFm\" %} https://last.fm/user/{{ value[0].data.name }} {%- endif %} {%- endmacro %}") -(text "{% macro user_menu() -%}") +(text "{%- endmacro %} {% macro connection_url(key, value) -%} {% if value[0].data.url %} {{ value[0].data.url }} {% elif key == \"LastFm\" %} https://last.fm/user/{{ value[0].data.name }} {%- endif %} {%- endmacro %} {% macro message_actions(can_manage_message, owner, message, owner) -%}") +(div + ("class" "dropdown") + (button + ("class" "camo small") + ("onclick" "trigger('atto::hooks::dropdown', [event])") + ("exclude" "dropdown") + ("title" "More options") + (text "{{ icon \"ellipsis\" }}")) + (div + ("class" "inner") + (text "{% if can_manage_message or (user and user.id == message.owner) -%}") + (button + ("class" "red") + ("onclick" "delete_message('{{ message.id }}')") + (text "{{ icon \"trash\" }}") + (span + (text "{{ text \"general:action.delete\" }}"))) + (text "{%- endif %}") + (button + ("onclick" "window.location.href = `${window.location.origin}/chats/{{ community }}/{{ channel }}?message={{ message.id }}`") + (text "{{ icon \"external-link\" }}") + (span + (text "{{ text \"general:action.open\" }}"))) + (button + ("onclick" "trigger('atto::copy_text', [`${window.location.origin}/chats/{{ community }}/{{ channel }}?message={{ message.id }}`])") + (text "{{ icon \"copy\" }}") + (span + (text "{{ text \"general:action.copy_link\" }}"))) + (button + ("onclick" "mention_user('{{ owner.username }}')") + (text "{{ icon \"at-sign\" }}") + (span + (text "{{ text \"chats:action.mention_user\" }}"))))) + +(text "{%- endmacro %} {% macro message(user, message, can_manage_message=false, grouped=false) -%}") +(div + ("class" "card secondary message flex gap_2 {% if grouped -%}grouped{%- endif %}") + ("id" "message-{{ message.id }}") + (text "{% if not grouped -%}") + (a + ("href" "/@{{ user.username }}") + ("target" "_top") + (text "{{ self::avatar(id=user.id, size=\"42px\") }}")) + (text "{%- endif %}") + (div + ("class" "flex flex_col gap_1 w_full") + (text "{% if not grouped -%}") + (div + ("class" "flex gap_2 w_full justify_between flex_wrap") + (div + ("class" "flex gap_2") + (text "{{ self::full_username(user=user) }} {% if message.edited != message.created %}") + (span + ("class" "date") + (text "{{ message.edited }}") + (sup + ("title" "Edited") + (text "*"))) + (text "{% else %}") + (span + ("class" "date") + (text "{{ message.created }}")) + (text "{%- endif %}")) + (div + ("class" "flex gap_2 hidden") + ("onclick" "window.EMOJI_PICKER_REACTION_MESSAGE_ID = '{{ message.id }}'") + (text "{{ self::emoji_picker(element_id=\"react_emoji_picker_field\", render_dialog=false, render_button=true, small=true) }}") + (text "{{ self::message_actions(owner=user, message=message, can_manage_message=can_manage_message) }}"))) + (text "{%- endif %}") + (div + ("class" "flex w_full gap_2 justify_between") + (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 %}"))) + (text "{% if grouped -%}") + (div + ("class" "hidden flex gap_2 items_center") + ("onclick" "window.EMOJI_PICKER_REACTION_MESSAGE_ID = '{{ message.id }}'") + (text "{{ self::emoji_picker(element_id=\"react_emoji_picker_field\", render_dialog=false, render_button=true, small=true) }}") + (text "{{ self::message_actions(owner=user, message=message, can_manage_message=can_manage_message) }}")) + (text "{%- endif %}")))) + +(text "{%- endmacro %} {% macro user_menu() -%}") (div ("class" "inner") (b @@ -1494,7 +1590,7 @@ (text "{% macro create_post_options() -%}") (div ("class" "flex gap_2 flex_wrap") - (text "{{ components::emoji_picker(element_id=\"content\", render_dialog=true) }} {% if is_supporter -%} {{ components::file_picker(files_list_id=\"files_list\") }} {%- endif %}") + (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 ("class" "small square lowered") @@ -1952,6 +2048,12 @@ (text "{{ icon \"circle-minus\" }}") (span (text "{{ text \"communities:action.leave\" }}"))) + (a + ("href" "/chats/{{ community.id }}/0") + ("class" "button lowered") + (text "{{ icon \"message-circle\" }}") + (span + (text "{{ text \"communities:label.chats\" }}"))) (text "{% if user and can_post -%}") (a ("href" "/communities/intents/post?community={{ community.id }}") @@ -1990,6 +2092,12 @@ }); };")) (text "{%- endif %} {% else %}") + (a + ("href" "/chats/{{ community.id }}/0") + ("class" "button lowered") + (text "{{ icon \"message-circle\" }}") + (span + (text "{{ text \"communities:label.chats\" }}"))) (text "{% if not community.is_forum -%}") (a ("href" "/communities/intents/post?community={{ community.id }}") diff --git a/crates/app/src/public/html/developer/app.lisp b/crates/app/src/public/html/developer/app.lisp index 1b4060e..4d4d9c8 100644 --- a/crates/app/src/public/html/developer/app.lisp +++ b/crates/app/src/public/html/developer/app.lisp @@ -95,7 +95,7 @@ (button (icon (text "check")) (span - (str (text "general:action.save")))))) + (text "{{ text \"general:action.save\" }}"))))) (div ("class" "card_nest") (div @@ -120,7 +120,7 @@ (button (icon (text "check")) (span - (str (text "general:action.save")))))) + (text "{{ text \"general:action.save\" }}"))))) (div ("class" "card_nest") (div @@ -145,7 +145,7 @@ (button (icon (text "check")) (span - (str (text "general:action.save")))))) + (text "{{ text \"general:action.save\" }}"))))) (div ("class" "card_nest") (div @@ -185,7 +185,7 @@ (button (icon (text "check")) (span - (str (text "general:action.save")))))) + (text "{{ text \"general:action.save\" }}"))))) (div ("class" "card_nest") (div diff --git a/crates/app/src/public/html/journals/app.lisp b/crates/app/src/public/html/journals/app.lisp index 8f52ed0..337d39c 100644 --- a/crates/app/src/public/html/journals/app.lisp +++ b/crates/app/src/public/html/journals/app.lisp @@ -255,7 +255,7 @@ (button (icon (text "check")) (span - (str (text "general:action.save")))))))) + (text "{{ text \"general:action.save\" }}"))))))) ; users should also be able to manage the journal's sub directories here (details diff --git a/crates/app/src/public/html/macros.lisp b/crates/app/src/public/html/macros.lisp index db07623..f71deeb 100644 --- a/crates/app/src/public/html/macros.lisp +++ b/crates/app/src/public/html/macros.lisp @@ -68,12 +68,10 @@ (div ("class" "inner") - (text "{% if config.service_hosts.tawny -%}") (a - ("href" "{{ config.service_hosts.tawny }}/api/v1/auth/set_token?token=") + ("href" "/chats/0/0") (icon (text "message-circle")) (str (text "communities:label.chats"))) - (text "{%- endif %}") (a ("href" "/mail") (icon (text "mail")) @@ -356,7 +354,7 @@ (str (text "forge:tab.tickets")))) (text "{%- endmacro %}") -(text "{% macro user_settings_nav_options() -%}") +(text "{% macro profile_settings_nav_options() -%}") (a ("data-tab-button" "account") ("class" "active") @@ -370,12 +368,6 @@ (text "{{ icon \"user-round\" }}") (span (text "{{ text \"settings:tab.profile\" }}"))) -(a - ("data-tab-button" "experience") - ("href" "#/experience") - (text "{{ icon \"settings-2\" }}") - (span - (text "{{ text \"settings:tab.experience\" }}"))) (a ("data-tab-button" "theme") ("href" "#/theme") @@ -401,10 +393,4 @@ (text "{{ icon \"book-user\" }}") (span (text "{{ text \"settings:tab.close_friends\" }}"))) -(a - ("data-tab-button" "presets") - ("href" "#/presets") - (icon (text "cooking-pot")) - (span - (str (text "settings:tab.presets")))) (text "{%- endmacro %}") diff --git a/crates/app/src/public/html/mod/profile.lisp b/crates/app/src/public/html/mod/profile.lisp index efac7d7..843f40c 100644 --- a/crates/app/src/public/html/mod/profile.lisp +++ b/crates/app/src/public/html/mod/profile.lisp @@ -358,7 +358,7 @@ ("onclick" "update_user_role(Number.parseInt(document.getElementById('role').value))") (icon (text "check")) (span - (str (text "general:action.save"))))) + (text "{{ text \"general:action.save\" }}")))) (div ("class" "card lowered flex flex_col gap_2") ("id" "permission_builder"))) @@ -376,7 +376,7 @@ ("onclick" "update_user_secondary_role(Number.parseInt(document.getElementById('secondary_role').value))") (icon (text "check")) (span - (str (text "general:action.save"))))) + (text "{{ text \"general:action.save\" }}")))) (div ("class" "card lowered flex flex_col gap_2") ("id" "secondary_permission_builder"))) @@ -409,7 +409,7 @@ SUPPORTER: 1 << 19, MANAGE_REQUESTS: 1 << 20, MANAGE_QUESTIONS: 1 << 21, - UNUSED_0: 1 << 22, + MANAGE_CHANNELS: 1 << 22, MANAGE_MESSAGES: 1 << 23, MANAGE_UPLOADS: 1 << 24, MANAGE_EMOJIS: 1 << 25, diff --git a/crates/app/src/public/html/mod/stats.lisp b/crates/app/src/public/html/mod/stats.lisp index 3a3bbeb..9d02f2e 100644 --- a/crates/app/src/public/html/mod/stats.lisp +++ b/crates/app/src/public/html/mod/stats.lisp @@ -20,11 +20,16 @@ (text "Active user streams: ")) (span (text "{{ active_users }}"))) + (li + (b + (text "Active chat subscriptions: ")) + (span + (text "{{ active_users_chats }}"))) (li (b (text "Socket tasks: ")) (span - (text "{{ active_users * 3 }}")))) + (text "{{ (active_users_chats + active_users) * 3 }}")))) (hr) (ul diff --git a/crates/app/src/public/html/post/post.lisp b/crates/app/src/public/html/post/post.lisp index 61214cf..ad6fcb0 100644 --- a/crates/app/src/public/html/post/post.lisp +++ b/crates/app/src/public/html/post/post.lisp @@ -148,7 +148,7 @@ ("onclick" "save_context()") (icon (text "check")) (span - (str (text "general:action.save")))) + (text "{{ text \"general:action.save\" }}"))) (script (text "setTimeout(async () => { const ui = await ns(\"ui\"); @@ -286,7 +286,7 @@ ("class" "flex gap_2") (text "{{ components::emoji_picker(element_id=\"new_content\", render_dialog=false) }}") (button - (str (text "general:action.save")))))) + (text "{{ text \"general:action.save\" }}"))))) (script (text "async function edit_post_from_form(e) { e.preventDefault(); diff --git a/crates/app/src/public/html/profile/base.lisp b/crates/app/src/public/html/profile/base.lisp index 31e6404..72d9ebf 100644 --- a/crates/app/src/public/html/profile/base.lisp +++ b/crates/app/src/public/html/profile/base.lisp @@ -115,7 +115,7 @@ ("class" "fade") (text "{{ profile.username }}")))) (div - ("class" "card flex flex_col items_center small gap_2") + ("class" "card flex flex_col items_center gap_2") ("id" "social") (text "{% if profile.settings.status -%}") (p @@ -159,20 +159,7 @@ (div ("id" "bio") ("class" "card small no_p_margin") - (text "{{ profile.settings.biography|markdown|safe }}") - - (text "{% if profile.settings.location|length > 0 -%}") - (span ("class" "flex items_center gap_2 flex_wrap") (icon (text "map-pin")) (text "{{ profile.settings.location }}")) - (text "{%- endif %}") - - (text "{% for link in profile.settings.links -%}") - (span - ("class" "flex items_center gap_2 flex_wrap") - (icon (text "link")) - (a - ("href" "{{ link[1] }}") - (text "{{ link[0] }}"))) - (text "{%- endfor %}")) + (text "{{ profile.settings.biography|markdown|safe }}")) (div ("class" "card flex flex_col gap_2") (text "{% if user -%}") @@ -278,18 +265,12 @@ (span (text "{{ text \"auth:action.unblock\" }}"))) (text "{%- endif %} {% if not profile.settings.private_chats or is_following_you %}") - (text "{% if config.service_hosts.tawny -%}") - (a - ("href" "{{ config.service_hosts.tawny }}/@{{ profile.username }}") - ("class" "button lowered") - (icon (text "egg")) - (span (text "Tawny"))) - (a - ("href" "{{ config.service_hosts.tawny }}/@{{ profile.username }}/confirm_dm") - ("class" "button lowered") - (icon (text "message-circle")) - (span (str (text "auth:action.message")))) - (text "{%- endif %}") + (button + ("onclick" "create_group_chat()") + ("class" "lowered") + (text "{{ icon \"message-circle\" }}") + (span + (text "{{ text \"auth:action.message\" }}"))) (text "{%- endif %} {% if not profile.settings.private_mails or is_following_you %}") (a ("href" "/mail/compose?receivers={{ profile.username }}") @@ -313,7 +294,31 @@ (text "{{ text \"general:action.manage\" }}"))) (text "{%- endif %}") (script - (text "globalThis.request_transfer = async () => { + (text "globalThis.create_group_chat = async () => { + fetch(\"/api/v1/channels/group\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + title: \"{{ user.username }} & {{ profile.username }}\", + members: [\"{{ profile.id }}\"], + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + window.location.href = `/chats/0/${res.payload}`; + } + }); + }; + + globalThis.request_transfer = async () => { await trigger(\"atto::debounce\", [\"economy::transfer\"]); const amount = Number.parseInt((await trigger(\"atto::prompt\", [\"Request amount:\"])) || \"0\"); diff --git a/crates/app/src/public/html/profile/posts.lisp b/crates/app/src/public/html/profile/posts.lisp index ee24aaf..7e1ee28 100644 --- a/crates/app/src/public/html/profile/posts.lisp +++ b/crates/app/src/public/html/profile/posts.lisp @@ -2,6 +2,7 @@ (div ("style" "display: contents") (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings, allow_anonymous=profile.settings.allow_anonymous_questions) }}")) + (text "{%- endif %}") (text "{{ macros::profile_nav(selected=\"posts\") }}") (div diff --git a/crates/app/src/public/html/profile/replies.lisp b/crates/app/src/public/html/profile/replies.lisp index 0494e2a..66b5ec3 100644 --- a/crates/app/src/public/html/profile/replies.lisp +++ b/crates/app/src/public/html/profile/replies.lisp @@ -24,4 +24,5 @@ (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) }}"))) + (text "{% endblock %}") diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index 7a91e0d..0a69f2b 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -7,7 +7,7 @@ ; nav desktop (menu ("class" "desktop col") - (text "{{ macros::user_settings_nav_options() }}")) + (text "{{ macros::profile_settings_nav_options() }}")) ; content (main @@ -35,7 +35,7 @@ (span ("class" "current_tab_text") (text "account"))) (div ("class" "inner left") - (text "{{ macros::user_settings_nav_options() }}")))) + (text "{{ macros::profile_settings_nav_options() }}")))) ; ... (div @@ -43,70 +43,81 @@ ("data-tab" "presets") (div ("class" "card lowered flex flex_col gap_2") - (p (text "Not sure where to start? Try some settings presets!")) - (details - ("class" "w_full accordion") - (summary - ("class" "button raised") - (icon (text "rss")) - (text "Microblogging")) - + (a + ("href" "#/account") + ("class" "button secondary") + (icon (text "arrow-left")) + (span + (str (text "general:action.back")))) + (div + ("class" "card_nest") (div - ("class" "inner flex flex_col gap_2") - (p ("class" "fade") (text "Focus on yourself and your communities.")) - (ul ("id" "preset_microblogging_ul")) - (button - ("onclick" "apply_preset(PRESET_MICROBLOGGING)") - (icon (text "settings")) - (str (text "general:action.apply"))))) - - (details - ("class" "w_full accordion") - (summary - ("class" "button raised") - (icon (text "message-circle-heart")) - (text "Q&A")) - + ("class" "card flex items_center gap_2 small") + (icon (text "cooking-pot")) + (span + (str (text "settings:tab.presets")))) (div - ("class" "inner flex flex_col gap_2") - (p ("class" "fade") (text "Just like Neospring!")) - (ul ("id" "preset_questions_ul")) - (button - ("onclick" "apply_preset(PRESET_QUESTIONS)") - (icon (text "settings")) - (str (text "general:action.apply"))))) + ("class" "card flex flex_col gap_2 secondary") + (p (text "Not sure where to start? Try some settings presets!")) + (details + ("class" "w_full accordion") + (summary + (icon (text "rss")) + (text "Microblogging")) - (details - ("class" "w_full accordion") - (summary - ("class" "button raised") - (icon (text "key")) - (text "Private")) + (div + ("class" "inner flex flex_col gap_2") + (p ("class" "fade") (text "Focus on yourself and your communities.")) + (ul ("id" "preset_microblogging_ul")) + (button + ("onclick" "apply_preset(PRESET_MICROBLOGGING)") + (icon (text "settings")) + (str (text "general:action.apply"))))) - (div - ("class" "inner flex flex_col gap_2") - (p ("class" "fade") (text "This preset allows you to keep your profile and posts hidden to people you aren't following.")) - (ul ("id" "preset_private_ul")) - (button - ("onclick" "apply_preset(PRESET_PRIVATE)") - (icon (text "settings")) - (str (text "general:action.apply"))))) + (details + ("class" "w_full accordion") + (summary + (icon (text "message-circle-heart")) + (text "Q&A")) - (details - ("class" "w_full accordion") - (summary - ("class" "button raised") - (icon (text "eye-closed")) - (text "NSFW")) + (div + ("class" "inner flex flex_col gap_2") + (p ("class" "fade") (text "Just like Neospring!")) + (ul ("id" "preset_questions_ul")) + (button + ("onclick" "apply_preset(PRESET_QUESTIONS)") + (icon (text "settings")) + (str (text "general:action.apply"))))) - (div - ("class" "inner flex flex_col gap_2") - (p ("class" "fade") (text "NSFW content is allowed if it is hidden from main timelines. This preset will help you do that quickly.")) - (ul ("id" "preset_nsfw_ul")) - (button - ("onclick" "apply_preset(PRESET_NSFW)") - (icon (text "settings")) - (str (text "general:action.apply"))))))) + (details + ("class" "w_full accordion") + (summary + (icon (text "key")) + (text "Private")) + + (div + ("class" "inner flex flex_col gap_2") + (p ("class" "fade") (text "This preset allows you to keep your profile and posts hidden to people you aren't following.")) + (ul ("id" "preset_private_ul")) + (button + ("onclick" "apply_preset(PRESET_PRIVATE)") + (icon (text "settings")) + (str (text "general:action.apply"))))) + + (details + ("class" "w_full accordion") + (summary + (icon (text "eye-closed")) + (text "NSFW")) + + (div + ("class" "inner flex flex_col gap_2") + (p ("class" "fade") (text "NSFW content is allowed if it is hidden from main timelines. This preset will help you do that quickly.")) + (ul ("id" "preset_nsfw_ul")) + (button + ("onclick" "apply_preset(PRESET_NSFW)") + (icon (text "settings")) + (str (text "general:action.apply"))))))))) (div ("class" "w_full flex flex_col gap_2") @@ -169,6 +180,61 @@ (span (text "{{ text \"settings:tab.billing\" }}")))) (text "{%- endif %}") + + (div + ("class" "card_nest") + ("ui_ident" "home_timeline") + (div + ("class" "card small") + (b + (text "Home timeline"))) + (div + ("class" "card") + (select + ("onchange" "window.SETTING_SET_FUNCTIONS[0]('default_timeline', event.target.selectedOptions[0].value.startsWith('{') ? JSON.parse(event.target.selectedOptions[0].value) : event.target.selectedOptions[0].value)") + (option + ("value" "MyCommunities") + ("selected" "{% if home == '/' -%}true{% else %}false{%- endif %}") + (text "My communities")) + (option + ("value" "MyCommunitiesQuestions") + ("selected" "{% if home == '/questions' -%}true{% else %}false{%- endif %}") + (text "My communities (questions)")) + (option + ("value" "PopularPosts") + ("selected" "{% if home == '/popular' -%}true{% else %}false{%- endif %}") + (text "Popular")) + (option + ("value" "PopularQuestions") + ("selected" "{% if home == '/popular/questions' -%}true{% else %}false{%- endif %}") + (text "Popular (questions)")) + (option + ("value" "FollowingPosts") + ("selected" "{% if home == '/following' -%}true{% else %}false{%- endif %}") + (text "Following")) + (option + ("value" "FollowingQuestions") + ("selected" "{% if home == '/following/questions' -%}true{% else %}false{%- endif %}") + (text "Following (questions)")) + (option + ("value" "AllPosts") + ("selected" "{% if home == '/all' -%}true{% else %}false{%- endif %}") + (text "All")) + (option + ("value" "AllQuestions") + ("selected" "{% if home == '/all/questions' -%}true{% else %}false{%- endif %}") + (text "All (questions)")) + (text "{% for stack in stacks %}") + (text "") + (text "{% endfor %}")) + (span + ("class" "fade") + (text "This represents the timeline the home button takes you to.")))) (div ("class" "card_nest desktop") ("ui_ident" "notifications") @@ -216,7 +282,7 @@ (button (icon (text "check")) (span - (str (text "general:action.save"))))))) + (text "{{ text \"general:action.save\" }}")))))) (div ("class" "card_nest") ("ui_ident" "delete_account") @@ -271,7 +337,7 @@ ("id" "save_button") (icon (text "check")) (span - (str (text "general:action.save"))))) + (text "{{ text \"general:action.save\" }}")))) (div ("class" "w_full flex flex_col gap_2 hidden") ("data-tab" "account/security") @@ -378,7 +444,7 @@ (button (icon (text "check")) (span - (str (text "general:action.save")))))))))) + (text "{{ text \"general:action.save\" }}"))))))))) (div ("class" "w_full flex flex_col gap_2 hidden") ("data-tab" "account/following") @@ -925,13 +991,13 @@ (span ("class" "fade") (text "Use an image of 1100x350px for the best results.")))) - (div ("class" "card_nest") ("ui_ident" "default_profile_page") (div ("class" "card small") - (b (text "Default profile tab"))) + (b + (text "Default profile tab"))) (div ("class" "card") (select @@ -947,36 +1013,10 @@ (span ("class" "fade") (text "This represents the timeline that is shown on your profile by default.")))) - - (div - ("class" "card_nest") - ("ui_ident" "user_links") - (div - ("class" "card small") - (b (text "My links"))) - (div - ("class" "card flex flex_col gap_2") - (button - ("onclick" "add_link()") - (icon (text "plus")) - (text "Add link")) - - (ul ("id" "user_links"))))) - (button - ("onclick" "save_settings()") - ("id" "save_button") - (icon (text "check")) - (span - (str (text "general:action.save"))))) - (div - ("class" "w_full hidden flex flex_col gap_2") - ("data-tab" "experience") - (div - ("class" "card lowered flex flex_col gap_2") - ("id" "experience_settings") (div ("class" "flex flex_col gap_2") ("ui_ident" "show_presets") + (hr ("class" "margin")) (div ("class" "card_nest") (div @@ -988,67 +1028,13 @@ (p (text "Quickly set up your account with ") (a ("href" "/settings#/presets") (text "settings presets")) - (text "!"))))) - (div - ("class" "card_nest") - ("ui_ident" "home_timeline") - (div - ("class" "card small") - (b - (text "Home timeline"))) - (div - ("class" "card") - (select - ("onchange" "window.SETTING_SET_FUNCTIONS[0]('default_timeline', event.target.selectedOptions[0].value.startsWith('{') ? JSON.parse(event.target.selectedOptions[0].value) : event.target.selectedOptions[0].value)") - (option - ("value" "MyCommunities") - ("selected" "{% if home == '/' -%}true{% else %}false{%- endif %}") - (text "My communities")) - (option - ("value" "MyCommunitiesQuestions") - ("selected" "{% if home == '/questions' -%}true{% else %}false{%- endif %}") - (text "My communities (questions)")) - (option - ("value" "PopularPosts") - ("selected" "{% if home == '/popular' -%}true{% else %}false{%- endif %}") - (text "Popular")) - (option - ("value" "PopularQuestions") - ("selected" "{% if home == '/popular/questions' -%}true{% else %}false{%- endif %}") - (text "Popular (questions)")) - (option - ("value" "FollowingPosts") - ("selected" "{% if home == '/following' -%}true{% else %}false{%- endif %}") - (text "Following")) - (option - ("value" "FollowingQuestions") - ("selected" "{% if home == '/following/questions' -%}true{% else %}false{%- endif %}") - (text "Following (questions)")) - (option - ("value" "AllPosts") - ("selected" "{% if home == '/all' -%}true{% else %}false{%- endif %}") - (text "All")) - (option - ("value" "AllQuestions") - ("selected" "{% if home == '/all/questions' -%}true{% else %}false{%- endif %}") - (text "All (questions)")) - (text "{% for stack in stacks %}") - (text "") - (text "{% endfor %}")) - (span - ("class" "fade") - (text "This represents the timeline the home button takes you to."))))) + (text "!")))))) (button ("onclick" "save_settings()") ("id" "save_button") (icon (text "check")) (span - (str (text "general:action.save"))))) + (text "{{ text \"general:action.save\" }}")))) (div ("class" "card w_full lowered hidden flex flex_col gap_2") ("data-tab" "sessions") @@ -1207,7 +1193,7 @@ ("id" "save_button") (icon (text "check")) (span - (str (text "general:action.save"))))) + (text "{{ text \"general:action.save\" }}")))) (div ("class" "card w_full lowered hidden flex flex_col gap_2") ("data-tab" "grants") @@ -1609,7 +1595,7 @@ `data:image/png;base64,${qr}`; document.getElementById( \"totp_recovery_codes\", - ).innerText = recovery_codes.join(\"\\n\"); + ).innerText = recovery_codes.join(\"\n\"); document.getElementById(\"totp_stuff\").style.display = \"contents\"; @@ -1801,13 +1787,12 @@ document.getElementById(\"account_settings\"); const profile_settings = document.getElementById(\"profile_settings\"); - const experience_settings = - document.getElementById(\"experience_settings\"); const theme_settings = document.getElementById(\"theme_settings\"); ui.refresh_container(account_settings, [ \"supporter_ad\", \"account_settings_tabs\", + \"home_timeline\", \"notifications\", \"change_username\", \"delete_account\", @@ -1817,11 +1802,6 @@ \"change_avatar\", \"change_banner\", \"default_profile_page\", - \"user_links\", - ]); - ui.refresh_container(experience_settings, [ - \"supporter_ad\", - \"home_timeline\", \"show_presets\", ]); ui.refresh_container(theme_settings, [ @@ -1834,7 +1814,7 @@ ]); ui.generate_settings_ui( - profile_settings, + account_settings, [ [ [\"display_name\", \"Display name\"], @@ -1855,23 +1835,50 @@ 'This biography is only shown to users you are not following while your account is private.', }, ], - [ - [\"location\", \"Location\"], - \"{{ profile.settings.location }}\", - \"input\", - ], [[\"status\", \"Status\"], settings.status, \"textarea\"], [ [\"warning\", \"Profile warning\"], settings.warning, \"textarea\", ], + [[\"muted\", \"Muted phrases\"], settings.muted.join(\"\\n\"), \"textarea\", { + embed_html: + 'Muted phrases should all be on new lines.', + }], + [[], \"Accessibility\", \"title\"], + [ + [\"large_text\", \"Increase UI text size\"], + \"{{ profile.settings.large_text }}\", + \"checkbox\", + ], + [ + [\"use_system_font\", \"Always use system font instead\"], + \"{{ profile.settings.use_system_font }}\", + \"checkbox\", + ], + [ + [\"paged_timelines\", \"Make timelines paged instead of infinitely scrolled\"], + \"{{ profile.settings.paged_timelines }}\", + \"checkbox\", + ], + [ + [\"auto_clear_notifs\", \"Automatically clear all notifications when you open the notifications page\"], + \"{{ profile.settings.auto_clear_notifs }}\", + \"checkbox\", + ], ], settings, + { + muted: (new_muted) => { + settings.muted = new_muted + .split(\"\\n\") + .map((t) => t.trim()); + }, + }, ); ui.generate_settings_ui( - experience_settings, + profile_settings, [ [[], \"Privacy\", \"title\"], [ @@ -1981,10 +1988,6 @@ \"{{ profile.settings.hide_username_badges }}\", \"checkbox\", ], - [[\"muted\", \"Muted phrases\"], settings.muted.join(\"\\n\"), \"textarea\", { - embed_html: - 'Muted phrases should all be on new lines.', - }], [[], \"Questions\", \"title\"], [ [ @@ -2071,36 +2074,8 @@ \"{{ profile.settings.disable_achievements }}\", \"checkbox\", ], - [[], \"Accessibility\", \"title\"], - [ - [\"large_text\", \"Increase UI text size\"], - \"{{ profile.settings.large_text }}\", - \"checkbox\", - ], - [ - [\"use_system_font\", \"Always use system font instead\"], - \"{{ profile.settings.use_system_font }}\", - \"checkbox\", - ], - [ - [\"paged_timelines\", \"Make timelines paged instead of infinitely scrolled\"], - \"{{ profile.settings.paged_timelines }}\", - \"checkbox\", - ], - [ - [\"auto_clear_notifs\", \"Automatically clear all notifications when you open the notifications page\"], - \"{{ profile.settings.auto_clear_notifs }}\", - \"checkbox\", - ], ], settings, - { - muted: (new_muted) => { - settings.muted = new_muted - .split(\"\\n\") - .map((t) => t.trim()); - }, - }, ); const can_use_custom_css = @@ -2376,40 +2351,5 @@ anchor.click(); anchor.remove(); }; - - // links - function render_links() { - document.getElementById(\"user_links\").innerHTML = \"\"; - - let i = 0; - for (const link of settings.links) { - document.getElementById(\"user_links\").innerHTML += ``; - i += 1; - } - } - - globalThis.add_link = async () => { - const label = await trigger(\"atto::prompt\", [\"Link label:\"]); - - if (!label) { - return; - } - - const url = await trigger(\"atto::prompt\", [\"Link URL:\"]); - - if (!url) { - return; - } - - settings.links.push([label, url]); - render_links(); - } - - globalThis.remove_link = (idx) => { - settings.links.splice(idx, 1); - document.getElementById(`link_${idx}`).remove(); - } - - render_links(); });")))) (text "{% endblock %}") diff --git a/crates/app/src/public/html/root.lisp b/crates/app/src/public/html/root.lisp index 6af46ef..5073670 100644 --- a/crates/app/src/public/html/root.lisp +++ b/crates/app/src/public/html/root.lisp @@ -33,7 +33,6 @@ classes: {}, service_hosts: { buckets: \"{{ config.service_hosts.buckets|safe }}\", - tawny: \"{{ config.service_hosts.tawny|safe }}\", } }; diff --git a/crates/app/src/public/html/stacks/add_user.lisp b/crates/app/src/public/html/stacks/add_user.lisp index d95db7a..80ab718 100644 --- a/crates/app/src/public/html/stacks/add_user.lisp +++ b/crates/app/src/public/html/stacks/add_user.lisp @@ -9,7 +9,7 @@ ("class" "card_nest") (div ("class" "card small flex items_center gap_2") - (text "{{ components::avatar(id=add_user.id, size=\"24px\") }}") + (text "{{ components::avatar(id=add_user.username, size=\"24px\") }}") (text "{{ components::full_username(user=add_user) }}")) (div ("class" "card flex flex_col gap_2") diff --git a/crates/app/src/public/html/stacks/manage.lisp b/crates/app/src/public/html/stacks/manage.lisp index e34fa4d..95a4942 100644 --- a/crates/app/src/public/html/stacks/manage.lisp +++ b/crates/app/src/public/html/stacks/manage.lisp @@ -117,7 +117,7 @@ (button (icon (text "check")) (span - (str (text "general:action.save"))))))) + (text "{{ text \"general:action.save\" }}")))))) (text "{% if not stack.is_locked -%}") (div ("class" "card_nest") diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js index 08b3f5c..847baad 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -860,8 +860,7 @@ media_theme_pref(); anchor.href.startsWith("https://buy.stripe.com") || anchor.href.startsWith("https://billing.stripe.com") || anchor.href.startsWith("https://last.fm") || - anchor.href.startsWith("atto://") || - anchor.href.startsWith(_app_base.service_hosts.tawny) + anchor.href.startsWith("atto://") ) { continue; } diff --git a/crates/app/src/public/js/me.js b/crates/app/src/public/js/me.js index beb74d6..ef41ad8 100644 --- a/crates/app/src/public/js/me.js +++ b/crates/app/src/public/js/me.js @@ -311,30 +311,19 @@ community, do_not_redirect = false, is_stack = false, - uploads = [], ) => { await trigger("atto::debounce", ["posts::create"]); return new Promise((resolve, _) => { - // create body - const body = new FormData(); - - for (const file of uploads) { - body.append(file.name, file); - } - - body.append( - "body", - JSON.stringify({ + fetch(`/api/v1/posts/${id}/repost`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ content, community: !is_stack ? community : "0", stack: is_stack ? community : "0", }), - ); - - // send - fetch(`/api/v1/posts/${id}/repost`, { - method: "POST", - body, }) .then((res) => res.json()) .then((res) => { @@ -673,15 +662,6 @@ }); // token switcher - self.define("token_links", ({ $ }) => { - for (const anchor of Array.from(document.querySelectorAll("a"))) { - if (anchor.href.endsWith("/set_token?token=")) { - anchor.href += $.LOGIN_ACCOUNT_TOKENS[globalThis.CURRENT_USER]; - continue; - } - } - }); - self.define("append_associations", (_, tokens) => { fetch("/api/v1/auth/user/me/append_associations", { method: "PUT", diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs index 31a5018..e378567 100644 --- a/crates/app/src/routes/api/v1/auth/profile.rs +++ b/crates/app/src/routes/api/v1/auth/profile.rs @@ -153,8 +153,28 @@ pub async fn update_user_settings_request( } // check lengths - if let Err(e) = req.verify_values() { - return Json(e.into()); + if req.display_name.len() > 32 { + return Json(Error::DataTooLong("display name".to_string()).into()); + } + + if req.warning.len() > 2048 { + return Json(Error::DataTooLong("warning".to_string()).into()); + } + + if req.status.len() > 256 { + return Json(Error::DataTooLong("status".to_string()).into()); + } + + if req.biography.len() > 4096 { + return Json(Error::DataTooLong("warning".to_string()).into()); + } + + if req.mail_signature.len() > 2048 { + return Json(Error::DataTooLong("mail signature".to_string()).into()); + } + + if req.forum_signature.len() > 2048 { + return Json(Error::DataTooLong("forum signature".to_string()).into()); } // check percentage themes diff --git a/crates/app/src/routes/api/v1/channels/channels.rs b/crates/app/src/routes/api/v1/channels/channels.rs new file mode 100644 index 0000000..2059a0f --- /dev/null +++ b/crates/app/src/routes/api/v1/channels/channels.rs @@ -0,0 +1,354 @@ +use axum::{Extension, Json, extract::Path, response::IntoResponse}; +use crate::cookie::CookieJar; +use tetratto_core::model::{oauth, channels::Channel, ApiReturn, Error}; +use crate::{ + get_user_from_token, + routes::api::v1::{ + CreateChannel, CreateGroupChannel, KickMember, UpdateChannelPosition, UpdateChannelTitle, + }, + State, +}; + +pub async fn create_request( + jar: CookieJar, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::CommunityCreateChannels) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data + .create_channel(Channel::new( + match req.community.parse::() { + Ok(c) => c, + Err(e) => return Json(Error::MiscError(e.to_string()).into()), + }, + user.id, + 0, + req.title, + )) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Channel created".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn create_group_request( + jar: CookieJar, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::CommunityManageChannels) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let mut members: Vec = Vec::new(); + + for member in req.members { + members.push(match member.parse::() { + Ok(c) => c, + Err(e) => return Json(Error::MiscError(e.to_string()).into()), + }) + } + + // check for existing + if members.len() == 1 { + let other_user = members.first().unwrap().to_owned(); + if let Ok(channel) = data.get_channel_by_owner_member(user.id, other_user).await { + return Json(ApiReturn { + ok: true, + message: "Channel exists".to_string(), + payload: Some(channel.id.to_string()), + }); + } + } + + // check member permissions + for member in &members { + let other_user = match data.get_user_by_id(member.to_owned()).await { + Ok(ua) => ua, + Err(e) => return Json(e.into()), + }; + + if other_user.settings.private_chats + && data + .get_userfollow_by_initiator_receiver(other_user.id, user.id) + .await + .is_err() + { + return Json(Error::NotAllowed.into()); + } + } + + // ... + let mut props = Channel::new(0, user.id, 0, req.title); + props.members = members; + let id = props.id; + + match data.create_channel(props).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Channel created".to_string(), + payload: Some(id.to_string()), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn delete_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::CommunityManageChannels) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.delete_channel(id, &user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Channel deleted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_title_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::CommunityManageChannels) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_channel_title(id, &user, &req.title).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Channel updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_position_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::CommunityManageChannels) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_channel_position(id, &user, req.position).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Channel updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn add_member_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::CommunityManageChannels) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.add_channel_member(id, user, req.member).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Member added".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn kick_member_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::CommunityManageChannels) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data + .remove_channel_member( + id, + user, + match req.member.parse::() { + Ok(c) => c, + Err(e) => return Json(Error::MiscError(e.to_string()).into()), + }, + ) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Member removed".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn get_dm_channels_request( + jar: CookieJar, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::CommunityManageChannels) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.get_channels_by_user(user.id).await { + Ok(c) => Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(c), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn get_community_channels_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::CommunityManageChannels) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + if data + .get_membership_by_owner_community_no_void(user.id, id) + .await + .is_err() + { + // must be a member of the community to request channels + return Json(Error::NotAllowed.into()); + } + + match data.get_channels_by_community(id).await { + Ok(c) => Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(c), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn get_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + if get_user_from_token!(jar, data, oauth::AppScope::CommunityManageChannels).is_none() { + return Json(Error::NotAllowed.into()); + } + + match data.get_channel_by_id(id).await { + Ok(c) => Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(c), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn mute_channel_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageChannelMutes) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + if user.channel_mutes.contains(&id) { + return Json(Error::MiscError("Channel already muted".to_string()).into()); + } + + user.channel_mutes.push(id); + match data + .update_user_channel_mutes(user.id, user.channel_mutes) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Channel muted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn unmute_channel_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageChannelMutes) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let pos = match user.channel_mutes.iter().position(|x| *x == id) { + Some(x) => x, + None => return Json(Error::MiscError("Channel not muted".to_string()).into()), + }; + + user.channel_mutes.remove(pos); + match data + .update_user_channel_mutes(user.id, user.channel_mutes) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Channel muted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} 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..5f5c79c --- /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 crate::cookie::CookieJar; +use tetratto_core::model::{channels::MessageReaction, oauth, ApiReturn, Error}; + +pub async fn get_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> 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, + Json(req): Json, +) -> 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::() { + 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, + 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/messages.rs b/crates/app/src/routes/api/v1/channels/messages.rs new file mode 100644 index 0000000..92a5c48 --- /dev/null +++ b/crates/app/src/routes/api/v1/channels/messages.rs @@ -0,0 +1,352 @@ +use std::{collections::HashMap, time::Duration}; +use axum::{ + extract::{ + ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}, + Path, Query, + }, + response::IntoResponse, + Extension, Json, +}; +use crate::cookie::CookieJar; +use tetratto_core::{ + cache::{Cache, redis::Commands}, + model::{ + oauth, + auth::User, + channels::Message, + socket::{PacketType, SocketMessage, SocketMethod}, + ApiReturn, Error, + }, + DataManager, +}; +use crate::{ + get_user_from_token, + routes::{api::v1::CreateMessage, pages::PaginatedQuery}, + State, +}; +use serde::Deserialize; +use futures_util::{sink::SinkExt, stream::StreamExt}; + +#[derive(Clone, Deserialize)] +pub struct SocketHeaders { + pub is_channel: bool, +} + +/// Handle a subscription to the websocket. +pub async fn subscription_handler( + jar: CookieJar, + ws: WebSocketUpgrade, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadSockets) { + Some(ua) => ua, + None => return Err(Error::NotAllowed.to_string()), + }; + + let data = data.clone(); + Ok(ws.on_upgrade(|socket| async move { + tokio::spawn(async move { + handle_socket(socket, data, id, user).await; + }); + })) +} + +pub async fn handle_socket(socket: WebSocket, db: DataManager, community_id: String, user: User) { + let (mut sink, mut stream) = socket.split(); + let mut headers: Option = None; + + let channel_id = format!("chats/{community_id}"); + + // handle incoming messages on socket + let dbc = db.clone(); + if let Some(Ok(WsMessage::Text(text))) = stream.next().await { + let data: SocketMessage = match serde_json::from_str(&text.to_string()) { + Ok(t) => t, + Err(_) => { + let _ = sink.close().await; + return; + } + }; + + if data.method != SocketMethod::Headers && headers.is_none() { + // we've sent something else before authenticating... that's not right + let _ = sink.close().await; + return; + } + + match data.method { + SocketMethod::Headers => { + let data: SocketHeaders = data.data(); + + headers = Some(data.clone()); + + if data.is_channel { + // verify permissions for single channel + let channel = match dbc + .get_channel_by_id(match community_id.parse::() { + Ok(c) => c, + Err(_) => { + let _ = sink.close().await; + return; + } + }) + .await + { + Ok(c) => c, + Err(_) => { + let _ = sink.close().await; + return; + } + }; + + let membership = match dbc + .get_membership_by_owner_community(user.id, channel.id) + .await + { + Ok(ua) => ua, + Err(_) => { + let _ = sink.close().await; + return; + } + }; + + if !channel.check_read(user.id, Some(membership.role)) { + let _ = sink.close().await; + return; + } + } + } + _ => { + let _ = sink.close().await; + return; + } + } + } else { + sink.close().await.unwrap(); + return; + } + + // get channel permissions + let headers = headers.unwrap(); + + let mut channel_read_statuses: HashMap = HashMap::new(); + if !headers.is_channel { + // check permissions for every channel in community + let community_id = match community_id.parse::() { + Ok(c) => c, + Err(_) => return, + }; + + let membership = match dbc + .get_membership_by_owner_community(user.id, community_id) + .await + { + Ok(ua) => ua, + Err(_) => { + return; + } + }; + + for channel in dbc.get_channels_by_community(community_id).await.unwrap() { + channel_read_statuses.insert( + channel.id, + channel.check_read(user.id, Some(membership.role)), + ); + } + } + + // ... + let mut recv_task = tokio::spawn(async move { + while let Some(Ok(WsMessage::Text(text))) = stream.next().await { + if text != "Close" { + continue; + } + + // yes, this is an "unclean" disconnection from the socket... + // i don't care, it works + drop(stream); + break; + } + }); + + let dbc = db.clone(); + let channel_id_c = channel_id.clone(); + let mut redis_task = tokio::spawn(async move { + // forward messages from redis to the socket + let mut pubsub = dbc.0.1.client.get_async_pubsub().await.unwrap(); + + pubsub.subscribe(user.id).await.unwrap(); + pubsub.subscribe(channel_id_c).await.unwrap(); + + // listen for pubsub messages + let mut pubsub = pubsub.into_on_message(); + while let Some(msg) = pubsub.next().await { + // payload is a stringified SocketMessage + let smsg = msg.get_payload::().unwrap(); + let packet: SocketMessage = serde_json::from_str(&smsg).unwrap(); + + if packet.method == SocketMethod::Forward(PacketType::Ping) { + // forward with custom message + if sink.send(WsMessage::Text("Ping".into())).await.is_err() { + drop(sink); + break; + } + } else if packet.method == SocketMethod::Message { + // check perms and then forward + let d: (String, Message) = packet.data(); + + if let Some(cs) = channel_read_statuses.get(&d.1.channel) { + if !cs { + continue; + } + } else if !headers.is_channel { + // since we didn't select by just a channel, there HAS to be + // an entry for the channel for us to check this message + continue; + // we don't need to check messages when we're subscribed to + // a channel, since that is checked on headers submission when + // we subscribe to a channel + } + + if sink.send(WsMessage::Text(smsg.into())).await.is_err() { + drop(sink); + break; + } + } else { + // forward to client + if sink.send(WsMessage::Text(smsg.into())).await.is_err() { + drop(sink); + break; + } + } + } + }); + + let db2c = db.0.1.clone(); + let heartbeat_task = tokio::spawn(async move { + let mut con = db2c.get_con().await; + let mut heartbeat = tokio::time::interval(Duration::from_secs(10)); + + loop { + con.publish::( + user.id, + serde_json::to_string(&SocketMessage { + method: SocketMethod::Forward(PacketType::Ping), + data: "Ping".to_string(), + }) + .unwrap(), + ) + .unwrap(); + + heartbeat.tick().await; + } + }); + + db.0.1 + .incr("atto.active_connections:chats".to_string()) + .await; + + tokio::select! { + _ = (&mut recv_task) => redis_task.abort(), + _ = (&mut redis_task) => recv_task.abort() + } + + heartbeat_task.abort(); // kill + db.0.1 + .decr("atto.active_connections:chats".to_string()) + .await; + tracing::info!("socket terminate"); +} + +pub async fn create_request( + jar: CookieJar, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateMessages) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data + .create_message(Message::new( + match req.channel.parse::() { + Ok(c) => c, + Err(e) => return Json(Error::MiscError(e.to_string()).into()), + }, + user.id, + req.content, + )) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Message created".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn delete_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserDeleteMessages) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.delete_message(id, user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Message deleted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn from_channel_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Query(props): Query, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateMessages) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let channel = match data.get_channel_by_id(id).await { + Ok(c) => c, + Err(e) => return Json(e.into()), + }; + + let membership = match data + .get_membership_by_owner_community(user.id, channel.community) + .await + { + Ok(m) => m, + Err(e) => return Json(e.into()), + }; + + if !channel.check_read(user.id, Some(membership.role)) { + return Json(Error::NotAllowed.into()); + } + + match data.get_messages_by_channel(id, 24, props.page).await { + Ok(m) => Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(m), + }), + 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 new file mode 100644 index 0000000..33792c3 --- /dev/null +++ b/crates/app/src/routes/api/v1/channels/mod.rs @@ -0,0 +1,3 @@ +pub mod channels; +pub mod message_reactions; +pub mod messages; diff --git a/crates/app/src/routes/api/v1/communities/posts.rs b/crates/app/src/routes/api/v1/communities/posts.rs index 43e2fb3..e22a7ff 100644 --- a/crates/app/src/routes/api/v1/communities/posts.rs +++ b/crates/app/src/routes/api/v1/communities/posts.rs @@ -241,7 +241,7 @@ pub async fn create_repost_request( jar: CookieJar, Extension(data): Extension, Path(id): Path, - JsonMultipart(images, req): JsonMultipart, + Json(req): Json, ) -> impl IntoResponse { let data = &(data.read().await).0; let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreatePosts) { @@ -264,69 +264,13 @@ pub async fn create_repost_request( Err(e) => return Json(Error::MiscError(e.to_string()).into()), }; - // check sizes - for img in &images { - if img.len() > MAXIMUM_FILE_SIZE { - return Json(Error::FileTooLarge.into()); - } - } - - // create uploads - for _ in 0..images.len() { - props.uploads.push( - match data - .2 - .create_upload(MediaUpload::new( - MediaType::Webp, - props.owner, - "post_media".to_string(), - )) - .await - { - Ok(u) => u.id, - Err(e) => return Json(Error::MiscError(e.to_string()).into()), - }, - ); - } - // ... - let uploads = props.uploads.clone(); match data.create_post(props).await { - Ok(id) => { - // write to uploads - for (i, upload_id) in uploads.iter().enumerate() { - let image = match images.get(i) { - Some(img) => img, - None => { - if let Err(e) = data.2.delete_upload(*upload_id).await { - return Json(Error::MiscError(e.to_string()).into()); - } - - continue; - } - }; - - let upload = match data.2.get_upload_by_id(*upload_id).await { - Ok(u) => u, - Err(e) => return Json(Error::MiscError(e.to_string()).into()), - }; - - if let Err(e) = save_webp_buffer( - &upload.path(&data.2.0.0.directory).to_string(), - image.to_vec(), - None, - ) { - return Json(Error::MiscError(e.to_string()).into()); - } - } - - // ... - Json(ApiReturn { - ok: true, - message: "Post reposted".to_string(), - payload: Some(id.to_string()), - }) - } + Ok(id) => Json(ApiReturn { + ok: true, + message: "Post reposted".to_string(), + payload: Some(id.to_string()), + }), 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 b4e0e4c..03593c7 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -2,6 +2,7 @@ pub mod ads; pub mod app_data; pub mod apps; pub mod auth; +pub mod channels; pub mod communities; pub mod domains; pub mod journals; @@ -54,6 +55,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}", @@ -572,6 +586,57 @@ pub fn routes() -> Router { "/service_hooks/stripe/checkout/success", get(auth::connections::stripe::handle_stupid_fucking_checkout_success_session), ) + // channels + .route("/channels", post(channels::channels::create_request)) + .route( + "/channels/group", + post(channels::channels::create_group_request), + ) + .route( + "/channels/{id}/title", + post(channels::channels::update_title_request), + ) + .route( + "/channels/{id}/move", + post(channels::channels::update_position_request), + ) + .route("/channels/{id}", delete(channels::channels::delete_request)) + .route( + "/channels/{id}/add", + post(channels::channels::add_member_request), + ) + .route( + "/channels/{id}/kick", + post(channels::channels::kick_member_request), + ) + .route( + "/channels/{id}/mute", + post(channels::channels::mute_channel_request), + ) + .route( + "/channels/{id}/mute", + delete(channels::channels::unmute_channel_request), + ) + .route("/channels/{id}", get(channels::channels::get_request)) + .route( + "/channels/community/{id}", + get(channels::channels::get_community_channels_request), + ) + .route( + "/channels/dms", + get(channels::channels::get_dm_channels_request), + ) + // messages + .route( + "/_connect/{id}", + any(channels::messages::subscription_handler), + ) + .route("/messages", post(channels::messages::create_request)) + .route("/messages/{id}", delete(channels::messages::delete_request)) + .route( + "/messages/from_channel/{id}", + get(channels::messages::from_channel_request), + ) // emojis .route( "/lookup_emoji", @@ -947,6 +1012,39 @@ pub struct CreateQuestion { pub asking_about: String, } +#[derive(Deserialize)] +pub struct CreateChannel { + pub title: String, + pub community: String, +} + +#[derive(Deserialize)] +pub struct CreateGroupChannel { + pub title: String, + pub members: Vec, +} + +#[derive(Deserialize)] +pub struct UpdateChannelTitle { + pub title: String, +} + +#[derive(Deserialize)] +pub struct UpdateChannelPosition { + pub position: i32, +} + +#[derive(Deserialize)] +pub struct CreateMessage { + pub content: String, + pub channel: String, +} + +#[derive(Deserialize)] +pub struct KickMember { + pub member: String, +} + #[derive(Deserialize)] pub struct CreateStack { pub name: String, @@ -1087,6 +1185,12 @@ pub struct RenderMarkdown { pub content: String, } +#[derive(Deserialize)] +pub struct CreateMessageReaction { + pub message: String, + pub emoji: String, +} + #[derive(Deserialize)] pub struct UpdateNoteDir { pub dir: String, diff --git a/crates/app/src/routes/pages/chats.rs b/crates/app/src/routes/pages/chats.rs new file mode 100644 index 0000000..4134888 --- /dev/null +++ b/crates/app/src/routes/pages/chats.rs @@ -0,0 +1,380 @@ +use super::{render_error, ChatsAppQuery, PaginatedQuery}; +use crate::{State, assets::initial_context, get_lang, get_user_from_token}; +use axum::{ + extract::{Path, Query}, + response::{Html, IntoResponse, Redirect}, + Extension, Json, +}; +use crate::cookie::CookieJar; +use tetratto_core::model::{ + channels::Message, communities_permissions::CommunityPermission, permissions::FinePermission, + Error, +}; +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct RenderMessage { + pub data: String, + pub grouped: bool, +} + +pub async fn redirect_request() -> impl IntoResponse { + Redirect::to("/chats/0/0") +} + +/// `/chats/{community}/{channel}` +/// +/// `/chats/0` is for channels the user is part of (not in a community) +pub async fn app_request( + jar: CookieJar, + Extension(data): Extension, + Path((selected_community, selected_channel)): Path<(usize, usize)>, + Query(props): Query, +) -> 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 membership = match data + .0 + .get_membership_by_owner_community(user.id, selected_community) + .await + { + Ok(m) => m, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + let can_manage_channels = membership.role.check(CommunityPermission::MANAGE_CHANNELS) + | user.permissions.check(FinePermission::MANAGE_CHANNELS); + + let communities = match data.0.get_memberships_by_owner(user.id).await { + Ok(p) => match data.0.fill_communities(p).await { + Ok(p) => p, + 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)), + }; + + if selected_community != 0 && selected_channel == 0 { + let channels = match data.0.get_channels_by_community(selected_community).await { + Ok(p) => p, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + if let Some(channel) = channels.first() { + return Ok(Html(format!( + "", + selected_community, channel.id, props.nav + ))); + } + } + + let community = if selected_community != 0 { + match data.0.get_community_by_id(selected_community).await { + Ok(p) => Some(p), + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + } + } else { + None + }; + + let channel = if selected_channel != 0 { + match data.0.get_channel_by_id(selected_channel).await { + Ok(p) => { + if !p.check_read(user.id, Some(membership.role)) { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &Some(user)).await, + )); + } + + 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.clone())).await; + + context.insert("selected_community", &selected_community); + context.insert("selected_channel", &selected_channel); + context.insert("membership_role", &membership.role.bits()); + context.insert("page", &props.page); + context.insert("message", &props.message); + + context.insert( + "can_manage_channels", + &if selected_community == 0 { + false + } else { + can_manage_channels + }, + ); + + context.insert( + "can_manage_channel", + &if selected_community == 0 { + if let Some(ref channel) = channel { + channel.members.contains(&user.id) | (channel.owner == user.id) + } else { + false + } + } else { + can_manage_channels + }, + ); + + context.insert("community", &community); + context.insert("channel", &channel); + context.insert("communities", &communities); + + // return + Ok(Html(data.1.render("chats/app.html", &context).unwrap())) +} + +/// `/chats/{community}/{channel}/_stream` +pub async fn stream_request( + jar: CookieJar, + Extension(data): Extension, + Path((community, channel)): Path<(usize, usize)>, + Query(props): Query, +) -> 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 ignore_users = crate::ignore_users_gen!(user!, data); + + let channel = match data.0.get_channel_by_id(channel).await { + Ok(c) => c, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + let membership = match data + .0 + .get_membership_by_owner_community(user.id, community) + .await + { + Ok(m) => m, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + if !channel.check_read(user.id, Some(membership.role)) { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &Some(user)).await, + )); + } + + let can_manage_messages = membership.role.check(CommunityPermission::MANAGE_MESSAGES) + | user.permissions.check(FinePermission::MANAGE_MESSAGES); + + let messages = if props.message == 0 { + match data + .0 + .get_messages_by_channel(channel.id, 24, props.page) + .await + { + Ok(p) => match data.0.fill_messages(p, &ignore_users).await { + Ok(p) => p, + 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 { + Vec::new() + }; + + let message = if props.message == 0 { + None + } else { + Some(match data.0.get_message_by_id(props.message).await { + Ok(p) => p, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }) + }; + + let message_owner = if let Some(ref message) = message { + Some(match data.0.get_user_by_id(message.owner).await { + Ok(p) => 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("messages", &messages); + context.insert("message", &message); + context.insert("message_owner", &message_owner); + context.insert("can_manage_messages", &can_manage_messages); + + context.insert("page", &props.page); + context.insert("community", &community); + context.insert("channel", &channel); + + // return + Ok(Html(data.1.render("chats/stream.html", &context).unwrap())) +} + +/// `/chats/{community}/{channel}/_render` +pub async fn message_request( + jar: CookieJar, + Extension(data): Extension, + Path((community, channel)): Path<(usize, usize)>, + Json(req): Json, +) -> 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 message: (String, Message) = match serde_json::from_str(&req.data) { + Ok(m) => m, + Err(e) => { + return Err(Html( + render_error(Error::MiscError(e.to_string()), &jar, &data, &Some(user)).await, + )); + } + }; + + let message = message.1; + + let membership = match data + .0 + .get_membership_by_owner_community(user.id, community) + .await + { + Ok(m) => m, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + let can_manage_messages = membership.role.check(CommunityPermission::MANAGE_MESSAGES) + | user.permissions.check(FinePermission::MANAGE_MESSAGES); + + let owner = match data.0.get_user_by_id(message.owner).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("can_manage_messages", &can_manage_messages); + context.insert("message", &message); + context.insert("user", &owner); + + context.insert("channel", &channel); + context.insert("community", &community); + context.insert("grouped", &req.grouped); + + // return + Ok(Html(data.1.render("chats/message.html", &context).unwrap())) +} + +/// `/chats/{community}/{channel/_channels` +pub async fn channels_request( + jar: CookieJar, + Extension(data): Extension, + Path((community, channel_id)): Path<(usize, usize)>, + Query(props): Query, +) -> 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 channels = if community == 0 { + match data.0.get_channels_by_user(user.id).await { + Ok(p) => p, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + } + } else { + match data.0.get_channels_by_community(community).await { + Ok(p) => p, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + } + }; + + let channel = if channel_id != 0 { + Some(match data.0.get_channel_by_id(channel_id).await { + Ok(p) => p, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }) + } else { + None + }; + + let members = if community == 0 && channel.is_some() { + let ignore_users = crate::ignore_users_gen!(user!, data); + + let mut channel = channel.as_ref().unwrap().clone(); + channel.members.insert(0, channel.owner); // include the owner in the members list (at the start) + + Some( + match data.0.fill_members(&channel.members, ignore_users).await { + Ok(p) => p, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }, + ) + } else { + None + }; + + let membership = match data + .0 + .get_membership_by_owner_community(user.id, community) + .await + { + Ok(m) => m, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + let can_manage_channels = membership.role.check(CommunityPermission::MANAGE_CHANNELS) + | user.permissions.check(FinePermission::MANAGE_CHANNELS); + + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; + + context.insert("channels", &channels); + context.insert("page", &props.page); + + context.insert("can_manage_channels", &can_manage_channels); + context.insert("members", &members); + context.insert("channel", &channel); + + context.insert("selected_community", &community); + context.insert("selected_channel", &channel_id); + + // return + Ok(Html( + data.1.render("chats/channels.html", &context).unwrap(), + )) +} diff --git a/crates/app/src/routes/pages/communities.rs b/crates/app/src/routes/pages/communities.rs index 8e309d2..1e2ce88 100644 --- a/crates/app/src/routes/pages/communities.rs +++ b/crates/app/src/routes/pages/communities.rs @@ -793,6 +793,14 @@ pub async fn settings_request( )); } + let channels = match data.0.get_channels_by_community(community.id).await { + Ok(p) => p, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + let can_manage_channels = membership.role.check(CommunityPermission::MANAGE_CHANNELS) + | user.permissions.check(FinePermission::MANAGE_CHANNELS); + let emojis = match data.0.get_emojis_by_community(community.id).await { Ok(p) => p, Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), @@ -806,6 +814,10 @@ pub async fn settings_request( let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; context.insert("community", &community); + + context.insert("can_manage_channels", &can_manage_channels); + context.insert("channels", &channels); + context.insert("can_manage_emojis", &can_manage_emojis); context.insert("emojis", &emojis); diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index 80e4bb7..8ab6da5 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -1,4 +1,5 @@ pub mod auth; +pub mod chats; pub mod communities; pub mod developer; pub mod economy; @@ -11,7 +12,10 @@ pub mod mod_panel; pub mod profile; pub mod stacks; -use axum::{routing::get, Router}; +use axum::{ + routing::{get, post}, + Router, +}; use crate::{cookie::CookieJar, routes::pages::misc::TimelineOrderMode}; use serde::Deserialize; use tetratto_core::model::{Error, auth::User}; @@ -111,6 +115,21 @@ pub fn routes() -> Router { .route("/post/{id}/reposts", get(communities::reposts_request)) .route("/post/{id}/likes", get(communities::likes_request)) .route("/question/{id}", get(communities::question_request)) + // chats + .route("/chats", get(chats::redirect_request)) + .route("/chats/{community}/{channel}", get(chats::app_request)) + .route( + "/chats/{community}/{channel}/_stream", + get(chats::stream_request), + ) + .route( + "/chats/{community}/{channel}/_render", + post(chats::message_request), + ) + .route( + "/chats/{community}/{channel}/_channels", + get(chats::channels_request), + ) // forge .route("/forges", get(forge::home_request)) .route("/forge/{title}", get(forge::info_request)) @@ -181,6 +200,16 @@ pub struct PaginatedQuery { pub before: usize, } +#[derive(Deserialize)] +pub struct ChatsAppQuery { + #[serde(default)] + pub page: usize, + #[serde(default)] + pub nav: bool, + #[serde(default)] + pub message: usize, +} + #[derive(Deserialize)] pub struct ProfileQuery { #[serde(default)] diff --git a/crates/app/src/routes/pages/mod_panel.rs b/crates/app/src/routes/pages/mod_panel.rs index d232325..2b82cf1 100644 --- a/crates/app/src/routes/pages/mod_panel.rs +++ b/crates/app/src/routes/pages/mod_panel.rs @@ -286,6 +286,18 @@ pub async fn stats_request(jar: CookieJar, Extension(data): Extension) -> let lang = get_lang!(jar, data.0); let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; + context.insert( + "active_users_chats", + &data + .0 + .0 + .1 + .get("atto.active_connections:chats".to_string()) + .await + .unwrap_or("0".to_string()) + .parse::() + .unwrap(), + ); context.insert( "active_users", &data diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index a001303..5a4dcdf 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 = "16.0.3" +version = "16.0.0" edition = "2024" readme = "../../README.md" authors.workspace = true @@ -50,4 +50,3 @@ oiseau = { version = "0.1.2", default-features = false, features = [ paste = { version = "1.0.15", optional = true } tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] } buckets-core = "1.0.4" -serde_valid = "1.0.5" diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 0e3af22..5c852a3 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -264,9 +264,6 @@ pub struct ServiceHostsConfig { /// Littleweb browser host. #[serde(default)] pub littleweb: String, - /// Tawny host . - #[serde(default)] - pub tawny: String, } impl Default for ServiceHostsConfig { @@ -274,7 +271,6 @@ impl Default for ServiceHostsConfig { Self { buckets: String::new(), littleweb: String::new(), - tawny: String::new(), } } } diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index be378ef..4b277be 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -123,14 +123,14 @@ impl DataManager { was_purchased: get!(x->25(i32)) as i8 == 1, browser_session: get!(x->26(String)), ban_reason: get!(x->27(String)), - is_deactivated: get!(x->28(i32)) as i8 == 1, - ban_expire: get!(x->29(i64)) as usize, - coins: get!(x->30(i32)), - checkouts: serde_json::from_str(&get!(x->31(String)).to_string()).unwrap(), - applied_configurations: serde_json::from_str(&get!(x->32(String)).to_string()).unwrap(), - last_policy_consent: get!(x->33(i64)) as usize, - close_friends_stack: get!(x->34(i64)) as usize, - missed_messages_count: get!(x->35(i32)) as usize, + channel_mutes: serde_json::from_str(&get!(x->28(String)).to_string()).unwrap(), + is_deactivated: get!(x->29(i32)) as i8 == 1, + ban_expire: get!(x->30(i64)) as usize, + coins: get!(x->31(i32)), + checkouts: serde_json::from_str(&get!(x->32(String)).to_string()).unwrap(), + applied_configurations: serde_json::from_str(&get!(x->33(String)).to_string()).unwrap(), + last_policy_consent: get!(x->34(i64)) as usize, + close_friends_stack: get!(x->35(i64)) as usize, } } @@ -287,7 +287,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, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35)", + "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, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36)", params![ &(data.id as i64), &(data.created as i64), @@ -317,6 +317,7 @@ impl DataManager { &if data.was_purchased { 1_i32 } else { 0_i32 }, &data.browser_session, &data.ban_reason, + &serde_json::to_string(&data.channel_mutes).unwrap(), &if data.is_deactivated { 1_i32 } else { 0_i32 }, &(data.ban_expire as i64), &(data.coins as i32), @@ -708,17 +709,15 @@ impl DataManager { self.cache_clear_user(&other_user).await; - // create audit log entry (if we aren't the user that is being updated) - if user.id != other_user.id { - self.create_audit_log_entry(AuditLogEntry::new( - user.id, - format!( - "invoked `update_user_is_deactivated` with x value `{}` and y value `{}`", - other_user.id, x - ), - )) - .await?; - } + // create audit log entry + self.create_audit_log_entry(AuditLogEntry::new( + user.id, + format!( + "invoked `update_user_is_deactivated` with x value `{}` and y value `{}`", + other_user.id, x + ), + )) + .await?; // ... Ok(()) @@ -1164,8 +1163,4 @@ impl DataManager { 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); - - auto_method!(update_user_missed_messages_count(i32)@get_user_by_id -> "UPDATE users SET missed_messages_count = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); - auto_method!(incr_user_missed_messages()@get_user_by_id -> "UPDATE users SET missed_messages_count = missed_messages_count + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_user --incr); - auto_method!(decr_user_missed_messages()@get_user_by_id -> "UPDATE users SET missed_messages_count = missed_messages_count - 1 WHERE id = $1" --cache-key-tmpl=cache_clear_user --decr=notification_count); } diff --git a/crates/core/src/database/channels.rs b/crates/core/src/database/channels.rs new file mode 100644 index 0000000..c1e9938 --- /dev/null +++ b/crates/core/src/database/channels.rs @@ -0,0 +1,325 @@ +use oiseau::cache::Cache; +use crate::model::moderation::AuditLogEntry; +use crate::model::{ + Error, Result, auth::User, permissions::FinePermission, + communities_permissions::CommunityPermission, channels::Channel, +}; +use crate::{auto_method, DataManager}; +use oiseau::{PostgresRow, execute, get, query_row, query_rows, params}; + +impl DataManager { + /// Get a [`Channel`] from an SQL row. + pub(crate) fn get_channel_from_row(x: &PostgresRow) -> Channel { + Channel { + id: get!(x->0(i64)) as usize, + community: get!(x->1(i64)) as usize, + owner: get!(x->2(i64)) as usize, + created: get!(x->3(i64)) as usize, + minimum_role_read: get!(x->4(i32)) as u32, + minimum_role_write: get!(x->5(i32)) as u32, + 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, + } + } + + auto_method!(get_channel_by_id(usize as i64)@get_channel_from_row -> "SELECT * FROM channels WHERE id = $1" --name="channel" --returns=Channel --cache-key-tmpl="atto.channel:{}"); + + /// Get all member profiles from a channel members list. + pub async fn fill_members( + &self, + members: &Vec, + ignore_users: Vec, + ) -> Result> { + let mut out = Vec::new(); + + for member in members { + if ignore_users.contains(member) { + continue; + } + + out.push(self.get_user_by_id(member.to_owned()).await?); + } + + Ok(out) + } + + /// Get all channels by community. + /// + /// # Arguments + /// * `community` - the ID of the community to fetch channels for + pub async fn get_channels_by_community(&self, community: 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 channels WHERE community = $1 ORDER BY position ASC", + &[&(community as i64)], + |x| { Self::get_channel_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("channel".to_string())); + } + + Ok(res.unwrap()) + } + + /// Get all channels by user. + /// + /// # Arguments + /// * `user` - the ID of the user to fetch channels for + pub async fn get_channels_by_user(&self, user: 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 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) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("channel".to_string())); + } + + Ok(res.unwrap()) + } + + /// Get a channel given its `owner` and a member. + /// + /// # Arguments + /// * `owner` - the ID of the owner + /// * `member` - the ID of the member + pub async fn get_channel_by_owner_member( + &self, + owner: usize, + member: usize, + ) -> Result { + 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 channels WHERE owner = $1 AND members = $2 AND community = 0 ORDER BY created DESC", + params![&(owner as i64), &format!("[{member}]")], + |x| { Ok(Self::get_channel_from_row(x)) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("channel".to_string())); + } + + Ok(res.unwrap()) + } + + /// Create a new channel in the database. + /// + /// # Arguments + /// * `data` - a mock [`Channel`] object to insert + pub async fn create_channel(&self, data: Channel) -> Result<()> { + let user = self.get_user_by_id(data.owner).await?; + + // check user permission in community + if data.community != 0 { + let membership = self + .get_membership_by_owner_community(user.id, data.community) + .await?; + + if !membership.role.check(CommunityPermission::MANAGE_CHANNELS) + && !user.permissions.check(FinePermission::MANAGE_CHANNELS) + { + return Err(Error::NotAllowed); + } + } + // check members + else { + for member in &data.members { + if self + .get_userblock_by_initiator_receiver(member.to_owned(), data.owner) + .await + .is_ok() + { + 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, + "INSERT INTO channels VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", + params![ + &(data.id as i64), + &(data.community as i64), + &(data.owner as i64), + &(data.created as i64), + &(data.minimum_role_read as i32), + &(data.minimum_role_write as i32), + &(data.position as i32), + &serde_json::to_string(&data.members).unwrap(), + &data.title, + &(data.last_message as i64) + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(()) + } + + pub async fn delete_channel(&self, id: usize, user: &User) -> Result<()> { + let channel = self.get_channel_by_id(id).await?; + + // check user permission in community + if user.id != channel.owner { + let membership = self + .get_membership_by_owner_community(user.id, channel.community) + .await?; + + if !membership.role.check(CommunityPermission::MANAGE_CHANNELS) { + 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 channels WHERE id = $1", &[&(id as i64)]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // delete messages + let res = execute!( + &conn, + "DELETE FROM messages WHERE channel = $1", + &[&(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // ... + self.0.1.remove(format!("atto.channel:{}", id)).await; + Ok(()) + } + + pub async fn add_channel_member(&self, id: usize, user: User, member: String) -> Result<()> { + let mut y = self.get_channel_by_id(id).await?; + + if user.id != y.owner && member != user.username { + if !user.permissions.check(FinePermission::MANAGE_CHANNELS) { + return Err(Error::NotAllowed); + } else { + self.create_audit_log_entry(AuditLogEntry::new( + user.id, + format!("invoked `add_channel_member` with x value `{member}`"), + )) + .await? + } + } + + // check permissions + let member = self.get_user_by_username(&member).await?; + + if self + .get_userblock_by_initiator_receiver(member.id, user.id) + .await + .is_ok() + { + return Err(Error::NotAllowed); + } + + // ... + y.members.push(member.id); + + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "UPDATE channels SET members = $1 WHERE id = $2", + params![&serde_json::to_string(&y.members).unwrap(), &(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.0.1.remove(format!("atto.channel:{}", id)).await; + + Ok(()) + } + + pub async fn remove_channel_member(&self, id: usize, user: User, member: usize) -> Result<()> { + let mut y = self.get_channel_by_id(id).await?; + + if user.id != y.owner && member != user.id { + if !user.permissions.check(FinePermission::MANAGE_CHANNELS) { + return Err(Error::NotAllowed); + } else { + self.create_audit_log_entry(AuditLogEntry::new( + user.id, + format!("invoked `remove_channel_member` with x value `{member}`"), + )) + .await? + } + } + + y.members + .remove(match y.members.iter().position(|x| *x == member) { + Some(i) => i, + None => return Err(Error::GeneralNotFound("member".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 channels SET members = $1 WHERE id = $2", + params![&serde_json::to_string(&y.members).unwrap(), &(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.0.1.remove(format!("atto.channel:{}", id)).await; + + Ok(()) + } + + auto_method!(update_channel_title(&str)@get_channel_by_id:FinePermission::MANAGE_CHANNELS; -> "UPDATE channels SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}"); + auto_method!(update_channel_position(i32)@get_channel_by_id:FinePermission::MANAGE_CHANNELS; -> "UPDATE channels SET position = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}"); + auto_method!(update_channel_minimum_role_read(i32)@get_channel_by_id:FinePermission::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:FinePermission::MANAGE_CHANNELS; -> "UPDATE channels SET minimum_role_write = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}"); + auto_method!(update_channel_members(Vec)@get_channel_by_id:FinePermission::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 d778076..8df91ae 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -26,6 +26,8 @@ impl DataManager { execute!(&conn, common::CREATE_TABLE_REQUESTS).unwrap(); execute!(&conn, common::CREATE_TABLE_QUESTIONS).unwrap(); execute!(&conn, common::CREATE_TABLE_IPBLOCKS).unwrap(); + execute!(&conn, common::CREATE_TABLE_CHANNELS).unwrap(); + execute!(&conn, common::CREATE_TABLE_MESSAGES).unwrap(); execute!(&conn, common::CREATE_TABLE_EMOJIS).unwrap(); execute!(&conn, common::CREATE_TABLE_STACKS).unwrap(); execute!(&conn, common::CREATE_TABLE_DRAFTS).unwrap(); @@ -35,6 +37,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(); execute!(&conn, common::CREATE_TABLE_INVITE_CODES).unwrap(); execute!(&conn, common::CREATE_TABLE_DOMAINS).unwrap(); execute!(&conn, common::CREATE_TABLE_SERVICES).unwrap(); @@ -52,6 +55,10 @@ impl DataManager { .1 .set("atto.active_connections:users".to_string(), "0".to_string()) .await; + self.0 + .1 + .set("atto.active_connections:chats".to_string(), "0".to_string()) + .await; self.2.init().await.expect("failed to init buckets manager"); Ok(()) diff --git a/crates/core/src/database/communities.rs b/crates/core/src/database/communities.rs index cd7a6ab..1290bdd 100644 --- a/crates/core/src/database/communities.rs +++ b/crates/core/src/database/communities.rs @@ -375,6 +375,11 @@ impl DataManager { return Err(Error::DatabaseError(e.to_string())); } + // remove channels + for channel in self.get_channels_by_community(id).await? { + self.delete_channel(channel.id, &user).await?; + } + // remove images let avatar = PathBufD::current().extend(&[ self.0.0.dirs.media.as_str(), diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs index 6cff2ef..6a16c2d 100644 --- a/crates/core/src/database/drivers/common.rs +++ b/crates/core/src/database/drivers/common.rs @@ -14,6 +14,8 @@ pub const CREATE_TABLE_USER_WARNINGS: &str = include_str!("./sql/create_user_war pub const CREATE_TABLE_REQUESTS: &str = include_str!("./sql/create_requests.sql"); pub const CREATE_TABLE_QUESTIONS: &str = include_str!("./sql/create_questions.sql"); pub const CREATE_TABLE_IPBLOCKS: &str = include_str!("./sql/create_ipblocks.sql"); +pub const CREATE_TABLE_CHANNELS: &str = include_str!("./sql/create_channels.sql"); +pub const CREATE_TABLE_MESSAGES: &str = include_str!("./sql/create_messages.sql"); pub const CREATE_TABLE_EMOJIS: &str = include_str!("./sql/create_emojis.sql"); pub const CREATE_TABLE_STACKS: &str = include_str!("./sql/create_stacks.sql"); pub const CREATE_TABLE_DRAFTS: &str = include_str!("./sql/create_drafts.sql"); @@ -23,6 +25,7 @@ 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"); pub const CREATE_TABLE_INVITE_CODES: &str = include_str!("./sql/create_invite_codes.sql"); pub const CREATE_TABLE_DOMAINS: &str = include_str!("./sql/create_domains.sql"); pub const CREATE_TABLE_SERVICES: &str = include_str!("./sql/create_services.sql"); diff --git a/crates/core/src/database/drivers/sql/create_channels.sql b/crates/core/src/database/drivers/sql/create_channels.sql new file mode 100644 index 0000000..83f7ff6 --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_channels.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS channels ( + id BIGINT NOT NULL PRIMARY KEY, + community BIGINT NOT NULL, + owner BIGINT NOT NULL, + created BIGINT NOT NULL, + minimum_role_read INT NOT NULL, + minimum_role_write INT NOT NULL, + position INT NOT NULL, + members TEXT NOT NULL, + title TEXT NOT NULL, + last_message BIGINT NOT NULL +) 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 new file mode 100644 index 0000000..235d8dc --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_messages.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS messages ( + id BIGINT NOT NULL PRIMARY KEY, + channel BIGINT NOT NULL, + owner BIGINT NOT NULL, + created BIGINT NOT NULL, + edited BIGINT NOT NULL, + content TEXT NOT NULL, + context TEXT NOT NULL, + reactions TEXT NOT NULL +) diff --git a/crates/core/src/database/drivers/sql/create_users.sql b/crates/core/src/database/drivers/sql/create_users.sql index 8d6aad3..11b97f8 100644 --- a/crates/core/src/database/drivers/sql/create_users.sql +++ b/crates/core/src/database/drivers/sql/create_users.sql @@ -27,12 +27,12 @@ CREATE TABLE IF NOT EXISTS users ( was_purchased INT NOT NULL, browser_session TEXT NOT NULL, ban_reason TEXT NOT NULL, + channel_mutes TEXT NOT NULL, is_deactivated INT NOT NULL, ban_expire BIGINT NOT NULL, coins INT NOT NULL, checkouts TEXT NOT NULL, applied_configurations TEXT NOT NULL, last_policy_consent BIGINT NOT NULL, - close_friends_stack BIGINT NOT NULL, - missed_messages_count INT NOT NULL + close_friends_stack 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 8f5524b..e9e731f 100644 --- a/crates/core/src/database/drivers/sql/version_migrations.sql +++ b/crates/core/src/database/drivers/sql/version_migrations.sql @@ -85,11 +85,3 @@ ADD COLUMN IF NOT EXISTS close_friends_stack BIGINT DEFAULT 0; -- stacks is_locked ALTER TABLE stacks ADD COLUMN IF NOT EXISTS is_locked INT DEFAULT 0; - --- users missed_messages_count -ALTER TABLE users -ADD COLUMN IF NOT EXISTS missed_messages_count INT DEFAULT 0; - --- users channel_mutes -ALTER TABLE users -DROP COLUMN IF EXISTS channel_mutes; 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> { + 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 { + 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 new file mode 100644 index 0000000..6b7c037 --- /dev/null +++ b/crates/core/src/database/messages.rs @@ -0,0 +1,380 @@ +use std::collections::HashMap; +use oiseau::cache::Cache; +use crate::model::auth::Notification; +use crate::model::moderation::AuditLogEntry; +use crate::model::socket::{SocketMessage, SocketMethod}; +use crate::model::{ + Error, Result, auth::User, permissions::FinePermission, + communities_permissions::CommunityPermission, channels::Message, +}; +use serde::Serialize; +use tetratto_shared::unix_epoch_timestamp; +use crate::{auto_method, DataManager}; + +use oiseau::{PostgresRow, cache::redis::Commands}; + +use oiseau::{execute, get, query_rows, params}; + +#[derive(Serialize)] +struct DeleteMessageEvent { + pub id: String, +} + +impl DataManager { + /// Get a [`Message`] from an SQL row. + pub(crate) fn get_message_from_row(x: &PostgresRow) -> Message { + Message { + id: get!(x->0(i64)) as usize, + channel: get!(x->1(i64)) as usize, + owner: get!(x->2(i64)) as usize, + created: get!(x->3(i64)) as usize, + 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(), + } + } + + auto_method!(get_message_by_id(usize as i64)@get_message_from_row -> "SELECT * FROM messages WHERE id = $1" --name="message" --returns=Message --cache-key-tmpl="atto.message:{}"); + + /// Complete a vector of just messages with their owner as well. + /// + /// # Returns + /// `(message, owner, group with previous messages in ui)` + pub async fn fill_messages( + &self, + messages: Vec, + ignore_users: &[usize], + ) -> Result> { + let mut out: Vec<(Message, User, bool)> = Vec::new(); + + let mut users: HashMap = HashMap::new(); + for (i, message) in messages.iter().enumerate() { + let next_owner: usize = match messages.get(i + 1) { + Some(m) => m.owner, + None => 0, + }; + + let owner = message.owner; + + if ignore_users.contains(&owner) { + continue; + } + + if let Some(user) = users.get(&owner) { + out.push((message.to_owned(), user.clone(), next_owner == owner)); + } else { + let user = self.get_user_by_id_with_void(owner).await?; + users.insert(owner, user.clone()); + out.push((message.to_owned(), user, next_owner == owner)); + } + } + + Ok(out) + } + + /// Get all messages by channel (paginated). + /// + /// # Arguments + /// * `channel` - the ID of the community to fetch channels for + /// * `batch` - the limit of items in each page + /// * `page` - the page number + pub async fn get_messages_by_channel( + &self, + channel: usize, + batch: usize, + page: 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 messages WHERE channel = $1 ORDER BY created DESC LIMIT $2 OFFSET $3", + &[&(channel as i64), &(batch as i64), &((page * batch) as i64)], + |x| { Self::get_message_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("message".to_string())); + } + + Ok(res.unwrap()) + } + + /// Create a new message in the database. + /// + /// # Arguments + /// * `data` - a mock [`Message`] object to insert + pub async fn create_message(&self, mut data: Message) -> Result<()> { + if data.content.len() < 2 { + return Err(Error::DataTooLong("content".to_string())); + } + + if data.content.len() > 2048 { + return Err(Error::DataTooLong("content".to_string())); + } + + let owner = self.get_user_by_id(data.owner).await?; + let channel = self.get_channel_by_id(data.channel).await?; + + // check user permission in community + let membership = self + .get_membership_by_owner_community(owner.id, channel.community) + .await?; + + // check user permission to post in channel + if !channel.check_post(owner.id, Some(membership.role)) { + return Err(Error::NotAllowed); + } + + // send mention notifications + let mut already_notified: HashMap = HashMap::new(); + for username in User::parse_mentions(&data.content) { + let user = { + if let Some(ua) = already_notified.get(&username) { + ua.to_owned() + } else { + let user = self.get_user_by_username(&username).await?; + + // check blocked status + if self + .get_userblock_by_initiator_receiver(user.id, data.owner) + .await + .is_ok() + { + return Err(Error::NotAllowed); + } + + // check private status + if user.settings.private_profile { + if self + .get_userfollow_by_initiator_receiver(user.id, data.owner) + .await + .is_err() + { + return Err(Error::NotAllowed); + } + } + + // check if the user can read the channel + let membership = self + .get_membership_by_owner_community(user.id, channel.community) + .await?; + + if !channel.check_read(user.id, Some(membership.role)) { + continue; + } + + // create notif + self.create_notification(Notification::new( + "You've been mentioned in a message!".to_string(), + format!( + "[@{}](/api/v1/auth/user/find/{}) has mentioned you in their [message](/chats/{}/{}?message={}).", + owner.username, owner.id, channel.community, data.channel, data.id + ), + user.id, + )) + .await?; + + // ... + already_notified.insert(username.to_owned(), user.clone()); + user + } + }; + + data.content = data.content.replace( + &format!("@{username}"), + &format!( + "@{username}", + user.id + ), + ); + } + + // send notifs to members (if this message isn't associated with a channel) + if channel.community == 0 { + for member in [channel.members, vec![channel.owner]].concat() { + if member == owner.id { + continue; + } + + let user = self.get_user_by_id(member).await?; + if user.channel_mutes.contains(&channel.id) { + continue; + } + + let mut notif = Notification::new( + "You've received a new message!".to_string(), + format!( + "[@{}](/api/v1/auth/user/find/{}) has sent a [message](/chats/{}/{}?message={}) in [{}](/chats/{}/{}).", + owner.username, + owner.id, + channel.community, + data.channel, + data.id, + channel.title, + channel.community, + data.channel + ), + member, + ); + + notif.tag = format!("chats/{}", channel.id); + self.create_notification(notif).await?; + } + } + + // ... + 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 messages VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + params![ + &(data.id as i64), + &(data.channel as i64), + &(data.owner as i64), + &(data.created as i64), + &(data.edited as i64), + &data.content, + &serde_json::to_string(&data.context).unwrap(), + &serde_json::to_string(&data.reactions).unwrap(), + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // post event + let mut con = self.0.1.get_con().await; + + if let Err(e) = con.publish::( + if channel.community != 0 { + // broadcast to community ws + format!("chats/{}", channel.community) + } else { + // broadcast to channel ws + format!("chats/{}", channel.id) + }, + serde_json::to_string(&SocketMessage { + method: SocketMethod::Message, + data: serde_json::to_string(&(data.channel.to_string(), data)).unwrap(), + }) + .unwrap(), + ) { + return Err(Error::MiscError(e.to_string())); + } + + // update channel position + self.update_channel_last_message(channel.id, unix_epoch_timestamp() as i64) + .await?; + + // ... + Ok(()) + } + + pub async fn delete_message(&self, id: usize, user: User) -> Result<()> { + let message = self.get_message_by_id(id).await?; + let channel = self.get_channel_by_id(message.channel).await?; + + // check user permission in community + if user.id != message.owner { + let membership = self + .get_membership_by_owner_community(user.id, channel.community) + .await?; + + if !membership.role.check(CommunityPermission::MANAGE_MESSAGES) + && !user.permissions.check(FinePermission::MANAGE_MESSAGES) + { + return Err(Error::NotAllowed); + } else if user.permissions.check(FinePermission::MANAGE_MESSAGES) { + self.create_audit_log_entry(AuditLogEntry::new( + user.id, + format!("invoked `delete_message` with x value `{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 messages 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:{}", id)).await; + + // post event + let mut con = self.0.1.get_con().await; + + if let Err(e) = con.publish::( + if channel.community != 0 { + // broadcast to community ws + format!("chats/{}", channel.community) + } else { + // broadcast to channel ws + format!("chats/{}", channel.id) + }, + serde_json::to_string(&SocketMessage { + method: SocketMethod::Delete, + data: serde_json::to_string(&DeleteMessageEvent { id: id.to_string() }).unwrap(), + }) + .unwrap(), + ) { + return Err(Error::MiscError(e.to_string())); + } + + // ... + Ok(()) + } + + pub async fn update_message_content(&self, id: usize, user: User, x: String) -> Result<()> { + let y = self.get_message_by_id(id).await?; + + if user.id != y.owner { + if !user.permissions.check(FinePermission::MANAGE_MESSAGES) { + return Err(Error::NotAllowed); + } else { + self.create_audit_log_entry(AuditLogEntry::new( + user.id, + format!("invoked `update_message_content` with x value `{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, + "UPDATE messages SET content = $1, edited = $2 WHERE id = $2", + params![&x, &(unix_epoch_timestamp() as i64), &(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // return + Ok(()) + } + + auto_method!(update_message_reactions(HashMap) -> "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 b027ea9..1adbf88 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -3,6 +3,7 @@ pub mod app_data; mod apps; mod audit_log; mod auth; +mod channels; mod common; mod communities; pub mod connections; @@ -16,6 +17,8 @@ mod ipblocks; mod journals; mod letters; mod memberships; +mod message_reactions; +mod messages; mod notes; mod notifications; mod polls; diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index fbfa51a..806552b 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -1,6 +1,4 @@ use std::collections::HashMap; -use crate::model::{Error, Result}; - use super::{ oauth::AuthGrant, permissions::{FinePermission, SecondaryPermission}, @@ -12,7 +10,6 @@ use tetratto_shared::{ snow::Snowflake, unix_epoch_timestamp, }; -use serde_valid::Validate; /// `(ip, token, creation timestamp)` pub type Token = (String, String, usize); @@ -85,6 +82,9 @@ pub struct User { /// The reason the user was banned. #[serde(default)] pub ban_reason: String, + /// IDs of channels the user has muted. + #[serde(default)] + pub channel_mutes: Vec, /// If the user is deactivated. Deactivated users act almost like deleted /// users, but their data is not wiped. #[serde(default)] @@ -113,9 +113,6 @@ pub struct User { /// (the user) to post to it. #[serde(default)] pub close_friends_stack: usize, - /// The number of messages this user has missed. - #[serde(default)] - pub missed_messages_count: usize, } pub type UserConnections = @@ -190,16 +187,13 @@ impl Default for DefaultProfileTabChoice { } } -#[derive(Clone, Debug, Serialize, Deserialize, Default, Validate)] +#[derive(Clone, Debug, Serialize, Deserialize, Default)] pub struct UserSettings { #[serde(default)] - #[validate(max_length = 32)] pub display_name: String, #[serde(default)] - #[validate(max_length = 4096)] pub biography: String, #[serde(default)] - #[validate(max_length = 2048)] pub warning: String, #[serde(default)] pub private_profile: bool, @@ -309,7 +303,6 @@ pub struct UserSettings { pub private_mails: bool, /// The user's status. Shows over connection info. #[serde(default)] - #[validate(max_length = 256)] pub status: String, /// The mime type of the user's banner. #[serde(default = "mime_avif")] @@ -372,11 +365,9 @@ pub struct UserSettings { pub hide_social_follows: bool, /// The signature automatically attached to new mail letters. #[serde(default)] - #[validate(max_length = 2048)] pub mail_signature: String, /// The signature automatically attached to new forum posts. #[serde(default)] - #[validate(max_length = 2048)] pub forum_signature: String, /// If coin transfer requests are disabled. #[serde(default)] @@ -390,26 +381,6 @@ pub struct UserSettings { /// If the user's system font is always used over Lexend. #[serde(default)] pub use_system_font: bool, - /// The user's location. This isn't actually verified or anything, so it can really - /// be whatever the user wants. - #[serde(default)] - #[validate(max_length = 128)] - pub location: String, - /// External links for the user's other profiles on other websites. - #[serde(default)] - #[validate(max_items = 5)] - #[validate(unique_items)] - pub links: Vec<(String, String)>, -} - -impl UserSettings { - pub fn verify_values(&self) -> Result<()> { - if let Err(e) = self.validate() { - return Err(Error::MiscError(e.to_string())); - } - - Ok(()) - } } fn mime_avif() -> String { @@ -458,6 +429,7 @@ impl User { was_purchased: false, browser_session: String::new(), ban_reason: String::new(), + channel_mutes: Vec::new(), is_deactivated: false, ban_expire: 0, coins: 0, @@ -465,7 +437,6 @@ impl User { applied_configurations: Vec::new(), last_policy_consent: created, close_friends_stack: 0, - missed_messages_count: 0, } } diff --git a/crates/core/src/model/channels.rs b/crates/core/src/model/channels.rs new file mode 100644 index 0000000..84180c4 --- /dev/null +++ b/crates/core/src/model/channels.rs @@ -0,0 +1,133 @@ +use std::collections::HashMap; + +use serde::{Serialize, Deserialize}; +use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; + +use super::communities_permissions::CommunityPermission; + +/// A channel is a more "chat-like" feed in communities. +#[derive(Clone, Serialize, Deserialize)] +pub struct Channel { + pub id: usize, + pub community: usize, + pub owner: usize, + pub created: usize, + /// The minimum role (as bits) that can read this channel. + pub minimum_role_read: u32, + /// The minimum role (as bits) that can write to this channel. + pub minimum_role_write: u32, + /// The position of this channel in the UI. + /// + /// Top (0) to bottom. + pub position: usize, + /// The members of the chat (ids). Should be empty if `community > 0`. + /// + /// The owner should not be a member of the channel since any member can update members. + pub members: Vec, + /// The title of the channel. + pub title: String, + /// The timestamp of the last message in the channel. + pub last_message: usize, +} + +impl Channel { + /// Create a new [`Channel`]. + pub fn new(community: usize, owner: usize, position: usize, title: String) -> Self { + let created = unix_epoch_timestamp(); + + Self { + id: Snowflake::new().to_string().parse::().unwrap(), + community, + owner, + created, + minimum_role_read: (CommunityPermission::DEFAULT | CommunityPermission::MEMBER).bits(), + minimum_role_write: (CommunityPermission::DEFAULT | CommunityPermission::MEMBER).bits(), + position, + members: Vec::new(), + title, + last_message: created, + } + } + + /// Check if the given `uid` can post in the channel. + pub fn check_post(&self, uid: usize, membership: Option) -> bool { + let mut is_member = false; + + if let Some(membership) = membership { + is_member = membership.bits() >= self.minimum_role_write + } + + (uid == self.owner) | is_member | self.members.contains(&uid) + } + + /// Check if the given `uid` can post in the channel. + pub fn check_read(&self, uid: usize, membership: Option) -> bool { + let mut is_member = false; + + if let Some(membership) = membership { + is_member = membership.bits() >= self.minimum_role_read + } + + (uid == self.owner) | is_member | self.members.contains(&uid) + } +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct Message { + pub id: usize, + pub channel: usize, + pub owner: usize, + pub created: usize, + pub edited: usize, + pub content: String, + pub context: MessageContext, + pub reactions: HashMap, +} + +impl Message { + pub fn new(channel: usize, owner: usize, content: String) -> Self { + let now = unix_epoch_timestamp(); + + Self { + id: Snowflake::new().to_string().parse::().unwrap(), + channel, + owner, + created: now, + edited: now, + content, + context: MessageContext, + reactions: HashMap::new(), + } + } +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct MessageContext; + +impl Default for MessageContext { + fn default() -> Self { + 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::().unwrap(), + created: unix_epoch_timestamp(), + owner, + message, + emoji, + } + } +} diff --git a/crates/core/src/model/communities_permissions.rs b/crates/core/src/model/communities_permissions.rs index b61c1d2..1d8f0da 100644 --- a/crates/core/src/model/communities_permissions.rs +++ b/crates/core/src/model/communities_permissions.rs @@ -18,7 +18,7 @@ bitflags! { const MANAGE_PINS = 1 << 7; const MANAGE_COMMUNITY = 1 << 8; const MANAGE_QUESTIONS = 1 << 9; - const UNUSED_0 = 1 << 10; + const MANAGE_CHANNELS = 1 << 10; const MANAGE_MESSAGES = 1 << 11; const MANAGE_EMOJIS = 1 << 12; diff --git a/crates/core/src/model/mod.rs b/crates/core/src/model/mod.rs index 532408c..75a133f 100644 --- a/crates/core/src/model/mod.rs +++ b/crates/core/src/model/mod.rs @@ -2,6 +2,7 @@ pub mod addr; pub mod apps; pub mod auth; pub mod carp; +pub mod channels; pub mod communities; pub mod communities_permissions; pub mod economy; diff --git a/crates/core/src/model/permissions.rs b/crates/core/src/model/permissions.rs index 01e1193..61ebb61 100644 --- a/crates/core/src/model/permissions.rs +++ b/crates/core/src/model/permissions.rs @@ -30,7 +30,7 @@ bitflags! { const SUPPORTER = 1 << 19; const MANAGE_REQUESTS = 1 << 20; const MANAGE_QUESTIONS = 1 << 21; - const UNUSED_0 = 1 << 22; + const MANAGE_CHANNELS = 1 << 22; const MANAGE_MESSAGES = 1 << 23; const MANAGE_UPLOADS = 1 << 24; const MANAGE_EMOJIS = 1 << 25; diff --git a/example/tetratto.toml b/example/tetratto.toml index df898a6..21b2932 100644 --- a/example/tetratto.toml +++ b/example/tetratto.toml @@ -24,7 +24,6 @@ system_user = 211903918383300608 [service_hosts] buckets = "http://localhost:8020" littleweb = "http://localhost:4119" -tawny = "http://localhost:8021" [security] registration_enabled = true