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:
@@ -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 {
|
||||
|
||||
17
api/db/migrations/0009_chat_reply.ts
Normal file
17
api/db/migrations/0009_chat_reply.ts
Normal 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;`);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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