Files
electric-cherry/src/lib/ui/components/ChatPanel.svelte
T

627 lines
22 KiB
Svelte

<script lang="ts">
import { onMount, tick } from 'svelte';
import { page } from '$app/stores';
import { canModerate, canReadModeration } from '$lib/auth.js';
import { moderationCaseHref, moderationHref } from '$ui/moderation.js';
import type { ChatContext, ContextualChatEntry } from '$lib/game-chat.js';
import { formatDuration } from '$ui/duration.js';
import { groupChatTimeline, type ChatBoundaryEntry } from '$ui/chat-timeline.js';
import { pollVisible } from '$ui/poll.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import Button from './primitives/Button.svelte';
import Freshness from './Freshness.svelte';
import MatchEvidenceDialog from './moderation/MatchEvidenceDialog.svelte';
let {
server,
game,
history,
hostOffline = false,
hostName
}: {
server?: string;
game?: string;
history?: ContextualChatEntry[];
hostOffline?: boolean;
hostName?: string;
} = $props();
let updatedAt = $state<number | null>(null);
const moderationGame = $derived(game ?? server?.split('_')[0]);
let liveEntries = $state<ContextualChatEntry[]>([]);
const entries = $derived(history ?? liveEntries);
let timeline = $derived(groupChatTimeline(entries));
let hasMessages = $derived(entries.some((entry) => entry.type === 'message'));
let liveLoaded = $state(false);
const loaded = $derived(history !== undefined || liveLoaded);
let loading = $state(false);
let manualRefreshing = $state(false);
let error = $state('');
let missing = $state(false);
let body = $state<HTMLDivElement | null>(null);
let follow = $state(true);
let paused = $state(false);
let controller: AbortController | null = null;
const selectableMessages = $derived(
history !== undefined &&
canModerate($page.data?.user?.role) &&
(moderationGame === 't5' || moderationGame === 't6')
? entries.filter(
(entry) => entry.type === 'message' && entry.messageId && entry.matchId
)
: []
);
let selectedMessageIds = $state<string[]>([]);
let evidenceOpen = $state(false);
let attachedCaseId = $state('');
const selectedMatchId = $derived(
selectableMessages.find(
(entry) => entry.messageId && selectedMessageIds.includes(entry.messageId)
)?.matchId
);
const selectionCandidates = $derived(
selectableMessages.filter(
(entry) => entry.matchId === (selectedMatchId ?? selectableMessages[0]?.matchId)
)
);
$effect(() => {
void history;
selectedMessageIds = [];
evidenceOpen = false;
attachedCaseId = '';
});
function toggleEvidence(messageId: string) {
selectedMessageIds = selectedMessageIds.includes(messageId)
? selectedMessageIds.filter((id) => id !== messageId)
: [...selectedMessageIds, messageId];
}
async function refresh(manual = false) {
if (!server || hostOffline) return;
if (loading) {
if (manual) manualRefreshing = true;
return;
}
loading = true;
manualRefreshing = manual;
const request = new AbortController();
controller = request;
try {
const response = await fetch(`/api/servers/${encodeURIComponent(server)}/chat`, {
signal: request.signal
});
const data = await response.json().catch(() => null);
if (!response.ok || !data?.ok)
throw new Error('Could not refresh chat. Try again shortly.');
if (request.signal.aborted || hostOffline) return;
if (paused && !manual) return;
liveEntries = data.entries ?? [];
missing = !!data.missing;
liveLoaded = true;
updatedAt = Date.now();
error = '';
await tick();
if (follow && body) body.scrollTop = body.scrollHeight;
} catch (err) {
if (!request.signal.aborted)
error = err instanceof Error ? err.message : 'Could not refresh chat.';
} finally {
loading = false;
manualRefreshing = false;
}
}
$effect(() => {
if (hostOffline) controller?.abort();
});
function onScroll() {
if (body) follow = body.scrollHeight - body.scrollTop - body.clientHeight < 40;
}
async function latest() {
follow = true;
await tick();
if (body) body.scrollTop = body.scrollHeight;
}
onMount(() => {
if (!server) return;
const stop = pollVisible(() => (paused ? Promise.resolve() : refresh()), 5000);
return () => {
stop();
controller?.abort();
};
});
</script>
{#snippet unavailable()}
<span aria-hidden="true">—</span><span class="sr-only">Unavailable</span>
{/snippet}
{#snippet clockTime(value: string)}
<time
datetime={value}
title={$timeFormat.dateTime(value)}
aria-label={$timeFormat.dateTime(value)}>{$timeFormat.clockTime(value)}</time
>
{/snippet}
{#snippet chatContext(context: ChatContext)}
<dl class="context" aria-label="Message context">
<div>
<dt>Match</dt>
<dd>
{#if context.matchElapsedMs === null}{@render unavailable()}{:else}{formatDuration(
context.matchElapsedMs
)}{/if}
</dd>
</div>
<div>
<dt>Round</dt>
<dd>
{#if context.round === null}{@render unavailable()}{:else}{context.round}{/if}
</dd>
</div>
</dl>
{/snippet}
{#snippet matchBoundary(entry: ChatBoundaryEntry)}
<div class="match-divider">
<div class="match-label">
<span
>{entry.event === 'start'
? 'Match started'
: entry.event === 'end'
? 'Match ended'
: 'New match'}</span
>
{#if entry.context.realTime}<span class="boundary-context"
>{@render clockTime(entry.context.realTime)}</span
>{/if}
{#if entry.context.matchElapsedMs !== null}<span class="boundary-context"
>Match <span class="number">{formatDuration(entry.context.matchElapsedMs)}</span
></span
>{/if}
{#if entry.context.round !== null}<span class="boundary-context"
>Round <span class="number">{entry.context.round}</span></span
>{/if}
</div>
</div>
{/snippet}
<section aria-label="Player chat">
<div class="chat-head">
<div class="chat-title">
<h2>Chat</h2>
{#if !server}<span class="update-cadence">Match history</span
>{:else if hostOffline}<span class="update-cadence">Host offline</span
>{:else if paused}<span class="update-cadence">Paused</span
>{:else if !error}<Freshness {updatedAt} intervalLabel="every 5 s" />{/if}
</div>
{#if server}
<div class="actions">
{#if hasMessages}<Button
size="sm"
variant="ghost"
onclick={() => (paused = !paused)}>{paused ? 'Resume' : 'Pause'}</Button
>{/if}
{#if !follow}<Button size="sm" variant="ghost" onclick={latest}
>Latest messages</Button
>{/if}
</div>
{/if}
</div>
{#if selectableMessages.length}
<div class="evidence-toolbar" aria-label="Chat evidence selection">
<span>{selectedMessageIds.length} selected</span>
<Button
size="sm"
onclick={() =>
(selectedMessageIds = selectionCandidates
.slice(0, 100)
.map((entry) => entry.messageId!))}
>Select {selectionCandidates.length > 100 ? 'first 100' : 'all'}</Button
>
<Button
size="sm"
disabled={!selectedMessageIds.length}
onclick={() => (selectedMessageIds = [])}>Clear selection</Button
>
<Button
size="sm"
variant="primary"
disabled={!selectedMessageIds.length}
onclick={() => (evidenceOpen = true)}>Attach to case</Button
>
{#if selectedMessageIds.length >= 100}<span
>Attach up to 100 selected messages at a time.</span
>{/if}
</div>
{/if}
{#if attachedCaseId}<p class="evidence-notice" role="status">
Selected evidence saved. <a href={moderationCaseHref(attachedCaseId)}>View case</a>
</p>{/if}
{#if hostOffline}<p class="outage">
Chat resumes when {hostName ?? 'the host agent'} reconnects.{loaded
? ' Showing the last loaded messages.'
: ''}
</p>
{:else if error}<p class="ui-error" role="alert">
Chat updates paused. {loaded
? 'Showing the last loaded messages.'
: 'Could not load messages.'}
<Button size="sm" onclick={() => refresh(true)} loading={manualRefreshing}>Retry</Button
>
</p>{/if}
{#if !loaded && !error && !hostOffline}
<p class="empty" role="status">Loading chat…</p>
{:else if loaded && !hasMessages}
<p class="empty">
{history !== undefined
? 'No chat messages were saved for this match.'
: missing
? 'Waiting for the game log. Player messages will appear here.'
: 'No player messages in this match yet.'}
</p>
{:else if hasMessages}
<!-- svelte-ignore a11y_no_noninteractive_tabindex (The scrollable chat region must support keyboard scrolling.) -->
<div
class="messages"
bind:this={body}
onscroll={onScroll}
tabindex="0"
role="region"
aria-label="Chat messages"
>
<ol>
{#each timeline as item}
{#if item.type === 'boundaries'}
<li class="match-events">
{#if item.entries.length > 1}
{#key JSON.stringify(item.entries)}
<details
class="earlier-events"
ontoggle={(event) => {
if (event.currentTarget.open) follow = false;
}}
>
<summary
>{item.entries.length - 1} earlier match {item.entries
.length === 2
? 'event'
: 'events'}</summary
>
<ol aria-label="Earlier match events">
{#each item.entries.slice(0, -1) as entry}
<li>{@render matchBoundary(entry)}</li>
{/each}
</ol>
</details>
{/key}
{/if}
{@render matchBoundary(item.entries[item.entries.length - 1])}
</li>
{:else}
<li
class="message"
class:selected={!!item.entry.messageId &&
selectedMessageIds.includes(item.entry.messageId)}
>
<span class="message-clock">
{#if item.entry.context.realTime}{@render clockTime(
item.entry.context.realTime
)}{:else}<span class="sr-only"
>Time:
</span>{@render unavailable()}{/if}
</span>
<div class="message-content">
<div class="message-head">
{#if selectableMessages.length && item.entry.messageId && item.entry.matchId}
<label class="evidence-select">
<input
type="checkbox"
checked={selectedMessageIds.includes(
item.entry.messageId
)}
disabled={!selectedMessageIds.includes(
item.entry.messageId
) &&
(selectedMessageIds.length >= 100 ||
(!!selectedMatchId &&
selectedMatchId !==
item.entry.matchId))}
onchange={() =>
toggleEvidence(item.entry.messageId!)}
aria-label={`Select message by ${item.entry.message.player} at ${item.entry.message.time}: ${item.entry.message.message}`}
/>
<span class="sr-only">Select evidence</span>
</label>
{/if}
<strong>{item.entry.message.player}</strong
>{@render chatContext(item.entry.context)}
{#if canReadModeration($page.data?.user?.role) && (moderationGame === 't5' || moderationGame === 't6')}<a
class="moderation-link"
href={moderationHref({
game: moderationGame,
q: item.entry.message.guid,
server,
matchId: item.entry.matchId,
message: item.entry.message.message
})}
aria-label={`Review ${item.entry.message.player} in moderation`}
>Moderation</a
>{/if}
</div>
<p>{item.entry.message.message}</p>
</div>
</li>
{/if}
{/each}
</ol>
</div>
{/if}
</section>
{#if evidenceOpen && selectedMatchId && (moderationGame === 't5' || moderationGame === 't6')}
<MatchEvidenceDialog
bind:open={evidenceOpen}
game={moderationGame}
matchId={selectedMatchId}
entries={entries.filter((entry) => entry.matchId === selectedMatchId)}
{selectedMessageIds}
onsubmitted={(id) => {
attachedCaseId = id;
selectedMessageIds = [];
}}
/>
{/if}
<style>
section {
container-type: inline-size;
}
.chat-head,
.actions,
.chat-title {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
.evidence-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-3);
margin-top: var(--space-3);
font-size: var(--text-sm);
color: var(--text-secondary);
}
.evidence-notice {
font-size: var(--text-sm);
color: var(--text-secondary);
}
.evidence-select {
display: inline-flex;
align-items: center;
min-width: 24px;
min-height: 24px;
}
.evidence-select input {
accent-color: var(--accent-primary);
}
.message.selected {
background: var(--surface-raised);
}
.chat-head {
justify-content: space-between;
}
.chat-title {
gap: var(--space-2) var(--space-3);
}
h2 {
margin: 0;
font-size: var(--text-lg);
}
.update-cadence {
color: var(--text-secondary);
font-size: var(--text-xs);
}
.outage {
color: var(--state-warning);
font-size: var(--text-sm);
line-height: var(--leading-body);
}
.empty {
color: var(--text-secondary);
font-size: var(--text-sm);
padding: 8px 0;
margin-bottom: 0;
}
.messages {
max-height: 360px;
overflow: auto;
margin-top: var(--space-4);
}
.messages:focus-visible {
outline: none;
box-shadow: inset 0 0 0 2px var(--accent-primary);
border-radius: var(--radius-sm);
}
ol {
list-style: none;
padding: 0;
margin: 0;
}
.message {
display: grid;
grid-template-columns: 6rem minmax(0, 1fr);
align-items: baseline;
column-gap: var(--space-4);
padding: var(--space-3) 0;
}
.message-clock,
.number {
font-family: var(--font-numeric);
font-variant-numeric: tabular-nums;
}
.message-clock {
font-size: var(--text-xs);
color: var(--text-secondary);
white-space: nowrap;
}
.message-content {
min-width: 0;
}
.message-head {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: var(--space-1) var(--space-3);
}
strong {
min-width: 0;
color: var(--text-primary);
font-size: var(--text-base);
font-weight: var(--weight-semibold);
overflow-wrap: anywhere;
}
.context {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: var(--space-1) var(--space-3);
margin: 0;
color: var(--text-secondary);
font-size: var(--text-xs);
font-weight: var(--weight-regular);
}
.context > div {
display: flex;
align-items: baseline;
gap: var(--space-1);
white-space: nowrap;
}
.context > div + div::before {
content: '·';
margin-right: var(--space-1);
color: var(--text-tertiary);
}
.context dd {
margin: 0;
font-family: var(--font-numeric);
font-variant-numeric: tabular-nums;
}
.message p {
margin: var(--space-1) 0 0;
font-size: var(--text-base);
line-height: 1.6;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.moderation-link {
font-size: var(--text-xs);
}
.match-events {
padding: var(--space-1) 0;
}
.match-divider {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) 0;
color: var(--text-secondary);
font-size: var(--text-xs);
}
.match-divider::before,
.match-divider::after {
content: '';
height: 1px;
flex: 1;
min-width: var(--space-3);
background: var(--border-subtle);
}
.match-label {
min-width: 0;
display: flex;
align-items: baseline;
justify-content: center;
flex-wrap: wrap;
gap: var(--space-1) var(--space-2);
}
.boundary-context {
white-space: nowrap;
}
.boundary-context::before {
content: '·';
margin-right: var(--space-2);
color: var(--text-tertiary);
}
.earlier-events summary {
width: fit-content;
max-width: 100%;
box-sizing: border-box;
min-height: 32px;
margin-inline: auto;
padding: var(--space-2);
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-size: var(--text-xs);
cursor: pointer;
}
.earlier-events summary:hover {
color: var(--text-primary);
background: var(--surface-raised);
}
.earlier-events summary:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@container (max-width: 560px) {
.message {
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--space-1) var(--space-3);
}
.message-clock {
grid-column: 2;
grid-row: 1;
}
.message-content,
.message-head {
display: contents;
}
strong {
grid-column: 1;
grid-row: 1;
}
.context {
grid-column: 1 / -1;
grid-row: 2;
}
.message p {
grid-column: 1 / -1;
grid-row: 3;
margin-top: 0;
}
.moderation-link {
grid-column: 1 / -1;
grid-row: 4;
}
.evidence-select {
grid-column: 1 / -1;
grid-row: 5;
}
}
</style>