Compare commits

..

6 commits

Author SHA1 Message Date
7983b460e5 remove: atto.active_connections:chats 2025-09-08 22:45:15 -04:00
6e4fd3da36 add: uploads in post quotes 2025-09-07 11:06:03 -04:00
abd23e0ccc remove: channel_mutes column 2025-09-06 22:53:52 -04:00
6d333378a4 remove: old chats core 2025-09-05 22:08:16 -04:00
918d47d873 add: tawny beta 2025-09-01 20:17:55 -04:00
140a11ff72 add: user links and location 2025-08-31 23:41:12 -04:00
61 changed files with 580 additions and 3681 deletions

95
Cargo.lock generated
View file

@ -1603,6 +1603,7 @@ checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661"
dependencies = [ dependencies = [
"equivalent", "equivalent",
"hashbrown", "hashbrown",
"serde",
] ]
[[package]] [[package]]
@ -1667,6 +1668,15 @@ dependencies = [
"either", "either",
] ]
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.15" version = "1.0.15"
@ -2379,6 +2389,27 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" 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]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.95" version = "1.0.95"
@ -2587,7 +2618,7 @@ dependencies = [
"built", "built",
"cfg-if", "cfg-if",
"interpolate_name", "interpolate_name",
"itertools", "itertools 0.12.1",
"libc", "libc",
"libfuzzer-sys", "libfuzzer-sys",
"log", "log",
@ -3028,6 +3059,51 @@ dependencies = [
"serde", "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]] [[package]]
name = "sha1" name = "sha1"
version = "0.10.6" version = "0.10.6"
@ -3214,6 +3290,12 @@ dependencies = [
"unicode-properties", "unicode-properties",
] ]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]] [[package]]
name = "subtle" name = "subtle"
version = "2.6.1" version = "2.6.1"
@ -3370,7 +3452,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"tera", "tera",
"tetratto-core 16.0.0", "tetratto-core 16.0.3",
"tetratto-l10n 12.0.0", "tetratto-l10n 12.0.0",
"tetratto-shared 12.0.6", "tetratto-shared 12.0.6",
"tokio", "tokio",
@ -3407,7 +3489,7 @@ dependencies = [
[[package]] [[package]]
name = "tetratto-core" name = "tetratto-core"
version = "16.0.0" version = "16.0.3"
dependencies = [ dependencies = [
"async-recursion", "async-recursion",
"base16ct", "base16ct",
@ -3423,6 +3505,7 @@ dependencies = [
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"serde_valid",
"tetratto-l10n 12.0.0", "tetratto-l10n 12.0.0",
"tetratto-shared 12.0.6", "tetratto-shared 12.0.6",
"tokio", "tokio",
@ -4027,6 +4110,12 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0"
[[package]]
name = "unicode-segmentation"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]] [[package]]
name = "unicode-width" name = "unicode-width"
version = "0.2.1" version = "0.2.1"

View file

@ -117,11 +117,6 @@ 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_WARNINGS: &str = include_str!("./public/html/mod/warnings.lisp");
pub const MOD_STATS: &str = include_str!("./public/html/mod/stats.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_LIST: &str = include_str!("./public/html/stacks/list.lisp");
pub const STACKS_FEED: &str = include_str!("./public/html/stacks/feed.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"); pub const STACKS_MANAGE: &str = include_str!("./public/html/stacks/manage.lisp");
@ -357,11 +352,6 @@ 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/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->"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/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/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); write_template!(html_path->"stacks/manage.html"(crate::assets::STACKS_MANAGE) --config=config --lisp plugins);

View file

@ -175,6 +175,7 @@ version = "1.0.0"
"settings:tab.general" = "General" "settings:tab.general" = "General"
"settings:tab.account" = "Account" "settings:tab.account" = "Account"
"settings:tab.profile" = "Profile" "settings:tab.profile" = "Profile"
"settings:tab.experience" = "Experience"
"settings:tab.theme" = "Theme" "settings:tab.theme" = "Theme"
"settings:tab.sessions" = "Sessions" "settings:tab.sessions" = "Sessions"
"settings:tab.grants" = "Grants" "settings:tab.grants" = "Grants"

View file

@ -1,4 +1,5 @@
@import url("utility.css"); @import url("utility.css");
@import url("https://repodelivery.tetratto.com/tetratto-aux/lexend.css");
:root { :root {
color-scheme: light dark; color-scheme: light dark;
@ -85,12 +86,6 @@
box-sizing: border-box; box-sizing: border-box;
} }
@font-face {
font-family: "Lexend";
src: url("https://repodelivery.tetratto.com/fonts/lexend_variable.woff2")
format("woff2");
}
html, html,
body { body {
line-height: 1.5; line-height: 1.5;
@ -193,15 +188,6 @@ p {
margin-bottom: var(--pad-4); 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 { .no_p_margin p:last-child {
margin-bottom: 0; margin-bottom: 0;
} }

View file

@ -1256,6 +1256,11 @@ details summary::-webkit-details-marker {
display: none; display: none;
} }
details summary.button {
height: max-content;
justify-content: start;
}
details[open] > summary { details[open] > summary {
position: relative; position: relative;
color: var(--color-text-lowered) !important; color: var(--color-text-lowered) !important;
@ -1288,7 +1293,7 @@ details.accordion {
details.accordion summary { details.accordion summary {
background: var(--color-lowered); background: var(--color-lowered);
border-radius: var(--radius); border-radius: var(--radius);
padding: var(--pad-3) var(--pad-4); padding: var(--pad-3) var(--pad-4) !important;
margin: 0; margin: 0;
width: 100%; width: 100%;
user-select: none; user-select: none;

View file

@ -174,5 +174,5 @@
(text "Or, ") (text "Or, ")
(a (a
("href" "/auth/login") ("href" "/auth/login")
(text "login"))) (text "log in")))
(text "{% endblock %}") (text "{% endblock %}")

View file

@ -101,6 +101,9 @@
atto[\"hooks::spotify_time_text\"](); // spotify durations atto[\"hooks::spotify_time_text\"](); // spotify durations
atto[\"hooks::verify_emoji\"](); atto[\"hooks::verify_emoji\"]();
globalThis.CURRENT_USER = \"{% if user -%} {{ user.username }} {%- endif %}\";
trigger(\"me::token_links\");
fix_atto_links(); fix_atto_links();
if (document.getElementById(\"tokens\")) { if (document.getElementById(\"tokens\")) {
@ -126,14 +129,6 @@
trigger(\"me::seen\"); trigger(\"me::seen\");
trigger(\"streams::user\", [\"{{ user.id }}\"]); 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\")) { if (window.location.pathname.startsWith(\"/reference\")) {
window.location.reload(); window.location.reload();
} }

View file

@ -1,509 +0,0 @@
(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 %}")

View file

@ -1,80 +0,0 @@
(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 %}"))

View file

@ -1 +0,0 @@
(text "{%- import \"components.html\" as components -%} {{ components::message(user=user, message=message, grouped=grouped) }}")

View file

@ -1,55 +0,0 @@
(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;
}")))

View file

@ -361,6 +361,7 @@
false, false,
document.getElementById(\"community_to_post_to\") document.getElementById(\"community_to_post_to\")
.selectedOptions[0].getAttribute(\"is_stack\") === \"true\", .selectedOptions[0].getAttribute(\"is_stack\") === \"true\",
e.target.file_picker ? e.target.file_picker.files : [],
]); ]);
// update settings // update settings

View file

@ -29,14 +29,6 @@
(text "{{ text \"communities:tab.members\" }}")))) (text "{{ text \"communities:tab.members\" }}"))))
(div (div
("class" "row") ("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 (a
("href" "#/topics") ("href" "#/topics")
("data-tab-button" "topics") ("data-tab-button" "topics")
@ -147,7 +139,7 @@
(button (button
(icon (text "check")) (icon (text "check"))
(span (span
(text "{{ text \"general:action.save\" }}")))))) (str (text "general:action.save")))))))
(div (div
("class" "card_nest") ("class" "card_nest")
("ui_ident" "danger_zone") ("ui_ident" "danger_zone")
@ -170,7 +162,7 @@
("onclick" "save_context()") ("onclick" "save_context()")
(icon (text "check")) (icon (text "check"))
(span (span
(text "{{ text \"general:action.save\" }}"))) (str (text "general:action.save"))))
(a (a
("href" "/community/{{ community.title }}") ("href" "/community/{{ community.title }}")
("class" "button secondary") ("class" "button secondary")
@ -273,167 +265,11 @@
("onclick" "update_user_role(document.getElementById('uid').value, document.getElementById('role').value)") ("onclick" "update_user_role(document.getElementById('uid').value, document.getElementById('role').value)")
(icon (text "check")) (icon (text "check"))
(span (span
(text "{{ text \"general:action.save\" }}")))) (str (text "general:action.save")))))
(div (div
("class" "card flex flex_col gap_2") ("class" "card flex flex_col gap_2")
("id" "permission_builder")))) ("id" "permission_builder"))))
(text "{% if can_manage_channels -%}") (text "{% if can_manage_emojis -%}")
(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 (div
("class" "card lowered w_full hidden flex flex_col gap_2") ("class" "card lowered w_full hidden flex flex_col gap_2")
("data-tab" "emojis") ("data-tab" "emojis")
@ -987,7 +823,7 @@
MANAGE_PINS: 1 << 7, MANAGE_PINS: 1 << 7,
MANAGE_COMMUNITY: 1 << 8, MANAGE_COMMUNITY: 1 << 8,
MANAGE_QUESTIONS: 1 << 9, MANAGE_QUESTIONS: 1 << 9,
MANAGE_CHANNELS: 1 << 10, UNUSED_0: 1 << 10,
MANAGE_MESSAGES: 1 << 11, MANAGE_MESSAGES: 1 << 11,
MANAGE_EMOJIS: 1 << 12, MANAGE_EMOJIS: 1 << 12,
}, },

