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;
}