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
126 lines
5.4 KiB
TypeScript
126 lines
5.4 KiB
TypeScript
import { CSSProperties } from 'react';
|
|
|
|
import { Icon } from '../Icon';
|
|
import {
|
|
avatarColor,
|
|
formatClock,
|
|
formatCountdown,
|
|
formatCountdownShort,
|
|
formatUntil,
|
|
isReady,
|
|
readyCountOf,
|
|
statusOf,
|
|
} from '../../lib/callToPlay';
|
|
import { Game, Nomination } from '../../lib/types';
|
|
|
|
interface Props {
|
|
nominations: ReadonlyArray<Nomination>;
|
|
games: ReadonlyArray<Game>;
|
|
accent: string;
|
|
onOpen: (callId: string) => void;
|
|
}
|
|
|
|
const LABEL = {
|
|
scheduled: 'Scheduled',
|
|
call: 'Call to Play',
|
|
soon: 'Starting soon',
|
|
ready: 'Ready',
|
|
} as const;
|
|
|
|
const RANK = { ready: 0, soon: 1, call: 2, scheduled: 2, started: 3 } as const;
|
|
|
|
const MiniBubbles = ({ nomination, now }: { nomination: Nomination; now: number }) => {
|
|
const entries = Object.entries(nomination.participants);
|
|
return (
|
|
<span className="ctp-ticker-bubbles">
|
|
{entries.slice(0, 6).map(([participantId, participant]) => {
|
|
const name = participant.name;
|
|
const ready = isReady(participant, now);
|
|
const state = ready ? 'ready' : participant.status === 'in' ? 'in' : 'pending';
|
|
const initials = name.replace(/[^a-z0-9]/gi, '').slice(0, 2).toUpperCase();
|
|
const remaining = (participant.readyAt ?? now) - now;
|
|
return (
|
|
<span
|
|
key={participantId}
|
|
className="ctp-mini"
|
|
data-state={state}
|
|
title={`${name} — ${ready ? 'ready' : state === 'in' ? 'in' : `ready ${formatCountdownShort(remaining)}`}`}
|
|
style={{ background: avatarColor(name) }}
|
|
>
|
|
{initials}
|
|
{ready && <span className="ctp-mini-check"><Icon.check /></span>}
|
|
{state === 'pending' && (
|
|
<i className="ctp-mini-tag">{formatCountdownShort(remaining)}</i>
|
|
)}
|
|
</span>
|
|
);
|
|
})}
|
|
{entries.length > 6 && <span className="ctp-mini ctp-mini-more">+{entries.length - 6}</span>}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
export const CallToPlayTicker = ({ nominations, games, accent, onOpen }: Props) => {
|
|
const now = Date.now();
|
|
const gameById = new Map(games.map(game => [game.id, game]));
|
|
const active = nominations
|
|
.filter(nomination => nomination.state !== 'started')
|
|
.sort((left, right) =>
|
|
RANK[statusOf(left, now)] - RANK[statusOf(right, now)]
|
|
|| left.deadline - right.deadline
|
|
);
|
|
if (active.length === 0) return null;
|
|
|
|
return (
|
|
<div className="ctp-ticker-stack">
|
|
{active.map(nomination => {
|
|
const game = gameById.get(nomination.gameId);
|
|
if (!game) return null;
|
|
const status = statusOf(nomination, now);
|
|
if (status === 'started') return null;
|
|
const ready = readyCountOf(nomination, now);
|
|
const total = Object.keys(nomination.participants).length;
|
|
const remaining = Math.max(0, nomination.deadline - now);
|
|
const last = nomination.messages[nomination.messages.length - 1];
|
|
const count = status === 'scheduled'
|
|
? `${total} in`
|
|
: `${ready}/${nomination.maxPlayers} ready`;
|
|
const time = status === 'ready'
|
|
? 'waiting to start'
|
|
: status === 'scheduled'
|
|
? `${formatClock(nomination.scheduledFor!)} · ${formatUntil(nomination.scheduledFor! - now)}`
|
|
: nomination.scheduledFor !== null
|
|
? `starts ${formatClock(nomination.scheduledFor)} · ${formatCountdown(remaining)}`
|
|
: formatCountdown(remaining);
|
|
return (
|
|
<button
|
|
key={nomination.id}
|
|
className="ctp-ticker"
|
|
data-status={status}
|
|
style={{ '--accent': accent } as CSSProperties}
|
|
onClick={() => onOpen(nomination.id)}
|
|
>
|
|
<span className="ctp-ticker-dot" data-status={status} />
|
|
<span className="ctp-ticker-label" data-status={status}>{LABEL[status]}</span>
|
|
<span className="ctp-ticker-game">{game.name}</span>
|
|
<span className="ctp-ticker-by">by {nomination.creator}</span>
|
|
<span className="ctp-ticker-ready">{count}</span>
|
|
<span className="ctp-ticker-time">{time}</span>
|
|
{last ? (
|
|
<span className="ctp-ticker-chat" title={`${last.from}: ${last.text}`}>
|
|
<Icon.chat />
|
|
<b style={{ color: avatarColor(last.from) }}>{last.from}:</b>
|
|
<span className="ctp-ticker-chat-text">{last.text}</span>
|
|
</span>
|
|
) : <span className="ctp-ticker-chat" />}
|
|
<MiniBubbles nomination={nomination} now={now} />
|
|
<span className="ctp-ticker-cta">
|
|
{status === 'soon' ? 'Check in' : 'View'}<Icon.chevron />
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
};
|