v3: added a chatbox
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled

This commit is contained in:
khannurien
2026-06-29 20:25:10 +00:00
parent f171027673
commit 6c4f410d9b
35 changed files with 1800 additions and 152 deletions

View File

@@ -0,0 +1,13 @@
import { createContext } from "react";
export interface ChatContextValue {
isChatOpen: boolean;
openChat: () => void;
closeChat: () => void;
}
export const ChatContext = createContext<ChatContextValue>({
isChatOpen: false,
openChat: () => {},
closeChat: () => {},
});

View File

@@ -0,0 +1,57 @@
import {
lazy,
type ReactNode,
Suspense,
useCallback,
useMemo,
useState,
} from "react";
import { useSearchParams } from "react-router";
import { useAuth } from "../hooks/useAuth.ts";
import { ChatContext, type ChatContextValue } from "./ChatContext.ts";
const ChatModal = lazy(() =>
import("../components/ChatModal.tsx").then((m) => ({ default: m.ChatModal }))
);
/**
* Owns the single live-chat modal instance so the header button, the floating
* button, and notification deep-links all drive one modal. Open state is derived
* (no effects): a manual open, or a `?chat=open` query param (used by mention
* notifications), opens it — and it's only ever open for signed-in users.
*/
export function ChatProvider({ children }: { children: ReactNode }) {
const { user } = useAuth();
const [manuallyOpen, setManuallyOpen] = useState(false);
const [searchParams, setSearchParams] = useSearchParams();
const deepLinkOpen = searchParams.get("chat") === "open";
const isChatOpen = !!user && (manuallyOpen || deepLinkOpen);
const openChat = useCallback(() => setManuallyOpen(true), []);
const closeChat = useCallback(() => {
setManuallyOpen(false);
// Strip the deep-link param so a reload/back-nav doesn't reopen the modal.
if (searchParams.get("chat") === "open") {
const next = new URLSearchParams(searchParams);
next.delete("chat");
setSearchParams(next, { replace: true });
}
}, [searchParams, setSearchParams]);
const value: ChatContextValue = useMemo(
() => ({ isChatOpen, openChat, closeChat }),
[isChatOpen, openChat, closeChat],
);
return (
<ChatContext.Provider value={value}>
{children}
{isChatOpen && (
<Suspense>
<ChatModal onClose={closeChat} />
</Suspense>
)}
</ChatContext.Provider>
);
}

View File

