Add private chat to multiplayer rooms
This commit is contained in:
245
components/RoomChat.tsx
Normal file
245
components/RoomChat.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
"use client";
|
||||
|
||||
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>
|
||||
{message.name}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user