All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 12m17s
245 lines
7.3 KiB
TypeScript
245 lines
7.3 KiB
TypeScript
"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>
|
||
);
|
||
}
|