add: chat pins

This commit is contained in:
trisua 2025-09-03 17:12:26 -04:00
parent 9546c580e7
commit 82eafdadb3
21 changed files with 330 additions and 56 deletions

2
Cargo.lock generated
View file

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

View file

@ -1,6 +1,6 @@
[package]
name = "tawny"
version = "1.0.1"
version = "1.0.2"
edition = "2024"
authors = ["trisuaso"]
repository = "https://trisua.com/t/tawny"
@ -30,7 +30,10 @@ glob = "0.3.2"
serde_json = "1.0.142"
toml = "0.9.4"
regex = "1.11.1"
oiseau = { version = "0.1.2", default-features = false, features = ["postgres", "redis",] }
oiseau = { version = "0.1.2", default-features = false, features = [
"postgres",
"redis",
] }
buckets-core = "1.0.4"
axum-image = "0.1.1"
futures-util = "0.3.31"

View file

@ -326,3 +326,27 @@ function create_direct_chat_with_user(id) {
}
});
}
function pin_message(e, id) {
fetch(`/api/v1/chats/${STATE.chat_id}/pins/${id}`, {
method: "POST",
})
.then((res) => res.json())
.then((res) => {
show_message(res.message, res.ok);
if (res.ok) {
e.target.remove();
}
});
}
function unpin_message(id) {
fetch(`/api/v1/chats/${STATE.chat_id}/pins/${id}`, {
method: "DELETE",
})
.then((res) => res.json())
.then((res) => {
show_message(res.message, res.ok);
});
}

View file

@ -116,6 +116,10 @@ article {
height: 100%;
}
.tabs {
overflow: auto;
}
.fadein {
animation: fadein ease-in-out 1 0.5s forwards running;
}
@ -801,6 +805,7 @@ menu.col {
background: var(--color-surface);
color: var(--color-text);
border-radius: var(--radius);
min-height: 36px;
}
.message.mine .body {
@ -812,3 +817,8 @@ menu.col {
.message:not(.mine) .body {
border-bottom-left-radius: 0;
}
.message:hover .dropdown.hidden,
.message:focus .dropdown.hidden {
display: flex !important;
}

View file

@ -9,7 +9,7 @@
(a
("class" "button tab camo")
("href" "/chats")
(text "chats")
(text "{{ icon \"castle\" }} chats")
(text "{% if user.missed_messages_count > 0 -%}") (b (text "({{ user.missed_messages_count }})")) (text "{%- endif %}"))
(a
("class" "button tab")
@ -18,7 +18,7 @@
(a
("class" "button tab camo")
("href" "/chats/{{ chat.id }}/manage")
(text "{{ icon \"settings-2\" }} Manage"))))
(text "{{ icon \"settings-2\" }} manage"))))
(div
("class" "flex flex_col card_nest reverse")
("style" "flex: 1 0 auto")
@ -29,10 +29,6 @@
("class" "card flex flex_rev_col gap_2")
("style" "flex: 1 0 auto")
("id" "messages_stream")
(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 %}")
(div ("ui_ident" "data_marker")))
(div ("id" "read_receipt_zone") ("class" "card") ("style" "min-height: 32.5px; position: sticky; bottom: 0")))
(form
@ -57,5 +53,5 @@
setTimeout(() => {
scroll_bottom();
}, 500);"))
}, 1500);"))
(text "{% endblock %}")

View file

