diff --git a/README.md b/README.md index 8f9ea16..1fb7233 100644 --- a/README.md +++ b/README.md @@ -3,3 +3,7 @@ messaging service :) + +## Usage notes + +- For message uploads to work properly, you must symlink the `buckets` directory into the app's CWD diff --git a/app/.gitignore b/app/.gitignore index 5b117d6..0b55aa6 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -6,3 +6,4 @@ public/favicon.svg .env app.toml tetratto.toml +buckets diff --git a/app/public/messages.js b/app/public/messages.js index 603992b..ff00834 100644 --- a/app/public/messages.js +++ b/app/public/messages.js @@ -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 += + ``; + 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 }); +} diff --git a/app/public/style.css b/app/public/style.css index a18f77d..5a585be 100644 --- a/app/public/style.css +++ b/app/public/style.css @@ -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; +} diff --git a/app/templates_src/chat.lisp b/app/templates_src/chat.lisp index 248d12c..f276a60 100644 --- a/app/templates_src/chat.lisp +++ b/app/templates_src/chat.lisp @@ -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") diff --git a/app/templates_src/components.lisp b/app/templates_src/components.lisp index 81c6a61..186d91b 100644 --- a/app/templates_src/components.lisp +++ b/app/templates_src/components.lisp @@ -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") diff --git a/src/routes/api/messages.rs b/src/routes/api/messages.rs index 4d90326..7926e0e 100644 --- a/src/routes/api/messages.rs +++ b/src/routes/api/messages.rs @@ -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