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]]
name = "tawny"
version = "1.0.2"
version = "1.0.3"
dependencies = [
"ammonia",
"axum",

View file

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

View file

@ -5,6 +5,7 @@ const STATE = {
is_loading: false,
stream_element: null,
last_message_time: 0,
replying_to: undefined,
last_read_receipt_load: 0,
};
@ -144,6 +145,7 @@ function create_message(e) {
"body",
JSON.stringify({
content: e.target.content.value,
replying_to: STATE.replying_to,
}),
);
@ -154,6 +156,7 @@ function create_message(e) {
if (res.ok) {
e.target.reset();
document.getElementById("images_zone").classList.add("hidden");
clear_replying_to();
} else {
show_message(res.message, res.ok);
}
@ -395,3 +398,25 @@ function remove_file_from_picker(input_id, idx) {
// render
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")
(div ("ui_ident" "data_marker")))
(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
("class" "card flex flex_row items_center gap_2")
("onsubmit" "create_message(event)")

View file

@ -41,10 +41,11 @@
(text "{%- endif %}")
(text "{%- endmacro %}")
(text "{% macro message(message, is_pinned=false) -%}")
(text "{% macro message(message, replying_to=false, is_pinned=false, hide_actions=false) -%}")
(div
("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
("class" "dropdown hidden")
(button
@ -61,6 +62,11 @@
(text "pin"))
(text "{%- endif %}")
(button
("class" "button surface")
("onclick" "reply_to_message('{{ message.id }}')")
(text "reply"))
(text "{% if message.owner == user.id -%}")
(button
("class" "button surface")
@ -71,6 +77,7 @@
("onclick" "delete_message('{{ message.id }}')")
(text "delete"))
(text "{%- endif %}")))
(text "{%- endif %}")
(div
("class" "body no_p_margin")
@ -101,6 +108,12 @@
("href" "/@{{ message.owner }}?redirect=true")
("target" "_blank")
(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 "{% 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 "{{ 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 "{% 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 %}")
(div

View file

@ -22,6 +22,7 @@ impl DataManager {
chat: get!(x->4(i64)) as usize,
content: get!(x->5(String)),
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
pub async fn create_message(&self, data: Message) -> Result<Message> {
// 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()));
} else if data.content.len() > 2048 {
return Err(Error::DataTooLong("content".to_string()));
@ -105,7 +106,7 @@ impl DataManager {
let res = execute!(
&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![
&(data.id as i64),
&(data.created as i64),
@ -114,6 +115,7 @@ impl DataManager {
&(data.chat as i64),
&data.content,
&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,
chat BIGINT 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
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 content: String,
pub uploads: Vec<usize>,
pub replying_to: usize,
}
impl Message {
@ -83,6 +84,7 @@ impl Message {
chat,
content,
uploads,
replying_to: 0,
}
}
}

View file

@ -11,6 +11,7 @@ use tetratto_core::model::{ApiReturn, Error, permissions::FinePermission};
#[derive(Deserialize)]
pub struct CreateMessage {
pub content: String,
pub replying_to: Option<String>,
}
const MAXIMUM_UPLOAD_SIZE: usize = 4_194_304; // 4 MiB
@ -28,7 +29,7 @@ pub async fn create_request(
};
// 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());
} else if req.content.len() > 2048 {
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;
}
// ...
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
match data
.create_message(Message::new(
user.id,
chat.id,
req.content,
uploads.iter().map(|x| x.0.id).collect(),
))
.await
{
match data.create_message(msg).await {
Ok(x) => {
// store 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));
ctx.insert("message", &message);
ctx.insert("replying_to", &replying_to);
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
@ -179,7 +204,7 @@ pub async fn messages_request(
ctx.insert(
"last_message_time",
&match messages.last() {
Some(x) => x.created,
Some(x) => x.0.created,
None => 0,
},
);