import clsx from "clsx"; import { ArrowLeft, 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 { Chat } from "~/features/chat/components/Chat"; import { useChatContext } from "~/features/chat/useChatContext"; import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat"; import { useLayoutSize } from "~/hooks/useMainContentWidth"; import sideNavStyles from "../SideNav.module.css"; import styles from "./ChatSidebar.module.css"; export function ChatSidebar({ onClose }: { onClose?: () => void }) { const chatContext = useChatContext(); if (!chatContext) return null; if (chatContext.activeRooms.length > 0) { return ; } if (chatContext.isLoading) { return ; } return ; } 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 { formatter: headerFormatter } = useDateTimeFormat({ month: "numeric", day: "numeric", hour: "numeric", minute: "numeric", }); const { formatter: timestampFormatter } = useDateTimeFormat({ hour: "numeric", minute: "numeric", }); const routeChatCodes = useCurrentRouteChatCodes(); 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; }); // Rooms the active route groups together collapse into a single combined // list entry that opens the stacked/tabbed view. const combinedRooms = routeChatCodes.length > 1 ? routeChatCodes .map((code) => visibleRooms.find((r) => r.chatCode === code)) .filter((r): r is RoomInfo => 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 openRooms = (chatCodes: string[]) => { for (const chatCode of chatCodes) { chatContext.requestHistory(chatCode); chatContext.markAsRead(chatCode); } chatContext.setActiveRooms(chatCodes); }; return (
{!isCombined && standaloneRooms.length === 0 ? (
{t("common:chat.sidebar.noActiveChats")}
) : ( <> {isCombined ? ( openRooms(combinedRooms.map((room) => room.chatCode)) } /> ) : null} {standaloneRooms.map((room) => { const unread = chatContext.unreadCounts[room.chatCode] ?? 0; return ( ); })} )}
); } function CombinedRoomListItem({ rooms, onPress, }: { rooms: RoomInfo[]; onPress: () => void; }) { const chatContext = useChatContext()!; const { formatter: headerFormatter } = useDateTimeFormat({ month: "numeric", day: "numeric", hour: "numeric", minute: "numeric", }); const primary = rooms[0]; const unread = rooms.reduce( (sum, room) => sum + (chatContext.unreadCounts[room.chatCode] ?? 0), 0, ); return ( ); } 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)); if (activeRooms.length > 1) { return ; } return ; } function SingleChatView({ onClose }: { 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 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, ); 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 headerContent = ( <> {room?.imageUrl ? ( ) : null}
{resolveDatePlaceholders( room?.header ?? t("common:chat.sidebar.title"), (d) => headerFormatter.format(d) ?? "", )} {room?.subtitle ? ( {room.subtitle} ) : null}
); return (
{room?.url ? ( {headerContent} ) : (
{headerContent}
)} {onClose ? ( ) : null}
); } function CombinedChatView({ rooms, onClose, }: { rooms: RoomInfo[]; onClose?: () => void; }) { const chatContext = useChatContext()!; const { formatter: headerFormatter } = useDateTimeFormat({ month: "numeric", day: "numeric", hour: "numeric", minute: "numeric", }); const handleBack = () => { chatContext.setActiveRooms([]); }; const primary = rooms[0]; const headerContent = ( <> {primary.imageUrl ? ( ) : null}
{resolveDatePlaceholders( primary.header, (d) => headerFormatter.format(d) ?? "", )} {primary.subtitle ? ( {primary.subtitle} ) : null}
); return (
{primary.url ? ( {headerContent} ) : (
{headerContent}
)} {onClose ? ( ) : 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 }) => ( ))}
); } function SplitPanel({ room, grow, showHeader, }: { room: RoomInfo; grow: number; showHeader: boolean; }) { return (
{showHeader ? (
{roomShortLabel(room.header)}
) : null}
); } function RoomChat({ room }: { room: RoomInfo }) { 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 ( ); } function roomUsersWithLabels( chatContext: ChatContextValue, room: RoomInfo | undefined, ) { const participantIds = new Set(room?.participantUserIds ?? []); const usersWithLabels = { ...chatContext.chatUsers }; 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 }; } } 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; }