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

@@ -7,6 +7,7 @@ import { up as up0005DropIsAdmin } from "./migrations/0005_drop_is_admin.ts";
import { up as up0006Categories } from "./migrations/0006_categories.ts";
import { up as up0007PasswordResetTokens } from "./migrations/0007_password_reset_tokens.ts";
import { up as up0008ChatMessages } from "./migrations/0008_chat_messages.ts";
import { up as up0009ChatReply } from "./migrations/0009_chat_reply.ts";
interface Migration {
name: string;
@@ -25,6 +26,7 @@ const MIGRATIONS: Migration[] = [
{ name: "0006_categories", up: up0006Categories },
{ name: "0007_password_reset_tokens", up: up0007PasswordResetTokens },
{ name: "0008_chat_messages", up: up0008ChatMessages },
{ name: "0009_chat_reply", up: up0009ChatReply },
];
export function runMigrations(db: DatabaseSync): void {

View File

@@ -0,0 +1,17 @@
import type { DatabaseSync } from "node:sqlite";
// Adds an optional self-reference so a chat message can reply to another.
// Kept as a plain column (no FK): when the replied-to message is later deleted
// the id is retained but the join yields null, letting the UI show a subtle
// "deleted message" reference instead of breaking the insert.
//
// Idempotent: also runs against fresh databases created from schema.sql, where
// the column already exists.
export function up(db: DatabaseSync): void {
const cols = db.prepare(`PRAGMA table_info(chat_messages);`).all() as {
name: string;
}[];
if (!cols.some((c) => c.name === "reply_to_id")) {
db.exec(`ALTER TABLE chat_messages ADD COLUMN reply_to_id TEXT;`);
}
}

View File

@@ -194,11 +194,12 @@ CREATE UNIQUE INDEX idx_notifications_dedup
WHERE source_key IS NOT NULL;
CREATE TABLE chat_messages (
id TEXT NOT NULL PRIMARY KEY,
user_id TEXT NOT NULL,
body TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT,
id TEXT NOT NULL PRIMARY KEY,
user_id TEXT NOT NULL,
body TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT,
reply_to_id TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX idx_chat_messages_created ON chat_messages(created_at);

View File

@@ -313,6 +313,12 @@ export interface ChatMessageRow {
updated_at: string | null;
author_username: string;
author_avatar_mime: string | null;
// Reply columns: reply_to_id is the stored target; the author/body preview are
// LEFT-JOINed from the target and go null when there's no reply (or it was
// since deleted).
reply_to_id: string | null;
reply_to_author: string | null;
reply_to_body: string | null;
[key: string]: SQLOutputValue;
}
@@ -327,7 +333,13 @@ export function isChatMessageRow(obj: unknown): obj is ChatMessageRow {
"author_username" in obj && typeof obj.author_username === "string" &&
"author_avatar_mime" in obj &&
(typeof obj.author_avatar_mime === "string" ||
obj.author_avatar_mime === null);
obj.author_avatar_mime === null) &&
"reply_to_id" in obj &&
(typeof obj.reply_to_id === "string" || obj.reply_to_id === null) &&
"reply_to_author" in obj &&
(typeof obj.reply_to_author === "string" || obj.reply_to_author === null) &&
"reply_to_body" in obj &&
(typeof obj.reply_to_body === "string" || obj.reply_to_body === null);
}
export function chatMessageRowToApi(row: ChatMessageRow): ChatMessage {
@@ -339,6 +351,9 @@ export function chatMessageRowToApi(row: ChatMessageRow): ChatMessage {
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
authorUsername: row.author_username,
authorAvatarMime: row.author_avatar_mime ?? undefined,
replyToId: row.reply_to_id ?? undefined,
replyToAuthor: row.reply_to_author ?? undefined,
replyToBody: row.reply_to_body ?? undefined,
};
}

View File

@@ -321,6 +321,13 @@ export interface ChatMessage {
updatedAt?: Date;
authorUsername: string;
authorAvatarMime?: string;
/** Id of the message this one replies to, if any. Retained even when the
* target is later deleted (the preview fields below then go undefined). */
replyToId?: string;
/** Author/snippet of the replied-to message, for a subtle inline reference.
* Absent when there's no reply, or the target has since been deleted. */
replyToAuthor?: string;
replyToBody?: string;
}
/** Wire format — createdAt arrives as an ISO string over JSON. */
@@ -554,6 +561,15 @@ export interface CommentLikeRemoveMessage {
export interface ChatSendMessage {
type: "chat_send";
body: string;
/** Id of the message being replied to, if this is a reply. */
replyToId?: string;
}
// Sent while the user is composing (true) and when they stop (false). The
// server rebroadcasts the live set of typers to everyone as ChatTypingUpdate.
export interface ChatTypingMessage {
type: "chat_typing";
typing: boolean;
}
// Tells the server whether this client currently has the chatbox open, so that
@@ -582,6 +598,7 @@ export type ClientToServerMessage =
| CommentLikeCastMessage
| CommentLikeRemoveMessage
| ChatSendMessage
| ChatTypingMessage
| ChatFocusMessage
| ChatEditMessage
| ChatDeleteMessage;
@@ -730,6 +747,13 @@ export interface ChatMessageDeletedMessage {
id: string;
}
// The current set of users composing a chat message. Reuses OnlineUser so the
// client can render the same avatars as the presence row.
export interface ChatTypingUpdateMessage {
type: "chat_typing_update";
users: OnlineUser[];
}
export type ServerToClientMessage =
| PingMessage
| WelcomeMessage
@@ -754,7 +778,8 @@ export type ServerToClientMessage =
| ForceLogoutMessage
| ChatMessageMessage
| ChatMessageUpdatedMessage
| ChatMessageDeletedMessage;
| ChatMessageDeletedMessage
| ChatTypingUpdateMessage;
/**
* Follows

View File

@@ -7,11 +7,13 @@ import {
broadcastChatMessageUpdated,
broadcastCommentLikeUpdate,
broadcastPresence,
broadcastTyping,
broadcastVoteUpdate,
getOnlineUsers,
handleClientPong,
isUserChatOpen,
register,
setClientTyping,
unregister,
type WsClient,
} from "../services/ws-service.ts";
@@ -127,7 +129,10 @@ router.get("/ws", async (ctx) => {
handleCommentLike(client, msg.commentId, "remove");
break;
case "chat_send":
handleChatSend(client, msg.body);
handleChatSend(client, msg.body, msg.replyToId);
break;
case "chat_typing":
if (client.userId) setClientTyping(client, msg.typing);
break;
case "chat_focus":
client.chatOpen = msg.open;
@@ -144,6 +149,8 @@ router.get("/ws", async (ctx) => {
socket.addEventListener("close", () => {
unregister(client);
broadcastPresence();
// If they were mid-compose, drop them from the typing set right away.
if (client.typingAt) broadcastTyping();
});
});
@@ -214,7 +221,11 @@ function handleCommentLike(
}
}
function handleChatSend(client: WsClient, body: string): void {
function handleChatSend(
client: WsClient,
body: string,
replyToId?: string,
): void {
const { socket } = client;
if (!client.userId) {
@@ -225,7 +236,10 @@ function handleChatSend(client: WsClient, body: string): void {
}
try {
const message = createChatMessage(client.userId, body);
const message = createChatMessage(client.userId, body, replyToId);
// Sending a message ends composition — clear the typing flag before the
// message broadcast so the indicator doesn't linger next to it.
setClientTyping(client, false);
// Broadcast to everyone including the sender, so the sender's own message
// simply arrives over the socket — no optimistic-send machinery needed.
broadcastChatMessage(message);

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);

View File

@@ -19,6 +19,9 @@ export interface WsClient {
pongReceived?: boolean;
/** Whether this client currently has the chatbox open (is reading chat). */
chatOpen?: boolean;
/** Timestamp (ms) of the client's last "typing" signal; undefined when not
* composing. Entries older than TYPING_TTL are treated as stale. */
typingAt?: number;
}
const clients = new Set<WsClient>();
@@ -233,6 +236,64 @@ export function broadcastChatMessageDeleted(id: string): void {
}
}
// Typing indicator: a client's "typing" signal is considered live for this long
// after its last refresh. Must exceed the client's typing-ping cadence so a
// steady typist never flickers out between pings.
const TYPING_TTL = 6_000;
// Deduped set of users currently composing a chat message (typing signal within
// the TTL), shaped like OnlineUser so the client renders the same avatars.
export function getTypingUsers(): OnlineUser[] {
const now = Date.now();
const seen = new Map<string, OnlineUser>();
for (const client of clients) {
if (
client.userId && client.typingAt && now - client.typingAt < TYPING_TTL &&
!seen.has(client.userId)
) {
seen.set(client.userId, {
userId: client.userId,
username: client.username!,
hasAvatar: !!client.avatarMime,
avatarVersion: client.avatarVersion,
});
}
}
return Array.from(seen.values());
}
export function broadcastTyping(): void {
const users = getTypingUsers();
for (const client of clients) {
send(client.socket, { type: "chat_typing_update", users });
}
}
// Record (or clear) a client's typing state and tell everyone. The client also
// resends "true" periodically while composing; the sweep below expires it if
// they go quiet without a clean "false" (e.g. they closed the tab).
export function setClientTyping(client: WsClient, typing: boolean): void {
const wasTyping = !!client.typingAt;
client.typingAt = typing ? Date.now() : undefined;
// Only rebroadcast on an actual edge to avoid a flood of identical updates
// from the periodic typing pings.
if (typing !== wasTyping) broadcastTyping();
}
// Sweep stale typing entries (clients that stopped pinging without a clean
// stop) and rebroadcast when the visible set shrinks.
setInterval(() => {
const now = Date.now();
let changed = false;
for (const client of clients) {
if (client.typingAt && now - client.typingAt >= TYPING_TTL) {
client.typingAt = undefined;
changed = true;
}
}
if (changed) broadcastTyping();
}, 2_000);
// True if the user has the chatbox open on any of their connected clients — i.e.
// they're already reading chat, so a mention notification would be redundant.
export function isUserChatOpen(userId: string): boolean {