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:
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.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user