diff --git a/README.md b/README.md index 7e63cdf4..fba6b02b 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,12 @@ An uncertain saved-kit recovery keeps its exact retry details in memory and prev Use **Pass your seat to another player** for a voluntary one-time transfer. The private offer lasts 24 hours; acceptance preserves progress and revokes the old owner’s sessions and recovery kits. The recipient uses **Accept a seat handover** on the home page, then creates their own recovery kit. Keep the tab open until acceptance is confirmed; exact private retry details survive refresh in that tab. See [seat handover](docs/SEAT_HANDOVER.md) for cancellation, retry behavior and current limits. +Use **Table discussion** below the board for public conversation or private +messages to another human seat. Messages survive refresh; exact retries avoid +duplicates. Private history follows the seat through recovery or handover. +Messages do not execute game actions, and AI opponents do not answer them yet. +See [table discussion](docs/TABLE_DISCUSSION.md). + The table header offers optional **Sound effects**, with mute, volume and a test sample. Short cues mark phase changes and automatic action notices. Sound starts off; preferences stay in your browser. See [table sounds](docs/TABLE_SOUNDS.md) diff --git a/app/api/rooms/[code]/messages/route.ts b/app/api/rooms/[code]/messages/route.ts new file mode 100644 index 00000000..93c1a9b3 --- /dev/null +++ b/app/api/rooms/[code]/messages/route.ts @@ -0,0 +1,73 @@ +import { authenticate } from '@/db/rooms'; +import { readTableTalk, sendTableTalk, TableTalkError } from '@/db/table-talk'; +import { RuleError } from '@/game/engine'; +const noStore = { 'Cache-Control': 'no-store' }; +async function handle(req: Request, write: boolean) { + try { + const url = new URL(req.url); + if ( + write && + req.headers.get('origin') && + req.headers.get('origin') !== url.origin + ) + throw new TableTalkError('Invalid request origin.', 403); + const code = url.pathname.split('/').at(-2)!.toUpperCase(); + if (!/^[A-Z2-9]{8}$/.test(code)) + throw new TableTalkError('Invalid room code.', 400); + const token = + req.headers + .get('cookie') + ?.split(';') + .map((part) => part.trim()) + .find((part) => part.startsWith(`dune_${code}=`)) + ?.split('=')[1] ?? ''; + const auth = await authenticate(code, token); + if (write) { + const raw = await req.text(); + if (raw.length > 6500) + throw new TableTalkError('Request too large.', 413); + return Response.json(await sendTableTalk(code, auth, JSON.parse(raw)), { + headers: noStore, + }); + } + if ( + [...url.searchParams.keys()].some( + (key) => !['recipient', 'before'].includes(key), + ) + ) + throw new TableTalkError('Invalid conversation request.', 400); + return Response.json( + await readTableTalk( + code, + auth, + url.searchParams.get('recipient'), + url.searchParams.get('before'), + ), + { headers: noStore }, + ); + } catch (error) { + return Response.json( + { + error: + error instanceof TableTalkError || error instanceof RuleError + ? error.message + : error instanceof SyntaxError + ? 'Invalid JSON.' + : 'Table discussion is temporarily unavailable.', + }, + { + status: + error instanceof TableTalkError + ? error.status + : error instanceof RuleError + ? 409 + : error instanceof SyntaxError + ? 400 + : 500, + headers: noStore, + }, + ); + } +} +export const GET = (req: Request) => handle(req, false); +export const POST = (req: Request) => handle(req, true); diff --git a/app/globals.css b/app/globals.css index cb87c76c..474ec036 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1927,3 +1927,18 @@ footer { .spice-card-front > button { margin-top: auto; } + +/* Table discussion uses the same dark surfaces and readable controls as the table. */ +.table-talk { margin-block: 1.5rem; border: 1px solid var(--border); border-radius: 0.75rem; overflow: hidden; } +.table-talk h2 { margin: 0; font-size: 1.125rem; } +.table-talk h2 button { display: flex; align-items: center; justify-content: space-between; width: 100%; min-height: 48px; padding: 1rem; text-align: left; } +.table-talk-content { padding: 0 1rem 1rem; } +.table-talk-content label { display: block; margin-block: 0.75rem 0.375rem; font-size: 1rem; } +.table-talk-content select, .table-talk-content textarea { width: 100%; min-height: 44px; font-size: 1rem; } +.table-talk-content textarea { resize: vertical; } +.table-talk-content button { min-height: 44px; } +.table-talk-messages { list-style: none; margin: 1rem 0; padding: 0; max-height: 24rem; overflow-y: auto; overflow-wrap: anywhere; } +.table-talk-messages li { padding: 0.75rem 0; border-bottom: 1px solid var(--border); } +.table-talk-messages p { white-space: pre-wrap; unicode-bidi: plaintext; margin: 0.5rem 0 0; font-size: 1rem; } +.table-talk-author { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 0.25rem 1rem; } +.table-talk-author time { font-size: 0.875rem; } diff --git a/app/page.tsx b/app/page.tsx index 07170638..ed502013 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,6 +1,7 @@ 'use client'; import Link from 'next/link'; import { useState, useEffect, useCallback, useRef } from 'react'; +import { TableTalk } from '@/components/table-talk'; import { GameTable } from '@/components/game-table'; import { SeatHandoverSetup, @@ -675,6 +676,7 @@ export default function Home() { exitDisabled={handoverPending || aiPermissionPending} />
+ {(entryAttempt || entryProblem) && (

diff --git a/components/table-talk.tsx b/components/table-talk.tsx new file mode 100644 index 00000000..84b6f9b7 --- /dev/null +++ b/components/table-talk.tsx @@ -0,0 +1,388 @@ +'use client'; +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: crypto.randomUUID(), + 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(); + }} + > + +