add: images in messages for supporters

This commit is contained in:
trisua 2025-09-03 20:05:43 -04:00
parent 1c1eb3be5d
commit dfa1abe2d9
7 changed files with 116 additions and 4 deletions

View file

@ -3,3 +3,7 @@
messaging service :)
<https://tetratto.com/post/217811578144161792>
## Usage notes
- For message uploads to work properly, you must symlink the `buckets` directory into the app's CWD

1
app/.gitignore vendored
View file

@ -6,3 +6,4 @@ public/favicon.svg
.env
app.toml
tetratto.toml
buckets

View file

@ -132,6 +132,14 @@ function create_message(e) {
const body = new FormData();
// attach images
if (e.target.images) {
for (const file of e.target.images.files) {
body.append(file.name, file);
}
}
// add json body
body.append(
"body",
JSON.stringify({
@ -139,11 +147,13 @@ function create_message(e) {
}),
);
// send request
fetch(`/api/v1/messages/${STATE.chat_id}`, { method: "POST", body })
.then((res) => res.json())
.then((res) => {
if (res.ok) {
e.target.reset();
document.getElementById("images_zone").classList.add("hidden");
} else {
show_message(res.message, res.ok);
}
@ -350,3 +360,38 @@ function unpin_message(id) {
show_message(res.message, res.ok);
});
}
function display_pending_images(e) {
document.getElementById("images_zone").innerHTML = "";
document.getElementById("images_zone").classList.remove("hidden");
if (e.target.files.length < 1) {
document.getElementById("images_zone").classList.add("hidden");
return;
}
let idx = 0;
for (const file of e.target.files) {
document.getElementById("images_zone").innerHTML +=
`<button class="button surface" onclick="remove_file_from_picker('images', ${idx})">${file.name}</button>`;
idx += 1;
}
}
function remove_file_from_picker(input_id, idx) {
const input = document.getElementById(input_id);
const files = Array.from(input.files);
files.splice(idx - 1, 1);
// update files
const list = new DataTransfer();
for (item of files) {
list.items.add(item);
}
input.files = list.files;
// render
display_pending_images({ target: input });
}

View file

@ -822,3 +822,18 @@ menu.col {
.message:focus .dropdown.hidden {
display: flex !important;
}
.message img {
max-width: 50dvw;
max-height: 25dvh;
}
.message .upload {
border-radius: var(--radius);
box-shadow: var(--shadow-x-offset) var(--shadow-y-offset) var(--shadow-size)
var(--color-shadow);
}
.message .body p:last-of-type {
margin: 0 !important;
}

View file

@ -30,10 +30,38 @@
("style" "flex: 1 0 auto")
("id" "messages_stream")
(div ("ui_ident" "data_marker")))
(div ("id" "read_receipt_zone") ("class" "card") ("style" "min-height: 32.5px; position: sticky; bottom: 0")))
(div ("id" "read_receipt_zone") ("class" "card") ("style" "min-height: 32.5px; position: sticky; bottom: 0"))
(div ("id" "images_zone") ("class" "card hidden flex gap_2 flex_wrap")))
(form
("class" "card flex flex_row items_center gap_2")
("onsubmit" "create_message(event)")
(text "{% if user.permissions|has_supporter -%}")
(div
("class" "dropdown")
(button
("onclick" "open_dropdown(event)")
("exclude" "dropdown")
("class" "button icon_only big_icon")
("type" "button")
(text "{{ icon \"plus\" }}"))
(div
("class" "inner left")
(button
("class" "button")
("onclick" "document.getElementById('images').click()")
("type" "button")
(text "attach image"))))
(text "{%- endif %}")
(input
("type" "file")
("class" "hidden")
("accept" "image/*")
("id" "images")
("name" "images")
("multiple" "")
("onchange" "display_pending_images(event)"))
(input
("type" "text")
("class" "w_full")

View file

@ -75,7 +75,15 @@
(div
("class" "body no_p_margin")
("id" "{{ message.id }}_body")
(text "{{ message.content|markdown|safe }}"))
(text "{{ message.content|markdown|safe }}")
(div
("class" "flex flex_col gap_1 {% if message.uploads|length == 0 -%} hidden {%- endif %}")
(text "{% for upload in message.uploads -%}")
(a
("href" "{{ config.service_hosts.buckets }}/message_media/{{ upload }}")
("target" "_blank")
(img ("class" "upload") ("src" "{{ config.service_hosts.buckets }}/message_media/{{ upload }}") ("alt" "Media upload")))
(text "{%- endfor %}")))
(form
("class" "body hidden flex flex_row gap_ch")
("id" "{{ message.id }}_edit_area")

View file

@ -1,10 +1,12 @@
use std::time::Duration;
use crate::{State, get_user_from_token, markdown::render_markdown, model::Message};
use axum::{Extension, Json, body::Bytes, extract::Path, response::IntoResponse};
use axum_extra::extract::CookieJar;
use axum_image::{encode::save_webp_buffer, extract::JsonMultipart};
use buckets_core::model::{MediaType, MediaUpload};
use serde::Deserialize;
use tetratto_core::model::{ApiReturn, Error};
use tetratto_core::model::{ApiReturn, Error, permissions::FinePermission};
#[derive(Deserialize)]
pub struct CreateMessage {
@ -45,8 +47,16 @@ pub async fn create_request(
// create uploads
let mut uploads: Vec<(MediaUpload, Bytes)> = Vec::new();
if !user.permissions.check(FinePermission::SUPPORTER) && !byte_parts.is_empty() {
return Json(Error::RequiresSupporter.into());
}
if byte_parts.len() > 4 {
return Json(Error::MiscError("Too many images".to_string()).into());
}
for part in &byte_parts {
if part.len() < MAXIMUM_UPLOAD_SIZE {
if part.len() > MAXIMUM_UPLOAD_SIZE {
return Json(Error::FileTooLarge.into());
}
}
@ -56,6 +66,7 @@ pub async fn create_request(
MediaUpload::new(MediaType::Webp, user.id, "message_media".to_string()),
part,
));
tokio::time::sleep(Duration::from_millis(150)).await;
}
// create message