sendou.ink/app/features/chat/components/Chat.tsx
2026-03-19 17:39:35 +02:00

321 lines
7.7 KiB
TypeScript

import clsx from "clsx";
import { sub } from "date-fns";
import { SendHorizontal } from "lucide-react";
import * as React from "react";
import { Button } from "react-aria-components";
import { useTranslation } from "react-i18next";
import { Avatar } from "../../../components/Avatar";
import { SendouButton } from "../../../components/elements/Button";
import { SubmitButton } from "../../../components/SubmitButton";
import { useTimeFormat } from "../../../hooks/useTimeFormat";
import { MESSAGE_MAX_LENGTH } from "../chat-constants";
import { useChatAutoScroll } from "../chat-hooks";
import type { ChatMessage, ChatProps, ChatUser } from "../chat-types";
import styles from "./Chat.module.css";
export interface ChatAdapter {
messages: ChatMessage[];
send: (contents: string) => void;
currentRoom: string | undefined;
setCurrentRoom: (room: string) => void;
readyState: "CONNECTING" | "CONNECTED" | "CLOSED";
unseenMessages: Map<string, number>;
}
export function Chat({
users,
rooms,
className,
messagesContainerClassName,
hidden = false,
chat,
onMount,
onUnmount,
disabled,
missingUserName,
}: Omit<ChatProps, "onNewMessage" | "revalidates"> & {
chat: ChatAdapter;
}) {
const { t } = useTranslation(["common"]);
const messagesContainerRef = React.useRef<HTMLOListElement>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const {
send,
messages,
currentRoom,
setCurrentRoom,
readyState,
unseenMessages,
} = chat;
const handleSubmit = React.useCallback(
(e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
// can't send empty messages
if (inputRef.current!.value.trim().length === 0) {
return;
}
send(inputRef.current!.value);
inputRef.current!.value = "";
},
[send],
);
const { unseenMessagesInTheRoom, scrollToBottom, resetScroller } =
useChatAutoScroll(messages, messagesContainerRef);
React.useEffect(() => {
onMount?.();
return () => {
onUnmount?.();
};
}, [onMount, onUnmount]);
const sendingMessagesDisabled = disabled || readyState !== "CONNECTED";
const systemMessageText = (msg: ChatMessage) => {
const name = () => {
if (!msg.context) return "";
return msg.context.name;
};
switch (msg.type) {
case "SCORE_REPORTED": {
return t("common:chat.systemMsg.scoreReported", { name: name() });
}
case "SCORE_CONFIRMED": {
return t("common:chat.systemMsg.scoreConfirmed", { name: name() });
}
case "CANCEL_REPORTED": {
return t("common:chat.systemMsg.cancelReported", { name: name() });
}
case "CANCEL_CONFIRMED": {
return t("common:chat.systemMsg.cancelConfirmed", { name: name() });
}
case "USER_LEFT": {
return t("common:chat.systemMsg.userLeft", { name: name() });
}
default: {
return null;
}
}
};
return (
<section className={clsx(styles.container, className, { hidden })}>
{rooms.length > 1 ? (
<div className="stack horizontal">
{rooms.map((room) => {
const unseen = unseenMessages.get(room.code);
return (
<Button
key={room.code}
className={clsx(styles.roomButton, {
[styles.roomButtonCurrent]: currentRoom === room.code,
})}
onPress={() => {
setCurrentRoom(room.code);
resetScroller();
}}
>
<span className={clsx(styles.roomButtonUnseen, "invisible")} />
{room.label}
{unseen ? (
<span className={styles.roomButtonUnseen}>{unseen}</span>
) : (
<span
className={clsx(styles.roomButtonUnseen, "invisible")}
/>
)}
</Button>
);
})}
</div>
) : null}
<div className={styles.inputContainer}>
<ol
className={clsx(
styles.messages,
"scrollbar",
messagesContainerClassName,
)}
ref={messagesContainerRef}
>
{messages.map((msg) => {
const systemMessage = systemMessageText(msg);
if (systemMessage) {
return (
<SystemMessage
key={msg.id}
message={msg}
text={systemMessage}
/>
);
}
const user = msg.userId ? users[msg.userId] : null;
if (!user && !missingUserName) return null;
return (
<Message
key={msg.id}
user={user}
missingUserName={missingUserName}
message={msg}
/>
);
})}
</ol>
{unseenMessagesInTheRoom ? (
<SendouButton
className={styles.unseenMessages}
onPress={scrollToBottom}
>
{t("common:chat.newMessages")}
</SendouButton>
) : null}
{disabled ? (
<div className="text-xs text-lighter text-center my-4">
{t("common:chat.expired")}
</div>
) : (
<form onSubmit={handleSubmit} className="mt-4">
<input
className="w-full text-xs"
ref={inputRef}
placeholder={t("common:chat.input.placeholder")}
disabled={sendingMessagesDisabled}
maxLength={MESSAGE_MAX_LENGTH}
/>{" "}
<div className={styles.bottomRow}>
{readyState === "CONNECTED" || readyState === "CONNECTING" ? (
<div className="text-xxs font-semi-bold text-lighter">
{t(
readyState === "CONNECTED"
? "common:chat.connected"
: "common:chat.connecting",
)}
</div>
) : (
<div className="text-xxs font-semi-bold text-warning">
{t("common:chat.disconnected")}
</div>
)}
<SubmitButton
className={styles.sendButton}
size="small"
isDisabled={sendingMessagesDisabled}
aria-label={t("common:chat.send")}
icon={<SendHorizontal size={16} />}
testId="chat-submit-button"
/>
</div>
</form>
)}
</div>
</section>
);
}
function Message({
user,
message,
missingUserName,
}: {
user?: ChatUser | null;
message: ChatMessage;
missingUserName?: string;
}) {
return (
<li className={styles.message}>
{user ? (
<div
className={clsx(styles.avatarWrapper, {
[styles.avatarWrapperStaff]: user.title,
})}
>
<Avatar user={user} size="xs" />
{user.title ? (
<span className={styles.avatarBadge}>{user.title}</span>
) : null}
</div>
) : null}
<div>
<div className={styles.messageInfo}>
<div
className={styles.messageUser}
style={
user?.chatNameHue ? { "--chat-hue": user.chatNameHue } : undefined
}
>
{user?.username ?? missingUserName}
</div>
{user?.pronouns ? (
<span className={styles.pronounsTag}>
{user.pronouns.subject}/{user.pronouns.object}
</span>
) : null}
{!message.pending ? (
<MessageTimestamp timestamp={message.timestamp} />
) : null}
</div>
<div
className={clsx(styles.messageContents, {
[styles.messageContentsPending]: message.pending,
})}
>
{message.contents}
</div>
</div>
</li>
);
}
function SystemMessage({
message,
text,
}: {
message: ChatMessage;
text: string;
}) {
return (
<li className={styles.message}>
<div>
<div className="stack horizontal sm">
<MessageTimestamp timestamp={message.timestamp} />
</div>
<div
className={clsx(
styles.messageContents,
"text-xs text-lighter font-semi-bold",
)}
>
{text}
</div>
</div>
</li>
);
}
function MessageTimestamp({ timestamp }: { timestamp: number }) {
const { formatDateTime, formatTime } = useTimeFormat();
const moreThanDayAgo = sub(new Date(), { days: 1 }) > new Date(timestamp);
return (
<time className={styles.messageTime}>
{moreThanDayAgo
? formatDateTime(new Date(timestamp), {
day: "numeric",
month: "numeric",
hour: "numeric",
minute: "numeric",
})
: formatTime(new Date(timestamp))}
</time>
);
}