mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-06-23 21:22:04 -05:00
564 lines
15 KiB
TypeScript
564 lines
15 KiB
TypeScript
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.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 { 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 openRoom = (chatCode: string) => {
|
|
chatContext.requestHistory(chatCode);
|
|
chatContext.setActiveRoom(chatCode);
|
|
chatContext.markAsRead(chatCode);
|
|
};
|
|
|
|
return (
|
|
<div className={styles.sidebar}>
|
|
<SidebarHeader onClose={onClose} />
|
|
<div className={styles.roomList}>
|
|
{!isCombined && standaloneRooms.length === 0 ? (
|
|
<div className={styles.emptyState}>
|
|
{t("common:chat.sidebar.noActiveChats")}
|
|
</div>
|
|
) : (
|
|
<>
|
|
{isCombined ? (
|
|
<CombinedRoomListItem
|
|
rooms={combinedRooms}
|
|
onPress={() => openRoom(combinedRooms[0].chatCode)}
|
|
/>
|
|
) : null}
|
|
{standaloneRooms.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={() => openRoom(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) => headerFormatter.format(d) ?? "",
|
|
)}
|
|
</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}>
|
|
{timestampFormatter.format(
|
|
new Date(room.lastMessageTimestamp),
|
|
)}
|
|
</span>
|
|
) : null}
|
|
</Button>
|
|
);
|
|
})}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<Button
|
|
className={clsx(sideNavStyles.listButton, styles.roomItem)}
|
|
onPress={onPress}
|
|
>
|
|
{primary.imageUrl ? (
|
|
<img
|
|
src={primary.imageUrl}
|
|
alt=""
|
|
className={sideNavStyles.listLinkImage}
|
|
/>
|
|
) : null}
|
|
<div className={sideNavStyles.listLinkContent}>
|
|
<span className={clsx(sideNavStyles.listLinkTitle, styles.roomName)}>
|
|
{resolveDatePlaceholders(
|
|
primary.header,
|
|
(d) => headerFormatter.format(d) ?? "",
|
|
)}
|
|
</span>
|
|
<span className={sideNavStyles.listLinkSubtitle}>
|
|
{rooms.map((room) => roomShortLabel(room.header)).join(" · ")}
|
|
</span>
|
|
</div>
|
|
{unread > 0 ? <span className={styles.unreadBadge}>{unread}</span> : null}
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
function ChatView({ onClose }: { onClose?: () => void }) {
|
|
const chatContext = useChatContext()!;
|
|
const activeRoom = chatContext.activeRoom!;
|
|
|
|
const routeChatCodes = useCurrentRouteChatCodes();
|
|
const groupRooms = routeChatCodes
|
|
.map((code) => chatContext.rooms.find((r) => r.chatCode === code))
|
|
.filter((r): r is RoomInfo => Boolean(r));
|
|
|
|
const isCombined =
|
|
groupRooms.length > 1 && routeChatCodes.includes(activeRoom);
|
|
|
|
if (isCombined) {
|
|
return <CombinedChatView rooms={groupRooms} onClose={onClose} />;
|
|
}
|
|
|
|
return <SingleChatView onClose={onClose} />;
|
|
}
|
|
|
|
function SingleChatView({ onClose }: { onClose?: () => void }) {
|
|
const { t } = useTranslation(["common"]);
|
|
const chatContext = useChatContext()!;
|
|
const activeRoom = chatContext.activeRoom!;
|
|
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<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) => headerFormatter.format(d) ?? "",
|
|
)}
|
|
</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>
|
|
);
|
|
}
|
|
|
|
function CombinedChatView({
|
|
rooms,
|
|
onClose,
|
|
}: {
|
|
rooms: RoomInfo[];
|
|
onClose?: () => void;
|
|
}) {
|
|
const chatContext = useChatContext()!;
|
|
const { formatter: headerFormatter } = useDateTimeFormat({
|
|
month: "numeric",
|
|
day: "numeric",
|
|
hour: "numeric",
|
|
minute: "numeric",
|
|
});
|
|
|
|
const roomCodesKey = rooms.map((r) => r.chatCode).join(",");
|
|
React.useEffect(() => {
|
|
for (const code of roomCodesKey.split(",")) {
|
|
chatContext.requestHistory(code);
|
|
}
|
|
}, [roomCodesKey, chatContext.requestHistory]);
|
|
|
|
const handleBack = () => {
|
|
chatContext.setActiveRoom(null);
|
|
};
|
|
|
|
const primary = rooms[0];
|
|
const headerContent = (
|
|
<>
|
|
{primary.imageUrl ? (
|
|
<img
|
|
src={primary.imageUrl}
|
|
alt=""
|
|
className={sideNavStyles.listLinkImage}
|
|
/>
|
|
) : null}
|
|
<div className={styles.chatHeaderInfo}>
|
|
<span className={styles.chatHeaderTitle}>
|
|
{resolveDatePlaceholders(
|
|
primary.header,
|
|
(d) => headerFormatter.format(d) ?? "",
|
|
)}
|
|
</span>
|
|
{primary.subtitle ? (
|
|
<span className={styles.chatHeaderSubtitle}>{primary.subtitle}</span>
|
|
) : null}
|
|
</div>
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<div className={styles.sidebar}>
|
|
<div className={styles.chatHeader}>
|
|
<Button className={styles.backButton} onPress={handleBack}>
|
|
<ArrowLeft size={18} />
|
|
</Button>
|
|
{primary.url ? (
|
|
<Link to={primary.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>
|
|
<SplitPanels rooms={rooms} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className={styles.splitView}>
|
|
{panels.map(({ room, grow, showHeader }) => (
|
|
<SplitPanel
|
|
key={room.chatCode}
|
|
room={room}
|
|
grow={grow}
|
|
showHeader={showHeader}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SplitPanel({
|
|
room,
|
|
grow,
|
|
showHeader,
|
|
}: {
|
|
room: RoomInfo;
|
|
grow: number;
|
|
showHeader: boolean;
|
|
}) {
|
|
return (
|
|
<div
|
|
className={styles.splitPanel}
|
|
style={{ "--split-grow": grow } as React.CSSProperties}
|
|
>
|
|
{showHeader ? (
|
|
<div className={styles.splitPanelHeader}>
|
|
{roomShortLabel(room.header)}
|
|
</div>
|
|
) : null}
|
|
<div className={styles.chatContainer}>
|
|
<RoomChat room={room} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<string, number>(),
|
|
};
|
|
|
|
return (
|
|
<Chat
|
|
users={usersWithLabels}
|
|
rooms={[{ label: room.header, code: room.chatCode }]}
|
|
chat={chatAdapter}
|
|
disabled={roomExpired}
|
|
/>
|
|
);
|
|
}
|
|
|
|
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;
|
|
}
|