/** * Hushle renderClient — the React panel the activity host renders in a * voice room once an activity for the `hushle` plugin is active. * * The panel receives a `state` snapshot (the server-authoritative * reducer output, already projected for this viewer) and a `dispatch` * function that POSTs to the activity action route. It is purely a * presentational component: no HTTP, no DB — every action goes through * `dispatch`, and the host application is responsible for re-rendering * the panel with the next state once the dispatch round-trip completes. * * Built from the activity UI kit (`@lobbyforge/plugin-sdk/ui`); the phase * views live in `./ui/` — lobby, team setup, playing (a running turn and * the pause between turns) and the end screen. * * Locale strategy: the plugin ships its own `locales/*.json` tables, * registered through `@lobbyforge/plugin-sdk`, so it stays * self-contained and works in any host. The host tells it which * language to speak via `data-lf-locale`; see docs/TRANSLATING.md. */ 'use client'; import { useMemo, useRef, useState } from 'react'; import type { ReactNode } from 'react'; import { detectLocale, loadPluginLocale, pickBestLocale } from '@lobbyforge/plugin-sdk'; import { ActivityShell } from '@lobbyforge/plugin-sdk/ui'; import { LOCALE_TABLES } from './locales.generated'; import { HUSHLE_PLUGIN_ID } from './plugin-id'; import type { HushleAction, HushleState } from './state'; import { EndedView } from './ui/ended'; import { HushleI18nProvider, createHushleI18n } from './ui/i18n'; import { LobbyView } from './ui/lobby'; import { EMPTY_TURN_LOG, advanceTurnLog, type HushleViewState, type PanelPlayer, type TurnLog } from './ui/model'; import { PlayingView } from './ui/playing'; import { TeamSetupView } from './ui/setup'; import type { ViewProps } from './ui/shared'; import { HUSHLE_CSS } from './ui/theme'; // Load the plugin's locale tables into the shared registry the // moment the panel mounts. This is the one-line step that lets a // community contributor add a new language: drop a new JSON file in // `locales/`, run `pnpm i18n:sync`, and the panel + host language // switcher pick it up automatically. loadPluginLocale(HUSHLE_PLUGIN_ID, LOCALE_TABLES); export interface HushlePanelCardPack { id: string; slug: string; name: string; /** Any language tag — hosts create packs in their own language. */ language: string; cardCount: number; isBuiltIn: boolean; } export interface HushlePanelClientProps { state: HushleState; dispatch: (action: HushleAction) => void | Promise; actorUserId: string; hostUserId: string | null; /** The people in the room — the voice channel and anyone who has acted — with their names. */ players: Array<{ userId: string; name?: string | null }>; cardPacks?: HushlePanelCardPack[]; } export type HushlePanelProps = HushlePanelClientProps; export function HushlePanel(props: HushlePanelProps): ReactNode { const { dispatch, actorUserId, hostUserId, players, cardPacks } = props; // The projected state carries a few fields the reducer's type does not // (deckSize, cardsRemaining) — see HushleViewState. const state = props.state as HushleViewState; // Resolve the active locale against the plugin's actual locale list // so a user with `fr` falls back to the first available language // (en/tr) without showing raw keys. const locale = useMemo( () => pickBestLocale(HUSHLE_PLUGIN_ID, detectLocale('en')), // Re-run only on mount — the document lang doesn't change mid-session. [] ); const i18n = useMemo(() => createHushleI18n(locale), [locale]); const log = useTurnLog(state); const inRoom = players ?? []; const everyone = useRememberedPlayers(inRoom); const present = useMemo(() => new Set(inRoom.map((player) => player.userId)), [inRoom]); const view: ViewProps = { state, dispatch, actorUserId, hostUserId, players: everyone, present }; return ( {/* React 19 hoists this into once, like the kit's own sheet. */} {state.phase === 'lobby' ? ( ) : state.phase === 'team_setup' ? ( ) : state.phase === 'playing' ? ( ) : ( )} ); } /** * Everyone the panel has been given a name for this session. The host * passes the people in the room NOW; a player who steps out stays on their * team and keeps their name here instead of becoming "Unknown player". */ function useRememberedPlayers(players: PanelPlayer[]): PanelPlayer[] { const book = useRef(new Map()); for (const player of players) { const name = player.name?.trim(); if (name) book.current.set(player.userId, name); } const here = new Set(players.map((player) => player.userId)); const earlier = [...book.current] .filter(([userId]) => !here.has(userId)) .map(([userId, name]) => ({ userId, name })); return earlier.length === 0 ? players : [...players, ...earlier]; } /** * The "this turn" log, advanced once per new state snapshot. This is * React's pattern for state derived from the previous props: compare with * the snapshot stored alongside, and update during render. */ function useTurnLog(state: HushleViewState): TurnLog { const [track, setTrack] = useState(() => ({ state, log: advanceTurnLog(EMPTY_TURN_LOG, null, state) })); if (track.state !== state) { const next = { state, log: advanceTurnLog(track.log, track.state, state) }; setTrack(next); return next.log; } return track.log; }