add: chats list page

This commit is contained in:
trisua 2025-08-26 00:24:12 -04:00
parent c48cf78314
commit 747a05d649
16 changed files with 576 additions and 24 deletions

View file

@ -17,6 +17,10 @@
--color-green-lowered: hsl(100, 84%, 15%);
--color-red-lowered: hsl(0, 84%, 35%);
--color-primary: hsl(25, 95%, 53%);
--color-primary-lowered: hsl(25, 95%, 49%);
--color-text-primary: hsl(0, 0%, 5%);
--shadow-x-offset: 0;
--shadow-y-offset: 0.125rem;
--shadow-size: var(--pad-1);
@ -51,6 +55,10 @@
--color-green: hsl(100, 94%, 82%);
--color-yellow: oklch(90.1% 0.076 70.697);
--color-purple: hsl(284, 94%, 82%);
--color-primary: hsl(25, 95%, 63%);
--color-primary-lowered: hsl(25, 95%, 59%);
--color-text-primary: hsl(0, 0%, 5%);
}
html,
@ -94,7 +102,7 @@ article {
overflow: auto;
}
.tabs .tab {
.tabs:not(.short) .tab {
height: 100%;
}
@ -195,6 +203,10 @@ video {
padding: var(--pad-2) var(--pad-4);
}
.card.surface {
background: var(--color-surface);
}
/* button */
.button {
--h: 36px;
@ -215,6 +227,7 @@ video {
text-decoration: none !important;
user-select: none;
appearance: none;
overflow: hidden;
}
.button:disabled {
@ -276,6 +289,15 @@ video {
}
}
.button.primary {
color: var(--color-text-primary) !important;
background: var(--color-primary);
}
.button.primary:hover {
background: var(--color-primary-lowered) !important;
}
/* dropdown */
.dropdown {
position: relative;
@ -468,6 +490,12 @@ svg.icon {
fill: currentColor;
}
.big_icon svg.icon {
width: 18px;
height: 18px;
position: absolute;
}
button svg {
pointer-events: none;
}
@ -621,6 +649,10 @@ span {
align-items: flex-end;
}
.gap_ch {
gap: 1ch;
}
/* table */
table {
width: 100%;
@ -675,7 +707,7 @@ dialog {
border: 0;
}
dialog.inner {
dialog .inner {
display: flex;
flex-direction: column;
gap: var(--pad-2);

View file

@ -0,0 +1,26 @@
(text "{% extends \"root.lisp\" %} {% block head %}")
(title
(text "{{ components::chat_name(chat=chat, members=members) }} - {{ name }}"))
(text "{% endblock %} {% block body %}")
(div
("class" "flex w_full gap_2 justify_between items_center")
(div
("class" "tabs short bar flex")
(a
("class" "button tab camo")
("href" "/chats")
(text "chats"))
(a
("class" "button tab")
("href" "/chats/{{ chat.id }}")
(text "{{ components::chat_name(chat=chat, members=members, advanced=true, avatar_size=\"18px\") }}"))))
(div
("class" "card flex flex_col gap_2")
("style" "flex: 1 0 auto")
(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 %}"))
(script
(text ""))
(text "{% endblock %}")

View file

@ -0,0 +1,217 @@
(text "{% extends \"root.lisp\" %} {% block head %}")
(title
(text "My chats - {{ name }}"))
(text "{% endblock %} {% block body %}")
(div
("class" "flex w_full gap_2 justify_between items_center")
(div
("class" "tabs short bar")
(a
("class" "button tab")
("href" "/chats")
(text "chats")))
(button
("class" "button")
("title" "Create chat")
("onclick" "document.getElementById('create_dialog').showModal()")
(text "{{ icon \"plus\" }}")))
(div
("class" "card flex flex_col gap_2")
(text "{% for chat in chats -%}")
(div
("class" "card surface w_full flex justify_between items_center gap_2")
(a
("class" "flex gap_ch items_center")
("href" "/chats/{{ chat[0].id }}")
(text "{{ components::chat_name(chat=chat[0], members=chat[1], advanced=true) }}"))
(div
("class" "dropdown")
(button
("onclick" "open_dropdown(event)")
("exclude" "dropdown")
("class" "button")
(text "{{ icon \"ellipsis\" }}"))
(div
("class" "inner")
(a
("class" "button")
("href" "/chats/{{ chat[0].id }}")
("target" "_blank")
(text "pop open"))
(text "{% if chat[0].style != \"Direct\" -%}")
; group chat only
(button
("class" "button")
("onclick" "rename_gc('{{ chat[0].id }}')")
(text "rename"))
(text "{%- endif %}")
(button
("class" "button red")
("onclick" "leave_chat('{{ chat[0].id }}')")
(text "leave")))))
(text "{%- endfor %}")
(text "{% if chats|length == 0 -%}")
(i ("class" "flex gap_ch items_center fade") (text "{{ icon \"smile\" }} No results, yet!"))
(text "{%- endif %}")
; pagination
(div
("class" "flex w_full items_center gap_2 justify_between")
(text "{% if page > 0 -%}")
(a
("href" "?page={{ page - 1 }}")
("class" "button surface")
(text "{{ icon \"arrow-left\" }} Back"))
(text "{%- else -%}")
(div null?)
(text "{%- endif %}")
(text "{% if chats|length > 0 -%}")
(a
("href" "?page={{ page + 1 }}")
("class" "button surface")
(text "Next {{ icon \"arrow-right\" }}"))
(text "{%- endif %}")))
(dialog
("id" "create_dialog")
(div
("class" "inner")
(h2
("class" "text_center w_full")
(text "Create chat"))
(ul ("id" "members_list"))
(form
("class" "flex flex_row gap_2 w_full")
("onsubmit" "add_member(event)")
(input
("type" "text")
("list" "users_search")
("name" "username")
("id" "username")
("placeholder" "username")
("oninput" "search_users(event)"))
(button
("class" "button")
(text "Add")))
(hr ("class" "margin"))
(div
("class" "flex gap_2 justify_between")
(button
("onclick" "document.getElementById('create_dialog').close()")
("class" "button red")
(text "Cancel"))
(button
("class" "button green")
("onclick" "create_chat_from_form()")
(text "Create")))))
(datalist ("id" "users_search"))
(script
(text "globalThis.CHAT_MEMBERS = [];
function render_members() {
document.getElementById('members_list').innerHTML = \"\";
for (const member of CHAT_MEMBERS) {
document.getElementById('members_list').innerHTML += `<li>${member} (<a href=\"javascript:remove_member('${member}')\" class=\"red\">remove</a>)</li>`;
}
}
function add_member(e) {
e.preventDefault();
const member = e.target.username.value;
if (CHAT_MEMBERS.includes(member)) {
return;
}
CHAT_MEMBERS.push(member);
render_members();
e.target.reset();
}
function remove_member(member) {
CHAT_MEMBERS.splice(CHAT_MEMBERS.indexOf(member), 1);
render_members();
}
function create_chat_from_form(e) {
document.getElementById('create_dialog').close();
fetch(\"/api/v1/chats\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
style: CHAT_MEMBERS.length > 1 ? { Group: { name: \"Untitled\" } } : \"Direct\",
members: CHAT_MEMBERS,
}),
})
.then((res) => res.json())
.then((res) => {
show_message(res.message, res.ok);
if (res.ok) {
window.location.href = `/chats/${res.payload}`;
e.target.reset();
}
});
}
let search_users_timeout;
function search_users(e) {
if (search_users_timeout) {
clearTimeout(search_users_timeout);
}
if (e.target.value.trim().length === 0) {
return;
}
search_users_timeout = setTimeout(() => {
fetch(\"/api/v1/auth/users/search\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
prefix: e.target.value.trim(),
}),
})
.then((res) => res.json())
.then((res) => {
if (res.ok) {
document.getElementById(\"users_search\").innerHTML = \"\";
for (const username of res.payload) {
document.getElementById(\"users_search\").innerHTML += `<option value=\"${username}\">${username}</option>`;
}
} else {
show_message(res.message, res.ok);
}
});
}, 1000);
}
function leave_chat(id) {
if (!confirm(\"Are you sure you would like to do this?\")) {
return;
}
fetch(`/api/v1/chats/${id}/leave`, {
method: \"POST\",
})
.then((res) => res.json())
.then((res) => {
show_message(res.message, res.ok);
});
}"))
(text "{% endblock %}")

View file

@ -7,3 +7,36 @@
("loading" "lazy")
("style" "--size: {{ size }}"))
(text "{%- endmacro %}")
(text "{% macro username(user) -%}")
(b
(text "{% if user.settings.display_name|length > 0 -%}")
(text "{{ user.settings.display_name }}")
(text "{%- else -%}")
(text "{{ user.username }}")
(text "{%- endif %}"))
(text "{%- endmacro %}")
(text "{% macro chat_name(chat, members, advanced=false, avatar_size=\"24px\") -%}")
(text "{% if advanced -%}")
; 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 "{%- endif %} {%- endfor %} {%- else -%}")
; group chat
(text "{% for member in members -%} {{ components::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 "{%- endif %} {%- endfor %} {%- else -%}")
; group chat
(text "{{ chat.style.Group.name }}")
(text "{%- endif %}")
(text "{%- endif %}")
(text "{%- endmacro %}")

View file

@ -10,8 +10,6 @@
(link ("rel" "stylesheet") ("href" "https://repodelivery.tetratto.com/tetratto/crates/app/src/public/css/utility.css"))
(link ("rel" "stylesheet") ("href" "/public/style.css?v={{ build_code }}"))
(style (text ":root { --color-primary: {{ theme_color }}; }"))
(meta ("name" "theme-color") ("content" "{{ theme_color }}"))
(meta ("property" "og:type") ("content" "website"))
(meta ("property" "og:site_name") ("content" "{{ name }}"))
@ -28,7 +26,7 @@
(body
; nav
(nav
("class" "flex w_full justify_between gap_2 sticky")
("class" "flex w_full justify_between gap_2")
(div
("class" "flex side")
(div
@ -76,8 +74,7 @@
("onclick" "user_logout()")
(text "logout"))
(text "{%- endif %}")
(text "{% block dropdown %}{% endblock %}")))
(a ("class" "button camo") ("href" "/") (b (text "{{ name }}"))))
(text "{% block dropdown %}{% endblock %}"))))
(div
("class" "side flex")

View file

@ -1,9 +1,9 @@
use super::DataManager;
use crate::model::{Chat, ChatStyle};
use oiseau::{PostgresRow, cache::Cache, execute, get, params};
use oiseau::{PostgresRow, cache::Cache, execute, get, params, query_rows};
use tetratto_core::{
auto_method,
model::{Error, Result},
model::{Error, Result, auth::User},
};
impl DataManager {
@ -21,6 +21,59 @@ impl DataManager {
auto_method!(get_chat_by_id(usize as i64)@get_chat_from_row -> "SELECT * FROM t_chats WHERE id = $1" --name="chat" --returns=Chat --cache-key-tmpl="twny.chat:{}");
pub async fn fill_chat(&self, chat: Chat) -> (Chat, Vec<User>) {
let mut members = Vec::new();
for x in &chat.members {
members.push(match self.2.get_user_by_id(*x).await {
Ok(x) => x,
Err(_) => User::deleted(),
});
}
(chat, members)
}
pub async fn fill_chats(&self, chats: Vec<Chat>) -> Vec<(Chat, Vec<User>)> {
let mut out = Vec::new();
for x in chats {
out.push(self.fill_chat(x).await);
}
out
}
/// Get all chats the given member is participating in.
pub async fn get_chats_by_member(
&self,
id: usize,
batch: usize,
page: usize,
) -> Result<Vec<Chat>> {
let conn = match self.0.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = query_rows!(
&conn,
"SELECT * FROM t_chats WHERE members LIKE $1 ORDER BY last_message_created LIMIT $2 OFFSET $3",
params![
&format!("%{id}%"),
&(batch as i64),
&((page * batch) as i64)
],
|x| { Self::get_chat_from_row(x) }
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
Ok(res.unwrap())
}
/// Create a new chat in the database.
///
/// # Arguments

View file

@ -3,7 +3,7 @@ use crate::model::{Message, SocketMessage, SocketMethod};
use oiseau::{
PostgresRow,
cache::{Cache, redis::Commands},
execute, get, params,
execute, get, params, query_rows,
};
use tetratto_core::{
auto_method,
@ -27,6 +27,32 @@ impl DataManager {
auto_method!(get_message_by_id(usize as i64)@get_message_from_row -> "SELECT * FROM t_messages WHERE id = $1" --name="message" --returns=Message --cache-key-tmpl="twny.message:{}");
/// Get messages by their chat ID.
pub async fn get_messages_by_chat(
&self,
id: usize,
batch: usize,
page: usize,
) -> Result<Vec<Message>> {
let conn = match self.0.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = query_rows!(
&conn,
"SELECT * FROM t_messages WHERE chat = $1 LIMIT $2 OFFSET $3",
params![&(id as i64), &(batch as i64), &((page * batch) as i64)],
|x| { Self::get_message_from_row(x) }
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
Ok(res.unwrap())
}
/// Create a new message in the database.
///
/// # Arguments

View file

@ -1,6 +1,7 @@
mod chats;
mod messages;
mod sql;
mod users;
use crate::config::Config;
use buckets_core::{Config as BucketsConfig, DataManager as BucketsManager};

View file

@ -3,7 +3,7 @@ CREATE TABLE IF NOT EXISTS t_messages (
created BIGINT NOT NULL,
edited BIGINT NOT NULL,
owner BIGINT NOT NULL,
chats BIGINT NOT NULL,
chat BIGINT NOT NULL,
content TEXT NOT NULL,
uploads TEXT NOT NULL
);

26
src/database/users.rs Normal file
View file

@ -0,0 +1,26 @@
use super::DataManager;
use oiseau::{get, params, query_rows};
use tetratto_core::model::{Error, Result};
impl DataManager {
/// Search all users.
pub async fn search_users(&self, query: &str, batch: usize) -> Result<Vec<String>> {
let conn = match self.0.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = query_rows!(
&conn,
"SELECT username, id FROM users WHERE username LIKE $1 LIMIT $2",
params![&format!("{query}%"), &(batch as i64),],
|x| { get!(x->0(String)) }
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
Ok(res.unwrap())
}
}

View file

@ -189,3 +189,28 @@ pub async fn check_totp_request(
payload: Some(!user.totp.is_empty()),
})
}
#[derive(Deserialize)]
pub struct SearchUsers {
pub prefix: String,
}
pub async fn search_users_request(
jar: CookieJar,
Extension(data): Extension<State>,
Json(props): Json<SearchUsers>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
if get_user_from_token!(jar, data.2).is_none() {
return Json(Error::NotAllowed.into());
}
match data.search_users(&props.prefix, 4).await {
Ok(x) => Json(ApiReturn {
ok: true,
message: "Success".to_string(),
payload: Some(x),
}),
Err(e) => Json(e.into()),
}
}

View file

@ -22,7 +22,7 @@ use tetratto_core::model::{ApiReturn, Error, auth::User};
#[derive(Deserialize)]
pub struct CreateChat {
pub style: ChatStyle,
pub members: Vec<usize>,
pub members: Vec<String>,
}
pub async fn create_request(
@ -37,13 +37,23 @@ pub async fn create_request(
};
if req.members.len() > 2 && req.style == ChatStyle::Direct {
return Json(Error::DataTooLong("members".to_string()).into());
return Json(Error::DataTooLong("members list".to_string()).into());
} else if req.members.len() < 1 {
return Json(Error::DataTooShort("members list".to_string()).into());
}
match data
.create_chat(Chat::new(req.style, {
let mut x = req.members;
let mut x = Vec::new();
x.push(user.id);
for y in req.members {
x.push(match data.2.get_user_by_username(&y).await {
Ok(x) => x.id,
Err(e) => return Json(e.into()),
})
}
x
}))
.await

View file

@ -14,6 +14,7 @@ pub fn routes() -> Router {
"/auth/user/{username}/check_totp",
get(auth::check_totp_request),
)
.route("/auth/users/search", post(auth::search_users_request))
// chats
.route("/chats", post(chats::create_request))
.route("/chats/{id}/leave", post(chats::leave_request))

87
src/routes/pages/chats.rs Normal file
View file

@ -0,0 +1,87 @@
use crate::{
State, get_user_from_token,
routes::{
default_context,
pages::{PaginatedQuery, misc::render_error},
},
};
use axum::{
Extension,
extract::{Path, Query},
response::{Html, IntoResponse},
};
use axum_extra::extract::CookieJar;
use tetratto_core::model::Error;
pub async fn list_request(
jar: CookieJar,
Extension(data): Extension<State>,
Query(props): Query<PaginatedQuery>,
) -> 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 chats = match data.get_chats_by_member(user.id, 12, props.page).await {
Ok(x) => data.fill_chats(x).await,
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("chats", &chats);
ctx.insert("page", &props.page);
Ok(Html(tera.render("chats.lisp", &ctx).unwrap()))
}
pub async fn chat_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
Query(props): Query<PaginatedQuery>,
) -> 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, members) = match data.get_chat_by_id(id).await {
Ok(x) => {
if !x.members.contains(&user.id) {
return Err(
render_error(Error::NotAllowed, tera, data.0.0.clone(), Some(user)).await,
);
}
data.fill_chat(x).await
}
Err(e) => {
return Err(render_error(e, tera, data.0.0.clone(), Some(user)).await);
}
};
let messages = match data.get_messages_by_chat(id, 12, props.page).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);
ctx.insert("members", &members);
ctx.insert("messages", &messages);
Ok(Html(tera.render("chat.lisp", &ctx).unwrap()))
}

View file

@ -1,24 +1,30 @@
use crate::{State, get_user_from_token, routes::default_context};
use crate::{State, config::Config, get_user_from_token, routes::default_context};
use axum::{
Extension,
response::{Html, IntoResponse},
};
use axum_extra::extract::CookieJar;
use tetratto_core::model::Error;
use tera::Tera;
use tetratto_core::model::{Error, auth::User};
pub async fn render_error(
e: Error,
tera: &Tera,
config: Config,
user: Option<User>,
) -> impl IntoResponse + use<> {
let mut ctx = default_context(&config, "", &user);
ctx.insert("error", &e.to_string());
return Html(tera.render("error.lisp", &ctx).unwrap());
}
pub async fn not_found_request(
jar: CookieJar,
Extension(data): Extension<State>,
) -> impl IntoResponse {
let (ref data, ref tera, ref build_code) = *data.read().await;
let (ref data, ref tera, _) = *data.read().await;
let user = get_user_from_token!(jar, data.2);
let mut ctx = default_context(&data.0.0, &build_code, &user);
ctx.insert(
"error",
&Error::GeneralNotFound("page".to_string()).to_string(),
);
return Html(tera.render("error.lisp", &ctx).unwrap());
render_error(Error::NotAllowed, tera, data.0.0.clone(), user).await
}
pub async fn index_request(jar: CookieJar, Extension(data): Extension<State>) -> impl IntoResponse {

View file

@ -1,9 +1,21 @@
pub mod chats;
pub mod misc;
use axum::routing::{Router, get};
use serde::Deserialize;
pub fn routes() -> Router {
Router::new()
.route("/", get(misc::index_request))
// auth
.route("/login", get(misc::login_request))
// chats
.route("/chats", get(chats::list_request))
.route("/chats/{id}", get(chats::chat_request))
}
#[derive(Deserialize)]
pub struct PaginatedQuery {
#[serde(default)]
pub page: usize,
}