Files
MineGeo/components/RoomChat.tsx
pavel444-byte 9c314ecb4e
All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 12m17s
Add tournament and multiplayer community chat moderation
2026-10-27 12:54:15 +05:00

245 lines
7.3 KiB
TypeScript
Raw Permalink 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,
endpoint,
}: {
id: string;
playerId: string | null;
available: boolean;
playing: boolean;
endpoint?: string;
}) {
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(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>(endpoint ?? `rooms/${id}/chat`);
if (current === version.current) accept(view);
} catch (failure) {
if (current === version.current) setError((failure as Error).message);
}
}, [id, endpoint, 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>(endpoint ?? `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.tag && <small> · {message.tag}</small>}
{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>
);
}