View file

@ -706,7 +706,9 @@
("cy" "12") ("cy" "12")
("r" "6")))) ("r" "6"))))
(text "{%- endif %} {%- endmacro %} {% macro theme(user, theme_preference) -%} {% if user %} {% if user.settings.theme_hue -%}") (text "{%- endif %} {%- endmacro %}")
(text "{% macro theme(user, theme_preference) -%} {% if user %} {% if user.settings.theme_hue -%}")
(style (style
(text ":root, * { (text ":root, * {
--hue: {{ user.settings.theme_hue }} !important; --hue: {{ user.settings.theme_hue }} !important;
@ -739,7 +741,6 @@
setTimeout(() => { setTimeout(() => {
match_user_theme(); match_user_theme();
}, 150);")) }, 150);"))
(text "{%- endif %}") (text "{%- endif %}")
(div (div
("style" "display: none;") ("style" "display: none;")
@ -748,7 +749,6 @@
(style (style
(text "{{ user.settings.theme_custom_css|remove_script_tags|safe }}")) (text "{{ user.settings.theme_custom_css|remove_script_tags|safe }}"))
(text "{%- endif %}")) (text "{%- endif %}"))
(text "{%- endif %} {%- endmacro %} {% macro theme_color(color, css) -%} {% if color -%}") (text "{%- endif %} {%- endmacro %} {% macro theme_color(color, css) -%} {% if color -%}")
(style (style
(text ":root, (text ":root,
@ -756,7 +756,9 @@
--{{ css }}: {{ color|color }} !important; --{{ css }}: {{ color|color }} !important;
}")) }"))
(text "{%- endif %} {%- endmacro %} {% macro question(question, owner, asking_about=false, show_community=true, secondary=false, profile=false) -%}") (text "{%- endif %} {%- endmacro %}")
(text "{% macro question(question, owner, asking_about=false, show_community=true, secondary=false, profile=false) -%}")
(div (div
("class" "card question {% if secondary -%}secondary{%- endif %} flex gap_2") ("class" "card question {% if secondary -%}secondary{%- endif %} flex gap_2")
(text "{% if owner.id == 0 or question.context.mask_owner -%}") (text "{% if owner.id == 0 or question.context.mask_owner -%}")
@ -860,8 +862,8 @@
(div (div
("class" "card_nest") ("class" "card_nest")
(div (div
("class" "card small flex items_center gap_2") ("class" "card small flex items_center gap_2 flex_wrap")
(text "{{ icon \"message-circle-heart\" }}") (icon (text "message-circle-heart"))
(span (span
("class" "no_p_margin") ("class" "no_p_margin")
(text "{% if header -%} {{ header|markdown|safe }} {% else %} {{ text \"requests:label.ask_question\" }} {%- endif %}"))) (text "{% if header -%} {{ header|markdown|safe }} {% else %} {{ text \"requests:label.ask_question\" }} {%- endif %}")))
@ -1143,106 +1145,8 @@
(div (div
("style" "display: contents;") ("style" "display: contents;")
(text "{% if key == \"Spotify\" -%} {{ icon \"spotify\" }} {% elif key == \"LastFm\" %} {{ icon \"last_fm\" }} {%- endif %}")) (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 %} {% macro message_actions(can_manage_message, owner, message, owner) -%}") (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 %}")
(div (text "{% macro user_menu() -%}")
("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 (div
("class" "inner") ("class" "inner")
(b (b
@ -1590,7 +1494,7 @@
(text "{% macro create_post_options() -%}") (text "{% macro create_post_options() -%}")
(div (div
("class" "flex gap_2 flex_wrap") ("class" "flex gap_2 flex_wrap")
(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 %}") (text "{{ components::emoji_picker(element_id=\"content\", render_dialog=true) }} {% if is_supporter -%} {{ components::file_picker(files_list_id=\"files_list\") }} {%- endif %}")
(button (button
("class" "small square lowered") ("class" "small square lowered")
@ -2048,12 +1952,6 @@
(text "{{ icon \"circle-minus\" }}") (text "{{ icon \"circle-minus\" }}")
(span (span
(text "{{ text \"communities:action.leave\" }}"))) (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 -%}") (text "{% if user and can_post -%}")
(a (a
("href" "/communities/intents/post?community={{ community.id }}") ("href" "/communities/intents/post?community={{ community.id }}")
@ -2092,12 +1990,6 @@
}); });
};")) };"))
(text "{%- endif %} {% else %}") (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 -%}") (text "{% if not community.is_forum -%}")
(a (a
("href" "/communities/intents/post?community={{ community.id }}") ("href" "/communities/intents/post?community={{ community.id }}")

View file

@ -95,7 +95,7 @@
(button (button
(icon (text "check")) (icon (text "check"))
(span (span
(text "{{ text \"general:action.save\" }}"))))) (str (text "general:action.save"))))))
(div (div
("class" "card_nest") ("class" "card_nest")
(div (div
@ -120,7 +120,7 @@
(button (button
(icon (text "check")) (icon (text "check"))
(span (span
(text "{{ text \"general:action.save\" }}"))))) (str (text "general:action.save"))))))
(div (div
("class" "card_nest") ("class" "card_nest")
(div (div
@ -145,7 +145,7 @@
(button (button
(icon (text "check")) (icon (text "check"))
(span (span
(text "{{ text \"general:action.save\" }}"))))) (str (text "general:action.save"))))))
(div (div
("class" "card_nest") ("class" "card_nest")
(div (div
@ -185,7 +185,7 @@
(button (button
(icon (text "check")) (icon (text "check"))
(span (span
(text "{{ text \"general:action.save\" }}"))))) (str (text "general:action.save"))))))
(div (div
("class" "card_nest") ("class" "card_nest")
(div (div

View file

@ -255,7 +255,7 @@
(button (button
(icon (text "check")) (icon (text "check"))
(span (span
(text "{{ text \"general:action.save\" }}"))))))) (str (text "general:action.save"))))))))
; users should also be able to manage the journal's sub directories here ; users should also be able to manage the journal's sub directories here
(details (details

View file

@ -68,10 +68,12 @@
(div (div
("class" "inner") ("class" "inner")
(text "{% if config.service_hosts.tawny -%}")
(a (a
("href" "/chats/0/0") ("href" "{{ config.service_hosts.tawny }}/api/v1/auth/set_token?token=")
(icon (text "message-circle")) (icon (text "message-circle"))
(str (text "communities:label.chats"))) (str (text "communities:label.chats")))
(text "{%- endif %}")
(a (a
("href" "/mail") ("href" "/mail")
(icon (text "mail")) (icon (text "mail"))
@ -354,7 +356,7 @@
(str (text "forge:tab.tickets")))) (str (text "forge:tab.tickets"))))
(text "{%- endmacro %}") (text "{%- endmacro %}")
(text "{% macro profile_settings_nav_options() -%}") (text "{% macro user_settings_nav_options() -%}")
(a (a
("data-tab-button" "account") ("data-tab-button" "account")
("class" "active") ("class" "active")
@ -368,6 +370,12 @@
(text "{{ icon \"user-round\" }}") (text "{{ icon \"user-round\" }}")
(span (span
(text "{{ text \"settings:tab.profile\" }}"))) (text "{{ text \"settings:tab.profile\" }}")))
(a
("data-tab-button" "experience")
("href" "#/experience")
(text "{{ icon \"settings-2\" }}")
(span
(text "{{ text \"settings:tab.experience\" }}")))
(a (a
("data-tab-button" "theme") ("data-tab-button" "theme")
("href" "#/theme") ("href" "#/theme")
@ -393,4 +401,10 @@
(text "{{ icon \"book-user\" }}") (text "{{ icon \"book-user\" }}")
(span (span
(text "{{ text \"settings:tab.close_friends\" }}"))) (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 %}") (text "{%- endmacro %}")

View file

@ -358,7 +358,7 @@
("onclick" "update_user_role(Number.parseInt(document.getElementById('role').value))") ("onclick" "update_user_role(Number.parseInt(document.getElementById('role').value))")
(icon (text "check")) (icon (text "check"))
(span (span
(text "{{ text \"general:action.save\" }}")))) (str (text "general:action.save")))))
(div (div
("class" "card lowered flex flex_col gap_2") ("class" "card lowered flex flex_col gap_2")
("id" "permission_builder"))) ("id" "permission_builder")))
@ -376,7 +376,7 @@
("onclick" "update_user_secondary_role(Number.parseInt(document.getElementById('secondary_role').value))") ("onclick" "update_user_secondary_role(Number.parseInt(document.getElementById('secondary_role').value))")
(icon (text "check")) (icon (text "check"))
(span (span
(text "{{ text \"general:action.save\" }}")))) (str (text "general:action.save")))))
(div (div
("class" "card lowered flex flex_col gap_2") ("class" "card lowered flex flex_col gap_2")
("id" "secondary_permission_builder"))) ("id" "secondary_permission_builder")))
@ -409,7 +409,7 @@
SUPPORTER: 1 << 19, SUPPORTER: 1 << 19,
MANAGE_REQUESTS: 1 << 20, MANAGE_REQUESTS: 1 << 20,
MANAGE_QUESTIONS: 1 << 21, MANAGE_QUESTIONS: 1 << 21,
MANAGE_CHANNELS: 1 << 22, UNUSED_0: 1 << 22,
MANAGE_MESSAGES: 1 << 23, MANAGE_MESSAGES: 1 << 23,
MANAGE_UPLOADS: 1 << 24, MANAGE_UPLOADS: 1 << 24,
MANAGE_EMOJIS: 1 << 25, MANAGE_EMOJIS: 1 << 25,

