v3: the chat stops dropping things and holds up to long messages — a message sent while the socket was down vanished with the draft already cleared, and a rejected one fared no better since the client ignored any error that wasn't about a vote or a like. Sends now carry a nonce and sit dimmed in the list until their echo retires them; the server's refusal, an offline socket or ten seconds of silence turns one into a failed row with retry, edit and discard, and a failed row that turns up in the history after a reconnect is retired as having landed after all. Failed edits and deletes show in a bar above the composer. The live stream only goes to signed-in sockets, matching the history behind auth; sends, edits and deletes share a per-user token bucket (typing and read markers get their own, and are just dropped past it); and moderation re-reads the role from the database on every check rather than trusting the one a week-old socket captured. Unread now lives on the server as users.chat_read_at (migration 0014, existing users start caught up), so the count survives a reload and every tab and device clears together; opening onto unread history lands on a "new" divider rather than the bottom, and marks it read only once you've scrolled down to it. After a reconnect the chat re-reads everything from its oldest loaded message and takes that as the truth, edits and deletions included, or reloads the latest page if too much happened. History pages on (created_at, id) so a shared millisecond can't hide a message at a boundary. In the modal: a run of messages from one author shares a header with the time in the gutter on hover, mentions and replies to you get an accent edge, a pill counts what arrived while you were scrolled up, the draft survives closing and reloading, relative times tick, reply previews are flattened from markdown, owners can delete their own messages, ↑ in an empty composer edits your last one, and there's a proper loading and retry state instead of a flash of "No messages yet". Long messages: the composer and edit box grow only so far and then scroll, instead of squeezing the list away; the counter shows only near the limit and sits above the box, since .comment-reply-textarea's padding shorthand had quietly cancelled the room kept for it inside; TextEditor sizes itself before paint, so opening an edit animates to the right height instead of dipping and snapping; and the list follows the bottom through any change in its content's height while pinned there, so ending an edit returns you to the end. A mention notification opens the chat over the page you're on rather than the home page.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 43s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 43s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ import { up as up0010YoutubeEmbedStart } from "./migrations/0010_youtube_embed_s
|
||||
import { up as up0011SplitFaviconThumbnail } from "./migrations/0011_split_favicon_thumbnail.ts";
|
||||
import { up as up0012FixFaviconReclassification } from "./migrations/0012_fix_favicon_reclassification.ts";
|
||||
import { up as up0013DumpUrlCanonical } from "./migrations/0013_dump_url_canonical.ts";
|
||||
import { up as up0014ChatReadState } from "./migrations/0014_chat_read_state.ts";
|
||||
|
||||
interface Migration {
|
||||
name: string;
|
||||
@@ -38,6 +39,7 @@ const MIGRATIONS: Migration[] = [
|
||||
up: up0012FixFaviconReclassification,
|
||||
},
|
||||
{ name: "0013_dump_url_canonical", up: up0013DumpUrlCanonical },
|
||||
{ name: "0014_chat_read_state", up: up0014ChatReadState },
|
||||
];
|
||||
|
||||
export function runMigrations(db: DatabaseSync): void {
|
||||
|
||||
28
api/db/migrations/0014_chat_read_state.ts
Normal file
28
api/db/migrations/0014_chat_read_state.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
// Chat unread state lives on the server so it survives reloads and agrees
|
||||
// across tabs and devices: `users.chat_read_at` is the created_at of the newest
|
||||
// message the user has seen. Existing users start caught up (now) rather than
|
||||
// with the whole history unread; new users fall back to their own created_at.
|
||||
//
|
||||
// The history cursor now pages on (created_at, id) so two messages sharing a
|
||||
// millisecond can't be skipped at a page boundary; the index follows suit.
|
||||
//
|
||||
// Idempotent: also runs against fresh databases created from schema.sql, where
|
||||
// the column and index already exist.
|
||||
export function up(db: DatabaseSync): void {
|
||||
const cols = db.prepare(`PRAGMA table_info(users);`).all() as {
|
||||
name: string;
|
||||
}[];
|
||||
if (!cols.some((c) => c.name === "chat_read_at")) {
|
||||
db.exec(`ALTER TABLE users ADD COLUMN chat_read_at TEXT;`);
|
||||
db.prepare(`UPDATE users SET chat_read_at = ?;`).run(
|
||||
new Date().toISOString(),
|
||||
);
|
||||
}
|
||||
|
||||
db.exec(`DROP INDEX IF EXISTS idx_chat_messages_created;`);
|
||||
db.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_chat_messages_created_id ON chat_messages(created_at, id);`,
|
||||
);
|
||||
}
|
||||
@@ -30,7 +30,8 @@ CREATE TABLE users (
|
||||
avatar_mime TEXT,
|
||||
description TEXT,
|
||||
invited_by TEXT REFERENCES users(id),
|
||||
email TEXT NOT NULL
|
||||
email TEXT NOT NULL,
|
||||
chat_read_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE votes (
|
||||
@@ -205,4 +206,4 @@ CREATE TABLE chat_messages (
|
||||
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);
|
||||
CREATE INDEX idx_chat_messages_created_id ON chat_messages(created_at, id);
|
||||
|
||||
@@ -101,7 +101,8 @@ export type Role = "user" | "moderator" | "admin";
|
||||
export const ROLES: readonly Role[] = ["user", "moderator", "admin"];
|
||||
|
||||
export function isRole(value: unknown): value is Role {
|
||||
return typeof value === "string" && (ROLES as readonly string[]).includes(value);
|
||||
return typeof value === "string" &&
|
||||
(ROLES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export interface User {
|
||||
@@ -595,6 +596,9 @@ export interface ChatSendMessage {
|
||||
body: string;
|
||||
/** Id of the message being replied to, if this is a reply. */
|
||||
replyToId?: string;
|
||||
/** Client-chosen id echoed on the broadcast (or on the error), so the
|
||||
* sender can match the outcome to its pending message. */
|
||||
nonce?: string;
|
||||
}
|
||||
|
||||
// Sent while the user is composing (true) and when they stop (false). The
|
||||
@@ -622,6 +626,12 @@ export interface ChatDeleteMessage {
|
||||
id: string;
|
||||
}
|
||||
|
||||
// The user has seen the chat up to the message created at `at`.
|
||||
export interface ChatReadMessage {
|
||||
type: "chat_read";
|
||||
at: string;
|
||||
}
|
||||
|
||||
export type ClientToServerMessage =
|
||||
| PingMessage
|
||||
| PongMessage
|
||||
@@ -633,7 +643,8 @@ export type ClientToServerMessage =
|
||||
| ChatTypingMessage
|
||||
| ChatFocusMessage
|
||||
| ChatEditMessage
|
||||
| ChatDeleteMessage;
|
||||
| ChatDeleteMessage
|
||||
| ChatReadMessage;
|
||||
|
||||
// ── Server → Client ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -650,6 +661,10 @@ export interface WelcomeMessage {
|
||||
myVotes: string[];
|
||||
myCommentLikes: string[];
|
||||
unreadNotificationCount: number;
|
||||
/** The user's chat read marker and the unread count behind it (signed-in
|
||||
* users only). */
|
||||
chatReadAt?: string;
|
||||
unreadChatCount: number;
|
||||
}
|
||||
|
||||
export interface PresenceUpdateMessage {
|
||||
@@ -758,6 +773,10 @@ export interface ErrorMessage {
|
||||
dumpId?: string;
|
||||
commentId?: string;
|
||||
action?: "cast" | "remove";
|
||||
/** Set when a chat action failed, so the client can surface it in the chat. */
|
||||
scope?: "chat";
|
||||
/** Echo of the failed chat_send's nonce. */
|
||||
nonce?: string;
|
||||
}
|
||||
|
||||
export interface ForceLogoutMessage {
|
||||
@@ -767,6 +786,8 @@ export interface ForceLogoutMessage {
|
||||
export interface ChatMessageMessage {
|
||||
type: "chat_message";
|
||||
message: ChatMessage;
|
||||
/** The sender's nonce, so its pending copy can be retired. */
|
||||
nonce?: string;
|
||||
}
|
||||
|
||||
export interface ChatMessageUpdatedMessage {
|
||||
@@ -786,6 +807,13 @@ export interface ChatTypingUpdateMessage {
|
||||
users: OnlineUser[];
|
||||
}
|
||||
|
||||
// The user's read marker moved (from any of their tabs or devices).
|
||||
export interface ChatReadUpdateMessage {
|
||||
type: "chat_read_update";
|
||||
readAt: string;
|
||||
unread: number;
|
||||
}
|
||||
|
||||
export type ServerToClientMessage =
|
||||
| PingMessage
|
||||
| WelcomeMessage
|
||||
@@ -811,7 +839,8 @@ export type ServerToClientMessage =
|
||||
| ChatMessageMessage
|
||||
| ChatMessageUpdatedMessage
|
||||
| ChatMessageDeletedMessage
|
||||
| ChatTypingUpdateMessage;
|
||||
| ChatTypingUpdateMessage
|
||||
| ChatReadUpdateMessage;
|
||||
|
||||
/**
|
||||
* Follows
|
||||
|
||||
@@ -1,21 +1,36 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import type { APIResponse, ChatMessage } from "../model/interfaces.ts";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
import { getRecentChatMessages } from "../services/chat-service.ts";
|
||||
import {
|
||||
getChatMessagesAfter,
|
||||
getRecentChatMessages,
|
||||
} from "../services/chat-service.ts";
|
||||
|
||||
const router = new Router({ prefix: "/api/chat" });
|
||||
|
||||
// GET /api/chat — recent messages (ascending). Optional `before` (ISO created_at)
|
||||
// cursor loads the page of older messages, and `limit` caps the page size.
|
||||
// GET /api/chat — recent messages (ascending). `limit` caps the page size.
|
||||
// Cursors (ISO created_at, plus the message id to break ties):
|
||||
// before/beforeId — the page of older messages preceding it ("load older");
|
||||
// after/afterId — the messages following it (resync after a reconnect);
|
||||
// without afterId the timestamp itself is included.
|
||||
router.get("/", authMiddleware, (ctx) => {
|
||||
const params = ctx.request.url.searchParams;
|
||||
const before = params.get("before") ?? undefined;
|
||||
const limitParam = params.get("limit");
|
||||
const limit = limitParam ? Number(limitParam) : undefined;
|
||||
const messages = getRecentChatMessages(
|
||||
Number.isFinite(limit) ? limit : undefined,
|
||||
before,
|
||||
);
|
||||
const limit = limitParam ? Number(limitParam) : NaN;
|
||||
const cappedLimit = Number.isFinite(limit) ? limit : undefined;
|
||||
const before = params.get("before");
|
||||
const after = params.get("after");
|
||||
const messages = after
|
||||
? getChatMessagesAfter(
|
||||
{ createdAt: after, id: params.get("afterId") ?? undefined },
|
||||
cappedLimit,
|
||||
)
|
||||
: getRecentChatMessages(
|
||||
cappedLimit,
|
||||
before
|
||||
? { createdAt: before, id: params.get("beforeId") ?? undefined }
|
||||
: undefined,
|
||||
);
|
||||
const responseBody: APIResponse<ChatMessage[]> = {
|
||||
success: true,
|
||||
data: messages,
|
||||
|
||||
179
api/routes/ws.ts
179
api/routes/ws.ts
@@ -12,14 +12,19 @@ import {
|
||||
getOnlineUsers,
|
||||
handleClientPong,
|
||||
isUserChatOpen,
|
||||
type RateLimit,
|
||||
register,
|
||||
sendToUser,
|
||||
setClientTyping,
|
||||
takeToken,
|
||||
unregister,
|
||||
type WsClient,
|
||||
} from "../services/ws-service.ts";
|
||||
import {
|
||||
createChatMessage,
|
||||
deleteChatMessage,
|
||||
getChatReadState,
|
||||
markChatRead,
|
||||
updateChatMessage,
|
||||
} from "../services/chat-service.ts";
|
||||
import { notifyMentions } from "../services/notification-service.ts";
|
||||
@@ -89,12 +94,17 @@ router.get("/ws", async (ctx) => {
|
||||
const unreadNotificationCount = authPayload
|
||||
? getUnreadCount(authPayload.userId)
|
||||
: 0;
|
||||
const chatRead = authPayload
|
||||
? getChatReadState(authPayload.userId)
|
||||
: undefined;
|
||||
socket.send(JSON.stringify({
|
||||
type: "welcome",
|
||||
users: getOnlineUsers(),
|
||||
myVotes,
|
||||
myCommentLikes,
|
||||
unreadNotificationCount,
|
||||
chatReadAt: chatRead?.readAt,
|
||||
unreadChatCount: chatRead?.unread ?? 0,
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error("[ws] welcome send failed:", err);
|
||||
@@ -129,13 +139,19 @@ router.get("/ws", async (ctx) => {
|
||||
handleCommentLike(client, msg.commentId, "remove");
|
||||
break;
|
||||
case "chat_send":
|
||||
handleChatSend(client, msg.body, msg.replyToId);
|
||||
handleChatSend(client, msg.body, msg.replyToId, msg.nonce);
|
||||
break;
|
||||
case "chat_typing":
|
||||
if (client.userId) setClientTyping(client, msg.typing);
|
||||
// Typing is cosmetic: past the limit it's just dropped.
|
||||
if (
|
||||
client.userId &&
|
||||
takeToken(`typing:${client.userId}`, TYPING_LIMIT)
|
||||
) {
|
||||
setClientTyping(client, msg.typing === true);
|
||||
}
|
||||
break;
|
||||
case "chat_focus":
|
||||
client.chatOpen = msg.open;
|
||||
client.chatOpen = msg.open === true;
|
||||
break;
|
||||
case "chat_edit":
|
||||
handleChatEdit(client, msg.id, msg.body);
|
||||
@@ -143,6 +159,9 @@ router.get("/ws", async (ctx) => {
|
||||
case "chat_delete":
|
||||
handleChatDelete(client, msg.id);
|
||||
break;
|
||||
case "chat_read":
|
||||
handleChatRead(client, msg.at);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -221,32 +240,79 @@ function handleCommentLike(
|
||||
}
|
||||
}
|
||||
|
||||
// Sending, editing and deleting share one bucket: a burst of 8, then one every
|
||||
// 1.5s — room for a quick back-and-forth, not for a flood.
|
||||
const CHAT_WRITE_LIMIT: RateLimit = { capacity: 8, refill: 1 / 1.5 };
|
||||
const TYPING_LIMIT: RateLimit = { capacity: 10, refill: 1 };
|
||||
// Read markers are cheap for the client to send often; past this they're
|
||||
// dropped, and the next one catches up.
|
||||
const CHAT_READ_LIMIT: RateLimit = { capacity: 10, refill: 1 };
|
||||
|
||||
function sendChatError(
|
||||
client: WsClient,
|
||||
message: string,
|
||||
nonce?: string,
|
||||
): void {
|
||||
if (client.socket.readyState !== WebSocket.OPEN) return;
|
||||
client.socket.send(
|
||||
JSON.stringify({ type: "error", scope: "chat", message, nonce }),
|
||||
);
|
||||
}
|
||||
|
||||
// Validates the common preconditions of a chat write: signed in and under the
|
||||
// rate limit. Returns the user id, or undefined after reporting why not.
|
||||
function chatWriter(client: WsClient, nonce?: string): string | undefined {
|
||||
if (!client.userId) {
|
||||
sendChatError(client, "Authentication required", nonce);
|
||||
return undefined;
|
||||
}
|
||||
if (!takeToken(`chat:${client.userId}`, CHAT_WRITE_LIMIT)) {
|
||||
sendChatError(
|
||||
client,
|
||||
"You're sending messages too fast — wait a moment",
|
||||
nonce,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
return client.userId;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown, maxLength: number): string | undefined {
|
||||
return typeof value === "string" && value.length <= maxLength
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function handleChatSend(
|
||||
client: WsClient,
|
||||
body: string,
|
||||
replyToId?: string,
|
||||
body: unknown,
|
||||
replyToId: unknown,
|
||||
rawNonce: unknown,
|
||||
): void {
|
||||
const { socket } = client;
|
||||
|
||||
if (!client.userId) {
|
||||
socket.send(
|
||||
JSON.stringify({ type: "error", message: "Authentication required" }),
|
||||
);
|
||||
const nonce = optionalString(rawNonce, 64);
|
||||
const userId = chatWriter(client, nonce);
|
||||
if (!userId) return;
|
||||
if (typeof body !== "string") {
|
||||
sendChatError(client, "Message cannot be empty", nonce);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const message = createChatMessage(client.userId, body, replyToId);
|
||||
const message = createChatMessage(
|
||||
userId,
|
||||
body,
|
||||
optionalString(replyToId, 64),
|
||||
);
|
||||
// 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);
|
||||
// Broadcast to everyone including the sender, whose pending copy the nonce
|
||||
// retires.
|
||||
broadcastChatMessage(message, nonce);
|
||||
// Skip mention notifications for users who already have the chat open —
|
||||
// they've seen the message live.
|
||||
notifyMentions(
|
||||
client.userId,
|
||||
userId,
|
||||
message.body,
|
||||
"chat",
|
||||
message.id,
|
||||
@@ -255,24 +321,32 @@ function handleChatSend(
|
||||
isUserChatOpen,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof APIException
|
||||
? err.message
|
||||
: "Failed to send message";
|
||||
socket.send(JSON.stringify({ type: "error", message }));
|
||||
sendChatError(
|
||||
client,
|
||||
err instanceof APIException ? err.message : "Failed to send message",
|
||||
nonce,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Reads the role from the DB on every moderation check rather than trusting the
|
||||
// one captured at connect: a demoted moderator loses the power right away, not
|
||||
// whenever their socket next reconnects.
|
||||
function canModerateChat(client: WsClient): boolean {
|
||||
if (!client.userId) return false;
|
||||
try {
|
||||
client.role = getUserById(client.userId).role;
|
||||
} catch {
|
||||
client.role = undefined;
|
||||
}
|
||||
return !!client.role && hasPermission(client.role, "chat:moderate");
|
||||
}
|
||||
|
||||
function handleChatEdit(client: WsClient, id: string, body: string): void {
|
||||
const { socket } = client;
|
||||
|
||||
if (!client.userId) {
|
||||
socket.send(
|
||||
JSON.stringify({ type: "error", message: "Authentication required" }),
|
||||
);
|
||||
function handleChatEdit(client: WsClient, id: unknown, body: unknown): void {
|
||||
const userId = chatWriter(client);
|
||||
if (!userId) return;
|
||||
if (typeof id !== "string" || typeof body !== "string") {
|
||||
sendChatError(client, "Invalid message");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -280,14 +354,14 @@ function handleChatEdit(client: WsClient, id: string, body: string): void {
|
||||
const message = updateChatMessage(
|
||||
id,
|
||||
body,
|
||||
client.userId,
|
||||
userId,
|
||||
canModerateChat(client),
|
||||
);
|
||||
broadcastChatMessageUpdated(message);
|
||||
// A newly added @mention in an edit should still notify (deduped per
|
||||
// message+user), skipping anyone already reading the chat.
|
||||
notifyMentions(
|
||||
client.userId,
|
||||
userId,
|
||||
message.body,
|
||||
"chat",
|
||||
message.id,
|
||||
@@ -296,31 +370,46 @@ function handleChatEdit(client: WsClient, id: string, body: string): void {
|
||||
isUserChatOpen,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof APIException
|
||||
? err.message
|
||||
: "Failed to edit message";
|
||||
socket.send(JSON.stringify({ type: "error", message }));
|
||||
sendChatError(
|
||||
client,
|
||||
err instanceof APIException ? err.message : "Failed to edit message",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function handleChatDelete(client: WsClient, id: string): void {
|
||||
const { socket } = client;
|
||||
|
||||
if (!client.userId) {
|
||||
socket.send(
|
||||
JSON.stringify({ type: "error", message: "Authentication required" }),
|
||||
);
|
||||
function handleChatDelete(client: WsClient, id: unknown): void {
|
||||
const userId = chatWriter(client);
|
||||
if (!userId) return;
|
||||
if (typeof id !== "string") {
|
||||
sendChatError(client, "Invalid message");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
deleteChatMessage(id, canModerateChat(client));
|
||||
deleteChatMessage(id, userId, canModerateChat(client));
|
||||
broadcastChatMessageDeleted(id);
|
||||
} catch (err) {
|
||||
const message = err instanceof APIException
|
||||
? err.message
|
||||
: "Failed to delete message";
|
||||
socket.send(JSON.stringify({ type: "error", message }));
|
||||
sendChatError(
|
||||
client,
|
||||
err instanceof APIException ? err.message : "Failed to delete message",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Moves the user's read marker and tells all their sockets, so every tab and
|
||||
// device drops its badge together.
|
||||
function handleChatRead(client: WsClient, at: unknown): void {
|
||||
if (!client.userId || typeof at !== "string") return;
|
||||
if (!takeToken(`read:${client.userId}`, CHAT_READ_LIMIT)) return;
|
||||
try {
|
||||
const state = markChatRead(client.userId, at);
|
||||
sendToUser(client.userId, {
|
||||
type: "chat_read_update",
|
||||
readAt: state.readAt,
|
||||
unread: state.unread,
|
||||
});
|
||||
} catch {
|
||||
// A malformed marker changes nothing; there's nothing to report.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,10 @@ 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;
|
||||
// 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;
|
||||
|
||||
const SELECT_COLS =
|
||||
`m.id, m.user_id, m.body, m.created_at, m.updated_at, m.reply_to_id,
|
||||
@@ -21,8 +23,7 @@ const SELECT_COLS =
|
||||
|
||||
// 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
|
||||
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`;
|
||||
@@ -124,39 +125,34 @@ export function updateChatMessage(
|
||||
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) {
|
||||
// Owners may delete their own messages; moderators may delete any.
|
||||
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",
|
||||
);
|
||||
}
|
||||
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_JOINS}
|
||||
WHERE m.created_at < ? ORDER BY m.created_at DESC LIMIT ?;`,
|
||||
).all(before, capped)
|
||||
: db.prepare(
|
||||
`SELECT ${SELECT_COLS} ${FROM_JOINS}
|
||||
ORDER BY m.created_at DESC LIMIT ?;`,
|
||||
).all(capped);
|
||||
/** 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 {
|
||||
createdAt: string;
|
||||
/** Omitted: the cursor sits before (for `after`) or after (for `before`)
|
||||
* every message sharing that timestamp. */
|
||||
id?: string;
|
||||
}
|
||||
|
||||
function toMessages(rows: unknown[]): ChatMessage[] {
|
||||
if (!rows.every(isChatMessageRow)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.SERVER_ERROR,
|
||||
@@ -164,6 +160,103 @@ export function getRecentChatMessages(
|
||||
"Malformed chat message data",
|
||||
);
|
||||
}
|
||||
// Newest-first from SQL; reverse to ascending for display.
|
||||
return rows.map(chatMessageRowToApi).reverse();
|
||||
return rows.map(chatMessageRowToApi);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
export interface ChatReadState {
|
||||
/** created_at of the newest message the user has seen (ISO). */
|
||||
readAt: string;
|
||||
/** Messages from other people since then. */
|
||||
unread: number;
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
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 };
|
||||
const iso = parsed.toISOString();
|
||||
const target = newest.newest && 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);
|
||||
}
|
||||
|
||||
@@ -217,23 +217,28 @@ export function broadcastCommentUpdated(comment: Comment): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Global chat room: every connected client receives every message.
|
||||
export function broadcastChatMessage(message: ChatMessage): void {
|
||||
// Global chat room for members: history is behind auth, so the live stream is
|
||||
// too — an anonymous socket gets nothing chat-related.
|
||||
function sendToMembers(data: ServerToClientMessage): void {
|
||||
for (const client of clients) {
|
||||
send(client.socket, { type: "chat_message", message });
|
||||
if (client.userId) send(client.socket, data);
|
||||
}
|
||||
}
|
||||
|
||||
// `nonce` is the sender's id for its pending copy; harmless to everyone else.
|
||||
export function broadcastChatMessage(
|
||||
message: ChatMessage,
|
||||
nonce?: string,
|
||||
): void {
|
||||
sendToMembers({ type: "chat_message", message, nonce });
|
||||
}
|
||||
|
||||
export function broadcastChatMessageUpdated(message: ChatMessage): void {
|
||||
for (const client of clients) {
|
||||
send(client.socket, { type: "chat_message_updated", message });
|
||||
}
|
||||
sendToMembers({ type: "chat_message_updated", message });
|
||||
}
|
||||
|
||||
export function broadcastChatMessageDeleted(id: string): void {
|
||||
for (const client of clients) {
|
||||
send(client.socket, { type: "chat_message_deleted", id });
|
||||
}
|
||||
sendToMembers({ type: "chat_message_deleted", id });
|
||||
}
|
||||
|
||||
// Typing indicator: a client's "typing" signal is considered live for this long
|
||||
@@ -263,10 +268,7 @@ export function getTypingUsers(): OnlineUser[] {
|
||||
}
|
||||
|
||||
export function broadcastTyping(): void {
|
||||
const users = getTypingUsers();
|
||||
for (const client of clients) {
|
||||
send(client.socket, { type: "chat_typing_update", users });
|
||||
}
|
||||
sendToMembers({ type: "chat_typing_update", users: getTypingUsers() });
|
||||
}
|
||||
|
||||
// Record (or clear) a client's typing state and tell everyone. The client also
|
||||
@@ -303,6 +305,45 @@ export function isUserChatOpen(userId: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Token buckets keyed by `${kind}:${userId}` — per user rather than per socket,
|
||||
// so opening more tabs doesn't buy more throughput.
|
||||
interface Bucket {
|
||||
tokens: number;
|
||||
at: number;
|
||||
}
|
||||
const buckets = new Map<string, Bucket>();
|
||||
|
||||
export interface RateLimit {
|
||||
/** Burst size. */
|
||||
capacity: number;
|
||||
/** Tokens regained per second. */
|
||||
refill: number;
|
||||
}
|
||||
|
||||
/** Takes one token for `key`; false when the bucket is empty. */
|
||||
export function takeToken(key: string, limit: RateLimit): boolean {
|
||||
const now = Date.now();
|
||||
const bucket = buckets.get(key) ?? { tokens: limit.capacity, at: now };
|
||||
bucket.tokens = Math.min(
|
||||
limit.capacity,
|
||||
bucket.tokens + ((now - bucket.at) / 1000) * limit.refill,
|
||||
);
|
||||
bucket.at = now;
|
||||
buckets.set(key, bucket);
|
||||
if (bucket.tokens < 1) return false;
|
||||
bucket.tokens -= 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Full buckets carry no information; drop them so the map tracks only users
|
||||
// who've been active lately.
|
||||
setInterval(() => {
|
||||
const cutoff = Date.now() - 5 * 60_000;
|
||||
for (const [key, bucket] of buckets) {
|
||||
if (bucket.at < cutoff) buckets.delete(key);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
export function handleClientPong(client: WsClient): void {
|
||||
client.pongReceived = true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user