sendou.ink/app/components/layout/ChatSidebar.tsx
Kalle 747a63edbc Show current route's chat in room list even when expired
Non-participants (e.g. tournament organizers) viewing a tournament
match chat could see the chat auto-open correctly, but clicking the
back arrow to the room list left the chat invisible — the only way
back was to leave the route and return.

The match chat appears in chatContext.rooms via the SUBSCRIBE
response, which (unlike the initial-payload path that participants
go through) does not check room expiry on the skalop side. So an
organizer viewing an old match ends up with an expired room in
their rooms list. ChatView already handles this gracefully with a
read-only banner, but RoomList was filtering on
expiresAt > Date.now() and dropping it.

Cleanup in useChatRouteSync removes the room from chatContext.rooms
on navigation, so exempting the current route's chatCode from the
expiry filter only affects the page that subscribed to it.
2026-05-02 11:36:56 +03:00

270 lines
7.3 KiB
TypeScript

import clsx from "clsx";
import { ArrowLeft, MessageSquare, X } from "lucide-react";
import { Button } from "react-aria-components";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { useCurrentRouteChatCode } from "~/features/chat/ChatProvider";
import { resolveDatePlaceholders } from "~/features/chat/chat-utils";
import { Chat } from "~/features/chat/components/Chat";
import { useChatContext } from "~/features/chat/useChatContext";
import { useTimeFormat } from "~/hooks/useTimeFormat";
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.activeRoom) {
return <ChatView onClose={onClose} />;
}
if (chatContext.isLoading) {
return <LoadingState onClose={onClose} />;
}
return <RoomList onClose={onClose} />;
}
function SidebarHeader({ onClose }: { onClose?: () => void }) {
const { t } = useTranslation(["common"]);
return (
<div className={styles.sidebarHeader}>
<div className={sideNavStyles.iconContainer}>
<MessageSquare size={18} />
</div>
<h2>{t("common:chat.sidebar.title")}</h2>
{onClose ? (
<Button className={styles.closeButton} onPress={onClose}>
<X size={18} />
</Button>
) : null}
</div>
);
}
function LoadingState({ onClose }: { onClose?: () => void }) {
const { t } = useTranslation(["common"]);
return (
<div className={styles.sidebar}>
<SidebarHeader onClose={onClose} />
<div className={styles.roomList}>
<div className={styles.emptyState}>{t("common:chat.connecting")}</div>
</div>
</div>
);
}
function RoomList({ onClose }: { onClose?: () => void }) {
const { t } = useTranslation(["common"]);
const chatContext = useChatContext()!;
const { formatDateTime } = useTimeFormat();
const rawRouteChatCode = useCurrentRouteChatCode();
const routeChatCodes = rawRouteChatCode
? Array.isArray(rawRouteChatCode)
? rawRouteChatCode
: [rawRouteChatCode]
: [];
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;
});
return (
<div className={styles.sidebar}>
<SidebarHeader onClose={onClose} />
<div className={styles.roomList}>
{visibleRooms.length === 0 ? (
<div className={styles.emptyState}>
{t("common:chat.sidebar.noActiveChats")}
</div>
) : (
visibleRooms.map((room) => {
const unread = chatContext.unreadCounts[room.chatCode] ?? 0;
return (
<Button
key={room.chatCode}
className={clsx(
sideNavStyles.listButton,
styles.roomItem,
room.isObsolete ? "opaque" : null,
)}
onPress={() => {
chatContext.requestHistory(room.chatCode);
chatContext.setActiveRoom(room.chatCode);
chatContext.markAsRead(room.chatCode);
}}
>
{room.imageUrl ? (
<img
src={room.imageUrl}
alt=""
className={sideNavStyles.listLinkImage}
/>
) : null}
<div className={sideNavStyles.listLinkContent}>
<span
className={clsx(
sideNavStyles.listLinkTitle,
styles.roomName,
room.isObsolete ? "line-through" : null,
)}
>
{resolveDatePlaceholders(room.header, (d) =>
formatDateTime(d, {
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
}),
)}
</span>
<span className={sideNavStyles.listLinkSubtitle}>
{room.subtitle}
</span>
</div>
{unread > 0 && !room.isObsolete ? (
<span className={styles.unreadBadge}>{unread}</span>
) : room.lastMessageTimestamp > 0 ? (
<span className={styles.roomTimestamp}>
{formatDateTime(new Date(room.lastMessageTimestamp), {
hour: "numeric",
minute: "numeric",
})}
</span>
) : null}
</Button>
);
})
)}
</div>
</div>
);
}
function ChatView({ onClose }: { onClose?: () => void }) {
const { t } = useTranslation(["common"]);
const chatContext = useChatContext()!;
const activeRoom = chatContext.activeRoom!;
const { formatDateTime } = useTimeFormat();
const otherRoomsUnreadCount = Object.entries(chatContext.unreadCounts)
.filter(([code]) => code !== activeRoom)
.reduce((sum, [, count]) => sum + count, 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 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 };
}
}
const chatAdapter = {
messages,
send: (contents: string) => chatContext.send(activeRoom, contents),
currentRoom: activeRoom,
setCurrentRoom: () => {},
readyState: chatContext.readyState,
unseenMessages: new Map<string, number>(),
};
const handleBack = () => {
chatContext.setActiveRoom(null);
};
const headerContent = (
<>
{room?.imageUrl ? (
<img
src={room.imageUrl}
alt=""
className={sideNavStyles.listLinkImage}
/>
) : null}
<div className={styles.chatHeaderInfo}>
<span
className={clsx(
styles.chatHeaderTitle,
room?.isObsolete ? "line-through" : null,
)}
>
{resolveDatePlaceholders(
room?.header ?? t("common:chat.sidebar.title"),
(d) =>
formatDateTime(d, {
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
}),
)}
</span>
{room?.subtitle ? (
<span className={styles.chatHeaderSubtitle}>{room.subtitle}</span>
) : null}
</div>
</>
);
return (
<div className={styles.sidebar}>
<div className={styles.chatHeader}>
<Button className={styles.backButton} onPress={handleBack}>
<ArrowLeft size={18} />
{otherRoomsUnreadCount > 0 ? (
<span className={styles.backButtonBadge}>
{otherRoomsUnreadCount}
</span>
) : null}
</Button>
{room?.url ? (
<Link to={room.url} className={styles.chatHeaderLink}>
{headerContent}
</Link>
) : (
<div className={styles.chatHeaderLink}>{headerContent}</div>
)}
{onClose ? (
<Button className={styles.closeButton} onPress={onClose}>
<X size={18} />
</Button>
) : null}
</div>
<div className={styles.chatContainer}>
<Chat
users={usersWithLabels}
rooms={[
{
label: room?.header ?? "Chat",
code: activeRoom,
},
]}
chat={chatAdapter}
disabled={roomExpired}
/>
</div>
</div>
);
}