'use client'; import { randomId } from '@/lib/random-id'; import { useEffect, useId, useRef, useState } from 'react'; import type { GameView } from '@/game/engine'; import { faction } from '@/game/catalog'; import { Button } from './ui/button'; import { requestJson, requestMayHaveCompleted } from '@/lib/client-request'; import { TALK_LIMIT, clearTalkDraft, mergeTalkPages, restoreTalkDraft, talkDraftKey, type TalkDraft, type TalkPage, } from '@/lib/table-talk'; /** Remount on room/seat changes. Messages belong to seats, never browser identities. */ export function TableTalk({ game }: { game: GameView }) { const field = useId(); const [open, setOpen] = useState(false); const [recipient, setRecipient] = useState(''); const [text, setText] = useState(''); const [record, setRecord] = useState(null); const [pageChannel, setPageChannel] = useState(null); const [page, setPage] = useState({ messages: [], before: null }); const [ready, setReady] = useState(false); const [busy, setBusy] = useState(false); const [loading, setLoading] = useState(false); const [notice, setNotice] = useState(''); const [readError, setReadError] = useState(''); const [refresh, setRefresh] = useState(0); const sending = useRef(false); const mounted = useRef(true); useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); const olderPending = useRef(false); const readingHistory = useRef(false); const [history, setHistory] = useState(false); const channelGeneration = useRef(0); const key = talkDraftKey(game.code, game.me); const peers = game.players.filter( (player) => player.id !== game.me && !player.bot, ); const target = peers.find((player) => player.id === recipient); const own = game.players.find((player) => player.id === game.me); useEffect(() => { try { const raw = sessionStorage.getItem(key); const saved = restoreTalkDraft(raw); if (saved) { // oxlint-disable-next-line react/react-compiler -- Restore the exact pending message after SSR. setRecord(saved); setRecipient(saved.recipientId ?? ''); setText(saved.text); setOpen(true); setNotice( 'A message may have been sent. Retry this exact message to confirm it without a duplicate.', ); } else if (raw) { setOpen(true); setNotice( 'The saved message could not be read. No message was sent from that record.', ); } setReady(true); } catch { setNotice( 'Allow tab storage and reload to send messages with safe retry.', ); } }, [key]); const endpoint = `/api/rooms/${game.code}/messages`; function pageUrl(before?: string) { const query = new URLSearchParams(); if (recipient) query.set('recipient', recipient); if (before) query.set('before', before); return `${endpoint}?${query}`; } const url = pageUrl(); const shownPage = pageChannel === url ? page : { messages: [], before: null }; useEffect(() => { if (!open) return; const controller = new AbortController(); const generation = ++channelGeneration.current; let timer: ReturnType; async function read() { if (readingHistory.current) { timer = setTimeout(read, 5000); return; } try { const result = await requestJson(url, { signal: controller.signal, }); if ( !controller.signal.aborted && generation === channelGeneration.current && !readingHistory.current ) { setPage(result); setPageChannel(url); setReadError(''); } } catch { if (!controller.signal.aborted) setReadError( 'Messages could not be refreshed. The table is still available.', ); } finally { if (!controller.signal.aborted) timer = setTimeout(read, 5000); } } // A conversation change clears the old recipient's text before any new read. readingHistory.current = false; // oxlint-disable-next-line react/react-compiler -- Reset saved-history browsing when the external conversation subscription changes. setHistory(false); setPage({ messages: [], before: null }); void read(); return () => { controller.abort(); clearTimeout(timer); channelGeneration.current = generation + 1; }; }, [url, open, refresh]); async function older() { if (!shownPage.before || olderPending.current) return; olderPending.current = true; readingHistory.current = true; setHistory(true); setLoading(true); const generation = channelGeneration.current; try { const result = await requestJson(pageUrl(shownPage.before)); if (generation === channelGeneration.current) setPage((current) => ({ before: result.before, messages: mergeTalkPages(result.messages, current.messages), })); } catch { if (generation === channelGeneration.current) setReadError('Older messages could not be loaded. Try again.'); } finally { olderPending.current = false; setLoading(false); } } async function submit() { if (sending.current || !ready) return; const draft = record ?? { id: randomId(), recipientId: recipient || null, text, }; const wasRetry = !!record; sending.current = true; setBusy(true); let dispatched = false; try { sessionStorage.setItem(key, JSON.stringify(draft)); setRecord(draft); dispatched = true; const receipt = await requestJson<{ id: string; replayed: boolean }>( endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(draft), }, ); if (!mounted.current) return; if (receipt.id !== draft.id) throw new Error('Message receipt did not match.'); clearTalkDraft(sessionStorage, key, draft); setRecord(null); setText(''); setNotice( receipt.replayed ? 'The original message is saved. No duplicate was sent.' : 'Message sent.', ); setRefresh((value) => value + 1); } catch (error) { if (!mounted.current) return; if (dispatched && (wasRetry || requestMayHaveCompleted(error))) { setNotice( 'Message not confirmed. Retry the exact message, or check the conversation before discarding its retry details.', ); } else { try { clearTalkDraft(sessionStorage, key, draft); setRecord(null); } catch {} setNotice( error instanceof Error ? error.message : 'Message could not be sent.', ); } } finally { sending.current = false; if (mounted.current) setBusy(false); } } if (!own || own.bot) return null; return (

{open && (

Talk through plans and offers. Use game controls to transfer spice, form alliances or make a Truthtrance commitment. Messages do not perform game actions.

{recipient ? `Only your seat and ${target?.name ?? 'the addressed seat'} can read this conversation. A seat's next owner inherits its messages.` : 'Public messages are visible to everyone who joins this room.'}{' '} AI opponents do not read or answer messages yet.

{readError && ( {readError}{' '} )} {history && (

Browsing saved history.{' '}

)} {shownPage.before && ( )}
    {shownPage.messages.map((message) => (
  1. {message.senderName}

    {message.text}

  2. ))}
{shownPage.messages.length === 0 && (

No messages loaded in this conversation.

)}
{ event.preventDefault(); void submit(); }} >