View file

@ -20,16 +20,11 @@
(text "Active user streams: ")) (text "Active user streams: "))
(span (span
(text "{{ active_users }}"))) (text "{{ active_users }}")))
(li
(b
(text "Active chat subscriptions: "))
(span
(text "{{ active_users_chats }}")))
(li (li
(b (b
(text "Socket tasks: ")) (text "Socket tasks: "))
(span (span
(text "{{ (active_users_chats + active_users) * 3 }}")))) (text "{{ active_users * 3 }}"))))
(hr) (hr)
(ul (ul

View file

@ -148,7 +148,7 @@
("onclick" "save_context()") ("onclick" "save_context()")
(icon (text "check")) (icon (text "check"))
(span (span
(text "{{ text \"general:action.save\" }}"))) (str (text "general:action.save"))))
(script (script
(text "setTimeout(async () => { (text "setTimeout(async () => {
const ui = await ns(\"ui\"); const ui = await ns(\"ui\");
@ -286,7 +286,7 @@
("class" "flex gap_2") ("class" "flex gap_2")
(text "{{ components::emoji_picker(element_id=\"new_content\", render_dialog=false) }}") (text "{{ components::emoji_picker(element_id=\"new_content\", render_dialog=false) }}")
(button (button
(text "{{ text \"general:action.save\" }}"))))) (str (text "general:action.save"))))))
(script (script
(text "async function edit_post_from_form(e) { (text "async function edit_post_from_form(e) {
e.preventDefault(); e.preventDefault();

View file

@ -115,7 +115,7 @@
("class" "fade") ("class" "fade")
(text "{{ profile.username }}")))) (text "{{ profile.username }}"))))
(div (div
("class" "card flex flex_col items_center gap_2") ("class" "card flex flex_col items_center small gap_2")
("id" "social") ("id" "social")
(text "{% if profile.settings.status -%}") (text "{% if profile.settings.status -%}")
(p (p
@ -159,7 +159,20 @@
(div (div
("id" "bio") ("id" "bio")
("class" "card small no_p_margin") ("class" "card small no_p_margin")
(text "{{ profile.settings.biography|markdown|safe }}")) (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 %}"))
(div (div
("class" "card flex flex_col gap_2") ("class" "card flex flex_col gap_2")
(text "{% if user -%}") (text "{% if user -%}")
@ -265,12 +278,18 @@
(span (span
(text "{{ text \"auth:action.unblock\" }}"))) (text "{{ text \"auth:action.unblock\" }}")))
(text "{%- endif %} {% if not profile.settings.private_chats or is_following_you %}") (text "{%- endif %} {% if not profile.settings.private_chats or is_following_you %}")
(button (text "{% if config.service_hosts.tawny -%}")
("onclick" "create_group_chat()") (a
("class" "lowered") ("href" "{{ config.service_hosts.tawny }}/@{{ profile.username }}")
(text "{{ icon \"message-circle\" }}") ("class" "button lowered")
(span (icon (text "egg"))
(text "{{ text \"auth:action.message\" }}"))) (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 %}")
(text "{%- endif %} {% if not profile.settings.private_mails or is_following_you %}") (text "{%- endif %} {% if not profile.settings.private_mails or is_following_you %}")
(a (a
("href" "/mail/compose?receivers={{ profile.username }}") ("href" "/mail/compose?receivers={{ profile.username }}")
@ -294,31 +313,7 @@
(text "{{ text \"general:action.manage\" }}"))) (text "{{ text \"general:action.manage\" }}")))
(text "{%- endif %}") (text "{%- endif %}")
(script (script
(text "globalThis.create_group_chat = async () => { (text "globalThis.request_transfer = 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\"]); await trigger(\"atto::debounce\", [\"economy::transfer\"]);
const amount = Number.parseInt((await trigger(\"atto::prompt\", [\"Request amount:\"])) || \"0\"); const amount = Number.parseInt((await trigger(\"atto::prompt\", [\"Request amount:\"])) || \"0\");

View file

@ -2,7 +2,6 @@
(div (div
("style" "display: contents") ("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 "{{ 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 "{%- endif %}")
(text "{{ macros::profile_nav(selected=\"posts\") }}") (text "{{ macros::profile_nav(selected=\"posts\") }}")
(div (div

View file

@ -24,5 +24,4 @@
(div (div
("class" "card flex flex_col gap_4") ("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 "{% 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 %}") (text "{% endblock %}")

View file

@ -7,7 +7,7 @@
; nav desktop ; nav desktop
(menu (menu
("class" "desktop col") ("class" "desktop col")
(text "{{ macros::profile_settings_nav_options() }}")) (text "{{ macros::user_settings_nav_options() }}"))
; content ; content
(main (main
@ -35,7 +35,7 @@
(span ("class" "current_tab_text") (text "account"))) (span ("class" "current_tab_text") (text "account")))
(div (div
("class" "inner left") ("class" "inner left")
(text "{{ macros::profile_settings_nav_options() }}")))) (text "{{ macros::user_settings_nav_options() }}"))))
; ... ; ...
(div (div
@ -43,81 +43,70 @@
("data-tab" "presets") ("data-tab" "presets")
(div (div
("class" "card lowered flex flex_col gap_2") ("class" "card lowered flex flex_col gap_2")
(a (p (text "Not sure where to start? Try some settings presets!"))
("href" "#/account") (details
("class" "button secondary") ("class" "w_full accordion")
(icon (text "arrow-left")) (summary
(span ("class" "button raised")
(str (text "general:action.back")))) (icon (text "rss"))
(div (text "Microblogging"))
("class" "card_nest")
(div (div
("class" "card flex items_center gap_2 small") ("class" "inner flex flex_col gap_2")
(icon (text "cooking-pot")) (p ("class" "fade") (text "Focus on yourself and your communities."))
(span (ul ("id" "preset_microblogging_ul"))
(str (text "settings:tab.presets")))) (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"))
(div (div
("class" "card flex flex_col gap_2 secondary") ("class" "inner flex flex_col gap_2")
(p (text "Not sure where to start? Try some settings presets!")) (p ("class" "fade") (text "Just like Neospring!"))
(details (ul ("id" "preset_questions_ul"))
("class" "w_full accordion") (button
(summary ("onclick" "apply_preset(PRESET_QUESTIONS)")
(icon (text "rss")) (icon (text "settings"))
(text "Microblogging")) (str (text "general:action.apply")))))
(div (details
("class" "inner flex flex_col gap_2") ("class" "w_full accordion")
(p ("class" "fade") (text "Focus on yourself and your communities.")) (summary
(ul ("id" "preset_microblogging_ul")) ("class" "button raised")
(button (icon (text "key"))
("onclick" "apply_preset(PRESET_MICROBLOGGING)") (text "Private"))
(icon (text "settings"))
(str (text "general:action.apply")))))
(details (div
("class" "w_full accordion") ("class" "inner flex flex_col gap_2")
(summary (p ("class" "fade") (text "This preset allows you to keep your profile and posts hidden to people you aren't following."))
(icon (text "message-circle-heart")) (ul ("id" "preset_private_ul"))
(text "Q&A")) (button
("onclick" "apply_preset(PRESET_PRIVATE)")
(icon (text "settings"))
(str (text "general:action.apply")))))
(div (details
("class" "inner flex flex_col gap_2") ("class" "w_full accordion")
(p ("class" "fade") (text "Just like Neospring!")) (summary
(ul ("id" "preset_questions_ul")) ("class" "button raised")
(button (icon (text "eye-closed"))
("onclick" "apply_preset(PRESET_QUESTIONS)") (text "NSFW"))
(icon (text "settings"))
(str (text "general:action.apply")))))
(details (div
("class" "w_full accordion") ("class" "inner flex flex_col gap_2")
(summary (p ("class" "fade") (text "NSFW content is allowed if it is hidden from main timelines. This preset will help you do that quickly."))
(icon (text "key")) (ul ("id" "preset_nsfw_ul"))
(text "Private")) (button
("onclick" "apply_preset(PRESET_NSFW)")
(div (icon (text "settings"))
("class" "inner flex flex_col gap_2") (str (text "general:action.apply")))))))
(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 (div
("class" "w_full flex flex_col gap_2") ("class" "w_full flex flex_col gap_2")
@ -180,61 +169,6 @@
(span (span
(text "{{ text \"settings:tab.billing\" }}")))) (text "{{ text \"settings:tab.billing\" }}"))))
(text "{%- endif %}") (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 "<option
value='{\"Stack\":\"{{ stack.id }}\"}'
selected=\"{% if home is ending_with(stack.id|as_str) -%}true{% else %}false{%- endif %}\"
>
{{ stack.name }} (stack)
</option>")
(text "{% endfor %}"))
(span
("class" "fade")
(text "This represents the timeline the home button takes you to."))))
(div (div
("class" "card_nest desktop") ("class" "card_nest desktop")
("ui_ident" "notifications") ("ui_ident" "notifications")
@ -282,7 +216,7 @@
(button (button
(icon (text "check")) (icon (text "check"))
(span (span
(text "{{ text \"general:action.save\" }}")))))) (str (text "general:action.save")))))))
(div (div
("class" "card_nest") ("class" "card_nest")
("ui_ident" "delete_account") ("ui_ident" "delete_account")
@ -337,7 +271,7 @@
("id" "save_button") ("id" "save_button")
(icon (text "check")) (icon (text "check"))
(span (span
(text "{{ text \"general:action.save\" }}")))) (str (text "general:action.save")))))
(div (div
("class" "w_full flex flex_col gap_2 hidden") ("class" "w_full flex flex_col gap_2 hidden")
("data-tab" "account/security") ("data-tab" "account/security")
@ -444,7 +378,7 @@
(button (button
(icon (text "check")) (icon (text "check"))
(span (span
(text "{{ text \"general:action.save\" }}"))))))))) (str (text "general:action.save"))))))))))
(div (div
("class" "w_full flex flex_col gap_2 hidden") ("class" "w_full flex flex_col gap_2 hidden")
("data-tab" "account/following") ("data-tab" "account/following")
@ -991,13 +925,13 @@
(span (span
("class" "fade") ("class" "fade")
(text "Use an image of 1100x350px for the best results.")))) (text "Use an image of 1100x350px for the best results."))))
(div (div
("class" "card_nest") ("class" "card_nest")
("ui_ident" "default_profile_page") ("ui_ident" "default_profile_page")
(div (div
("class" "card small") ("class" "card small")
(b (b (text "Default profile tab")))
(text "Default profile tab")))
(div (div
("class" "card") ("class" "card")
(select (select
@ -1013,10 +947,36 @@
(span (span
("class" "fade") ("class" "fade")
(text "This represents the timeline that is shown on your profile by default.")))) (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 (div
("class" "flex flex_col gap_2") ("class" "flex flex_col gap_2")
("ui_ident" "show_presets") ("ui_ident" "show_presets")
(hr ("class" "margin"))
(div (div
("class" "card_nest") ("class" "card_nest")
(div (div
@ -1028,13 +988,67 @@
(p (p
(text "Quickly set up your account with ") (text "Quickly set up your account with ")
(a ("href" "/settings#/presets") (text "settings presets")) (a ("href" "/settings#/presets") (text "settings presets"))
(text "!")))))) (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 "<option
value='{\"Stack\":\"{{ stack.id }}\"}'
selected=\"{% if home is ending_with(stack.id|as_str) -%}true{% else %}false{%- endif %}\"
>
{{ stack.name }} (stack)
</option>")
(text "{% endfor %}"))
(span
("class" "fade")
(text "This represents the timeline the home button takes you to.")))))
(button (button
("onclick" "save_settings()") ("onclick" "save_settings()")
("id" "save_button") ("id" "save_button")
(icon (text "check")) (icon (text "check"))
(span (span
(text "{{ text \"general:action.save\" }}")))) (str (text "general:action.save")))))
(div (div
("class" "card w_full lowered hidden flex flex_col gap_2") ("class" "card w_full lowered hidden flex flex_col gap_2")
("data-tab" "sessions") ("data-tab" "sessions")
@ -1193,7 +1207,7 @@
("id" "save_button") ("id" "save_button")
(icon (text "check")) (icon (text "check"))
(span (span
(text "{{ text \"general:action.save\" }}")))) (str (text "general:action.save")))))
(div (div
("class" "card w_full lowered hidden flex flex_col gap_2") ("class" "card w_full lowered hidden flex flex_col gap_2")
("data-tab" "grants") ("data-tab" "grants")
@ -1595,7 +1609,7 @@
`data:image/png;base64,${qr}`; `data:image/png;base64,${qr}`;
document.getElementById( document.getElementById(
\"totp_recovery_codes\", \"totp_recovery_codes\",
).innerText = recovery_codes.join(\"\n\"); ).innerText = recovery_codes.join(\"\\n\");
document.getElementById(\"totp_stuff\").style.display = document.getElementById(\"totp_stuff\").style.display =
\"contents\"; \"contents\";
@ -1787,12 +1801,13 @@
document.getElementById(\"account_settings\"); document.getElementById(\"account_settings\");
const profile_settings = const profile_settings =
document.getElementById(\"profile_settings\"); document.getElementById(\"profile_settings\");
const experience_settings =
document.getElementById(\"experience_settings\");
const theme_settings = document.getElementById(\"theme_settings\"); const theme_settings = document.getElementById(\"theme_settings\");
ui.refresh_container(account_settings, [ ui.refresh_container(account_settings, [
\"supporter_ad\", \"supporter_ad\",
\"account_settings_tabs\", \"account_settings_tabs\",
\"home_timeline\",
\"notifications\", \"notifications\",
\"change_username\", \"change_username\",
\"delete_account\", \"delete_account\",
@ -1802,6 +1817,11 @@
\"change_avatar\", \"change_avatar\",
\"change_banner\", \"change_banner\",
\"default_profile_page\", \"default_profile_page\",
\"user_links\",
]);
ui.refresh_container(experience_settings, [
\"supporter_ad\",
\"home_timeline\",
\"show_presets\", \"show_presets\",
]); ]);
ui.refresh_container(theme_settings, [ ui.refresh_container(theme_settings, [
@ -1814,7 +1834,7 @@
]); ]);
ui.generate_settings_ui( ui.generate_settings_ui(
account_settings, profile_settings,
[ [
[ [
[\"display_name\", \"Display name\"], [\"display_name\", \"Display name\"],
@ -1835,50 +1855,23 @@
'<span class=\"fade\">This biography is only shown to users you are not following while your account is private.</span>', '<span class=\"fade\">This biography is only shown to users you are not following while your account is private.</span>',
}, },
], ],
[
[\"location\", \"Location\"],
\"{{ profile.settings.location }}\",
\"input\",
],
[[\"status\", \"Status\"], settings.status, \"textarea\"], [[\"status\", \"Status\"], settings.status, \"textarea\"],
[ [
[\"warning\", \"Profile warning\"], [\"warning\", \"Profile warning\"],
settings.warning, settings.warning,
\"textarea\", \"textarea\",
], ],
[[\"muted\", \"Muted phrases\"], settings.muted.join(\"\\n\"), \"textarea\", {
embed_html:
'<span class=\"fade\">Muted phrases should all be on new lines.</span>',
}],
[[], \"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, settings,
{
muted: (new_muted) => {
settings.muted = new_muted
.split(\"\\n\")
.map((t) => t.trim());
},
},
); );
ui.generate_settings_ui( ui.generate_settings_ui(
profile_settings, experience_settings,
[ [
[[], \"Privacy\", \"title\"], [[], \"Privacy\", \"title\"],
[ [
@ -1988,6 +1981,10 @@
\"{{ profile.settings.hide_username_badges }}\", \"{{ profile.settings.hide_username_badges }}\",
\"checkbox\", \"checkbox\",
], ],
[[\"muted\", \"Muted phrases\"], settings.muted.join(\"\\n\"), \"textarea\", {
embed_html:
'<span class=\"fade\">Muted phrases should all be on new lines.</span>',
}],
[[], \"Questions\", \"title\"], [[], \"Questions\", \"title\"],
[ [
[ [
@ -2074,8 +2071,36 @@
\"{{ profile.settings.disable_achievements }}\", \"{{ profile.settings.disable_achievements }}\",
\"checkbox\", \"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, settings,
{
muted: (new_muted) => {
settings.muted = new_muted
.split(\"\\n\")
.map((t) => t.trim());
},
},
); );
const can_use_custom_css = const can_use_custom_css =
@ -2351,5 +2376,40 @@
anchor.click(); anchor.click();
anchor.remove(); 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 += `<li id=\"link_${i}\"><span>${link[0]}</span> (<a href=\"${link[1]}\">${link[1]}</a>) (<a class=\"red\" href=\"javascript:remove_link(${i})\">delete</a>)</li>`;
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 %}") (text "{% endblock %}")

View file

@ -33,6 +33,7 @@
classes: {}, classes: {},
service_hosts: { service_hosts: {
buckets: \"{{ config.service_hosts.buckets|safe }}\", buckets: \"{{ config.service_hosts.buckets|safe }}\",
tawny: \"{{ config.service_hosts.tawny|safe }}\",
} }
}; };

View file

@ -9,7 +9,7 @@
("class" "card_nest") ("class" "card_nest")
(div (div
("class" "card small flex items_center gap_2") ("class" "card small flex items_center gap_2")
(text "{{ components::avatar(id=add_user.username, size=\"24px\") }}") (text "{{ components::avatar(id=add_user.id, size=\"24px\") }}")
(text "{{ components::full_username(user=add_user) }}")) (text "{{ components::full_username(user=add_user) }}"))
(div (div
("class" "card flex flex_col gap_2") ("class" "card flex flex_col gap_2")

View file

@ -117,7 +117,7 @@
(button (button
(icon (text "check")) (icon (text "check"))
(span (span
(text "{{ text \"general:action.save\" }}")))))) (str (text "general:action.save")))))))
(text "{% if not stack.is_locked -%}") (text "{% if not stack.is_locked -%}")
(div (div
("class" "card_nest") ("class" "card_nest")

View file

@ -860,7 +860,8 @@ media_theme_pref();
anchor.href.startsWith("https://buy.stripe.com") || anchor.href.startsWith("https://buy.stripe.com") ||
anchor.href.startsWith("https://billing.stripe.com") || anchor.href.startsWith("https://billing.stripe.com") ||
anchor.href.startsWith("https://last.fm") || anchor.href.startsWith("https://last.fm") ||
anchor.href.startsWith("atto://") anchor.href.startsWith("atto://") ||
anchor.href.startsWith(_app_base.service_hosts.tawny)
) { ) {
continue; continue;
} }

View file

@ -311,19 +311,30 @@
community, community,
do_not_redirect = false, do_not_redirect = false,
is_stack = false, is_stack = false,
uploads = [],
) => { ) => {
await trigger("atto::debounce", ["posts::create"]); await trigger("atto::debounce", ["posts::create"]);
return new Promise((resolve, _) => { return new Promise((resolve, _) => {
fetch(`/api/v1/posts/${id}/repost`, { // create body
method: "POST", const body = new FormData();
headers: {
"Content-Type": "application/json", for (const file of uploads) {
}, body.append(file.name, file);
body: JSON.stringify({ }
body.append(
"body",
JSON.stringify({
content, content,
community: !is_stack ? community : "0", community: !is_stack ? community : "0",
stack: 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) => res.json())
.then((res) => { .then((res) => {
@ -662,6 +673,15 @@
}); });
// token switcher // 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) => { self.define("append_associations", (_, tokens) => {
fetch("/api/v1/auth/user/me/append_associations", { fetch("/api/v1/auth/user/me/append_associations", {
method: "PUT", method: "PUT",

View file

@ -153,28 +153,8 @@ pub async fn update_user_settings_request(
} }
// check lengths // check lengths
if req.display_name.len() > 32 { if let Err(e) = req.verify_values() {
return Json(Error::DataTooLong("display name".to_string()).into()); return Json(e.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 // check percentage themes

View file

@ -1,354 +0,0 @@
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<State>,
Json(req): Json<CreateChannel>,
) -> 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::<usize>() {
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<State>,
Json(req): Json<CreateGroupChannel>,
) -> 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<usize> = Vec::new();
for member in req.members {
members.push(match member.parse::<usize>() {
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<State>,
Path(id): Path<usize>,
) -> 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<State>,
Path(id): Path<usize>,
Json(req): Json<UpdateChannelTitle>,
) -> 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<State>,
Path(id): Path<usize>,
Json(req): Json<UpdateChannelPosition>,
) -> 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<State>,
Path(id): Path<usize>,
Json(req): Json<KickMember>,
) -> 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<State>,
Path(id): Path<usize>,
Json(req): Json<KickMember>,
) -> 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::<usize>() {
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<State>,
) -> 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<State>,
Path(id): Path<usize>,
) -> 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<State>,
Path(id): Path<usize>,
) -> 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<State>,
Path(id): Path<usize>,
) -> 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<State>,
Path(id): Path<usize>,
) -> 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()),
}
}

View file

@ -1,103 +0,0 @@
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<State>,
Path(id): Path<usize>,
) -> 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<State>,
Json(req): Json<CreateMessageReaction>,
) -> 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::<usize>() {
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<State>,
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()),
}
}

View file

@ -1,352 +0,0 @@
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<State>,
Path(id): Path<String>,
) -> 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<SocketHeaders> = 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::<usize>() {
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<usize, bool> = HashMap::new();
if !headers.is_channel {
// check permissions for every channel in community
let community_id = match community_id.parse::<usize>() {
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::<String>().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::<usize, String, ()>(
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<State>,
Json(req): Json<CreateMessage>,
) -> 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::<usize>() {
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<State>,
Path(id): Path<usize>,
) -> 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<State>,
Path(id): Path<usize>,
Query(props): Query<PaginatedQuery>,
) -> 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()),
}
}

View file

@ -1,3 +0,0 @@
pub mod channels;
pub mod message_reactions;
pub mod messages;

View file

@ -241,7 +241,7 @@ pub async fn create_repost_request(
jar: CookieJar, jar: CookieJar,
Extension(data): Extension<State>, Extension(data): Extension<State>,
Path(id): Path<usize>, Path(id): Path<usize>,
Json(req): Json<CreateRepost>, JsonMultipart(images, req): JsonMultipart<CreateRepost>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let data = &(data.read().await).0; let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreatePosts) { let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreatePosts) {
@ -264,13 +264,69 @@ pub async fn create_repost_request(
Err(e) => return Json(Error::MiscError(e.to_string()).into()), 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 { match data.create_post(props).await {
Ok(id) => Json(ApiReturn { Ok(id) => {
ok: true, // write to uploads
message: "Post reposted".to_string(), for (i, upload_id) in uploads.iter().enumerate() {
payload: Some(id.to_string()), 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()),
})
}
Err(e) => Json(e.into()), Err(e) => Json(e.into()),
} }
} }

View file

@ -2,7 +2,6 @@ pub mod ads;
pub mod app_data; pub mod app_data;
pub mod apps; pub mod apps;
pub mod auth; pub mod auth;
pub mod channels;
pub mod communities; pub mod communities;
pub mod domains; pub mod domains;
pub mod journals; pub mod journals;
@ -55,19 +54,6 @@ pub fn routes() -> Router {
.route("/reactions", post(reactions::create_request)) .route("/reactions", post(reactions::create_request))
.route("/reactions/{id}", get(reactions::get_request)) .route("/reactions/{id}", get(reactions::get_request))
.route("/reactions/{id}", delete(reactions::delete_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 // communities
.route( .route(
"/communities/find/{id}", "/communities/find/{id}",
@ -586,57 +572,6 @@ pub fn routes() -> Router {
"/service_hooks/stripe/checkout/success", "/service_hooks/stripe/checkout/success",
get(auth::connections::stripe::handle_stupid_fucking_checkout_success_session), 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 // emojis
.route( .route(
"/lookup_emoji", "/lookup_emoji",
@ -1012,39 +947,6 @@ pub struct CreateQuestion {
pub asking_about: String, 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<String>,
}
#[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)] #[derive(Deserialize)]
pub struct CreateStack { pub struct CreateStack {
pub name: String, pub name: String,
@ -1185,12 +1087,6 @@ pub struct RenderMarkdown {
pub content: String, pub content: String,
} }
#[derive(Deserialize)]
pub struct CreateMessageReaction {
pub message: String,
pub emoji: String,
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct UpdateNoteDir { pub struct UpdateNoteDir {
pub dir: String, pub dir: String,

View file

@ -1,380 +0,0 @@
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<State>,
Path((selected_community, selected_channel)): Path<(usize, usize)>,
Query(props): Query<ChatsAppQuery>,
) -> 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!(
"<!doctype html><html><head><meta http-equiv=\"refresh\" content=\"0; url=/chats/{}/{}?nav={}\" /></head></html>",
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<State>,
Path((community, channel)): Path<(usize, usize)>,
Query(props): Query<ChatsAppQuery>,
) -> 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<State>,
Path((community, channel)): Path<(usize, usize)>,
Json(req): Json<RenderMessage>,
) -> 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<State>,
Path((community, channel_id)): Path<(usize, usize)>,
Query(props): Query<PaginatedQuery>,
) -> 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(),
))
}

View file

@ -793,14 +793,6 @@ 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 { let emojis = match data.0.get_emojis_by_community(community.id).await {
Ok(p) => p, 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)),
@ -814,10 +806,6 @@ pub async fn settings_request(
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
context.insert("community", &community); 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("can_manage_emojis", &can_manage_emojis);
context.insert("emojis", &emojis); context.insert("emojis", &emojis);

View file

@ -1,5 +1,4 @@
pub mod auth; pub mod auth;
pub mod chats;
pub mod communities; pub mod communities;
pub mod developer; pub mod developer;
pub mod economy; pub mod economy;
@ -12,10 +11,7 @@ pub mod mod_panel;
pub mod profile; pub mod profile;
pub mod stacks; pub mod stacks;
use axum::{ use axum::{routing::get, Router};
routing::{get, post},
Router,
};
use crate::{cookie::CookieJar, routes::pages::misc::TimelineOrderMode}; use crate::{cookie::CookieJar, routes::pages::misc::TimelineOrderMode};
use serde::Deserialize; use serde::Deserialize;
use tetratto_core::model::{Error, auth::User}; use tetratto_core::model::{Error, auth::User};
@ -115,21 +111,6 @@ pub fn routes() -> Router {
.route("/post/{id}/reposts", get(communities::reposts_request)) .route("/post/{id}/reposts", get(communities::reposts_request))
.route("/post/{id}/likes", get(communities::likes_request)) .route("/post/{id}/likes", get(communities::likes_request))
.route("/question/{id}", get(communities::question_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 // forge
.route("/forges", get(forge::home_request)) .route("/forges", get(forge::home_request))
.route("/forge/{title}", get(forge::info_request)) .route("/forge/{title}", get(forge::info_request))
@ -200,16 +181,6 @@ pub struct PaginatedQuery {
pub before: usize, 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)] #[derive(Deserialize)]
pub struct ProfileQuery { pub struct ProfileQuery {
#[serde(default)] #[serde(default)]

View file

@ -286,18 +286,6 @@ pub async fn stats_request(jar: CookieJar, Extension(data): Extension<State>) ->
let lang = get_lang!(jar, data.0); let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; 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::<i64>()
.unwrap(),
);
context.insert( context.insert(
"active_users", "active_users",
&data &data

View file

@ -1,7 +1,7 @@
[package] [package]
name = "tetratto-core" name = "tetratto-core"
description = "The core behind Tetratto" description = "The core behind Tetratto"
version = "16.0.0" version = "16.0.3"
edition = "2024" edition = "2024"
readme = "../../README.md" readme = "../../README.md"
authors.workspace = true authors.workspace = true
@ -50,3 +50,4 @@ oiseau = { version = "0.1.2", default-features = false, features = [
paste = { version = "1.0.15", optional = true } paste = { version = "1.0.15", optional = true }
tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] } tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] }
buckets-core = "1.0.4" buckets-core = "1.0.4"
serde_valid = "1.0.5"

View file

@ -264,6 +264,9 @@ pub struct ServiceHostsConfig {
/// Littleweb browser host. /// Littleweb browser host.
#[serde(default)] #[serde(default)]
pub littleweb: String, pub littleweb: String,
/// Tawny host <https://trisua.com/t/tawny>.
#[serde(default)]
pub tawny: String,
} }
impl Default for ServiceHostsConfig { impl Default for ServiceHostsConfig {
@ -271,6 +274,7 @@ impl Default for ServiceHostsConfig {
Self { Self {
buckets: String::new(), buckets: String::new(),
littleweb: String::new(), littleweb: String::new(),
tawny: String::new(),
} }
} }
} }

View file

@ -123,14 +123,14 @@ impl DataManager {
was_purchased: get!(x->25(i32)) as i8 == 1, was_purchased: get!(x->25(i32)) as i8 == 1,
browser_session: get!(x->26(String)), browser_session: get!(x->26(String)),
ban_reason: get!(x->27(String)), ban_reason: get!(x->27(String)),
channel_mutes: serde_json::from_str(&get!(x->28(String)).to_string()).unwrap(), is_deactivated: get!(x->28(i32)) as i8 == 1,
is_deactivated: get!(x->29(i32)) as i8 == 1, ban_expire: get!(x->29(i64)) as usize,
ban_expire: get!(x->30(i64)) as usize, coins: get!(x->30(i32)),
coins: get!(x->31(i32)), checkouts: serde_json::from_str(&get!(x->31(String)).to_string()).unwrap(),
checkouts: serde_json::from_str(&get!(x->32(String)).to_string()).unwrap(), applied_configurations: 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->33(i64)) as usize,
last_policy_consent: get!(x->34(i64)) as usize, close_friends_stack: get!(x->34(i64)) as usize,
close_friends_stack: get!(x->35(i64)) as usize, missed_messages_count: get!(x->35(i32)) as usize,
} }
} }
@ -287,7 +287,7 @@ impl DataManager {
let res = execute!( let res = execute!(
&conn, &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, $36)", "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)",
params![ params![
&(data.id as i64), &(data.id as i64),
&(data.created as i64), &(data.created as i64),
@ -317,7 +317,6 @@ impl DataManager {
&if data.was_purchased { 1_i32 } else { 0_i32 }, &if data.was_purchased { 1_i32 } else { 0_i32 },
&data.browser_session, &data.browser_session,
&data.ban_reason, &data.ban_reason,
&serde_json::to_string(&data.channel_mutes).unwrap(),
&if data.is_deactivated { 1_i32 } else { 0_i32 }, &if data.is_deactivated { 1_i32 } else { 0_i32 },
&(data.ban_expire as i64), &(data.ban_expire as i64),
&(data.coins as i32), &(data.coins as i32),
@ -709,15 +708,17 @@ impl DataManager {
self.cache_clear_user(&other_user).await; self.cache_clear_user(&other_user).await;
// create audit log entry // create audit log entry (if we aren't the user that is being updated)
self.create_audit_log_entry(AuditLogEntry::new( if user.id != other_user.id {
user.id, self.create_audit_log_entry(AuditLogEntry::new(
format!( user.id,
"invoked `update_user_is_deactivated` with x value `{}` and y value `{}`", format!(
other_user.id, x "invoked `update_user_is_deactivated` with x value `{}` and y value `{}`",
), other_user.id, x
)) ),
.await?; ))
.await?;
}
// ... // ...
Ok(()) Ok(())
@ -1163,4 +1164,8 @@ 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!(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!(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);
} }

View file

@ -1,325 +0,0 @@
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<usize>,
ignore_users: Vec<usize>,
) -> Result<Vec<User>> {
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<Vec<Channel>> {
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<Vec<Channel>> {
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<Channel> {
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<usize>)@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:{}");
}

View file

@ -26,8 +26,6 @@ impl DataManager {
execute!(&conn, common::CREATE_TABLE_REQUESTS).unwrap(); execute!(&conn, common::CREATE_TABLE_REQUESTS).unwrap();
execute!(&conn, common::CREATE_TABLE_QUESTIONS).unwrap(); execute!(&conn, common::CREATE_TABLE_QUESTIONS).unwrap();
execute!(&conn, common::CREATE_TABLE_IPBLOCKS).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_EMOJIS).unwrap();
execute!(&conn, common::CREATE_TABLE_STACKS).unwrap(); execute!(&conn, common::CREATE_TABLE_STACKS).unwrap();
execute!(&conn, common::CREATE_TABLE_DRAFTS).unwrap(); execute!(&conn, common::CREATE_TABLE_DRAFTS).unwrap();
@ -37,7 +35,6 @@ impl DataManager {
execute!(&conn, common::CREATE_TABLE_STACKBLOCKS).unwrap(); execute!(&conn, common::CREATE_TABLE_STACKBLOCKS).unwrap();
execute!(&conn, common::CREATE_TABLE_JOURNALS).unwrap(); execute!(&conn, common::CREATE_TABLE_JOURNALS).unwrap();
execute!(&conn, common::CREATE_TABLE_NOTES).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_INVITE_CODES).unwrap();
execute!(&conn, common::CREATE_TABLE_DOMAINS).unwrap(); execute!(&conn, common::CREATE_TABLE_DOMAINS).unwrap();
execute!(&conn, common::CREATE_TABLE_SERVICES).unwrap(); execute!(&conn, common::CREATE_TABLE_SERVICES).unwrap();
@ -55,10 +52,6 @@ impl DataManager {
.1 .1
.set("atto.active_connections:users".to_string(), "0".to_string()) .set("atto.active_connections:users".to_string(), "0".to_string())
.await; .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"); self.2.init().await.expect("failed to init buckets manager");
Ok(()) Ok(())

View file

@ -375,11 +375,6 @@ impl DataManager {
return Err(Error::DatabaseError(e.to_string())); 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 // remove images
let avatar = PathBufD::current().extend(&[ let avatar = PathBufD::current().extend(&[
self.0.0.dirs.media.as_str(), self.0.0.dirs.media.as_str(),

View file

@ -14,8 +14,6 @@ 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_REQUESTS: &str = include_str!("./sql/create_requests.sql");
pub const CREATE_TABLE_QUESTIONS: &str = include_str!("./sql/create_questions.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_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_EMOJIS: &str = include_str!("./sql/create_emojis.sql");
pub const CREATE_TABLE_STACKS: &str = include_str!("./sql/create_stacks.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"); pub const CREATE_TABLE_DRAFTS: &str = include_str!("./sql/create_drafts.sql");
@ -25,7 +23,6 @@ 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_STACKBLOCKS: &str = include_str!("./sql/create_stackblocks.sql");
pub const CREATE_TABLE_JOURNALS: &str = include_str!("./sql/create_journals.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_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_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_DOMAINS: &str = include_str!("./sql/create_domains.sql");
pub const CREATE_TABLE_SERVICES: &str = include_str!("./sql/create_services.sql"); pub const CREATE_TABLE_SERVICES: &str = include_str!("./sql/create_services.sql");

View file

@ -1,12 +0,0 @@
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
)

View file

@ -1,8 +0,0 @@
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)
)

View file

@ -1,10 +0,0 @@
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
)

View file

@ -27,12 +27,12 @@ CREATE TABLE IF NOT EXISTS users (
was_purchased INT NOT NULL, was_purchased INT NOT NULL,
browser_session TEXT NOT NULL, browser_session TEXT NOT NULL,
ban_reason TEXT NOT NULL, ban_reason TEXT NOT NULL,
channel_mutes TEXT NOT NULL,
is_deactivated INT NOT NULL, is_deactivated INT NOT NULL,
ban_expire BIGINT NOT NULL, ban_expire BIGINT NOT NULL,
coins INT NOT NULL, coins INT NOT NULL,
checkouts TEXT NOT NULL, checkouts TEXT NOT NULL,
applied_configurations TEXT NOT NULL, applied_configurations TEXT NOT NULL,
last_policy_consent BIGINT NOT NULL, last_policy_consent BIGINT NOT NULL,
close_friends_stack BIGINT NOT NULL close_friends_stack BIGINT NOT NULL,
missed_messages_count INT NOT NULL
) )

View file

@ -85,3 +85,11 @@ ADD COLUMN IF NOT EXISTS close_friends_stack BIGINT DEFAULT 0;
-- stacks is_locked -- stacks is_locked
ALTER TABLE stacks ALTER TABLE stacks
ADD COLUMN IF NOT EXISTS is_locked INT DEFAULT 0; 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;

View file

@ -1,183 +0,0 @@
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<Vec<MessageReaction>> {
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<MessageReaction> {
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(())
}
}

View file

@ -1,380 +0,0 @@
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<Message>,
ignore_users: &[usize],
) -> Result<Vec<(Message, User, bool)>> {
let mut out: Vec<(Message, User, bool)> = Vec::new();
let mut users: HashMap<usize, User> = 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<Vec<Message>> {
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<String, User> = 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!(
"<a href=\"/api/v1/auth/user/find/{}\" target=\"_top\">@{username}</a>",
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::<String, String, ()>(
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::<String, String, ()>(
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<String, usize>) -> "UPDATE messages SET reactions = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.message:{}");
}

View file

@ -3,7 +3,6 @@ pub mod app_data;
mod apps; mod apps;
mod audit_log; mod audit_log;
mod auth; mod auth;
mod channels;
mod common; mod common;
mod communities; mod communities;
pub mod connections; pub mod connections;
@ -17,8 +16,6 @@ mod ipblocks;
mod journals; mod journals;
mod letters; mod letters;
mod memberships; mod memberships;
mod message_reactions;
mod messages;
mod notes; mod notes;
mod notifications; mod notifications;
mod polls; mod polls;

View file

@ -1,4 +1,6 @@
use std::collections::HashMap; use std::collections::HashMap;
use crate::model::{Error, Result};
use super::{ use super::{
oauth::AuthGrant, oauth::AuthGrant,
permissions::{FinePermission, SecondaryPermission}, permissions::{FinePermission, SecondaryPermission},
@ -10,6 +12,7 @@ use tetratto_shared::{
snow::Snowflake, snow::Snowflake,
unix_epoch_timestamp, unix_epoch_timestamp,
}; };
use serde_valid::Validate;
/// `(ip, token, creation timestamp)` /// `(ip, token, creation timestamp)`
pub type Token = (String, String, usize); pub type Token = (String, String, usize);
@ -82,9 +85,6 @@ pub struct User {
/// The reason the user was banned. /// The reason the user was banned.
#[serde(default)] #[serde(default)]
pub ban_reason: String, pub ban_reason: String,
/// IDs of channels the user has muted.
#[serde(default)]
pub channel_mutes: Vec<usize>,
/// If the user is deactivated. Deactivated users act almost like deleted /// If the user is deactivated. Deactivated users act almost like deleted
/// users, but their data is not wiped. /// users, but their data is not wiped.
#[serde(default)] #[serde(default)]
@ -113,6 +113,9 @@ pub struct User {
/// (the user) to post to it. /// (the user) to post to it.
#[serde(default)] #[serde(default)]
pub close_friends_stack: usize, pub close_friends_stack: usize,
/// The number of messages this user has missed.
#[serde(default)]
pub missed_messages_count: usize,
} }
pub type UserConnections = pub type UserConnections =
@ -187,13 +190,16 @@ impl Default for DefaultProfileTabChoice {
} }
} }
#[derive(Clone, Debug, Serialize, Deserialize, Default)] #[derive(Clone, Debug, Serialize, Deserialize, Default, Validate)]
pub struct UserSettings { pub struct UserSettings {
#[serde(default)] #[serde(default)]
#[validate(max_length = 32)]
pub display_name: String, pub display_name: String,
#[serde(default)] #[serde(default)]
#[validate(max_length = 4096)]
pub biography: String, pub biography: String,
#[serde(default)] #[serde(default)]
#[validate(max_length = 2048)]
pub warning: String, pub warning: String,
#[serde(default)] #[serde(default)]
pub private_profile: bool, pub private_profile: bool,
@ -303,6 +309,7 @@ pub struct UserSettings {
pub private_mails: bool, pub private_mails: bool,
/// The user's status. Shows over connection info. /// The user's status. Shows over connection info.
#[serde(default)] #[serde(default)]
#[validate(max_length = 256)]
pub status: String, pub status: String,
/// The mime type of the user's banner. /// The mime type of the user's banner.
#[serde(default = "mime_avif")] #[serde(default = "mime_avif")]
@ -365,9 +372,11 @@ pub struct UserSettings {
pub hide_social_follows: bool, pub hide_social_follows: bool,
/// The signature automatically attached to new mail letters. /// The signature automatically attached to new mail letters.
#[serde(default)] #[serde(default)]
#[validate(max_length = 2048)]
pub mail_signature: String, pub mail_signature: String,
/// The signature automatically attached to new forum posts. /// The signature automatically attached to new forum posts.
#[serde(default)] #[serde(default)]
#[validate(max_length = 2048)]
pub forum_signature: String, pub forum_signature: String,
/// If coin transfer requests are disabled. /// If coin transfer requests are disabled.
#[serde(default)] #[serde(default)]
@ -381,6 +390,26 @@ pub struct UserSettings {
/// If the user's system font is always used over Lexend. /// If the user's system font is always used over Lexend.
#[serde(default)] #[serde(default)]
pub use_system_font: bool, 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 { fn mime_avif() -> String {
@ -429,7 +458,6 @@ impl User {
was_purchased: false, was_purchased: false,
browser_session: String::new(), browser_session: String::new(),
ban_reason: String::new(), ban_reason: String::new(),
channel_mutes: Vec::new(),
is_deactivated: false, is_deactivated: false,
ban_expire: 0, ban_expire: 0,
coins: 0, coins: 0,
@ -437,6 +465,7 @@ impl User {
applied_configurations: Vec::new(), applied_configurations: Vec::new(),
last_policy_consent: created, last_policy_consent: created,
close_friends_stack: 0, close_friends_stack: 0,
missed_messages_count: 0,
} }
} }

View file

@ -1,133 +0,0 @@
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<usize>,
/// 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::<usize>().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<CommunityPermission>) -> 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<CommunityPermission>) -> 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<String, usize>,
}
impl Message {
pub fn new(channel: usize, owner: usize, content: String) -> Self {
let now = unix_epoch_timestamp();
Self {
id: Snowflake::new().to_string().parse::<usize>().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::<usize>().unwrap(),
created: unix_epoch_timestamp(),
owner,
message,
emoji,
}
}
}

View file

@ -18,7 +18,7 @@ bitflags! {
const MANAGE_PINS = 1 << 7; const MANAGE_PINS = 1 << 7;
const MANAGE_COMMUNITY = 1 << 8; const MANAGE_COMMUNITY = 1 << 8;
const MANAGE_QUESTIONS = 1 << 9; const MANAGE_QUESTIONS = 1 << 9;
const MANAGE_CHANNELS = 1 << 10; const UNUSED_0 = 1 << 10;
const MANAGE_MESSAGES = 1 << 11; const MANAGE_MESSAGES = 1 << 11;
const MANAGE_EMOJIS = 1 << 12; const MANAGE_EMOJIS = 1 << 12;

View file

@ -2,7 +2,6 @@ pub mod addr;
pub mod apps; pub mod apps;
pub mod auth; pub mod auth;
pub mod carp; pub mod carp;
pub mod channels;
pub mod communities; pub mod communities;
pub mod communities_permissions; pub mod communities_permissions;
pub mod economy; pub mod economy;

View file

@ -30,7 +30,7 @@ bitflags! {
const SUPPORTER = 1 << 19; const SUPPORTER = 1 << 19;
const MANAGE_REQUESTS = 1 << 20; const MANAGE_REQUESTS = 1 << 20;
const MANAGE_QUESTIONS = 1 << 21; const MANAGE_QUESTIONS = 1 << 21;
const MANAGE_CHANNELS = 1 << 22; const UNUSED_0 = 1 << 22;
const MANAGE_MESSAGES = 1 << 23; const MANAGE_MESSAGES = 1 << 23;
const MANAGE_UPLOADS = 1 << 24; const MANAGE_UPLOADS = 1 << 24;
const MANAGE_EMOJIS = 1 << 25; const MANAGE_EMOJIS = 1 << 25;

View file

@ -24,6 +24,7 @@ system_user = 211903918383300608
[service_hosts] [service_hosts]
buckets = "http://localhost:8020" buckets = "http://localhost:8020"
littleweb = "http://localhost:4119" littleweb = "http://localhost:4119"
tawny = "http://localhost:8021"
[security] [security]
registration_enabled = true registration_enabled = true