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 += `
${link[0]} (${link[1]}) (delete)`;
- 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