Rewrite the chat client onto the SSE + HTTP system

This commit is contained in:
Kalle
2026-08-23 17:50:22 +03:00
parent 06447a1157
commit 988d7fd5a2
71 changed files with 2097 additions and 1589 deletions

View File

@@ -70,6 +70,27 @@
padding: var(--s-2);
}
.inactiveToggle {
display: flex;
align-items: center;
gap: var(--s-1);
margin-block-start: var(--s-2);
padding: var(--s-1) var(--s-2);
background: none;
border: none;
cursor: pointer;
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-high);
border-radius: var(--radius-field);
&:hover {
background-color: var(--color-bg-higher);
}
}
.roomName {
font-weight: var(--weight-semi);
}
@@ -251,33 +272,18 @@
padding-inline: var(--s-2);
}
/* form: stick to bottom, inline layout */
/* form: stick to bottom */
& form {
display: flex;
align-items: center;
gap: var(--s-1);
padding: var(--s-1-5);
border-top: 1.5px solid var(--color-border);
margin-top: 0;
flex-shrink: 0;
}
& form input {
flex: 1;
width: 100%;
}
/* bottomRow: hide connected text, inline send button */
& form > div {
display: contents;
}
& form > div > div {
display: none;
}
/* replace "Send" text with send-horizontal icon */
& form button {
font-size: 0;
width: 32px;
height: 32px;
padding: 0;

View File

@@ -1,19 +1,28 @@
import clsx from "clsx";
import { ArrowLeft, MessageSquare, X } from "lucide-react";
import type { TFunction } from "i18next";
import {
ArrowLeft,
ChevronDown,
ChevronRight,
MessageSquare,
X,
} from "lucide-react";
import * as React from "react";
import { Button } from "react-aria-components";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { useCurrentRouteChatCodes } from "~/features/chat/ChatProvider";
import type {
ChatContextValue,
RoomInfo,
} from "~/features/chat/chat-provider-types";
import { resolveDatePlaceholders } from "~/features/chat/chat-utils";
import { useCurrentRouteChatRoomIds } from "~/features/chat/ChatProvider";
import type { ChatContextValue } from "~/features/chat/chat-provider-types";
import type { ChatRoomListItem } from "~/features/chat/chat-types";
import { Chat } from "~/features/chat/components/Chat";
import { useChatContext } from "~/features/chat/useChatContext";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { useLayoutSize } from "~/hooks/useMainContentWidth";
import {
databaseTimestampToDate,
dateToDatabaseTimestamp,
} from "~/utils/dates";
import { navIconUrl } from "~/utils/urls";
import {
NavIconContainer,
NavListButton,
@@ -29,17 +38,88 @@ export function ChatSidebar({ onClose }: { onClose?: () => void }) {
if (!chatContext) return null;
if (chatContext.activeRooms.length > 0) {
if (chatContext.activeRoomIds.length > 0) {
return <ChatView onClose={onClose} />;
}
if (chatContext.isLoading) {
if (!chatContext.roomsLoaded) {
return <LoadingState onClose={onClose} />;
}
return <RoomList onClose={onClose} />;
}
interface RoomDisplay {
title: string;
subtitle: string;
imageUrl: string;
}
function useRoomDisplay() {
const { t } = useTranslation(["common"]);
const { formatter: dateFormatter } = useDateTimeFormat({
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
});
return (room: ChatRoomListItem): RoomDisplay => {
switch (room.type) {
case "SQ_GROUP": {
return {
title: t("common:chat.room.group", {
members: room.participantUserIds.length,
}),
subtitle: "SendouQ",
imageUrl: `${navIconUrl("sendouq")}.avif`,
};
}
case "SQ_MATCH": {
return {
title: t("common:chat.room.match", { id: room.titleParams.matchId }),
subtitle: "SendouQ",
imageUrl: `${navIconUrl("sendouq")}.avif`,
};
}
case "TOURNAMENT_MATCH": {
return {
title: t("common:chat.room.match", { id: room.titleParams.matchId }),
subtitle: room.titleParams.tournamentName,
imageUrl: room.imageUrl ?? `${navIconUrl("medal")}.avif`,
};
}
case "TOURNAMENT_TEAM": {
return {
title: room.titleParams.teamName,
subtitle: room.titleParams.tournamentName,
imageUrl: room.imageUrl ?? `${navIconUrl("medal")}.avif`,
};
}
case "SCRIM": {
return {
title: dateFormatter.format(
databaseTimestampToDate(Number(room.titleParams.startsAt)),
),
subtitle: t("common:chat.room.scrim"),
imageUrl: `${navIconUrl("scrims")}.avif`,
};
}
}
};
}
/** Concise label for split view headers, e.g. "Match" / "Group". */
function roomShortLabel(room: ChatRoomListItem, t: TFunction<["common"]>) {
return room.type === "SQ_GROUP"
? t("common:chat.room.groupShort")
: t("common:chat.room.matchShort");
}
function roomIsInactive(room: ChatRoomListItem) {
return room.inactive || room.expiresAt <= dateToDatabaseTimestamp(new Date());
}
function SidebarHeader({ onClose }: { onClose?: () => void }) {
const { t } = useTranslation(["common"]);
@@ -74,58 +154,49 @@ function LoadingState({ onClose }: { onClose?: () => void }) {
function RoomList({ onClose }: { onClose?: () => void }) {
const { t } = useTranslation(["common"]);
const chatContext = useChatContext()!;
const { formatter: headerFormatter } = useDateTimeFormat({
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
});
const { formatter: timestampFormatter } = useDateTimeFormat({
hour: "numeric",
minute: "numeric",
});
const [showInactive, setShowInactive] = React.useState(false);
const routeChatCodes = useCurrentRouteChatCodes();
const routeRoomIds = useCurrentRouteChatRoomIds();
const visibleRooms = chatContext.rooms
.filter(
(room) =>
room.expiresAt > Date.now() || routeChatCodes.includes(room.chatCode),
)
.sort((a, b) => {
if (a.isObsolete !== b.isObsolete) return a.isObsolete ? 1 : -1;
const aRecency = a.lastMessageTimestamp || a.createdAt;
const bRecency = b.lastMessageTimestamp || b.createdAt;
return bRecency - aRecency;
});
const byRecency = (a: ChatRoomListItem, b: ChatRoomListItem) =>
(b.latestMessageAt ?? 0) - (a.latestMessageAt ?? 0) || b.id - a.id;
// Rooms the active route groups together collapse into a single combined
// list entry that opens the stacked/tabbed view.
// list entry that opens the stacked split view.
const combinedRooms =
routeChatCodes.length > 1
? routeChatCodes
.map((code) => visibleRooms.find((r) => r.chatCode === code))
.filter((r): r is RoomInfo => Boolean(r))
routeRoomIds.length > 1
? routeRoomIds
.map((roomId) => chatContext.rooms.find((r) => r.id === roomId))
.filter((r): r is ChatRoomListItem => Boolean(r))
: [];
const isCombined = combinedRooms.length > 1;
const combinedChatCodes = new Set(combinedRooms.map((r) => r.chatCode));
const standaloneRooms = isCombined
? visibleRooms.filter((room) => !combinedChatCodes.has(room.chatCode))
: visibleRooms;
const combinedRoomIds = new Set(combinedRooms.map((r) => r.id));
const openRooms = (chatCodes: string[]) => {
for (const chatCode of chatCodes) {
chatContext.requestHistory(chatCode);
chatContext.markAsRead(chatCode);
const standaloneRooms = chatContext.rooms.filter(
(room) => !combinedRoomIds.has(room.id),
);
const activeRooms = standaloneRooms
.filter((room) => !roomIsInactive(room))
.sort(byRecency);
const inactiveRooms = standaloneRooms
.filter((room) => roomIsInactive(room))
.sort(byRecency);
const openRooms = (roomIds: number[]) => {
for (const roomId of roomIds) {
chatContext.ensureMessagesLoaded(roomId);
chatContext.markAsRead(roomId);
}
chatContext.setActiveRooms(chatCodes);
chatContext.setActiveRoomIds(roomIds);
};
const hasAnyRoom = isCombined || standaloneRooms.length > 0;
return (
<div className={styles.sidebar}>
<SidebarHeader onClose={onClose} />
<div className={styles.roomList}>
{!isCombined && standaloneRooms.length === 0 ? (
{!hasAnyRoom ? (
<div className={styles.emptyState}>
{t("common:chat.sidebar.noActiveChats")}
</div>
@@ -134,50 +205,41 @@ function RoomList({ onClose }: { onClose?: () => void }) {
{isCombined ? (
<CombinedRoomListItem
rooms={combinedRooms}
onPress={() =>
openRooms(combinedRooms.map((room) => room.chatCode))
}
onPress={() => openRooms(combinedRooms.map((room) => room.id))}
/>
) : null}
{standaloneRooms.map((room) => {
const unread = chatContext.unreadCounts[room.chatCode] ?? 0;
return (
<NavListButton
key={room.chatCode}
className={clsx(
styles.roomItem,
room.isObsolete ? "opaque" : null,
)}
onPress={() => openRooms([room.chatCode])}
{activeRooms.map((room) => (
<RoomListItem
key={room.id}
room={room}
onPress={() => openRooms([room.id])}
/>
))}
{inactiveRooms.length > 0 ? (
<>
<Button
className={styles.inactiveToggle}
onPress={() => setShowInactive((shown) => !shown)}
>
{room.imageUrl ? <NavListImage src={room.imageUrl} /> : null}
<NavListTexts>
<NavListTitle
className={clsx(
styles.roomName,
room.isObsolete ? "line-through" : null,
)}
>
{resolveDatePlaceholders(
room.header,
(d) => headerFormatter.format(d) ?? "",
)}
</NavListTitle>
<NavListSubtitle>{room.subtitle}</NavListSubtitle>
</NavListTexts>
{unread > 0 && !room.isObsolete ? (
<span className={styles.unreadBadge}>{unread}</span>
) : room.lastMessageTimestamp > 0 ? (
<span className={styles.roomTimestamp}>
{timestampFormatter.format(
new Date(room.lastMessageTimestamp),
)}
</span>
) : null}
</NavListButton>
);
})}
{showInactive ? (
<ChevronDown size={14} />
) : (
<ChevronRight size={14} />
)}
{t("common:chat.sidebar.inactive")} ({inactiveRooms.length})
</Button>
{showInactive
? inactiveRooms.map((room) => (
<RoomListItem
key={room.id}
room={room}
inactive
onPress={() => openRooms([room.id])}
/>
))
: null}
</>
) : null}
</>
)}
</div>
@@ -185,39 +247,67 @@ function RoomList({ onClose }: { onClose?: () => void }) {
);
}
function CombinedRoomListItem({
rooms,
function RoomListItem({
room,
inactive = false,
onPress,
}: {
rooms: RoomInfo[];
room: ChatRoomListItem;
inactive?: boolean;
onPress: () => void;
}) {
const chatContext = useChatContext()!;
const { formatter: headerFormatter } = useDateTimeFormat({
month: "numeric",
day: "numeric",
const roomDisplay = useRoomDisplay();
const { formatter: timestampFormatter } = useDateTimeFormat({
hour: "numeric",
minute: "numeric",
});
const primary = rooms[0];
const unread = rooms.reduce(
(sum, room) => sum + (chatContext.unreadCounts[room.chatCode] ?? 0),
0,
const { title, subtitle, imageUrl } = roomDisplay(room);
return (
<NavListButton
className={clsx(styles.roomItem, inactive ? "opaque" : null)}
onPress={onPress}
>
<NavListImage src={imageUrl} />
<NavListTexts>
<NavListTitle className={styles.roomName}>{title}</NavListTitle>
<NavListSubtitle>{subtitle}</NavListSubtitle>
</NavListTexts>
{room.unreadCount > 0 ? (
<span className={styles.unreadBadge}>{room.unreadCount}</span>
) : room.latestMessageAt !== null ? (
<span className={styles.roomTimestamp}>
{timestampFormatter.format(
databaseTimestampToDate(room.latestMessageAt),
)}
</span>
) : null}
</NavListButton>
);
}
function CombinedRoomListItem({
rooms,
onPress,
}: {
rooms: ChatRoomListItem[];
onPress: () => void;
}) {
const { t } = useTranslation(["common"]);
const roomDisplay = useRoomDisplay();
const primary = rooms[0];
const { title, imageUrl } = roomDisplay(primary);
const unread = rooms.reduce((sum, room) => sum + room.unreadCount, 0);
return (
<NavListButton className={styles.roomItem} onPress={onPress}>
{primary.imageUrl ? <NavListImage src={primary.imageUrl} /> : null}
<NavListImage src={imageUrl} />
<NavListTexts>
<NavListTitle className={styles.roomName}>
{resolveDatePlaceholders(
primary.header,
(d) => headerFormatter.format(d) ?? "",
)}
</NavListTitle>
<NavListTitle className={styles.roomName}>{title}</NavListTitle>
<NavListSubtitle>
{rooms.map((room) => roomShortLabel(room.header)).join(" · ")}
{rooms.map((room) => roomShortLabel(room, t)).join(" · ")}
</NavListSubtitle>
</NavListTexts>
{unread > 0 ? <span className={styles.unreadBadge}>{unread}</span> : null}
@@ -228,83 +318,43 @@ function CombinedRoomListItem({
function ChatView({ onClose }: { onClose?: () => void }) {
const chatContext = useChatContext()!;
const activeRooms = chatContext.activeRooms
.map((code) => chatContext.rooms.find((r) => r.chatCode === code))
.filter((r): r is RoomInfo => Boolean(r));
const activeRooms = chatContext.activeRoomIds
.map((roomId) => chatContext.rooms.find((r) => r.id === roomId))
.filter((r): r is ChatRoomListItem => Boolean(r));
if (activeRooms.length > 1) {
return <CombinedChatView rooms={activeRooms} onClose={onClose} />;
}
return <SingleChatView onClose={onClose} />;
return <SingleChatView room={activeRooms[0]} onClose={onClose} />;
}
function SingleChatView({ onClose }: { onClose?: () => void }) {
function SingleChatView({
room,
onClose,
}: {
room: ChatRoomListItem | undefined;
onClose?: () => void;
}) {
const { t } = useTranslation(["common"]);
const chatContext = useChatContext()!;
const activeRoom = chatContext.activeRooms[0];
const { formatter: headerFormatter } = useDateTimeFormat({
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
});
const roomDisplay = useRoomDisplay();
const routeChatCodes = useCurrentRouteChatCodes();
// Mirror the room list's badge visibility (RoomList): only rooms that are
// visible (non-expired or in route) and not obsolete contribute, so the
// back-arrow total can't outrun what the list can actually show.
const otherRoomsUnreadCount = chatContext.rooms
.filter(
(room) =>
room.chatCode !== activeRoom &&
!room.isObsolete &&
(room.expiresAt > Date.now() || routeChatCodes.includes(room.chatCode)),
)
.reduce(
(sum, room) => sum + (chatContext.unreadCounts[room.chatCode] ?? 0),
0,
);
.filter((candidate) => candidate.id !== room?.id)
.reduce((sum, candidate) => sum + candidate.unreadCount, 0);
const room = chatContext.rooms.find((r) => r.chatCode === activeRoom);
const roomExpired = Boolean(room?.expiresAt && room.expiresAt < Date.now());
const messages = chatContext.messagesForRoom(activeRoom);
const usersWithLabels = roomUsersWithLabels(chatContext, room);
const chatAdapter = {
messages,
send: (contents: string) => {
chatContext.send(activeRoom, contents);
},
currentRoom: activeRoom,
setCurrentRoom: () => {},
readyState: chatContext.readyState,
unseenMessages: new Map<string, number>(),
};
const handleBack = () => {
chatContext.setActiveRooms([]);
};
const display = room ? roomDisplay(room) : null;
const headerContent = (
<>
{room?.imageUrl ? <NavListImage src={room.imageUrl} /> : null}
{display ? <NavListImage src={display.imageUrl} /> : null}
<div className={styles.chatHeaderInfo}>
<span
className={clsx(
styles.chatHeaderTitle,
room?.isObsolete ? "line-through" : null,
)}
>
{resolveDatePlaceholders(
room?.header ?? t("common:chat.sidebar.title"),
(d) => headerFormatter.format(d) ?? "",
)}
<span className={styles.chatHeaderTitle}>
{display?.title ?? t("common:chat.sidebar.title")}
</span>
{room?.subtitle ? (
<span className={styles.chatHeaderSubtitle}>{room.subtitle}</span>
{display?.subtitle ? (
<span className={styles.chatHeaderSubtitle}>{display.subtitle}</span>
) : null}
</div>
</>
@@ -313,7 +363,10 @@ function SingleChatView({ onClose }: { onClose?: () => void }) {
return (
<div className={styles.sidebar}>
<div className={styles.chatHeader}>
<Button className={styles.backButton} onPress={handleBack}>
<Button
className={styles.backButton}
onPress={() => chatContext.setActiveRoomIds([])}
>
<ArrowLeft size={18} />
{otherRoomsUnreadCount > 0 ? (
<span className={styles.backButtonBadge}>
@@ -335,17 +388,7 @@ function SingleChatView({ onClose }: { onClose?: () => void }) {
) : null}
</div>
<div className={styles.chatContainer}>
<Chat
users={usersWithLabels}
rooms={[
{
label: room?.header ?? "Chat",
code: activeRoom,
},
]}
chat={chatAdapter}
disabled={roomExpired}
/>
{room ? <RoomChat room={room} /> : null}
</div>
</div>
);
@@ -355,43 +398,46 @@ function CombinedChatView({
rooms,
onClose,
}: {
rooms: RoomInfo[];
rooms: ChatRoomListItem[];
onClose?: () => void;
}) {
const chatContext = useChatContext()!;
const { formatter: headerFormatter } = useDateTimeFormat({
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
});
const handleBack = () => {
chatContext.setActiveRooms([]);
};
const roomDisplay = useRoomDisplay();
const isMobile = useLayoutSize() === "mobile";
const primary = rooms[0];
const display = roomDisplay(primary);
const headerContent = (
<>
{primary.imageUrl ? <NavListImage src={primary.imageUrl} /> : null}
<NavListImage src={display.imageUrl} />
<div className={styles.chatHeaderInfo}>
<span className={styles.chatHeaderTitle}>
{resolveDatePlaceholders(
primary.header,
(d) => headerFormatter.format(d) ?? "",
)}
</span>
{primary.subtitle ? (
<span className={styles.chatHeaderSubtitle}>{primary.subtitle}</span>
<span className={styles.chatHeaderTitle}>{display.title}</span>
{display.subtitle ? (
<span className={styles.chatHeaderSubtitle}>{display.subtitle}</span>
) : null}
</div>
</>
);
// Primary (match) sits on top, flush below the main header which already names
// it, so its sub-header is hidden. Desktop splits evenly; mobile gives the
// match chat the larger 3/5 share (group chat 2/5).
const panels = [
{ room: primary, grow: isMobile ? 3 : 1, showHeader: false },
...rooms.slice(1).map((room) => ({
room,
grow: isMobile ? 2 : 1,
showHeader: true,
})),
];
return (
<div className={styles.sidebar}>
<div className={styles.chatHeader}>
<Button className={styles.backButton} onPress={handleBack}>
<Button
className={styles.backButton}
onPress={() => chatContext.setActiveRoomIds([])}
>
<ArrowLeft size={18} />
</Button>
{primary.url ? (
@@ -407,49 +453,16 @@ function CombinedChatView({
</Button>
) : null}
</div>
<SplitPanels rooms={rooms} />
</div>
);
}
function SplitPanels({ rooms }: { rooms: RoomInfo[] }) {
const chatContext = useChatContext()!;
const isMobile = useLayoutSize() === "mobile";
// Both panels are on screen at once, so keep them all marked read while open,
// re-running whenever any room's message count changes.
const countsKey = rooms
.map((r) => `${r.chatCode}:${r.totalMessageCount}`)
.join(",");
React.useEffect(() => {
for (const entry of countsKey.split(",")) {
chatContext.markAsRead(entry.split(":")[0]);
}
}, [countsKey, chatContext.markAsRead]);
const [primary, ...rest] = rooms;
// Primary (match) sits on top, flush below the main header which already names
// it, so its sub-header is hidden. Desktop splits evenly; mobile gives the
// match chat the larger 3/5 share (group chat 2/5).
const panels = [
{ room: primary, grow: isMobile ? 3 : 1, showHeader: false },
...rest.map((room) => ({
room,
grow: isMobile ? 2 : 1,
showHeader: true,
})),
];
return (
<div className={styles.splitView}>
{panels.map(({ room, grow, showHeader }) => (
<SplitPanel
key={room.chatCode}
room={room}
grow={grow}
showHeader={showHeader}
/>
))}
<div className={styles.splitView}>
{panels.map(({ room, grow, showHeader }) => (
<SplitPanel
key={room.id}
room={room}
grow={grow}
showHeader={showHeader}
/>
))}
</div>
</div>
);
}
@@ -459,19 +472,19 @@ function SplitPanel({
grow,
showHeader,
}: {
room: RoomInfo;
room: ChatRoomListItem;
grow: number;
showHeader: boolean;
}) {
const { t } = useTranslation(["common"]);
return (
<div
className={styles.splitPanel}
style={{ "--split-grow": grow } as React.CSSProperties}
>
{showHeader ? (
<div className={styles.splitPanelHeader}>
{roomShortLabel(room.header)}
</div>
<div className={styles.splitPanelHeader}>{roomShortLabel(room, t)}</div>
) : null}
<div className={styles.chatContainer}>
<RoomChat room={room} />
@@ -480,52 +493,30 @@ function SplitPanel({
);
}
function RoomChat({ room }: { room: RoomInfo }) {
function RoomChat({ room }: { room: ChatRoomListItem }) {
const chatContext = useChatContext()!;
const roomExpired = Boolean(room.expiresAt && room.expiresAt < Date.now());
const usersWithLabels = roomUsersWithLabels(chatContext, room);
const chatAdapter = {
messages: chatContext.messagesForRoom(room.chatCode),
send: (contents: string) => {
chatContext.send(room.chatCode, contents);
},
currentRoom: room.chatCode,
setCurrentRoom: () => {},
readyState: chatContext.readyState,
unseenMessages: new Map<string, number>(),
};
return (
<Chat
users={usersWithLabels}
rooms={[{ label: room.header, code: room.chatCode }]}
chat={chatAdapter}
disabled={roomExpired}
messages={chatContext.messagesForRoom(room.id)}
onSend={(message) => chatContext.sendMessage(room.id, message)}
labelByUserId={nonParticipantLabels(chatContext, room)}
disabled={room.expiresAt <= dateToDatabaseTimestamp(new Date())}
/>
);
}
function roomUsersWithLabels(
/** Role labels apply to non-participant authors only (e.g. a TO posting into a match chat). */
function nonParticipantLabels(
chatContext: ChatContextValue,
room: RoomInfo | undefined,
room: ChatRoomListItem,
) {
const participantIds = new Set(room?.participantUserIds ?? []);
const usersWithLabels = { ...chatContext.chatUsers };
const participantIds = new Set(room.participantUserIds);
const labels: Record<number, string> = {};
for (const [userIdStr, label] of Object.entries(chatContext.chatLabels)) {
const userId = Number(userIdStr);
if (participantIds.has(userId)) continue;
const existing = usersWithLabels[userId];
if (existing) {
usersWithLabels[userId] = { ...existing, title: label };
}
labels[userId] = label;
}
return usersWithLabels;
}
/** Concise label for tabs/split headers, e.g. "Match #123" -> "Match". */
function roomShortLabel(header: string): string {
const trimmed = header.trim();
if (!trimmed) return "Chat";
return trimmed.split(/[\s(#]/)[0] || trimmed;
return labels;
}

View File

@@ -1,494 +1,180 @@
import { nanoid } from "nanoid";
import { WebSocket } from "partysocket";
import * as React from "react";
import { useLocation, useMatches } from "react-router";
import { eventsClient } from "~/features/events/events-client";
import {
useFetcher,
useLocation,
useMatches,
useRevalidator,
} from "react-router";
import { Config } from "~/config";
import { useEventsConnection } from "~/features/events/events-hooks";
useEventsConnection,
useEventsReadyState,
} from "~/features/events/events-hooks";
import { chatRoomChannel } from "~/features/events/events-types";
import { useLayoutSize } from "~/hooks/useMainContentWidth";
import { logger } from "~/utils/logger";
import type { LoggedInUser } from "~/root";
import { type ChatSnapshot, chatClient } from "./chat-client";
import {
useRefreshOnReconnect,
useServerRevalidationEvents,
} 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 { playMessageSound } from "./chat-utils";
import { scheduleBroadcastRevalidation } from "./revalidation-scope";
import type { ChatContextValue } from "./chat-provider-types";
import type { ChatRoomListItem, ClientChatMessage } from "./chat-types";
import { ChatContext } from "./useChatContext";
const PING_INTERVAL_MS = 60_000;
const EMPTY_MESSAGES: ClientChatMessage[] = [];
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,
);
}
const SERVER_SNAPSHOT: ChatSnapshot = {
roomsLoaded: false,
rooms: [],
totalUnreadCount: 0,
messagesByRoomId: new Map(),
};
const getServerSnapshot = () => SERVER_SNAPSHOT;
export function ChatProvider({
user,
children,
}: {
user?: { id: number } | null;
user?: LoggedInUser | null;
children: React.ReactNode;
}) {
if (!user) {
return <>{children}</>;
}
return <ChatProviderInner userId={user.id}>{children}</ChatProviderInner>;
return <ChatProviderInner user={user}>{children}</ChatProviderInner>;
}
function ChatProviderInner({
userId,
user,
children,
}: {
userId: number;
user: LoggedInUser;
children: React.ReactNode;
}) {
const { revalidate } = useRevalidator();
useEventsConnection(true);
useServerRevalidationEvents(userId);
useServerRevalidationEvents(user.id);
const snapshot = React.useSyncExternalStore(
chatClient.subscribe,
chatClient.getSnapshot,
getServerSnapshot,
);
React.useEffect(() => {
chatClient.start(user.id);
return () => chatClient.stop();
}, [user.id]);
const readyState = useEventsReadyState();
useRefreshOnReconnect(readyState, () => chatClient.catchUp());
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 [activeRoomIds, setActiveRoomIds] = React.useState<number[]>([]);
const [chatLabels, setChatLabels] = React.useState<Record<number, string>>(
{},
);
const clearChatLabels = React.useCallback(() => setChatLabels({}), []);
const ws = React.useRef<WebSocket>(undefined);
// messages arriving to a room on screen are read immediately instead of counting unread
React.useEffect(() => {
chatClient.setViewedRoomIds(chatOpen ? activeRoomIds : []);
}, [chatOpen, activeRoomIds]);
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 rooms = snapshot.rooms;
const onMessage = React.useEffectEvent((e: MessageEvent) => {
const parsed = JSON.parse(e.data);
logger.debug("WS message received:", parsed);
// a room that vanished from the list is one the user lost access to (e.g.
// left the group) — close its open view. Only ever-listed rooms count: a
// just-created room the loader knows before the list does must not have
// the view it just opened closed underneath it.
const previouslyListedRoomIdsRef = React.useRef(new Set<number>());
React.useEffect(() => {
if (!snapshot.roomsLoaded) return;
// 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 listedRoomIds = new Set(rooms.map((room) => room.id));
const previouslyListed = previouslyListedRoomIdsRef.current;
previouslyListedRoomIdsRef.current = listedRoomIds;
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;
}
// 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,
const keptActiveRoomIds = activeRoomIds.filter(
(roomId) => !previouslyListed.has(roomId) || listedRoomIds.has(roomId),
);
if (isSystemMessage) {
// 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);
if (keptActiveRoomIds.length !== activeRoomIds.length) {
setActiveRoomIds(keptActiveRoomIds);
}
playMessageSound(messageArr[0].type);
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 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,
);
}, [snapshot.roomsLoaded, rooms, activeRoomIds]);
const setChatOpen = React.useCallback(
(open: boolean) => {
_setChatOpen(open);
if (!open) return;
if (activeRooms.length > 0) {
for (const code of activeRooms) {
markAsRead(code);
if (activeRoomIds.length > 0) {
for (const roomId of activeRoomIds) {
chatClient.markRead(roomId);
}
} else if (rooms.length === 1) {
requestHistory(rooms[0].chatCode);
setActiveRooms([rooms[0].chatCode]);
markAsRead(rooms[0].chatCode);
setActiveRoomIds([rooms[0].id]);
chatClient.ensureMessagesLoaded(rooms[0].id);
chatClient.markRead(rooms[0].id);
}
},
[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],
[activeRoomIds, rooms.length, rooms[0]?.id],
);
useChatRouteSync({
userId: user.id,
roomsLoaded: snapshot.roomsLoaded,
rooms,
userId,
isLoading,
readyState,
activeRooms,
setActiveRooms,
setActiveRoomIds,
setChatOpen,
markAsRead,
subscribe,
unsubscribe,
setRooms,
setMessagesByRoom,
requestHistory,
messagesByRoom,
});
const contextValue = React.useMemo(
const sendMessage = React.useCallback(
(roomId: number, message: { publicId: string; contents: string }) => {
chatClient.send(roomId, {
...message,
author: {
id: user.id,
username: user.username,
discordId: user.discordId,
discordAvatar: user.discordAvatar,
customUrl: user.customUrl ?? null,
customAvatarUrl: user.customAvatarUrl ?? null,
pronouns: null,
chatNameHue: null,
},
});
},
[user],
);
const messagesForRoom = React.useCallback(
(roomId: number) => snapshot.messagesByRoomId.get(roomId) ?? EMPTY_MESSAGES,
[snapshot.messagesByRoomId],
);
const contextValue = React.useMemo<ChatContextValue>(
() => ({
isLoading,
roomsLoaded: snapshot.roomsLoaded,
rooms,
messagesForRoom,
send,
subscribe,
unsubscribe,
requestHistory,
markAsRead,
unreadCounts,
totalUnreadCount,
readyState,
chatUsers,
ensureMessagesLoaded: chatClient.ensureMessagesLoaded,
sendMessage,
markAsRead: chatClient.markRead,
totalUnreadCount: snapshot.totalUnreadCount,
chatOpen,
setChatOpen,
activeRooms,
setActiveRooms,
activeRoomIds,
setActiveRoomIds,
chatLabels,
setChatLabels,
clearChatLabels,
}),
[
isLoading,
snapshot.roomsLoaded,
snapshot.totalUnreadCount,
rooms,
messagesForRoom,
send,
subscribe,
unsubscribe,
requestHistory,
markAsRead,
unreadCounts,
totalUnreadCount,
readyState,
chatUsers,
sendMessage,
chatOpen,
activeRooms,
setChatOpen,
activeRoomIds,
chatLabels,
clearChatLabels,
],
@@ -500,242 +186,117 @@ function ChatProviderInner({
}
function useChatRouteSync({
rooms,
userId,
isLoading,
readyState,
activeRooms,
setActiveRooms,
roomsLoaded,
rooms,
setActiveRoomIds,
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[]>>;
roomsLoaded: boolean;
rooms: ChatRoomListItem[];
setActiveRoomIds: (roomIds: number[]) => void;
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 routeRoomIdsKey = useCurrentRouteChatRoomIds().join(",");
const { pathname } = useLocation();
const layoutSize = useLayoutSize();
const subscribedRoomRef = React.useRef<string[]>([]);
const previousRouteChatCodeRef = React.useRef<string[]>([]);
const previousRouteRoomIdsKeyRef = React.useRef<string | null>(null);
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);
}
});
// revalidate broadcasts for the page's rooms (e.g. a score report on the
// match the user is viewing) arrive on the rooms' topic channels
React.useEffect(() => {
const roomIds = routeRoomIdsKey ? routeRoomIdsKey.split(",") : [];
const unsubscribes = roomIds.map((roomId) =>
eventsClient.subscribeTopic(chatRoomChannel(Number(roomId))),
);
return () => {
for (const unsubscribe of unsubscribes) {
unsubscribe();
}
};
}, [routeRoomIdsKey]);
React.useEffect(() => {
if (isLoading) return;
if (!roomsLoaded) return;
const chatCodes = chatCodesKey ? chatCodesKey.split(",") : [];
const routeRoomIds = routeRoomIdsKey
? routeRoomIdsKey.split(",").map(Number)
: [];
// Clean up subscriptions for rooms no longer in chatCodes
const previousSubscribed = subscribedRoomRef.current;
const removedRooms = previousSubscribed.filter(
(code) => !chatCodes.includes(code),
if (routeRoomIds.length > 0) {
const routeRoomIdsChanged =
previousRouteRoomIdsKeyRef.current !== routeRoomIdsKey;
previousRouteRoomIdsKeyRef.current = routeRoomIdsKey;
if (!routeRoomIdsChanged) return;
// the loader can know about a just-created room before the room list does
if (routeRoomIds.some((id) => rooms.every((room) => room.id !== id))) {
void chatClient.refreshRooms();
}
setActiveRoomIds(routeRoomIds);
for (const roomId of routeRoomIds) {
chatClient.ensureMessagesLoaded(roomId);
}
if (layoutSize === "desktop") {
setChatOpen(true);
for (const roomId of routeRoomIds) {
chatClient.markRead(roomId);
}
}
return;
}
previousRouteRoomIdsKeyRef.current = null;
const pathnameChanged = previousPathnameRef.current !== pathname;
previousPathnameRef.current = pathname;
if (!pathnameChanged) return;
const matchedRoom = rooms.find(
(room) =>
room.url === pathname && room.participantUserIds.includes(userId),
);
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),
);
if (!matchedRoom) return;
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);
}
}
}
setActiveRoomIds([matchedRoom.id]);
chatClient.ensureMessagesLoaded(matchedRoom.id);
if (layoutSize === "desktop") {
setChatOpen(true);
chatClient.markRead(matchedRoom.id);
}
}, [
isLoading,
chatCodesKey,
roomsLoaded,
routeRoomIdsKey,
pathname,
rooms,
userId,
activeRooms,
setActiveRooms,
setActiveRoomIds,
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.
* Chat rooms the current route wants visible, from its loader's `chatRoomIds`.
* A route may expose several (e.g. a SendouQ match exposes the match chat
* alongside the user's own group chat) which are then shown as a single
* combined split view.
*/
export function useCurrentRouteChatCodes(): string[] {
export function useCurrentRouteChatRoomIds(): number[] {
const matches = useMatches();
for (const match of matches) {
const matchData = match.loaderData as
| { chatCode?: string | string[] }
| { chatRoomIds?: number[] }
| undefined;
if (matchData?.chatCode) {
return Array.isArray(matchData.chatCode)
? matchData.chatCode
: [matchData.chatCode];
if (matchData?.chatRoomIds && matchData.chatRoomIds.length > 0) {
return matchData.chatRoomIds;
}
}
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

@@ -151,15 +151,15 @@ describe("ChatRepository.findMessageById", () => {
});
});
describe("ChatRepository.findUnreadCountsByRoomIds", () => {
describe("ChatRepository.findMessageStatsByRoomIds", () => {
test("counts messages newer than the user's read indicator per room", async () => {
const room = await ChatRoomFactory.create();
const otherRoom = await ChatRoomFactory.create();
const [first] = await ChatMessageFactory.createMany(3, {
const [first, , third] = await ChatMessageFactory.createMany(3, {
roomId: room.id,
authorUserId: users.id(2),
});
await ChatMessageFactory.create({
const otherRoomMessage = await ChatMessageFactory.create({
roomId: otherRoom.id,
authorUserId: users.id(2),
});
@@ -169,18 +169,28 @@ describe("ChatRepository.findUnreadCountsByRoomIds", () => {
lastSeenMessageId: first.id,
});
const counts = await ChatRepository.findUnreadCountsByRoomIds(users.id(1), [
const stats = await ChatRepository.findMessageStatsByRoomIds(users.id(1), [
room.id,
otherRoom.id,
]);
expect(counts.sort((a, b) => a.roomId - b.roomId)).toEqual([
{ roomId: room.id, unreadCount: 2 },
{ roomId: otherRoom.id, unreadCount: 1 },
expect(stats.sort((a, b) => a.roomId - b.roomId)).toEqual([
{
roomId: room.id,
unreadCount: 2,
latestMessageId: third.id,
latestMessageCreatedAt: third.createdAt,
},
{
roomId: otherRoom.id,
unreadCount: 1,
latestMessageId: otherRoomMessage.id,
latestMessageCreatedAt: otherRoomMessage.createdAt,
},
]);
});
test("leaves out rooms with nothing unread", async () => {
test("returns a zero unread count once everything is read", async () => {
const room = await ChatRoomFactory.create();
const message = await ChatMessageFactory.create({
roomId: room.id,
@@ -193,13 +203,28 @@ describe("ChatRepository.findUnreadCountsByRoomIds", () => {
});
expect(
await ChatRepository.findUnreadCountsByRoomIds(users.id(1), [room.id]),
await ChatRepository.findMessageStatsByRoomIds(users.id(1), [room.id]),
).toEqual([
{
roomId: room.id,
unreadCount: 0,
latestMessageId: message.id,
latestMessageCreatedAt: message.createdAt,
},
]);
});
test("leaves out rooms with no messages", async () => {
const room = await ChatRoomFactory.create();
expect(
await ChatRepository.findMessageStatsByRoomIds(users.id(1), [room.id]),
).toEqual([]);
});
test("returns an empty array for no room ids", async () => {
expect(
await ChatRepository.findUnreadCountsByRoomIds(users.id(1), []),
await ChatRepository.findMessageStatsByRoomIds(users.id(1), []),
).toEqual([]);
});
});

View File

@@ -163,11 +163,18 @@ export async function upsertReadIndicator(
.execute();
}
/** Unread message counts per room: how many messages are newer than the user's read indicator. Rooms with nothing unread are left out. */
export async function findUnreadCountsByRoomIds(
/** Per-room message stats for the user: unread count (messages newer than their read indicator) and the latest message. Rooms with no messages are left out. */
export async function findMessageStatsByRoomIds(
userId: number,
roomIds: number[],
): Promise<Array<{ roomId: number; unreadCount: number }>> {
): Promise<
Array<{
roomId: number;
unreadCount: number;
latestMessageId: number;
latestMessageCreatedAt: number;
}>
> {
if (roomIds.length === 0) return [];
return db
@@ -177,18 +184,26 @@ export async function findUnreadCountsByRoomIds(
.onRef("ChatMessageReadIndicator.roomId", "=", "ChatMessage.roomId")
.on("ChatMessageReadIndicator.userId", "=", userId),
)
.select(({ fn }) => [
.select(({ eb, fn, val }) => [
"ChatMessage.roomId",
fn.countAll<number>().as("unreadCount"),
fn
.sum<number>(
eb
.case()
.when(
"ChatMessage.id",
">",
fn.coalesce("ChatMessageReadIndicator.lastSeenMessageId", val(0)),
)
.then(1)
.else(0)
.end(),
)
.as("unreadCount"),
fn.max<number>("ChatMessage.id").as("latestMessageId"),
fn.max<number>("ChatMessage.createdAt").as("latestMessageCreatedAt"),
])
.where("ChatMessage.roomId", "in", roomIds)
.where(({ eb, fn, val }) =>
eb(
"ChatMessage.id",
">",
fn.coalesce("ChatMessageReadIndicator.lastSeenMessageId", val(0)),
),
)
.groupBy("ChatMessage.roomId")
.execute();
}

View File

@@ -8,6 +8,7 @@ import * as TournamentTeamFactory from "~/db/seed/factories/TournamentTeamFactor
import * as UserFactory from "~/db/seed/factories/UserFactory";
import { db } from "~/db/sql";
import * as ScrimPostRepository from "~/features/scrims/ScrimPostRepository.server";
import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import * as ChatRepository from "./ChatRepository.server";
@@ -114,6 +115,20 @@ describe("ChatRoomResolver.resolve", () => {
expect(room.participantUserIds.sort()).toEqual(memberUserIds.sort());
expect(room.url).toBe("/q/looking");
expect(room.observerUserIds).toEqual([]);
expect(room.inactive).toBe(false);
});
test("marks a dead group's room inactive", async () => {
const group = await SQGroupFactory.create({
memberUserIds: [users.id(2), users.id(3)],
});
await SQGroupRepository.setAsInactive(group.id);
const [room] = await ChatRoomResolver.resolve([
await groupChatRoomId(group.id),
]);
expect(room.inactive).toBe(true);
});
test("resolves an SQ_MATCH room to both groups' members", async () => {
@@ -329,6 +344,7 @@ describe("ChatRoomResolver.canPost", () => {
observerUserIds: [],
expiresAt: dateToDatabaseTimestamp(addHours(new Date(), 1)),
closedAt: null,
inactive: false,
...overrides,
});

View File

@@ -34,6 +34,8 @@ export interface ResolvedRoom {
observerUserIds: number[];
expiresAt: number;
closedAt: number | null;
/** Whether the owner's activity has concluded (e.g. the match was finalized). */
inactive: boolean;
}
type ChatRoomRow = Tables["ChatRoom"];
@@ -244,6 +246,7 @@ async function resolveSqGroupRooms(
.selectFrom("Group")
.select((eb) => [
"Group.chatRoomId",
"Group.status",
jsonArrayFrom(
eb
.selectFrom("GroupMember")
@@ -264,6 +267,8 @@ async function resolveSqGroupRooms(
imageUrl: null,
participantUserIds: owner.members.map((member) => member.userId),
observerUserIds: [],
// derived live instead of a persisted flag: a dead group can never chat again
inactive: owner.status === "INACTIVE",
}));
}
@@ -524,7 +529,8 @@ function joinOwners<T extends { chatRoomId: number | null }>(
| "imageUrl"
| "participantUserIds"
| "observerUserIds"
>,
> &
Partial<Pick<ResolvedRoom, "inactive">>,
): ResolvedRoom[] {
const ownerByRoomId = new Map(
owners.map((owner) => [owner.chatRoomId, owner]),
@@ -539,6 +545,7 @@ function joinOwners<T extends { chatRoomId: number | null }>(
type: room.type,
expiresAt: room.expiresAt,
closedAt: room.closedAt,
inactive: Boolean(room.inactive),
...build(owner),
};
});

View File

@@ -165,3 +165,16 @@ describe("ChatSystemMessage.notifyNotificationsChanged", () => {
expect(bravo).toEqual([{ kind: "notificationsChanged" }]);
});
});
describe("ChatSystemMessage.notifyRoomsChanged", () => {
test("publishes to each user's channel", async () => {
const alpha = subscribeTo(EventBus.userChannel(1));
const bravo = subscribeTo(EventBus.userChannel(2));
ChatSystemMessage.notifyRoomsChanged([1, 2]);
await flushEvents();
expect(alpha).toEqual([{ kind: "roomsChanged" }]);
expect(bravo).toEqual([{ kind: "roomsChanged" }]);
});
});

View File

@@ -9,8 +9,8 @@ import { logger } from "~/utils/logger";
import * as ChatRepository from "./ChatRepository.server";
import * as ChatRoomResolver from "./ChatRoomResolver.server";
import type {
ChatMessage,
PersistedSystemMessageType,
RevalidateScope,
SoundOnlySystemMessageType,
SystemMessageType,
} from "./chat-types";
@@ -33,10 +33,12 @@ function logSkalpError(action: string) {
};
}
type RevalidateBroadcast = Pick<
ChatMessage,
"room" | "revalidateScope" | "authorUserId"
> & {
type RevalidateBroadcast = {
/** Channel of the room whose pages should refetch, see `EventBus.chatRoomChannel`. */
room: string;
/** Actor whose own broadcast clients skip (their submission already reran the loaders). */
authorUserId?: number;
revalidateScope?: RevalidateScope;
revalidateOnly: true;
type?: SoundOnlySystemMessageType;
};
@@ -125,9 +127,12 @@ async function persistAndPublish(args: {
});
}
function publishRevalidate(
msg: Pick<ChatMessage, "room" | "revalidateScope" | "authorUserId" | "type">,
) {
function publishRevalidate(msg: {
room: string;
revalidateScope?: RevalidateScope;
authorUserId?: number;
type?: SystemMessageType;
}) {
EventBus.publish([msg.room], {
kind: "revalidate",
scope: msg.revalidateScope,
@@ -164,6 +169,21 @@ export function notifyNotificationsChanged(userIds: number[]) {
});
}
/**
* Publishes a "your chat room set changed" event to the users' event streams
* after a membership change (leave, kick, group merge, added member). Clients
* refetch their room list and drop rooms — including any locally held history —
* they no longer have access to.
*/
export function notifyRoomsChanged(userIds: number[]) {
if (systemMessagesDisabled) return;
if (userIds.length === 0) return;
EventBus.publish(userIds.map(EventBus.userChannel), {
kind: "roomsChanged",
});
}
export function removeRoom(chatCode: string) {
if (systemMessagesDisabled) return;

View File

@@ -0,0 +1,450 @@
import { describe, expect, test, vi } from "vitest";
import type { ServerEvent } from "~/features/events/events-types";
import { type ChatClient, createChatClient } from "./chat-client";
import type {
ChatMessageAuthor,
ChatMessageWithAuthor,
ChatRoomListItem,
} from "./chat-types";
const READ_DEBOUNCE_MS = 20;
const author = (id: number): ChatMessageAuthor => ({
id,
username: `user-${id}`,
discordId: String(id),
discordAvatar: null,
customUrl: null,
customAvatarUrl: null,
pronouns: null,
chatNameHue: null,
});
function room(overrides: Partial<ChatRoomListItem> = {}): ChatRoomListItem {
return {
id: 1,
type: "SQ_MATCH",
titleParams: { matchId: "17" },
url: "/q/match/17",
imageUrl: null,
participantUserIds: [1, 2],
expiresAt: 2_000_000_000,
inactive: false,
unreadCount: 0,
latestMessageId: null,
latestMessageAt: null,
...overrides,
};
}
function message(
overrides: Partial<ChatMessageWithAuthor> = {},
): ChatMessageWithAuthor {
const authorUserId = overrides.authorUserId ?? 2;
return {
id: 1,
roomId: 1,
authorUserId,
type: null,
contents: "hello",
publicId: `public-${overrides.id ?? 1}`,
createdAt: 1_700_000_000,
author: authorUserId === null ? null : author(authorUserId),
...overrides,
};
}
function createHarness({
rooms = [room()],
messages = [] as ChatMessageWithAuthor[],
// in-flight forever by default; tests exercising the response path override it
postMessage = vi.fn(
() => new Promise<{ message: ChatMessageWithAuthor } | null>(() => {}),
),
} = {}) {
let eventListener: ((event: ServerEvent) => void) | null = null;
const fetchRooms = vi.fn(async () => ({ rooms }));
const fetchMessages = vi.fn(async (_roomId: number) => ({ messages }));
const postRead = vi.fn(async () => {});
const client = createChatClient({
fetchRooms,
fetchMessages,
postMessage,
postRead,
addServerEventListener: (listener) => {
eventListener = listener;
return () => {
eventListener = null;
};
},
readDebounceMs: READ_DEBOUNCE_MS,
});
return {
client,
fetchRooms,
fetchMessages,
postMessage,
postRead,
emit: (event: ServerEvent) => eventListener?.(event),
isListening: () => eventListener !== null,
};
}
const flush = () => new Promise((resolve) => setTimeout(resolve));
const flushReadDebounce = () =>
new Promise((resolve) => setTimeout(resolve, READ_DEBOUNCE_MS + 10));
async function startedClient(harness: { client: ChatClient }) {
harness.client.start(1);
await flush();
return harness.client;
}
describe("createChatClient", () => {
test("start fetches the room list and exposes total unread", async () => {
const harness = createHarness({
rooms: [room({ id: 1, unreadCount: 2 }), room({ id: 2, unreadCount: 1 })],
});
const client = await startedClient(harness);
const snapshot = client.getSnapshot();
expect(snapshot.roomsLoaded).toBe(true);
expect(snapshot.rooms).toHaveLength(2);
expect(snapshot.totalUnreadCount).toBe(3);
});
test("ensureMessagesLoaded fetches a room's history once", async () => {
const harness = createHarness({
messages: [message({ id: 1 }), message({ id: 2 })],
});
const client = await startedClient(harness);
client.ensureMessagesLoaded(1);
await flush();
client.ensureMessagesLoaded(1);
await flush();
expect(harness.fetchMessages).toHaveBeenCalledTimes(1);
expect(client.getSnapshot().messagesByRoomId.get(1)).toHaveLength(2);
});
test("an incoming message appends to the loaded history and bumps the room's unread count", async () => {
const harness = createHarness();
const client = await startedClient(harness);
client.ensureMessagesLoaded(1);
await flush();
const incoming = message({ id: 5, authorUserId: 2 });
harness.emit({ kind: "chatMessage", roomId: 1, message: incoming });
const snapshot = client.getSnapshot();
expect(snapshot.messagesByRoomId.get(1)).toEqual([incoming]);
expect(snapshot.rooms[0]).toMatchObject({
unreadCount: 1,
latestMessageId: 5,
});
});
test("an incoming message to a room with unloaded history only bumps the unread count", async () => {
const harness = createHarness();
const client = await startedClient(harness);
harness.emit({
kind: "chatMessage",
roomId: 1,
message: message({ id: 5 }),
});
const snapshot = client.getSnapshot();
expect(snapshot.messagesByRoomId.has(1)).toBe(false);
expect(snapshot.totalUnreadCount).toBe(1);
});
test("the user's own message from another device never counts as unread", async () => {
const harness = createHarness();
const client = await startedClient(harness);
harness.emit({
kind: "chatMessage",
roomId: 1,
message: message({ id: 5, authorUserId: 1 }),
});
expect(client.getSnapshot().totalUnreadCount).toBe(0);
});
test("a message to a viewed room is read immediately instead of counting unread", async () => {
const harness = createHarness();
const client = await startedClient(harness);
client.ensureMessagesLoaded(1);
await flush();
client.setViewedRoomIds([1]);
harness.emit({
kind: "chatMessage",
roomId: 1,
message: message({ id: 5 }),
});
expect(client.getSnapshot().totalUnreadCount).toBe(0);
await flushReadDebounce();
expect(harness.postRead).toHaveBeenCalledWith(1, 5);
});
test("the echo replaces the optimistic pending send with the same publicId", async () => {
const harness = createHarness();
const client = await startedClient(harness);
client.ensureMessagesLoaded(1);
await flush();
client.send(1, {
publicId: "abcdefghij",
contents: "hi there",
author: author(1),
});
expect(client.getSnapshot().messagesByRoomId.get(1)).toMatchObject([
{ publicId: "abcdefghij", pending: true },
]);
harness.emit({
kind: "chatMessage",
roomId: 1,
message: message({ id: 7, authorUserId: 1, publicId: "abcdefghij" }),
});
const messages = client.getSnapshot().messagesByRoomId.get(1)!;
expect(messages).toHaveLength(1);
expect(messages[0]).toMatchObject({ id: 7, publicId: "abcdefghij" });
expect(messages[0].pending).toBeUndefined();
});
test("the POST response reconciles the pending send when the echo is delayed", async () => {
const sent = message({ id: 7, authorUserId: 1, publicId: "abcdefghij" });
const harness = createHarness({
postMessage: vi.fn(async () => ({ message: sent })),
});
const client = await startedClient(harness);
client.ensureMessagesLoaded(1);
await flush();
client.send(1, {
publicId: "abcdefghij",
contents: "hi there",
author: author(1),
});
await flush();
expect(harness.postMessage).toHaveBeenCalledWith(1, {
publicId: "abcdefghij",
contents: "hi there",
});
const messages = client.getSnapshot().messagesByRoomId.get(1)!;
expect(messages).toMatchObject([{ id: 7, publicId: "abcdefghij" }]);
expect(messages[0].pending).toBeUndefined();
expect(client.getSnapshot().rooms[0].latestMessageId).toBe(7);
});
test("a failed send's pending message is removed", async () => {
const harness = createHarness({
postMessage: vi.fn(async () => null),
});
const client = await startedClient(harness);
client.ensureMessagesLoaded(1);
await flush();
client.send(1, {
publicId: "abcdefghij",
contents: "hi there",
author: author(1),
});
expect(client.getSnapshot().messagesByRoomId.get(1)).toHaveLength(1);
await flush();
expect(client.getSnapshot().messagesByRoomId.get(1)).toHaveLength(0);
});
test("a send whose POST throws is removed like a failed one", async () => {
const harness = createHarness({
postMessage: vi.fn(async () => {
throw new Error("network down");
}),
});
const client = await startedClient(harness);
client.ensureMessagesLoaded(1);
await flush();
client.send(1, {
publicId: "abcdefghij",
contents: "hi there",
author: author(1),
});
await flush();
expect(client.getSnapshot().messagesByRoomId.get(1)).toHaveLength(0);
});
test("optimistic sends appended while the history fetch is in flight are kept", async () => {
const harness = createHarness({ messages: [message({ id: 1 })] });
const client = await startedClient(harness);
client.ensureMessagesLoaded(1);
client.send(1, {
publicId: "abcdefghij",
contents: "raced",
author: author(1),
});
await flush();
expect(client.getSnapshot().messagesByRoomId.get(1)).toMatchObject([
{ id: 1 },
{ publicId: "abcdefghij", pending: true },
]);
});
test("a message for an unknown room refetches the room list", async () => {
const harness = createHarness();
const client = await startedClient(harness);
expect(harness.fetchRooms).toHaveBeenCalledTimes(1);
harness.emit({
kind: "chatMessage",
roomId: 999,
message: message({ id: 5, roomId: 999 }),
});
await flush();
expect(harness.fetchRooms).toHaveBeenCalledTimes(2);
expect(client.getSnapshot().roomsLoaded).toBe(true);
});
test("a roomsChanged event refetches the room list", async () => {
const harness = createHarness();
await startedClient(harness);
harness.emit({ kind: "roomsChanged" });
await flush();
expect(harness.fetchRooms).toHaveBeenCalledTimes(2);
});
test("a system message triggers a room list refetch, tracking the owner state change it accompanies", async () => {
const harness = createHarness();
const client = await startedClient(harness);
client.ensureMessagesLoaded(1);
await flush();
expect(harness.fetchRooms).toHaveBeenCalledTimes(1);
harness.fetchRooms.mockResolvedValue({
rooms: [room({ inactive: true })],
});
harness.emit({
kind: "chatMessage",
roomId: 1,
message: message({ id: 5, type: "SCORE_CONFIRMED", contents: null }),
});
await flush();
expect(harness.fetchRooms).toHaveBeenCalledTimes(2);
expect(client.getSnapshot().rooms[0].inactive).toBe(true);
});
test("a rooms refetch drops the held history of a room the user lost access to", async () => {
const harness = createHarness({
messages: [message({ id: 1 })],
});
const client = await startedClient(harness);
client.ensureMessagesLoaded(1);
await flush();
expect(client.getSnapshot().messagesByRoomId.has(1)).toBe(true);
harness.fetchRooms.mockResolvedValue({ rooms: [] });
harness.emit({ kind: "roomsChanged" });
await flush();
expect(client.getSnapshot().rooms).toEqual([]);
expect(client.getSnapshot().messagesByRoomId.has(1)).toBe(false);
});
test("markRead zeroes the unread count and debounces a single read POST for the newest message", async () => {
const harness = createHarness({
rooms: [room({ unreadCount: 2, latestMessageId: 8, latestMessageAt: 1 })],
});
const client = await startedClient(harness);
client.markRead(1);
client.markRead(1);
expect(client.getSnapshot().totalUnreadCount).toBe(0);
await flushReadDebounce();
expect(harness.postRead).toHaveBeenCalledTimes(1);
expect(harness.postRead).toHaveBeenCalledWith(1, 8);
});
test("a rooms refetch cannot resurrect the unread count of a locally read room", async () => {
const harness = createHarness({
rooms: [room({ unreadCount: 2, latestMessageId: 8, latestMessageAt: 1 })],
});
const client = await startedClient(harness);
client.markRead(1);
await client.refreshRooms();
expect(client.getSnapshot().totalUnreadCount).toBe(0);
});
test("catchUp refetches the room list and every loaded history", async () => {
const harness = createHarness();
const client = await startedClient(harness);
client.ensureMessagesLoaded(1);
await flush();
client.catchUp();
await flush();
expect(harness.fetchRooms).toHaveBeenCalledTimes(2);
expect(harness.fetchMessages).toHaveBeenCalledTimes(2);
});
test("stop resets held data and stops listening to events", async () => {
const harness = createHarness({ rooms: [room({ unreadCount: 1 })] });
const client = await startedClient(harness);
client.ensureMessagesLoaded(1);
await flush();
client.stop();
const snapshot = client.getSnapshot();
expect(snapshot.roomsLoaded).toBe(false);
expect(snapshot.rooms).toEqual([]);
expect(snapshot.messagesByRoomId.size).toBe(0);
expect(harness.isListening()).toBe(false);
});
test("persisted messages stay ordered by id when echoes arrive out of order", async () => {
const harness = createHarness();
const client = await startedClient(harness);
client.ensureMessagesLoaded(1);
await flush();
harness.emit({
kind: "chatMessage",
roomId: 1,
message: message({ id: 5 }),
});
harness.emit({
kind: "chatMessage",
roomId: 1,
message: message({ id: 3 }),
});
expect(
client
.getSnapshot()
.messagesByRoomId.get(1)
?.map((m) => m.id),
).toEqual([3, 5]);
});
});

View File

@@ -0,0 +1,454 @@
import type { ServerEvent } from "~/features/events/events-types";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { logger } from "~/utils/logger";
import { eventsClient } from "../events/events-client";
import {
CHAT_ROOMS_DATA_ROUTE,
chatMarkReadRoute,
chatRoomMessagesDataRoute,
chatSendMessageRoute,
} from "./chat-constants";
import type {
ChatMessageAuthor,
ChatMessageWithAuthor,
ChatRoomListItem,
ClientChatMessage,
} from "./chat-types";
const READ_DEBOUNCE_MS = 1_500;
interface ChatClientDeps {
fetchRooms: () => Promise<{ rooms: ChatRoomListItem[] } | null>;
fetchMessages: (
roomId: number,
) => Promise<{ messages: ChatMessageWithAuthor[] } | null>;
/** Plain fetch on purpose: a router fetcher submission would revalidate the page's loaders, which a chat send must never do. Null on failure. */
postMessage: (
roomId: number,
message: { publicId: string; contents: string },
) => Promise<{ message: ChatMessageWithAuthor } | null>;
postRead: (roomId: number, lastSeenMessageId: number) => Promise<void>;
addServerEventListener: (
listener: (event: ServerEvent) => void,
) => () => void;
readDebounceMs?: number;
}
export interface ChatSnapshot {
/** False until the first rooms fetch has landed. */
roomsLoaded: boolean;
rooms: ChatRoomListItem[];
totalUnreadCount: number;
/** Loaded histories, oldest first, optimistic pending sends last. Absent key = history not fetched yet. */
messagesByRoomId: ReadonlyMap<number, ClientChatMessage[]>;
}
export interface ChatClient {
/** Starts listening to server events and fetches the room list. */
start: (ownUserId: number) => void;
/** Stops event handling and resets all held data. */
stop: () => void;
getSnapshot: () => ChatSnapshot;
/** Subscribes to snapshot changes, for `useSyncExternalStore`. Returns an unsubscribe function. */
subscribe: (listener: () => void) => () => void;
refreshRooms: () => Promise<void>;
/** Fetches the room's history unless it is already loaded or loading. */
ensureMessagesLoaded: (roomId: number) => void;
/** Reconnect catch-up: refetches the room list and every loaded history. */
catchUp: () => void;
/** Appends an optimistic pending message and POSTs the send; the pending row is replaced by the SSE echo or the POST response (whichever lands first), and removed if the send fails. */
send: (
roomId: number,
message: { publicId: string; contents: string; author: ChatMessageAuthor },
) => void;
/** Zeroes the room's unread count and debounces the read-indicator POST. */
markRead: (roomId: number) => void;
/** Rooms the user has on screen right now: incoming messages there are read immediately instead of counting unread. */
setViewedRoomIds: (roomIds: number[]) => void;
}
export function createChatClient(deps: ChatClientDeps): ChatClient {
const readDebounceMs = deps.readDebounceMs ?? READ_DEBOUNCE_MS;
const listeners = new Set<() => void>();
let ownUserId: number | null = null;
let removeEventListener: (() => void) | null = null;
let roomsLoaded = false;
let rooms: ChatRoomListItem[] = [];
let messagesByRoomId = new Map<number, ClientChatMessage[]>();
let viewedRoomIds = new Set<number>();
let snapshot: ChatSnapshot | null = null;
let roomsRefreshInflight: Promise<void> | null = null;
const loadingMessageRoomIds = new Set<number>();
/** Newest message id already marked read locally per room, so refetches can't resurrect stale unread counts. */
const locallyReadByRoomId = new Map<number, number>();
const readTimers = new Map<number, ReturnType<typeof setTimeout>>();
const notify = () => {
snapshot = null;
for (const listener of listeners) {
listener();
}
};
const roomById = (roomId: number) => rooms.find((room) => room.id === roomId);
const setRoom = (roomId: number, patch: Partial<ChatRoomListItem>) => {
rooms = rooms.map((room) =>
room.id === roomId ? { ...room, ...patch } : room,
);
};
const setMessages = (roomId: number, messages: ClientChatMessage[]) => {
messagesByRoomId = new Map(messagesByRoomId);
messagesByRoomId.set(roomId, messages);
};
/** Inserts a persisted message into a loaded history, replacing a pending send or older copy with the same `publicId`. */
const insertPersisted = (message: ChatMessageWithAuthor) => {
const existing = messagesByRoomId.get(message.roomId);
if (!existing) return;
const replaceIndex = existing.findIndex(
(candidate) => candidate.publicId === message.publicId,
);
if (replaceIndex !== -1) {
const updated = [...existing];
updated[replaceIndex] = message;
setMessages(message.roomId, sortedMessages(updated));
return;
}
setMessages(message.roomId, sortedMessages([...existing, message]));
};
const flushRead = (roomId: number) => {
const timer = readTimers.get(roomId);
if (timer) {
clearTimeout(timer);
readTimers.delete(roomId);
}
const lastSeenMessageId = locallyReadByRoomId.get(roomId);
if (!lastSeenMessageId) return;
deps.postRead(roomId, lastSeenMessageId).catch((error) => {
logger.error("Posting chat read indicator failed", error);
});
};
const markRead = (roomId: number) => {
const room = roomById(roomId);
const latestLoadedId = messagesByRoomId
.get(roomId)
?.findLast((message) => !message.pending)?.id;
const targetId = latestLoadedId ?? room?.latestMessageId ?? null;
if (room && room.unreadCount !== 0) {
setRoom(roomId, { unreadCount: 0 });
notify();
}
if (targetId === null || (locallyReadByRoomId.get(roomId) ?? 0) >= targetId)
return;
locallyReadByRoomId.set(roomId, targetId);
if (!readTimers.has(roomId)) {
readTimers.set(
roomId,
setTimeout(() => flushRead(roomId), readDebounceMs),
);
}
};
const handleIncomingMessage = (message: ChatMessageWithAuthor) => {
const room = roomById(message.roomId);
if (!room) {
// e.g. a first message right after a room was created; the refetched
// list includes the new room and its unread count
if (roomsLoaded) void refreshRooms();
return;
}
insertPersisted(message);
const isOwn = message.authorUserId === ownUserId;
const patch: Partial<ChatRoomListItem> = {
latestMessageId: Math.max(room.latestMessageId ?? 0, message.id),
latestMessageAt: Math.max(room.latestMessageAt ?? 0, message.createdAt),
};
if (!isOwn && !viewedRoomIds.has(message.roomId)) {
patch.unreadCount = room.unreadCount + 1;
}
setRoom(message.roomId, patch);
notify();
if (viewedRoomIds.has(message.roomId)) {
markRead(message.roomId);
}
// a system message accompanies an owner state change (a confirmed score
// concludes the match, a leaver shrinks the roster) — refetch so inactive
// flags and titles track it
if (message.type !== null) {
void refreshRooms();
}
};
const handleEvent = (event: ServerEvent) => {
if (event.kind === "chatMessage") {
handleIncomingMessage(event.message);
} else if (event.kind === "roomsChanged") {
void refreshRooms();
}
};
const refreshRooms = () => {
if (roomsRefreshInflight) return roomsRefreshInflight;
roomsRefreshInflight = (async () => {
try {
const data = await deps.fetchRooms();
if (!data) return;
rooms = data.rooms.map((room) => {
const readUpTo = locallyReadByRoomId.get(room.id) ?? 0;
// a locally-read room stays read even when the server response
// raced the debounced read POST
if (
room.unreadCount > 0 &&
room.latestMessageId !== null &&
readUpTo >= room.latestMessageId
) {
return { ...room, unreadCount: 0 };
}
return room;
});
pruneLostRooms();
roomsLoaded = true;
notify();
} catch (error) {
logger.error("Fetching chat rooms failed", error);
} finally {
roomsRefreshInflight = null;
}
})();
return roomsRefreshInflight;
};
/** A held history whose room is no longer in the list belongs to a room the user lost access to (e.g. left the group); drop the local copy. */
const pruneLostRooms = () => {
const keptRoomIds = new Set(rooms.map((room) => room.id));
const lostRoomIds = [...messagesByRoomId.keys()].filter(
(roomId) => !keptRoomIds.has(roomId),
);
if (lostRoomIds.length === 0) return;
messagesByRoomId = new Map(messagesByRoomId);
for (const roomId of lostRoomIds) {
messagesByRoomId.delete(roomId);
locallyReadByRoomId.delete(roomId);
const timer = readTimers.get(roomId);
if (timer) {
clearTimeout(timer);
readTimers.delete(roomId);
}
}
};
const loadMessages = async (roomId: number) => {
if (loadingMessageRoomIds.has(roomId)) return;
loadingMessageRoomIds.add(roomId);
try {
const data = await deps.fetchMessages(roomId);
if (!data) return;
// keep optimistic sends that were appended while the fetch was in flight
const pending = (messagesByRoomId.get(roomId) ?? []).filter(
(message) =>
message.pending &&
!data.messages.some(
(fetched) => fetched.publicId === message.publicId,
),
);
setMessages(roomId, [...data.messages, ...pending]);
notify();
if (viewedRoomIds.has(roomId)) {
markRead(roomId);
}
} catch (error) {
logger.error("Fetching chat messages failed", error);
} finally {
loadingMessageRoomIds.delete(roomId);
}
};
return {
start: (userId) => {
if (removeEventListener) return;
ownUserId = userId;
removeEventListener = deps.addServerEventListener(handleEvent);
void refreshRooms();
},
stop: () => {
removeEventListener?.();
removeEventListener = null;
for (const roomId of readTimers.keys()) {
flushRead(roomId);
}
ownUserId = null;
roomsLoaded = false;
rooms = [];
messagesByRoomId = new Map();
viewedRoomIds = new Set();
locallyReadByRoomId.clear();
notify();
},
getSnapshot: () => {
snapshot ??= {
roomsLoaded,
rooms,
totalUnreadCount: rooms.reduce(
(sum, room) => sum + room.unreadCount,
0,
),
messagesByRoomId,
};
return snapshot;
},
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
refreshRooms,
ensureMessagesLoaded: (roomId) => {
if (messagesByRoomId.has(roomId)) return;
void loadMessages(roomId);
},
catchUp: () => {
void refreshRooms();
for (const roomId of messagesByRoomId.keys()) {
void loadMessages(roomId);
}
},
send: (roomId, { publicId, contents, author }) => {
const existing = messagesByRoomId.get(roomId) ?? [];
setMessages(roomId, [
...existing,
{
id: 0,
roomId,
authorUserId: author.id,
type: null,
contents,
publicId,
createdAt: dateToDatabaseTimestamp(new Date()),
author,
pending: true,
},
]);
notify();
void deps
.postMessage(roomId, { publicId, contents })
.catch((error) => {
logger.error("Sending chat message failed", error);
return null;
})
.then((data) => {
if (data) {
// usually the SSE echo lands first; both reconcile by publicId
insertPersisted(data.message);
const room = roomById(roomId);
if (room) {
setRoom(roomId, {
latestMessageId: Math.max(
room.latestMessageId ?? 0,
data.message.id,
),
latestMessageAt: Math.max(
room.latestMessageAt ?? 0,
data.message.createdAt,
),
});
}
notify();
return;
}
// a failed send stuck at pending forever would read as delivered
const messages = messagesByRoomId.get(roomId);
if (!messages) return;
const withoutFailed = messages.filter(
(message) => !(message.pending && message.publicId === publicId),
);
if (withoutFailed.length !== messages.length) {
setMessages(roomId, withoutFailed);
notify();
}
});
},
markRead,
setViewedRoomIds: (roomIds) => {
const previous = viewedRoomIds;
viewedRoomIds = new Set(roomIds);
for (const roomId of viewedRoomIds) {
if (!previous.has(roomId)) {
markRead(roomId);
}
}
},
};
}
/** Persisted messages by id ascending, optimistic pending sends after them in send order. */
function sortedMessages(messages: ClientChatMessage[]): ClientChatMessage[] {
const persisted = messages.filter((message) => !message.pending);
const pending = messages.filter((message) => message.pending);
persisted.sort((a, b) => a.id - b.id);
return [...persisted, ...pending];
}
const fetchJson = async <T>(url: string): Promise<T | null> => {
const response = await fetch(url);
if (!response.ok) {
logger.error(`Chat fetch failed (${response.status}): ${url}`);
return null;
}
return (await response.json()) as T;
};
export const chatClient = createChatClient({
fetchRooms: () => fetchJson(CHAT_ROOMS_DATA_ROUTE),
fetchMessages: (roomId) => fetchJson(chatRoomMessagesDataRoute(roomId)),
postMessage: async (roomId, message) => {
const response = await fetch(chatSendMessageRoute(roomId), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(message),
});
if (!response.ok) {
logger.error(`Sending chat message failed (${response.status})`);
return null;
}
const data = (await response.json()) as {
message?: ChatMessageWithAuthor;
};
return data.message ? { message: data.message } : null;
},
postRead: async (roomId, lastSeenMessageId) => {
await fetch(chatMarkReadRoute(roomId), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ lastSeenMessageId }),
});
},
addServerEventListener: (listener) => eventsClient.addEventListener(listener),
});

View File

@@ -1,5 +1,19 @@
export const MESSAGE_MAX_LENGTH = 200;
export const CHAT_ROOMS_DATA_ROUTE = "/api/chat/rooms";
export function chatRoomMessagesDataRoute(roomId: number) {
return `/api/chat/rooms/${roomId}/messages`;
}
export function chatSendMessageRoute(roomId: number) {
return `/chat/${roomId}/messages`;
}
export function chatMarkReadRoute(roomId: number) {
return `/chat/${roomId}/read`;
}
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;

View File

@@ -7,7 +7,7 @@ import {
useServerEventListener,
} from "~/features/events/events-hooks";
import { useUser } from "../auth/core/user";
import type { ChatMessage } from "./chat-types";
import type { ClientChatMessage } from "./chat-types";
import { playMessageSound } from "./chat-utils";
import { scheduleBroadcastRevalidation } from "./revalidation-scope";
@@ -21,7 +21,7 @@ const EVENTS_DOWN_CATCH_UP_MS = 2 * 60 * 1000;
const USER_SCROLL_INTENT_MS = 150;
export function useChatAutoScroll(
messages: ChatMessage[],
messages: ClientChatMessage[],
ref: React.RefObject<HTMLElement | null>,
) {
const user = useUser();
@@ -132,18 +132,19 @@ export function useChatAutoScroll(
}, [ref, hasMessages]);
const latestMessage = messages.at(-1);
const latestMessageId = latestMessage?.id;
const latestMessageIsOwn = user != null && latestMessage?.userId === user.id;
const latestMessagePublicId = latestMessage?.publicId;
const latestMessageIsOwn =
user != null && latestMessage?.authorUserId === user.id;
React.useEffect(() => {
if (!latestMessageId) return;
if (!latestMessagePublicId) return;
if (latestMessageIsOwn || pinnedToBottomRef.current) {
scrollToBottom();
} else {
setUnseenMessages(true);
}
}, [latestMessageId, latestMessageIsOwn, scrollToBottom]);
}, [latestMessagePublicId, latestMessageIsOwn, scrollToBottom]);
const reset = () => {
pinnedToBottomRef.current = true;

View File

@@ -1,20 +0,0 @@
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

@@ -1,23 +0,0 @@
import * as v from "valibot";
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: v.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

@@ -1,62 +1,29 @@
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;
}
import type { ChatRoomListItem, ClientChatMessage } from "./chat-types";
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;
requestHistory: (chatCode: string) => void;
markAsRead: (chatCode: string) => void;
unreadCounts: Record<string, number>;
/** False until the first rooms fetch has landed. */
roomsLoaded: boolean;
rooms: ChatRoomListItem[];
messagesForRoom: (roomId: number) => ClientChatMessage[];
/** Fetches the room's history unless it is already loaded or loading. */
ensureMessagesLoaded: (roomId: number) => void;
/** Sends the message outside the router (no revalidation), rendering it optimistically until the echo or POST response confirms it. */
sendMessage: (
roomId: number,
message: { publicId: string; contents: string },
) => void;
markAsRead: (roomId: number) => void;
totalUnreadCount: number;
readyState: "CONNECTING" | "CONNECTED" | "CLOSED";
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).
* Rooms the user is currently viewing. Empty means none are open, one
* renders a single chat, more than one renders the split view. The first room
* is the primary one (shown on top / given the larger share in split view).
*/
activeRooms: string[];
setActiveRooms: (chatCodes: string[]) => void;
activeRoomIds: number[];
setActiveRoomIds: (roomIds: number[]) => void;
/** Role labels (e.g. "TO", "Caster") shown next to non-participant authors, keyed by user id. */
chatLabels: Record<number, string>;
setChatLabels: (labels: Record<number, string>) => void;
clearChatLabels: () => void;

View File

@@ -5,5 +5,8 @@ import { MESSAGE_MAX_LENGTH } from "./chat-constants";
export const sendChatMessageSchema = v.object({
publicId: hidden(v.pipe(v.string(), v.length(SHORT_NANOID_LENGTH))),
contents: textField({ maxLength: MESSAGE_MAX_LENGTH }),
contents: textField({
maxLength: MESSAGE_MAX_LENGTH,
placeholder: "placeholders.chatMessage",
}),
});

View File

@@ -23,10 +23,6 @@ export type SystemMessageType =
| "MAP_REPLAYED"
| "MAP_PICKED";
export type SystemMessageContext = {
name: string;
};
export type PersistedSystemMessageType = Extract<
SystemMessageType,
| "SCORE_REPORTED"
@@ -44,6 +40,18 @@ export type SoundOnlySystemMessageType = Extract<
"NEW_GROUP" | "MATCH_STARTED" | "READY_CHECK_STARTED" | "LIKE_RECEIVED"
>;
// xxx: extend CommonUser
export interface ChatMessageAuthor {
id: number;
username: string;
discordId: string;
discordAvatar: string | null;
customUrl: string | null;
customAvatarUrl: string | null;
pronouns: Tables["User"]["pronouns"];
chatNameHue: string | null;
}
export interface ChatMessageWithAuthor {
id: number;
roomId: number;
@@ -51,43 +59,33 @@ export interface ChatMessageWithAuthor {
type: PersistedSystemMessageType | null;
contents: string | null;
publicId: string;
/** databaseTimestamp */
createdAt: number;
author: ChatUser | null;
author: ChatMessageAuthor | null;
}
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;
/** A message as held client-side: a persisted row, or an optimistic send awaiting its echo. */
export interface ClientChatMessage extends ChatMessageWithAuthor {
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;
/** One room of the user's room list as served by `GET /api/chat/rooms`. */
export interface ChatRoomListItem {
id: number;
type: ChatRoomType;
/** Interpolation values for the client-localized room title, keyed per room type. */
titleParams: Record<string, string>;
url: string;
imageUrl: string | null;
participantUserIds: number[];
/** databaseTimestamp */
expiresAt: number;
/** Whether the owner's activity has concluded (e.g. the match was finalized). */
inactive: boolean;
unreadCount: number;
latestMessageId: number | null;
/** databaseTimestamp */
latestMessageAt: number | null;
}
export type RevalidateScope = "MATCH_RESULTS";

View File

@@ -1,62 +1,5 @@
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);
});
});
import { datePlaceholder } from "./chat-utils";
describe("datePlaceholder", () => {
test("returns correctly formatted placeholder string", () => {
@@ -65,37 +8,3 @@ describe("datePlaceholder", () => {
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

@@ -1,40 +1,12 @@
import { differenceInDays } from "date-fns";
import { logger } from "~/utils/logger";
import { soundPath } from "~/utils/urls";
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;
import type { SystemMessageType } from "./chat-types";
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"]) {
export function messageTypeToSound(type: SystemMessageType | undefined) {
if (type === "LIKE_RECEIVED") return "sq_like";
if (type === "MATCH_STARTED") return "sq_match";
if (type === "READY_CHECK_STARTED") return "sq_ready-check";
@@ -54,7 +26,7 @@ export function soundEnabled(soundCode: string) {
return !soundEnabled || soundEnabled === "true";
}
export function playMessageSound(type: ChatMessage["type"]) {
export function playMessageSound(type: SystemMessageType | undefined) {
const sound = messageTypeToSound(type);
if (!sound || !soundEnabled(sound)) return;

View File

@@ -2,60 +2,48 @@ 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";
import type { ChatMessageAuthor, ClientChatMessage } from "../chat-types";
import { Chat } 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,
},
const ALICE: ChatMessageAuthor = {
id: 1,
username: "Alice",
discordId: "1",
discordAvatar: null,
customUrl: null,
customAvatarUrl: null,
pronouns: null,
chatNameHue: null,
};
function createMessage(overrides: Partial<ChatMessage> = {}): ChatMessage {
function createMessage(
overrides: Partial<ClientChatMessage> = {},
): ClientChatMessage {
return {
id: "1",
userId: 1,
id: 1,
roomId: 1,
authorUserId: 1,
type: null,
contents: "Hello world",
timestamp: 1700000000000,
room: "room",
publicId: "publicid-1",
createdAt: 1700000000,
author: ALICE,
...overrides,
};
}
function renderChat(
messages: ChatMessage[],
props?: { missingUserName?: string },
) {
const chat: ChatAdapter = {
messages,
send: () => {},
currentRoom: "room",
setCurrentRoom: () => {},
readyState: "CONNECTED",
unseenMessages: new Map(),
};
function renderChat(messages: ClientChatMessage[]) {
const router = createMemoryRouter(
[
{
path: "/",
element: (
<div style={{ width: 400 }}>
<Chat
users={USERS}
rooms={[]}
chat={chat}
missingUserName={props?.missingUserName}
/>
<Chat messages={messages} onSend={() => {}} />
</div>
),
},
@@ -66,27 +54,18 @@ function renderChat(
return render(<RouterProvider router={router} />);
}
async function renderChatWithControls(initialMessages: ChatMessage[]) {
async function renderChatWithControls(initialMessages: ClientChatMessage[]) {
const controls = {
addMessage: (_msg: ChatMessage) => {},
addMessage: (_msg: ClientChatMessage) => {},
};
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} />
<Chat messages={messages} onSend={() => {}} />
</div>
);
}
@@ -100,7 +79,11 @@ async function renderChatWithControls(initialMessages: ChatMessage[]) {
function manyMessages(count: number) {
return Array.from({ length: count }, (_, i) =>
createMessage({ id: String(i + 1), contents: `Message ${i + 1}` }),
createMessage({
id: i + 1,
publicId: `publicid-${i + 1}`,
contents: `Message ${i + 1}`,
}),
);
}
@@ -111,8 +94,16 @@ function isScrolledToBottom(element: HTMLElement) {
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" }),
createMessage({
id: 1,
publicId: "publicid-1",
contents: "First message",
}),
createMessage({
id: 2,
publicId: "publicid-2",
contents: "Second message",
}),
]);
await expect.element(screen.getByRole("listbox")).toBeInTheDocument();
@@ -124,11 +115,7 @@ describe("Chat", () => {
});
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 screen = await renderChat(manyMessages(100));
const listbox = screen.getByRole("listbox").element() as HTMLElement;
await expect.element(screen.getByRole("listbox")).toBeInTheDocument();
@@ -147,14 +134,12 @@ describe("Chat", () => {
);
});
test("renders system messages", async () => {
test("renders system messages with the author interpolated", async () => {
const screen = await renderChat([
createMessage({
id: "1",
type: "USER_LEFT",
contents: undefined,
userId: undefined,
context: { name: "Bob" },
contents: null,
author: { ...ALICE, username: "Bob" },
}),
]);
@@ -163,23 +148,13 @@ describe("Chat", () => {
.toBeInTheDocument();
});
test("skips messages with an unknown user when no fallback name is given", async () => {
test("renders a deleted account's message with a fallback name", async () => {
const screen = await renderChat([
createMessage({ id: "1", userId: 999, contents: "Ghost message" }),
createMessage({ authorUserId: null, author: null, contents: "Ghost" }),
]);
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();
await expect.element(screen.getByText("Ghost")).toBeInTheDocument();
await expect.element(screen.getByText("???")).toBeInTheDocument();
});
test("scrolls to the bottom on initial load", async () => {
@@ -205,7 +180,8 @@ describe("Chat", () => {
controls.addMessage(
createMessage({
id: "new",
id: 51,
publicId: "publicid-new",
contents:
"A brand new message that is long enough to wrap onto multiple lines in the chat window",
}),
@@ -235,7 +211,13 @@ describe("Chat", () => {
expect(element.scrollTop).toBe(0);
});
controls.addMessage(createMessage({ id: "new", contents: "While away" }));
controls.addMessage(
createMessage({
id: 51,
publicId: "publicid-new",
contents: "While away",
}),
);
await expect.element(screen.getByText("New messages")).toBeInTheDocument();
expect(isScrolledToBottom(element)).toBe(false);
@@ -266,7 +248,13 @@ describe("Chat", () => {
await new Promise((resolve) => setTimeout(resolve, 300));
controls.addMessage(createMessage({ id: "new", contents: "While away" }));
controls.addMessage(
createMessage({
id: 51,
publicId: "publicid-new",
contents: "While away",
}),
);
await expect.element(screen.getByText("New messages")).toBeInTheDocument();
await new Promise((resolve) => setTimeout(resolve, 300));

View File

@@ -83,11 +83,6 @@
.inputContainer {
margin-top: auto;
position: relative;
& > form {
padding: var(--s-2);
border-top: 1.5px solid var(--color-border);
}
}
.sendButton.sendButton {
@@ -130,43 +125,24 @@
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);
.composer {
display: flex;
width: auto;
align-items: center;
justify-content: center;
font-weight: var(--weight-bold);
flex: 1 1 0px;
flex-direction: column;
gap: var(--s-1);
padding: var(--s-2);
border-top: 1.5px solid var(--color-border);
}
.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 {
.composerRow {
display: flex;
justify-content: space-between;
align-items: center;
margin-block-start: var(--s-2);
gap: var(--s-1);
/* the contents field wrapper takes the row's free space */
& > div:first-child {
flex: 1;
min-width: 0;
}
}
.unseenMessages {

View File

@@ -4,20 +4,29 @@ 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 { useLocation } from "react-router";
import { useEventsReadyState } from "~/features/events/events-hooks";
import {
type FormRenderProps,
SendouForm,
useFormValue,
} from "~/form/SendouForm";
import { databaseTimestampToDate } from "~/utils/dates";
import { shortNanoid } from "~/utils/id";
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 { findRoomLinks } from "../chat-constants";
import { useChatAutoScroll } from "../chat-hooks";
import type { ChatMessage, ChatProps, ChatUser } from "../chat-types";
import { sendChatMessageSchema } from "../chat-schemas";
import type { ChatMessageAuthor, ClientChatMessage } from "../chat-types";
import styles from "./Chat.module.css";
const MESSAGE_GAP = 8;
@@ -27,89 +36,63 @@ const VIRTUALIZER_LAYOUT_OPTIONS = {
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 interface ChatProps {
messages: ClientChatMessage[];
/** Hands a validated composer send to the chat client (optimistic append + POST). */
onSend: (message: { publicId: string; contents: string }) => void;
/** Role labels (e.g. "TO") shown next to the author, keyed by user id. */
labelByUserId?: Record<number, string>;
className?: string;
messagesContainerClassName?: string;
/** Renders the room read-only, e.g. once it has expired. */
disabled?: boolean;
}
// xxx: message to inactive chat? how does it show
export function Chat({
users,
rooms,
messages,
onSend,
labelByUserId,
className,
messagesContainerClassName,
hidden = false,
chat,
disabled,
missingUserName,
}: Omit<ChatProps, "onNewMessage" | "revalidates"> & {
chat: ChatAdapter;
}) {
}: ChatProps) {
const { t } = useTranslation(["common"]);
const messagesContainerRef = React.useRef<HTMLDivElement>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const {
send,
const { unseenMessagesInTheRoom, scrollToBottom } = useChatAutoScroll(
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],
messagesContainerRef,
);
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;
};
const systemMessageText = (msg: ClientChatMessage) => {
const name = msg.author?.username ?? "";
switch (msg.type) {
case "SCORE_REPORTED": {
return t("common:chat.systemMsg.scoreReported", { name: name() });
return t("common:chat.systemMsg.scoreReported", { name });
}
case "SCORE_CONFIRMED": {
return t("common:chat.systemMsg.scoreConfirmed", { name: name() });
return t("common:chat.systemMsg.scoreConfirmed", { name });
}
case "CANCEL_REPORTED": {
return t("common:chat.systemMsg.cancelReported", { name: name() });
return t("common:chat.systemMsg.cancelReported", { name });
}
case "CANCEL_CONFIRMED": {
return t("common:chat.systemMsg.cancelConfirmed", { name: name() });
return t("common:chat.systemMsg.cancelConfirmed", { name });
}
case "CANCEL_REFUSED": {
return t("common:chat.systemMsg.cancelRefused", { name: name() });
return t("common:chat.systemMsg.cancelRefused", { name });
}
case "USER_LEFT": {
return t("common:chat.systemMsg.userLeft", { name: name() });
return t("common:chat.systemMsg.userLeft", { name });
}
case "MAP_REPLAYED": {
return t("common:chat.systemMsg.mapReplayed", { name: name() });
return t("common:chat.systemMsg.mapReplayed", { name });
}
case "MAP_PICKED": {
return t("common:chat.systemMsg.mapPicked", { name: name() });
return t("common:chat.systemMsg.mapPicked", { name });
}
default: {
return null;
@@ -117,45 +100,8 @@ export function Chat({
}
};
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}
<section className={clsx(styles.container, className)}>
<div className={styles.inputContainer}>
<Virtualizer
layout={ListLayout}
@@ -165,7 +111,7 @@ export function Chat({
ref={messagesContainerRef}
aria-label="Chat messages"
selectionMode="none"
items={renderableMessages}
items={messages}
className={clsx(
styles.messages,
"scrollbar",
@@ -178,13 +124,14 @@ export function Chat({
return <SystemMessage message={msg} text={systemMessage} />;
}
const user = msg.userId ? users[msg.userId] : null;
return (
<Message
user={user}
missingUserName={missingUserName}
message={msg}
label={
msg.authorUserId != null
? labelByUserId?.[msg.authorUserId]
: undefined
}
/>
);
}}
@@ -203,68 +150,133 @@ export function Chat({
{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>
<Composer onSend={onSend} />
)}
</div>
</section>
);
}
function Message({
user,
message,
missingUserName,
function Composer({ onSend }: { onSend: ChatProps["onSend"] }) {
const { t } = useTranslation(["common"]);
const { pathname } = useLocation();
const readyState = useEventsReadyState();
const [publicId, setPublicId] = React.useState(() => shortNanoid());
const [hasSent, setHasSent] = React.useState(false);
// don't let a send's autofocus carry over to an unrelated page
React.useEffect(() => {
setHasSent(false);
}, [pathname]);
const sendingDisabled = readyState !== "CONNECTED";
return (
<SendouForm
key={publicId}
schema={sendChatMessageSchema}
defaultValues={{ publicId }}
className={styles.composer}
hideSubmitButton
guardUnsavedChanges={false}
// `onApply` bypasses the router: chat-client POSTs the message itself,
// so sending never revalidates the page's loaders
onApply={(values) => {
onSend(values);
setPublicId(shortNanoid());
setHasSent(true);
}}
>
{({ FormField }) => (
<>
{readyState !== "CONNECTED" ? (
<div
className={clsx(
"text-xxs font-semi-bold",
readyState === "CONNECTING" ? "text-lighter" : "text-warning",
)}
>
{t(
readyState === "CONNECTING"
? "common:chat.connecting"
: "common:chat.disconnected",
)}
</div>
) : null}
<ComposerRow
FormField={FormField}
sendingDisabled={sendingDisabled}
hasSent={hasSent}
/>
</>
)}
</SendouForm>
);
}
function ComposerRow({
FormField,
sendingDisabled,
hasSent,
}: {
user?: ChatUser | null;
message: ChatMessage;
missingUserName?: string;
FormField: FormRenderProps<typeof sendChatMessageSchema.entries>["FormField"];
sendingDisabled: boolean;
hasSent: boolean;
}) {
const { t } = useTranslation(["common"]);
const contents = useFormValue("contents");
const isEmpty = typeof contents !== "string" || contents.trim().length === 0;
return (
<div
className={styles.composerRow}
// an empty send is a no-op, not a validation error
onKeyDownCapture={(event) => {
if (event.key === "Enter" && isEmpty) {
event.preventDefault();
}
}}
>
<FormField
name="contents"
disabled={sendingDisabled}
autoFocus={hasSent}
/>
<SubmitButton
className={styles.sendButton}
size="small"
isDisabled={sendingDisabled || isEmpty}
aria-label={t("common:chat.send")}
icon={<SendHorizontal size={16} />}
testId="chat-submit-button"
/>
</div>
);
}
function Message({
message,
label,
}: {
message: ClientChatMessage;
label?: string;
}) {
const author = message.author;
return (
<ListBoxItem
id={message.publicId}
className={styles.message}
textValue={message.contents ?? user?.username ?? missingUserName ?? ""}
textValue={message.contents ?? author?.username ?? "???"}
>
{user ? (
{author ? (
<div
className={clsx(styles.avatarWrapper, {
[styles.avatarWrapperStaff]: user.title,
[styles.avatarWrapperStaff]: label,
})}
>
<Avatar user={user} size="xs" />
{user.title ? (
<span className={styles.avatarBadge}>{user.title}</span>
) : null}
<Avatar user={author} size="xs" />
{label ? <span className={styles.avatarBadge}>{label}</span> : null}
</div>
) : null}
<div>
@@ -272,18 +284,16 @@ function Message({
<div
className={styles.messageUser}
style={
user?.chatNameHue ? { "--chat-hue": user.chatNameHue } : undefined
author?.chatNameHue
? { "--chat-hue": author.chatNameHue }
: undefined
}
>
{user?.username ?? missingUserName}
{author?.username ?? "???"}
</div>
{user?.pronouns ? (
<span className={styles.pronounsTag}>
{user.pronouns.subject}/{user.pronouns.object}
</span>
) : null}
<PronounsTag author={author} />
{!message.pending ? (
<MessageTimestamp timestamp={message.timestamp} />
<MessageTimestamp createdAt={message.createdAt} />
) : null}
</div>
<div
@@ -300,18 +310,32 @@ function Message({
);
}
function PronounsTag({ author }: { author: ChatMessageAuthor | null }) {
if (!author?.pronouns) return null;
return (
<span className={styles.pronounsTag}>
{author.pronouns.subject}/{author.pronouns.object}
</span>
);
}
function SystemMessage({
message,
text,
}: {
message: ChatMessage;
message: ClientChatMessage;
text: string;
}) {
return (
<ListBoxItem className={styles.message} textValue={text}>
<ListBoxItem
id={message.publicId}
className={styles.message}
textValue={text}
>
<div>
<div className="stack horizontal sm">
<MessageTimestamp timestamp={message.timestamp} />
<MessageTimestamp createdAt={message.createdAt} />
</div>
<div
className={clsx(
@@ -361,7 +385,7 @@ function MessageContents({ text }: { text: string }) {
return <>{parts}</>;
}
function MessageTimestamp({ timestamp }: { timestamp: number }) {
function MessageTimestamp({ createdAt }: { createdAt: number }) {
const { formatter: dateTimeFormatter } = useDateTimeFormat({
day: "numeric",
month: "numeric",
@@ -372,13 +396,14 @@ function MessageTimestamp({ timestamp }: { timestamp: number }) {
hour: "numeric",
minute: "numeric",
});
const moreThanDayAgo = sub(new Date(), { days: 1 }) > new Date(timestamp);
const date = databaseTimestampToDate(createdAt);
const moreThanDayAgo = sub(new Date(), { days: 1 }) > date;
return (
<time className={styles.messageTime}>
{moreThanDayAgo
? dateTimeFormatter.format(new Date(timestamp))
: timeFormatter.format(new Date(timestamp))}
? dateTimeFormatter.format(date)
: timeFormatter.format(date)}
</time>
);
}

View File

@@ -1,15 +1,17 @@
import type { ChatMessage } from "./chat-types";
import type { RevalidateScope, SystemMessageType } from "./chat-types";
import { messageTypeToSound } from "./chat-utils";
type ThrottleableMessage = Pick<
ChatMessage,
"room" | "type" | "revalidateOnly" | "revalidateScope"
>;
interface ThrottleableMessage {
room: string;
type?: SystemMessageType;
revalidateOnly?: boolean;
revalidateScope?: RevalidateScope;
}
interface ThrottleEntry {
lastSentAt: number;
trailing: {
scope: ChatMessage["revalidateScope"];
scope: RevalidateScope | undefined;
timer: ReturnType<typeof setTimeout>;
} | null;
}
@@ -46,7 +48,7 @@ export function createRevalidateBroadcastThrottle({
/** Delivers the coalesced trailing broadcast of a window. */
sendTrailing: (msg: {
room: string;
revalidateScope: ChatMessage["revalidateScope"];
revalidateScope: RevalidateScope | undefined;
}) => void;
}) {
const entries = new Map<string, ThrottleEntry>();
@@ -65,7 +67,9 @@ export function createRevalidateBroadcastThrottle({
* 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 {
throttles(
msg: Pick<ThrottleableMessage, "type" | "revalidateOnly">,
): boolean {
return Boolean(msg.revalidateOnly) && !messageTypeToSound(msg.type);
},
handle(msg: ThrottleableMessage): void {

View File

@@ -1,34 +1,40 @@
import { requireUser } from "~/features/auth/core/user.server";
import * as ChatRepository from "../ChatRepository.server";
import * as ChatRoomResolver from "../ChatRoomResolver.server";
import type { ChatRoomListItem } from "../chat-types";
/**
* The user's open chat rooms with server-computed unread counts. A background
* resource like the notifications peek: fetched after mount and refetched on
* `chatMessage` / `roomsChanged` events instead of riding any page loader.
*/
export const loader = async () => {
export const loader = async (): Promise<{ rooms: ChatRoomListItem[] }> => {
const user = requireUser();
const rooms = await ChatRoomResolver.findAllByUserId(user.id);
const unreadCounts = await ChatRepository.findUnreadCountsByRoomIds(
const messageStats = await ChatRepository.findMessageStatsByRoomIds(
user.id,
rooms.map((room) => room.roomId),
);
const unreadCountByRoomId = new Map(
unreadCounts.map((row) => [row.roomId, row.unreadCount]),
);
const statsByRoomId = new Map(messageStats.map((row) => [row.roomId, row]));
return {
rooms: rooms.map((room) => ({
id: room.roomId,
type: room.type,
titleParams: room.titleParams,
url: room.url,
imageUrl: room.imageUrl,
participantUserIds: room.participantUserIds,
expiresAt: room.expiresAt,
unreadCount: unreadCountByRoomId.get(room.roomId) ?? 0,
})),
rooms: rooms.map((room) => {
const stats = statsByRoomId.get(room.roomId);
return {
id: room.roomId,
type: room.type,
titleParams: room.titleParams,
url: room.url,
imageUrl: room.imageUrl,
participantUserIds: room.participantUserIds,
expiresAt: room.expiresAt,
inactive: room.inactive,
unreadCount: stats?.unreadCount ?? 0,
latestMessageId: stats?.latestMessageId ?? null,
latestMessageAt: stats?.latestMessageCreatedAt ?? null,
};
}),
};
};

View File

@@ -182,6 +182,24 @@ describe("chat rooms loader", () => {
expect(matchRoom?.participantUserIds).toHaveLength(8);
});
test("exposes the room's inactive flag and latest message stats", async () => {
const { match, alphaUserIds, bravoUserIds } = await setupSqMatch(users);
const message = await sendMessageOk(alphaUserIds[0], match.chatRoomId!, {
publicId: "oooooooooo",
contents: "hello",
});
await ChatRepository.updateRoomsInactive([match.chatRoomId], true);
const data = await loadRooms(bravoUserIds[0]);
const matchRoom = data.rooms.find((room) => room.id === match.chatRoomId);
expect(matchRoom).toMatchObject({
inactive: true,
latestMessageId: message.id,
latestMessageAt: message.createdAt,
});
});
test("does not count the sender's own message as unread on their other devices", async () => {
const { match, alphaUserIds } = await setupSqMatch(users);
await sendMessageOk(alphaUserIds[0], match.chatRoomId!, {

View File

@@ -1,17 +1,8 @@
import type { ServerEvent } from "../events-types";
export { chatRoomChannel, userChannel } from "../events-types";
export type { ServerEvent };
/** Channel delivering events addressed to the user across all of their connections. */
export function userChannel(userId: number): string {
return `user__${userId}`;
}
/** Channel delivering a chat room's events to its viewers. */
export function chatRoomChannel(roomId: number): string {
return `chat-room__${roomId}`;
}
interface Subscriber {
queue: ServerEvent[];
wake: (() => void) | null;

View File

@@ -4,6 +4,16 @@ import type {
SoundOnlySystemMessageType,
} from "~/features/chat/chat-types";
/** Channel delivering events addressed to the user across all of their connections. */
export function userChannel(userId: number): string {
return `user__${userId}`;
}
/** Channel delivering a chat room's events to its viewers. */
export function chatRoomChannel(roomId: number): string {
return `chat-room__${roomId}`;
}
export type ServerEvent =
| { kind: "chatMessage"; roomId: number; message: ChatMessageWithAuthor }
| {

View File

@@ -1,10 +1,7 @@
import type { LoaderFunctionArgs } from "react-router";
import { chatAccessible } from "~/features/chat/chat-utils";
import * as EventBus from "~/features/events/core/EventBus.server";
import { resolveNotifications } from "~/features/notifications/core/resolve.server";
import * as UserCardRepository from "~/features/user-card/UserCardRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { databaseTimestampToDate } from "~/utils/dates";
import { notFoundIfNullish } from "../../../utils/remix.server";
import {
type AuthenticatedUser,
@@ -55,16 +52,10 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
include: { friendCode: true },
})),
post,
chatCode:
(user.roles.includes("STAFF") || participantIds.includes(user.id)) &&
post.chatRoomId !== null &&
chatAccessible({
isStaff: user.roles.includes("STAFF"),
expiresAfterDays: 1,
comparedTo: databaseTimestampToDate(Scrim.getStartTime(post)),
})
? EventBus.chatRoomChannel(post.chatRoomId)
: undefined,
chatRoomIds:
participantIds.includes(user.id) && post.chatRoomId !== null
? [post.chatRoomId]
: [],
anyUserPrefersNoScreen,
mapByMap,
};

View File

@@ -250,6 +250,11 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
});
}
// non-continuing members lose the group room
ChatSystemMessage.notifyRoomsChanged(
viewerGroup.members.map((member) => member.id),
);
// The continuing group re-enters the looking pool, so refresh
// every looking client.
ChatSystemMessage.send({

View File

@@ -1,7 +1,5 @@
import type { LoaderFunctionArgs } from "react-router";
import { getUser } from "~/features/auth/core/user.server";
import { chatAccessible } from "~/features/chat/chat-utils";
import * as EventBus from "~/features/events/core/EventBus.server";
import * as Seasons from "~/features/mmr/core/Seasons";
import { resolveNotifications } from "~/features/notifications/core/resolve.server";
import * as ScannerIngestRepository from "~/features/scanner-ingest/ScannerIngestRepository.server";
@@ -9,7 +7,6 @@ import { SendouQ } from "~/features/sendouq/core/SendouQ.server";
import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server";
import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
import * as UserCardRepository from "~/features/user-card/UserCardRepository.server";
import { databaseTimestampToDate } from "~/utils/dates";
import type { SerializeFrom } from "~/utils/remix";
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
import { qMatchPageParamsSchema } from "../q-match-schemas";
@@ -56,33 +53,19 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
reportedWeapons,
ingestedScoreboards,
isOffSeason: Seasons.current() === null,
chatCode: (() => {
if (!(isStaff || isParticipant)) return null;
chatRoomIds: (() => {
// observers (staff) get room access through the moderation view instead
if (!user || !isParticipant) return [];
const accessible = chatAccessible({
isStaff,
expiresAfterDays: 1,
comparedTo: databaseTimestampToDate(matchUnmapped.createdAt),
});
if (!accessible) return null;
const ownGroup = matchUnmapped.groupAlpha.members.some(
(member) => member.id === user.id,
)
? match.groupAlpha
: match.groupBravo;
if (!isParticipant) {
return match.chatRoomId
? EventBus.chatRoomChannel(match.chatRoomId)
: null;
}
const codes = [
match.chatRoomId,
match.groupAlpha.chatRoomId,
match.groupBravo.chatRoomId,
]
.filter((id): id is number => typeof id === "number")
.map(EventBus.chatRoomChannel);
if (codes.length === 0) return null;
if (codes.length === 1) return codes[0];
return codes;
return [match.chatRoomId, ownGroup.chatRoomId].filter(
(id): id is number => typeof id === "number",
);
})(),
};
};

View File

@@ -43,7 +43,7 @@ export default function SendouQMatchPage() {
const data = useLoaderData<typeof loader>();
// the page's updates are broadcast to its chat room, subscribed to by the chat
// provider via the loader's chatCode rather than by a topic of our own
// provider via the loader's chatRoomIds rather than by a topic of our own
useLiveRevalidation();
return (

View File

@@ -171,6 +171,13 @@ export const action: ActionFunction = async ({ request }) => {
});
}
// both old rooms died and a fresh merged room was created
ChatSystemMessage.notifyRoomsChanged(
[...ourGroup.members, ...theirGroup.members].map(
(member) => member.id,
),
);
broadcastLookingUpdate();
break;
@@ -226,6 +233,10 @@ export const action: ActionFunction = async ({ request }) => {
});
}
ChatSystemMessage.notifyRoomsChanged(
currentGroup.members.map((member) => member.id),
);
broadcastLookingUpdate();
throw redirect(SENDOUQ_PAGE);
@@ -262,6 +273,10 @@ export const action: ActionFunction = async ({ request }) => {
});
}
ChatSystemMessage.notifyRoomsChanged(
currentGroup.members.map((member) => member.id),
);
broadcastLookingUpdate();
break;

View File

@@ -83,6 +83,12 @@ export const action = async ({ request }: ActionFunctionArgs) => {
});
}
ChatSystemMessage.notifyRoomsChanged(
updatedGroup
? updatedGroup.members.map((member) => member.id)
: [data.id],
);
ChatSystemMessage.send({
room: sqGroupWebsocketRoom(ownGroup.id),
revalidateOnly: true,

View File

@@ -1,7 +1,6 @@
import type { LoaderFunctionArgs } from "react-router";
import * as R from "remeda";
import { requireUser } from "~/features/auth/core/user.server";
import * as EventBus from "~/features/events/core/EventBus.server";
import { resolveNotifications } from "~/features/notifications/core/resolve.server";
import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
import { cachedStreams } from "~/features/sendouq-streams/core/streams.server";
@@ -70,9 +69,9 @@ export const loader = async ({ url }: LoaderFunctionArgs) => {
: [],
lastUpdated: Date.now(),
streamsCount: (await cachedStreams()).length,
chatCode:
chatRoomIds:
ownGroup && ownGroup.members.length > 1 && ownGroup.chatRoomId !== null
? EventBus.chatRoomChannel(ownGroup.chatRoomId)
: null,
? [ownGroup.chatRoomId]
: [],
};
};

View File

@@ -1,7 +1,6 @@
import cachified from "@epic-web/cachified";
import type { LoaderFunctionArgs } from "react-router";
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
import { chatAccessible } from "~/features/chat/chat-utils";
import * as EventBus from "~/features/events/core/EventBus.server";
import * as ScannerIngestRepository from "~/features/scanner-ingest/ScannerIngestRepository.server";
import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server";
@@ -202,24 +201,8 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
});
}
const hasPermsToSeeChat =
tournament.isOrganizerOrStreamer(user) ||
match.players.some((p) => p.id === user?.id);
const isSiteStaff = user?.roles.includes("STAFF") ?? false;
const isTournamentStaff = tournament.isOrganizer(user);
const chatCodeExpired =
tournament.ctx.isFinalized && !isSiteStaff && !isTournamentStaff
? true
: !chatAccessible({
expiresAfterDays: tournament.isLeague ? 30 : 7,
comparedTo: tournament.ctx.startsAt,
});
const visibleChatCode =
hasPermsToSeeChat && !chatCodeExpired && match.chatRoomId
? EventBus.chatRoomChannel(match.chatRoomId)
: undefined;
const isParticipant = match.players.some((p) => p.id === user?.id);
const leagueRoundLocked = isLeagueRoundLocked(tournament, match.roundId);
@@ -251,7 +234,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
match: {
...match,
status,
chatRoomId: hasPermsToSeeChat ? match.chatRoomId : undefined,
chatRoomId: undefined,
},
results,
reportedWeapons,
@@ -266,7 +249,8 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
matchIsOver,
endedEarly,
noScreen,
chatCode: visibleChatCode,
// observers (TO/streamer/staff) get room access through the moderation view instead
chatRoomIds: isParticipant && match.chatRoomId ? [match.chatRoomId] : [],
canJoin,
// the views can't derive these themselves, the layout ships no bracket match data
bracketContext: {

View File

@@ -31,7 +31,7 @@ import {
} from "~/utils/kysely.server";
import { logger } from "~/utils/logger";
import { bskyUrl, twitchUrl, youtubeUrl } from "~/utils/urls";
import type { ChatUser } from "../chat/chat-types";
import type { ChatMessageAuthor } from "../chat/chat-types";
import { sortBadgesByFavorites } from "./core/badge-sorting.server";
import { findWidgetById } from "./core/widgets/portfolio";
import { WIDGET_LOADERS } from "./core/widgets/portfolio-loaders.server";
@@ -539,7 +539,7 @@ export async function findChatUsersByUserIds(userIds: number[]) {
.where("User.id", "in", userIds)
.execute();
const result: Record<number, ChatUser> = {};
const result: Record<number, ChatMessageAuthor> = {};
for (const user of users) {
result[user.id] = user;

View File

@@ -129,6 +129,16 @@ type BaseFormProps<T extends v.ObjectEntries> = {
* back to a read-only view.
*/
onSuccess?: () => void;
/**
* Hides the built-in submit button, for forms that render their own submit
* control inside `children` (e.g. the chat composer's send button).
*/
hideSubmitButton?: boolean;
/**
* When false, navigating away with unsaved edits is not blocked. For forms
* whose value is ephemeral by nature, like a chat message draft.
*/
guardUnsavedChanges?: boolean;
};
/**
@@ -208,6 +218,8 @@ function SendouFormInner<T extends v.ObjectEntries>({
secondarySubmit,
hideSubmitButtonWhen,
onSuccess,
hideSubmitButton = false,
guardUnsavedChanges = true,
}: SendouFormProps<T>) {
const { t } = useTranslation(["forms"]);
const fetcher = useFetcher<{ fieldErrors?: Record<string, string> }>();
@@ -283,7 +295,11 @@ function SendouFormInner<T extends v.ObjectEntries>({
const hasUnsavedChangesRef = React.useRef<() => boolean>(() => false);
hasUnsavedChangesRef.current = () =>
mode === "submit" && !readOnly && store.dirty && fetcher.state === "idle";
guardUnsavedChanges &&
mode === "submit" &&
!readOnly &&
store.dirty &&
fetcher.state === "idle";
useUnsavedChangesChecker(hasUnsavedChangesRef);
const previousFetcherStateRef = React.useRef(fetcher.state);
@@ -340,7 +356,7 @@ function SendouFormInner<T extends v.ObjectEntries>({
<>
{title ? <h2 className={styles.title}>{title}</h2> : null}
{resolvedChildren}
{mode !== "submit" || readOnly ? null : (
{mode !== "submit" || readOnly || hideSubmitButton ? null : (
<SubmitRow
hideWhen={
hideSubmitButtonWhen as ((values: unknown) => boolean) | undefined

View File

@@ -374,6 +374,12 @@
"chat.newMessages": "",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",
"chat.sidebar.inactive": "",
"chat.room.group": "",
"chat.room.groupShort": "",
"chat.room.match": "",
"chat.room.matchShort": "",
"chat.room.scrim": "",
"fc.title": "",
"fc.whereToFind": "",
"fc.onceSetStaffOnly": "",

View File

@@ -72,6 +72,7 @@
"errors.noOnlySpecialCharacters": "",
"errors.customRoleRequired": "",
"labels.weaponPool": "Våbenpulje",
"placeholders.chatMessage": "",
"placeholders.weaponPoolFull": "",
"placeholders.vodStartTimestamp": "",
"labels.voiceChat": "",

View File

@@ -374,6 +374,12 @@
"chat.newMessages": "",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",
"chat.sidebar.inactive": "",
"chat.room.group": "",
"chat.room.groupShort": "",
"chat.room.match": "",
"chat.room.matchShort": "",
"chat.room.scrim": "",
"fc.title": "",
"fc.whereToFind": "",
"fc.onceSetStaffOnly": "",

View File

@@ -72,6 +72,7 @@
"errors.noOnlySpecialCharacters": "",
"errors.customRoleRequired": "",
"labels.weaponPool": "Waffenpool",
"placeholders.chatMessage": "",
"placeholders.weaponPoolFull": "",
"placeholders.vodStartTimestamp": "",
"labels.voiceChat": "",

View File

@@ -374,6 +374,12 @@
"chat.newMessages": "New messages",
"chat.sidebar.title": "Chat",
"chat.sidebar.noActiveChats": "No active chats",
"chat.sidebar.inactive": "Inactive",
"chat.room.group": "Group ({{members}}/4)",
"chat.room.groupShort": "Group",
"chat.room.match": "Match #{{id}}",
"chat.room.matchShort": "Match",
"chat.room.scrim": "Scrim",
"fc.title": "Friend code",
"fc.whereToFind": "Find your friend code on your Nintendo Switch (2)",
"fc.onceSetStaffOnly": "Once set, only a member of staff can change it",

View File

@@ -72,6 +72,7 @@
"errors.noOnlySpecialCharacters": "Name can't be only special characters",
"errors.customRoleRequired": "Enter a name for the custom role",
"labels.weaponPool": "Weapon pool",
"placeholders.chatMessage": "Press enter to send",
"placeholders.weaponPoolFull": "Pool full - remove a weapon to add more",
"placeholders.vodStartTimestamp": "10:22",
"labels.voiceChat": "Can voice chat",

View File

@@ -374,6 +374,12 @@
"chat.newMessages": "Nuevos mensajes",
"chat.sidebar.title": "Chat",
"chat.sidebar.noActiveChats": "No hay chats activos",
"chat.sidebar.inactive": "",
"chat.room.group": "",
"chat.room.groupShort": "",
"chat.room.match": "",
"chat.room.matchShort": "",
"chat.room.scrim": "",
"fc.title": "Clave de amigo",
"fc.whereToFind": "Encuentra tu clave de amigo en tu Nintendo Switch (2)",
"fc.onceSetStaffOnly": "Una vez guardada, solo un miembro del staff puede modificarla.",

View File

@@ -72,6 +72,7 @@
"errors.noOnlySpecialCharacters": "El nombre no puede ser solo caracteres especiales",
"errors.customRoleRequired": "Introduce un nombre para el rol personalizado",
"labels.weaponPool": "Selección de armas",
"placeholders.chatMessage": "Presiona Enter para enviar",
"placeholders.weaponPoolFull": "Selección llena - elimina un arma para añadir más",
"placeholders.vodStartTimestamp": "10:22",
"labels.voiceChat": "Puede usar chat de voz",

View File

@@ -374,6 +374,12 @@
"chat.newMessages": "Nuevos mensajes",
"chat.sidebar.title": "Chat",
"chat.sidebar.noActiveChats": "No hay chats activos",
"chat.sidebar.inactive": "",
"chat.room.group": "",
"chat.room.groupShort": "",
"chat.room.match": "",
"chat.room.matchShort": "",
"chat.room.scrim": "",
"fc.title": "Clave de amigo",
"fc.whereToFind": "Encuentra tu clave de amigo en tu Nintendo Switch (2)",
"fc.onceSetStaffOnly": "Una vez guardada, solo un miembro del staff puede modificarla.",

View File

@@ -72,6 +72,7 @@
"errors.noOnlySpecialCharacters": "El nombre no puede ser solo caracteres especiales",
"errors.customRoleRequired": "Introduce un nombre para el rol personalizado",
"labels.weaponPool": "Grupo de armas",
"placeholders.chatMessage": "Presionar 'enter' para enviar",
"placeholders.weaponPoolFull": "Selección llena - elimina un arma para añadir más",
"placeholders.vodStartTimestamp": "10:22",
"labels.voiceChat": "Puede usar chat de voz",

View File

@@ -374,6 +374,12 @@
"chat.newMessages": "",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",
"chat.sidebar.inactive": "",
"chat.room.group": "",
"chat.room.groupShort": "",
"chat.room.match": "",
"chat.room.matchShort": "",
"chat.room.scrim": "",
"fc.title": "",
"fc.whereToFind": "",
"fc.onceSetStaffOnly": "",

View File

@@ -72,6 +72,7 @@
"errors.noOnlySpecialCharacters": "",
"errors.customRoleRequired": "",
"labels.weaponPool": "Armes jouées",
"placeholders.chatMessage": "",
"placeholders.weaponPoolFull": "",
"placeholders.vodStartTimestamp": "",
"labels.voiceChat": "",

View File

@@ -374,6 +374,12 @@
"chat.newMessages": "Nouveau message",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",
"chat.sidebar.inactive": "",
"chat.room.group": "",
"chat.room.groupShort": "",
"chat.room.match": "",
"chat.room.matchShort": "",
"chat.room.scrim": "",
"fc.title": "Code ami ",
"fc.whereToFind": "",
"fc.onceSetStaffOnly": "",

View File

@@ -72,6 +72,7 @@
"errors.noOnlySpecialCharacters": "",
"errors.customRoleRequired": "",
"labels.weaponPool": "Armes jouées",
"placeholders.chatMessage": "Appuyer sur entrer pour envoyer",
"placeholders.weaponPoolFull": "",
"placeholders.vodStartTimestamp": "",
"labels.voiceChat": "",

View File

@@ -374,6 +374,12 @@
"chat.newMessages": "",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",
"chat.sidebar.inactive": "",
"chat.room.group": "",
"chat.room.groupShort": "",
"chat.room.match": "",
"chat.room.matchShort": "",
"chat.room.scrim": "",
"fc.title": "",
"fc.whereToFind": "",
"fc.onceSetStaffOnly": "",

View File

@@ -72,6 +72,7 @@
"errors.noOnlySpecialCharacters": "",
"errors.customRoleRequired": "",
"labels.weaponPool": "מאגר נשקים",
"placeholders.chatMessage": "",
"placeholders.weaponPoolFull": "",
"placeholders.vodStartTimestamp": "",
"labels.voiceChat": "",

View File

@@ -374,6 +374,12 @@
"chat.newMessages": "Nuovi messaggi",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",
"chat.sidebar.inactive": "",
"chat.room.group": "",
"chat.room.groupShort": "",
"chat.room.match": "",
"chat.room.matchShort": "",
"chat.room.scrim": "",
"fc.title": "Codice amico",
"fc.whereToFind": "",
"fc.onceSetStaffOnly": "",

View File

@@ -72,6 +72,7 @@
"errors.noOnlySpecialCharacters": "",
"errors.customRoleRequired": "",
"labels.weaponPool": "Pool armi",
"placeholders.chatMessage": "Premi Invio per inviare",
"placeholders.weaponPoolFull": "",
"placeholders.vodStartTimestamp": "",
"labels.voiceChat": "",

View File

@@ -374,6 +374,12 @@
"chat.newMessages": "新着メッセージ",
"chat.sidebar.title": "チャット",
"chat.sidebar.noActiveChats": "使用中のチャットはありません",
"chat.sidebar.inactive": "",
"chat.room.group": "",
"chat.room.groupShort": "",
"chat.room.match": "",
"chat.room.matchShort": "",
"chat.room.scrim": "",
"fc.title": "フレンドコード",
"fc.whereToFind": "フレンドコードは Nintendo Switch から見ることができます",
"fc.onceSetStaffOnly": "フレンドコードは一度設定すると、変更できるのはスタッフのみです。",

View File

@@ -72,6 +72,7 @@
"errors.noOnlySpecialCharacters": "",
"errors.customRoleRequired": "",
"labels.weaponPool": "使用ブキ",
"placeholders.chatMessage": "送信するには enter を押してください",
"placeholders.weaponPoolFull": "",
"placeholders.vodStartTimestamp": "",
"labels.voiceChat": "",

View File

@@ -374,6 +374,12 @@
"chat.newMessages": "",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",
"chat.sidebar.inactive": "",
"chat.room.group": "",
"chat.room.groupShort": "",
"chat.room.match": "",
"chat.room.matchShort": "",
"chat.room.scrim": "",
"fc.title": "",
"fc.whereToFind": "",
"fc.onceSetStaffOnly": "",

View File

@@ -72,6 +72,7 @@
"errors.noOnlySpecialCharacters": "",
"errors.customRoleRequired": "",
"labels.weaponPool": "",
"placeholders.chatMessage": "",
"placeholders.weaponPoolFull": "",
"placeholders.vodStartTimestamp": "",
"labels.voiceChat": "",

View File

@@ -374,6 +374,12 @@
"chat.newMessages": "",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",
"chat.sidebar.inactive": "",
"chat.room.group": "",
"chat.room.groupShort": "",
"chat.room.match": "",
"chat.room.matchShort": "",
"chat.room.scrim": "",
"fc.title": "",
"fc.whereToFind": "",
"fc.onceSetStaffOnly": "",

View File

@@ -72,6 +72,7 @@
"errors.noOnlySpecialCharacters": "",
"errors.customRoleRequired": "",
"labels.weaponPool": "",
"placeholders.chatMessage": "",
"placeholders.weaponPoolFull": "",
"placeholders.vodStartTimestamp": "",
"labels.voiceChat": "",

View File

@@ -374,6 +374,12 @@
"chat.newMessages": "",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",
"chat.sidebar.inactive": "",
"chat.room.group": "",
"chat.room.groupShort": "",
"chat.room.match": "",
"chat.room.matchShort": "",
"chat.room.scrim": "",
"fc.title": "",
"fc.whereToFind": "",
"fc.onceSetStaffOnly": "",

View File

@@ -72,6 +72,7 @@
"errors.noOnlySpecialCharacters": "",
"errors.customRoleRequired": "",
"labels.weaponPool": "Pula broni",
"placeholders.chatMessage": "",
"placeholders.weaponPoolFull": "",
"placeholders.vodStartTimestamp": "",
"labels.voiceChat": "",

View File

@@ -374,6 +374,12 @@
"chat.newMessages": "",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",
"chat.sidebar.inactive": "",
"chat.room.group": "",
"chat.room.groupShort": "",
"chat.room.match": "",
"chat.room.matchShort": "",
"chat.room.scrim": "",
"fc.title": "Código de amigo",
"fc.whereToFind": "",
"fc.onceSetStaffOnly": "",

View File

@@ -72,6 +72,7 @@
"errors.noOnlySpecialCharacters": "",
"errors.customRoleRequired": "",
"labels.weaponPool": "Seleção de armas",
"placeholders.chatMessage": "Aperte enter para enviar",
"placeholders.weaponPoolFull": "",
"placeholders.vodStartTimestamp": "",
"labels.voiceChat": "",

View File

@@ -374,6 +374,12 @@
"chat.newMessages": "Новые сообщения",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",
"chat.sidebar.inactive": "",
"chat.room.group": "",
"chat.room.groupShort": "",
"chat.room.match": "",
"chat.room.matchShort": "",
"chat.room.scrim": "",
"fc.title": "Код друга",
"fc.whereToFind": "",
"fc.onceSetStaffOnly": "",

View File

@@ -72,6 +72,7 @@
"errors.noOnlySpecialCharacters": "",
"errors.customRoleRequired": "",
"labels.weaponPool": "Используемое оружие",
"placeholders.chatMessage": "Нажмите enter, чтобы отправить",
"placeholders.weaponPoolFull": "",
"placeholders.vodStartTimestamp": "",
"labels.voiceChat": "",

View File

@@ -374,6 +374,12 @@
"chat.newMessages": "新消息",
"chat.sidebar.title": "聊天",
"chat.sidebar.noActiveChats": "暂无活跃聊天",
"chat.sidebar.inactive": "",
"chat.room.group": "",
"chat.room.groupShort": "",
"chat.room.match": "",
"chat.room.matchShort": "",
"chat.room.scrim": "",
"fc.title": "好友编号",
"fc.whereToFind": "在您的 Nintendo Switch (2) 上找到好友编号",
"fc.onceSetStaffOnly": "设置完成后,只有工作人员能修改",

View File

@@ -72,6 +72,7 @@
"errors.noOnlySpecialCharacters": "名称不能仅由特殊字符组成",
"errors.customRoleRequired": "请输入自定义职责的名称",
"labels.weaponPool": "武器池",
"placeholders.chatMessage": "按回车键发送",
"placeholders.weaponPoolFull": "武器池已满。请移除一个武器以添加新武器",
"placeholders.vodStartTimestamp": "",
"labels.voiceChat": "可以进行语音聊天吗?",

View File

@@ -242,10 +242,10 @@ export function buildCases(fx: Fixtures): {
ChatRepository.findMessageById(messageId),
);
add(
"ChatRepository.findUnreadCountsByRoomIds",
"ChatRepository.findMessageStatsByRoomIds",
both(fx.heavyUser, fx.heavyChatRoomId),
([user, roomId]) =>
ChatRepository.findUnreadCountsByRoomIds(user.id, [roomId]),
ChatRepository.findMessageStatsByRoomIds(user.id, [roomId]),
);
// ChatRoomResolver