Add private chat to multiplayer rooms

This commit is contained in:
2026-10-08 10:22:19 +05:00
parent 1307436e65
commit 36e8d11eb4
14 changed files with 810 additions and 4 deletions

View File

@@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
import { Copy, Users, Crown, Trophy } from "lucide-react";
import { Brand } from "./Brand";
import { MultiplayerGame } from "./MultiplayerGame";
import { RoomChat } from "./RoomChat";
import { useI18n } from "./I18nProvider";
import { api } from "@/lib/client-api";
import { roundClock } from "@/shared/round-clock";
@@ -125,6 +126,13 @@ export function MultiplayerRoom({ id }: { id: string }) {
</button>
</div>
)}
<RoomChat
key={id}
id={id}
playerId={room.you}
available={room.status !== "closed"}
playing
/>
</>
);
const owner = !!room?.you && room.you === room.hostId;
@@ -362,6 +370,13 @@ export function MultiplayerRoom({ id }: { id: string }) {
</>
)}
</main>
<RoomChat
key={id}
id={id}
playerId={room?.you ?? null}
available={!!room && room.status !== "closed"}
playing={false}
/>
</>
);
}

245
components/RoomChat.tsx Normal file
View 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>
);
}