627 lines
22 KiB
Svelte
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>
|