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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-22 20:36:49 +00:00
parent b313f70d31
commit 73b80af08e
20 changed files with 2091 additions and 548 deletions

View File

@@ -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;
}