Files
MineGeo/components/RoomChat.tsx

247 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { Username } from "./Username";
import { useCallback, useEffect, useRef, useState } from "react";
import { MessageCircle, Send, X } from "lucide-react";
import { api } from "@/lib/client-api";
import {
chatMessageSchema,
type RoomChat as ChatView,
} from "@/shared/multiplayer";
import { useI18n } from "./I18nProvider";
export function RoomChat({
id,
playerId,
available,
playing,
}: {
id: string;
playerId: string | null;
available: boolean;
playing: boolean;
}) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const [messages, setMessages] = useState<ChatView["messages"]>([]);
const [text, setText] = useState("");
const [error, setError] = useState("");
const [sending, setSending] = useState(false);
const [lastRead, setLastRead] = useState(0n);
const version = useRef(0);
const inFlight = useRef(false);
const initialized = useRef(false);
const pending = useRef<{ text: string; clientId: string } | null>(null);
const input = useRef<HTMLInputElement>(null);
const toggle = useRef<HTMLButtonElement>(null);
const log = useRef<HTMLDivElement>(null);
const followLatest = useRef(true);
const enabled = !!playerId && available;
const accept = useCallback((view: ChatView) => {
setMessages((previous) =>
previous.length === view.messages.length &&
previous.at(-1)?.id === view.messages.at(-1)?.id
? previous
: view.messages,
);
if (!initialized.current) {
initialized.current = true;
setLastRead(BigInt(view.messages.at(-1)?.id ?? "0"));
}
setError("");
}, []);
const load = useCallback(async () => {
if (inFlight.current) return;
const current = ++version.current;
try {
const view = await api<ChatView>(`rooms/${id}/chat`);
if (current === version.current) accept(view);
} catch (failure) {
if (current === version.current) setError((failure as Error).message);
}
}, [id, accept]);
useEffect(() => {
if (!enabled) return;
let stopped = false;
let timer: ReturnType<typeof setTimeout>;
const poll = async () => {
await load();
if (!stopped) timer = setTimeout(poll, document.hidden ? 5000 : 2000);
};
void poll();
const resume = () => {
if (!document.hidden) void load();
};
document.addEventListener("visibilitychange", resume);
return () => {
stopped = true;
version.current++;
clearTimeout(timer);
document.removeEventListener("visibilitychange", resume);
};
}, [enabled, load]);
useEffect(() => {
if (open) {
setLastRead(BigInt(messages.at(-1)?.id ?? "0"));
if (log.current && followLatest.current)
log.current.scrollTop = log.current.scrollHeight;
}
}, [open, messages]);
useEffect(() => {
if (open) {
followLatest.current = true;
if (log.current) log.current.scrollTop = log.current.scrollHeight;
input.current?.focus();
}
}, [open]);
async function send() {
if (inFlight.current || !enabled) return;
const draft = text.trim();
const body =
pending.current?.text === draft
? pending.current
: { text: draft, clientId: crypto.randomUUID() };
if (!chatMessageSchema.safeParse(body).success) {
setError("Write a message of 1–500 characters without hidden controls");
return;
}
pending.current = body;
inFlight.current = true;
setSending(true);
setError("");
const current = ++version.current;
try {
const view = await api<ChatView>(`rooms/${id}/chat`, body);
if (current === version.current) {
accept(view);
followLatest.current = true;
setText("");
pending.current = null;
}
} catch (failure) {
if (current === version.current) setError((failure as Error).message);
} finally {
inFlight.current = false;
setSending(false);
input.current?.focus();
}
}
if (!enabled) return null;
const unread = messages.filter(
(message) => message.playerId !== playerId && BigInt(message.id) > lastRead,
).length;
return (
<aside
className={`room-chat${playing ? " room-chat-playing" : ""}`}
aria-label={t("Room chat")}
>
<button
ref={toggle}
className="button room-chat-toggle"
aria-expanded={open}
aria-controls="room-chat-panel"
onClick={() => setOpen((value) => !value)}
>
<MessageCircle size={18} aria-hidden="true" /> {t("Chat")}
{unread > 0 && (
<span
className="room-chat-unread"
aria-label={t("{count} unread messages", { count: unread })}
>
{unread}
</span>
)}
</button>
<section
id="room-chat-panel"
hidden={!open}
onKeyDown={(event) => {
if (event.key === "Escape") {
setOpen(false);
toggle.current?.focus();
}
}}
>
<div className="room-chat-heading">
<strong>{t("Room chat")}</strong>
<button
type="button"
aria-label={t("Close chat")}
onClick={() => {
setOpen(false);
toggle.current?.focus();
}}
>
<X size={18} />
</button>
</div>
<div
className="room-chat-log"
ref={log}
role="log"
aria-live={open ? "polite" : "off"}
aria-relevant="additions"
aria-label={t("Chat messages")}
onScroll={(event) => {
const element = event.currentTarget;
followLatest.current =
element.scrollHeight - element.scrollTop - element.clientHeight <
40;
}}
>
{!messages.length && (
<p className="muted">{t("Say hello to your teammates.")}</p>
)}
{messages.map((message) => (
<p
key={message.id}
className={message.playerId === playerId ? "room-chat-mine" : ""}
>
<strong>
<Username name={message.name} appearance={message.appearance} />
{message.playerId === playerId && ` ${t("(you)")}`}
</strong>
<span>{message.text}</span>
</p>
))}
</div>
{error && (
<p className="error-message" role="alert">
{t(error)}{" "}
<button className="text-link" onClick={() => void load()}>
{t("Retry")}
</button>
</p>
)}
<form
onSubmit={(event) => {
event.preventDefault();
void send();
}}
>
<label className="sr-only" htmlFor="room-chat-input">
{t("Message")}
</label>
<input
id="room-chat-input"
ref={input}
value={text}
maxLength={500}
autoComplete="off"
disabled={sending}
placeholder={t("Message your teammates…")}
onChange={(event) => setText(event.target.value)}
/>
<button
className="button primary"
disabled={sending || !text.trim()}
aria-label={t("Send message")}
>
<Send size={18} aria-hidden="true" />
</button>
</form>
</section>
</aside>
);
}