add: mail ui
This commit is contained in:
parent
2e60cbc464
commit
b2a73d286b
24 changed files with 993 additions and 259 deletions
426
Cargo.lock
generated
426
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "tetratto"
|
||||
version = "12.0.0"
|
||||
version = "13.0.0"
|
||||
edition = "2024"
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
|
|
|
@ -139,6 +139,11 @@ pub const LITTLEWEB_BROWSER: &str = include_str!("./public/html/littleweb/browse
|
|||
|
||||
pub const MARKETPLACE_SELLER: &str = include_str!("./public/html/marketplace/seller.lisp");
|
||||
|
||||
pub const MAIL_RECEIVED: &str = include_str!("./public/html/mail/received.lisp");
|
||||
pub const MAIL_SENT: &str = include_str!("./public/html/mail/sent.lisp");
|
||||
pub const MAIL_COMPOSE: &str = include_str!("./public/html/mail/compose.lisp");
|
||||
pub const MAIL_LETTER: &str = include_str!("./public/html/mail/letter.lisp");
|
||||
|
||||
// langs
|
||||
pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml");
|
||||
|
||||
|
@ -365,6 +370,11 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD {
|
|||
|
||||
write_template!(html_path->"marketplace/seller.html"(crate::assets::MARKETPLACE_SELLER) -d "marketplace" --config=config --lisp plugins);
|
||||
|
||||
write_template!(html_path->"mail/received.html"(crate::assets::MAIL_RECEIVED) -d "mail" --config=config --lisp plugins);
|
||||
write_template!(html_path->"mail/sent.html"(crate::assets::MAIL_SENT) --config=config --lisp plugins);
|
||||
write_template!(html_path->"mail/compose.html"(crate::assets::MAIL_COMPOSE) --config=config --lisp plugins);
|
||||
write_template!(html_path->"mail/letter.html"(crate::assets::MAIL_LETTER) --config=config --lisp plugins);
|
||||
|
||||
html_path
|
||||
}
|
||||
|
||||
|
|
|
@ -19,6 +19,7 @@ version = "1.0.0"
|
|||
"general:link.journals" = "Journals"
|
||||
"general:link.achievements" = "Achievements"
|
||||
"general:link.little_web" = "Little web"
|
||||
"general:link.mail" = "Mail"
|
||||
"general:action.save" = "Save"
|
||||
"general:action.delete" = "Delete"
|
||||
"general:action.purge" = "Purge"
|
||||
|
@ -203,7 +204,6 @@ version = "1.0.0"
|
|||
"mod_panel:label.invited_by" = "Invited by"
|
||||
"mod_panel:label.send_debug_payload" = "Send debug payload"
|
||||
"mod_panel:label.ban_reason" = "Ban reason"
|
||||
"mod_panel:action.send" = "Send"
|
||||
|
||||
"requests:label.requests" = "Requests"
|
||||
"requests:label.community_join_request" = "Community join request"
|
||||
|
@ -308,3 +308,12 @@ version = "1.0.0"
|
|||
"marketplace:action.get_started" = "Get started"
|
||||
"marketplace:action.finsh_setting_up_account" = "Finish setting up my account"
|
||||
"marketplace:action.open_seller_dashboard" = "Open seller dashboard"
|
||||
|
||||
"mail:label.received" = "Received"
|
||||
"mail:label.sent" = "Sent"
|
||||
"mail:label.compose" = "Compose"
|
||||
"mail:label.receivers" = "Receivers"
|
||||
"mail:label.subject" = "Subject"
|
||||
"mail:label.content" = "Content"
|
||||
"mail:action.send" = "Send"
|
||||
"mail:action.send_mail" = "Send mail"
|
||||
|
|
|
@ -189,6 +189,23 @@ macro_rules! user_banned {
|
|||
};
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! check_user_is_blocked {
|
||||
($data:expr, $user:ident, $other_user:ident) => {
|
||||
($data
|
||||
.get_userblock_by_initiator_receiver($other_user.id, $user.id)
|
||||
.await
|
||||
.is_ok()
|
||||
| $data
|
||||
.get_user_stack_blocked_users($other_user.id)
|
||||
.await
|
||||
.contains(&$user.id))
|
||||
&& !$user
|
||||
.permissions
|
||||
.check(tetratto_core::model::permissions::FinePermission::MANAGE_USERS)
|
||||
};
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! check_user_blocked_or_private {
|
||||
($user:expr, $other_user:ident, $data:ident, $jar:ident) => {
|
||||
|
@ -244,20 +261,7 @@ macro_rules! check_user_blocked_or_private {
|
|||
|
||||
// check if we're blocked
|
||||
if let Some(ref ua) = $user {
|
||||
if ($data
|
||||
.0
|
||||
.get_userblock_by_initiator_receiver($other_user.id, ua.id)
|
||||
.await
|
||||
.is_ok()
|
||||
| $data
|
||||
.0
|
||||
.get_user_stack_blocked_users($other_user.id)
|
||||
.await
|
||||
.contains(&ua.id))
|
||||
&& !ua
|
||||
.permissions
|
||||
.check(tetratto_core::model::permissions::FinePermission::MANAGE_USERS)
|
||||
{
|
||||
if crate::check_user_is_blocked!($data.0, ua, $other_user) {
|
||||
let lang = get_lang!($jar, $data.0);
|
||||
let mut context = initial_context(&$data.0.0.0, lang, &$user).await;
|
||||
|
||||
|
@ -341,18 +345,7 @@ macro_rules! check_user_blocked_or_private {
|
|||
($user:expr, $other_user:ident, $data:ident, @api) => {
|
||||
// check if we're blocked
|
||||
if let Some(ref ua) = $user {
|
||||
if ($data
|
||||
.get_userblock_by_initiator_receiver($other_user.id, ua.id)
|
||||
.await
|
||||
.is_ok()
|
||||
| $data
|
||||
.get_user_stack_blocked_users($other_user.id)
|
||||
.await
|
||||
.contains(&ua.id))
|
||||
&& !ua
|
||||
.permissions
|
||||
.check(tetratto_core::model::permissions::FinePermission::MANAGE_USERS)
|
||||
{
|
||||
if crate::check_user_is_blocked!($data, ua, $other_user) {
|
||||
return Json(
|
||||
tetratto_core::model::Error::MiscError("You're blocked".to_string()).into(),
|
||||
);
|
||||
|
|
|
@ -293,6 +293,12 @@ button.small.square,
|
|||
height: 32px;
|
||||
}
|
||||
|
||||
button.tiny.square,
|
||||
.button.tiny.square {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
button.big_icon svg,
|
||||
.button.big_icon svg {
|
||||
height: 16px;
|
||||
|
|
|
@ -113,6 +113,12 @@
|
|||
("style" "color: var(--color-primary)")
|
||||
("class" "flex items-center")
|
||||
(text "{{ icon \"badge-check\" }}"))
|
||||
(text "{%- endif %} {% if user.permissions|has_supporter -%}")
|
||||
(span
|
||||
("title" "Supporter")
|
||||
("style" "color: var(--color-primary);")
|
||||
("class" "flex items-center")
|
||||
(text "{{ icon \"star\" }}"))
|
||||
(text "{%- endif %} {% if user.permissions|has_staff_badge -%}")
|
||||
(span
|
||||
("title" "Staff")
|
||||
|
@ -456,21 +462,25 @@
|
|||
(div
|
||||
("class" "w-full card-nest")
|
||||
(div
|
||||
("class" "card small notif_title flex items-center")
|
||||
(text "{% if not notification.read -%}")
|
||||
(svg
|
||||
("width" "24")
|
||||
("height" "24")
|
||||
("viewBox" "0 0 24 24")
|
||||
("style" "fill: var(--color-link)")
|
||||
(circle
|
||||
("cx" "12")
|
||||
("cy" "12")
|
||||
("r" "6")))
|
||||
(text "{%- endif %}")
|
||||
(b
|
||||
("class" "no_p_margin")
|
||||
(text "{{ notification.title|markdown|safe }}")))
|
||||
("class" "card small notif_title flex gap-2 justify-between items-center")
|
||||
(div
|
||||
("class" "flex items-center")
|
||||
(text "{% if not notification.read -%}")
|
||||
(svg
|
||||
("width" "24")
|
||||
("height" "24")
|
||||
("viewBox" "0 0 24 24")
|
||||
("style" "fill: var(--color-link)")
|
||||
(circle
|
||||
("cx" "12")
|
||||
("cy" "12")
|
||||
("r" "6")))
|
||||
(text "{%- endif %}")
|
||||
(b
|
||||
("class" "no_p_margin")
|
||||
(text "{{ notification.title|markdown|safe }}")))
|
||||
|
||||
(span ("class" "date") (text "{{ notification.created }}")))
|
||||
(div
|
||||
("class" "card notif_content flex flex-col gap-2")
|
||||
(span
|
||||
|
@ -2451,3 +2461,85 @@
|
|||
(span
|
||||
(str (text "dialog:action.continue"))))))
|
||||
(text "{%- endif %} {%- endmacro %}")
|
||||
|
||||
(text "{% macro letter_listing(letter, owner) -%}")
|
||||
(div
|
||||
("class" "card lowered flex gap-2 flex-row")
|
||||
(a
|
||||
("href" "/@{{ owner.username }}")
|
||||
(text "{{ self::avatar(username=owner.username, size=\"32px\") }}"))
|
||||
(div
|
||||
("class" "flex flex-col")
|
||||
(text "{{ self::full_username(user=owner) }}")
|
||||
(div
|
||||
("class" "flex items-center gap-2")
|
||||
; read status
|
||||
(text "{% if user.id in letter.read_by -%}")
|
||||
(div ("class" "flex items-center green") (icon (text "mail-check")))
|
||||
(text "{% else %}")
|
||||
(div ("class" "flex items-center") (icon (text "mail")))
|
||||
(text "{%- endif %}")
|
||||
|
||||
; subject
|
||||
(a ("class" "flush") ("href" "/mail/letter/{{ letter.id }}") (b (text "{{ letter.subject }}"))))))
|
||||
(text "{%- endmacro %}")
|
||||
|
||||
(text "{% macro letter(letter, owner, show_subject=true) -%}")
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(text "{% if show_subject -%}")
|
||||
(div
|
||||
("class" "card flex gap-2 flex-row")
|
||||
(a
|
||||
("href" "/@{{ owner.username }}")
|
||||
(text "{{ self::avatar(username=owner.username, size=\"32px\") }}"))
|
||||
(div
|
||||
("class" "flex flex-col")
|
||||
(text "{{ self::full_username(user=owner) }}")
|
||||
(span
|
||||
(b (text "{{ letter.subject }}"))
|
||||
(text "{% if letter.replying_to -%}")
|
||||
(a
|
||||
("href" "/mail/letter/{{ letter.replying_to }}")
|
||||
(text " (up)"))
|
||||
(text "{%- endif %}"))
|
||||
(div
|
||||
("class" "flex flex-wrap gap-2")
|
||||
(text "{% for receiver in letter.receivers %}")
|
||||
(a
|
||||
("href" "/api/v1/auth/user/find/{{ receiver }}")
|
||||
(text "{{ components::avatar(username=receiver, selector_type=\"id\", size=\"18px\") }}"))
|
||||
(text "{%- endfor %}"))))
|
||||
(text "{% else %}")
|
||||
(div
|
||||
("class" "card small flex gap-2 flex-row")
|
||||
(a
|
||||
("href" "/@{{ owner.username }}")
|
||||
(text "{{ self::avatar(username=owner.username, size=\"24px\") }}"))
|
||||
(text "{{ self::full_username(user=owner) }}"))
|
||||
(text "{%- endif %}")
|
||||
|
||||
(div
|
||||
("class" "card flex flex-col gap-2")
|
||||
(text "{{ letter.content|markdown|safe }}")
|
||||
(hr)
|
||||
(div
|
||||
("class" "flex gap-2 items-center")
|
||||
(a
|
||||
("class" "button small lowered")
|
||||
("href" "/mail/compose?receivers={{ owner.username }}&subject=Re%3A%20{{ letter.subject }}&replying_to={{ letter.id }}")
|
||||
("title" "Reply")
|
||||
(icon (text "reply")))
|
||||
(a
|
||||
("class" "button small lowered")
|
||||
("href" "/mail/compose?receivers={% for receiver in letter.receivers %},id%3A{{ receiver }}{% endfor %}&subject=Re%3A%20{{ letter.subject }}&replying_to={{ letter.id }}")
|
||||
("title" "Reply all")
|
||||
(icon (text "reply-all")))
|
||||
(text "{% if user and letter.owner == user.id -%}")
|
||||
(button
|
||||
("class" "small lowered red")
|
||||
("onclick" "delete_letter('{{ letter.id }}')")
|
||||
("title" "Delete")
|
||||
(icon (text "trash")))
|
||||
(text "{%- endif %}"))))
|
||||
(text "{%- endmacro %}")
|
||||
|
|
|
@ -76,6 +76,11 @@
|
|||
("title" "Chats")
|
||||
(icon (text "message-circle"))
|
||||
(str (text "communities:label.chats")))
|
||||
(a
|
||||
("href" "/mail")
|
||||
("title" "Mail")
|
||||
(icon (text "mail"))
|
||||
(str (text "general:link.mail")))
|
||||
(a
|
||||
("href" "/journals/0/0")
|
||||
(icon (text "notebook"))
|
||||
|
|
139
crates/app/src/public/html/mail/compose.lisp
Normal file
139
crates/app/src/public/html/mail/compose.lisp
Normal file
|
@ -0,0 +1,139 @@
|
|||
(text "{% extends \"root.html\" %} {% block head %}")
|
||||
(title
|
||||
(text "Compose letter - {{ config.name }}"))
|
||||
|
||||
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
|
||||
(main
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card small items-center gap-2 flex justify-between")
|
||||
(div
|
||||
("class" "flex gap-2 items-center")
|
||||
(icon (text "mail-plus"))
|
||||
(str (text "mail:label.compose")))
|
||||
|
||||
(button
|
||||
("onclick" "window.history.back()")
|
||||
("class" "lowered small")
|
||||
(icon (text "arrow-left"))
|
||||
(str (text "general:action.back"))))
|
||||
|
||||
(form
|
||||
("class" "card flex flex-col gap-2")
|
||||
("onsubmit" "create_letter_from_form(event)")
|
||||
(div
|
||||
("class" "flex flex-col gap-1")
|
||||
(span
|
||||
(b (str (text "mail:label.receivers"))))
|
||||
(div
|
||||
("class" "flex flex-wrap gap-2 small card lowered")
|
||||
(div ("id" "receivers") ("class" "flex flex-wrap gap-2"))
|
||||
(button
|
||||
("class" "small tiny big_icon square raised")
|
||||
("onclick" "add_receiver()")
|
||||
("type" "button")
|
||||
(icon (text "plus")))))
|
||||
|
||||
(div
|
||||
("class" "flex flex-col gap-1")
|
||||
(label
|
||||
("for" "subject")
|
||||
(b (str (text "mail:label.subject"))))
|
||||
(input
|
||||
("type" "text")
|
||||
("placeholder" "subject")
|
||||
("required" "")
|
||||
("name" "subject")
|
||||
("id" "subject")))
|
||||
|
||||
(div
|
||||
("class" "flex flex-col gap-1")
|
||||
(label
|
||||
("for" "content")
|
||||
(b (str (text "mail:label.content"))))
|
||||
(textarea
|
||||
("placeholder" "content")
|
||||
("required" "")
|
||||
("name" "content")
|
||||
("id" "content")))
|
||||
|
||||
(button
|
||||
(icon (text "send-horizontal"))
|
||||
(str (text "mail:action.send"))))))
|
||||
|
||||
(script
|
||||
(text "globalThis.RECEIVERS = [];
|
||||
globalThis.SEARCH_PARAMS = new URLSearchParams(window.location.search);
|
||||
|
||||
globalThis.add_receiver = async () => {
|
||||
const username = await trigger(\"atto::prompt\", [\"Username:\"]);
|
||||
if (!username) {
|
||||
return;
|
||||
}
|
||||
|
||||
RECEIVERS.push(username);
|
||||
render_receivers();
|
||||
}
|
||||
|
||||
globalThis.remove_receiver = (username) => {
|
||||
RECEIVERS.splice(RECEIVERS.indexOf(username), 1);
|
||||
render_receivers();
|
||||
}
|
||||
|
||||
globalThis.render_receivers = () => {
|
||||
const element = document.getElementById(\"receivers\");
|
||||
element.innerHTML = \"\";
|
||||
|
||||
for (let receiver of RECEIVERS) {
|
||||
const is_id = receiver.startsWith(\"id:\");
|
||||
receiver = receiver.replaceAll(\"<\", \"<\").replaceAll(\">\", \">\").replace(\"id:\", \"\");
|
||||
element.innerHTML += `<button class=\"small lowered\" onclick=\"remove_receiver('${receiver}')\" type=\"button\">
|
||||
<img class=\"avatar\" style=\"--size: 18px\" src=\"/api/v1/auth/user/${receiver}/avatar?selector_type=${is_id ? \"id\" : \"username\"}\" />
|
||||
<span>${is_id ? \"...\" : receiver}</span>
|
||||
</button>`;
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.create_letter_from_form = async (e) => {
|
||||
e.preventDefault();
|
||||
await trigger(\"atto::debounce\", [\"letters::create\"]);
|
||||
|
||||
fetch(\"/api/v1/letters\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: e.target.content.value.trim(),
|
||||
subject: e.target.subject.value.trim(),
|
||||
receivers: RECEIVERS,
|
||||
replying_to: SEARCH_PARAMS.get(\"replying_to\") || \"0\",
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
trigger(\"atto::toast\", [\"error\", res.message]);
|
||||
} else {
|
||||
e.target.reset();
|
||||
window.location.href = `/mail/letter/${res.payload}`;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (SEARCH_PARAMS.get(\"receivers\")) {
|
||||
let r = SEARCH_PARAMS.get(\"receivers\");
|
||||
|
||||
if (r.startsWith(\",\")) {
|
||||
r = r.replace(\",\", \"\");
|
||||
}
|
||||
|
||||
RECEIVERS = r.split(\",\");
|
||||
render_receivers();
|
||||
}
|
||||
|
||||
if (SEARCH_PARAMS.get(\"subject\")) {
|
||||
document.getElementById(\"subject\").value = SEARCH_PARAMS.get(\"subject\");
|
||||
}"))
|
||||
(text "{% endblock %}")
|
49
crates/app/src/public/html/mail/letter.lisp
Normal file
49
crates/app/src/public/html/mail/letter.lisp
Normal file
|
@ -0,0 +1,49 @@
|
|||
(text "{% extends \"root.html\" %} {% block head %}")
|
||||
(title
|
||||
(text "Letter - {{ config.name }}"))
|
||||
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
|
||||
(main
|
||||
("class" "flex flex-col gap-2")
|
||||
(text "{{ components::letter(letter=letter, owner=owner) }}")
|
||||
|
||||
(text "{% for letter in replies %}")
|
||||
(text "{{ components::letter(letter=letter[1], owner=letter[0], show_subject=false) }}")
|
||||
(text "{%- endfor %}")
|
||||
|
||||
(text "{{ components::pagination(page=page, items=replies|length) }}"))
|
||||
|
||||
(script
|
||||
(text "globalThis.delete_letter = async (id) => {
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you would like to do this?\",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/api/v1/letters/${id}`, {
|
||||
method: \"DELETE\",
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
fetch(\"/api/v1/letters/{{ letter.id }}/read\", {
|
||||
method: \"POST\",
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
}
|
||||
});"))
|
||||
(text "{% endblock %}")
|
43
crates/app/src/public/html/mail/received.lisp
Normal file
43
crates/app/src/public/html/mail/received.lisp
Normal file
|
@ -0,0 +1,43 @@
|
|||
(text "{% extends \"root.html\" %} {% block head %}")
|
||||
(title
|
||||
(text "Received mail - {{ config.name }}"))
|
||||
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
|
||||
(main
|
||||
("class" "flex flex-col gap-2")
|
||||
(div
|
||||
("class" "pillmenu")
|
||||
(a
|
||||
("href" "/mail")
|
||||
("class" "active")
|
||||
(str (text "mail:label.received")))
|
||||
(a
|
||||
("href" "/mail/sent")
|
||||
(str (text "mail:label.sent"))))
|
||||
|
||||
; letters
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card small flex items-center justify-between gap-2")
|
||||
(div
|
||||
("class" "flex items-center gap-2")
|
||||
(icon (text "mailbox"))
|
||||
(str (text "mail:label.received")))
|
||||
(a
|
||||
("href" "/mail/compose")
|
||||
("class" "button small lowered")
|
||||
(icon (text "plus"))
|
||||
(str (text "mail:label.compose"))))
|
||||
(div
|
||||
("class" "card flex flex-col gap-2")
|
||||
(text "{% for letter in list %}")
|
||||
(text "{{ components::letter_listing(letter=letter[1], owner=letter[0]) }}")
|
||||
(text "{% endfor %}")
|
||||
|
||||
; pagination
|
||||
(text "{% if list|length == 0 -%}")
|
||||
(i ("class" "fade") (text "Nothing yet!"))
|
||||
(text "{% else %}")
|
||||
(text "{{ components::pagination(page=page, items=list|length) }}")
|
||||
(text "{%- endif %}"))))
|
||||
(text "{% endblock %}")
|
43
crates/app/src/public/html/mail/sent.lisp
Normal file
43
crates/app/src/public/html/mail/sent.lisp
Normal file
|
@ -0,0 +1,43 @@
|
|||
(text "{% extends \"root.html\" %} {% block head %}")
|
||||
(title
|
||||
(text "Sent mail - {{ config.name }}"))
|
||||
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
|
||||
(main
|
||||
("class" "flex flex-col gap-2")
|
||||
(div
|
||||
("class" "pillmenu")
|
||||
(a
|
||||
("href" "/mail")
|
||||
(str (text "mail:label.received")))
|
||||
(a
|
||||
("href" "/mail/sent")
|
||||
("class" "active")
|
||||
(str (text "mail:label.sent"))))
|
||||
|
||||
; letters
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card small flex items-center justify-between gap-2")
|
||||
(div
|
||||
("class" "flex items-center gap-2")
|
||||
(icon (text "mailbox"))
|
||||
(str (text "mail:label.sent")))
|
||||
(a
|
||||
("href" "/mail/compose")
|
||||
("class" "button small lowered")
|
||||
(icon (text "plus"))
|
||||
(str (text "mail:label.compose"))))
|
||||
(div
|
||||
("class" "card flex flex-col gap-2")
|
||||
(text "{% for letter in list %}")
|
||||
(text "{{ components::letter_listing(letter=letter[1], owner=letter[0]) }}")
|
||||
(text "{% endfor %}")
|
||||
|
||||
; pagination
|
||||
(text "{% if list|length == 0 -%}")
|
||||
(i ("class" "fade") (text "Nothing yet!"))
|
||||
(text "{% else %}")
|
||||
(text "{{ components::pagination(page=page, items=list|length) }}")
|
||||
(text "{%- endif %}"))))
|
||||
(text "{% endblock %}")
|
|
@ -7,7 +7,7 @@
|
|||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card")
|
||||
("class" "card small")
|
||||
(b (text "Error 😦")))
|
||||
|
||||
(div
|
||||
|
|
|
@ -252,13 +252,20 @@
|
|||
(text "{{ icon \"shield-off\" }}")
|
||||
(span
|
||||
(text "{{ text \"auth:action.unblock\" }}")))
|
||||
(text "{%- endif %} {% if not user.settings.private_chats or is_following_you %}")
|
||||
(text "{%- endif %} {% if not profile.settings.private_chats or is_following_you %}")
|
||||
(button
|
||||
("onclick" "create_group_chat()")
|
||||
("class" "lowered")
|
||||
(text "{{ icon \"message-circle\" }}")
|
||||
(span
|
||||
(text "{{ text \"auth:action.message\" }}")))
|
||||
(text "{%- endif %} {% if not profile.settings.private_mails or is_following_you %}")
|
||||
(a
|
||||
("href" "/mail/compose?receivers={{ profile.username }}")
|
||||
("class" "button lowered")
|
||||
(icon (text "mail-plus"))
|
||||
(span
|
||||
(str (text "mail:action.send_mail"))))
|
||||
(text "{%- endif %} {% if is_helper -%}")
|
||||
(a
|
||||
("href" "/mod_panel/profile/{{ profile.id }}")
|
||||
|
|
|
@ -1700,6 +1700,7 @@
|
|||
[\"private_last_seen\", true],
|
||||
[\"private_communities\", true],
|
||||
[\"private_chats\", true],
|
||||
[\"private_mails\", true],
|
||||
[\"require_account\", true],
|
||||
];
|
||||
|
||||
|
@ -1830,6 +1831,14 @@
|
|||
\"{{ profile.settings.private_chats }}\",
|
||||
\"checkbox\",
|
||||
],
|
||||
[
|
||||
[
|
||||
\"private_mails\",
|
||||
\"Only allow users I'm following to add send me mail\",
|
||||
],
|
||||
\"{{ profile.settings.private_mails }}\",
|
||||
\"checkbox\",
|
||||
],
|
||||
[
|
||||
[
|
||||
\"private_communities\",
|
||||
|
|
|
@ -1,16 +1,27 @@
|
|||
use axum::{response::IntoResponse, Extension, Json, extract::Path};
|
||||
use axum::{
|
||||
extract::{Path, Query},
|
||||
response::IntoResponse,
|
||||
Extension, Json,
|
||||
};
|
||||
use tetratto_core::model::{auth::Notification, mail::Letter, oauth, ApiReturn, Error};
|
||||
use crate::{get_user_from_token, State, cookie::CookieJar};
|
||||
use crate::{cookie::CookieJar, get_user_from_token, routes::pages::PaginatedQuery, State};
|
||||
use super::CreateLetter;
|
||||
|
||||
pub async fn list_received_request(jar: CookieJar, data: Extension<State>) -> impl IntoResponse {
|
||||
pub async fn list_received_request(
|
||||
jar: CookieJar,
|
||||
data: Extension<State>,
|
||||
Query(props): Query<PaginatedQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadLetters) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
let letters = match data.get_received_letters_by_user(user.id).await {
|
||||
let letters = match data
|
||||
.get_received_letters_by_user(user.id, 12, props.page)
|
||||
.await
|
||||
{
|
||||
Ok(l) => l,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
@ -22,14 +33,18 @@ pub async fn list_received_request(jar: CookieJar, data: Extension<State>) -> im
|
|||
})
|
||||
}
|
||||
|
||||
pub async fn list_sent_request(jar: CookieJar, data: Extension<State>) -> impl IntoResponse {
|
||||
pub async fn list_sent_request(
|
||||
jar: CookieJar,
|
||||
data: Extension<State>,
|
||||
Query(props): Query<PaginatedQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadLetters) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
let letters = match data.get_letters_by_user(user.id).await {
|
||||
let letters = match data.get_letters_by_user(user.id, 12, props.page).await {
|
||||
Ok(l) => l,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
@ -92,7 +107,7 @@ pub async fn delete_request(
|
|||
pub async fn create_request(
|
||||
jar: CookieJar,
|
||||
data: Extension<State>,
|
||||
Json(props): Json<CreateLetter>,
|
||||
Json(mut props): Json<CreateLetter>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateLetters) {
|
||||
|
@ -100,10 +115,51 @@ pub async fn create_request(
|
|||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
// check receivers
|
||||
props.receivers.dedup();
|
||||
let mut receivers = Vec::new();
|
||||
|
||||
if props.receivers.len() < 1 {
|
||||
return Json(Error::DataTooShort("receivers".to_string()).into());
|
||||
} else if props.receivers.len() > 10 {
|
||||
return Json(Error::DataTooLong("receivers".to_string()).into());
|
||||
}
|
||||
|
||||
for receiver in &props.receivers {
|
||||
let other_user = match if receiver.starts_with("id:") {
|
||||
data.get_user_by_id(match receiver.replace("id:", "").parse() {
|
||||
Ok(x) => x,
|
||||
Err(_) => continue,
|
||||
})
|
||||
.await
|
||||
} else {
|
||||
data.get_user_by_username(receiver).await
|
||||
} {
|
||||
Ok(ua) => ua,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
if crate::check_user_is_blocked!(data, user, other_user) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if other_user.settings.private_mails
|
||||
&& data
|
||||
.get_userfollow_by_initiator_receiver(other_user.id, user.id)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
receivers.push(other_user.id);
|
||||
}
|
||||
|
||||
// ...
|
||||
match data
|
||||
.create_letter(Letter::new(
|
||||
user.id,
|
||||
props.receivers,
|
||||
receivers,
|
||||
props.subject,
|
||||
props.content,
|
||||
match props.replying_to.parse() {
|
||||
|
@ -120,7 +176,7 @@ pub async fn create_request(
|
|||
.create_notification(Notification::new(
|
||||
"You've got mail!".to_string(),
|
||||
format!(
|
||||
"[@{}](/api/v1/auth/user/find/{}) has sent you a [letter](/mail/{}).",
|
||||
"[@{}](/api/v1/auth/user/find/{}) has sent you a [letter](/mail/letter/{}).",
|
||||
user.username, user.id, l.id
|
||||
),
|
||||
*x,
|
||||
|
@ -135,7 +191,7 @@ pub async fn create_request(
|
|||
Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Success".to_string(),
|
||||
payload: Some(l),
|
||||
payload: Some(l.id.to_string()),
|
||||
})
|
||||
}
|
||||
Err(e) => return Json(e.into()),
|
||||
|
@ -163,7 +219,11 @@ pub async fn add_read_request(
|
|||
}
|
||||
|
||||
if letter.read_by.contains(&user.id) {
|
||||
return Json(Error::MiscError("Already marked as read".to_string()).into());
|
||||
return Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Already marked as read".to_string(),
|
||||
payload: (),
|
||||
});
|
||||
}
|
||||
|
||||
letter.read_by.push(user.id);
|
||||
|
|
|
@ -1219,7 +1219,7 @@ pub struct QueryAppData {
|
|||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateLetter {
|
||||
pub receivers: Vec<usize>,
|
||||
pub receivers: Vec<String>,
|
||||
pub subject: String,
|
||||
pub content: String,
|
||||
pub replying_to: String,
|
||||
|
|
156
crates/app/src/routes/pages/mail.rs
Normal file
156
crates/app/src/routes/pages/mail.rs
Normal file
|
@ -0,0 +1,156 @@
|
|||
use axum::{
|
||||
extract::{Query, Path},
|
||||
response::{Html, IntoResponse},
|
||||
Extension,
|
||||
};
|
||||
use crate::cookie::CookieJar;
|
||||
use tetratto_core::model::Error;
|
||||
use crate::{assets::initial_context, get_lang, get_user_from_token, State};
|
||||
use super::{render_error, PaginatedQuery};
|
||||
|
||||
/// `/mail`
|
||||
pub async fn received_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
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 list = match data
|
||||
.0
|
||||
.get_received_letters_by_user(user.id, 12, props.page)
|
||||
.await
|
||||
{
|
||||
Ok(x) => match data.0.fill_letters(x).await {
|
||||
Ok(x) => x,
|
||||
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)),
|
||||
};
|
||||
|
||||
let lang = get_lang!(jar, data.0);
|
||||
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
|
||||
|
||||
context.insert("list", &list);
|
||||
context.insert("page", &props.page);
|
||||
|
||||
// return
|
||||
Ok(Html(data.1.render("mail/received.html", &context).unwrap()))
|
||||
}
|
||||
|
||||
/// `/mail/sent`
|
||||
pub async fn sent_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
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 list = match data.0.get_letters_by_user(user.id, 12, props.page).await {
|
||||
Ok(x) => match data.0.fill_letters(x).await {
|
||||
Ok(x) => x,
|
||||
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)),
|
||||
};
|
||||
|
||||
let lang = get_lang!(jar, data.0);
|
||||
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
|
||||
|
||||
context.insert("list", &list);
|
||||
context.insert("page", &props.page);
|
||||
|
||||
// return
|
||||
Ok(Html(data.1.render("mail/sent.html", &context).unwrap()))
|
||||
}
|
||||
|
||||
/// `/mail/compose`
|
||||
pub async fn compose_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
) -> 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 lang = get_lang!(jar, data.0);
|
||||
let context = initial_context(&data.0.0.0, lang, &Some(user)).await;
|
||||
|
||||
// return
|
||||
Ok(Html(data.1.render("mail/compose.html", &context).unwrap()))
|
||||
}
|
||||
|
||||
/// `/mail/letter`
|
||||
pub async fn letter_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<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 letter = match data.0.get_letter_by_id(id).await {
|
||||
Ok(l) => l,
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
|
||||
};
|
||||
|
||||
if !letter.can_read(&user) {
|
||||
return Err(Html(
|
||||
render_error(Error::NotAllowed, &jar, &data, &None).await,
|
||||
));
|
||||
}
|
||||
|
||||
let owner = match data.0.get_user_by_id(letter.owner).await {
|
||||
Ok(l) => l,
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
|
||||
};
|
||||
|
||||
let replies = match data.0.get_letters_by_replying_to(id, 12, props.page).await {
|
||||
Ok(x) => match data.0.fill_letters(x).await {
|
||||
Ok(x) => x,
|
||||
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)),
|
||||
};
|
||||
|
||||
let lang = get_lang!(jar, data.0);
|
||||
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
|
||||
|
||||
context.insert("letter", &letter);
|
||||
context.insert("owner", &owner);
|
||||
context.insert("replies", &replies);
|
||||
context.insert("page", &props.page);
|
||||
|
||||
// return
|
||||
Ok(Html(data.1.render("mail/letter.html", &context).unwrap()))
|
||||
}
|
|
@ -5,6 +5,7 @@ pub mod developer;
|
|||
pub mod forge;
|
||||
pub mod journals;
|
||||
pub mod littleweb;
|
||||
pub mod mail;
|
||||
pub mod marketplace;
|
||||
pub mod misc;
|
||||
pub mod mod_panel;
|
||||
|
@ -17,10 +18,7 @@ use axum::{
|
|||
};
|
||||
use crate::cookie::CookieJar;
|
||||
use serde::Deserialize;
|
||||
use tetratto_core::{
|
||||
model::{Error, auth::User},
|
||||
};
|
||||
|
||||
use tetratto_core::model::{Error, auth::User};
|
||||
use crate::{assets::initial_context, get_lang, InnerState};
|
||||
|
||||
pub fn routes() -> Router {
|
||||
|
@ -160,6 +158,11 @@ pub fn routes() -> Router {
|
|||
"/settings/seller",
|
||||
get(marketplace::seller_settings_request),
|
||||
)
|
||||
// mail
|
||||
.route("/mail", get(mail::received_request))
|
||||
.route("/mail/sent", get(mail::sent_request))
|
||||
.route("/mail/compose", get(mail::compose_request))
|
||||
.route("/mail/letter/{id}", get(mail::letter_request))
|
||||
}
|
||||
|
||||
pub fn lw_routes() -> Router {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
[package]
|
||||
name = "tetratto-core"
|
||||
description = "The core behind Tetratto"
|
||||
version = "12.0.2"
|
||||
version = "13.0.0"
|
||||
edition = "2024"
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
|
|
|
@ -388,6 +388,7 @@ fn default_banned_usernames() -> Vec<String> {
|
|||
"app".to_string(),
|
||||
"services".to_string(),
|
||||
"domains".to_string(),
|
||||
"mail".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
@ -5,5 +5,6 @@ CREATE TABLE IF NOT EXISTS letters (
|
|||
receivers TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
read_by TEXT NOT NULL
|
||||
read_by TEXT NOT NULL,
|
||||
replying_to BIGINT NOT NULL
|
||||
)
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use crate::model::{auth::User, mail::Letter, permissions::SecondaryPermission, Error, Result};
|
||||
use crate::{auto_method, DataManager};
|
||||
use oiseau::{cache::Cache, execute, get, params, query_rows, PostgresRow};
|
||||
|
@ -13,7 +15,7 @@ impl DataManager {
|
|||
subject: get!(x->4(String)),
|
||||
content: get!(x->5(String)),
|
||||
read_by: serde_json::from_str(&get!(x->6(String))).unwrap(),
|
||||
replying_to: get!(x->7(i32)) as usize,
|
||||
replying_to: get!(x->7(i64)) as usize,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -23,7 +25,14 @@ impl DataManager {
|
|||
///
|
||||
/// # Arguments
|
||||
/// * `id` - the ID of the user to fetch letters for
|
||||
pub async fn get_letters_by_user(&self, id: usize) -> Result<Vec<Letter>> {
|
||||
/// * `batch` - the limit of items in each page
|
||||
/// * `page` - the page number
|
||||
pub async fn get_letters_by_user(
|
||||
&self,
|
||||
id: usize,
|
||||
batch: usize,
|
||||
page: usize,
|
||||
) -> Result<Vec<Letter>> {
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||
|
@ -31,8 +40,8 @@ impl DataManager {
|
|||
|
||||
let res = query_rows!(
|
||||
&conn,
|
||||
"SELECT * FROM letters WHERE owner = $1 ORDER BY created DESC",
|
||||
&[&(id as i64)],
|
||||
"SELECT * FROM letters WHERE owner = $1 ORDER BY created DESC LIMIT $2 OFFSET $3",
|
||||
&[&(id as i64), &(batch as i64), &((page * batch) as i64)],
|
||||
|x| { Self::get_letter_from_row(x) }
|
||||
);
|
||||
|
||||
|
@ -47,7 +56,14 @@ impl DataManager {
|
|||
///
|
||||
/// # Arguments
|
||||
/// * `id` - the ID of the user to fetch letters for
|
||||
pub async fn get_received_letters_by_user(&self, id: usize) -> Result<Vec<Letter>> {
|
||||
/// * `batch` - the limit of items in each page
|
||||
/// * `page` - the page number
|
||||
pub async fn get_received_letters_by_user(
|
||||
&self,
|
||||
id: usize,
|
||||
batch: usize,
|
||||
page: usize,
|
||||
) -> Result<Vec<Letter>> {
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||
|
@ -55,8 +71,12 @@ impl DataManager {
|
|||
|
||||
let res = query_rows!(
|
||||
&conn,
|
||||
"SELECT * FROM letters WHERE receivers LIKE $1 ORDER BY created DESC",
|
||||
&[&format!("%\"{id}\"%")],
|
||||
"SELECT * FROM letters WHERE receivers LIKE $1 ORDER BY created DESC LIMIT $2 OFFSET $3",
|
||||
&[
|
||||
&format!("%{id}%"),
|
||||
&(batch as i64),
|
||||
&((page * batch) as i64)
|
||||
],
|
||||
|x| { Self::get_letter_from_row(x) }
|
||||
);
|
||||
|
||||
|
@ -71,7 +91,14 @@ impl DataManager {
|
|||
///
|
||||
/// # Arguments
|
||||
/// * `id` - the ID of the letter to fetch letters for
|
||||
pub async fn get_letters_by_replying_to(&self, id: usize) -> Result<Vec<Letter>> {
|
||||
/// * `batch` - the limit of items in each page
|
||||
/// * `page` - the page number
|
||||
pub async fn get_letters_by_replying_to(
|
||||
&self,
|
||||
id: usize,
|
||||
batch: usize,
|
||||
page: usize,
|
||||
) -> Result<Vec<Letter>> {
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||
|
@ -79,8 +106,8 @@ impl DataManager {
|
|||
|
||||
let res = query_rows!(
|
||||
&conn,
|
||||
"SELECT * FROM letters WHERE replying_to = $1 ORDER BY created DESC",
|
||||
&[&(id as i64)],
|
||||
"SELECT * FROM letters WHERE replying_to = $1 ORDER BY created DESC LIMIT $2 OFFSET $3",
|
||||
&[&(id as i64), &(batch as i64), &((page * batch) as i64)],
|
||||
|x| { Self::get_letter_from_row(x) }
|
||||
);
|
||||
|
||||
|
@ -91,6 +118,24 @@ impl DataManager {
|
|||
Ok(res.unwrap())
|
||||
}
|
||||
|
||||
/// Fill a list of letters with their owner.
|
||||
pub async fn fill_letters(&self, letters: Vec<Letter>) -> Result<Vec<(User, Letter)>> {
|
||||
let mut seen_users: HashMap<usize, User> = HashMap::new();
|
||||
let mut out = Vec::new();
|
||||
|
||||
for letter in letters {
|
||||
out.push(if let Some(ua) = seen_users.get(&letter.owner) {
|
||||
(ua.to_owned(), letter)
|
||||
} else {
|
||||
let user = self.get_user_by_id(letter.owner).await?;
|
||||
seen_users.insert(letter.owner, user.clone());
|
||||
(user, letter)
|
||||
})
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Create a new letter in the database.
|
||||
///
|
||||
/// # Arguments
|
||||
|
@ -109,6 +154,12 @@ impl DataManager {
|
|||
return Err(Error::DataTooLong("content".to_string()));
|
||||
}
|
||||
|
||||
if data.receivers.len() < 1 {
|
||||
return Err(Error::DataTooShort("receivers".to_string()));
|
||||
} else if data.receivers.len() > 10 {
|
||||
return Err(Error::DataTooLong("receivers".to_string()));
|
||||
}
|
||||
|
||||
// ...
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
|
|
|
@ -273,6 +273,9 @@ pub struct UserSettings {
|
|||
/// If other users that you aren't following can add you to chats.
|
||||
#[serde(default)]
|
||||
pub private_chats: bool,
|
||||
/// If other users that you aren't following can send you letters.
|
||||
#[serde(default)]
|
||||
pub private_mails: bool,
|
||||
/// The user's status. Shows over connection info.
|
||||
#[serde(default)]
|
||||
pub status: String,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue