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:
@@ -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