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

@@ -40,6 +40,30 @@ export interface UserEvent {
user: PublicUser;
}
/** The replied-to message, as shown in the reply bar and on a pending send. */
export interface ChatReplyTarget {
id: string;
author: string;
body: string;
}
/** A message this client sent that the server hasn't echoed back yet — shown
* in the list as "sending", or "failed" with a way to retry or discard it. */
export interface PendingChatMessage {
nonce: string;
body: string;
replyTo?: ChatReplyTarget;
createdAt: Date;
status: "sending" | "failed";
error?: string;
}
/** A failed chat edit or delete, surfaced in the chat until dismissed. */
export interface ChatError {
id: number;
message: string;
}
export interface WSContextValue {
wsStatus: "connecting" | "connected" | "disconnected";
wsErrorMessage: string | null;
@@ -64,7 +88,14 @@ export interface WSContextValue {
/** Ids of chat messages deleted live this session, so fetched history can
* filter them out. */
deletedChatIds: Set<string>;
/** Messages from others since the read marker — kept by the server, so it
* survives reloads and agrees across tabs and devices. */
unreadChatCount: number;
/** The server's read marker: created_at of the newest message the user has
* seen. Null until the socket's welcome. */
chatReadAt: Date | null;
pendingChatMessages: PendingChatMessage[];
chatError: ChatError | null;
/** Users currently composing a chat message (excludes nobody — the chatbox
* filters out the current user itself). Shaped like presence for avatars. */
typingUsers: OnlineUser[];
@@ -76,10 +107,18 @@ export interface WSContextValue {
removeCommentLike: (commentId: string) => void;
injectDump: (dump: Dump) => void;
clearUnreadNotifications: () => void;
sendChatMessage: (body: string, replyToId?: string) => void;
sendChatMessage: (body: string, replyTo?: ChatReplyTarget) => void;
retryChatMessage: (nonce: string) => void;
discardChatMessage: (nonce: string) => void;
editChatMessage: (id: string, body: string) => void;
deleteChatMessage: (id: string) => void;
clearUnreadChat: () => void;
dismissChatError: () => void;
/** Moves the read marker up to the message created at `at`. */
markChatRead: (at: Date) => void;
/** Folds an authoritative fetch of the stream between `from` and `to` into
* the live buffer after a reconnect: buffered messages in that range which
* the server no longer has were deleted while the socket was down. */
reconcileChat: (messages: ChatMessage[], from: Date, to: Date) => void;
/** Inform the server whether the user is currently composing a message, so it
* can show/hide the "is typing" indicator to other clients. */
setChatTyping: (typing: boolean) => void;
@@ -110,6 +149,9 @@ export const WSContext = createContext<WSContextValue>({
chatMessages: [],
deletedChatIds: new Set(),
unreadChatCount: 0,
chatReadAt: null,
pendingChatMessages: [],
chatError: null,
typingUsers: [],
connectionEpoch: 0,
castVote: () => {},
@@ -119,9 +161,13 @@ export const WSContext = createContext<WSContextValue>({
injectDump: () => {},
clearUnreadNotifications: () => {},
sendChatMessage: () => {},
retryChatMessage: () => {},
discardChatMessage: () => {},
editChatMessage: () => {},
deleteChatMessage: () => {},
clearUnreadChat: () => {},
dismissChatError: () => {},
markChatRead: () => {},
reconcileChat: () => {},
setChatTyping: () => {},
setChatOpen: () => {},
});

View File

@@ -8,8 +8,11 @@ import {
useState,
} from "react";
import {
type ChatError,
type ChatReplyTarget,
type CommentEvent,
type LikeEvent,
type PendingChatMessage,
type PlaylistEvent,
type UserEvent,
type VoteEvent,
@@ -41,6 +44,7 @@ import { t } from "@lingui/core/macro";
import { useAuth } from "../hooks/useAuth.ts";
import { setTitleBadge } from "../hooks/useDocumentTitle.ts";
import { playChime } from "../utils/sound.ts";
import { mentionsUser } from "../utils/chatText.ts";
interface WSProviderProps {
children: ReactNode;
@@ -56,17 +60,13 @@ const MAX_RECENT_DUMPS = 100;
// Cap the live chat buffer so a long-lived session can't grow it without bound.
// Older messages remain reachable via the /api/chat history endpoint.
const MAX_CHAT_MESSAGES = 200;
// Mirrors MENTION_RE in api/services/notification-service.ts.
const MENTION_RE = /(?<![[(\w])@(\w+)/g;
function mentionsUser(body: string, username: string): boolean {
const target = username.toLowerCase();
for (const m of body.matchAll(MENTION_RE)) {
if (m[1].toLowerCase() === target) return true;
}
return false;
}
// How long a sent chat message may go without its echo before it's shown as
// failed, and how often the read marker may go out.
const CHAT_SEND_TIMEOUT = 10_000;
const CHAT_READ_THROTTLE = 1_000;
// A failed send whose text turns up in the resynced history within this long
// of being sent did reach the server — only its echo was lost.
const CHAT_ECHO_WINDOW = 60_000;
interface PendingVote {
timeout: ReturnType<typeof setTimeout>;
@@ -132,6 +132,11 @@ export function WSProvider({ children }: WSProviderProps) {
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
const [deletedChatIds, setDeletedChatIds] = useState<Set<string>>(new Set());
const [unreadChatCount, setUnreadChatCount] = useState(0);
const [chatReadAt, setChatReadAt] = useState<Date | null>(null);
const [pendingChatMessages, setPendingChatMessages] = useState<
PendingChatMessage[]
>([]);
const [chatError, setChatError] = useState<ChatError | null>(null);
const [typingUsers, setTypingUsers] = useState<OnlineUser[]>([]);
// Live avatar overrides for site-wide Avatar refresh (own narrow context).
const [avatarOverrides, setAvatarOverrides] = useState<
@@ -170,6 +175,9 @@ export function WSProvider({ children }: WSProviderProps) {
setChatMessages([]);
setDeletedChatIds(new Set());
setUnreadChatCount(0);
setChatReadAt(null);
setPendingChatMessages([]);
setChatError(null);
setTypingUsers([]);
setAvatarOverrides({});
}
@@ -205,6 +213,34 @@ export function WSProvider({ children }: WSProviderProps) {
() => chatOpenRef.current && !document.hidden,
[],
);
// Echo timeouts of in-flight chat sends, by nonce.
const chatSendTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
new Map(),
);
const chatErrorSeqRef = useRef(0);
// Read marker bookkeeping: the newest marker sent (or learned from the
// server), and a trailing timer so a burst of arrivals sends one marker.
const chatReadSentRef = useRef(0);
const chatReadQueuedRef = useRef(0);
const chatReadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const chatReadLastSendRef = useRef(0);
const failPendingChat = useCallback((nonce: string, error: string) => {
const timer = chatSendTimersRef.current.get(nonce);
if (timer) clearTimeout(timer);
chatSendTimersRef.current.delete(nonce);
setPendingChatMessages((prev) =>
prev.map((p) => p.nonce === nonce ? { ...p, status: "failed", error } : p)
);
}, []);
const retirePendingChat = useCallback((nonce: string) => {
const timer = chatSendTimersRef.current.get(nonce);
if (timer) clearTimeout(timer);
chatSendTimersRef.current.delete(nonce);
setPendingChatMessages((prev) => prev.filter((p) => p.nonce !== nonce));
}, []);
// Tracks pending optimistic votes: dumpId → pending rollback handler
const pendingRef = useRef<Map<string, PendingVote>>(
new Map(),
@@ -321,6 +357,15 @@ export function WSProvider({ children }: WSProviderProps) {
setMyVotes(new Set(msg.myVotes));
setMyLikes(new Set(msg.myCommentLikes));
setUnreadNotificationCount(msg.unreadNotificationCount);
setUnreadChatCount(msg.unreadChatCount);
if (msg.chatReadAt) {
const readAt = new Date(msg.chatReadAt);
setChatReadAt(readAt);
chatReadSentRef.current = Math.max(
chatReadSentRef.current,
readAt.getTime(),
);
}
// welcome provides authoritative server state — cancel any
// in-flight revert timers, they are now superseded.
clearAllPendingVotes();
@@ -328,13 +373,37 @@ export function WSProvider({ children }: WSProviderProps) {
// Reconnect (not the first welcome): signal pages to backfill the
// dumps/comments they couldn't receive while disconnected.
welcomeCount += 1;
if (welcomeCount > 1) setConnectionEpoch((n) => n + 1);
if (welcomeCount > 1) {
setConnectionEpoch((n) => n + 1);
// With the chatbox closed nothing will resync the live buffer,
// and whatever was edited or deleted meanwhile would win over
// the fresh history it fetches on open — so just drop it.
if (!chatOpenRef.current) {
setChatMessages([]);
setDeletedChatIds(new Set());
}
}
// A marker that was waiting on the socket goes out now.
if (chatReadQueuedRef.current > chatReadSentRef.current) {
ws.send(
JSON.stringify(
{
type: "chat_read",
at: new Date(chatReadQueuedRef.current).toISOString(),
} satisfies OutgoingWSMessage,
),
);
chatReadSentRef.current = chatReadQueuedRef.current;
}
// Restore the chat-open flag on the fresh connection so mention
// suppression keeps working across reconnects.
if (isViewingChat()) {
ws.send(
JSON.stringify(
{ type: "chat_focus", open: true } satisfies OutgoingWSMessage,
{
type: "chat_focus",
open: true,
} satisfies OutgoingWSMessage,
),
);
}
@@ -528,9 +597,11 @@ export function WSProvider({ children }: WSProviderProps) {
if (prev.some((m) => m.id === message.id)) return prev;
return [...prev, message].slice(-MAX_CHAT_MESSAGES);
});
// Count as unread unless it's the current user's own message.
if (msg.nonce) retirePendingChat(msg.nonce);
// Count as unread unless it's the current user's own message, or
// they're looking at it (the chatbox then moves the read marker).
if (message.userId !== userIdRef.current) {
setUnreadChatCount((prev) => prev + 1);
if (!isViewingChat()) setUnreadChatCount((prev) => prev + 1);
const username = usernameRef.current;
if (username && mentionsUser(message.body, username)) {
// Someone reading the chat gets no notification for a mention
@@ -567,11 +638,28 @@ export function WSProvider({ children }: WSProviderProps) {
setTypingUsers(msg.users);
break;
case "chat_read_update": {
const readAt = new Date(msg.readAt);
chatReadSentRef.current = Math.max(
chatReadSentRef.current,
readAt.getTime(),
);
setChatReadAt(readAt);
setUnreadChatCount(msg.unread);
break;
}
case "force_logout":
onForceLogoutRef.current();
break;
case "error": {
if (msg.scope === "chat") {
const message = msg.message ?? t`Something went wrong`;
if (msg.nonce) failPendingChat(msg.nonce, message);
else setChatError({ id: ++chatErrorSeqRef.current, message });
break;
}
// When the server identifies the failed optimistic action, revert
// its pending vote/like right away instead of waiting out the ACK
// timeout. Without context, the per-action timer handles cleanup.
@@ -601,6 +689,15 @@ export function WSProvider({ children }: WSProviderProps) {
connectTimeout = null;
}
if (closed) return;
// Sends still waiting on their echo won't get one on this socket.
// They may have landed, though: the resync after reconnect retires
// any whose text shows up in the history.
for (const nonce of chatSendTimersRef.current.keys()) {
failPendingChat(
nonce,
t`Connection lost — this message may not have been sent`,
);
}
setWSStatus("disconnected");
setWSErrorMessage(
everConnected
@@ -642,7 +739,9 @@ export function WSProvider({ children }: WSProviderProps) {
clearPendingVote,
clearAllPendingLikes,
clearPendingLike,
failPendingChat,
isViewingChat,
retirePendingChat,
token,
]);
@@ -798,21 +897,80 @@ export function WSProvider({ children }: WSProviderProps) {
setUnreadNotificationCount(0);
}, []);
const sendChatMessage = useCallback((body: string, replyToId?: string) => {
const trimmed = body.trim();
if (!trimmed) return;
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(
JSON.stringify(
{
type: "chat_send",
body: trimmed,
...(replyToId ? { replyToId } : {}),
} satisfies OutgoingWSMessage,
),
);
// Sends (or resends) a pending message: it shows as "sending" until its echo
// arrives, or as failed if the socket is down, the server rejects it, or no
// echo comes back in time.
const transmitPendingChat = useCallback((pending: PendingChatMessage) => {
const socket = socketRef.current;
if (socket?.readyState !== WebSocket.OPEN) {
failPendingChat(pending.nonce, t`You're offline — not sent`);
return;
}
}, []);
socket.send(
JSON.stringify(
{
type: "chat_send",
body: pending.body,
nonce: pending.nonce,
...(pending.replyTo ? { replyToId: pending.replyTo.id } : {}),
} satisfies OutgoingWSMessage,
),
);
chatSendTimersRef.current.set(
pending.nonce,
setTimeout(
() =>
failPendingChat(
pending.nonce,
t`No reply from the server — this message may not have been sent`,
),
CHAT_SEND_TIMEOUT,
),
);
}, [failPendingChat]);
const sendChatMessage = useCallback(
(body: string, replyTo?: ChatReplyTarget) => {
const trimmed = body.trim();
if (!trimmed) return;
const pending: PendingChatMessage = {
nonce: crypto.randomUUID(),
body: trimmed,
replyTo,
createdAt: new Date(),
status: "sending",
};
setPendingChatMessages((prev) => [...prev, pending]);
transmitPendingChat(pending);
},
[transmitPendingChat],
);
const pendingChatRef = useRef(pendingChatMessages);
useLayoutEffect(() => {
pendingChatRef.current = pendingChatMessages;
});
const retryChatMessage = useCallback((nonce: string) => {
const pending = pendingChatRef.current.find((p) => p.nonce === nonce);
if (!pending) return;
// A fresh nonce: should the first attempt's echo still turn up, it mustn't
// retire the retry.
const retry: PendingChatMessage = {
...pending,
nonce: crypto.randomUUID(),
status: "sending",
error: undefined,
};
setPendingChatMessages((prev) =>
prev.map((p) => (p.nonce === nonce ? retry : p))
);
transmitPendingChat(retry);
}, [transmitPendingChat]);
const discardChatMessage = useCallback((nonce: string) => {
retirePendingChat(nonce);
}, [retirePendingChat]);
const editChatMessage = useCallback((id: string, body: string) => {
const trimmed = body.trim();
@@ -836,10 +994,71 @@ export function WSProvider({ children }: WSProviderProps) {
}
}, []);
const clearUnreadChat = useCallback(() => {
setUnreadChatCount(0);
const dismissChatError = useCallback(() => setChatError(null), []);
const flushChatRead = useCallback(() => {
chatReadTimerRef.current = null;
const at = chatReadQueuedRef.current;
if (at <= chatReadSentRef.current) return;
const socket = socketRef.current;
// Offline: stays queued, and the next welcome sends it.
if (socket?.readyState !== WebSocket.OPEN) return;
socket.send(
JSON.stringify(
{
type: "chat_read",
at: new Date(at).toISOString(),
} satisfies OutgoingWSMessage,
),
);
chatReadSentRef.current = at;
chatReadLastSendRef.current = Date.now();
}, []);
const markChatRead = useCallback((at: Date) => {
const time = at.getTime();
if (time <= Math.max(chatReadSentRef.current, chatReadQueuedRef.current)) {
return;
}
chatReadQueuedRef.current = time;
// The badge goes at once; the server's count follows with the marker.
setUnreadChatCount(0);
if (chatReadTimerRef.current) return;
const wait = Math.max(
0,
CHAT_READ_THROTTLE - (Date.now() - chatReadLastSendRef.current),
);
chatReadTimerRef.current = setTimeout(flushChatRead, wait);
}, [flushChatRead]);
useEffect(() => () => {
if (chatReadTimerRef.current) clearTimeout(chatReadTimerRef.current);
for (const timer of chatSendTimersRef.current.values()) clearTimeout(timer);
}, []);
const reconcileChat = useCallback(
(messages: ChatMessage[], from: Date, to: Date) => {
const ids = new Set(messages.map((m) => m.id));
setChatMessages((prev) =>
prev.filter((m) =>
ids.has(m.id) || m.createdAt < from || m.createdAt > to
)
);
// Failed sends that did land (only the echo was lost) are retired.
const mine = messages.filter((m) => m.userId === userIdRef.current);
for (const pending of pendingChatRef.current) {
if (pending.status !== "failed") continue;
const landed = mine.some((m) =>
m.body === pending.body &&
Math.abs(m.createdAt.getTime() - pending.createdAt.getTime()) <
CHAT_ECHO_WINDOW
);
if (landed) retirePendingChat(pending.nonce);
}
},
[retirePendingChat],
);
const setChatTyping = useCallback((typing: boolean) => {
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(
@@ -906,6 +1125,9 @@ export function WSProvider({ children }: WSProviderProps) {
chatMessages,
deletedChatIds,
unreadChatCount,
chatReadAt,
pendingChatMessages,
chatError,
typingUsers,
connectionEpoch,
castVote,
@@ -915,9 +1137,13 @@ export function WSProvider({ children }: WSProviderProps) {
injectDump,
clearUnreadNotifications,
sendChatMessage,
retryChatMessage,
discardChatMessage,
editChatMessage,
deleteChatMessage,
clearUnreadChat,
dismissChatError,
markChatRead,
reconcileChat,
setChatTyping,
setChatOpen,
}), [
@@ -942,6 +1168,9 @@ export function WSProvider({ children }: WSProviderProps) {
chatMessages,
deletedChatIds,
unreadChatCount,
chatReadAt,
pendingChatMessages,
chatError,
typingUsers,
connectionEpoch,
castVote,
@@ -951,9 +1180,13 @@ export function WSProvider({ children }: WSProviderProps) {
injectDump,
clearUnreadNotifications,
sendChatMessage,
retryChatMessage,
discardChatMessage,
editChatMessage,
deleteChatMessage,
clearUnreadChat,
dismissChatError,
markChatRead,
reconcileChat,
setChatTyping,
setChatOpen,
]);