317 lines
10 KiB
TypeScript
317 lines
10 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|