@ -9,7 +9,7 @@
(a
("class" "button tab")
("href" "/chats")
(text "chats")
(text "{{ icon \"castle\" }} chats")
(text "{% if user.missed_messages_count > 0 -%}") (b (text "({{ user.missed_messages_count }})")) (text "{%- endif %}")))
(button
("class" "button square")

View file

@ -41,20 +41,27 @@
(text "{%- endif %}")
(text "{%- endmacro %}")
(text "{% macro message(message) -%}")
(text "{% macro message(message, is_pinned=false) -%}")
(div
("class" "flex w_full gap_ch message {%- if user.id == message.owner %} justify_right mine {%- endif %}")
("id" "message_{{ message.id }}")
(text "{% if message.owner == user.id -%}")
(div
("class" "dropdown")
("class" "dropdown hidden")
(button
("onclick" "open_dropdown(event)")
("exclude" "dropdown")
("class" "button")
("class" "button icon_only big_icon")
(text "{{ icon \"ellipsis\" }}"))
(div
("class" "inner surface")
(text "{% if not is_pinned -%}")
(button
("class" "button surface")
("onclick" "pin_message(event, '{{ message.id }}')")
(text "pin"))
(text "{%- endif %}")
(text "{% if message.owner == user.id -%}")
(button
("class" "button surface")
("onclick" "edit_message_ui('{{ message.id }}')")
@ -62,8 +69,8 @@
(button
("class" "button surface red")
("onclick" "delete_message('{{ message.id }}')")
(text "delete"))))
(text "{%- endif %}")
(text "delete"))
(text "{%- endif %}")))
(div
("class" "body no_p_margin")

View file

@ -9,7 +9,7 @@
(a
("class" "button tab camo")
("href" "/chats")
(text "chats"))
(text "{{ icon \"castle\" }} chats"))
(a
("class" "button tab camo")
("href" "/chats/{{ chat.id }}")
@ -17,24 +17,31 @@
(a
("class" "button tab")
("href" "/chats/{{ chat.id }}/manage")
(text "{{ icon \"settings-2\" }} Manage"))))
(text "{{ icon \"settings-2\" }} manage"))))
(div
("class" "flex flex_col gap_4 card")
("style" "flex: 1 0 auto")
(text "{% if chat.style != \"Direct\" -%}")
; gc only
(button
("class" "button surface")
("onclick" "rename_chat('{{ chat.id }}', GC_INFO)")
(text "{{ icon \"pencil\" }} rename chat"))
(div
("class" "flex gap_2 flex_wrap")
(text "{% if chat.style != \"Direct\" -%}")
; gc only
(button
("class" "button surface")
("onclick" "rename_chat('{{ chat.id }}', GC_INFO)")
(text "{{ icon \"pencil\" }} rename chat"))
(script
("type" "application/json")
("id" "gc_info")
(text "{{ chat.style.Group|json_encode() }}"))
(script
(text "globalThis.GC_INFO = JSON.parse(document.getElementById(\"gc_info\").innerHTML)"))
(text "{%- endif %}")
(script
("type" "application/json")
("id" "gc_info")
(text "{{ chat.style.Group|json_encode() }}"))
(script
(text "globalThis.GC_INFO = JSON.parse(document.getElementById(\"gc_info\").innerHTML)"))
(text "{%- endif %}")
; every chat
(a
("class" "button surface")
("href" "/chats/{{ chat.id }}/pins")
(text "{{ icon \"pin\" }} view pins")))
(ul
(li (b (text "Chat name: ")) (span (text "{{ components::chat_name(chat=chat, members=members) }}")))

View file

@ -1,6 +1,6 @@
(text "{%- import \"components.lisp\" as components -%}")
(text "{% for message in messages -%}")
(text "{{ components::message(message=message) }}")
(text "{{ components::message(message=message, is_pinned=message.id in pins) }}")
(text "{%- endfor %}")
(div
@ -12,4 +12,5 @@
(div
("class" "hidden")
("id" "msgs_quit_{{ id }}"))
(i ("class" "flex gap_ch items_center fade") (text "{{ icon \"star\" }} This is the start of the chat!"))
(text "{%- endif %}")

View file

@ -0,0 +1,36 @@
(text "{% extends \"root.lisp\" %} {% block head %}")
(title
(text "Pins in {{ components::chat_name(chat=chat, members=members) }} — {{ config.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 "{{ icon \"castle\" }} chats"))
(a
("class" "button tab camo")
("href" "/chats/{{ chat.id }}")
(text "{{ components::chat_name(chat=chat, members=members, advanced=true, avatar_size=\"18px\") }}"))
(a
("class" "button tab")
("href" "/chats/{{ chat.id }}/manage")
(text "{{ icon \"settings-2\" }} manage"))))
(div
("class" "flex flex_col gap_4 card")
("style" "flex: 1 0 auto")
(p (text "Using ") (b (text "{{ messages|length }} ")) (text "of ") (b (text "12 ")) (text "pins."))
(text "{% for message in messages -%}")
(hr)
(button
("class" "button surface red")
("onclick" "unpin_message('{{ message.id }}')")
(text "unpin"))
(text "{{ components::message(message=message) }}")
(text "{%- endfor %}"))
(script ("src" "/public/messages.js"))
(script (text "STATE.chat_id = '{{ chat.id }}';"))
(text "{% endblock %}")

View file

@ -134,7 +134,9 @@
}
.profile .banner {
background: url(\"{{ config.service_hosts.buckets }}/banners/{{ profile.id }}\") no-repeat center !important;
background-image: url(\"{{ config.service_hosts.buckets }}/banners/{{ profile.id }}\") !important;
background-repeat: no-repeat !important;
background-position: center !important;
background-size: cover !important;
border-radius: var(--radius) var(--radius) 0 0;
height: 225px;

View file

@ -16,6 +16,7 @@ impl DataManager {
members: serde_json::from_str(&get!(x->3(String))).unwrap(),
last_message_created: get!(x->4(i64)) as usize,
last_message_read_by: serde_json::from_str(&get!(x->5(String))).unwrap(),
pinned_messages: serde_json::from_str(&get!(x->6(String))).unwrap(),
}
}
@ -120,14 +121,15 @@ impl DataManager {
let res = execute!(
&conn,
"INSERT INTO t_chats VALUES ($1, $2, $3, $4, $5, $6)",
"INSERT INTO t_chats VALUES ($1, $2, $3, $4, $5, $6, $7)",
params![
&(data.id as i64),
&(data.created as i64),
&serde_json::to_string(&data.style).unwrap(),
&serde_json::to_string(&data.members).unwrap(),
&(data.last_message_created as i64),
&serde_json::to_string(&data.last_message_read_by).unwrap()
&serde_json::to_string(&data.last_message_read_by).unwrap(),
&serde_json::to_string(&data.pinned_messages).unwrap(),
]
);
@ -173,6 +175,7 @@ impl DataManager {
auto_method!(update_chat_style(ChatStyle) -> "UPDATE t_chats SET style = $1 WHERE id = $2" --serde --cache-key-tmpl="twny.chat:{}");
auto_method!(update_chat_members(Vec<usize>) -> "UPDATE t_chats SET members = $1 WHERE id = $2" --serde --cache-key-tmpl="twny.chat:{}");
auto_method!(update_chat_last_message_read_by(Vec<usize>) -> "UPDATE t_chats SET last_message_read_by = $1 WHERE id = $2" --serde --cache-key-tmpl="twny.chat:{}");
auto_method!(update_chat_last_message_created(i64) -> "UPDATE t_chats SET last_message_created = $1 WHERE id = $2" --cache-key-tmpl="twny.chat:{}");
auto_method!(update_chat_last_message_read_by(Vec<usize>) -> "UPDATE t_chats SET last_message_read_by = $1 WHERE id = $2" --serde --cache-key-tmpl="twny.chat:{}");
auto_method!(update_chat_pinned_messages(Vec<usize>) -> "UPDATE t_chats SET pinned_messages = $1 WHERE id = $2" --serde --cache-key-tmpl="twny.chat:{}");
}

View file

@ -52,6 +52,10 @@ impl DataManager {
execute!(&conn, sql::CREATE_TABLE_CHATS).unwrap();
execute!(&conn, sql::CREATE_TABLE_MESSAGES).unwrap();
for x in sql::VERSION_MIGRATIONS.split(";") {
execute!(&conn, x).unwrap();
}
Ok(())
}
}

View file

@ -1,2 +1,3 @@
pub const CREATE_TABLE_CHATS: &str = include_str!("./create_chats.sql");
pub const CREATE_TABLE_MESSAGES: &str = include_str!("./create_messages.sql");
pub const VERSION_MIGRATIONS: &str = include_str!("./version_migrations.sql");

View file

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

View file

@ -32,6 +32,7 @@ pub struct Chat {
/// keep up with if we store by chat instead. This will also declutter
/// the UI and prevent every message showing a read receipt.
pub last_message_read_by: Vec<usize>,
pub pinned_messages: Vec<usize>,
}
impl Chat {
@ -44,6 +45,7 @@ impl Chat {
members,
last_message_created: 0,
last_message_read_by: Vec::new(),
pinned_messages: Vec::new(),
}
}

View file

@ -43,7 +43,7 @@ pub async fn create_request(
req.members.dedup();
if (req.members.len() > 2 && req.style == ChatStyle::Direct)
| (req.members.len() > GC_MAXIMUM_MEMBERS && req.style != ChatStyle::Direct)
| (req.members.len() >= GC_MAXIMUM_MEMBERS && req.style != ChatStyle::Direct)
{
return Json(Error::DataTooLong("members list".to_string()).into());
} else if req.members.len() < 1 {
@ -199,7 +199,7 @@ pub async fn add_member_request(
return Json(Error::NotAllowed.into());
}
if chat.style != ChatStyle::Direct && chat.members.len() > GC_MAXIMUM_MEMBERS {
if chat.style != ChatStyle::Direct && chat.members.len() >= GC_MAXIMUM_MEMBERS {
// can only have a maximum of GC_MAXIMUM_MEMBERS members in one chat
return Json(Error::DataTooLong("members list".to_string()).into());
}
@ -460,3 +460,83 @@ pub async fn handle_socket(socket: WebSocket, db: DataManager, chat_id: String,
.await;
tracing::info!("socket terminate");
}
pub const MAXIMUM_PINS: usize = 12;
pub async fn add_pin_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path((id, message_id)): Path<(usize, usize)>,
) -> 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.members.contains(&user.id)
|| chat.pinned_messages.contains(&message_id)
|| chat.pinned_messages.len() >= MAXIMUM_PINS
{
return Json(Error::NotAllowed.into());
}
chat.pinned_messages.push(message_id);
match data
.update_chat_pinned_messages(chat.id, chat.pinned_messages)
.await
{
Ok(_) => Json(ApiReturn {
ok: true,
message: "Success".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn remove_pin_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path((id, message_id)): Path<(usize, usize)>,
) -> 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.members.contains(&user.id) || !chat.pinned_messages.contains(&message_id) {
return Json(Error::NotAllowed.into());
}
chat.pinned_messages.remove(
chat.pinned_messages
.iter()
.position(|x| *x == message_id)
.unwrap(),
);
match data
.update_chat_pinned_messages(chat.id, chat.pinned_messages)
.await
{
Ok(_) => Json(ApiReturn {
ok: true,
message: "Success".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}

View file

@ -33,6 +33,11 @@ pub fn routes() -> Router {
"/chats/{id}/read_message",
post(chats::read_message_request),
)
.route("/chats/{id}/pins/{message}", post(chats::add_pin_request))
.route(
"/chats/{id}/pins/{message}",
delete(chats::remove_pin_request),
)
// messages
.route("/messages/{id}", post(messages::create_request))
.route("/messages/{id}", delete(messages::delete_request))

View file

@ -1,3 +1,5 @@
use std::collections::HashMap;
use crate::{
State, get_user_from_token,
routes::{
@ -50,7 +52,6 @@ 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) {
@ -75,18 +76,10 @@ pub async fn chat_request(
}
};
let messages = match data.get_messages_by_chat(id, 24, 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()))
}
@ -135,12 +128,47 @@ pub async fn messages_request(
}
};
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 seen_user_blocks: HashMap<usize, bool> = HashMap::new();
let messages = match if before > 0 {
data.get_messages_by_chat_before(id, before, 24, 0).await
} else {
data.get_messages_by_chat(id, 24, 0).await
} {
Ok(x) => x,
Ok(x) => {
let mut y = Vec::new();
for z in x {
if let Some(status) = seen_user_blocks.get(&z.owner) {
if *status {
continue;
}
} else {
let is_blocked = data
.2
.get_userblock_by_initiator_receiver(user.id, z.owner)
.await
.is_ok();
seen_user_blocks.insert(z.owner, is_blocked);
if is_blocked {
continue;
}
}
y.push(z);
}
y
}
Err(e) => {
return Err(render_error(e, tera, data.0.0.clone(), Some(user)).await);
}
@ -156,6 +184,7 @@ pub async fn messages_request(
},
);
ctx.insert("pins", &chat.pinned_messages);
ctx.insert("messages", &messages);
ctx.insert("id", &props.use_id);
@ -225,3 +254,55 @@ pub async fn manage_chat_request(
Ok(Html(tera.render("manage.lisp", &ctx).unwrap()))
}
pub async fn chat_pins_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
) -> 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 = {
let mut x = Vec::new();
for y in &chat.pinned_messages {
x.push(match data.get_message_by_id(*y).await {
Ok(z) => z,
Err(e) => {
return Err(render_error(e, tera, data.0.0.clone(), Some(user)).await);
}
});
}
x
};
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("pins.lisp", &ctx).unwrap()))
}

View file

@ -9,7 +9,7 @@ use axum::{
use axum_extra::extract::CookieJar;
use serde::Deserialize;
use tera::Tera;
use tetratto_core::model::{Error, Result, auth::User};
use tetratto_core::model::{Error, Result, auth::User, permissions::FinePermission};
pub async fn render_error(
e: Error,
@ -57,6 +57,20 @@ pub async fn login_request(jar: CookieJar, Extension(data): Extension<State>) ->
)
}
async fn check_user_is_blocked(user: &User, other_user: &User, data: &DataManager) -> bool {
(data
.2
.get_userblock_by_initiator_receiver(other_user.id, user.id)
.await
.is_ok()
| data
.2
.get_user_stack_blocked_users(other_user.id)
.await
.contains(&user.id))
&& !user.permissions.check(FinePermission::MANAGE_USERS)
}
async fn check_user_blocked_or_private(
user: &Option<User>,
other_user: &User,
@ -72,12 +86,7 @@ async fn check_user_blocked_or_private(
{
// private profile and other_user isn't following user
return Err(Error::NotAllowed);
} else if data
.2
.get_userblock_by_initiator_receiver(other_user.id, ua.id)
.await
.is_ok()
{
} else if check_user_is_blocked(ua, other_user, data).await {
// blocked
return Err(Error::NotAllowed);
}

View file

@ -15,6 +15,7 @@ pub fn routes() -> Router {
// chats
.route("/chats", get(chats::list_request))
.route("/chats/{id}", get(chats::chat_request))
.route("/chats/{id}/pins", get(chats::chat_pins_request))
.route("/chats/{id}/manage", get(chats::manage_chat_request))
.route(
"/chats/_templates/message/{id}",