Move app to apps/web-react in pnpm workspace layout

This commit is contained in:
Kalle
2026-08-16 09:20:33 +03:00
parent 7e365ccfcf
commit bbc8ea57af
2913 changed files with 227 additions and 250 deletions

View 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;
}

View 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"));
}

View 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 },
]);
});
});

View 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);
}

View 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();
});
});

View 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]);
}

View 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"]);
});
});

View 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);
}

View 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;
}

View 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]);
});
});

View 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,
}),
});

View 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;
}

View 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",
);
});
});

View 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;
}

View File

@@ -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);
});
});

View 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%);
}
}

View 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>
);
}

View File

@@ -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);
});
});

View File

@@ -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 };
},
};
}

View 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);
});
});

View 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++;
}

View 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);
};

View 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);
}