mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 15:48:23 -05:00
Move app to apps/web-react in pnpm workspace layout
This commit is contained in:
777
apps/web-react/app/features/chat/ChatProvider.tsx
Normal file
777
apps/web-react/app/features/chat/ChatProvider.tsx
Normal file
@@ -0,0 +1,777 @@
|
||||
import { nanoid } from "nanoid";
|
||||
import { WebSocket } from "partysocket";
|
||||
import * as React from "react";
|
||||
import {
|
||||
useFetcher,
|
||||
useLocation,
|
||||
useMatches,
|
||||
useRevalidator,
|
||||
} from "react-router";
|
||||
import { Config } from "~/config";
|
||||
import { useLayoutSize } from "~/hooks/useMainContentWidth";
|
||||
import { logger } from "~/utils/logger";
|
||||
import { soundPath } from "~/utils/urls";
|
||||
import { useRefreshOnReconnect } from "./chat-hooks";
|
||||
import { useLastReadCounts, writeLastReadCount } from "./chat-last-read";
|
||||
import type {
|
||||
RoomInfo,
|
||||
RoomMetadata,
|
||||
ServerRoomInfo,
|
||||
} from "./chat-provider-types";
|
||||
import { chatUsersSearchParams } from "./chat-search-params";
|
||||
import type { ChatMessage, ChatUser } from "./chat-types";
|
||||
import { messageTypeToSound, soundEnabled, soundVolume } from "./chat-utils";
|
||||
import { scheduleBroadcastRevalidation } from "./revalidation-scope";
|
||||
import { ChatContext } from "./useChatContext";
|
||||
|
||||
const PING_INTERVAL_MS = 60_000;
|
||||
|
||||
function flattenServerRoom(serverRoom: ServerRoomInfo): RoomInfo {
|
||||
return {
|
||||
chatCode: serverRoom.chatCode,
|
||||
header: serverRoom.metadata.header,
|
||||
subtitle: serverRoom.metadata.subtitle ?? "",
|
||||
url: serverRoom.metadata.url ?? "",
|
||||
imageUrl: serverRoom.metadata.imageUrl ?? "",
|
||||
participantUserIds: serverRoom.metadata.participantUserIds,
|
||||
expiresAt: serverRoom.metadata.expiresAt,
|
||||
lastMessageTimestamp: serverRoom.lastMessageTimestamp ?? 0,
|
||||
totalMessageCount: serverRoom.totalMessageCount,
|
||||
createdAt: serverRoom.metadata.createdAt ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
const SENDOUQ_GROUP_HEADER_PATTERN = /^Group\b/;
|
||||
|
||||
function isSendouQGroupRoom(room: RoomInfo) {
|
||||
return (
|
||||
room.subtitle === "SendouQ" &&
|
||||
SENDOUQ_GROUP_HEADER_PATTERN.test(room.header)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveObsoleteGroupRooms(rooms: RoomInfo[]): RoomInfo[] {
|
||||
const groupRooms = rooms.filter(isSendouQGroupRoom);
|
||||
|
||||
if (groupRooms.length <= 1) return rooms;
|
||||
|
||||
const newestCreatedAt = Math.max(...groupRooms.map((r) => r.createdAt));
|
||||
|
||||
// If no room has createdAt set, skip resolution
|
||||
if (newestCreatedAt === 0) return rooms;
|
||||
|
||||
const obsoleteChatCodes = new Set<string>();
|
||||
const removedChatCodes = new Set<string>();
|
||||
|
||||
for (const room of groupRooms) {
|
||||
if (room.createdAt === newestCreatedAt) continue;
|
||||
|
||||
if (room.totalMessageCount === 0) {
|
||||
removedChatCodes.add(room.chatCode);
|
||||
} else {
|
||||
obsoleteChatCodes.add(room.chatCode);
|
||||
}
|
||||
}
|
||||
|
||||
return rooms
|
||||
.filter((r) => !removedChatCodes.has(r.chatCode))
|
||||
.map((r) =>
|
||||
obsoleteChatCodes.has(r.chatCode) ? { ...r, isObsolete: true } : r,
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatProvider({
|
||||
user,
|
||||
children,
|
||||
}: {
|
||||
user?: { id: number } | null;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
if (!user) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return <ChatProviderInner userId={user.id}>{children}</ChatProviderInner>;
|
||||
}
|
||||
|
||||
function ChatProviderInner({
|
||||
userId,
|
||||
children,
|
||||
}: {
|
||||
userId: number;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { revalidate } = useRevalidator();
|
||||
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
const [rooms, setRooms] = React.useState<RoomInfo[]>([]);
|
||||
const [messagesByRoom, setMessagesByRoom] = React.useState<
|
||||
Record<string, ChatMessage[]>
|
||||
>({});
|
||||
const [chatUsersCache, setChatUsersCache] = React.useState<
|
||||
Record<number, ChatUser>
|
||||
>({});
|
||||
const [readyState, setReadyState] = React.useState<
|
||||
"CONNECTING" | "CONNECTED" | "CLOSED"
|
||||
>("CONNECTING");
|
||||
const [chatOpen, _setChatOpen] = React.useState(false);
|
||||
const [activeRooms, setActiveRooms] = React.useState<string[]>([]);
|
||||
const [chatLabels, setChatLabels] = React.useState<Record<number, string>>(
|
||||
{},
|
||||
);
|
||||
const clearChatLabels = React.useCallback(() => setChatLabels({}), []);
|
||||
const [notificationsVersion, setNotificationsVersion] = React.useState(0);
|
||||
|
||||
const ws = React.useRef<WebSocket>(undefined);
|
||||
|
||||
const lastReadCounts = useLastReadCounts();
|
||||
// derived instead of stored: rooms hold the total counts and the
|
||||
// localStorage-backed store holds the read counts, so unread can never fall
|
||||
// out of sync with either
|
||||
const unreadCounts = React.useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const room of rooms) {
|
||||
counts[room.chatCode] = Math.max(
|
||||
0,
|
||||
room.totalMessageCount - (lastReadCounts[room.chatCode] ?? 0),
|
||||
);
|
||||
}
|
||||
return counts;
|
||||
}, [rooms, lastReadCounts]);
|
||||
|
||||
const onMessage = React.useEffectEvent((e: MessageEvent) => {
|
||||
const parsed = JSON.parse(e.data);
|
||||
logger.debug("WS message received:", parsed);
|
||||
|
||||
// Initial rooms payload on connect
|
||||
if (parsed.rooms && Array.isArray(parsed.rooms)) {
|
||||
logger.debug("WS initial rooms payload, count:", parsed.rooms.length);
|
||||
const serverRooms = parsed.rooms as ServerRoomInfo[];
|
||||
const roomList = resolveObsoleteGroupRooms(
|
||||
serverRooms.map(flattenServerRoom),
|
||||
);
|
||||
setRooms(roomList);
|
||||
setIsLoading(false);
|
||||
|
||||
const allChatUsers: Record<number, ChatUser> = {};
|
||||
for (const sr of serverRooms) {
|
||||
Object.assign(allChatUsers, sr.metadata.chatUsers);
|
||||
}
|
||||
setChatUsersCache((prev) => ({ ...prev, ...allChatUsers }));
|
||||
return;
|
||||
}
|
||||
|
||||
// ROOM_REMOVED: room deleted (e.g. group merge)
|
||||
if (parsed.event === "ROOM_REMOVED" && parsed.chatCode) {
|
||||
const removedCode = parsed.chatCode as string;
|
||||
logger.debug("WS ROOM_REMOVED:", removedCode);
|
||||
revalidate();
|
||||
setRooms((prev) => prev.filter((r) => r.chatCode !== removedCode));
|
||||
setMessagesByRoom((prev) => {
|
||||
const { [removedCode]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
setActiveRooms((prev) => prev.filter((code) => code !== removedCode));
|
||||
return;
|
||||
}
|
||||
|
||||
// ROOM_JOINED: new room added
|
||||
if (parsed.event === "ROOM_JOINED" && parsed.room) {
|
||||
logger.debug("WS ROOM_JOINED:", parsed.room?.chatCode);
|
||||
const serverRoom = parsed.room as ServerRoomInfo;
|
||||
const newRoom = flattenServerRoom(serverRoom);
|
||||
setRooms((prev) => {
|
||||
const exists = prev.some((r) => r.chatCode === newRoom.chatCode);
|
||||
if (exists) return prev;
|
||||
return resolveObsoleteGroupRooms([...prev, newRoom]);
|
||||
});
|
||||
|
||||
setChatUsersCache((prev) => ({
|
||||
...prev,
|
||||
...serverRoom.metadata.chatUsers,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Notifications changed server-side; handled before the fallthrough below
|
||||
// so a contentless ping is never treated as a chat message
|
||||
if (parsed.event === "NOTIFICATIONS_CHANGED") {
|
||||
setNotificationsVersion((version) => version + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
// CHAT_HISTORY response (also returned by SUBSCRIBE with metadata)
|
||||
if (parsed.event === "CHAT_HISTORY" && Array.isArray(parsed.messages)) {
|
||||
logger.debug(
|
||||
"WS CHAT_HISTORY for:",
|
||||
parsed.chatCode,
|
||||
"messages:",
|
||||
parsed.messages.length,
|
||||
);
|
||||
const chatCode = parsed.chatCode as string;
|
||||
const messages = parsed.messages as ChatMessage[];
|
||||
setMessagesByRoom((prev) => ({
|
||||
...prev,
|
||||
[chatCode]: messages,
|
||||
}));
|
||||
|
||||
if (parsed.metadata) {
|
||||
const metadata = parsed.metadata as RoomMetadata;
|
||||
const newRoom: RoomInfo = {
|
||||
chatCode,
|
||||
header: metadata.header,
|
||||
subtitle: metadata.subtitle ?? "",
|
||||
url: metadata.url ?? "",
|
||||
imageUrl: metadata.imageUrl ?? "",
|
||||
participantUserIds: metadata.participantUserIds,
|
||||
expiresAt: metadata.expiresAt,
|
||||
lastMessageTimestamp: messages.at(-1)?.timestamp ?? 0,
|
||||
totalMessageCount: messages.length,
|
||||
createdAt: metadata.createdAt ?? 0,
|
||||
};
|
||||
setRooms((prev) => {
|
||||
const exists = prev.some((r) => r.chatCode === chatCode);
|
||||
if (exists) return prev;
|
||||
return [...prev, newRoom];
|
||||
});
|
||||
|
||||
if (metadata.chatUsers) {
|
||||
setChatUsersCache((prev) => ({ ...prev, ...metadata.chatUsers }));
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Regular message(s)
|
||||
const messageArr = (
|
||||
Array.isArray(parsed) ? parsed : [parsed]
|
||||
) as (ChatMessage & { totalMessageCount: number })[];
|
||||
|
||||
const isSystemMessage = Boolean(messageArr[0].type);
|
||||
logger.debug(
|
||||
"WS message(s):",
|
||||
messageArr.length,
|
||||
"system:",
|
||||
isSystemMessage,
|
||||
);
|
||||
if (isSystemMessage || messageArr[0].revalidateOnly) {
|
||||
// The actor that triggered this revalidate is the current user — their
|
||||
// own form submission already reran loaders, so skip the duplicate fetch.
|
||||
const isOwnRevalidate =
|
||||
messageArr[0].revalidateOnly && messageArr[0].authorUserId === userId;
|
||||
if (!isOwnRevalidate) {
|
||||
// jittered so a broadcast fanning out to a whole room does not make
|
||||
// every subscribed client refetch in the same instant
|
||||
scheduleBroadcastRevalidation(
|
||||
revalidate,
|
||||
messageArr[0].revalidateScope,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const sound = messageTypeToSound(messageArr[0].type);
|
||||
if (sound && soundEnabled(sound)) {
|
||||
const audio = new Audio(soundPath(sound));
|
||||
audio.volume = soundVolume() / 100;
|
||||
void audio
|
||||
.play()
|
||||
.catch((err) => logger.error(`Couldn't play sound: ${err}`));
|
||||
}
|
||||
|
||||
if (messageArr[0].revalidateOnly) return;
|
||||
|
||||
for (const msg of messageArr) {
|
||||
const roomCode = msg.room;
|
||||
setMessagesByRoom((prev) => {
|
||||
const existing = prev[roomCode] ?? [];
|
||||
const pendingIndex = existing.findIndex(
|
||||
(m) => m.id === msg.id && m.pending,
|
||||
);
|
||||
|
||||
if (pendingIndex !== -1) {
|
||||
const updated = [...existing];
|
||||
updated[pendingIndex] = msg;
|
||||
return { ...prev, [roomCode]: updated };
|
||||
}
|
||||
|
||||
if (existing.some((m) => m.id === msg.id)) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return { ...prev, [roomCode]: [...existing, msg] };
|
||||
});
|
||||
|
||||
setRooms((prev) =>
|
||||
prev.map((r) =>
|
||||
r.chatCode === roomCode
|
||||
? {
|
||||
...r,
|
||||
lastMessageTimestamp: msg.timestamp,
|
||||
totalMessageCount: Math.max(
|
||||
r.totalMessageCount,
|
||||
msg.totalMessageCount!,
|
||||
),
|
||||
}
|
||||
: r,
|
||||
),
|
||||
);
|
||||
|
||||
const isOwnMessage = msg.userId === userId;
|
||||
if (isOwnMessage || (activeRooms.includes(roomCode) && chatOpen)) {
|
||||
writeLastReadCount(roomCode, msg.totalMessageCount);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// WebSocket connection
|
||||
React.useEffect(() => {
|
||||
const wsUrl = Config.skalop.wsUrl;
|
||||
if (!wsUrl) {
|
||||
logger.warn("No WS URL provided, ChatProvider not connecting");
|
||||
setReadyState("CLOSED");
|
||||
return;
|
||||
}
|
||||
|
||||
ws.current = new WebSocket(wsUrl, [], {
|
||||
maxReconnectionDelay: 20_000,
|
||||
reconnectionDelayGrowFactor: 1.5,
|
||||
});
|
||||
|
||||
ws.current.onopen = () => {
|
||||
setReadyState("CONNECTED");
|
||||
};
|
||||
|
||||
ws.current.onclose = () => setReadyState("CLOSED");
|
||||
ws.current.onerror = () => setReadyState("CLOSED");
|
||||
ws.current.onmessage = onMessage;
|
||||
|
||||
const wsCurrent = ws.current;
|
||||
return () => {
|
||||
wsCurrent?.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Ping to keep connection alive
|
||||
React.useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
ws.current?.send("");
|
||||
}, PING_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const messagesForRoom = React.useCallback(
|
||||
(chatCode: string) => {
|
||||
return (messagesByRoom[chatCode] ?? []).toSorted(
|
||||
(a, b) => a.timestamp - b.timestamp,
|
||||
);
|
||||
},
|
||||
[messagesByRoom],
|
||||
);
|
||||
|
||||
const send = React.useCallback(
|
||||
(chatCode: string, contents: string) => {
|
||||
const id = nanoid();
|
||||
const message: ChatMessage = {
|
||||
id,
|
||||
room: chatCode,
|
||||
contents,
|
||||
timestamp: Date.now(),
|
||||
userId,
|
||||
pending: true,
|
||||
};
|
||||
|
||||
// Optimistic add
|
||||
setMessagesByRoom((prev) => ({
|
||||
...prev,
|
||||
[chatCode]: [...(prev[chatCode] ?? []), message],
|
||||
}));
|
||||
|
||||
ws.current?.send(
|
||||
JSON.stringify({ event: "MESSAGE", chatCode, id, contents }),
|
||||
);
|
||||
},
|
||||
[userId],
|
||||
);
|
||||
|
||||
const subscribe = React.useCallback((chatCode: string) => {
|
||||
logger.debug("WS SUBSCRIBE:", chatCode);
|
||||
ws.current?.send(JSON.stringify({ event: "SUBSCRIBE", chatCode }));
|
||||
}, []);
|
||||
|
||||
const unsubscribe = React.useCallback((chatCode: string) => {
|
||||
logger.debug("WS UNSUBSCRIBE:", chatCode);
|
||||
ws.current?.send(JSON.stringify({ event: "UNSUBSCRIBE", chatCode }));
|
||||
}, []);
|
||||
|
||||
const subscribeTopic = React.useCallback((topic: string) => {
|
||||
logger.debug("WS SUBSCRIBE_TOPIC:", topic);
|
||||
ws.current?.send(JSON.stringify({ event: "SUBSCRIBE_TOPIC", topic }));
|
||||
}, []);
|
||||
|
||||
const unsubscribeTopic = React.useCallback((topic: string) => {
|
||||
logger.debug("WS UNSUBSCRIBE_TOPIC:", topic);
|
||||
ws.current?.send(JSON.stringify({ event: "UNSUBSCRIBE_TOPIC", topic }));
|
||||
}, []);
|
||||
|
||||
const requestHistory = React.useCallback((chatCode: string) => {
|
||||
logger.debug("WS CHAT_HISTORY:", chatCode);
|
||||
ws.current?.send(JSON.stringify({ event: "CHAT_HISTORY", chatCode }));
|
||||
}, []);
|
||||
|
||||
const markAsRead = React.useCallback(
|
||||
(chatCode: string) => {
|
||||
const room = rooms.find((r) => r.chatCode === chatCode);
|
||||
const messageCount =
|
||||
room?.totalMessageCount ?? messagesByRoom[chatCode]?.length ?? 0;
|
||||
writeLastReadCount(chatCode, messageCount);
|
||||
},
|
||||
[rooms, messagesByRoom],
|
||||
);
|
||||
|
||||
const totalUnreadCount = Object.values(unreadCounts).reduce(
|
||||
(sum, count) => sum + count,
|
||||
0,
|
||||
);
|
||||
|
||||
const setChatOpen = React.useCallback(
|
||||
(open: boolean) => {
|
||||
_setChatOpen(open);
|
||||
if (!open) return;
|
||||
|
||||
if (activeRooms.length > 0) {
|
||||
for (const code of activeRooms) {
|
||||
markAsRead(code);
|
||||
}
|
||||
} else if (rooms.length === 1) {
|
||||
requestHistory(rooms[0].chatCode);
|
||||
setActiveRooms([rooms[0].chatCode]);
|
||||
markAsRead(rooms[0].chatCode);
|
||||
}
|
||||
},
|
||||
[activeRooms, markAsRead, requestHistory, rooms.length, rooms[0]?.chatCode],
|
||||
);
|
||||
|
||||
const fetchedChatUsers = useFetchUnknownChatUsers({
|
||||
messages: messagesByRoom,
|
||||
chatUsersCache,
|
||||
});
|
||||
// cache spreads last: users are only API-fetched while missing from the
|
||||
// cache, so a later WS payload for them is newer and should win
|
||||
const chatUsers = React.useMemo(
|
||||
() => ({ ...fetchedChatUsers, ...chatUsersCache }),
|
||||
[chatUsersCache, fetchedChatUsers],
|
||||
);
|
||||
|
||||
useChatRouteSync({
|
||||
rooms,
|
||||
userId,
|
||||
isLoading,
|
||||
readyState,
|
||||
activeRooms,
|
||||
setActiveRooms,
|
||||
setChatOpen,
|
||||
markAsRead,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
setRooms,
|
||||
setMessagesByRoom,
|
||||
requestHistory,
|
||||
messagesByRoom,
|
||||
});
|
||||
|
||||
const contextValue = React.useMemo(
|
||||
() => ({
|
||||
isLoading,
|
||||
rooms,
|
||||
messagesForRoom,
|
||||
send,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
subscribeTopic,
|
||||
unsubscribeTopic,
|
||||
requestHistory,
|
||||
markAsRead,
|
||||
unreadCounts,
|
||||
totalUnreadCount,
|
||||
readyState,
|
||||
notificationsVersion,
|
||||
chatUsers,
|
||||
chatOpen,
|
||||
setChatOpen,
|
||||
activeRooms,
|
||||
setActiveRooms,
|
||||
chatLabels,
|
||||
setChatLabels,
|
||||
clearChatLabels,
|
||||
}),
|
||||
[
|
||||
isLoading,
|
||||
rooms,
|
||||
messagesForRoom,
|
||||
send,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
subscribeTopic,
|
||||
unsubscribeTopic,
|
||||
requestHistory,
|
||||
markAsRead,
|
||||
unreadCounts,
|
||||
totalUnreadCount,
|
||||
readyState,
|
||||
notificationsVersion,
|
||||
chatUsers,
|
||||
chatOpen,
|
||||
activeRooms,
|
||||
setChatOpen,
|
||||
chatLabels,
|
||||
clearChatLabels,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<ChatContext.Provider value={contextValue}>{children}</ChatContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function useChatRouteSync({
|
||||
rooms,
|
||||
userId,
|
||||
isLoading,
|
||||
readyState,
|
||||
activeRooms,
|
||||
setActiveRooms,
|
||||
setChatOpen,
|
||||
markAsRead,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
setRooms,
|
||||
setMessagesByRoom,
|
||||
requestHistory,
|
||||
messagesByRoom,
|
||||
}: {
|
||||
rooms: RoomInfo[];
|
||||
userId: number;
|
||||
isLoading: boolean;
|
||||
readyState: "CONNECTING" | "CONNECTED" | "CLOSED";
|
||||
activeRooms: string[];
|
||||
setActiveRooms: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
setChatOpen: (open: boolean) => void;
|
||||
markAsRead: (chatCode: string) => void;
|
||||
subscribe: (chatCode: string) => void;
|
||||
unsubscribe: (chatCode: string) => void;
|
||||
setRooms: React.Dispatch<React.SetStateAction<RoomInfo[]>>;
|
||||
setMessagesByRoom: React.Dispatch<
|
||||
React.SetStateAction<Record<string, ChatMessage[]>>
|
||||
>;
|
||||
requestHistory: (chatCode: string) => void;
|
||||
messagesByRoom: Record<string, ChatMessage[]>;
|
||||
}) {
|
||||
const chatCodesKey = useCurrentRouteChatCodes().join(",");
|
||||
const { pathname } = useLocation();
|
||||
const layoutSize = useLayoutSize();
|
||||
const subscribedRoomRef = React.useRef<string[]>([]);
|
||||
const previousRouteChatCodeRef = React.useRef<string[]>([]);
|
||||
const previousPathnameRef = React.useRef<string | null>(null);
|
||||
|
||||
// On reconnect the server sends a fresh initial rooms payload that drops
|
||||
// rooms we joined via SUBSCRIBE as a non-participant, and the previous
|
||||
// socket's subscriptions died with it. Clear the subscription tracking so
|
||||
// the route sync effect below re-subscribes once the new payload arrives,
|
||||
// and refresh history for the open room to fill any gap from the downtime.
|
||||
useRefreshOnReconnect(readyState, () => {
|
||||
logger.debug("WS reconnected, re-acquiring room subscriptions and history");
|
||||
subscribedRoomRef.current = [];
|
||||
for (const code of activeRooms) {
|
||||
requestHistory(code);
|
||||
}
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isLoading) return;
|
||||
|
||||
const chatCodes = chatCodesKey ? chatCodesKey.split(",") : [];
|
||||
|
||||
// Clean up subscriptions for rooms no longer in chatCodes
|
||||
const previousSubscribed = subscribedRoomRef.current;
|
||||
const removedRooms = previousSubscribed.filter(
|
||||
(code) => !chatCodes.includes(code),
|
||||
);
|
||||
for (const code of removedRooms) {
|
||||
unsubscribe(code);
|
||||
setRooms((prev) => prev.filter((r) => r.chatCode !== code));
|
||||
setMessagesByRoom((prev) => {
|
||||
const { [code]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
}
|
||||
if (removedRooms.length > 0) {
|
||||
subscribedRoomRef.current = previousSubscribed.filter((code) =>
|
||||
chatCodes.includes(code),
|
||||
);
|
||||
|
||||
const remainingActive = activeRooms.filter(
|
||||
(code) => !removedRooms.includes(code),
|
||||
);
|
||||
if (remainingActive.length !== activeRooms.length) {
|
||||
setActiveRooms(remainingActive);
|
||||
if (remainingActive.length === 0) {
|
||||
setChatOpen(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (chatCodes.length > 0) {
|
||||
for (const code of chatCodes) {
|
||||
const alreadyInRooms = rooms.some((r) => r.chatCode === code);
|
||||
|
||||
if (!alreadyInRooms && !subscribedRoomRef.current.includes(code)) {
|
||||
logger.debug("Subscribing to non-participant room:", code);
|
||||
subscribe(code);
|
||||
subscribedRoomRef.current = [...subscribedRoomRef.current, code];
|
||||
}
|
||||
}
|
||||
|
||||
const previousCodes = previousRouteChatCodeRef.current;
|
||||
const routeChatCodeChanged =
|
||||
chatCodes.length !== previousCodes.length ||
|
||||
chatCodes.some((code, i) => previousCodes[i] !== code);
|
||||
previousRouteChatCodeRef.current = chatCodes;
|
||||
|
||||
if (routeChatCodeChanged) {
|
||||
setActiveRooms(chatCodes);
|
||||
for (const code of chatCodes) {
|
||||
if (!messagesByRoom[code]) {
|
||||
requestHistory(code);
|
||||
}
|
||||
}
|
||||
if (layoutSize === "desktop") {
|
||||
setChatOpen(true);
|
||||
for (const code of chatCodes) {
|
||||
markAsRead(code);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
previousRouteChatCodeRef.current = [];
|
||||
|
||||
const pathnameChanged = previousPathnameRef.current !== pathname;
|
||||
previousPathnameRef.current = pathname;
|
||||
|
||||
if (pathnameChanged) {
|
||||
const matchedRoom = rooms.find(
|
||||
(r) => r.url === pathname && r.participantUserIds.includes(userId),
|
||||
);
|
||||
|
||||
if (matchedRoom) {
|
||||
setActiveRooms([matchedRoom.chatCode]);
|
||||
if (!messagesByRoom[matchedRoom.chatCode]) {
|
||||
requestHistory(matchedRoom.chatCode);
|
||||
}
|
||||
if (layoutSize === "desktop") {
|
||||
setChatOpen(true);
|
||||
markAsRead(matchedRoom.chatCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isLoading,
|
||||
chatCodesKey,
|
||||
pathname,
|
||||
rooms,
|
||||
userId,
|
||||
activeRooms,
|
||||
setActiveRooms,
|
||||
setChatOpen,
|
||||
markAsRead,
|
||||
layoutSize,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
setRooms,
|
||||
setMessagesByRoom,
|
||||
requestHistory,
|
||||
messagesByRoom,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat codes the current route wants visible. A route may expose several codes
|
||||
* (e.g. a SendouQ match exposes the match chat alongside the group chat) which
|
||||
* are then shown stacked as a single combined view.
|
||||
*/
|
||||
export function useCurrentRouteChatCodes(): string[] {
|
||||
const matches = useMatches();
|
||||
|
||||
for (const match of matches) {
|
||||
const matchData = match.loaderData as
|
||||
| { chatCode?: string | string[] }
|
||||
| undefined;
|
||||
if (matchData?.chatCode) {
|
||||
return Array.isArray(matchData.chatCode)
|
||||
? matchData.chatCode
|
||||
: [matchData.chatCode];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function useFetchUnknownChatUsers({
|
||||
messages,
|
||||
chatUsersCache,
|
||||
}: {
|
||||
messages: Record<string, ChatMessage[]>;
|
||||
chatUsersCache: Record<number, ChatUser>;
|
||||
}): Record<number, ChatUser> {
|
||||
const fetcher = useFetcher<Record<number, ChatUser>>();
|
||||
|
||||
// Accumulated across loads because `fetcher.data` only holds the latest response
|
||||
const fetchedUsersRef = React.useRef<Record<number, ChatUser>>({});
|
||||
const lastFetcherData = React.useRef(fetcher.data);
|
||||
if (fetcher.data && fetcher.data !== lastFetcherData.current) {
|
||||
lastFetcherData.current = fetcher.data;
|
||||
fetchedUsersRef.current = { ...fetchedUsersRef.current, ...fetcher.data };
|
||||
}
|
||||
|
||||
const unknownIds: number[] = [];
|
||||
for (const msgs of Object.values(messages)) {
|
||||
for (const msg of msgs) {
|
||||
if (
|
||||
msg.userId &&
|
||||
!chatUsersCache[msg.userId] &&
|
||||
!fetchedUsersRef.current[msg.userId]
|
||||
) {
|
||||
unknownIds.push(msg.userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sortedUnknownIds = unknownIds.sort((a, b) => a - b);
|
||||
const chatUsersUrl =
|
||||
sortedUnknownIds.length > 0
|
||||
? chatUsersSearchParams.href("/api/chat-users", {
|
||||
ids: sortedUnknownIds,
|
||||
})
|
||||
: null;
|
||||
|
||||
// Ids the API did not return would otherwise stay "unknown" and refetch forever
|
||||
const lastRequestedUrlRef = React.useRef<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
!chatUsersUrl ||
|
||||
chatUsersUrl === lastRequestedUrlRef.current ||
|
||||
fetcher.state !== "idle"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastRequestedUrlRef.current = chatUsersUrl;
|
||||
logger.debug(`Fetching unknown chat users: ${chatUsersUrl}`);
|
||||
fetcher.load(chatUsersUrl);
|
||||
}, [chatUsersUrl, fetcher.load, fetcher.state]);
|
||||
|
||||
return fetchedUsersRef.current;
|
||||
}
|
||||
244
apps/web-react/app/features/chat/ChatSystemMessage.server.ts
Normal file
244
apps/web-react/app/features/chat/ChatSystemMessage.server.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { add } from "date-fns";
|
||||
import { nanoid } from "nanoid";
|
||||
import { ServerConfig } from "~/config.server";
|
||||
import { actorIdOrNullSafe } from "~/features/auth/core/user.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { IS_E2E_TEST_RUN } from "~/utils/e2e";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { logger } from "~/utils/logger";
|
||||
import type { ChatMessage } from "./chat-types";
|
||||
import { createRevalidateBroadcastThrottle } from "./revalidate-broadcast-throttle";
|
||||
|
||||
const SKALOP_TOKEN_HEADER_NAME = "Skalop-Token";
|
||||
|
||||
function logSkalpError(action: string) {
|
||||
return (err: unknown) => {
|
||||
const cause = err instanceof TypeError ? (err as any).cause : undefined;
|
||||
const code = cause?.code;
|
||||
|
||||
if (code === "ECONNREFUSED") {
|
||||
logger.error(
|
||||
`Skalop "${action}" failed: connection refused at ${ServerConfig.skalop.systemMessageUrl} — is the skalop service running?`,
|
||||
);
|
||||
} else {
|
||||
logger.error(`Skalop "${action}" failed:`, err);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
type PartialChatMessage = Pick<
|
||||
ChatMessage,
|
||||
| "type"
|
||||
| "context"
|
||||
| "room"
|
||||
| "revalidateOnly"
|
||||
| "revalidateScope"
|
||||
| "authorUserId"
|
||||
>;
|
||||
interface ChatSystemMessageService {
|
||||
send: (msg: PartialChatMessage | PartialChatMessage[]) => undefined;
|
||||
}
|
||||
|
||||
let systemMessagesDisabled = false;
|
||||
|
||||
if (!IS_E2E_TEST_RUN) {
|
||||
invariant(
|
||||
ServerConfig.skalop.systemMessageUrl,
|
||||
"Missing env var: SKALOP_SYSTEM_MESSAGE_URL",
|
||||
);
|
||||
invariant(ServerConfig.skalop.token, "Missing env var: SKALOP_TOKEN");
|
||||
} else if (
|
||||
!ServerConfig.skalop.systemMessageUrl ||
|
||||
!ServerConfig.skalop.token
|
||||
) {
|
||||
systemMessagesDisabled = true;
|
||||
}
|
||||
|
||||
const REVALIDATE_BROADCAST_THROTTLE_WINDOW_MS = 2_000;
|
||||
|
||||
const revalidateThrottle = createRevalidateBroadcastThrottle({
|
||||
windowMs: REVALIDATE_BROADCAST_THROTTLE_WINDOW_MS,
|
||||
sendLeading: (msg) => postMessages([toFullMessage(msg)]),
|
||||
// no author on purpose: the trailing broadcast covers many actors' changes,
|
||||
// so no client may skip it as a duplicate of their own submission
|
||||
sendTrailing: (msg) =>
|
||||
postMessages([
|
||||
{
|
||||
id: nanoid(),
|
||||
timestamp: Date.now(),
|
||||
room: msg.room,
|
||||
revalidateOnly: true,
|
||||
revalidateScope: msg.revalidateScope,
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
export const send: ChatSystemMessageService["send"] = (partialMsg) => {
|
||||
if (systemMessagesDisabled) return;
|
||||
|
||||
const msgArr = Array.isArray(partialMsg) ? partialMsg : [partialMsg];
|
||||
|
||||
const immediate: PartialChatMessage[] = [];
|
||||
for (const msg of msgArr) {
|
||||
if (revalidateThrottle.throttles(msg)) {
|
||||
revalidateThrottle.handle(msg);
|
||||
} else {
|
||||
immediate.push(msg);
|
||||
}
|
||||
}
|
||||
if (immediate.length === 0) return;
|
||||
|
||||
return postMessages(immediate.map(toFullMessage));
|
||||
};
|
||||
|
||||
function toFullMessage(partialMsg: PartialChatMessage): ChatMessage {
|
||||
return {
|
||||
id: nanoid(),
|
||||
timestamp: Date.now(),
|
||||
room: partialMsg.room,
|
||||
context: partialMsg.context,
|
||||
type: partialMsg.type,
|
||||
revalidateOnly: partialMsg.revalidateOnly,
|
||||
revalidateScope: partialMsg.revalidateScope,
|
||||
authorUserId: partialMsg.authorUserId ?? actorIdOrNullSafe() ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function postMessages(fullMessages: ChatMessage[]) {
|
||||
return void fetch(ServerConfig.skalop.systemMessageUrl!, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
action: "sendMessage",
|
||||
messages: fullMessages,
|
||||
}),
|
||||
headers: [
|
||||
[SKALOP_TOKEN_HEADER_NAME, ServerConfig.skalop.token!],
|
||||
["Content-Type", "application/json"],
|
||||
],
|
||||
}).catch(logSkalpError("sendMessage"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells skalop to send a contentless "your notifications changed" ping to the
|
||||
* users' websocket connections, prompting their clients to refetch. Fire and
|
||||
* forget like the other system messages; a lost ping only delays the refetch.
|
||||
*/
|
||||
export function notifyNotificationsChanged(userIds: number[]) {
|
||||
if (systemMessagesDisabled) return;
|
||||
if (userIds.length === 0) return;
|
||||
|
||||
return void fetch(ServerConfig.skalop.systemMessageUrl!, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
action: "notifyUsers",
|
||||
userIds,
|
||||
}),
|
||||
headers: [
|
||||
[SKALOP_TOKEN_HEADER_NAME, ServerConfig.skalop.token!],
|
||||
["Content-Type", "application/json"],
|
||||
],
|
||||
}).catch(logSkalpError("notifyUsers"));
|
||||
}
|
||||
|
||||
export function removeRoom(chatCode: string) {
|
||||
if (systemMessagesDisabled) return;
|
||||
|
||||
return void fetch(ServerConfig.skalop.systemMessageUrl!, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
action: "removeRoom",
|
||||
chatCode,
|
||||
}),
|
||||
headers: [
|
||||
[SKALOP_TOKEN_HEADER_NAME, ServerConfig.skalop.token!],
|
||||
["Content-Type", "application/json"],
|
||||
],
|
||||
}).catch(logSkalpError("removeRoom"));
|
||||
}
|
||||
|
||||
interface SetMetadataArgs {
|
||||
chatCode: string;
|
||||
header: string;
|
||||
subtitle: string;
|
||||
url: string;
|
||||
imageUrl?: string;
|
||||
participantUserIds: number[];
|
||||
expiresAfter?: { hours: number } | { days: number };
|
||||
expiresAt?: Date;
|
||||
}
|
||||
|
||||
const MAX_DEDUP_CACHE_SIZE = 5_000;
|
||||
const DEDUP_CACHE_PRUNE_TARGET = 2_500;
|
||||
const MIN_EXPIRY_EXTENSION_MS = 15 * 60 * 1000;
|
||||
const metadataDedup = new Map<
|
||||
string,
|
||||
{ participantsKey: string; expiresAt: number }
|
||||
>();
|
||||
|
||||
export async function setMetadata(args: SetMetadataArgs) {
|
||||
if (systemMessagesDisabled) return;
|
||||
if (!ServerConfig.skalop.systemMessageUrl) return;
|
||||
|
||||
invariant(
|
||||
args.expiresAt || args.expiresAfter,
|
||||
"setMetadata requires either expiresAt or expiresAfter",
|
||||
);
|
||||
|
||||
const participantsKey = args.participantUserIds
|
||||
.slice()
|
||||
.sort((a, b) => a - b)
|
||||
.join(",");
|
||||
const expiresAt = args.expiresAt
|
||||
? args.expiresAt.getTime()
|
||||
: add(new Date(), args.expiresAfter!).getTime();
|
||||
|
||||
// skip only if a resend would neither change the roster nor meaningfully
|
||||
// extend the room's lifetime
|
||||
const cached = metadataDedup.get(args.chatCode);
|
||||
if (
|
||||
cached?.participantsKey === participantsKey &&
|
||||
expiresAt - cached.expiresAt < MIN_EXPIRY_EXTENSION_MS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
metadataDedup.delete(args.chatCode);
|
||||
metadataDedup.set(args.chatCode, { participantsKey, expiresAt });
|
||||
|
||||
if (metadataDedup.size > MAX_DEDUP_CACHE_SIZE) {
|
||||
const entries = [...metadataDedup.entries()];
|
||||
metadataDedup.clear();
|
||||
for (const entry of entries.slice(-DEDUP_CACHE_PRUNE_TARGET)) {
|
||||
metadataDedup.set(entry[0], entry[1]);
|
||||
}
|
||||
}
|
||||
|
||||
const chatUsers = await UserRepository.findChatUsersByUserIds(
|
||||
args.participantUserIds,
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`Setting chat room metadata for ${args.chatCode} (participants: ${participantsKey})`,
|
||||
);
|
||||
|
||||
return void fetch(ServerConfig.skalop.systemMessageUrl, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
action: "setMetadata",
|
||||
chatCode: args.chatCode,
|
||||
metadata: {
|
||||
participantUserIds: args.participantUserIds,
|
||||
chatUsers,
|
||||
expiresAt,
|
||||
header: args.header,
|
||||
subtitle: args.subtitle,
|
||||
url: args.url,
|
||||
imageUrl: args.imageUrl,
|
||||
},
|
||||
}),
|
||||
headers: [
|
||||
[SKALOP_TOKEN_HEADER_NAME, ServerConfig.skalop.token!],
|
||||
["Content-Type", "application/json"],
|
||||
],
|
||||
}).catch(logSkalpError("setMetadata"));
|
||||
}
|
||||
61
apps/web-react/app/features/chat/chat-constants.test.ts
Normal file
61
apps/web-react/app/features/chat/chat-constants.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { findRoomLinks, isSplatnetRoomUrl } from "./chat-constants";
|
||||
|
||||
describe("isSplatnetRoomUrl", () => {
|
||||
test.each([
|
||||
"https://s.nintendo.com/av5ja-lp1/znca/game/4834290508791808?p=%2Froom_creator%2Finvitation%2F1f14e24b-3c9e-6352-8a80-b7993ffad0d0",
|
||||
"https://s.nintendo.com/av5ja-lp1/abc123",
|
||||
"https://s.nintendo.com/abcdef",
|
||||
])("accepts %s", (url) => {
|
||||
expect(isSplatnetRoomUrl(url)).toBe(true);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["http://s.nintendo.com/abc", "http, not https"],
|
||||
["https://sanintendoacom.evil.tld/lobby", "unescaped-dot lookalike host"],
|
||||
["https://s-nintendo-com.evil.tld/lobby", "dash variant host"],
|
||||
["https://s.nintendo.com@evil.com/abc", "userinfo in the URL"],
|
||||
["https://s.nintendo.com:8080/abc", "custom port"],
|
||||
["https://s.nintendo.com/abc?redirect=evil.com", "query string"],
|
||||
["https://s.nintendo.com/abc#@evil.com", "fragment"],
|
||||
["https://s.nintendo.com./abc", "trailing dot in the hostname"],
|
||||
["https://s.nintendo.com/", "empty path"],
|
||||
["https://s.nintendo.com/abc!def", "disallowed characters in the path"],
|
||||
["not a url", "malformed URL"],
|
||||
])("rejects %s (%s)", (url) => {
|
||||
expect(isSplatnetRoomUrl(url)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findRoomLinks", () => {
|
||||
test("returns empty array when no links", () => {
|
||||
expect(findRoomLinks("just chatting here")).toEqual([]);
|
||||
});
|
||||
|
||||
test("finds a valid link with its index", () => {
|
||||
const text = "join: https://s.nintendo.com/abc123 thanks";
|
||||
expect(findRoomLinks(text)).toEqual([
|
||||
{ url: "https://s.nintendo.com/abc123", index: 6 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("ignores spoofed lookalike hosts even when surrounding text matches the candidate regex", () => {
|
||||
const text = "join here https://sanintendoacom.evil.tld/lobby right now";
|
||||
expect(findRoomLinks(text)).toEqual([]);
|
||||
});
|
||||
|
||||
test("ignores links with query strings", () => {
|
||||
const text =
|
||||
"https://s.nintendo.com/abc?redirect=https://evil.com legitimate?";
|
||||
expect(findRoomLinks(text)).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns multiple valid links", () => {
|
||||
const text =
|
||||
"https://s.nintendo.com/aaa and also https://s.nintendo.com/bbb";
|
||||
expect(findRoomLinks(text)).toEqual([
|
||||
{ url: "https://s.nintendo.com/aaa", index: 0 },
|
||||
{ url: "https://s.nintendo.com/bbb", index: 36 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
48
apps/web-react/app/features/chat/chat-constants.ts
Normal file
48
apps/web-react/app/features/chat/chat-constants.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
export const MESSAGE_MAX_LENGTH = 200;
|
||||
|
||||
const SPLATNET_ROOM_HOST = "s.nintendo.com";
|
||||
const SPLATNET_ROOM_PATH_PATTERN = /^\/[A-Za-z0-9/_-]+$/;
|
||||
const SPLATNET_ROOM_CANDIDATE_PATTERN = /https:\/\/s\.nintendo\.com\/\S+/g;
|
||||
|
||||
export function isSplatnetRoomUrl(url: string): boolean {
|
||||
if (!canParseUrl(url)) return false;
|
||||
const parsed = new URL(url);
|
||||
return (
|
||||
parsed.protocol === "https:" &&
|
||||
parsed.hostname === SPLATNET_ROOM_HOST &&
|
||||
parsed.username === "" &&
|
||||
parsed.password === "" &&
|
||||
parsed.port === "" &&
|
||||
parsed.hash === "" &&
|
||||
SPLATNET_ROOM_PATH_PATTERN.test(parsed.pathname) &&
|
||||
isAllowedSplatnetSearch(parsed.searchParams)
|
||||
);
|
||||
}
|
||||
|
||||
export function findRoomLinks(
|
||||
text: string,
|
||||
): Array<{ url: string; index: number }> {
|
||||
const results: Array<{ url: string; index: number }> = [];
|
||||
for (const match of text.matchAll(SPLATNET_ROOM_CANDIDATE_PATTERN)) {
|
||||
if (isSplatnetRoomUrl(match[0])) {
|
||||
results.push({ url: match[0], index: match.index });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function canParseUrl(url: string): boolean {
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isAllowedSplatnetSearch(params: URLSearchParams): boolean {
|
||||
if (params.size === 0) return true;
|
||||
if (params.size > 1) return false;
|
||||
const p = params.get("p");
|
||||
return p !== null && SPLATNET_ROOM_PATH_PATTERN.test(p);
|
||||
}
|
||||
192
apps/web-react/app/features/chat/chat-hooks.browser.test.tsx
Normal file
192
apps/web-react/app/features/chat/chat-hooks.browser.test.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import * as React from "react";
|
||||
import { createMemoryRouter, RouterProvider } from "react-router";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { render } from "vitest-browser-react";
|
||||
import { useLiveRevalidation } from "./chat-hooks";
|
||||
import type { ChatContextValue } from "./chat-provider-types";
|
||||
import { ChatContext } from "./useChatContext";
|
||||
|
||||
vi.mock("~/features/auth/core/user", () => ({
|
||||
useUser: () => null,
|
||||
}));
|
||||
|
||||
const { scheduleBroadcastRevalidation } = vi.hoisted(() => ({
|
||||
scheduleBroadcastRevalidation: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./revalidation-scope", () => ({ scheduleBroadcastRevalidation }));
|
||||
|
||||
const WS_DOWN_CATCH_UP_MS = 2 * 60 * 1000;
|
||||
const CATCH_UP_HIDDEN_MS = 20 * 1000;
|
||||
|
||||
type ReadyState = ChatContextValue["readyState"];
|
||||
|
||||
function LiveRevalidator({ enabled }: { enabled: boolean }) {
|
||||
useLiveRevalidation(enabled);
|
||||
|
||||
return <div data-testid="live" />;
|
||||
}
|
||||
|
||||
function Harness({
|
||||
initialReadyState,
|
||||
enabled,
|
||||
}: {
|
||||
initialReadyState: ReadyState | null;
|
||||
enabled: boolean;
|
||||
}) {
|
||||
const [readyState, setReadyState] = React.useState(initialReadyState);
|
||||
|
||||
return (
|
||||
<ChatContext.Provider
|
||||
value={readyState === null ? null : ({ readyState } as ChatContextValue)}
|
||||
>
|
||||
<LiveRevalidator enabled={enabled} />
|
||||
<button type="button" onClick={() => setReadyState("CLOSED")}>
|
||||
drop
|
||||
</button>
|
||||
<button type="button" onClick={() => setReadyState("CONNECTED")}>
|
||||
reconnect
|
||||
</button>
|
||||
</ChatContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** `null` stands for a logged out visitor: no chat provider, so no context. */
|
||||
const renderHarness = (initialReadyState: ReadyState | null, enabled = true) =>
|
||||
render(
|
||||
<RouterProvider
|
||||
router={createMemoryRouter([
|
||||
{
|
||||
path: "/",
|
||||
element: (
|
||||
<Harness initialReadyState={initialReadyState} enabled={enabled} />
|
||||
),
|
||||
},
|
||||
])}
|
||||
/>,
|
||||
);
|
||||
|
||||
const setVisibility = (state: DocumentVisibilityState) => {
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
configurable: true,
|
||||
get: () => state,
|
||||
});
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
};
|
||||
|
||||
/**
|
||||
* Runs the fake clock forward and lets React paint what the fired timers
|
||||
* changed. React schedules its render through a MessageChannel, which fake
|
||||
* timers do not control, so a message of our own posted afterwards is what
|
||||
* tells us the render already happened.
|
||||
*/
|
||||
const advanceTimers = async (ms: number) => {
|
||||
await vi.advanceTimersByTimeAsync(ms);
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
const channel = new MessageChannel();
|
||||
channel.port1.onmessage = () => resolve();
|
||||
channel.port2.postMessage(null);
|
||||
});
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
scheduleBroadcastRevalidation.mockClear();
|
||||
setVisibility("visible");
|
||||
});
|
||||
|
||||
describe("useLiveRevalidation - reconnect", () => {
|
||||
test("does not revalidate on the first connect", async () => {
|
||||
await renderHarness("CONNECTED");
|
||||
|
||||
expect(scheduleBroadcastRevalidation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("revalidates once the websocket comes back up", async () => {
|
||||
const screen = await renderHarness("CONNECTED");
|
||||
|
||||
await screen.getByRole("button", { name: "drop" }).click();
|
||||
await screen.getByRole("button", { name: "reconnect" }).click();
|
||||
|
||||
await expect
|
||||
.poll(() => scheduleBroadcastRevalidation.mock.calls.length)
|
||||
.toBe(1);
|
||||
});
|
||||
|
||||
test("does not revalidate on reconnect when disabled", async () => {
|
||||
const screen = await renderHarness("CONNECTED", false);
|
||||
|
||||
await screen.getByRole("button", { name: "drop" }).click();
|
||||
await screen.getByRole("button", { name: "reconnect" }).click();
|
||||
|
||||
expect(scheduleBroadcastRevalidation).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useLiveRevalidation - visibility and fallback poll", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("revalidates when returning to a tab that was hidden for a while", async () => {
|
||||
await renderHarness("CONNECTED");
|
||||
|
||||
setVisibility("hidden");
|
||||
await advanceTimers(CATCH_UP_HIDDEN_MS);
|
||||
setVisibility("visible");
|
||||
|
||||
expect(scheduleBroadcastRevalidation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("does not revalidate after a brief tab away", async () => {
|
||||
await renderHarness("CONNECTED");
|
||||
|
||||
setVisibility("hidden");
|
||||
await advanceTimers(CATCH_UP_HIDDEN_MS / 2);
|
||||
setVisibility("visible");
|
||||
|
||||
expect(scheduleBroadcastRevalidation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("revalidates on an interval while the websocket is down", async () => {
|
||||
await renderHarness("CLOSED");
|
||||
|
||||
await advanceTimers(WS_DOWN_CATCH_UP_MS);
|
||||
expect(scheduleBroadcastRevalidation).toHaveBeenCalledTimes(1);
|
||||
|
||||
await advanceTimers(WS_DOWN_CATCH_UP_MS);
|
||||
expect(scheduleBroadcastRevalidation).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("does not poll while the websocket is up", async () => {
|
||||
await renderHarness("CONNECTED");
|
||||
|
||||
await advanceTimers(WS_DOWN_CATCH_UP_MS * 3);
|
||||
|
||||
expect(scheduleBroadcastRevalidation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does nothing at all without a chat provider", async () => {
|
||||
await renderHarness(null);
|
||||
|
||||
setVisibility("hidden");
|
||||
await advanceTimers(WS_DOWN_CATCH_UP_MS * 3);
|
||||
setVisibility("visible");
|
||||
|
||||
expect(scheduleBroadcastRevalidation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does nothing at all when disabled", async () => {
|
||||
await renderHarness("CLOSED", false);
|
||||
|
||||
setVisibility("hidden");
|
||||
await advanceTimers(WS_DOWN_CATCH_UP_MS * 3);
|
||||
setVisibility("visible");
|
||||
|
||||
expect(scheduleBroadcastRevalidation).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
251
apps/web-react/app/features/chat/chat-hooks.ts
Normal file
251
apps/web-react/app/features/chat/chat-hooks.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import * as React from "react";
|
||||
import { useRevalidator } from "react-router";
|
||||
import { useUser } from "../auth/core/user";
|
||||
import type { ChatContextValue } from "./chat-provider-types";
|
||||
import type { ChatMessage } from "./chat-types";
|
||||
import { scheduleBroadcastRevalidation } from "./revalidation-scope";
|
||||
import { useChatContext } from "./useChatContext";
|
||||
|
||||
// increasing this = scrolling happens even when scrolled more upwards
|
||||
const THRESHOLD = 100;
|
||||
// how long the tab must have been hidden for returning to it to be worth a catch-up
|
||||
const CATCH_UP_HIDDEN_MS = 20 * 1000;
|
||||
// how often to catch up while the websocket is down and no broadcast can arrive
|
||||
const WS_DOWN_CATCH_UP_MS = 2 * 60 * 1000;
|
||||
// how long after wheel/touch/keyboard input a scroll event still counts as user-initiated
|
||||
const USER_SCROLL_INTENT_MS = 150;
|
||||
|
||||
export function useChatAutoScroll(
|
||||
messages: ChatMessage[],
|
||||
ref: React.RefObject<HTMLElement | null>,
|
||||
) {
|
||||
const user = useUser();
|
||||
const [unseenMessages, setUnseenMessages] = React.useState(false);
|
||||
const pinnedToBottomRef = React.useRef(true);
|
||||
const lastUserScrollIntentRef = React.useRef(Number.NEGATIVE_INFINITY);
|
||||
const isPointerDownRef = React.useRef(false);
|
||||
const lastStableScrollTopRef = React.useRef(0);
|
||||
|
||||
const scrollToBottom = React.useCallback(() => {
|
||||
const messagesContainer = ref.current;
|
||||
if (!messagesContainer) return;
|
||||
|
||||
pinnedToBottomRef.current = true;
|
||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||
setUnseenMessages(false);
|
||||
}, [ref]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const messagesContainer = ref.current!;
|
||||
|
||||
function markUserScrollIntent() {
|
||||
lastUserScrollIntentRef.current = performance.now();
|
||||
}
|
||||
function handlePointerDown() {
|
||||
isPointerDownRef.current = true;
|
||||
}
|
||||
function handlePointerUp() {
|
||||
isPointerDownRef.current = false;
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
const isUserScroll =
|
||||
isPointerDownRef.current ||
|
||||
performance.now() - lastUserScrollIntentRef.current <
|
||||
USER_SCROLL_INTENT_MS;
|
||||
const isScrolledToBottom =
|
||||
messagesContainer.scrollTop + messagesContainer.clientHeight >=
|
||||
messagesContainer.scrollHeight - THRESHOLD;
|
||||
|
||||
// react-aria's Virtualizer resets the scroll position to the top
|
||||
// whenever the message collection changes; undo those resets so
|
||||
// they neither unpin the auto scroll nor yank the user out of the
|
||||
// history they were reading
|
||||
if (!isUserScroll) {
|
||||
if (pinnedToBottomRef.current && !isScrolledToBottom) {
|
||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!pinnedToBottomRef.current &&
|
||||
messagesContainer.scrollTop === 0 &&
|
||||
lastStableScrollTopRef.current > 0
|
||||
) {
|
||||
messagesContainer.scrollTop = lastStableScrollTopRef.current;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
pinnedToBottomRef.current = isScrolledToBottom;
|
||||
lastStableScrollTopRef.current = messagesContainer.scrollTop;
|
||||
if (isScrolledToBottom) {
|
||||
setUnseenMessages(false);
|
||||
}
|
||||
}
|
||||
|
||||
messagesContainer.addEventListener("wheel", markUserScrollIntent, {
|
||||
passive: true,
|
||||
});
|
||||
messagesContainer.addEventListener("touchmove", markUserScrollIntent, {
|
||||
passive: true,
|
||||
});
|
||||
messagesContainer.addEventListener("keydown", markUserScrollIntent);
|
||||
messagesContainer.addEventListener("pointerdown", handlePointerDown);
|
||||
window.addEventListener("pointerup", handlePointerUp);
|
||||
messagesContainer.addEventListener("scroll", handleScroll);
|
||||
|
||||
return () => {
|
||||
messagesContainer.removeEventListener("wheel", markUserScrollIntent);
|
||||
messagesContainer.removeEventListener("touchmove", markUserScrollIntent);
|
||||
messagesContainer.removeEventListener("keydown", markUserScrollIntent);
|
||||
messagesContainer.removeEventListener("pointerdown", handlePointerDown);
|
||||
window.removeEventListener("pointerup", handlePointerUp);
|
||||
messagesContainer.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}, [ref]);
|
||||
|
||||
const hasMessages = messages.length > 0;
|
||||
|
||||
// the virtualizer resizes the scrollable content asynchronously as it
|
||||
// measures rows, without scroll events firing, so keep the view glued to
|
||||
// the bottom whenever the content height changes while pinned there
|
||||
React.useEffect(() => {
|
||||
if (!hasMessages) return;
|
||||
|
||||
const messagesContainer = ref.current!;
|
||||
const scrollContent = messagesContainer.firstElementChild;
|
||||
if (!scrollContent) return;
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (pinnedToBottomRef.current) {
|
||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||
}
|
||||
});
|
||||
observer.observe(scrollContent);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [ref, hasMessages]);
|
||||
|
||||
const latestMessage = messages.at(-1);
|
||||
const latestMessageId = latestMessage?.id;
|
||||
const latestMessageIsOwn = user != null && latestMessage?.userId === user.id;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!latestMessageId) return;
|
||||
|
||||
if (latestMessageIsOwn || pinnedToBottomRef.current) {
|
||||
scrollToBottom();
|
||||
} else {
|
||||
setUnseenMessages(true);
|
||||
}
|
||||
}, [latestMessageId, latestMessageIsOwn, scrollToBottom]);
|
||||
|
||||
const reset = () => {
|
||||
pinnedToBottomRef.current = true;
|
||||
setUnseenMessages(false);
|
||||
};
|
||||
|
||||
return {
|
||||
unseenMessagesInTheRoom: unseenMessages,
|
||||
resetScroller: reset,
|
||||
scrollToBottom,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes the page to a Skalop topic over the shared chat WebSocket so that
|
||||
* `revalidateOnly` broadcasts to the topic trigger a data loader revalidation.
|
||||
* Topics are lightweight: no metadata, no participants, no history — purely a
|
||||
* fan-out channel. Pass `connected=false` to opt out (e.g. once a tournament
|
||||
* has been finalized and no further updates are expected).
|
||||
*/
|
||||
export function useWebsocketRevalidation(topic: string, connected = true) {
|
||||
const chat = useChatContext();
|
||||
const subscribeTopic = chat?.subscribeTopic;
|
||||
const unsubscribeTopic = chat?.unsubscribeTopic;
|
||||
const readyState = chat?.readyState;
|
||||
|
||||
useLiveRevalidation(connected);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!connected || readyState !== "CONNECTED") return;
|
||||
if (!subscribeTopic || !unsubscribeTopic) return;
|
||||
|
||||
subscribeTopic(topic);
|
||||
return () => unsubscribeTopic(topic);
|
||||
}, [topic, connected, readyState, subscribeTopic, unsubscribeTopic]);
|
||||
}
|
||||
|
||||
export function useLiveRevalidation(enabled = true) {
|
||||
const chat = useChatContext();
|
||||
const { revalidate } = useRevalidator();
|
||||
|
||||
// a logged out visitor has no websocket to miss broadcasts from in the first place,
|
||||
// and revalidating for them would only add load
|
||||
const active = enabled && chat !== null;
|
||||
const readyState = chat?.readyState ?? "CLOSED";
|
||||
|
||||
// goes through the broadcast scheduler so a catch-up shares its jitter (many clients
|
||||
// return to a page at once after a skalop deploy) and absorption into a broadcast
|
||||
// that is already scheduled
|
||||
const catchUp = React.useEffectEvent(() => {
|
||||
scheduleBroadcastRevalidation(revalidate, undefined);
|
||||
});
|
||||
|
||||
// while inactive nothing can be missed, so the connect that follows counts as the first
|
||||
useRefreshOnReconnect(active ? readyState : "CLOSED", catchUp);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active) return;
|
||||
|
||||
let hiddenAt: number | null = null;
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState !== "visible") {
|
||||
hiddenAt = Date.now();
|
||||
return;
|
||||
}
|
||||
|
||||
// a quick tab away can not have missed anything the socket would not
|
||||
// still deliver, and revalidating for it would be pure server load
|
||||
if (hiddenAt !== null && Date.now() - hiddenAt >= CATCH_UP_HIDDEN_MS) {
|
||||
catchUp();
|
||||
}
|
||||
hiddenAt = null;
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
return () =>
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
}, [active]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active || readyState === "CONNECTED") return;
|
||||
|
||||
const interval = setInterval(catchUp, WS_DOWN_CATCH_UP_MS);
|
||||
return () => clearInterval(interval);
|
||||
}, [active, readyState]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls `onReconnect` every time the websocket comes back up, skipping the initial
|
||||
* connect: only what happened while the socket was down needs catching up on.
|
||||
*/
|
||||
export function useRefreshOnReconnect(
|
||||
readyState: ChatContextValue["readyState"],
|
||||
onReconnect: () => void,
|
||||
) {
|
||||
const handleReconnect = React.useEffectEvent(onReconnect);
|
||||
const hasConnectedRef = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (readyState !== "CONNECTED") return;
|
||||
|
||||
if (!hasConnectedRef.current) {
|
||||
hasConnectedRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
handleReconnect();
|
||||
}, [readyState]);
|
||||
}
|
||||
20
apps/web-react/app/features/chat/chat-last-read.test.ts
Normal file
20
apps/web-react/app/features/chat/chat-last-read.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
assertDecodesToDefault,
|
||||
assertRoundTrips,
|
||||
} from "~/modules/persisted-state/persisted-state-test-utils";
|
||||
import { lastReadCountsPersisted } from "./chat-last-read";
|
||||
|
||||
describe("lastReadCountsPersisted", () => {
|
||||
test("round-trips", () => {
|
||||
assertRoundTrips(lastReadCountsPersisted, [0, 42]);
|
||||
});
|
||||
|
||||
test("decodes legacy raw number strings", () => {
|
||||
expect(lastReadCountsPersisted.decode("7")).toBe(7);
|
||||
});
|
||||
|
||||
test("malformed values decode to the default", () => {
|
||||
assertDecodesToDefault(lastReadCountsPersisted, ["abc", "", "Infinity"]);
|
||||
});
|
||||
});
|
||||
23
apps/web-react/app/features/chat/chat-last-read.ts
Normal file
23
apps/web-react/app/features/chat/chat-last-read.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { z } from "zod";
|
||||
import { usePersistedMapState } from "~/modules/persisted-state/hooks";
|
||||
import * as PersistedState from "~/modules/persisted-state/persisted-state";
|
||||
|
||||
export const lastReadCountsPersisted = PersistedState.defineMap({
|
||||
keyPrefix: "chat_read__",
|
||||
storage: "local",
|
||||
schema: z.number(),
|
||||
default: 0,
|
||||
});
|
||||
|
||||
/**
|
||||
* The last read message count per chat room (chat code -> count), persisted in
|
||||
* localStorage and kept in sync across tabs via the `storage` event.
|
||||
*/
|
||||
export function useLastReadCounts(): Record<string, number> {
|
||||
return usePersistedMapState(lastReadCountsPersisted);
|
||||
}
|
||||
|
||||
/** Persists the last read message count for a room, notifying subscribers in this tab (other tabs sync via the `storage` event). */
|
||||
export function writeLastReadCount(chatCode: string, count: number) {
|
||||
PersistedState.writeMapEntry(lastReadCountsPersisted, chatCode, count);
|
||||
}
|
||||
70
apps/web-react/app/features/chat/chat-provider-types.ts
Normal file
70
apps/web-react/app/features/chat/chat-provider-types.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { ChatMessage, ChatUser } from "./chat-types";
|
||||
|
||||
/** Metadata stored per room on the Skalop server */
|
||||
export interface RoomMetadata {
|
||||
participantUserIds: number[];
|
||||
chatUsers: Record<number, ChatUser>;
|
||||
expiresAt: number;
|
||||
header: string;
|
||||
subtitle?: string;
|
||||
url?: string;
|
||||
imageUrl?: string;
|
||||
createdAt?: number;
|
||||
}
|
||||
|
||||
/** Room info as returned by Skalop on connect and ROOM_JOINED events */
|
||||
export interface ServerRoomInfo {
|
||||
chatCode: string;
|
||||
metadata: RoomMetadata;
|
||||
lastMessageTimestamp: number | null;
|
||||
totalMessageCount: number;
|
||||
}
|
||||
|
||||
/** Flattened room info used by the UI */
|
||||
export interface RoomInfo {
|
||||
chatCode: string;
|
||||
header: string;
|
||||
subtitle: string;
|
||||
url: string;
|
||||
imageUrl: string;
|
||||
participantUserIds: number[];
|
||||
expiresAt: number;
|
||||
lastMessageTimestamp: number;
|
||||
totalMessageCount: number;
|
||||
createdAt: number;
|
||||
isObsolete?: boolean;
|
||||
}
|
||||
|
||||
export interface ChatContextValue {
|
||||
isLoading: boolean;
|
||||
rooms: RoomInfo[];
|
||||
messagesForRoom: (chatCode: string) => ChatMessage[];
|
||||
send: (chatCode: string, contents: string) => void;
|
||||
subscribe: (chatCode: string) => void;
|
||||
unsubscribe: (chatCode: string) => void;
|
||||
subscribeTopic: (topic: string) => void;
|
||||
unsubscribeTopic: (topic: string) => void;
|
||||
requestHistory: (chatCode: string) => void;
|
||||
markAsRead: (chatCode: string) => void;
|
||||
unreadCounts: Record<string, number>;
|
||||
totalUnreadCount: number;
|
||||
readyState: "CONNECTING" | "CONNECTED" | "CLOSED";
|
||||
/**
|
||||
* Bumps every time skalop pings that the user's notifications changed
|
||||
* server-side. Carries no data on purpose; watchers react by refetching.
|
||||
*/
|
||||
notificationsVersion: number;
|
||||
chatUsers: Record<number, ChatUser>;
|
||||
chatOpen: boolean;
|
||||
setChatOpen: (open: boolean) => void;
|
||||
/**
|
||||
* Chat codes the user is currently viewing. Empty means none are open, one
|
||||
* renders a single chat, more than one renders the split view. The first code
|
||||
* is the primary room (shown on top / given the larger share in split view).
|
||||
*/
|
||||
activeRooms: string[];
|
||||
setActiveRooms: (chatCodes: string[]) => void;
|
||||
chatLabels: Record<number, string>;
|
||||
setChatLabels: (labels: Record<number, string>) => void;
|
||||
clearChatLabels: () => void;
|
||||
}
|
||||
25
apps/web-react/app/features/chat/chat-search-params.test.ts
Normal file
25
apps/web-react/app/features/chat/chat-search-params.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { assertRoundTrips } from "~/modules/search-params/search-params-test-utils";
|
||||
import { chatUsersSearchParams } from "./chat-search-params";
|
||||
|
||||
describe("chatUsersSearchParams", () => {
|
||||
test("round-trips", () => {
|
||||
assertRoundTrips(chatUsersSearchParams, {
|
||||
ids: [[], [1], [1, 2, 3]],
|
||||
});
|
||||
});
|
||||
|
||||
test("accepts the legacy comma-joined form", () => {
|
||||
const { ids } = chatUsersSearchParams.parse(
|
||||
new URL("http://localhost/api/chat-users?ids=1,2,3"),
|
||||
);
|
||||
expect(ids).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
test("drops invalid members instead of the whole array", () => {
|
||||
const { ids } = chatUsersSearchParams.parse(
|
||||
new URL("http://localhost/api/chat-users?ids=1&ids=abc&ids=-5&ids=3"),
|
||||
);
|
||||
expect(ids).toEqual([1, 3]);
|
||||
});
|
||||
});
|
||||
10
apps/web-react/app/features/chat/chat-search-params.ts
Normal file
10
apps/web-react/app/features/chat/chat-search-params.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { z } from "zod";
|
||||
import * as SearchParams from "~/modules/search-params/search-params";
|
||||
import { SP } from "~/modules/search-params/search-params";
|
||||
|
||||
export const chatUsersSearchParams = SearchParams.define({
|
||||
ids: SP.param(z.array(z.number().int().positive()), {
|
||||
default: [],
|
||||
loader: true,
|
||||
}),
|
||||
});
|
||||
58
apps/web-react/app/features/chat/chat-types.ts
Normal file
58
apps/web-react/app/features/chat/chat-types.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { Tables } from "~/db/tables";
|
||||
|
||||
export type SystemMessageType =
|
||||
| "NEW_GROUP"
|
||||
| "USER_LEFT"
|
||||
| "MATCH_STARTED"
|
||||
| "READY_CHECK_STARTED"
|
||||
| "LIKE_RECEIVED"
|
||||
| "SCORE_REPORTED"
|
||||
| "SCORE_CONFIRMED"
|
||||
| "CANCEL_REPORTED"
|
||||
| "CANCEL_CONFIRMED"
|
||||
| "CANCEL_REFUSED"
|
||||
| "TOURNAMENT_UPDATED"
|
||||
| "TOURNAMENT_MATCH_UPDATED"
|
||||
| "MAP_REPLAYED"
|
||||
| "MAP_PICKED";
|
||||
|
||||
export type SystemMessageContext = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type RevalidateScope = "MATCH_RESULTS";
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
type?: SystemMessageType;
|
||||
contents?: string;
|
||||
context?: SystemMessageContext;
|
||||
/** If true, the purpose of this message is just to run the data loaders again meaning the logic related to showing a new chat message is skipped. Defaults to false. */
|
||||
revalidateOnly?: boolean;
|
||||
/** Narrows what data a `revalidateOnly` message may have changed so that routes whose data is unaffected can skip revalidating. Unset means anything may have changed. */
|
||||
revalidateScope?: RevalidateScope;
|
||||
/** User id of the actor that triggered this message. Used to skip own-author revalidates so we don't double-fetch loaders right after a form submission. */
|
||||
authorUserId?: number;
|
||||
userId?: number;
|
||||
timestamp: number;
|
||||
room: string;
|
||||
pending?: boolean;
|
||||
}
|
||||
|
||||
export type ChatUser = Pick<
|
||||
Tables["User"],
|
||||
"username" | "discordId" | "discordAvatar" | "pronouns"
|
||||
> & {
|
||||
customAvatarUrl: string | null;
|
||||
chatNameHue: string | null;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export interface ChatProps {
|
||||
users: Record<number, ChatUser>;
|
||||
rooms: { label: string; code: string }[];
|
||||
className?: string;
|
||||
messagesContainerClassName?: string;
|
||||
hidden?: boolean;
|
||||
disabled?: boolean;
|
||||
missingUserName?: string;
|
||||
}
|
||||
101
apps/web-react/app/features/chat/chat-utils.test.ts
Normal file
101
apps/web-react/app/features/chat/chat-utils.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { sub } from "date-fns";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
chatAccessible,
|
||||
datePlaceholder,
|
||||
resolveDatePlaceholders,
|
||||
} from "./chat-utils";
|
||||
|
||||
describe("chatCodeVisible", () => {
|
||||
test("visible when within expiration window", () => {
|
||||
const result = chatAccessible({
|
||||
isStaff: false,
|
||||
expiresAfterDays: 1,
|
||||
comparedTo: new Date(),
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("not visible when just past expiration window", () => {
|
||||
const result = chatAccessible({
|
||||
isStaff: false,
|
||||
expiresAfterDays: 1,
|
||||
comparedTo: sub(new Date(), { days: 1, hours: 12 }),
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test("not visible when past expiration window", () => {
|
||||
const result = chatAccessible({
|
||||
isStaff: false,
|
||||
expiresAfterDays: 1,
|
||||
comparedTo: sub(new Date(), { days: 3 }),
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test("staff gets 7 extra days", () => {
|
||||
const result = chatAccessible({
|
||||
isStaff: true,
|
||||
expiresAfterDays: 1,
|
||||
comparedTo: sub(new Date(), { days: 5 }),
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("staff extra days are not infinite", () => {
|
||||
const result = chatAccessible({
|
||||
isStaff: true,
|
||||
expiresAfterDays: 1,
|
||||
comparedTo: sub(new Date(), { days: 10 }),
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("datePlaceholder", () => {
|
||||
test("returns correctly formatted placeholder string", () => {
|
||||
const date = new Date(1700000000000);
|
||||
|
||||
expect(datePlaceholder(date)).toBe("{{date:1700000000000}}");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDatePlaceholders", () => {
|
||||
const mockFormat = (d: Date) => `FORMATTED:${d.getTime()}`;
|
||||
|
||||
test("replaces a single placeholder with formatted date", () => {
|
||||
const text = "Starts at {{date:1700000000000}}";
|
||||
|
||||
expect(resolveDatePlaceholders(text, mockFormat)).toBe(
|
||||
"Starts at FORMATTED:1700000000000",
|
||||
);
|
||||
});
|
||||
|
||||
test("replaces multiple placeholders in one string", () => {
|
||||
const text = "From {{date:1700000000000}} to {{date:1700003600000}}";
|
||||
|
||||
expect(resolveDatePlaceholders(text, mockFormat)).toBe(
|
||||
"From FORMATTED:1700000000000 to FORMATTED:1700003600000",
|
||||
);
|
||||
});
|
||||
|
||||
test("returns text unchanged when no placeholders present", () => {
|
||||
const text = "Just a normal string";
|
||||
|
||||
expect(resolveDatePlaceholders(text, mockFormat)).toBe(text);
|
||||
});
|
||||
|
||||
test("handles text that is only a placeholder", () => {
|
||||
const text = "{{date:1700000000000}}";
|
||||
|
||||
expect(resolveDatePlaceholders(text, mockFormat)).toBe(
|
||||
"FORMATTED:1700000000000",
|
||||
);
|
||||
});
|
||||
});
|
||||
59
apps/web-react/app/features/chat/chat-utils.ts
Normal file
59
apps/web-react/app/features/chat/chat-utils.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { differenceInDays } from "date-fns";
|
||||
import type { ChatMessage } from "./chat-types";
|
||||
|
||||
const STAFF_EXTRA_DAYS = 7;
|
||||
|
||||
/** Should a chat room be still accessible via chat code. */
|
||||
export function chatAccessible(args: {
|
||||
/** Is the user site staff? Allows them to see the chat code for extra days. */
|
||||
isStaff?: boolean;
|
||||
expiresAfterDays: number;
|
||||
comparedTo: Date;
|
||||
}): boolean {
|
||||
const extraDays = args.isStaff ? STAFF_EXTRA_DAYS : 0;
|
||||
return (
|
||||
differenceInDays(new Date(), args.comparedTo) <
|
||||
args.expiresAfterDays + extraDays
|
||||
);
|
||||
}
|
||||
|
||||
const DATE_PLACEHOLDER_PATTERN = /\{\{date:(\d+)\}\}/g;
|
||||
|
||||
export function datePlaceholder(date: Date): string {
|
||||
return `{{date:${date.getTime()}}}`;
|
||||
}
|
||||
|
||||
export function resolveDatePlaceholders(
|
||||
text: string,
|
||||
format: (date: Date) => string,
|
||||
): string {
|
||||
return text.replace(DATE_PLACEHOLDER_PATTERN, (_match, ts) =>
|
||||
format(new Date(Number(ts))),
|
||||
);
|
||||
}
|
||||
|
||||
export function messageTypeToSound(type: ChatMessage["type"]) {
|
||||
if (type === "LIKE_RECEIVED") return "sq_like";
|
||||
if (type === "MATCH_STARTED") return "sq_match";
|
||||
if (type === "READY_CHECK_STARTED") return "sq_ready-check";
|
||||
if (type === "NEW_GROUP") return "sq_new-group";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function soundCodeToLocalStorageKey(soundCode: string) {
|
||||
return `settings__sound-enabled__${soundCode}`;
|
||||
}
|
||||
|
||||
export function soundEnabled(soundCode: string) {
|
||||
const localStorageKey = soundCodeToLocalStorageKey(soundCode);
|
||||
const soundEnabled = localStorage.getItem(localStorageKey);
|
||||
|
||||
return !soundEnabled || soundEnabled === "true";
|
||||
}
|
||||
|
||||
export function soundVolume() {
|
||||
const volume = localStorage.getItem("settings__sound-volume");
|
||||
|
||||
return volume ? Number.parseFloat(volume) : 100;
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import * as React from "react";
|
||||
import { createMemoryRouter, RouterProvider } from "react-router";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { render } from "vitest-browser-react";
|
||||
import type { ChatMessage, ChatUser } from "../chat-types";
|
||||
import { Chat, type ChatAdapter } from "./Chat";
|
||||
|
||||
vi.mock("~/features/auth/core/user", () => ({
|
||||
useUser: () => null,
|
||||
}));
|
||||
|
||||
const USERS: Record<number, ChatUser> = {
|
||||
1: {
|
||||
username: "Alice",
|
||||
discordId: "1",
|
||||
discordAvatar: null,
|
||||
pronouns: null,
|
||||
customAvatarUrl: null,
|
||||
chatNameHue: null,
|
||||
},
|
||||
};
|
||||
|
||||
function createMessage(overrides: Partial<ChatMessage> = {}): ChatMessage {
|
||||
return {
|
||||
id: "1",
|
||||
userId: 1,
|
||||
contents: "Hello world",
|
||||
timestamp: 1700000000000,
|
||||
room: "room",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderChat(
|
||||
messages: ChatMessage[],
|
||||
props?: { missingUserName?: string },
|
||||
) {
|
||||
const chat: ChatAdapter = {
|
||||
messages,
|
||||
send: () => {},
|
||||
currentRoom: "room",
|
||||
setCurrentRoom: () => {},
|
||||
readyState: "CONNECTED",
|
||||
unseenMessages: new Map(),
|
||||
};
|
||||
|
||||
const router = createMemoryRouter(
|
||||
[
|
||||
{
|
||||
path: "/",
|
||||
element: (
|
||||
<div style={{ width: 400 }}>
|
||||
<Chat
|
||||
users={USERS}
|
||||
rooms={[]}
|
||||
chat={chat}
|
||||
missingUserName={props?.missingUserName}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
{ initialEntries: ["/"] },
|
||||
);
|
||||
|
||||
return render(<RouterProvider router={router} />);
|
||||
}
|
||||
|
||||
async function renderChatWithControls(initialMessages: ChatMessage[]) {
|
||||
const controls = {
|
||||
addMessage: (_msg: ChatMessage) => {},
|
||||
};
|
||||
|
||||
function ChatHarness() {
|
||||
const [messages, setMessages] = React.useState(initialMessages);
|
||||
controls.addMessage = (msg) => setMessages((prev) => [...prev, msg]);
|
||||
|
||||
const chat: ChatAdapter = {
|
||||
messages,
|
||||
send: () => {},
|
||||
currentRoom: "room",
|
||||
setCurrentRoom: () => {},
|
||||
readyState: "CONNECTED",
|
||||
unseenMessages: new Map(),
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ width: 400 }}>
|
||||
<Chat users={USERS} rooms={[]} chat={chat} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const router = createMemoryRouter([{ path: "/", element: <ChatHarness /> }], {
|
||||
initialEntries: ["/"],
|
||||
});
|
||||
|
||||
return { screen: await render(<RouterProvider router={router} />), controls };
|
||||
}
|
||||
|
||||
function manyMessages(count: number) {
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
createMessage({ id: String(i + 1), contents: `Message ${i + 1}` }),
|
||||
);
|
||||
}
|
||||
|
||||
function isScrolledToBottom(element: HTMLElement) {
|
||||
return element.scrollTop + element.clientHeight >= element.scrollHeight - 2;
|
||||
}
|
||||
|
||||
describe("Chat", () => {
|
||||
test("renders messages inside a virtualized listbox", async () => {
|
||||
const screen = await renderChat([
|
||||
createMessage({ id: "1", contents: "First message" }),
|
||||
createMessage({ id: "2", contents: "Second message" }),
|
||||
]);
|
||||
|
||||
await expect.element(screen.getByRole("listbox")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("First message")).toBeInTheDocument();
|
||||
await expect
|
||||
.element(screen.getByText("Second message"))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByRole("option").elements()).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("virtualizes a long list into a scrollable region taller than its viewport", async () => {
|
||||
const screen = await renderChat(
|
||||
Array.from({ length: 100 }, (_, i) =>
|
||||
createMessage({ id: String(i + 1), contents: `Message ${i + 1}` }),
|
||||
),
|
||||
);
|
||||
|
||||
const listbox = screen.getByRole("listbox").element() as HTMLElement;
|
||||
await expect.element(screen.getByRole("listbox")).toBeInTheDocument();
|
||||
|
||||
const scrollContent = listbox.querySelector(
|
||||
":scope > [role=presentation]",
|
||||
) as HTMLElement | null;
|
||||
const messageRow = listbox.querySelector(
|
||||
"[role=option]",
|
||||
) as HTMLElement | null;
|
||||
|
||||
expect(scrollContent).not.toBeNull();
|
||||
expect(scrollContent!.offsetHeight).toBeGreaterThan(listbox.clientHeight);
|
||||
expect(getComputedStyle(messageRow!.parentElement!).position).toBe(
|
||||
"absolute",
|
||||
);
|
||||
});
|
||||
|
||||
test("renders system messages", async () => {
|
||||
const screen = await renderChat([
|
||||
createMessage({
|
||||
id: "1",
|
||||
type: "USER_LEFT",
|
||||
contents: undefined,
|
||||
userId: undefined,
|
||||
context: { name: "Bob" },
|
||||
}),
|
||||
]);
|
||||
|
||||
await expect
|
||||
.element(screen.getByText("Bob left the group"))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("skips messages with an unknown user when no fallback name is given", async () => {
|
||||
const screen = await renderChat([
|
||||
createMessage({ id: "1", userId: 999, contents: "Ghost message" }),
|
||||
]);
|
||||
|
||||
await expect.element(screen.getByRole("listbox")).toBeInTheDocument();
|
||||
expect(screen.getByRole("option").elements()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("renders messages with an unknown user using the fallback name", async () => {
|
||||
const screen = await renderChat(
|
||||
[createMessage({ id: "1", userId: 999, contents: "Ghost message" })],
|
||||
{ missingUserName: "Unknown" },
|
||||
);
|
||||
|
||||
await expect.element(screen.getByText("Ghost message")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Unknown")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("scrolls to the bottom on initial load", async () => {
|
||||
const { screen } = await renderChatWithControls(manyMessages(50));
|
||||
|
||||
const listbox = screen.getByRole("listbox");
|
||||
await expect.element(listbox).toBeInTheDocument();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const element = listbox.element() as HTMLElement;
|
||||
expect(element.scrollHeight).toBeGreaterThan(element.clientHeight);
|
||||
expect(isScrolledToBottom(element)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("auto scrolls when a new message arrives while at the bottom", async () => {
|
||||
const { screen, controls } = await renderChatWithControls(manyMessages(50));
|
||||
|
||||
const listbox = screen.getByRole("listbox");
|
||||
await vi.waitFor(() => {
|
||||
expect(isScrolledToBottom(listbox.element() as HTMLElement)).toBe(true);
|
||||
});
|
||||
|
||||
controls.addMessage(
|
||||
createMessage({
|
||||
id: "new",
|
||||
contents:
|
||||
"A brand new message that is long enough to wrap onto multiple lines in the chat window",
|
||||
}),
|
||||
);
|
||||
|
||||
await expect
|
||||
.element(screen.getByText(/A brand new message/))
|
||||
.toBeInTheDocument();
|
||||
await vi.waitFor(() => {
|
||||
expect(isScrolledToBottom(listbox.element() as HTMLElement)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("does not auto scroll when scrolled up, shows the new messages button instead", async () => {
|
||||
const { screen, controls } = await renderChatWithControls(manyMessages(50));
|
||||
|
||||
const listbox = screen.getByRole("listbox");
|
||||
await vi.waitFor(() => {
|
||||
expect(isScrolledToBottom(listbox.element() as HTMLElement)).toBe(true);
|
||||
});
|
||||
|
||||
const element = listbox.element() as HTMLElement;
|
||||
element.dispatchEvent(new WheelEvent("wheel", { deltaY: -100 }));
|
||||
element.scrollTop = 0;
|
||||
element.dispatchEvent(new Event("scroll"));
|
||||
await vi.waitFor(() => {
|
||||
expect(element.scrollTop).toBe(0);
|
||||
});
|
||||
|
||||
controls.addMessage(createMessage({ id: "new", contents: "While away" }));
|
||||
|
||||
await expect.element(screen.getByText("New messages")).toBeInTheDocument();
|
||||
expect(isScrolledToBottom(element)).toBe(false);
|
||||
|
||||
await screen.getByText("New messages").click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(isScrolledToBottom(element)).toBe(true);
|
||||
});
|
||||
await expect
|
||||
.element(screen.getByText("New messages"))
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("keeps the reading position when a new message arrives while scrolled up", async () => {
|
||||
const { screen, controls } = await renderChatWithControls(manyMessages(50));
|
||||
|
||||
const listbox = screen.getByRole("listbox");
|
||||
await vi.waitFor(() => {
|
||||
expect(isScrolledToBottom(listbox.element() as HTMLElement)).toBe(true);
|
||||
});
|
||||
|
||||
const element = listbox.element() as HTMLElement;
|
||||
const readingPosition = Math.floor(element.scrollHeight / 2);
|
||||
element.dispatchEvent(new WheelEvent("wheel", { deltaY: -100 }));
|
||||
element.scrollTop = readingPosition;
|
||||
element.dispatchEvent(new Event("scroll"));
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
controls.addMessage(createMessage({ id: "new", contents: "While away" }));
|
||||
|
||||
await expect.element(screen.getByText("New messages")).toBeInTheDocument();
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
expect(element.scrollTop).toBe(readingPosition);
|
||||
});
|
||||
});
|
||||
190
apps/web-react/app/features/chat/components/Chat.module.css
Normal file
190
apps/web-react/app/features/chat/components/Chat.module.css
Normal file
@@ -0,0 +1,190 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.messages {
|
||||
padding: var(--s-3) 0 0 0;
|
||||
display: block;
|
||||
height: 310px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.message {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
gap: var(--s-3);
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.messageInfo {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0 var(--s-1-5);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.avatarWrapper {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatarWrapperStaff {
|
||||
& img {
|
||||
border: var(--border-style-accent);
|
||||
border-radius: var(--radius-avatar);
|
||||
}
|
||||
}
|
||||
|
||||
.avatarBadge {
|
||||
position: absolute;
|
||||
top: 28px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: var(--color-text-accent);
|
||||
color: var(--color-text-inverse);
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-bold);
|
||||
padding: 1px 4px;
|
||||
border-radius: var(--radius-full);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pronounsTag {
|
||||
background-color: var(--color-bg-higher);
|
||||
color: var(--color-text-accent);
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-semi);
|
||||
padding: 1px 5px;
|
||||
border-radius: var(--radius-full);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.messageUser {
|
||||
font-weight: var(--weight-semi);
|
||||
font-size: var(--font-sm);
|
||||
color: oklch(from var(--color-text-accent) l c var(--chat-hue));
|
||||
max-width: 110px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.messageTime {
|
||||
font-size: var(--font-2xs);
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.inputContainer {
|
||||
margin-top: auto;
|
||||
position: relative;
|
||||
|
||||
& > form {
|
||||
padding: var(--s-2);
|
||||
border-top: 1.5px solid var(--color-border);
|
||||
}
|
||||
}
|
||||
|
||||
.sendButton.sendButton {
|
||||
border-radius: var(--radius-full);
|
||||
background-color: var(--color-text-accent);
|
||||
color: var(--color-text-inverse);
|
||||
flex-shrink: 0;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
.messageContents {
|
||||
font-size: var(--font-sm);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.messageContentsPending {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.roomLinkBlock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--s-1);
|
||||
margin-block: var(--s-1);
|
||||
}
|
||||
|
||||
.roomQrCode {
|
||||
background-color: white;
|
||||
padding: var(--s-2);
|
||||
border-radius: var(--radius-field);
|
||||
}
|
||||
|
||||
.roomLink {
|
||||
color: var(--color-text-accent);
|
||||
text-decoration: underline;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.roomButton {
|
||||
border: 0;
|
||||
border-bottom: var(--border-style);
|
||||
background-color: transparent;
|
||||
color: var(--color-text-high);
|
||||
border-radius: var(--radius-box) var(--radius-box) 0 0;
|
||||
padding: var(--s-1) var(--s-1);
|
||||
border-color: var(--color-bg-high);
|
||||
font-size: var(--font-xs);
|
||||
padding-block: var(--s-1);
|
||||
padding-inline: var(--s-2);
|
||||
display: flex;
|
||||
width: auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: var(--weight-bold);
|
||||
flex: 1 1 0px;
|
||||
}
|
||||
|
||||
.roomButtonCurrent {
|
||||
background-color: var(--color-bg-high);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.roomButtonUnseen {
|
||||
color: var(--color-accent);
|
||||
text-shadow: var(--font-2xs);
|
||||
margin-inline-start: var(--s-1);
|
||||
width: 25px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.bottomRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-block-start: var(--s-2);
|
||||
}
|
||||
|
||||
.unseenMessages {
|
||||
position: absolute;
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-bold);
|
||||
border-radius: var(--radius-field);
|
||||
background-color: var(--color-bg-higher);
|
||||
border: none;
|
||||
color: var(--color-text);
|
||||
bottom: 60px;
|
||||
right: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
height: 25px;
|
||||
width: max-content;
|
||||
|
||||
&:active {
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
384
apps/web-react/app/features/chat/components/Chat.tsx
Normal file
384
apps/web-react/app/features/chat/components/Chat.tsx
Normal file
@@ -0,0 +1,384 @@
|
||||
import clsx from "clsx";
|
||||
import { sub } from "date-fns";
|
||||
import { SendHorizontal } from "lucide-react";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import * as React from "react";
|
||||
import {
|
||||
Button,
|
||||
ListBox,
|
||||
ListBoxItem,
|
||||
ListLayout,
|
||||
Virtualizer,
|
||||
} from "react-aria-components";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Avatar } from "../../../components/Avatar";
|
||||
import { SendouButton } from "../../../components/elements/Button";
|
||||
import { SubmitButton } from "../../../components/SubmitButton";
|
||||
import { useDateTimeFormat } from "../../../hooks/intl/useDateTimeFormat";
|
||||
import { findRoomLinks, MESSAGE_MAX_LENGTH } from "../chat-constants";
|
||||
import { useChatAutoScroll } from "../chat-hooks";
|
||||
import type { ChatMessage, ChatProps, ChatUser } from "../chat-types";
|
||||
import styles from "./Chat.module.css";
|
||||
|
||||
const MESSAGE_GAP = 8;
|
||||
const ESTIMATED_MESSAGE_HEIGHT = 44;
|
||||
const VIRTUALIZER_LAYOUT_OPTIONS = {
|
||||
gap: MESSAGE_GAP,
|
||||
estimatedRowSize: ESTIMATED_MESSAGE_HEIGHT,
|
||||
};
|
||||
|
||||
export interface ChatAdapter {
|
||||
messages: ChatMessage[];
|
||||
send: (contents: string) => void;
|
||||
currentRoom: string | undefined;
|
||||
setCurrentRoom: (room: string) => void;
|
||||
readyState: "CONNECTING" | "CONNECTED" | "CLOSED";
|
||||
unseenMessages: Map<string, number>;
|
||||
}
|
||||
|
||||
export function Chat({
|
||||
users,
|
||||
rooms,
|
||||
className,
|
||||
messagesContainerClassName,
|
||||
hidden = false,
|
||||
chat,
|
||||
disabled,
|
||||
missingUserName,
|
||||
}: Omit<ChatProps, "onNewMessage" | "revalidates"> & {
|
||||
chat: ChatAdapter;
|
||||
}) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const messagesContainerRef = React.useRef<HTMLDivElement>(null);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const {
|
||||
send,
|
||||
messages,
|
||||
currentRoom,
|
||||
setCurrentRoom,
|
||||
readyState,
|
||||
unseenMessages,
|
||||
} = chat;
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
(e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
// can't send empty messages
|
||||
if (inputRef.current!.value.trim().length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
send(inputRef.current!.value);
|
||||
inputRef.current!.value = "";
|
||||
},
|
||||
[send],
|
||||
);
|
||||
|
||||
const { unseenMessagesInTheRoom, scrollToBottom, resetScroller } =
|
||||
useChatAutoScroll(messages, messagesContainerRef);
|
||||
|
||||
const sendingMessagesDisabled = disabled || readyState !== "CONNECTED";
|
||||
|
||||
const systemMessageText = (msg: ChatMessage) => {
|
||||
const name = () => {
|
||||
if (!msg.context) return "";
|
||||
return msg.context.name;
|
||||
};
|
||||
|
||||
switch (msg.type) {
|
||||
case "SCORE_REPORTED": {
|
||||
return t("common:chat.systemMsg.scoreReported", { name: name() });
|
||||
}
|
||||
case "SCORE_CONFIRMED": {
|
||||
return t("common:chat.systemMsg.scoreConfirmed", { name: name() });
|
||||
}
|
||||
case "CANCEL_REPORTED": {
|
||||
return t("common:chat.systemMsg.cancelReported", { name: name() });
|
||||
}
|
||||
case "CANCEL_CONFIRMED": {
|
||||
return t("common:chat.systemMsg.cancelConfirmed", { name: name() });
|
||||
}
|
||||
case "CANCEL_REFUSED": {
|
||||
return t("common:chat.systemMsg.cancelRefused", { name: name() });
|
||||
}
|
||||
case "USER_LEFT": {
|
||||
return t("common:chat.systemMsg.userLeft", { name: name() });
|
||||
}
|
||||
case "MAP_REPLAYED": {
|
||||
return t("common:chat.systemMsg.mapReplayed", { name: name() });
|
||||
}
|
||||
case "MAP_PICKED": {
|
||||
return t("common:chat.systemMsg.mapPicked", { name: name() });
|
||||
}
|
||||
default: {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const renderableMessages = messages.filter((msg) => {
|
||||
if (systemMessageText(msg)) return true;
|
||||
|
||||
const user = msg.userId ? users[msg.userId] : null;
|
||||
return Boolean(user) || Boolean(missingUserName);
|
||||
});
|
||||
|
||||
return (
|
||||
<section className={clsx(styles.container, className, { hidden })}>
|
||||
{rooms.length > 1 ? (
|
||||
<div className="stack horizontal">
|
||||
{rooms.map((room) => {
|
||||
const unseen = unseenMessages.get(room.code);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={room.code}
|
||||
className={clsx(styles.roomButton, {
|
||||
[styles.roomButtonCurrent]: currentRoom === room.code,
|
||||
})}
|
||||
onPress={() => {
|
||||
setCurrentRoom(room.code);
|
||||
resetScroller();
|
||||
}}
|
||||
>
|
||||
<span className={clsx(styles.roomButtonUnseen, "invisible")} />
|
||||
{room.label}
|
||||
{unseen ? (
|
||||
<span className={styles.roomButtonUnseen}>{unseen}</span>
|
||||
) : (
|
||||
<span
|
||||
className={clsx(styles.roomButtonUnseen, "invisible")}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.inputContainer}>
|
||||
<Virtualizer
|
||||
layout={ListLayout}
|
||||
layoutOptions={VIRTUALIZER_LAYOUT_OPTIONS}
|
||||
>
|
||||
<ListBox
|
||||
ref={messagesContainerRef}
|
||||
aria-label="Chat messages"
|
||||
selectionMode="none"
|
||||
items={renderableMessages}
|
||||
className={clsx(
|
||||
styles.messages,
|
||||
"scrollbar",
|
||||
messagesContainerClassName,
|
||||
)}
|
||||
>
|
||||
{(msg) => {
|
||||
const systemMessage = systemMessageText(msg);
|
||||
if (systemMessage) {
|
||||
return <SystemMessage message={msg} text={systemMessage} />;
|
||||
}
|
||||
|
||||
const user = msg.userId ? users[msg.userId] : null;
|
||||
|
||||
return (
|
||||
<Message
|
||||
user={user}
|
||||
missingUserName={missingUserName}
|
||||
message={msg}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</ListBox>
|
||||
</Virtualizer>
|
||||
{unseenMessagesInTheRoom ? (
|
||||
<SendouButton
|
||||
className={styles.unseenMessages}
|
||||
onPress={scrollToBottom}
|
||||
>
|
||||
{t("common:chat.newMessages")}
|
||||
</SendouButton>
|
||||
) : null}
|
||||
{disabled ? (
|
||||
<div className="text-xs text-lighter text-center my-4">
|
||||
{t("common:chat.expired")}
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="mt-4">
|
||||
<input
|
||||
className="w-full text-xs"
|
||||
ref={inputRef}
|
||||
placeholder={t("common:chat.input.placeholder")}
|
||||
disabled={sendingMessagesDisabled}
|
||||
maxLength={MESSAGE_MAX_LENGTH}
|
||||
/>{" "}
|
||||
<div className={styles.bottomRow}>
|
||||
{readyState === "CONNECTED" || readyState === "CONNECTING" ? (
|
||||
<div className="text-xxs font-semi-bold text-lighter">
|
||||
{t(
|
||||
readyState === "CONNECTED"
|
||||
? "common:chat.connected"
|
||||
: "common:chat.connecting",
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xxs font-semi-bold text-warning">
|
||||
{t("common:chat.disconnected")}
|
||||
</div>
|
||||
)}
|
||||
<SubmitButton
|
||||
className={styles.sendButton}
|
||||
size="small"
|
||||
isDisabled={sendingMessagesDisabled}
|
||||
aria-label={t("common:chat.send")}
|
||||
icon={<SendHorizontal size={16} />}
|
||||
testId="chat-submit-button"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Message({
|
||||
user,
|
||||
message,
|
||||
missingUserName,
|
||||
}: {
|
||||
user?: ChatUser | null;
|
||||
message: ChatMessage;
|
||||
missingUserName?: string;
|
||||
}) {
|
||||
return (
|
||||
<ListBoxItem
|
||||
className={styles.message}
|
||||
textValue={message.contents ?? user?.username ?? missingUserName ?? ""}
|
||||
>
|
||||
{user ? (
|
||||
<div
|
||||
className={clsx(styles.avatarWrapper, {
|
||||
[styles.avatarWrapperStaff]: user.title,
|
||||
})}
|
||||
>
|
||||
<Avatar user={user} size="xs" />
|
||||
{user.title ? (
|
||||
<span className={styles.avatarBadge}>{user.title}</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<div className={styles.messageInfo}>
|
||||
<div
|
||||
className={styles.messageUser}
|
||||
style={
|
||||
user?.chatNameHue ? { "--chat-hue": user.chatNameHue } : undefined
|
||||
}
|
||||
>
|
||||
{user?.username ?? missingUserName}
|
||||
</div>
|
||||
{user?.pronouns ? (
|
||||
<span className={styles.pronounsTag}>
|
||||
{user.pronouns.subject}/{user.pronouns.object}
|
||||
</span>
|
||||
) : null}
|
||||
{!message.pending ? (
|
||||
<MessageTimestamp timestamp={message.timestamp} />
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className={clsx(styles.messageContents, {
|
||||
[styles.messageContentsPending]: message.pending,
|
||||
})}
|
||||
>
|
||||
{message.contents ? (
|
||||
<MessageContents text={message.contents} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</ListBoxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function SystemMessage({
|
||||
message,
|
||||
text,
|
||||
}: {
|
||||
message: ChatMessage;
|
||||
text: string;
|
||||
}) {
|
||||
return (
|
||||
<ListBoxItem className={styles.message} textValue={text}>
|
||||
<div>
|
||||
<div className="stack horizontal sm">
|
||||
<MessageTimestamp timestamp={message.timestamp} />
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
styles.messageContents,
|
||||
"text-xs text-lighter font-semi-bold",
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
</ListBoxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageContents({ text }: { text: string }) {
|
||||
const matches = findRoomLinks(text);
|
||||
|
||||
if (matches.length === 0) return <>{text}</>;
|
||||
|
||||
const parts: React.ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
for (const [i, match] of matches.entries()) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
parts.push(
|
||||
<span key={i} className={styles.roomLinkBlock}>
|
||||
<QRCodeSVG value={match.url} size={120} className={styles.roomQrCode} />
|
||||
<a
|
||||
href={match.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.roomLink}
|
||||
>
|
||||
{match.url}
|
||||
</a>
|
||||
</span>,
|
||||
);
|
||||
lastIndex = match.index + match.url.length;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
return <>{parts}</>;
|
||||
}
|
||||
|
||||
function MessageTimestamp({ timestamp }: { timestamp: number }) {
|
||||
const { formatter: dateTimeFormatter } = useDateTimeFormat({
|
||||
day: "numeric",
|
||||
month: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
});
|
||||
const { formatter: timeFormatter } = useDateTimeFormat({
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
});
|
||||
const moreThanDayAgo = sub(new Date(), { days: 1 }) > new Date(timestamp);
|
||||
|
||||
return (
|
||||
<time className={styles.messageTime}>
|
||||
{moreThanDayAgo
|
||||
? dateTimeFormatter.format(new Date(timestamp))
|
||||
: timeFormatter.format(new Date(timestamp))}
|
||||
</time>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
createRevalidateBroadcastThrottle,
|
||||
MAX_ENTRIES,
|
||||
} from "./revalidate-broadcast-throttle";
|
||||
|
||||
const WINDOW_MS = 2_000;
|
||||
|
||||
const setup = () => {
|
||||
const sendLeading = vi.fn();
|
||||
const sendTrailing = vi.fn();
|
||||
const throttle = createRevalidateBroadcastThrottle({
|
||||
windowMs: WINDOW_MS,
|
||||
sendLeading,
|
||||
sendTrailing,
|
||||
});
|
||||
return { throttle, sendLeading, sendTrailing };
|
||||
};
|
||||
|
||||
describe("createRevalidateBroadcastThrottle", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("throttles revalidation broadcasts but not chat messages", () => {
|
||||
const { throttle } = setup();
|
||||
|
||||
expect(throttle.throttles({ revalidateOnly: true })).toBe(true);
|
||||
expect(throttle.throttles({})).toBe(false);
|
||||
});
|
||||
|
||||
test("does not throttle broadcasts whose sound must not be dropped", () => {
|
||||
const { throttle } = setup();
|
||||
|
||||
expect(
|
||||
throttle.throttles({ revalidateOnly: true, type: "MATCH_STARTED" }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
throttle.throttles({ revalidateOnly: true, type: "READY_CHECK_STARTED" }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("throttles broadcasts whose type plays no sound", () => {
|
||||
const { throttle } = setup();
|
||||
|
||||
expect(
|
||||
throttle.throttles({ revalidateOnly: true, type: "TOURNAMENT_UPDATED" }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
throttle.throttles({
|
||||
revalidateOnly: true,
|
||||
type: "TOURNAMENT_MATCH_UPDATED",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("first broadcast of a window is delivered immediately", () => {
|
||||
const { throttle, sendLeading, sendTrailing } = setup();
|
||||
|
||||
throttle.handle({ room: "sq-looking", revalidateOnly: true });
|
||||
|
||||
expect(sendLeading).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrailing).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("broadcasts within the window coalesce into one trailing broadcast", () => {
|
||||
const { throttle, sendLeading, sendTrailing } = setup();
|
||||
|
||||
throttle.handle({ room: "sq-looking", revalidateOnly: true });
|
||||
vi.advanceTimersByTime(500);
|
||||
throttle.handle({ room: "sq-looking", revalidateOnly: true });
|
||||
throttle.handle({ room: "sq-looking", revalidateOnly: true });
|
||||
throttle.handle({ room: "sq-looking", revalidateOnly: true });
|
||||
|
||||
expect(sendLeading).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrailing).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(WINDOW_MS - 500);
|
||||
expect(sendTrailing).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrailing).toHaveBeenCalledWith({
|
||||
room: "sq-looking",
|
||||
revalidateScope: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("a broadcast after the window opens a fresh window and sends immediately", () => {
|
||||
const { throttle, sendLeading, sendTrailing } = setup();
|
||||
|
||||
throttle.handle({ room: "sq-looking", revalidateOnly: true });
|
||||
vi.advanceTimersByTime(WINDOW_MS);
|
||||
throttle.handle({ room: "sq-looking", revalidateOnly: true });
|
||||
|
||||
expect(sendLeading).toHaveBeenCalledTimes(2);
|
||||
expect(sendTrailing).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rooms are throttled independently", () => {
|
||||
const { throttle, sendLeading } = setup();
|
||||
|
||||
throttle.handle({ room: "sq-looking", revalidateOnly: true });
|
||||
throttle.handle({ room: "tournament-1", revalidateOnly: true });
|
||||
|
||||
expect(sendLeading).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("coalesced broadcasts of one scope keep it, differing scopes widen to unset", () => {
|
||||
const { throttle, sendTrailing } = setup();
|
||||
|
||||
throttle.handle({ room: "tournament-1", revalidateOnly: true });
|
||||
throttle.handle({
|
||||
room: "tournament-1",
|
||||
revalidateOnly: true,
|
||||
revalidateScope: "MATCH_RESULTS",
|
||||
});
|
||||
throttle.handle({
|
||||
room: "tournament-1",
|
||||
revalidateOnly: true,
|
||||
revalidateScope: "MATCH_RESULTS",
|
||||
});
|
||||
vi.advanceTimersByTime(WINDOW_MS);
|
||||
expect(sendTrailing).toHaveBeenLastCalledWith({
|
||||
room: "tournament-1",
|
||||
revalidateScope: "MATCH_RESULTS",
|
||||
});
|
||||
|
||||
throttle.handle({ room: "tournament-1", revalidateOnly: true });
|
||||
throttle.handle({
|
||||
room: "tournament-1",
|
||||
revalidateOnly: true,
|
||||
revalidateScope: "MATCH_RESULTS",
|
||||
});
|
||||
throttle.handle({ room: "tournament-1", revalidateOnly: true });
|
||||
vi.advanceTimersByTime(WINDOW_MS);
|
||||
expect(sendTrailing).toHaveBeenLastCalledWith({
|
||||
room: "tournament-1",
|
||||
revalidateScope: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("forgetting idle rooms spares one whose trailing broadcast is still pending", () => {
|
||||
const { throttle, sendLeading, sendTrailing } = setup();
|
||||
const startedAt = Date.now();
|
||||
|
||||
for (let i = 0; i <= MAX_ENTRIES; i++) {
|
||||
throttle.handle({ room: `tournament-${i}`, revalidateOnly: true });
|
||||
}
|
||||
throttle.handle({ room: "sq-looking", revalidateOnly: true });
|
||||
throttle.handle({ room: "sq-looking", revalidateOnly: true });
|
||||
sendLeading.mockClear();
|
||||
|
||||
// every room is idle by now, but the trailing broadcast is late rather than sent
|
||||
// (its timer would have run at the window's end on a server that was not busy)
|
||||
vi.setSystemTime(startedAt + WINDOW_MS + 1_000);
|
||||
throttle.handle({ room: "tournament-late", revalidateOnly: true });
|
||||
expect(sendLeading).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(WINDOW_MS);
|
||||
expect(sendTrailing).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrailing).toHaveBeenCalledWith({
|
||||
room: "sq-looking",
|
||||
revalidateScope: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("the trailing broadcast starts a new window", () => {
|
||||
const { throttle, sendLeading, sendTrailing } = setup();
|
||||
|
||||
throttle.handle({ room: "sq-looking", revalidateOnly: true });
|
||||
vi.advanceTimersByTime(1_000);
|
||||
throttle.handle({ room: "sq-looking", revalidateOnly: true });
|
||||
vi.advanceTimersByTime(1_000);
|
||||
expect(sendTrailing).toHaveBeenCalledTimes(1);
|
||||
|
||||
// still within the trailing broadcast's window: coalesces again
|
||||
vi.advanceTimersByTime(500);
|
||||
throttle.handle({ room: "sq-looking", revalidateOnly: true });
|
||||
expect(sendLeading).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(WINDOW_MS);
|
||||
expect(sendTrailing).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { ChatMessage } from "./chat-types";
|
||||
import { messageTypeToSound } from "./chat-utils";
|
||||
|
||||
type ThrottleableMessage = Pick<
|
||||
ChatMessage,
|
||||
"room" | "type" | "revalidateOnly" | "revalidateScope"
|
||||
>;
|
||||
|
||||
interface ThrottleEntry {
|
||||
lastSentAt: number;
|
||||
trailing: {
|
||||
scope: ChatMessage["revalidateScope"];
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
} | null;
|
||||
}
|
||||
|
||||
/** Above this many rooms tracked, idle ones are forgotten (see `prune`). */
|
||||
export const MAX_ENTRIES = 5_000;
|
||||
|
||||
/**
|
||||
* Rate limits `revalidateOnly` broadcasts per room so that a burst of them
|
||||
* (e.g. every player of a forming SendouQ match confirming within seconds, or every
|
||||
* reported game of a live tournament) fans out to the room's subscribers at most
|
||||
* twice per window instead of once per event — each fan-out makes every subscribed
|
||||
* client refetch its loaders at once.
|
||||
*
|
||||
* The first broadcast of a window is delivered immediately; further broadcasts for
|
||||
* the same room within the window coalesce into a single trailing broadcast at the
|
||||
* window's end, so subscribers never miss the final state. A trailing broadcast
|
||||
* covers every coalesced one: its scope is widened to the broadest seen and it
|
||||
* carries no author (nobody may skip it as their own) and no type.
|
||||
*
|
||||
* Broadcasts whose type plays a sound (a starting match, a ready check) are left alone:
|
||||
* coalescing would cost the player the sound, and they are rare enough not to be worth
|
||||
* rate limiting. Soundless types (a reported tournament game) are throttled like the
|
||||
* rest — they are the bulk of the traffic the throttle exists for.
|
||||
*/
|
||||
export function createRevalidateBroadcastThrottle({
|
||||
windowMs,
|
||||
sendLeading,
|
||||
sendTrailing,
|
||||
}: {
|
||||
windowMs: number;
|
||||
/** Delivers a broadcast that opened a fresh window, unaltered. */
|
||||
sendLeading: (msg: ThrottleableMessage) => void;
|
||||
/** Delivers the coalesced trailing broadcast of a window. */
|
||||
sendTrailing: (msg: {
|
||||
room: string;
|
||||
revalidateScope: ChatMessage["revalidateScope"];
|
||||
}) => void;
|
||||
}) {
|
||||
const entries = new Map<string, ThrottleEntry>();
|
||||
|
||||
const prune = (now: number) => {
|
||||
if (entries.size <= MAX_ENTRIES) return;
|
||||
for (const [room, entry] of entries) {
|
||||
if (!entry.trailing && now - entry.lastSentAt >= windowMs) {
|
||||
entries.delete(room);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
/**
|
||||
* Whether the throttle applies to the message: a revalidation broadcast carrying
|
||||
* no sound. Real chat messages always pass through untouched.
|
||||
*/
|
||||
throttles(msg: Pick<ChatMessage, "type" | "revalidateOnly">): boolean {
|
||||
return Boolean(msg.revalidateOnly) && !messageTypeToSound(msg.type);
|
||||
},
|
||||
handle(msg: ThrottleableMessage): void {
|
||||
const now = Date.now();
|
||||
prune(now);
|
||||
|
||||
const entry = entries.get(msg.room);
|
||||
if (!entry || (!entry.trailing && now - entry.lastSentAt >= windowMs)) {
|
||||
entries.set(msg.room, { lastSentAt: now, trailing: null });
|
||||
sendLeading(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.trailing) {
|
||||
// an unset scope means anything may have changed, so differing scopes widen to unset
|
||||
if (entry.trailing.scope !== msg.revalidateScope) {
|
||||
entry.trailing.scope = undefined;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(
|
||||
() => {
|
||||
const current = entries.get(msg.room);
|
||||
if (!current?.trailing) return;
|
||||
current.lastSentAt = Date.now();
|
||||
const scope = current.trailing.scope;
|
||||
current.trailing = null;
|
||||
sendTrailing({ room: msg.room, revalidateScope: scope });
|
||||
},
|
||||
windowMs - (now - entry.lastSentAt),
|
||||
);
|
||||
timer.unref?.();
|
||||
entry.trailing = { scope: msg.revalidateScope, timer };
|
||||
},
|
||||
};
|
||||
}
|
||||
177
apps/web-react/app/features/chat/revalidation-scope.test.ts
Normal file
177
apps/web-react/app/features/chat/revalidation-scope.test.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import type { ShouldRevalidateFunctionArgs } from "react-router";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
isMatchResultsScopedRevalidation,
|
||||
revalidateWithScope,
|
||||
scheduleBroadcastRevalidation,
|
||||
} from "./revalidation-scope";
|
||||
|
||||
const PENDING_REVALIDATION_STALE_MS = 30 * 1000;
|
||||
|
||||
const revalidationArgs = () =>
|
||||
({
|
||||
currentUrl: new URL("https://sendou.ink/to/1/brackets"),
|
||||
nextUrl: new URL("https://sendou.ink/to/1/brackets"),
|
||||
defaultShouldRevalidate: true,
|
||||
formMethod: undefined,
|
||||
}) as unknown as ShouldRevalidateFunctionArgs;
|
||||
|
||||
const deferred = () => {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
const flushMicrotasks = () => new Promise<void>((res) => setTimeout(res));
|
||||
|
||||
describe("revalidateWithScope", () => {
|
||||
test("scope is active while a scoped revalidation is in flight and cleared after", async () => {
|
||||
const { promise, resolve } = deferred();
|
||||
|
||||
revalidateWithScope(() => promise, "MATCH_RESULTS");
|
||||
expect(isMatchResultsScopedRevalidation(revalidationArgs())).toBe(true);
|
||||
|
||||
resolve();
|
||||
await flushMicrotasks();
|
||||
expect(isMatchResultsScopedRevalidation(revalidationArgs())).toBe(false);
|
||||
});
|
||||
|
||||
test("no scope is active for an unscoped revalidation", async () => {
|
||||
const { promise, resolve } = deferred();
|
||||
|
||||
revalidateWithScope(() => promise, undefined);
|
||||
expect(isMatchResultsScopedRevalidation(revalidationArgs())).toBe(false);
|
||||
|
||||
resolve();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
test("a scoped revalidation does not narrow an unscoped one in flight", async () => {
|
||||
const unscoped = deferred();
|
||||
const scoped = deferred();
|
||||
|
||||
revalidateWithScope(() => unscoped.promise, undefined);
|
||||
revalidateWithScope(() => scoped.promise, "MATCH_RESULTS");
|
||||
expect(isMatchResultsScopedRevalidation(revalidationArgs())).toBe(false);
|
||||
|
||||
unscoped.resolve();
|
||||
scoped.resolve();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
test("an unscoped revalidation broadens a scoped one in flight", async () => {
|
||||
const scoped = deferred();
|
||||
const unscoped = deferred();
|
||||
|
||||
revalidateWithScope(() => scoped.promise, "MATCH_RESULTS");
|
||||
revalidateWithScope(() => unscoped.promise, undefined);
|
||||
expect(isMatchResultsScopedRevalidation(revalidationArgs())).toBe(false);
|
||||
|
||||
scoped.resolve();
|
||||
unscoped.resolve();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
});
|
||||
|
||||
describe("scheduleBroadcastRevalidation", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.runAllTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("revalidates once after a delay instead of immediately", () => {
|
||||
const revalidate = vi.fn(() => Promise.resolve());
|
||||
|
||||
scheduleBroadcastRevalidation(revalidate, undefined);
|
||||
expect(revalidate).not.toHaveBeenCalled();
|
||||
|
||||
vi.runAllTimers();
|
||||
expect(revalidate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("broadcasts arriving while one is scheduled are absorbed into it", () => {
|
||||
const revalidate = vi.fn(() => Promise.resolve());
|
||||
|
||||
scheduleBroadcastRevalidation(revalidate, undefined);
|
||||
scheduleBroadcastRevalidation(revalidate, undefined);
|
||||
scheduleBroadcastRevalidation(revalidate, undefined);
|
||||
|
||||
vi.runAllTimers();
|
||||
expect(revalidate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("same-scope broadcasts keep the scope active during the revalidation", () => {
|
||||
const { promise, resolve } = deferred();
|
||||
|
||||
scheduleBroadcastRevalidation(() => promise, "MATCH_RESULTS");
|
||||
scheduleBroadcastRevalidation(() => promise, "MATCH_RESULTS");
|
||||
|
||||
vi.runAllTimers();
|
||||
expect(isMatchResultsScopedRevalidation(revalidationArgs())).toBe(true);
|
||||
|
||||
resolve();
|
||||
});
|
||||
|
||||
test("an absorbed unscoped broadcast widens the scheduled scope", () => {
|
||||
const { promise, resolve } = deferred();
|
||||
|
||||
scheduleBroadcastRevalidation(() => promise, "MATCH_RESULTS");
|
||||
scheduleBroadcastRevalidation(() => promise, undefined);
|
||||
|
||||
vi.runAllTimers();
|
||||
expect(isMatchResultsScopedRevalidation(revalidationArgs())).toBe(false);
|
||||
|
||||
resolve();
|
||||
});
|
||||
|
||||
test("a scoped broadcast does not narrow an unscoped revalidation still in flight", async () => {
|
||||
// let anything still in flight from an earlier test settle first
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const unscoped = deferred();
|
||||
revalidateWithScope(() => unscoped.promise, undefined);
|
||||
|
||||
scheduleBroadcastRevalidation(() => Promise.resolve(), "MATCH_RESULTS");
|
||||
vi.runAllTimers();
|
||||
expect(isMatchResultsScopedRevalidation(revalidationArgs())).toBe(false);
|
||||
|
||||
unscoped.resolve();
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
test("a scope left stuck by a never settling revalidation is forgotten once stale", async () => {
|
||||
// let anything still in flight from an earlier test settle first
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const neverSettles = new Promise<void>(() => {});
|
||||
|
||||
// a revalidation interrupted by a navigation never settles
|
||||
revalidateWithScope(() => neverSettles, "MATCH_RESULTS");
|
||||
expect(isMatchResultsScopedRevalidation(revalidationArgs())).toBe(true);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(PENDING_REVALIDATION_STALE_MS);
|
||||
|
||||
const unscoped = deferred();
|
||||
scheduleBroadcastRevalidation(() => unscoped.promise, undefined);
|
||||
vi.runAllTimers();
|
||||
expect(isMatchResultsScopedRevalidation(revalidationArgs())).toBe(false);
|
||||
|
||||
unscoped.resolve();
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const scoped = deferred();
|
||||
scheduleBroadcastRevalidation(() => scoped.promise, "MATCH_RESULTS");
|
||||
vi.runAllTimers();
|
||||
expect(isMatchResultsScopedRevalidation(revalidationArgs())).toBe(true);
|
||||
|
||||
scoped.resolve();
|
||||
await vi.runAllTimersAsync();
|
||||
expect(isMatchResultsScopedRevalidation(revalidationArgs())).toBe(false);
|
||||
});
|
||||
});
|
||||
104
apps/web-react/app/features/chat/revalidation-scope.ts
Normal file
104
apps/web-react/app/features/chat/revalidation-scope.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { ShouldRevalidateFunctionArgs } from "react-router";
|
||||
import { isRevalidation } from "~/utils/remix";
|
||||
import type { RevalidateScope } from "./chat-types";
|
||||
|
||||
const BROADCAST_REVALIDATE_MAX_JITTER_MS = 1_500;
|
||||
// past this a revalidation still counted as in flight is taken to be orphaned, not slow
|
||||
const PENDING_REVALIDATION_STALE_MS = 30 * 1000;
|
||||
|
||||
let activeScope: RevalidateScope | null = null;
|
||||
let pendingRevalidations = 0;
|
||||
let oldestPendingStartedAt: number | null = null;
|
||||
let revalidationGeneration = 0;
|
||||
let scheduledBroadcast: { scope: RevalidateScope | null } | null = null;
|
||||
|
||||
/**
|
||||
* Runs a websocket broadcast triggered revalidation, remembering the broadcast's scope
|
||||
* while it is in flight so `shouldRevalidate` implementations can skip loaders whose
|
||||
* data the broadcast can not have changed.
|
||||
*/
|
||||
export function revalidateWithScope(
|
||||
revalidate: () => Promise<void>,
|
||||
scope: RevalidateScope | undefined,
|
||||
) {
|
||||
if (!scope) {
|
||||
activeScope = null;
|
||||
} else if (pendingRevalidations === 0) {
|
||||
// never narrow the scope of an unscoped revalidation already in flight
|
||||
activeScope = scope;
|
||||
}
|
||||
|
||||
const generation = revalidationGeneration;
|
||||
if (pendingRevalidations === 0) {
|
||||
oldestPendingStartedAt = Date.now();
|
||||
}
|
||||
pendingRevalidations++;
|
||||
void revalidate().finally(() => {
|
||||
if (generation !== revalidationGeneration) return;
|
||||
|
||||
pendingRevalidations--;
|
||||
if (pendingRevalidations === 0) {
|
||||
activeScope = null;
|
||||
oldestPendingStartedAt = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a websocket broadcast triggered revalidation after a random delay so the clients
|
||||
* subscribed to a topic do not all refetch in the same instant the broadcast fans out
|
||||
* (thundering herd — a broadcast to e.g. the SendouQ looking room or a live tournament's
|
||||
* room reaches every client on that page at once). A broadcast arriving while one is
|
||||
* already scheduled is absorbed into it, widening its scope as needed: the eventual
|
||||
* single fetch returns data fresh enough to cover both.
|
||||
*/
|
||||
export function scheduleBroadcastRevalidation(
|
||||
revalidate: () => Promise<void>,
|
||||
scope: RevalidateScope | undefined,
|
||||
) {
|
||||
if (scheduledBroadcast) {
|
||||
if (scheduledBroadcast.scope !== (scope ?? null)) {
|
||||
scheduledBroadcast.scope = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const pending: { scope: RevalidateScope | null } = { scope: scope ?? null };
|
||||
scheduledBroadcast = pending;
|
||||
setTimeout(() => {
|
||||
scheduledBroadcast = null;
|
||||
forgetStalePendingRevalidations();
|
||||
revalidateWithScope(revalidate, pending.scope ?? undefined);
|
||||
}, Math.random() * BROADCAST_REVALIDATE_MAX_JITTER_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the pending revalidation is a websocket broadcast scoped to match results,
|
||||
* meaning only match data (reported scores, pick/ban events) changed. Loaders whose data
|
||||
* does not derive from match data can return `false` from `shouldRevalidate` for these —
|
||||
* during a live tournament this is the most frequent broadcast: one per reported game.
|
||||
*/
|
||||
export function isMatchResultsScopedRevalidation(
|
||||
args: ShouldRevalidateFunctionArgs,
|
||||
) {
|
||||
return isRevalidation(args) && activeScope === "MATCH_RESULTS";
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the bookkeeping of revalidations counted as in flight for longer than one can
|
||||
* plausibly take. A `revalidate()` interrupted by a navigation never settles, which would
|
||||
* otherwise leave `activeScope` stuck for the life of the tab and make every later
|
||||
* broadcast skip the loaders it narrows away. Only the stuck case is forgotten, so a
|
||||
* revalidation genuinely in flight keeps its scope from being narrowed under it.
|
||||
*/
|
||||
function forgetStalePendingRevalidations() {
|
||||
const isStale =
|
||||
oldestPendingStartedAt !== null &&
|
||||
Date.now() - oldestPendingStartedAt >= PENDING_REVALIDATION_STALE_MS;
|
||||
if (!isStale) return;
|
||||
|
||||
pendingRevalidations = 0;
|
||||
oldestPendingStartedAt = null;
|
||||
activeScope = null;
|
||||
revalidationGeneration++;
|
||||
}
|
||||
17
apps/web-react/app/features/chat/routes/api.chat-users.ts
Normal file
17
apps/web-react/app/features/chat/routes/api.chat-users.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { chatUsersSearchParams } from "../chat-search-params";
|
||||
|
||||
export const loader = async ({ url }: LoaderFunctionArgs) => {
|
||||
requireUser();
|
||||
|
||||
const { ids } = chatUsersSearchParams.parse(url);
|
||||
|
||||
if (ids.length === 0) {
|
||||
return Response.json({});
|
||||
}
|
||||
|
||||
const users = await UserRepository.findChatUsersByUserIds(ids);
|
||||
return Response.json(users);
|
||||
};
|
||||
8
apps/web-react/app/features/chat/useChatContext.ts
Normal file
8
apps/web-react/app/features/chat/useChatContext.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import * as React from "react";
|
||||
import type { ChatContextValue } from "./chat-provider-types";
|
||||
|
||||
export const ChatContext = React.createContext<ChatContextValue | null>(null);
|
||||
|
||||
export function useChatContext(): ChatContextValue | null {
|
||||
return React.useContext(ChatContext);
|
||||
}
|
||||
Reference in New Issue
Block a user