mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-04-24 23:19:39 -05:00
321 lines
7.7 KiB
TypeScript
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>
|
|
);
|
|
}
|