add: message replies

This commit is contained in:
trisua 2025-09-04 23:02:23 -04:00
parent dfa1abe2d9
commit ca1eca967c
13 changed files with 113 additions and 22 deletions

2
Cargo.lock generated
View file

@ -2998,7 +2998,7 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]] [[package]]
name = "tawny" name = "tawny"
version = "1.0.2" version = "1.0.3"
dependencies = [ dependencies = [
"ammonia", "ammonia",
"axum", "axum",

View file

@ -1,6 +1,6 @@
[package] [package]
name = "tawny" name = "tawny"
version = "1.0.2" version = "1.0.3"
edition = "2024" edition = "2024"
authors = ["trisuaso"] authors = ["trisuaso"]
repository = "https://trisua.com/t/tawny" repository = "https://trisua.com/t/tawny"

View file

@ -5,6 +5,7 @@ const STATE = {
is_loading: false, is_loading: false,
stream_element: null, stream_element: null,
last_message_time: 0, last_message_time: 0,
replying_to: undefined,
last_read_receipt_load: 0, last_read_receipt_load: 0,
}; };
@ -144,6 +145,7 @@ function create_message(e) {
"body", "body",
JSON.stringify({ JSON.stringify({
content: e.target.content.value, content: e.target.content.value,
replying_to: STATE.replying_to,
}), }),
); );
@ -154,6 +156,7 @@ function create_message(e) {
if (res.ok) { if (res.ok) {
e.target.reset(); e.target.reset();
document.getElementById("images_zone").classList.add("hidden"); document.getElementById("images_zone").classList.add("hidden");
clear_replying_to();
} else { } else {
show_message(res.message, res.ok); show_message(res.message, res.ok);
} }
@ -395,3 +398,25 @@ function remove_file_from_picker(input_id, idx) {
// render // render
display_pending_images({ target: input }); display_pending_images({ target: input });
} }
function reply_to_message(id) {
STATE.replying_to = id;
document.getElementById("replying_to_zone").classList.remove("hidden");
document.getElementById(`message_${id}`).classList.add("card");
document.getElementById(`message_${id}`).classList.add("surface");
scroll_bottom();
}
function clear_replying_to() {
if (STATE.replying_to) {
document
.getElementById(`message_${STATE.replying_to}`)
.classList.remove("card");
document
.getElementById(`message_${STATE.replying_to}`)
.classList.remove("surface");
}
STATE.replying_to = undefined;
document.getElementById("replying_to_zone").classList.add("hidden");
}

View file

@ -31,7 +31,13 @@
("id" "messages_stream") ("id" "messages_stream")
(div ("ui_ident" "data_marker"))) (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"))) (div ("id" "images_zone") ("class" "card hidden flex gap_2 flex_wrap"))
(div
("id" "replying_to_zone") ("class" "card hidden flex flex_col gap_1")
(div
("class" "flex items_center gap_ch")
(text "{{ icon \"reply\" }} Replying to message"))
(button ("class" "button surface red") ("onclick" "clear_replying_to()") (text "cancel"))))
(form (form
("class" "card flex flex_row items_center gap_2") ("class" "card flex flex_row items_center gap_2")
("onsubmit" "create_message(event)") ("onsubmit" "create_message(event)")

View file

@ -41,10 +41,11 @@
(text "{%- endif %}") (text "{%- endif %}")
(text "{%- endmacro %}") (text "{%- endmacro %}")
(text "{% macro message(message, is_pinned=false) -%}") (text "{% macro message(message, replying_to=false, is_pinned=false, hide_actions=false) -%}")
(div (div
("class" "flex w_full gap_ch message {%- if user.id == message.owner %} justify_right mine {%- endif %}") ("class" "flex w_full gap_ch message {%- if user.id == message.owner %} justify_right mine {%- endif %}")
("id" "message_{{ message.id }}") ("id" "{% if not hide_actions -%} message_{{ message.id }} {%- endif %}")
(text "{% if not hide_actions -%}")
(div (div
("class" "dropdown hidden") ("class" "dropdown hidden")
(button (button
@ -61,6 +62,11 @@
(text "pin")) (text "pin"))
(text "{%- endif %}") (text "{%- endif %}")
(button
("class" "button surface")
("onclick" "reply_to_message('{{ message.id }}')")
(text "reply"))
(text "{% if message.owner == user.id -%}") (text "{% if message.owner == user.id -%}")
(button (button
("class" "button surface") ("class" "button surface")
@ -71,6 +77,7 @@
("onclick" "delete_message('{{ message.id }}')") ("onclick" "delete_message('{{ message.id }}')")
(text "delete")) (text "delete"))
(text "{%- endif %}"))) (text "{%- endif %}")))
(text "{%- endif %}")
(div (div
("class" "body no_p_margin") ("class" "body no_p_margin")
@ -101,6 +108,12 @@
("href" "/@{{ message.owner }}?redirect=true") ("href" "/@{{ message.owner }}?redirect=true")
("target" "_blank") ("target" "_blank")
(text "{{ self::avatar(id=message.owner) }}"))) (text "{{ self::avatar(id=message.owner) }}")))
(text "{% if replying_to -%}")
(div
("style" "transform: scale(0.8); opacity: 75%; width: 110%")
(text "{{ self::message(message=replying_to, hide_actions=true) }}"))
(text "{%- endif %}")
(text "{%- endmacro %}") (text "{%- endmacro %}")
(text "{% macro theme(user, theme_preference) -%} {% if user %} {% if user.settings.theme_hue -%}") (text "{% macro theme(user, theme_preference) -%} {% if user %} {% if user.settings.theme_hue -%}")

