mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-08 04:05:54 -05:00
Rewrite the chat client onto the SSE + HTTP system
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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" }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
450
app/features/chat/chat-client.test.ts
Normal file
450
app/features/chat/chat-client.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
454
app/features/chat/chat-client.ts
Normal file
454
app/features/chat/chat-client.ts
Normal 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),
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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!, {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
| {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
})(),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
: [],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
"errors.noOnlySpecialCharacters": "",
|
||||
"errors.customRoleRequired": "",
|
||||
"labels.weaponPool": "Våbenpulje",
|
||||
"placeholders.chatMessage": "",
|
||||
"placeholders.weaponPoolFull": "",
|
||||
"placeholders.vodStartTimestamp": "",
|
||||
"labels.voiceChat": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
"errors.noOnlySpecialCharacters": "",
|
||||
"errors.customRoleRequired": "",
|
||||
"labels.weaponPool": "Waffenpool",
|
||||
"placeholders.chatMessage": "",
|
||||
"placeholders.weaponPoolFull": "",
|
||||
"placeholders.vodStartTimestamp": "",
|
||||
"labels.voiceChat": "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
"errors.noOnlySpecialCharacters": "",
|
||||
"errors.customRoleRequired": "",
|
||||
"labels.weaponPool": "Armes jouées",
|
||||
"placeholders.chatMessage": "",
|
||||
"placeholders.weaponPoolFull": "",
|
||||
"placeholders.vodStartTimestamp": "",
|
||||
"labels.voiceChat": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
"errors.noOnlySpecialCharacters": "",
|
||||
"errors.customRoleRequired": "",
|
||||
"labels.weaponPool": "מאגר נשקים",
|
||||
"placeholders.chatMessage": "",
|
||||
"placeholders.weaponPoolFull": "",
|
||||
"placeholders.vodStartTimestamp": "",
|
||||
"labels.voiceChat": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
"errors.noOnlySpecialCharacters": "",
|
||||
"errors.customRoleRequired": "",
|
||||
"labels.weaponPool": "Pool armi",
|
||||
"placeholders.chatMessage": "Premi Invio per inviare",
|
||||
"placeholders.weaponPoolFull": "",
|
||||
"placeholders.vodStartTimestamp": "",
|
||||
"labels.voiceChat": "",
|
||||
|
||||
@@ -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": "フレンドコードは一度設定すると、変更できるのはスタッフのみです。",
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
"errors.noOnlySpecialCharacters": "",
|
||||
"errors.customRoleRequired": "",
|
||||
"labels.weaponPool": "使用ブキ",
|
||||
"placeholders.chatMessage": "送信するには enter を押してください",
|
||||
"placeholders.weaponPoolFull": "",
|
||||
"placeholders.vodStartTimestamp": "",
|
||||
"labels.voiceChat": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
"errors.noOnlySpecialCharacters": "",
|
||||
"errors.customRoleRequired": "",
|
||||
"labels.weaponPool": "",
|
||||
"placeholders.chatMessage": "",
|
||||
"placeholders.weaponPoolFull": "",
|
||||
"placeholders.vodStartTimestamp": "",
|
||||
"labels.voiceChat": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
"errors.noOnlySpecialCharacters": "",
|
||||
"errors.customRoleRequired": "",
|
||||
"labels.weaponPool": "",
|
||||
"placeholders.chatMessage": "",
|
||||
"placeholders.weaponPoolFull": "",
|
||||
"placeholders.vodStartTimestamp": "",
|
||||
"labels.voiceChat": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
"errors.noOnlySpecialCharacters": "",
|
||||
"errors.customRoleRequired": "",
|
||||
"labels.weaponPool": "Pula broni",
|
||||
"placeholders.chatMessage": "",
|
||||
"placeholders.weaponPoolFull": "",
|
||||
"placeholders.vodStartTimestamp": "",
|
||||
"labels.voiceChat": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
"errors.noOnlySpecialCharacters": "",
|
||||
"errors.customRoleRequired": "",
|
||||
"labels.weaponPool": "Используемое оружие",
|
||||
"placeholders.chatMessage": "Нажмите enter, чтобы отправить",
|
||||
"placeholders.weaponPoolFull": "",
|
||||
"placeholders.vodStartTimestamp": "",
|
||||
"labels.voiceChat": "",
|
||||
|
||||
@@ -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": "设置完成后,只有工作人员能修改",
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
"errors.noOnlySpecialCharacters": "名称不能仅由特殊字符组成",
|
||||
"errors.customRoleRequired": "请输入自定义职责的名称",
|
||||
"labels.weaponPool": "武器池",
|
||||
"placeholders.chatMessage": "按回车键发送",
|
||||
"placeholders.weaponPoolFull": "武器池已满。请移除一个武器以添加新武器",
|
||||
"placeholders.vodStartTimestamp": "",
|
||||
"labels.voiceChat": "可以进行语音聊天吗?",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user