From 988d7fd5a232a6a9dfca65e829ae7a694453786e Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:50:22 +0300 Subject: [PATCH] Rewrite the chat client onto the SSE + HTTP system --- app/components/layout/ChatSidebar.module.css | 40 +- app/components/layout/ChatSidebar.tsx | 551 ++++++------ app/features/chat/ChatProvider.tsx | 799 ++++-------------- .../chat/ChatRepository.server.test.ts | 45 +- app/features/chat/ChatRepository.server.ts | 39 +- .../chat/ChatRoomResolver.server.test.ts | 16 + app/features/chat/ChatRoomResolver.server.ts | 9 +- .../chat/ChatSystemMessage.server.test.ts | 13 + app/features/chat/ChatSystemMessage.server.ts | 36 +- app/features/chat/chat-client.test.ts | 450 ++++++++++ app/features/chat/chat-client.ts | 454 ++++++++++ app/features/chat/chat-constants.ts | 14 + app/features/chat/chat-hooks.ts | 13 +- app/features/chat/chat-last-read.test.ts | 20 - app/features/chat/chat-last-read.ts | 23 - app/features/chat/chat-provider-types.ts | 71 +- app/features/chat/chat-schemas.ts | 5 +- app/features/chat/chat-types.ts | 72 +- app/features/chat/chat-utils.test.ts | 93 +- app/features/chat/chat-utils.ts | 34 +- .../chat/components/Chat.browser.test.tsx | 142 ++-- app/features/chat/components/Chat.module.css | 50 +- app/features/chat/components/Chat.tsx | 349 ++++---- .../chat/revalidate-broadcast-throttle.ts | 20 +- app/features/chat/routes/api.chat.rooms.ts | 36 +- app/features/chat/routes/chat-routes.test.ts | 18 + app/features/events/core/EventBus.server.ts | 11 +- app/features/events/events-types.ts | 10 + .../scrims/loaders/scrims.$id.server.ts | 17 +- .../actions/q.match.$id.server.ts | 5 + .../loaders/q.match.$id.server.ts | 39 +- .../sendouq-match/routes/q.match.$id.tsx | 2 +- .../sendouq/actions/q.looking.server.ts | 15 + .../sendouq/actions/q.preparing.server.ts | 6 + .../sendouq/loaders/q.looking.server.ts | 7 +- .../loaders/to.$id.matches.$mid.server.ts | 22 +- .../user-page/UserRepository.server.ts | 4 +- app/form/SendouForm.tsx | 20 +- locales/da/common.json | 6 + locales/da/forms.json | 1 + locales/de/common.json | 6 + locales/de/forms.json | 1 + locales/en/common.json | 6 + locales/en/forms.json | 1 + locales/es-ES/common.json | 6 + locales/es-ES/forms.json | 1 + locales/es-US/common.json | 6 + locales/es-US/forms.json | 1 + locales/fr-CA/common.json | 6 + locales/fr-CA/forms.json | 1 + locales/fr-EU/common.json | 6 + locales/fr-EU/forms.json | 1 + locales/he/common.json | 6 + locales/he/forms.json | 1 + locales/it/common.json | 6 + locales/it/forms.json | 1 + locales/ja/common.json | 6 + locales/ja/forms.json | 1 + locales/ko/common.json | 6 + locales/ko/forms.json | 1 + locales/nl/common.json | 6 + locales/nl/forms.json | 1 + locales/pl/common.json | 6 + locales/pl/forms.json | 1 + locales/pt-BR/common.json | 6 + locales/pt-BR/forms.json | 1 + locales/ru/common.json | 6 + locales/ru/forms.json | 1 + locales/zh/common.json | 6 + locales/zh/forms.json | 1 + scripts/benchmark-db/cases.ts | 4 +- 71 files changed, 2097 insertions(+), 1589 deletions(-) create mode 100644 app/features/chat/chat-client.test.ts create mode 100644 app/features/chat/chat-client.ts delete mode 100644 app/features/chat/chat-last-read.test.ts delete mode 100644 app/features/chat/chat-last-read.ts diff --git a/app/components/layout/ChatSidebar.module.css b/app/components/layout/ChatSidebar.module.css index b3620a1d4..ecd1d07fc 100644 --- a/app/components/layout/ChatSidebar.module.css +++ b/app/components/layout/ChatSidebar.module.css @@ -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; diff --git a/app/components/layout/ChatSidebar.tsx b/app/components/layout/ChatSidebar.tsx index 5a6afb724..98fda863a 100644 --- a/app/components/layout/ChatSidebar.tsx +++ b/app/components/layout/ChatSidebar.tsx @@ -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 ; } - if (chatContext.isLoading) { + if (!chatContext.roomsLoaded) { return ; } return ; } +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 (
- {!isCombined && standaloneRooms.length === 0 ? ( + {!hasAnyRoom ? (
{t("common:chat.sidebar.noActiveChats")}
@@ -134,50 +205,41 @@ function RoomList({ onClose }: { onClose?: () => void }) { {isCombined ? ( - 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 ( - openRooms([room.chatCode])} + {activeRooms.map((room) => ( + openRooms([room.id])} + /> + ))} + {inactiveRooms.length > 0 ? ( + <> + + {showInactive + ? inactiveRooms.map((room) => ( + openRooms([room.id])} + /> + )) + : null} + + ) : null} )}
@@ -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 ( + + + + {title} + {subtitle} + + {room.unreadCount > 0 ? ( + {room.unreadCount} + ) : room.latestMessageAt !== null ? ( + + {timestampFormatter.format( + databaseTimestampToDate(room.latestMessageAt), + )} + + ) : null} + ); +} + +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 ( - {primary.imageUrl ? : null} + - - {resolveDatePlaceholders( - primary.header, - (d) => headerFormatter.format(d) ?? "", - )} - + {title} - {rooms.map((room) => roomShortLabel(room.header)).join(" · ")} + {rooms.map((room) => roomShortLabel(room, t)).join(" · ")} {unread > 0 ? {unread} : 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 ; } - return ; + return ; } -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(), - }; - - const handleBack = () => { - chatContext.setActiveRooms([]); - }; + const display = room ? roomDisplay(room) : null; const headerContent = ( <> - {room?.imageUrl ? : null} + {display ? : null}
- - {resolveDatePlaceholders( - room?.header ?? t("common:chat.sidebar.title"), - (d) => headerFormatter.format(d) ?? "", - )} + + {display?.title ?? t("common:chat.sidebar.title")} - {room?.subtitle ? ( - {room.subtitle} + {display?.subtitle ? ( + {display.subtitle} ) : null}
@@ -313,7 +363,10 @@ function SingleChatView({ onClose }: { onClose?: () => void }) { return (
-
- + {room ? : null}
); @@ -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 ? : null} +
- - {resolveDatePlaceholders( - primary.header, - (d) => headerFormatter.format(d) ?? "", - )} - - {primary.subtitle ? ( - {primary.subtitle} + {display.title} + {display.subtitle ? ( + {display.subtitle} ) : null}
); + // 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 (
- {primary.url ? ( @@ -407,49 +453,16 @@ function CombinedChatView({ ) : null}
- -
- ); -} - -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 ( -
- {panels.map(({ room, grow, showHeader }) => ( - - ))} +
+ {panels.map(({ room, grow, showHeader }) => ( + + ))} +
); } @@ -459,19 +472,19 @@ function SplitPanel({ grow, showHeader, }: { - room: RoomInfo; + room: ChatRoomListItem; grow: number; showHeader: boolean; }) { + const { t } = useTranslation(["common"]); + return (
{showHeader ? ( -
- {roomShortLabel(room.header)} -
+
{roomShortLabel(room, t)}
) : null}
@@ -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(), - }; return ( 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 = {}; 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; } diff --git a/app/features/chat/ChatProvider.tsx b/app/features/chat/ChatProvider.tsx index c8228e9cb..75ea6f58a 100644 --- a/app/features/chat/ChatProvider.tsx +++ b/app/features/chat/ChatProvider.tsx @@ -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(); - const removedChatCodes = new Set(); - - 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 {children}; + return {children}; } 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([]); - const [messagesByRoom, setMessagesByRoom] = React.useState< - Record - >({}); - const [chatUsersCache, setChatUsersCache] = React.useState< - Record - >({}); - const [readyState, setReadyState] = React.useState< - "CONNECTING" | "CONNECTED" | "CLOSED" - >("CONNECTING"); const [chatOpen, _setChatOpen] = React.useState(false); - const [activeRooms, setActiveRooms] = React.useState([]); + const [activeRoomIds, setActiveRoomIds] = React.useState([]); const [chatLabels, setChatLabels] = React.useState>( {}, ); const clearChatLabels = React.useCallback(() => setChatLabels({}), []); - const ws = React.useRef(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 = {}; - 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()); + 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 = {}; - 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( () => ({ - 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>; + 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>; - setMessagesByRoom: React.Dispatch< - React.SetStateAction> - >; - requestHistory: (chatCode: string) => void; - messagesByRoom: Record; }) { - const chatCodesKey = useCurrentRouteChatCodes().join(","); + const routeRoomIdsKey = useCurrentRouteChatRoomIds().join(","); const { pathname } = useLocation(); const layoutSize = useLayoutSize(); - const subscribedRoomRef = React.useRef([]); - const previousRouteChatCodeRef = React.useRef([]); + const previousRouteRoomIdsKeyRef = React.useRef(null); const previousPathnameRef = React.useRef(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; - chatUsersCache: Record; -}): Record { - const fetcher = useFetcher>(); - - // Accumulated across loads because `fetcher.data` only holds the latest response - const fetchedUsersRef = React.useRef>({}); - 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(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; -} diff --git a/app/features/chat/ChatRepository.server.test.ts b/app/features/chat/ChatRepository.server.test.ts index b15c31863..babe51c14 100644 --- a/app/features/chat/ChatRepository.server.test.ts +++ b/app/features/chat/ChatRepository.server.test.ts @@ -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([]); }); }); diff --git a/app/features/chat/ChatRepository.server.ts b/app/features/chat/ChatRepository.server.ts index 2adcc683b..2351c4deb 100644 --- a/app/features/chat/ChatRepository.server.ts +++ b/app/features/chat/ChatRepository.server.ts @@ -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> { +): 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().as("unreadCount"), + fn + .sum( + eb + .case() + .when( + "ChatMessage.id", + ">", + fn.coalesce("ChatMessageReadIndicator.lastSeenMessageId", val(0)), + ) + .then(1) + .else(0) + .end(), + ) + .as("unreadCount"), + fn.max("ChatMessage.id").as("latestMessageId"), + fn.max("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(); } diff --git a/app/features/chat/ChatRoomResolver.server.test.ts b/app/features/chat/ChatRoomResolver.server.test.ts index 679e3d9c9..8b0c334f5 100644 --- a/app/features/chat/ChatRoomResolver.server.test.ts +++ b/app/features/chat/ChatRoomResolver.server.test.ts @@ -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, }); diff --git a/app/features/chat/ChatRoomResolver.server.ts b/app/features/chat/ChatRoomResolver.server.ts index 2552653ca..cdeee9e06 100644 --- a/app/features/chat/ChatRoomResolver.server.ts +++ b/app/features/chat/ChatRoomResolver.server.ts @@ -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( | "imageUrl" | "participantUserIds" | "observerUserIds" - >, + > & + Partial>, ): ResolvedRoom[] { const ownerByRoomId = new Map( owners.map((owner) => [owner.chatRoomId, owner]), @@ -539,6 +545,7 @@ function joinOwners( type: room.type, expiresAt: room.expiresAt, closedAt: room.closedAt, + inactive: Boolean(room.inactive), ...build(owner), }; }); diff --git a/app/features/chat/ChatSystemMessage.server.test.ts b/app/features/chat/ChatSystemMessage.server.test.ts index 01c6a3dda..42acf36db 100644 --- a/app/features/chat/ChatSystemMessage.server.test.ts +++ b/app/features/chat/ChatSystemMessage.server.test.ts @@ -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" }]); + }); +}); diff --git a/app/features/chat/ChatSystemMessage.server.ts b/app/features/chat/ChatSystemMessage.server.ts index 211266752..6a936a6ee 100644 --- a/app/features/chat/ChatSystemMessage.server.ts +++ b/app/features/chat/ChatSystemMessage.server.ts @@ -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, -) { +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; diff --git a/app/features/chat/chat-client.test.ts b/app/features/chat/chat-client.test.ts new file mode 100644 index 000000000..2cff00439 --- /dev/null +++ b/app/features/chat/chat-client.test.ts @@ -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 { + 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 { + 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]); + }); +}); diff --git a/app/features/chat/chat-client.ts b/app/features/chat/chat-client.ts new file mode 100644 index 000000000..898ecd070 --- /dev/null +++ b/app/features/chat/chat-client.ts @@ -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; + 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; +} + +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; + /** 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(); + let viewedRoomIds = new Set(); + let snapshot: ChatSnapshot | null = null; + + let roomsRefreshInflight: Promise | null = null; + const loadingMessageRoomIds = new Set(); + /** Newest message id already marked read locally per room, so refetches can't resurrect stale unread counts. */ + const locallyReadByRoomId = new Map(); + const readTimers = new Map>(); + + 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) => { + 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 = { + 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 (url: string): Promise => { + 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), +}); diff --git a/app/features/chat/chat-constants.ts b/app/features/chat/chat-constants.ts index 56c7d63ef..b2f065acc 100644 --- a/app/features/chat/chat-constants.ts +++ b/app/features/chat/chat-constants.ts @@ -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; diff --git a/app/features/chat/chat-hooks.ts b/app/features/chat/chat-hooks.ts index 89c858bb2..a3b8442aa 100644 --- a/app/features/chat/chat-hooks.ts +++ b/app/features/chat/chat-hooks.ts @@ -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, ) { 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; diff --git a/app/features/chat/chat-last-read.test.ts b/app/features/chat/chat-last-read.test.ts deleted file mode 100644 index 0ab703d65..000000000 --- a/app/features/chat/chat-last-read.test.ts +++ /dev/null @@ -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"]); - }); -}); diff --git a/app/features/chat/chat-last-read.ts b/app/features/chat/chat-last-read.ts deleted file mode 100644 index baa0e224d..000000000 --- a/app/features/chat/chat-last-read.ts +++ /dev/null @@ -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 { - 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); -} diff --git a/app/features/chat/chat-provider-types.ts b/app/features/chat/chat-provider-types.ts index f70c5b2e6..1e1c5c12d 100644 --- a/app/features/chat/chat-provider-types.ts +++ b/app/features/chat/chat-provider-types.ts @@ -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; - 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; + /** 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; 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; setChatLabels: (labels: Record) => void; clearChatLabels: () => void; diff --git a/app/features/chat/chat-schemas.ts b/app/features/chat/chat-schemas.ts index 5e63177b5..6781e2097 100644 --- a/app/features/chat/chat-schemas.ts +++ b/app/features/chat/chat-schemas.ts @@ -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", + }), }); diff --git a/app/features/chat/chat-types.ts b/app/features/chat/chat-types.ts index 6672b6c78..0a92ce55b 100644 --- a/app/features/chat/chat-types.ts +++ b/app/features/chat/chat-types.ts @@ -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; - 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; + 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"; diff --git a/app/features/chat/chat-utils.test.ts b/app/features/chat/chat-utils.test.ts index 425334081..39bc3862b 100644 --- a/app/features/chat/chat-utils.test.ts +++ b/app/features/chat/chat-utils.test.ts @@ -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", - ); - }); -}); diff --git a/app/features/chat/chat-utils.ts b/app/features/chat/chat-utils.ts index af581a0ce..afc89ab2d 100644 --- a/app/features/chat/chat-utils.ts +++ b/app/features/chat/chat-utils.ts @@ -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; diff --git a/app/features/chat/components/Chat.browser.test.tsx b/app/features/chat/components/Chat.browser.test.tsx index 377f75416..9c5a51319 100644 --- a/app/features/chat/components/Chat.browser.test.tsx +++ b/app/features/chat/components/Chat.browser.test.tsx @@ -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 = { - 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 { +function createMessage( + overrides: Partial = {}, +): 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: (
- + {}} />
), }, @@ -66,27 +54,18 @@ function renderChat( return render(); } -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 (
- + {}} />
); } @@ -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)); diff --git a/app/features/chat/components/Chat.module.css b/app/features/chat/components/Chat.module.css index 437b7fcd8..a63f970af 100644 --- a/app/features/chat/components/Chat.module.css +++ b/app/features/chat/components/Chat.module.css @@ -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 { diff --git a/app/features/chat/components/Chat.tsx b/app/features/chat/components/Chat.tsx index 9555eb686..b106d8b79 100644 --- a/app/features/chat/components/Chat.tsx +++ b/app/features/chat/components/Chat.tsx @@ -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; +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; + 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 & { - chat: ChatAdapter; -}) { +}: ChatProps) { const { t } = useTranslation(["common"]); const messagesContainerRef = React.useRef(null); - const inputRef = React.useRef(null); - const { - send, + + const { unseenMessagesInTheRoom, scrollToBottom } = useChatAutoScroll( messages, - currentRoom, - setCurrentRoom, - readyState, - unseenMessages, - } = chat; - - const handleSubmit = React.useCallback( - (e: React.FormEvent) => { - 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 ( -
); } -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 ( + { + onSend(values); + setPublicId(shortNanoid()); + setHasSent(true); + }} + > + {({ FormField }) => ( + <> + {readyState !== "CONNECTED" ? ( +
+ {t( + readyState === "CONNECTING" + ? "common:chat.connecting" + : "common:chat.disconnected", + )} +
+ ) : null} + + + )} +
+ ); +} + +function ComposerRow({ + FormField, + sendingDisabled, + hasSent, }: { - user?: ChatUser | null; - message: ChatMessage; - missingUserName?: string; + FormField: FormRenderProps["FormField"]; + sendingDisabled: boolean; + hasSent: boolean; }) { + const { t } = useTranslation(["common"]); + const contents = useFormValue("contents"); + const isEmpty = typeof contents !== "string" || contents.trim().length === 0; + + return ( +
{ + if (event.key === "Enter" && isEmpty) { + event.preventDefault(); + } + }} + > + + } + testId="chat-submit-button" + /> +
+ ); +} + +function Message({ + message, + label, +}: { + message: ClientChatMessage; + label?: string; +}) { + const author = message.author; + return ( - {user ? ( + {author ? (
- - {user.title ? ( - {user.title} - ) : null} + + {label ? {label} : null}
) : null}
@@ -272,18 +284,16 @@ function Message({
- {user?.username ?? missingUserName} + {author?.username ?? "???"}
- {user?.pronouns ? ( - - {user.pronouns.subject}/{user.pronouns.object} - - ) : null} + {!message.pending ? ( - + ) : null}
+ {author.pronouns.subject}/{author.pronouns.object} + + ); +} + function SystemMessage({ message, text, }: { - message: ChatMessage; + message: ClientChatMessage; 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 ( ); } diff --git a/app/features/chat/revalidate-broadcast-throttle.ts b/app/features/chat/revalidate-broadcast-throttle.ts index b309099c3..ae1c2b3f5 100644 --- a/app/features/chat/revalidate-broadcast-throttle.ts +++ b/app/features/chat/revalidate-broadcast-throttle.ts @@ -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; } | 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(); @@ -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): boolean { + throttles( + msg: Pick, + ): boolean { return Boolean(msg.revalidateOnly) && !messageTypeToSound(msg.type); }, handle(msg: ThrottleableMessage): void { diff --git a/app/features/chat/routes/api.chat.rooms.ts b/app/features/chat/routes/api.chat.rooms.ts index f7dee9c66..00405ed57 100644 --- a/app/features/chat/routes/api.chat.rooms.ts +++ b/app/features/chat/routes/api.chat.rooms.ts @@ -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, + }; + }), }; }; diff --git a/app/features/chat/routes/chat-routes.test.ts b/app/features/chat/routes/chat-routes.test.ts index 458a30b33..bc66f5a6c 100644 --- a/app/features/chat/routes/chat-routes.test.ts +++ b/app/features/chat/routes/chat-routes.test.ts @@ -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!, { diff --git a/app/features/events/core/EventBus.server.ts b/app/features/events/core/EventBus.server.ts index 0f16c3662..84b64a6f1 100644 --- a/app/features/events/core/EventBus.server.ts +++ b/app/features/events/core/EventBus.server.ts @@ -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; diff --git a/app/features/events/events-types.ts b/app/features/events/events-types.ts index 7db05d32e..cc66c277d 100644 --- a/app/features/events/events-types.ts +++ b/app/features/events/events-types.ts @@ -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 } | { diff --git a/app/features/scrims/loaders/scrims.$id.server.ts b/app/features/scrims/loaders/scrims.$id.server.ts index ed0045692..b9ba64c63 100644 --- a/app/features/scrims/loaders/scrims.$id.server.ts +++ b/app/features/scrims/loaders/scrims.$id.server.ts @@ -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, }; diff --git a/app/features/sendouq-match/actions/q.match.$id.server.ts b/app/features/sendouq-match/actions/q.match.$id.server.ts index bc3157743..83b84ddad 100644 --- a/app/features/sendouq-match/actions/q.match.$id.server.ts +++ b/app/features/sendouq-match/actions/q.match.$id.server.ts @@ -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({ diff --git a/app/features/sendouq-match/loaders/q.match.$id.server.ts b/app/features/sendouq-match/loaders/q.match.$id.server.ts index e94bd382f..5cbac3572 100644 --- a/app/features/sendouq-match/loaders/q.match.$id.server.ts +++ b/app/features/sendouq-match/loaders/q.match.$id.server.ts @@ -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", + ); })(), }; }; diff --git a/app/features/sendouq-match/routes/q.match.$id.tsx b/app/features/sendouq-match/routes/q.match.$id.tsx index fdb8f267b..49f2be653 100644 --- a/app/features/sendouq-match/routes/q.match.$id.tsx +++ b/app/features/sendouq-match/routes/q.match.$id.tsx @@ -43,7 +43,7 @@ export default function SendouQMatchPage() { const data = useLoaderData(); // 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 ( diff --git a/app/features/sendouq/actions/q.looking.server.ts b/app/features/sendouq/actions/q.looking.server.ts index 1ccde0dd2..a268aa233 100644 --- a/app/features/sendouq/actions/q.looking.server.ts +++ b/app/features/sendouq/actions/q.looking.server.ts @@ -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; diff --git a/app/features/sendouq/actions/q.preparing.server.ts b/app/features/sendouq/actions/q.preparing.server.ts index 991736e22..536743554 100644 --- a/app/features/sendouq/actions/q.preparing.server.ts +++ b/app/features/sendouq/actions/q.preparing.server.ts @@ -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, diff --git a/app/features/sendouq/loaders/q.looking.server.ts b/app/features/sendouq/loaders/q.looking.server.ts index efa31f692..3928bc517 100644 --- a/app/features/sendouq/loaders/q.looking.server.ts +++ b/app/features/sendouq/loaders/q.looking.server.ts @@ -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] + : [], }; }; diff --git a/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts b/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts index 93e2f0cf5..08c5234dc 100644 --- a/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts +++ b/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts @@ -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: { diff --git a/app/features/user-page/UserRepository.server.ts b/app/features/user-page/UserRepository.server.ts index c5da444a0..7b54401d6 100644 --- a/app/features/user-page/UserRepository.server.ts +++ b/app/features/user-page/UserRepository.server.ts @@ -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 = {}; + const result: Record = {}; for (const user of users) { result[user.id] = user; diff --git a/app/form/SendouForm.tsx b/app/form/SendouForm.tsx index 637439190..4e6b86be5 100644 --- a/app/form/SendouForm.tsx +++ b/app/form/SendouForm.tsx @@ -129,6 +129,16 @@ type BaseFormProps = { * 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({ secondarySubmit, hideSubmitButtonWhen, onSuccess, + hideSubmitButton = false, + guardUnsavedChanges = true, }: SendouFormProps) { const { t } = useTranslation(["forms"]); const fetcher = useFetcher<{ fieldErrors?: Record }>(); @@ -283,7 +295,11 @@ function SendouFormInner({ 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({ <> {title ?

{title}

: null} {resolvedChildren} - {mode !== "submit" || readOnly ? null : ( + {mode !== "submit" || readOnly || hideSubmitButton ? null : ( boolean) | undefined diff --git a/locales/da/common.json b/locales/da/common.json index b5ae99d44..01eedf31a 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -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": "", diff --git a/locales/da/forms.json b/locales/da/forms.json index 8cceae3ab..33e9c54f1 100644 --- a/locales/da/forms.json +++ b/locales/da/forms.json @@ -72,6 +72,7 @@ "errors.noOnlySpecialCharacters": "", "errors.customRoleRequired": "", "labels.weaponPool": "Våbenpulje", + "placeholders.chatMessage": "", "placeholders.weaponPoolFull": "", "placeholders.vodStartTimestamp": "", "labels.voiceChat": "", diff --git a/locales/de/common.json b/locales/de/common.json index ebfc31b46..cacb40b58 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -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": "", diff --git a/locales/de/forms.json b/locales/de/forms.json index 03964118d..295ba92bb 100644 --- a/locales/de/forms.json +++ b/locales/de/forms.json @@ -72,6 +72,7 @@ "errors.noOnlySpecialCharacters": "", "errors.customRoleRequired": "", "labels.weaponPool": "Waffenpool", + "placeholders.chatMessage": "", "placeholders.weaponPoolFull": "", "placeholders.vodStartTimestamp": "", "labels.voiceChat": "", diff --git a/locales/en/common.json b/locales/en/common.json index 29390654f..8cc9e75b0 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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", diff --git a/locales/en/forms.json b/locales/en/forms.json index f42433bf3..5b02111af 100644 --- a/locales/en/forms.json +++ b/locales/en/forms.json @@ -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", diff --git a/locales/es-ES/common.json b/locales/es-ES/common.json index 32edf2dad..389829411 100644 --- a/locales/es-ES/common.json +++ b/locales/es-ES/common.json @@ -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.", diff --git a/locales/es-ES/forms.json b/locales/es-ES/forms.json index 4bd06aa6a..48daa978f 100644 --- a/locales/es-ES/forms.json +++ b/locales/es-ES/forms.json @@ -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", diff --git a/locales/es-US/common.json b/locales/es-US/common.json index 9d03c4c24..9ccee95a1 100644 --- a/locales/es-US/common.json +++ b/locales/es-US/common.json @@ -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.", diff --git a/locales/es-US/forms.json b/locales/es-US/forms.json index 633c5fdc3..66b67733f 100644 --- a/locales/es-US/forms.json +++ b/locales/es-US/forms.json @@ -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", diff --git a/locales/fr-CA/common.json b/locales/fr-CA/common.json index 2a7a8ad45..c091f6da6 100644 --- a/locales/fr-CA/common.json +++ b/locales/fr-CA/common.json @@ -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": "", diff --git a/locales/fr-CA/forms.json b/locales/fr-CA/forms.json index 5905bfc18..fb4603e30 100644 --- a/locales/fr-CA/forms.json +++ b/locales/fr-CA/forms.json @@ -72,6 +72,7 @@ "errors.noOnlySpecialCharacters": "", "errors.customRoleRequired": "", "labels.weaponPool": "Armes jouées", + "placeholders.chatMessage": "", "placeholders.weaponPoolFull": "", "placeholders.vodStartTimestamp": "", "labels.voiceChat": "", diff --git a/locales/fr-EU/common.json b/locales/fr-EU/common.json index c2ef81f2c..03b24b5f7 100644 --- a/locales/fr-EU/common.json +++ b/locales/fr-EU/common.json @@ -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": "", diff --git a/locales/fr-EU/forms.json b/locales/fr-EU/forms.json index 13d9ea32e..d19e2f310 100644 --- a/locales/fr-EU/forms.json +++ b/locales/fr-EU/forms.json @@ -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": "", diff --git a/locales/he/common.json b/locales/he/common.json index c8d15cc38..a306d7d65 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -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": "", diff --git a/locales/he/forms.json b/locales/he/forms.json index 216bcd878..db862be3f 100644 --- a/locales/he/forms.json +++ b/locales/he/forms.json @@ -72,6 +72,7 @@ "errors.noOnlySpecialCharacters": "", "errors.customRoleRequired": "", "labels.weaponPool": "מאגר נשקים", + "placeholders.chatMessage": "", "placeholders.weaponPoolFull": "", "placeholders.vodStartTimestamp": "", "labels.voiceChat": "", diff --git a/locales/it/common.json b/locales/it/common.json index 4bf376419..becc59f31 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -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": "", diff --git a/locales/it/forms.json b/locales/it/forms.json index 9fd023eec..c72ace773 100644 --- a/locales/it/forms.json +++ b/locales/it/forms.json @@ -72,6 +72,7 @@ "errors.noOnlySpecialCharacters": "", "errors.customRoleRequired": "", "labels.weaponPool": "Pool armi", + "placeholders.chatMessage": "Premi Invio per inviare", "placeholders.weaponPoolFull": "", "placeholders.vodStartTimestamp": "", "labels.voiceChat": "", diff --git a/locales/ja/common.json b/locales/ja/common.json index d9bd59fe9..fb250cfda 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -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": "フレンドコードは一度設定すると、変更できるのはスタッフのみです。", diff --git a/locales/ja/forms.json b/locales/ja/forms.json index a8f3be794..817990d61 100644 --- a/locales/ja/forms.json +++ b/locales/ja/forms.json @@ -72,6 +72,7 @@ "errors.noOnlySpecialCharacters": "", "errors.customRoleRequired": "", "labels.weaponPool": "使用ブキ", + "placeholders.chatMessage": "送信するには enter を押してください", "placeholders.weaponPoolFull": "", "placeholders.vodStartTimestamp": "", "labels.voiceChat": "", diff --git a/locales/ko/common.json b/locales/ko/common.json index 5209e10e2..cd07045ac 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -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": "", diff --git a/locales/ko/forms.json b/locales/ko/forms.json index 2c5e6a620..45080fe08 100644 --- a/locales/ko/forms.json +++ b/locales/ko/forms.json @@ -72,6 +72,7 @@ "errors.noOnlySpecialCharacters": "", "errors.customRoleRequired": "", "labels.weaponPool": "", + "placeholders.chatMessage": "", "placeholders.weaponPoolFull": "", "placeholders.vodStartTimestamp": "", "labels.voiceChat": "", diff --git a/locales/nl/common.json b/locales/nl/common.json index b1164dbae..a9bce9122 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -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": "", diff --git a/locales/nl/forms.json b/locales/nl/forms.json index 822ca81c0..7212d7144 100644 --- a/locales/nl/forms.json +++ b/locales/nl/forms.json @@ -72,6 +72,7 @@ "errors.noOnlySpecialCharacters": "", "errors.customRoleRequired": "", "labels.weaponPool": "", + "placeholders.chatMessage": "", "placeholders.weaponPoolFull": "", "placeholders.vodStartTimestamp": "", "labels.voiceChat": "", diff --git a/locales/pl/common.json b/locales/pl/common.json index 979b5eaaa..cd47b02e1 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -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": "", diff --git a/locales/pl/forms.json b/locales/pl/forms.json index 7ac276cb1..058c8dec1 100644 --- a/locales/pl/forms.json +++ b/locales/pl/forms.json @@ -72,6 +72,7 @@ "errors.noOnlySpecialCharacters": "", "errors.customRoleRequired": "", "labels.weaponPool": "Pula broni", + "placeholders.chatMessage": "", "placeholders.weaponPoolFull": "", "placeholders.vodStartTimestamp": "", "labels.voiceChat": "", diff --git a/locales/pt-BR/common.json b/locales/pt-BR/common.json index 1e5c9e372..370ec56bc 100644 --- a/locales/pt-BR/common.json +++ b/locales/pt-BR/common.json @@ -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": "", diff --git a/locales/pt-BR/forms.json b/locales/pt-BR/forms.json index 98d6edf90..07e24e069 100644 --- a/locales/pt-BR/forms.json +++ b/locales/pt-BR/forms.json @@ -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": "", diff --git a/locales/ru/common.json b/locales/ru/common.json index d5c909534..af1265ba4 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -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": "", diff --git a/locales/ru/forms.json b/locales/ru/forms.json index a059b5e5c..974353c3e 100644 --- a/locales/ru/forms.json +++ b/locales/ru/forms.json @@ -72,6 +72,7 @@ "errors.noOnlySpecialCharacters": "", "errors.customRoleRequired": "", "labels.weaponPool": "Используемое оружие", + "placeholders.chatMessage": "Нажмите enter, чтобы отправить", "placeholders.weaponPoolFull": "", "placeholders.vodStartTimestamp": "", "labels.voiceChat": "", diff --git a/locales/zh/common.json b/locales/zh/common.json index 68fcddaa7..90d7e1ed8 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -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": "设置完成后,只有工作人员能修改", diff --git a/locales/zh/forms.json b/locales/zh/forms.json index e2561dfd0..942e51103 100644 --- a/locales/zh/forms.json +++ b/locales/zh/forms.json @@ -72,6 +72,7 @@ "errors.noOnlySpecialCharacters": "名称不能仅由特殊字符组成", "errors.customRoleRequired": "请输入自定义职责的名称", "labels.weaponPool": "武器池", + "placeholders.chatMessage": "按回车键发送", "placeholders.weaponPoolFull": "武器池已满。请移除一个武器以添加新武器", "placeholders.vodStartTimestamp": "", "labels.voiceChat": "可以进行语音聊天吗?", diff --git a/scripts/benchmark-db/cases.ts b/scripts/benchmark-db/cases.ts index c136b9d70..02078aefb 100644 --- a/scripts/benchmark-db/cases.ts +++ b/scripts/benchmark-db/cases.ts @@ -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