mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-05-06 13:19:31 -05:00
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.
270 lines
7.3 KiB
TypeScript
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>
|
|
);
|
|
}
|