View file

@ -1,2 +1,2 @@
(text "{%- import \"components.lisp\" as components -%}") (text "{%- import \"components.lisp\" as components -%}")
(text "{{ components::message(message=message) }}") (text "{{ components::message(message=message, replying_to=replying_to) }}")

View file

@ -1,6 +1,6 @@
(text "{%- import \"components.lisp\" as components -%}") (text "{%- import \"components.lisp\" as components -%}")
(text "{% for message in messages -%}") (text "{% for message in messages -%}")
(text "{{ components::message(message=message, is_pinned=message.id in pins) }}") (text "{{ components::message(message=message[0], replying_to=message[1], is_pinned=message[0].id in pins) }}")
(text "{%- endfor %}") (text "{%- endfor %}")
(div (div

View file

@ -22,6 +22,7 @@ impl DataManager {
chat: get!(x->4(i64)) as usize, chat: get!(x->4(i64)) as usize,
content: get!(x->5(String)), content: get!(x->5(String)),
uploads: serde_json::from_str(&get!(x->6(String))).unwrap(), uploads: serde_json::from_str(&get!(x->6(String))).unwrap(),
replying_to: get!(x->7(i64)) as usize,
} }
} }
@ -91,7 +92,7 @@ impl DataManager {
/// * `data` - a mock [`Message`] object to insert /// * `data` - a mock [`Message`] object to insert
pub async fn create_message(&self, data: Message) -> Result<Message> { pub async fn create_message(&self, data: Message) -> Result<Message> {
// check values // check values
if data.content.trim().len() < 2 { if data.content.trim().len() < 2 && data.uploads.is_empty() {
return Err(Error::DataTooShort("content".to_string())); return Err(Error::DataTooShort("content".to_string()));
} else if data.content.len() > 2048 { } else if data.content.len() > 2048 {
return Err(Error::DataTooLong("content".to_string())); return Err(Error::DataTooLong("content".to_string()));
@ -105,7 +106,7 @@ impl DataManager {
let res = execute!( let res = execute!(
&conn, &conn,
"INSERT INTO t_messages VALUES ($1, $2, $3, $4, $5, $6, $7)", "INSERT INTO t_messages VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
params![ params![
&(data.id as i64), &(data.id as i64),
&(data.created as i64), &(data.created as i64),
@ -114,6 +115,7 @@ impl DataManager {
&(data.chat as i64), &(data.chat as i64),
&data.content, &data.content,
&serde_json::to_string(&data.uploads).unwrap(), &serde_json::to_string(&data.uploads).unwrap(),
&(data.replying_to as i64)
] ]
); );

View file

@ -5,5 +5,6 @@ CREATE TABLE IF NOT EXISTS t_messages (
owner BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, owner BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
chat BIGINT NOT NULL, chat BIGINT NOT NULL,
content TEXT NOT NULL, content TEXT NOT NULL,
uploads TEXT NOT NULL uploads TEXT NOT NULL,
replying_to BIGINT NOT NULL
); );

View file

@ -1,2 +1,5 @@
-- chats pinned_messages -- chats pinned_messages
ALTER TABLE t_chats ADD COLUMN IF NOT EXISTS pinned_messages TEXT NOT NULL DEFAULT '[]'; ALTER TABLE t_chats ADD COLUMN IF NOT EXISTS pinned_messages TEXT NOT NULL DEFAULT '[]';
-- messages replying_to
ALTER TABLE t_messages ADD COLUMN IF NOT EXISTS replying_to BIGINT NOT NULL DEFAULT 0;

View file

@ -68,6 +68,7 @@ pub struct Message {
pub chat: usize, pub chat: usize,
pub content: String, pub content: String,
pub uploads: Vec<usize>, pub uploads: Vec<usize>,
pub replying_to: usize,
} }
impl Message { impl Message {
@ -83,6 +84,7 @@ impl Message {
chat, chat,
content, content,
uploads, uploads,
replying_to: 0,
} }
} }
} }

View file

@ -11,6 +11,7 @@ use tetratto_core::model::{ApiReturn, Error, permissions::FinePermission};
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct CreateMessage { pub struct CreateMessage {
pub content: String, pub content: String,
pub replying_to: Option<String>,
} }
const MAXIMUM_UPLOAD_SIZE: usize = 4_194_304; // 4 MiB const MAXIMUM_UPLOAD_SIZE: usize = 4_194_304; // 4 MiB
@ -28,7 +29,7 @@ pub async fn create_request(
}; };
// check fields // check fields
if req.content.trim().len() < 2 { if req.content.trim().len() < 2 && byte_parts.is_empty() {
return Json(Error::DataTooShort("content".to_string()).into()); return Json(Error::DataTooShort("content".to_string()).into());
} else if req.content.len() > 2048 { } else if req.content.len() > 2048 {
return Json(Error::DataTooLong("content".to_string()).into()); return Json(Error::DataTooLong("content".to_string()).into());
@ -69,16 +70,29 @@ pub async fn create_request(
tokio::time::sleep(Duration::from_millis(150)).await; tokio::time::sleep(Duration::from_millis(150)).await;
} }
// ...
let mut msg = Message::new(
user.id,
chat.id,
req.content,
uploads.iter().map(|x| x.0.id).collect(),
);
if let Some(replying_to) = req.replying_to {
let replying_to: usize = replying_to.parse().unwrap_or(0);
if replying_to != 0 {
let replying_to_msg = match data.get_message_by_id(replying_to).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
msg.replying_to = replying_to_msg.id;
}
}
// create message // create message
match data match data.create_message(msg).await {
.create_message(Message::new(
user.id,
chat.id,
req.content,
uploads.iter().map(|x| x.0.id).collect(),
))
.await
{
Ok(x) => { Ok(x) => {
// store uploads // store uploads
for (upload, part) in uploads { for (upload, part) in uploads {

View file

@ -104,8 +104,22 @@ pub async fn single_message_request(
} }
}; };
let replying_to = if message.replying_to != 0 {
match data.get_message_by_id(message.replying_to).await {
Ok(x) => Some(x),
Err(e) => {
return Err(render_error(e, tera, data.0.0.clone(), Some(user)).await);
}
}
} else {
None
};
let mut ctx = default_context(&data.0.0, &build_code, &Some(user)); let mut ctx = default_context(&data.0.0, &build_code, &Some(user));
ctx.insert("message", &message); ctx.insert("message", &message);
ctx.insert("replying_to", &replying_to);
Ok(Html(tera.render("message.lisp", &ctx).unwrap())) Ok(Html(tera.render("message.lisp", &ctx).unwrap()))
} }
@ -164,7 +178,18 @@ pub async fn messages_request(
} }
} }
y.push(z); let replying_to = if z.replying_to != 0 {
match data.get_message_by_id(z.replying_to).await {
Ok(x) => Some(x),
Err(e) => {
return Err(render_error(e, tera, data.0.0.clone(), Some(user)).await);
}
}
} else {
None
};
y.push((z, replying_to));
} }
y y
@ -179,7 +204,7 @@ pub async fn messages_request(
ctx.insert( ctx.insert(
"last_message_time", "last_message_time",
&match messages.last() { &match messages.last() {
Some(x) => x.created, Some(x) => x.0.created,
None => 0, None => 0,
}, },
); );