v3: added a chatbox
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled

This commit is contained in:
khannurien
2026-06-29 20:25:10 +00:00
parent f171027673
commit 6c4f410d9b
35 changed files with 1800 additions and 152 deletions

View File

@@ -0,0 +1,137 @@
import {
APIErrorCode,
APIException,
type ChatMessage,
} from "../model/interfaces.ts";
import { chatMessageRowToApi, db, isChatMessageRow } from "../model/db.ts";
export const MAX_CHAT_LENGTH = 2000;
const DEFAULT_LIMIT = 50;
const MAX_LIMIT = 100;
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`;
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 = ?;`,
).get(id);
if (!row || !isChatMessageRow(row)) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Message not found");
}
return chatMessageRowToApi(row);
}
export function createChatMessage(userId: string, body: string): ChatMessage {
const trimmed = body.trim();
if (!trimmed) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"Message cannot be empty",
);
}
if (trimmed.length > MAX_CHAT_LENGTH) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
`Message exceeds ${MAX_CHAT_LENGTH} characters`,
);
}
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);
return fetchMessage(id);
}
function getMessageOwner(id: string): string {
const row = db.prepare(`SELECT user_id FROM chat_messages WHERE id = ?;`).get(
id,
) as { user_id: string } | undefined;
if (!row) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Message not found");
}
return row.user_id;
}
// Owners may edit their own messages; moderators may edit any.
export function updateChatMessage(
id: string,
body: string,
requestingUserId: string,
canModerate: boolean,
): ChatMessage {
const ownerId = getMessageOwner(id);
if (ownerId !== requestingUserId && !canModerate) {
throw new APIException(
APIErrorCode.UNAUTHORIZED,
401,
"Not authorized to edit this message",
);
}
const trimmed = body.trim();
if (!trimmed) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"Message cannot be empty",
);
}
if (trimmed.length > MAX_CHAT_LENGTH) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
`Message exceeds ${MAX_CHAT_LENGTH} characters`,
);
}
db.prepare(`UPDATE chat_messages SET body = ?, updated_at = ? WHERE id = ?;`)
.run(trimmed, new Date().toISOString(), id);
return fetchMessage(id);
}
// Deletion is a moderation action only (owners can edit but not delete).
export function deleteChatMessage(id: string, canModerate: boolean): void {
if (!canModerate) {
throw new APIException(
APIErrorCode.UNAUTHORIZED,
401,
"Not authorized to delete this message",
);
}
getMessageOwner(id); // 404s if the message doesn't exist
db.prepare(`DELETE FROM chat_messages WHERE id = ?;`).run(id);
}
/**
* Returns up to `limit` most recent messages in ascending (display) order.
* When `before` (a message created_at ISO string) is given, returns the page
* of older messages immediately preceding it — used for "load older" history.
*/
export function getRecentChatMessages(
limit = DEFAULT_LIMIT,
before?: string,
): ChatMessage[] {
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
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
ORDER BY m.created_at DESC LIMIT ?;`,
).all(capped);
if (!rows.every(isChatMessageRow)) {
throw new APIException(
APIErrorCode.SERVER_ERROR,
500,
"Malformed chat message data",
);
}
// Newest-first from SQL; reverse to ascending for display.
return rows.map(chatMessageRowToApi).reverse();
}

View File

@@ -235,10 +235,13 @@ export function notifyDumpOwnerUpvote(
export function notifyMentions(
mentionerUserId: string,
body: string,
contextType: "comment" | "dump" | "playlist",
contextType: "comment" | "dump" | "playlist" | "chat",
contextId: string,
contextTitle: string,
dumpId?: string,
// Optional: skip notifying a mentioned user (e.g. they're already reading the
// chat live, so the persistent notification would be redundant).
skipUserId?: (userId: string) => boolean,
): void {
const mentionerRow = db.prepare(
`SELECT username FROM users WHERE id = ?;`,
@@ -256,6 +259,7 @@ export function notifyMentions(
`SELECT id FROM users WHERE lower(username) = ?;`,
).get(username) as { id: string } | undefined;
if (!mentionedRow || mentionedRow.id === mentionerUserId) continue;
if (skipUserId?.(mentionedRow.id)) continue;
createNotification(
mentionedRow.id,

View File

@@ -1,8 +1,10 @@
import type {
ChatMessage,
Comment,
Dump,
OnlineUser,
Playlist,
Role,
ServerToClientMessage,
User,
} from "../model/interfaces.ts";
@@ -11,9 +13,12 @@ export interface WsClient {
socket: WebSocket;
userId?: string;
username?: string;
role?: Role;
avatarMime?: string;
avatarVersion?: number;
pongReceived?: boolean;
/** Whether this client currently has the chatbox open (is reading chat). */
chatOpen?: boolean;
}
const clients = new Set<WsClient>();
@@ -209,6 +214,34 @@ export function broadcastCommentUpdated(comment: Comment): void {
}
}
// Global chat room: every connected client receives every message.
export function broadcastChatMessage(message: ChatMessage): void {
for (const client of clients) {
send(client.socket, { type: "chat_message", message });
}
}
export function broadcastChatMessageUpdated(message: ChatMessage): void {
for (const client of clients) {
send(client.socket, { type: "chat_message_updated", message });
}
}
export function broadcastChatMessageDeleted(id: string): void {
for (const client of clients) {
send(client.socket, { type: "chat_message_deleted", id });
}
}
// 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 {
for (const client of clients) {
if (client.userId === userId && client.chatOpen) return true;
}
return false;
}
export function handleClientPong(client: WsClient): void {
client.pongReceived = true;
}