@@ -1,5 +1,6 @@
import { createContext } from "react";
import type {
ChatMessage,
Comment,
Dump,
Notification,
@@ -58,6 +59,12 @@ export interface WSContextValue {
lastUserEvent: UserEvent | null;
unreadNotificationCount: number;
lastNotification: Notification | null;
/** Live buffer of chat messages received this session (capped). */
chatMessages: ChatMessage[];
/** Ids of chat messages deleted live this session, so fetched history can
* filter them out. */
deletedChatIds: Set<string>;
unreadChatCount: number;
/** Increments on each WS reconnect so pages can backfill missed events. */
connectionEpoch: number;
castVote: (dumpId: string) => void;
@@ -66,6 +73,13 @@ export interface WSContextValue {
removeCommentLike: (commentId: string) => void;
injectDump: (dump: Dump) => void;
clearUnreadNotifications: () => void;
sendChatMessage: (body: string) => void;
editChatMessage: (id: string, body: string) => void;
deleteChatMessage: (id: string) => void;
clearUnreadChat: () => void;
/** Inform the server whether the chatbox is open (suppresses redundant
* mention notifications while the user is reading chat). */
setChatOpen: (open: boolean) => void;
}
export const WSContext = createContext<WSContextValue>({
@@ -87,6 +101,9 @@ export const WSContext = createContext<WSContextValue>({
lastUserEvent: null,
unreadNotificationCount: 0,
lastNotification: null,
chatMessages: [],
deletedChatIds: new Set(),
unreadChatCount: 0,
connectionEpoch: 0,
castVote: () => {},
removeVote: () => {},
@@ -94,4 +111,9 @@ export const WSContext = createContext<WSContextValue>({
removeCommentLike: () => {},
injectDump: () => {},
clearUnreadNotifications: () => {},
sendChatMessage: () => {},
editChatMessage: () => {},
deleteChatMessage: () => {},
clearUnreadChat: () => {},
setChatOpen: () => {},
});

View File

@@ -22,6 +22,7 @@ import {
} from "./AvatarOverrideContext.ts";
import { WS_URL } from "../config/api.ts";
import type {
ChatMessage,
Dump,
IncomingWSMessage,
Notification,
@@ -29,6 +30,7 @@ import type {
OutgoingWSMessage,
} from "../model.ts";
import {
deserializeChatMessage,
deserializeComment,
deserializeDump,
deserializeNotification,
@@ -49,6 +51,9 @@ const CONNECT_TIMEOUT = 2_500;
// bound. Feeds merge these on top of their own paginated list, so a generous
// cap is plenty — older live arrivals are reachable via normal pagination.
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;
interface PendingVote {
timeout: ReturnType<typeof setTimeout>;
@@ -111,6 +116,9 @@ export function WSProvider({ children }: WSProviderProps) {
const [lastNotification, setLastNotification] = useState<Notification | null>(
null,
);
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
const [deletedChatIds, setDeletedChatIds] = useState<Set<string>>(new Set());
const [unreadChatCount, setUnreadChatCount] = useState(0);
// Live avatar overrides for site-wide Avatar refresh (own narrow context).
const [avatarOverrides, setAvatarOverrides] = useState<
Record<string, AvatarOverride>
@@ -145,6 +153,9 @@ export function WSProvider({ children }: WSProviderProps) {
setLastUserEvent(null);
setUnreadNotificationCount(0);
setLastNotification(null);
setChatMessages([]);
setDeletedChatIds(new Set());
setUnreadChatCount(0);
setAvatarOverrides({});
}
@@ -167,6 +178,9 @@ export function WSProvider({ children }: WSProviderProps) {
});
const socketRef = useRef<WebSocket | null>(null);
// Tracks whether the chatbox is open so it can be re-announced to the server
// after a reconnect (the fresh socket starts with the flag cleared).
const chatOpenRef = useRef(false);
// Tracks pending optimistic votes: dumpId → pending rollback handler
const pendingRef = useRef<Map<string, PendingVote>>(
new Map(),
@@ -291,6 +305,15 @@ export function WSProvider({ children }: WSProviderProps) {
// dumps/comments they couldn't receive while disconnected.
welcomeCount += 1;
if (welcomeCount > 1) setConnectionEpoch((n) => n + 1);
// Restore the chat-open flag on the fresh connection so mention
// suppression keeps working across reconnects.
if (chatOpenRef.current) {
ws.send(
JSON.stringify(
{ type: "chat_focus", open: true } satisfies OutgoingWSMessage,
),
);
}
break;
case "presence_update":
@@ -474,6 +497,38 @@ export function WSProvider({ children }: WSProviderProps) {
break;
}
case "chat_message": {
const message = deserializeChatMessage(msg.message);
setChatMessages((prev) => {
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 (message.userId !== userIdRef.current) {
setUnreadChatCount((prev) => prev + 1);
}
break;
}
case "chat_message_updated": {
const message = deserializeChatMessage(msg.message);
setChatMessages((prev) => {
const idx = prev.findIndex((m) => m.id === message.id);
if (idx === -1) return [...prev, message];
const next = [...prev];
next[idx] = message;
return next;
});
break;
}
case "chat_message_deleted": {
const { id } = msg;
setDeletedChatIds((prev) => new Set(prev).add(id));
setChatMessages((prev) => prev.filter((m) => m.id !== id));
break;
}
case "force_logout":
onForceLogoutRef.current();
break;
@@ -704,6 +759,55 @@ export function WSProvider({ children }: WSProviderProps) {
setUnreadNotificationCount(0);
}, []);
const sendChatMessage = useCallback((body: string) => {
const trimmed = body.trim();
if (!trimmed) return;
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(
JSON.stringify(
{ type: "chat_send", body: trimmed } satisfies OutgoingWSMessage,
),
);
}
}, []);
const editChatMessage = useCallback((id: string, body: string) => {
const trimmed = body.trim();
if (!trimmed) return;
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(
JSON.stringify(
{ type: "chat_edit", id, body: trimmed } satisfies OutgoingWSMessage,
),
);
}
}, []);
const deleteChatMessage = useCallback((id: string) => {
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(
JSON.stringify(
{ type: "chat_delete", id } satisfies OutgoingWSMessage,
),
);
}
}, []);
const clearUnreadChat = useCallback(() => {
setUnreadChatCount(0);
}, []);
const setChatOpen = useCallback((open: boolean) => {
chatOpenRef.current = open;
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(
JSON.stringify(
{ type: "chat_focus", open } satisfies OutgoingWSMessage,
),
);
}
}, []);
const value: WSContextValue = useMemo(() => ({
wsStatus,
wsErrorMessage,
@@ -723,6 +827,9 @@ export function WSProvider({ children }: WSProviderProps) {
lastUserEvent,
unreadNotificationCount,
lastNotification,
chatMessages,
deletedChatIds,
unreadChatCount,
connectionEpoch,
castVote,
removeVote,
@@ -730,6 +837,11 @@ export function WSProvider({ children }: WSProviderProps) {
removeCommentLike,
injectDump,
clearUnreadNotifications,
sendChatMessage,
editChatMessage,
deleteChatMessage,
clearUnreadChat,
setChatOpen,
}), [
wsStatus,
wsErrorMessage,
@@ -749,6 +861,9 @@ export function WSProvider({ children }: WSProviderProps) {
lastUserEvent,
unreadNotificationCount,
lastNotification,
chatMessages,
deletedChatIds,
unreadChatCount,
connectionEpoch,
castVote,
removeVote,
@@ -756,6 +871,11 @@ export function WSProvider({ children }: WSProviderProps) {
removeCommentLike,
injectDump,
clearUnreadNotifications,
sendChatMessage,
editChatMessage,
deleteChatMessage,
clearUnreadChat,
setChatOpen,
]);
return (