From b360c5e7370a41324436cf506af495c43c588550 Mon Sep 17 00:00:00 2001 From: trisua Date: Wed, 27 Aug 2025 20:22:12 -0400 Subject: [PATCH] add: working chat ui --- app/public/messages.js | 153 ++++++++++++++++++++++++++++ app/public/style.css | 21 ++++ app/templates_src/chat.lisp | 30 ++---- app/templates_src/components.lisp | 11 +- app/templates_src/login.lisp | 2 +- app/templates_src/message.lisp | 2 +- app/templates_src/messages.lisp | 13 ++- app/templates_src/read_receipt.lisp | 17 ++++ src/database/chats.rs | 2 +- src/database/messages.rs | 6 +- src/routes/api/chats.rs | 36 +++++++ src/routes/api/mod.rs | 8 +- src/routes/pages/chats.rs | 67 ++++++++---- src/routes/pages/mod.rs | 4 + 14 files changed, 319 insertions(+), 53 deletions(-) create mode 100644 app/public/messages.js create mode 100644 app/templates_src/read_receipt.lisp diff --git a/app/public/messages.js b/app/public/messages.js new file mode 100644 index 0000000..01d3a4d --- /dev/null +++ b/app/public/messages.js @@ -0,0 +1,153 @@ +const STATE = { + id: 0, + chat_id: "", + observer: null, + is_loading: false, + stream_element: null, + first_message_time: 0, +}; + +function create_streamer(chat_id, hook_element) { + STATE.chat_id = chat_id; + STATE.stream_element = hook_element.parentElement; + + STATE.observer = new IntersectionObserver( + () => { + load_messages(); + }, + { + root: STATE.stream_element, + rootMargin: "0px", + scrollMargin: "0px", + threshold: 1.0, + }, + ); + + STATE.observer.observe(hook_element); +} + +function load_messages() { + if (STATE.is_loading) { + return; + } + + STATE.is_loading = true; + STATE.id += 1; + + fetch( + `/chats/_templates/chat/${STATE.chat_id}/messages/before/${STATE.first_message_time}?use_id=${STATE.id}`, + ) + .then((res) => res.text()) + .then((res) => { + setTimeout(() => { + STATE.is_loading = false; + }, 2000); + + STATE.stream_element.innerHTML += res; + STATE.first_message_time = Number.parseInt( + document + .getElementById(`msgs_data_${STATE.id}`) + .getAttribute("data-first-message-time"), + ); + + // STATE.stream_element.scrollTo(0, STATE.stream_element.scrollHeight); + + if (document.getElementById(`msgs_quit_${STATE.id}`)) { + STATE.observer.disconnect(); + console.log("quit"); + } else { + STATE.observer.unobserve( + STATE.stream_element.querySelector( + "[ui_ident=data_marker]", + ), + ); + + const element = document.createElement("div"); + element.setAttribute("ui_ident", "data_marker"); + STATE.stream_element.append(element); + STATE.observer.observe(element); + } + }); +} + +function render_message(id) { + STATE.is_loading = true; + fetch(`/chats/_templates/message/${id}`) + .then((res) => res.text()) + .then((res) => { + STATE.is_loading = false; + STATE.stream_element.innerHTML = `${res}${STATE.stream_element.innerHTML}`; + mark_message_read(); + read_receipt(); + STATE.stream_element.scrollTo(0, STATE.stream_element.scrollHeight); + }); +} + +function sock_con() { + const socket = new WebSocket( + `//${window.location.origin.split("//")[1]}/api/v1/chats/${STATE.chat_id}/_connect`, + ); + + socket.addEventListener("message", async (event) => { + if (event.data === "Ping") { + return socket.send("Pong"); + } + + const msg = JSON.parse(event.data); + + if (msg.method === "MessageCreate") { + render_message(msg.body); + } else if (msg.method === "MessageDelete") { + if (document.getElementById(`message_${msg.body}`)) { + document.getElementById(`message_${msg.body}`).remove(); + } + } + }); +} + +function create_message(e) { + e.preventDefault(); + + const body = new FormData(); + + body.append( + "body", + JSON.stringify({ + content: e.target.content.value, + }), + ); + + fetch(`/api/v1/messages/${STATE.chat_id}`, { method: "POST", body }) + .then((res) => res.json()) + .then((res) => { + if (res.ok) { + e.target.reset(); + } else { + show_message(res.message, res.ok); + } + }); +} + +function mark_message_read() { + fetch(`/api/v1/chats/${STATE.chat_id}/read_message`, { + method: "POST", + }) + .then((res) => res.json()) + .then((res) => { + if (!res.ok) { + show_message(res.message, res.ok); + } + }); +} + +function read_receipt() { + if (document.getElementById("delivered_read_status")) { + document.getElementById("delivered_read_status").remove(); + } + + fetch(`/chats/_templates/chat/${STATE.chat_id}/read_receipt`) + .then((res) => res.text()) + .then((res) => { + STATE.stream_element.innerHTML = `${res}${STATE.stream_element.innerHTML}`; + }); +} diff --git a/app/public/style.css b/app/public/style.css index 78f1ca0..1fa3b85 100644 --- a/app/public/style.css +++ b/app/public/style.css @@ -746,3 +746,24 @@ menu.col { width: 25rem; max-width: 100%; } + +/* messages */ +.message { + align-items: flex-end; +} + +.message:not(.mine) { + flex-direction: row-reverse; + justify-content: flex-end; +} + +.message .inner { + padding: var(--pad-2) var(--pad-3); + background: var(--color-surface); + color: var(--color-text); +} + +.message.mine .inner { + background: var(--color-primary); + color: var(--color-text-primary); +} diff --git a/app/templates_src/chat.lisp b/app/templates_src/chat.lisp index 9761a2e..57ce425 100644 --- a/app/templates_src/chat.lisp +++ b/app/templates_src/chat.lisp @@ -19,30 +19,16 @@ ("style" "flex: 1 0 auto") (div ("class" "card flex flex_rev_col gap_2") - ("style" "flex: 1 0 auto") + ("style" "flex: 1 0 auto; max-height: 80dvh; overflow: auto") ("id" "messages_stream") - (text "{% if chat.last_message_created > 0 -%}") - (div - ("class" "flex gap_ch items_center") - ("id" "delivered_read_status") - (text "{% if chat.last_message_read_by|length <= 1 -%}") - ; just delivered - (text "{{ icon \"check\" }}") - (text "{{ chat.last_message_created|int|date(format=\"%Y-%m-%d %H:%M\", timezone=\"Etc/UTC\") }} UTC }}") - (text "{%- else -%}") - ; delivered and read by at least two people - (text "{{ icon \"check-check\" }}") - (text "{% for uid in chat.last_message_read_by -%}") - (text "{{ components::avatar(id=uid) }}") - (text "{%- endfor %}") - (text "{%- endif %}")) - (text "{%- endif %}") - (text "{% if messages|length == 0 -%}") (i ("class" "flex gap_ch items_center fade") (text "{{ icon \"star\" }} This is the start of the chat!")) - (text "{%- endif %}")) + (text "{%- endif %}") + + (div ("ui_ident" "data_marker"))) (form ("class" "card flex flex_row items_center gap_2") + ("onsubmit" "create_message(event)") (input ("type" "text") ("class" "w_full") @@ -53,6 +39,10 @@ ("class" "button") (text "{{ icon \"send\" }}")))) +(script ("src" "/public/messages.js")) (script - (text "")) + (text "create_streamer(\"{{ chat.id }}\", document.querySelector(\"[ui_ident=data_marker]\")); + sock_con(); + mark_message_read(); + read_receipt();")) (text "{% endblock %}") diff --git a/app/templates_src/components.lisp b/app/templates_src/components.lisp index 24fd8fe..048e06e 100644 --- a/app/templates_src/components.lisp +++ b/app/templates_src/components.lisp @@ -22,18 +22,18 @@ ; advanced (text "{% if chat.style == \"Direct\" -%} {% for member in members -%} {% if member.id != user.id -%}") ; direct message; user that ISN'T the current user -(text "{{ components::avatar(id=member.id, size=avatar_size) }}") -(text "{{ components::username(user=member) }}") +(text "{{ self::avatar(id=member.id, size=avatar_size) }}") +(text "{{ self::username(user=member) }}") (text "{%- endif %} {%- endfor %} {%- else -%}") ; group chat -(text "{% for member in members -%} {{ components::avatar(id=member.id, size=avatar_size) }} {%- endfor %}") +(text "{% for member in members -%} {{ self::avatar(id=member.id, size=avatar_size) }} {%- endfor %}") (b (text "{{ chat.style.Group.name }}")) (text "{%- endif %}") (text "{%- else -%}") ; NOT advanced (text "{% if chat.style == \"Direct\" -%} {% for member in members -%} {% if member.id != user.id -%}") ; direct message; user that ISN'T the current user -(text "{{ user.username }}") +(text "{{ member.username }}") (text "{%- endif %} {%- endfor %} {%- else -%}") ; group chat (text "{{ chat.style.Group.name }}") @@ -44,8 +44,9 @@ (text "{% macro message(message) -%}") (div ("class" "flex w_full gap_ch message {%- if user.id == message.owner %} justify_right mine {%- endif %}") + ("id" "message_{{ message.id }}") (div ("class" "inner no_p_margin") (text "{{ message.content|markdown|safe }}")) - (text "{{ components::avatar(id=uid) }}")) + (text "{{ self::avatar(id=message.owner) }}")) (text "{%- endmacro %}") diff --git a/app/templates_src/login.lisp b/app/templates_src/login.lisp index 9881054..08476c8 100644 --- a/app/templates_src/login.lisp +++ b/app/templates_src/login.lisp @@ -101,7 +101,7 @@ // redirect setTimeout(() => { - window.location.href = \"/app\"; + window.location.href = \"/chats\"; }, 150); } }); diff --git a/app/templates_src/message.lisp b/app/templates_src/message.lisp index d20b77f..fdc4e30 100644 --- a/app/templates_src/message.lisp +++ b/app/templates_src/message.lisp @@ -1,2 +1,2 @@ (text "{%- import \"components.lisp\" as components -%}") -(text "{{ components::message(message) }}") +(text "{{ components::message(message=message) }}") diff --git a/app/templates_src/messages.lisp b/app/templates_src/messages.lisp index 7a9527d..23159e2 100644 --- a/app/templates_src/messages.lisp +++ b/app/templates_src/messages.lisp @@ -1,4 +1,15 @@ (text "{%- import \"components.lisp\" as components -%}") (text "{% for message in messages -%}") -(text "{{ components::message(message) }}") +(text "{{ components::message(message=message) }}") (text "{%- endfor %}") + +(div + ("class" "hidden") + ("id" "msgs_data_{{ id }}") + ("data-first-message-time" "{{ first_message_time }}")) + +(text "{% if messages|length == 0 -%}") +(div + ("class" "hidden") + ("id" "msgs_quit_{{ id }}")) +(text "{%- endif %}") diff --git a/app/templates_src/read_receipt.lisp b/app/templates_src/read_receipt.lisp new file mode 100644 index 0000000..106931e --- /dev/null +++ b/app/templates_src/read_receipt.lisp @@ -0,0 +1,17 @@ +(text "{%- import \"components.lisp\" as components -%}") +(text "{% if chat.last_message_created > 0 -%}") +(div + ("class" "flex gap_ch items_center") + ("id" "delivered_read_status") + (text "{% if chat.last_message_read_by|length <= 1 -%}") + ; just delivered + (text "{{ icon \"check\" }}") + (text "{{ chat.last_message_created|int|date(format=\"%H:%M\", timezone=\"Etc/UTC\") }} UTC") + (text "{%- else -%}") + ; delivered and read by at least two people + (text "{{ icon \"check-check\" }}") + (text "{% for uid in chat.last_message_read_by -%}") + (text "{{ components::avatar(id=uid) }}") + (text "{%- endfor %}") + (text "{%- endif %}")) +(text "{%- endif %}") diff --git a/src/database/chats.rs b/src/database/chats.rs index b104c6d..adc5e6d 100644 --- a/src/database/chats.rs +++ b/src/database/chats.rs @@ -58,7 +58,7 @@ impl DataManager { let res = query_rows!( &conn, - "SELECT * FROM t_chats WHERE members LIKE $1 ORDER BY last_message_created LIMIT $2 OFFSET $3", + "SELECT * FROM t_chats WHERE members LIKE $1 ORDER BY last_message_created DESC LIMIT $2 OFFSET $3", params![ &format!("%{id}%"), &(batch as i64), diff --git a/src/database/messages.rs b/src/database/messages.rs index 92155df..988b67f 100644 --- a/src/database/messages.rs +++ b/src/database/messages.rs @@ -41,7 +41,7 @@ impl DataManager { let res = query_rows!( &conn, - "SELECT * FROM t_messages WHERE chat = $1 LIMIT $2 OFFSET $3", + "SELECT * FROM t_messages WHERE chat = $1 ORDER BY created DESC LIMIT $2 OFFSET $3", params![&(id as i64), &(batch as i64), &((page * batch) as i64)], |x| { Self::get_message_from_row(x) } ); @@ -68,7 +68,7 @@ impl DataManager { let res = query_rows!( &conn, - "SELECT * FROM t_messages WHERE chat = $1 AND created < $2 LIMIT $3 OFFSET $4", + "SELECT * FROM t_messages WHERE chat = $1 AND created < $2 ORDER BY created DESC LIMIT $3 OFFSET $4", params![ &(id as i64), &(before as i64), @@ -128,7 +128,7 @@ impl DataManager { data.chat, SocketMessage { method: SocketMethod::MessageCreate, - body: serde_json::to_string(&data).unwrap(), + body: data.id.to_string(), } .to_string(), ) { diff --git a/src/routes/api/chats.rs b/src/routes/api/chats.rs index 4c15cdf..fa9c911 100644 --- a/src/routes/api/chats.rs +++ b/src/routes/api/chats.rs @@ -158,6 +158,42 @@ pub async fn update_info_request( } } +pub async fn read_message_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data.2) { + Some(x) => x, + None => return Json(Error::NotAllowed.into()), + }; + + let mut chat = match data.get_chat_by_id(id).await { + Ok(x) => x, + Err(e) => { + return Json(e.into()); + } + }; + + if !chat.last_message_read_by.contains(&user.id) { + // update chat + chat.last_message_read_by.push(user.id); + if let Err(e) = data + .update_chat_last_message_read_by(id, chat.last_message_read_by) + .await + { + return Json(e.into()); + } + } + + Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: (), + }) +} + /// Handle a subscription to the websocket. pub async fn subscription_handler( jar: CookieJar, diff --git a/src/routes/api/mod.rs b/src/routes/api/mod.rs index cc08846..3248ac2 100644 --- a/src/routes/api/mod.rs +++ b/src/routes/api/mod.rs @@ -19,9 +19,13 @@ pub fn routes() -> Router { .route("/chats", post(chats::create_request)) .route("/chats/{id}/leave", post(chats::leave_request)) .route("/chats/{id}/info", post(chats::update_info_request)) - .route("/chats/{id}/_connect", post(chats::subscription_handler)) + .route("/chats/{id}/_connect", get(chats::subscription_handler)) + .route( + "/chats/{id}/read_message", + post(chats::read_message_request), + ) // messages - .route("/messages", post(messages::create_request)) + .route("/messages/{id}", post(messages::create_request)) .route("/messages/{id}", delete(messages::delete_request)) .route("/messages/{id}", put(messages::update_content_request)) } diff --git a/src/routes/pages/chats.rs b/src/routes/pages/chats.rs index 645d1e2..a5ea0c8 100644 --- a/src/routes/pages/chats.rs +++ b/src/routes/pages/chats.rs @@ -11,6 +11,7 @@ use axum::{ response::{Html, IntoResponse}, }; use axum_extra::extract::CookieJar; +use serde::Deserialize; use tetratto_core::model::Error; pub async fn list_request( @@ -106,33 +107,21 @@ pub async fn single_message_request( } }; - let mut chat = match data.get_chat_by_id(message.chat).await { - Ok(x) => x, - Err(e) => { - return Err(render_error(e, tera, data.0.0.clone(), Some(user)).await); - } - }; - - if !chat.last_message_read_by.contains(&user.id) { - // update chat - chat.last_message_read_by.push(user.id); - if let Err(e) = data - .update_chat_last_message_read_by(user.id, chat.last_message_read_by) - .await - { - return Err(render_error(e, tera, data.0.0.clone(), Some(user)).await); - } - } - let mut ctx = default_context(&data.0.0, &build_code, &Some(user)); ctx.insert("message", &message); Ok(Html(tera.render("message.lisp", &ctx).unwrap())) } +#[derive(Deserialize)] +pub struct MessagesProps { + pub use_id: String, +} + pub async fn messages_request( jar: CookieJar, Extension(data): Extension, Path((id, before)): Path<(usize, usize)>, + Query(props): Query, ) -> impl IntoResponse { let (ref data, ref tera, ref build_code) = *data.read().await; let user = match get_user_from_token!(jar, data.2) { @@ -142,7 +131,11 @@ pub async fn messages_request( } }; - let messages = match data.get_messages_by_chat_before(id, before, 12, 0).await { + let messages = match if before > 0 { + data.get_messages_by_chat_before(id, before, 12, 0).await + } else { + data.get_messages_by_chat(id, 12, 0).await + } { Ok(x) => x, Err(e) => { return Err(render_error(e, tera, data.0.0.clone(), Some(user)).await); @@ -150,6 +143,42 @@ pub async fn messages_request( }; let mut ctx = default_context(&data.0.0, &build_code, &Some(user)); + + ctx.insert( + "first_message_time", + &match messages.first() { + Some(x) => x.created, + None => 0, + }, + ); + ctx.insert("messages", &messages); + ctx.insert("id", &props.use_id); + Ok(Html(tera.render("messages.lisp", &ctx).unwrap())) } + +pub async fn read_receipt_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let (ref data, ref tera, ref build_code) = *data.read().await; + let user = match get_user_from_token!(jar, data.2) { + Some(x) => x, + None => { + return Err(render_error(Error::NotAllowed, tera, data.0.0.clone(), None).await); + } + }; + + let chat = match data.get_chat_by_id(id).await { + Ok(x) => x, + Err(e) => { + return Err(render_error(e, tera, data.0.0.clone(), Some(user)).await); + } + }; + + let mut ctx = default_context(&data.0.0, &build_code, &Some(user)); + ctx.insert("chat", &chat); + Ok(Html(tera.render("read_receipt.lisp", &ctx).unwrap())) +} diff --git a/src/routes/pages/mod.rs b/src/routes/pages/mod.rs index 10adf3f..efd561b 100644 --- a/src/routes/pages/mod.rs +++ b/src/routes/pages/mod.rs @@ -20,6 +20,10 @@ pub fn routes() -> Router { "/chats/_templates/chat/{id}/messages/before/{before}", get(chats::messages_request), ) + .route( + "/chats/_templates/chat/{id}/read_receipt", + get(chats::read_receipt_request), + ) } #[derive(Deserialize)]