Files
lanspread/crates/lanspread-tauri-deno-ts/src/components/calltoplay/NominationCard.tsx
T
ddidderr 29eacabcc0 fix(call-to-play): key actors by stable peer identity
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
2026-07-21 22:40:59 +02:00

367 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 youre 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">Youre in well nudge you when check-in opens</div>
<button className="ghost-btn ghost-danger" onClick={() => actions.leave(nomination.id)}>
Cant make it
</button>
</>
);
}
return (
<>
<button className="act-btn act-play" onClick={() => actions.rsvp(nomination.id)}>
<Icon.check /><span>Im 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
? 'Everyones ready.'
: `Its time — waiting for ${nomination.creator} to start.`}
</div>
);
}
if (isMe) {
if (myStatus.status === 'in') {
return (
<>
<div className="ctp-me-status">You said youre 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 ? 'Youre in — ready' : `You: ready in ${formatCountdown((myStatus.readyAt ?? now) - now)}`}
</div>
{!ready && (
<button className="ghost-btn" onClick={() => actions.respond(nomination.id, 'ready')}>
Im ready now
</button>
)}
<button className="ghost-btn ghost-danger" onClick={() => actions.leave(nomination.id)}>
Leave
</button>
</>
);
}
return <ReadyButtons nomination={nomination} actions={actions} includeThirty />;
};