v3: added a chatbox
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled
This commit is contained in:
13
src/contexts/ChatContext.ts
Normal file
13
src/contexts/ChatContext.ts
Normal 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: () => {},
|
||||
});
|
||||
57
src/contexts/ChatProvider.tsx
Normal file
57
src/contexts/ChatProvider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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: () => {},
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user