import clsx from "clsx"; import type { TFunction } from "i18next"; import { ArrowLeft, ChevronDown, ChevronRight, MessageSquare, X, } from "lucide-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; import { useChatContext, useCurrentRouteChatRooms, } from "~/features/chat/ChatProvider"; import type { ChatRoomListItem } from "~/features/chat/chat-types"; import { Chat } from "~/features/chat/components/Chat"; import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat"; import { databaseTimestampToDate, dateToDatabaseTimestamp, } from "~/utils/dates"; import { navIconUrl } from "~/utils/urls"; import { NavIconContainer, NavListButton, NavListImage, NavListSubtitle, NavListTexts, NavListTitle, } from "../SideNav"; import styles from "./ChatSidebar.module.css"; export function ChatSidebar({ onClose }: { onClose?: () => void }) { const chatContext = useChatContext(); if (!chatContext) return null; if (chatContext.activeRoomIds.length > 0) { return ; } 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"]); return (

{t("common:chat.sidebar.title")}

{onClose ? ( ) : null}
); } function LoadingState({ onClose }: { onClose?: () => void }) { const { t } = useTranslation(["common"]); return (
{t("common:chat.connecting")}
); } function RoomList({ onClose }: { onClose?: () => void }) { const { t } = useTranslation(["common"]); const chatContext = useChatContext()!; const [showInactive, setShowInactive] = React.useState(false); const byRecency = (a: ChatRoomListItem, b: ChatRoomListItem) => (b.latestMessageAt ?? 0) - (a.latestMessageAt ?? 0) || b.id - a.id; const routeRooms = useCurrentRouteChatRooms().flatMap((entry) => { const room = chatContext.roomForId(entry.roomId); return room ? [{ ...entry, room }] : []; }); // rooms the active route opens together collapse into one entry opening the stacked split view const autoOpenRooms = routeRooms.filter((entry) => entry.autoOpen); const combinedRooms = autoOpenRooms.length > 1 ? autoOpenRooms : []; const combinedRoomIds = new Set(combinedRooms.map((entry) => entry.room.id)); // route rooms outside the user's own list (an observer's match chat, the // group chats a staff member may read) are surfaced on top of it const pinnedRooms = routeRooms.filter( (entry) => !combinedRoomIds.has(entry.room.id) && chatContext.rooms.every((own) => own.id !== entry.room.id), ); const standaloneRooms = chatContext.rooms.filter( (room) => !combinedRoomIds.has(room.id), ); // an inactive room that got a new message surfaces in the normal list until read const activeRooms = standaloneRooms .filter((room) => !roomIsInactive(room) || room.unreadCount > 0) .sort(byRecency); const inactiveRooms = standaloneRooms .filter((room) => roomIsInactive(room) && room.unreadCount === 0) .sort(byRecency); const openRooms = (roomIds: number[]) => { for (const roomId of roomIds) { chatContext.ensureMessagesLoaded(roomId); chatContext.markAsRead(roomId); } chatContext.setActiveRoomIds(roomIds); }; const hasAnyRoom = combinedRooms.length > 0 || pinnedRooms.length > 0 || standaloneRooms.length > 0; return (
{!hasAnyRoom ? (
{t("common:chat.sidebar.noActiveChats")}
) : ( <> {combinedRooms.length > 0 ? ( entry.room)} onClick={() => openRooms(combinedRooms.map((entry) => entry.room.id)) } /> ) : null} {pinnedRooms.map(({ room, label }) => ( openRooms([room.id])} /> ))} {activeRooms.map((room) => ( openRooms([room.id])} /> ))} {inactiveRooms.length > 0 ? ( <> {showInactive ? inactiveRooms.map((room) => ( openRooms([room.id])} /> )) : null} ) : null} )}
); } function RoomListItem({ room, inactive = false, subtitle: subtitleOverride, onClick, }: { room: ChatRoomListItem; inactive?: boolean; /** Names the room in place of its own subtitle, where that can't tell it apart. */ subtitle?: string; onClick: () => void; }) { const roomDisplay = useRoomDisplay(); const { formatter: timestampFormatter } = useDateTimeFormat({ hour: "numeric", minute: "numeric", }); const { title, subtitle: roomSubtitle, imageUrl } = roomDisplay(room); const subtitle = subtitleOverride ?? roomSubtitle; return ( {title} {subtitle} {room.unreadCount > 0 ? ( {room.unreadCount} ) : room.latestMessageAt !== null ? ( {timestampFormatter.format( databaseTimestampToDate(room.latestMessageAt), )} ) : null} ); } function CombinedRoomListItem({ rooms, onClick, }: { rooms: ChatRoomListItem[]; onClick: () => 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 ( {title} {rooms.map((room) => roomShortLabel(room, t)).join(" ยท ")} {unread > 0 ? {unread} : null} ); } function ChatView({ onClose }: { onClose?: () => void }) { const chatContext = useChatContext()!; const activeRooms = chatContext.activeRoomIds .map((roomId) => chatContext.roomForId(roomId)) .filter((r): r is ChatRoomListItem => Boolean(r)); if (activeRooms.length > 1) { return ; } return ; } function SingleChatView({ room, onClose, }: { room: ChatRoomListItem | undefined; onClose?: () => void; }) { const { t } = useTranslation(["common"]); const chatContext = useChatContext()!; const roomDisplay = useRoomDisplay(); const routeLabel = useCurrentRouteChatRooms().find( (entry) => entry.roomId === room?.id, )?.label; const otherRoomsUnreadCount = chatContext.rooms .filter((candidate) => candidate.id !== room?.id) .reduce((sum, candidate) => sum + candidate.unreadCount, 0); const display = room ? roomDisplay(room) : null; const subtitle = routeLabel ?? display?.subtitle; const headerContent = ( <> {display ? : null}
{display?.title ?? t("common:chat.sidebar.title")} {subtitle ? ( {subtitle} ) : null}
); return (
{room?.url ? ( {headerContent} ) : (
{headerContent}
)} {onClose ? ( ) : null}
{/* keyed so switching rooms starts the scroller and its unseen state over */} {room ? : null}
); } function CombinedChatView({ rooms, onClose, }: { rooms: ChatRoomListItem[]; onClose?: () => void; }) { const chatContext = useChatContext()!; const roomDisplay = useRoomDisplay(); const primary = rooms[0]; const display = roomDisplay(primary); const headerContent = ( <>
{display.title} {display.subtitle ? ( {display.subtitle} ) : null}
); return (
{primary.url ? ( {headerContent} ) : (
{headerContent}
)} {onClose ? ( ) : null}
{rooms.map((room, index) => ( 0} /> ))}
); } /** The primary (match) room sits on top with its sub-header hidden, the main header already names it. */ function SplitPanel({ room, showHeader, }: { room: ChatRoomListItem; showHeader: boolean; }) { const { t } = useTranslation(["common"]); return (
{showHeader ? (
{roomShortLabel(room, t)}
) : null}
); } function RoomChat({ room }: { room: ChatRoomListItem }) { const chatContext = useChatContext()!; const expired = room.expiresAt <= dateToDatabaseTimestamp(new Date()); return ( chatContext.sendMessage(room.id, message)} labelByUserId={room.labelByUserId} disabled={expired} readOnly={!expired && !room.canPost} /> ); }