v3: chatbox improvements (typing indicators, answer to previous messages, backlog days separators, scroll to last message position, visual tweaks)
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 41s

This commit is contained in:
2026-06-30 12:27:03 +02:00
parent 124866ad4f
commit d2d086831e
17 changed files with 857 additions and 86 deletions

View File

@@ -9,13 +9,26 @@ export const MAX_CHAT_LENGTH = 2000;
const DEFAULT_LIMIT = 50;
const MAX_LIMIT = 100;
// Length cap for the replied-to preview snippet, so a reply to a long message
// doesn't ship the whole 2000-char body over the wire for every reply.
const REPLY_SNIPPET_LEN = 140;
const SELECT_COLS =
`m.id, m.user_id, m.body, m.created_at, m.updated_at,
u.username as author_username, u.avatar_mime as author_avatar_mime`;
`m.id, m.user_id, m.body, m.created_at, m.updated_at, m.reply_to_id,
u.username as author_username, u.avatar_mime as author_avatar_mime,
ru.username as reply_to_author, substr(rm.body, 1, ${REPLY_SNIPPET_LEN}) as reply_to_body`;
// Common joins: author (required) plus the optional replied-to message and its
// author (LEFT — null when there's no reply or the target was deleted).
const FROM_JOINS =
`FROM chat_messages m
JOIN users u ON m.user_id = u.id
LEFT JOIN chat_messages rm ON m.reply_to_id = rm.id
LEFT JOIN users ru ON rm.user_id = ru.id`;
function fetchMessage(id: string): ChatMessage {
const row = db.prepare(
`SELECT ${SELECT_COLS} FROM chat_messages m JOIN users u ON m.user_id = u.id WHERE m.id = ?;`,
`SELECT ${SELECT_COLS} ${FROM_JOINS} WHERE m.id = ?;`,
).get(id);
if (!row || !isChatMessageRow(row)) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Message not found");
@@ -23,7 +36,11 @@ function fetchMessage(id: string): ChatMessage {
return chatMessageRowToApi(row);
}
export function createChatMessage(userId: string, body: string): ChatMessage {
export function createChatMessage(
userId: string,
body: string,
replyToId?: string,
): ChatMessage {
const trimmed = body.trim();
if (!trimmed) {
throw new APIException(
@@ -39,11 +56,20 @@ export function createChatMessage(userId: string, body: string): ChatMessage {
`Message exceeds ${MAX_CHAT_LENGTH} characters`,
);
}
// Only store the reply target if it actually exists — drop dangling ids so a
// message never references a phantom parent.
let replyTo: string | null = null;
if (replyToId) {
const exists = db.prepare(`SELECT 1 FROM chat_messages WHERE id = ?;`).get(
replyToId,
);
if (exists) replyTo = replyToId;
}
const id = crypto.randomUUID();
const createdAt = new Date().toISOString();
db.prepare(
`INSERT INTO chat_messages (id, user_id, body, created_at) VALUES (?, ?, ?, ?);`,
).run(id, userId, trimmed, createdAt);
`INSERT INTO chat_messages (id, user_id, body, created_at, reply_to_id) VALUES (?, ?, ?, ?, ?);`,
).run(id, userId, trimmed, createdAt, replyTo);
return fetchMessage(id);
}
@@ -117,11 +143,11 @@ export function getRecentChatMessages(
const capped = Math.min(Math.max(limit, 1), MAX_LIMIT);
const rows = before
? db.prepare(
`SELECT ${SELECT_COLS} FROM chat_messages m JOIN users u ON m.user_id = u.id
`SELECT ${SELECT_COLS} ${FROM_JOINS}
WHERE m.created_at < ? ORDER BY m.created_at DESC LIMIT ?;`,
).all(before, capped)
: db.prepare(
`SELECT ${SELECT_COLS} FROM chat_messages m JOIN users u ON m.user_id = u.id
`SELECT ${SELECT_COLS} ${FROM_JOINS}
ORDER BY m.created_at DESC LIMIT ?;`,
).all(capped);