Participant maps and creator authorization previously used display names, so two peers left at the default Commander name collapsed into one participant and could exercise each other's creator controls through the normal client. Carry a stable actor_id separately from actor_name. The peer overwrites actor_id on every local publish, and live event envelopes are accepted only when the known peer, source, and event actor match. The frontend keys participants and authorization by actor_id while retaining actor_name for display. This follows the trusted-LAN model and is not cryptographic authentication against a hostile peer. Test Plan: - `just fmt` -- passed - `just clippy` -- passed - `just test` -- passed - `just frontend-test` -- passed, 21 tests - `just build` -- passed - `just peer-cli-tests S48` -- passed - `git diff --cached --check` -- passed
367 lines
14 KiB
TypeScript
367 lines
14 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
||
|
||
import { Icon } from '../Icon';
|
||
import { GameCover } from '../grid/GameCover';
|
||
import { CtpChat } from './CtpChat';
|
||
import { CallToPlayActions } from '../../hooks/useCallToPlay';
|
||
import {
|
||
avatarColor,
|
||
CHECKIN_LEAD_MS,
|
||
formatClock,
|
||
formatCountdown,
|
||
formatCountdownShort,
|
||
formatUntil,
|
||
inCountOf,
|
||
isReady,
|
||
phaseOf,
|
||
readyCountOf,
|
||
} from '../../lib/callToPlay';
|
||
import { CallToPlayParticipant, Game, Nomination } from '../../lib/types';
|
||
|
||
interface Props {
|
||
nomination: Nomination;
|
||
game: Game;
|
||
actorId: string | null;
|
||
actions: CallToPlayActions;
|
||
focused: boolean;
|
||
thumbnailUrl?: string | null;
|
||
totalPeerCount: number;
|
||
onLaunch: (game: Game) => void;
|
||
}
|
||
|
||
const AvatarChip = ({
|
||
name,
|
||
participant,
|
||
now,
|
||
}: {
|
||
name: string;
|
||
participant: CallToPlayParticipant;
|
||
now: number;
|
||
}) => {
|
||
const ready = isReady(participant, now);
|
||
const isIn = !ready && participant.status === 'in';
|
||
const remaining = Math.max(0, (participant.readyAt ?? now) - now);
|
||
const initials = name.replace(/[^a-z0-9]/gi, '').slice(0, 2).toUpperCase();
|
||
return (
|
||
<div
|
||
className={`ctp-avatar ${ready ? 'is-ready' : isIn ? 'is-in' : 'is-pending'}`}
|
||
title={`${name} — ${ready ? 'ready' : isIn ? 'in, not checked in' : `ready in ${formatCountdown(remaining)}`}`}
|
||
>
|
||
<span className="ctp-avatar-dot" style={{ background: avatarColor(name) }}>{initials}</span>
|
||
{ready
|
||
? <span className="ctp-avatar-check"><Icon.check /></span>
|
||
: isIn
|
||
? <span className="ctp-avatar-in">in</span>
|
||
: <span className="ctp-avatar-pending">{formatCountdownShort(remaining)}</span>}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export const NominationCard = ({
|
||
nomination,
|
||
game,
|
||
actorId,
|
||
actions,
|
||
focused,
|
||
thumbnailUrl,
|
||
totalPeerCount,
|
||
onLaunch,
|
||
}: Props) => {
|
||
const [confirmCancel, setConfirmCancel] = useState(false);
|
||
const cardRef = useRef<HTMLDivElement>(null);
|
||
useEffect(() => {
|
||
if (focused) cardRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||
}, [focused]);
|
||
|
||
const now = Date.now();
|
||
const entries = Object.entries(nomination.participants);
|
||
const readyCount = readyCountOf(nomination, now);
|
||
const inCount = inCountOf(nomination, now);
|
||
const myStatus = actorId === null ? undefined : nomination.participants[actorId];
|
||
const isMe = myStatus !== undefined;
|
||
const isCreator = nomination.creatorId === actorId;
|
||
const isDone = nomination.state === 'done';
|
||
const isStarted = nomination.state === 'started';
|
||
const phase = phaseOf(nomination, now);
|
||
const isScheduled = phase === 'scheduled' && !isDone && !isStarted;
|
||
const isCheckin = phase === 'checkin' && !isDone && !isStarted;
|
||
const windowStart = nomination.scheduledFor === null
|
||
? nomination.createdAt
|
||
: nomination.scheduledFor - CHECKIN_LEAD_MS;
|
||
const remaining = Math.max(0, nomination.deadline - now);
|
||
const percentage = Math.max(
|
||
0,
|
||
Math.min(100, (remaining / Math.max(1, nomination.deadline - windowStart)) * 100),
|
||
);
|
||
const urgency = percentage < 20 ? 'high' : percentage < 50 ? 'mid' : 'low';
|
||
const installedCount = (game.installed_peer_count ?? game.peer_count) + (game.installed ? 1 : 0);
|
||
const lanCount = Math.max(totalPeerCount + 1, installedCount);
|
||
|
||
const timer = isStarted
|
||
? <div className="ctp-card-timer" data-urgency="off">Launching…</div>
|
||
: isDone
|
||
? <div className="ctp-card-timer" data-urgency="off">Ready</div>
|
||
: isScheduled
|
||
? (
|
||
<div className="ctp-card-timer is-sched" data-urgency="off">
|
||
<span className="ctp-card-clock">{formatClock(nomination.scheduledFor!)}</span>
|
||
<span className="ctp-card-until">{formatUntil(nomination.scheduledFor! - now)}</span>
|
||
</div>
|
||
)
|
||
: isCheckin
|
||
? (
|
||
<div className="ctp-card-timer is-sched" data-urgency={urgency}>
|
||
<span className="ctp-card-clock">{formatCountdown(remaining)}</span>
|
||
<span className="ctp-card-until">starts {formatClock(nomination.scheduledFor!)}</span>
|
||
</div>
|
||
)
|
||
: <div className="ctp-card-timer" data-urgency={urgency}>{formatCountdown(remaining)}</div>;
|
||
|
||
const rosterLabel = isScheduled
|
||
? `${entries.length} in · up to ${nomination.maxPlayers} players`
|
||
: isCheckin && inCount > 0
|
||
? `${readyCount}/${nomination.maxPlayers} ready · ${inCount} not checked in yet`
|
||
: `${readyCount}/${nomination.maxPlayers} ready`;
|
||
|
||
return (
|
||
<div
|
||
ref={cardRef}
|
||
className={`ctp-card ${isDone ? 'is-done' : ''} ${isStarted ? 'is-started' : ''} ${isCheckin ? 'is-checkin' : ''} ${focused ? 'is-focused' : ''}`}
|
||
>
|
||
<div className="ctp-card-top">
|
||
<div className="ctp-card-cover">
|
||
<GameCover game={game} aspect="square" thumbnailUrl={thumbnailUrl} />
|
||
</div>
|
||
<div className="ctp-card-info">
|
||
<div className="ctp-card-title">{game.name}</div>
|
||
<div className="ctp-card-sub">
|
||
{nomination.scheduledFor === null ? 'Called' : 'Scheduled'} by{' '}
|
||
<strong>{nomination.creator}</strong>
|
||
{nomination.scheduledFor !== null && ` · starts at ${formatClock(nomination.scheduledFor)}`}
|
||
{` · ${installedCount}/${lanCount} peers have it installed`}
|
||
</div>
|
||
</div>
|
||
{timer}
|
||
</div>
|
||
|
||
{isCheckin && (
|
||
<div className="ctp-checkin-note">
|
||
<Icon.clock />
|
||
<span>{isMe && myStatus.status === 'in'
|
||
? 'Starting soon — you said you’re in. Check in below.'
|
||
: 'Starting soon — check-in is open.'}</span>
|
||
</div>
|
||
)}
|
||
|
||
{!isScheduled && (
|
||
<div className="ctp-progress">
|
||
<div
|
||
className="ctp-progress-fill"
|
||
style={{
|
||
width: `${isStarted || isDone ? 100 : percentage}%`,
|
||
background: isStarted || isDone ? 'var(--ok)' : 'var(--accent)',
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
<div className="ctp-roster">
|
||
<div className="ctp-roster-count">{rosterLabel}</div>
|
||
<div className="ctp-avatars">
|
||
{entries.map(([participantId, participant]) => (
|
||
<AvatarChip
|
||
key={participantId}
|
||
name={participant.name}
|
||
participant={participant}
|
||
now={now}
|
||
/>
|
||
))}
|
||
{Array.from({ length: Math.max(0, nomination.maxPlayers - entries.length) })
|
||
.map((_, index) => (
|
||
<div key={index} className="ctp-avatar ctp-avatar-empty" />
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="ctp-actions">
|
||
<CardActions
|
||
nomination={nomination}
|
||
game={game}
|
||
actorId={actorId}
|
||
actions={actions}
|
||
onLaunch={onLaunch}
|
||
now={now}
|
||
/>
|
||
</div>
|
||
|
||
<CtpChat
|
||
nomination={nomination}
|
||
actorId={actorId}
|
||
disabled={isStarted}
|
||
onSend={text => actions.sendMessage(nomination.id, text)}
|
||
/>
|
||
|
||
{isCreator && !isStarted && (
|
||
confirmCancel
|
||
? (
|
||
<div className="ctp-cancel-confirm">
|
||
<span>Cancel this call for everyone?</span>
|
||
<div className="ctp-cancel-confirm-btns">
|
||
<button
|
||
className="ghost-btn ghost-danger"
|
||
onClick={() => actions.cancel(nomination.id)}
|
||
>Yes, cancel it</button>
|
||
<button className="ghost-btn" onClick={() => setConfirmCancel(false)}>
|
||
No, keep it
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
: (
|
||
<button className="ctp-cancel-link" onClick={() => setConfirmCancel(true)}>
|
||
<Icon.trash /><span>Cancel this call</span>
|
||
</button>
|
||
)
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
interface CardActionsProps {
|
||
nomination: Nomination;
|
||
game: Game;
|
||
actorId: string | null;
|
||
actions: CallToPlayActions;
|
||
onLaunch: (game: Game) => void;
|
||
now: number;
|
||
}
|
||
|
||
const ReadyButtons = ({ nomination, actions, includeThirty = false }: {
|
||
nomination: Nomination;
|
||
actions: CallToPlayActions;
|
||
includeThirty?: boolean;
|
||
}) => (
|
||
<>
|
||
<button className="act-btn act-play" onClick={() => actions.respond(nomination.id, 'ready')}>
|
||
<Icon.play /><span>Ready now</span>
|
||
</button>
|
||
<div className="ctp-buffer-group">
|
||
{[5, 10, 15, ...(includeThirty ? [30] : [])].map(minutes => (
|
||
<button
|
||
key={minutes}
|
||
className="ctp-buffer-btn"
|
||
onClick={() => actions.respond(nomination.id, minutes)}
|
||
>+{minutes}m</button>
|
||
))}
|
||
</div>
|
||
</>
|
||
);
|
||
|
||
const CardActions = ({
|
||
nomination,
|
||
game,
|
||
actorId,
|
||
actions,
|
||
onLaunch,
|
||
now,
|
||
}: CardActionsProps) => {
|
||
const myStatus = actorId === null ? undefined : nomination.participants[actorId];
|
||
const isMe = myStatus !== undefined;
|
||
const isCreator = nomination.creatorId === actorId;
|
||
const isDone = nomination.state === 'done';
|
||
const isStarted = nomination.state === 'started';
|
||
const scheduled = phaseOf(nomination, now) === 'scheduled' && !isDone && !isStarted;
|
||
const readyCount = readyCountOf(nomination, now);
|
||
|
||
if (isStarted) {
|
||
return <div className="ctp-note ctp-note-launch">Launching {game.name}…</div>;
|
||
}
|
||
if (scheduled) {
|
||
if (isCreator) {
|
||
return (
|
||
<div className="ctp-note">
|
||
Scheduled for {formatClock(nomination.scheduledFor!)} — check-in opens 15 min before start.
|
||
</div>
|
||
);
|
||
}
|
||
if (isMe) {
|
||
return (
|
||
<>
|
||
<div className="ctp-me-status">You’re in — we’ll nudge you when check-in opens</div>
|
||
<button className="ghost-btn ghost-danger" onClick={() => actions.leave(nomination.id)}>
|
||
Can’t make it
|
||
</button>
|
||
</>
|
||
);
|
||
}
|
||
return (
|
||
<>
|
||
<button className="act-btn act-play" onClick={() => actions.rsvp(nomination.id)}>
|
||
<Icon.check /><span>I’m in</span>
|
||
</button>
|
||
<div className="ctp-note">Check-in opens 15 min before start</div>
|
||
</>
|
||
);
|
||
}
|
||
if (isCreator) {
|
||
if (isDone) {
|
||
return (
|
||
<>
|
||
<button
|
||
className="act-btn act-play"
|
||
onClick={() => void actions.startNow(nomination.id).then(accepted => {
|
||
if (accepted) onLaunch(game);
|
||
})}
|
||
><Icon.play /><span>Start now</span></button>
|
||
<button className="ghost-btn" onClick={() => actions.addTime(nomination.id)}>
|
||
Add 5 more minutes
|
||
</button>
|
||
</>
|
||
);
|
||
}
|
||
if (myStatus?.status === 'in') {
|
||
return <ReadyButtons nomination={nomination} actions={actions} />;
|
||
}
|
||
return <div className="ctp-note">Waiting for players to ready up…</div>;
|
||
}
|
||
if (isDone) {
|
||
return (
|
||
<div className="ctp-note">
|
||
{readyCount >= nomination.maxPlayers
|
||
? 'Everyone’s ready.'
|
||
: `It’s time — waiting for ${nomination.creator} to start.`}
|
||
</div>
|
||
);
|
||
}
|
||
if (isMe) {
|
||
if (myStatus.status === 'in') {
|
||
return (
|
||
<>
|
||
<div className="ctp-me-status">You said you’re in — check in:</div>
|
||
<ReadyButtons nomination={nomination} actions={actions} />
|
||
<button className="ghost-btn ghost-danger" onClick={() => actions.leave(nomination.id)}>
|
||
Leave
|
||
</button>
|
||
</>
|
||
);
|
||
}
|
||
const ready = isReady(myStatus, now);
|
||
return (
|
||
<>
|
||
<div className="ctp-me-status">
|
||
{ready ? 'You’re in — ready' : `You: ready in ${formatCountdown((myStatus.readyAt ?? now) - now)}`}
|
||
</div>
|
||
{!ready && (
|
||
<button className="ghost-btn" onClick={() => actions.respond(nomination.id, 'ready')}>
|
||
I’m ready now
|
||
</button>
|
||
)}
|
||
<button className="ghost-btn ghost-danger" onClick={() => actions.leave(nomination.id)}>
|
||
Leave
|
||
</button>
|
||
</>
|
||
);
|
||
}
|
||
return <ReadyButtons nomination={nomination} actions={actions} includeThirty />;
|
||
};
|