/** * The site-wide chat: messages, their history, and each user's read marker. * * @module */ import { APIErrorCode } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import type { ChatMessage } from "../../shared/chat.ts"; import { chatMessageRowToApi, isChatMessageRow } from "../model/chat.ts"; import { db } from "../db/connection.ts"; import { linkAttachments } from "./attachment-service.ts"; import { VALIDATION } from "../../shared/validation.ts"; /** How many messages a page holds when the caller names no limit. */ const DEFAULT_LIMIT = 50; /** The most messages a page may hold. */ const MAX_LIMIT = 100; /** * The length cap of 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. * This is raw markdown: the client strips it to text before showing a line of * it, so it's cut generously enough that an image URL or link doesn't eat it * all. */ const REPLY_SNIPPET_LEN = 300; /** The columns of a full `ChatMessageRow`, over {@link FROM_JOINS}. */ const SELECT_COLS = `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`; /** * The 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`; /** Returns message `id`, or throws a 404. */ function fetchMessage(id: string): ChatMessage { const row = db.prepare( `SELECT ${SELECT_COLS} ${FROM_JOINS} WHERE m.id = ?;`, ).get(id); if (!row || !isChatMessageRow(row)) { throw new APIException(APIErrorCode.NOT_FOUND, 404, "Message not found"); } return chatMessageRowToApi(row); } /** * Posts a message from `userId`, replying to `replyToId` when that message * exists, and binds its attachments. * * @throws {APIException} 400 when the body is blank or longer than * `VALIDATION.CHAT_MESSAGE_MAX`. */ export function createChatMessage( userId: string, body: string, replyToId?: string, ): ChatMessage { const trimmed = body.trim(); if (!trimmed) { throw new APIException( APIErrorCode.VALIDATION_ERROR, 400, "Message cannot be empty", ); } if (trimmed.length > VALIDATION.CHAT_MESSAGE_MAX) { throw new APIException( APIErrorCode.VALIDATION_ERROR, 400, `Message exceeds ${VALIDATION.CHAT_MESSAGE_MAX} 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, reply_to_id) VALUES (?, ?, ?, ?, ?);`, ).run(id, userId, trimmed, createdAt, replyTo); // Claim any pasted/dropped image attachments so the startup cleanup doesn't // purge them as orphans (mirrors dumps/comments/playlists). linkAttachments(trimmed, id); return fetchMessage(id); } /** Returns the author of message `id`, or throws a 404. */ 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; } /** * Replaces a message's body, as its author or a moderator (`canModerate`), and * binds any new attachment. * * @throws {APIException} 404 when there is no such message, 401 when the user * is neither its author nor a moderator, 400 when the body is blank or too * long. */ 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 > VALIDATION.CHAT_MESSAGE_MAX) { throw new APIException( APIErrorCode.VALIDATION_ERROR, 400, `Message exceeds ${VALIDATION.CHAT_MESSAGE_MAX} characters`, ); } db.prepare(`UPDATE chat_messages SET body = ?, updated_at = ? WHERE id = ?;`) .run(trimmed, new Date().toISOString(), id); // An edit may introduce a freshly pasted image; claim its attachment too. linkAttachments(trimmed, id); return fetchMessage(id); } /** * Deletes a message, as its author or a moderator (`canModerate`). * * @throws {APIException} 404 when there is no such message, 401 when the user * is neither its author nor a moderator. */ export function deleteChatMessage( id: string, requestingUserId: string, canModerate: boolean, ): void { const ownerId = getMessageOwner(id); // 404s if the message doesn't exist if (ownerId !== requestingUserId && !canModerate) { throw new APIException( APIErrorCode.UNAUTHORIZED, 401, "Not authorized to delete this message", ); } db.prepare(`DELETE FROM chat_messages WHERE id = ?;`).run(id); } /** * A position in the chat stream. Messages are ordered by (createdAt, id), so * two sharing a millisecond still have a strict order and a page boundary * between them can't skip either. */ export interface ChatCursor { /** The `created_at` of the message the cursor sits at. */ createdAt: string; /** * The id of that message. Omitted: the cursor sits before (for `after`) or * after (for `before`) every message sharing that timestamp. */ id?: string; } /** Converts query rows to messages. */ function toMessages(rows: unknown[]): ChatMessage[] { if (!rows.every(isChatMessageRow)) { throw new APIException( APIErrorCode.SERVER_ERROR, 500, "Malformed chat message data", ); } return rows.map(chatMessageRowToApi); } /** Clamps a page size to [1, {@link MAX_LIMIT}]. */ function capLimit(limit: number): number { return Math.min(Math.max(Math.floor(limit), 1), MAX_LIMIT); } /** * Returns up to `limit` most recent messages in ascending (display) order. * When `before` is given, returns the page of older messages immediately * preceding it — used for "load older" history. */ export function getRecentChatMessages( limit = DEFAULT_LIMIT, before?: ChatCursor, ): ChatMessage[] { const capped = capLimit(limit); const rows = before ? db.prepare( `SELECT ${SELECT_COLS} ${FROM_JOINS} WHERE m.created_at < ? OR (m.created_at = ? AND m.id < ?) ORDER BY m.created_at DESC, m.id DESC LIMIT ?;`, ).all( before.createdAt, before.createdAt, // No id: nothing at that timestamp sorts below the empty string. before.id ?? "", capped, ) : db.prepare( `SELECT ${SELECT_COLS} ${FROM_JOINS} ORDER BY m.created_at DESC, m.id DESC LIMIT ?;`, ).all(capped); // Newest-first from SQL; reverse to ascending for display. return toMessages(rows).reverse(); } /** * Returns up to `limit` messages following `after`, in ascending order — used * to resync the chat after a reconnect. Without an id the cursor is inclusive * of its timestamp. */ export function getChatMessagesAfter( after: ChatCursor, limit = DEFAULT_LIMIT, ): ChatMessage[] { const rows = db.prepare( `SELECT ${SELECT_COLS} ${FROM_JOINS} WHERE m.created_at > ? OR (m.created_at = ? AND m.id > ?) ORDER BY m.created_at ASC, m.id ASC LIMIT ?;`, ).all(after.createdAt, after.createdAt, after.id ?? "", capLimit(limit)); return toMessages(rows); } /** How far a user has read the chat. */ export interface ChatReadState { /** The `created_at` of the newest message the user has seen (ISO). */ readAt: string; /** Messages from other people since then. */ unread: number; } /** * Returns how far `userId` has read the chat; a user who never opened it has * read up to their registration. * * @throws {APIException} 404 when there is no such user. */ export function getChatReadState(userId: string): ChatReadState { const row = db.prepare( `SELECT COALESCE(u.chat_read_at, u.created_at) AS read_at, (SELECT COUNT(*) FROM chat_messages m WHERE m.user_id != u.id AND m.created_at > COALESCE(u.chat_read_at, u.created_at)) AS unread FROM users u WHERE u.id = ?;`, ).get(userId) as { read_at: string; unread: number } | undefined; if (!row) { throw new APIException(APIErrorCode.NOT_FOUND, 404, "User not found"); } return { readAt: row.read_at, unread: row.unread }; } /** * Moves the user's read marker forward to `at` (a message's created_at) — * never backward, so a lagging tab can't undo a newer tab's read, and never * past the newest message, so a bogus timestamp can't pre-read the future. * * @throws {APIException} 400 when `at` isn't a date, 404 when there is no * such user. */ export function markChatRead(userId: string, at: string): ChatReadState { const parsed = new Date(at); if (Number.isNaN(parsed.getTime())) { throw new APIException( APIErrorCode.VALIDATION_ERROR, 400, "Invalid read marker", ); } const newest = db.prepare( `SELECT MAX(created_at) AS newest FROM chat_messages;`, ).get() as { newest: string | null }; // No messages yet: there's nothing to have read, and no newest message to // cap a bogus far-future marker at — which could never be moved back. if (!newest.newest) return getChatReadState(userId); const iso = parsed.toISOString(); const target = iso > newest.newest ? newest.newest : iso; db.prepare( `UPDATE users SET chat_read_at = ? WHERE id = ? AND (chat_read_at IS NULL OR chat_read_at < ?);`, ).run(target, userId, target); return getChatReadState(userId); }