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
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 41s
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user