mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-27 05:38:09 -05:00
414 lines
10 KiB
TypeScript
414 lines
10 KiB
TypeScript
import clsx from "clsx";
|
|
import { sub } from "date-fns";
|
|
import { SendHorizontal } from "lucide-react";
|
|
import { QRCodeSVG } from "qrcode.react";
|
|
import * as React from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useLocation } from "react-router";
|
|
import { useEventsReadyState } from "~/features/events/events-hooks";
|
|
import {
|
|
type FormRenderProps,
|
|
SendouForm,
|
|
useFormValue,
|
|
} from "~/form/SendouForm";
|
|
import { useVirtualizer } from "~/modules/virtualizer/react";
|
|
import { databaseTimestampToDate } from "~/utils/dates";
|
|
import { shortNanoid } from "~/utils/id";
|
|
import { Avatar } from "../../../components/Avatar";
|
|
import { SendouButton } from "../../../components/elements/Button";
|
|
import { SubmitButton } from "../../../components/SubmitButton";
|
|
import { useDateTimeFormat } from "../../../hooks/intl/useDateTimeFormat";
|
|
import { useChatAutoScroll } from "../chat-hooks";
|
|
import { findRoomLinks } from "../chat-message-links";
|
|
import { sendChatMessageSchema } from "../chat-schemas";
|
|
import type { ChatMessageAuthor, ClientChatMessage } from "../chat-types";
|
|
import styles from "./Chat.module.css";
|
|
|
|
const MESSAGE_GAP = 8;
|
|
const ESTIMATED_MESSAGE_HEIGHT = 44;
|
|
|
|
export interface ChatProps {
|
|
messages: ClientChatMessage[];
|
|
/** Hands a validated composer send to the chat client (optimistic append + POST). */
|
|
onSend: (message: { publicId: string; contents: string }) => void;
|
|
/** Role labels (e.g. "TO") shown next to the author, keyed by user id. */
|
|
labelByUserId?: Record<number, string>;
|
|
className?: string;
|
|
messagesContainerClassName?: string;
|
|
/** Renders the room read-only with an expiry note, e.g. once it has expired. */
|
|
disabled?: boolean;
|
|
/** Renders the room read-only for a viewer who may never post in it (staff reading a private room). */
|
|
readOnly?: boolean;
|
|
}
|
|
|
|
export function Chat({
|
|
messages,
|
|
onSend,
|
|
labelByUserId,
|
|
className,
|
|
messagesContainerClassName,
|
|
disabled,
|
|
readOnly,
|
|
}: ChatProps) {
|
|
const { t } = useTranslation(["common"]);
|
|
const messagesContainerRef = React.useRef<HTMLDivElement>(null);
|
|
|
|
const { unseenMessagesInTheRoom, scrollToBottom } = useChatAutoScroll(
|
|
messages,
|
|
messagesContainerRef,
|
|
);
|
|
|
|
const systemMessageText = (msg: ClientChatMessage) => {
|
|
const name = msg.author?.username ?? "";
|
|
|
|
switch (msg.type) {
|
|
case "SCORE_REPORTED": {
|
|
return t("common:chat.systemMsg.scoreReported", { name });
|
|
}
|
|
case "SCORE_CONFIRMED": {
|
|
return t("common:chat.systemMsg.scoreConfirmed", { name });
|
|
}
|
|
case "CANCEL_REPORTED": {
|
|
return t("common:chat.systemMsg.cancelReported", { name });
|
|
}
|
|
case "CANCEL_CONFIRMED": {
|
|
return t("common:chat.systemMsg.cancelConfirmed", { name });
|
|
}
|
|
case "CANCEL_REFUSED": {
|
|
return t("common:chat.systemMsg.cancelRefused", { name });
|
|
}
|
|
case "USER_LEFT": {
|
|
return t("common:chat.systemMsg.userLeft", { name });
|
|
}
|
|
case "MAP_REPLAYED": {
|
|
return t("common:chat.systemMsg.mapReplayed", { name });
|
|
}
|
|
case "MAP_PICKED": {
|
|
return t("common:chat.systemMsg.mapPicked", { name });
|
|
}
|
|
case "MAP_BANNED": {
|
|
return t("common:chat.systemMsg.mapBanned", { name });
|
|
}
|
|
case "MODE_PICKED": {
|
|
return t("common:chat.systemMsg.modePicked", { name });
|
|
}
|
|
case "MODE_BANNED": {
|
|
return t("common:chat.systemMsg.modeBanned", { name });
|
|
}
|
|
default: {
|
|
return null;
|
|
}
|
|
}
|
|
};
|
|
|
|
const virtualizer = useVirtualizer({
|
|
count: messages.length,
|
|
scrollRef: messagesContainerRef,
|
|
estimatedSize: ESTIMATED_MESSAGE_HEIGHT,
|
|
gap: MESSAGE_GAP,
|
|
});
|
|
|
|
return (
|
|
<section className={clsx(styles.container, className)}>
|
|
<div className={styles.inputContainer}>
|
|
<div
|
|
ref={messagesContainerRef}
|
|
role="log"
|
|
aria-label="Chat messages"
|
|
className={clsx(
|
|
styles.messages,
|
|
"scrollbar",
|
|
messagesContainerClassName,
|
|
)}
|
|
>
|
|
<div
|
|
className={styles.messagesSizer}
|
|
style={{ height: virtualizer.totalSize }}
|
|
>
|
|
{virtualizer.items.map(({ index, start }) => {
|
|
const msg = messages[index];
|
|
const systemMessage = systemMessageText(msg);
|
|
|
|
return (
|
|
<div
|
|
key={msg.publicId}
|
|
ref={virtualizer.measureElement(index)}
|
|
className={styles.messageRow}
|
|
data-testid="chat-message-row"
|
|
style={{ transform: `translateY(${start}px)` }}
|
|
>
|
|
{systemMessage ? (
|
|
<SystemMessage message={msg} text={systemMessage} />
|
|
) : (
|
|
<Message
|
|
message={msg}
|
|
label={
|
|
msg.authorUserId != null
|
|
? labelByUserId?.[msg.authorUserId]
|
|
: undefined
|
|
}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
{unseenMessagesInTheRoom ? (
|
|
<SendouButton
|
|
className={styles.unseenMessages}
|
|
onClick={scrollToBottom}
|
|
>
|
|
{t("common:chat.newMessages")}
|
|
</SendouButton>
|
|
) : null}
|
|
{readOnly ? (
|
|
// only observers ever see this, so it stays English
|
|
<div className="text-xs text-lighter text-center my-4">Read-only</div>
|
|
) : disabled ? (
|
|
<div className="text-xs text-lighter text-center my-4">
|
|
{t("common:chat.expired")}
|
|
</div>
|
|
) : (
|
|
<Composer onSend={onSend} />
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function Composer({ onSend }: { onSend: ChatProps["onSend"] }) {
|
|
const { t } = useTranslation(["common"]);
|
|
const { pathname } = useLocation();
|
|
const readyState = useEventsReadyState();
|
|
const [publicId, setPublicId] = React.useState(() => shortNanoid());
|
|
const [hasSent, setHasSent] = React.useState(false);
|
|
|
|
// a send's autofocus must not carry over to an unrelated page
|
|
React.useEffect(() => {
|
|
setHasSent(false);
|
|
}, [pathname]);
|
|
|
|
const sendingDisabled = readyState !== "CONNECTED";
|
|
|
|
return (
|
|
<SendouForm
|
|
key={publicId}
|
|
schema={sendChatMessageSchema}
|
|
defaultValues={{ publicId }}
|
|
className={styles.composer}
|
|
hideSubmitButton
|
|
guardUnsavedChanges={false}
|
|
// `onApply` bypasses the router: chat-client POSTs the message itself,
|
|
// so sending never revalidates the page's loaders
|
|
onApply={(values) => {
|
|
onSend(values);
|
|
setPublicId(shortNanoid());
|
|
setHasSent(true);
|
|
}}
|
|
>
|
|
{({ FormField }) => (
|
|
<>
|
|
{readyState !== "CONNECTED" ? (
|
|
<div
|
|
className={clsx(
|
|
"text-xxs font-semi-bold",
|
|
readyState === "CONNECTING" ? "text-lighter" : "text-warning",
|
|
)}
|
|
>
|
|
{t(
|
|
readyState === "CONNECTING"
|
|
? "common:chat.connecting"
|
|
: "common:chat.disconnected",
|
|
)}
|
|
</div>
|
|
) : null}
|
|
<ComposerRow
|
|
FormField={FormField}
|
|
sendingDisabled={sendingDisabled}
|
|
hasSent={hasSent}
|
|
/>
|
|
</>
|
|
)}
|
|
</SendouForm>
|
|
);
|
|
}
|
|
|
|
function ComposerRow({
|
|
FormField,
|
|
sendingDisabled,
|
|
hasSent,
|
|
}: {
|
|
FormField: FormRenderProps<typeof sendChatMessageSchema.entries>["FormField"];
|
|
sendingDisabled: boolean;
|
|
hasSent: boolean;
|
|
}) {
|
|
const { t } = useTranslation(["common"]);
|
|
const contents = useFormValue("contents");
|
|
const isEmpty = typeof contents !== "string" || contents.trim().length === 0;
|
|
|
|
return (
|
|
<div className={styles.composerRow}>
|
|
<FormField
|
|
name="contents"
|
|
disabled={sendingDisabled}
|
|
autoFocus={hasSent}
|
|
/>
|
|
<SubmitButton
|
|
className={styles.sendButton}
|
|
size="small"
|
|
isDisabled={sendingDisabled || isEmpty}
|
|
aria-label={t("common:chat.send")}
|
|
icon={<SendHorizontal size={16} />}
|
|
testId="chat-submit-button"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Message({
|
|
message,
|
|
label,
|
|
}: {
|
|
message: ClientChatMessage;
|
|
label?: string;
|
|
}) {
|
|
const author = message.author;
|
|
|
|
return (
|
|
<div className={styles.message}>
|
|
{author ? (
|
|
<div
|
|
className={clsx(styles.avatarWrapper, {
|
|
[styles.avatarWrapperStaff]: label,
|
|
})}
|
|
>
|
|
<Avatar user={author} size="xs" />
|
|
{label ? <span className={styles.avatarBadge}>{label}</span> : null}
|
|
</div>
|
|
) : null}
|
|
<div>
|
|
<div className={styles.messageInfo}>
|
|
<div
|
|
className={styles.messageUser}
|
|
style={
|
|
author?.chatNameHue
|
|
? { "--chat-hue": author.chatNameHue }
|
|
: undefined
|
|
}
|
|
>
|
|
{author?.username ?? "???"}
|
|
</div>
|
|
<PronounsTag author={author} />
|
|
{!message.pending ? (
|
|
<MessageTimestamp createdAt={message.createdAt} />
|
|
) : null}
|
|
</div>
|
|
<div
|
|
className={clsx(styles.messageContents, {
|
|
[styles.messageContentsPending]: message.pending,
|
|
})}
|
|
>
|
|
{message.contents ? (
|
|
<MessageContents text={message.contents} />
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PronounsTag({ author }: { author: ChatMessageAuthor | null }) {
|
|
if (!author?.pronouns) return null;
|
|
|
|
return (
|
|
<span className={styles.pronounsTag}>
|
|
{author.pronouns.subject}/{author.pronouns.object}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function SystemMessage({
|
|
message,
|
|
text,
|
|
}: {
|
|
message: ClientChatMessage;
|
|
text: string;
|
|
}) {
|
|
return (
|
|
<div className={styles.message}>
|
|
<div>
|
|
<div className="stack horizontal sm">
|
|
<MessageTimestamp createdAt={message.createdAt} />
|
|
</div>
|
|
<div
|
|
className={clsx(
|
|
styles.messageContents,
|
|
"text-xs text-lighter font-semi-bold",
|
|
)}
|
|
>
|
|
{text}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MessageContents({ text }: { text: string }) {
|
|
const matches = findRoomLinks(text);
|
|
|
|
if (matches.length === 0) return <>{text}</>;
|
|
|
|
const parts: React.ReactNode[] = [];
|
|
let lastIndex = 0;
|
|
|
|
for (const [i, match] of matches.entries()) {
|
|
if (match.index > lastIndex) {
|
|
parts.push(text.slice(lastIndex, match.index));
|
|
}
|
|
parts.push(
|
|
<span key={i} className={styles.roomLinkBlock}>
|
|
<QRCodeSVG value={match.url} size={120} className={styles.roomQrCode} />
|
|
<a
|
|
href={match.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className={styles.roomLink}
|
|
>
|
|
{match.url}
|
|
</a>
|
|
</span>,
|
|
);
|
|
lastIndex = match.index + match.url.length;
|
|
}
|
|
|
|
if (lastIndex < text.length) {
|
|
parts.push(text.slice(lastIndex));
|
|
}
|
|
|
|
return <>{parts}</>;
|
|
}
|
|
|
|
function MessageTimestamp({ createdAt }: { createdAt: number }) {
|
|
const { formatter: dateTimeFormatter } = useDateTimeFormat({
|
|
day: "numeric",
|
|
month: "numeric",
|
|
hour: "numeric",
|
|
minute: "numeric",
|
|
});
|
|
const { formatter: timeFormatter } = useDateTimeFormat({
|
|
hour: "numeric",
|
|
minute: "numeric",
|
|
});
|
|
const date = databaseTimestampToDate(createdAt);
|
|
const moreThanDayAgo = sub(new Date(), { days: 1 }) > date;
|
|
|
|
return (
|
|
<time className={styles.messageTime}>
|
|
{moreThanDayAgo
|
|
? dateTimeFormatter.format(date)
|
|
: timeFormatter.format(date)}
|
|
</time>
|
|
);
|
|
}
|