Preserve conversation excerpts and deduplicate case evidence

This commit is contained in:
2026-10-17 19:35:52 -04:00
parent 83f1c91877
commit ef96a2c308
13 changed files with 1222 additions and 50 deletions
@@ -0,0 +1,107 @@
<script lang="ts">
import type { ContextualChatEntry } from '$lib/game-chat.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import Badge from '../primitives/Badge.svelte';
let {
entries,
selectedMessageIds = [],
highlightedMessageIds = []
}: {
entries: ContextualChatEntry[];
selectedMessageIds?: string[];
highlightedMessageIds?: string[];
} = $props();
</script>
<p class="excerpt-note">
Selected messages and surrounding context in archive order. Messages between separate selections
may be omitted.
</p>
<ol class="excerpt" aria-label="Preserved conversation excerpt">
{#each entries as entry}
{#if entry.type === 'message'}
{@const highlighted =
!!entry.messageId && highlightedMessageIds.includes(entry.messageId)}
{@const selected = !!entry.messageId && selectedMessageIds.includes(entry.messageId)}
<li class:highlighted>
<div class="author">
<strong>{entry.message.player}</strong>
{#if highlighted}<Badge variant="warning">Subject message</Badge>
{:else if selected}<Badge>Selected context</Badge>
{:else}<span>Surrounding context</span>{/if}
</div>
<div class="metadata">
<span>GUID {entry.message.guid}</span>
{#if entry.context.realTime}<span
>Estimated time <time datetime={entry.context.realTime}
>{$timeFormat.dateTime(entry.context.realTime)}</time
></span
>{:else}<span>Message time unknown</span>{/if}
<span>Log {entry.message.time}</span>
{#if entry.context.round !== null}<span>Round {entry.context.round}</span>{/if}
</div>
<p>{entry.message.message}</p>
{#if entry.messageId}<details>
<summary>Message identifier</summary><code>{entry.messageId}</code>
</details>{/if}
</li>
{/if}
{/each}
</ol>
<style>
.excerpt {
display: grid;
gap: var(--space-2);
list-style: none;
margin: 0;
padding: 0;
}
li {
padding: var(--space-3);
border-left: 3px solid var(--border-subtle);
background: var(--surface-sunken);
min-width: 0;
}
li.highlighted {
border-left-color: var(--state-warning);
background: var(--surface-raised);
}
.author,
.metadata {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
overflow-wrap: anywhere;
}
.author > span,
.metadata {
color: var(--text-secondary);
font-size: var(--text-xs);
}
.metadata {
margin-top: var(--space-1);
}
p {
white-space: pre-wrap;
overflow-wrap: anywhere;
margin: var(--space-2) 0 0;
line-height: var(--leading-body);
}
details {
margin-top: var(--space-2);
font-size: var(--text-xs);
color: var(--text-secondary);
overflow-wrap: anywhere;
}
summary {
cursor: pointer;
}
.excerpt-note {
margin: 0;
font-size: var(--text-xs);
color: var(--text-secondary);
}
</style>
@@ -0,0 +1,305 @@
<script lang="ts">
import { untrack } from 'svelte';
import type { ContextualChatEntry } from '$lib/game-chat.js';
import {
moderationGuid,
moderationCaseStatusLabels,
type ModerationCase,
type ModerationGame,
type ModerationOverview
} from '$lib/moderation.js';
import { conversationExcerpt } from '$ui/moderation-evidence.js';
import Dialog from '../primitives/Dialog.svelte';
import Field from '../primitives/Field.svelte';
import Input from '../primitives/Input.svelte';
import Select from '../primitives/Select.svelte';
import Textarea from '../primitives/Textarea.svelte';
import Button from '../primitives/Button.svelte';
import EvidenceMessages from './EvidenceMessages.svelte';
let {
open = $bindable(false),
game,
matchId,
entries,
selectedMessageIds,
onsubmitted
}: {
open?: boolean;
game: ModerationGame;
matchId: number;
entries: ContextualChatEntry[];
selectedMessageIds: string[];
onsubmitted: (id: string) => void;
} = $props();
const selected = $derived(
entries.filter(
(entry) =>
entry.type === 'message' &&
!!entry.messageId &&
selectedMessageIds.includes(entry.messageId)
)
);
const participants = $derived.by(() => {
const players = new Map<string, string>();
for (const entry of selected) {
if (entry.type === 'message')
players.set(moderationGuid(game, entry.message.guid), entry.message.player);
}
return [...players].map(([guid, name]) => ({
value: guid,
label: `${name} · GUID ${guid}`
}));
});
let guid = $state(untrack(() => participants[0]?.value ?? ''));
let destination = $state('');
let title = $state('');
let note = $state('');
let contextBefore = $state<number | undefined>(3);
let contextAfter = $state<number | undefined>(3);
let cases = $state<ModerationCase[]>([]);
let loading = $state(false);
let loadError = $state('');
let reload = $state(0);
let busy = $state(false);
let uncertain = $state(false);
let error = $state('');
let pending: Record<string, unknown> | null = null;
const excerpt = $derived(
conversationExcerpt(entries, selectedMessageIds, contextBefore ?? 0, contextAfter ?? 0)
);
const highlightedMessageIds = $derived(
selected.flatMap((entry) =>
entry.type === 'message' &&
entry.messageId &&
moderationGuid(game, entry.message.guid) === guid
? [entry.messageId]
: []
)
);
$effect(() => {
const selectedGuid = guid;
const selectedGame = game;
void reload;
const controller = new AbortController();
cases = [];
destination = '';
loadError = '';
loading = true;
async function loadCases() {
try {
const loaded: ModerationCase[] = [];
let offset = 0;
while (true) {
const params = new URLSearchParams({
view: 'cases',
game: selectedGame,
guid: selectedGuid,
offset: String(offset)
});
const response = await fetch(`/api/moderation?${params}`, {
signal: controller.signal
});
if (!response.ok)
throw new Error(
'Could not load existing cases. Retry before attaching evidence.'
);
const report: ModerationOverview = await response.json();
if (controller.signal.aborted) return;
loaded.push(...report.cases);
offset += report.cases.length;
if (!report.cases.length || offset >= report.total) break;
}
cases = loaded;
} catch (cause) {
if (!controller.signal.aborted)
loadError =
cause instanceof Error ? cause.message : 'Could not load existing cases.';
} finally {
if (!controller.signal.aborted) loading = false;
}
}
void loadCases();
return () => controller.abort();
});
async function submit(event: SubmitEvent) {
event.preventDefault();
if (busy || (!pending && (loading || loadError))) return;
if (!pending) {
const target = cases.find((item) => item.id === destination);
if (!guid || !highlightedMessageIds.length || (destination && !target)) {
error = 'Choose a case subject with at least one selected message.';
return;
}
if (
![contextBefore, contextAfter].every(
(value) => Number.isInteger(value) && value! >= 0 && value! <= 20
)
) {
error = 'Choose between 0 and 20 surrounding messages on each side.';
return;
}
pending = {
...(target
? { op: 'attach-evidence', id: target.id, revision: target.revision }
: { op: 'create-case', game, guid, title: title.trim(), note: note.trim() }),
evidence: { matchId, messageIds: selectedMessageIds, contextBefore, contextAfter },
idempotencyKey: crypto.randomUUID()
};
}
busy = true;
error = '';
try {
const response = await fetch('/api/moderation', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(pending)
});
const result = await response.json().catch(() => null);
if (!response.ok || !result?.ok) {
uncertain = response.status >= 500 || response.ok;
if (!uncertain) pending = null;
if (response.status === 409) reload++;
throw new Error(result?.error || 'Could not attach the selected evidence.');
}
open = false;
onsubmitted(result.id);
} catch (cause) {
if (pending) uncertain = true;
error =
cause instanceof Error ? cause.message : 'Could not attach the selected evidence.';
} finally {
busy = false;
}
}
</script>
<Dialog
bind:open
title="Attach to case"
{busy}
wide
description={`${selectedMessageIds.length} selected messages from match #${matchId}`}
>
<form onsubmit={submit}>
<fieldset disabled={busy || uncertain}>
<Field
label="Case subject"
required
hint="Select the player being reviewed. Other participants remain in the preserved conversation."
>
<Select bind:value={guid} options={participants} required />
</Field>
{#if loading}<p role="status">Loading this player's cases…</p>
{:else if loadError}<p class="ui-error" role="alert">
{loadError}
<Button size="sm" onclick={() => reload++}>Retry</Button>
</p>
{:else}
<Field
label="Destination"
hint={cases.length
? 'Review existing cases before opening another.'
: 'No existing cases for this player.'}
>
<Select
bind:value={destination}
options={[
{ value: '', label: 'Open a new case' },
...cases.map((item) => ({
value: item.id,
label: `${item.title} · ${moderationCaseStatusLabels[item.status]}`
}))
]}
/>
</Field>
{#if !destination}
<Field label="Case title" required
><Input bind:value={title} maxlength={160} required /></Field
>
<Field label="Private staff note"
><Textarea bind:value={note} rows={3} maxlength={4000} /></Field
>
{/if}
{/if}
<div class="paired">
<Field label="Messages before" hint="Per selected message"
><Input
type="number"
bind:value={contextBefore}
min={0}
max={20}
step={1}
required
/></Field
>
<Field label="Messages after" hint="Per selected message"
><Input
type="number"
bind:value={contextAfter}
min={0}
max={20}
step={1}
required
/></Field
>
</div>
<p class="hint">
{excerpt.length} messages in this excerpt. Selected subject messages are highlighted.
Messages already preserved as evidence in this case are not attached again.
</p>
<EvidenceMessages entries={excerpt} {selectedMessageIds} {highlightedMessageIds} />
</fieldset>
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
{#if uncertain}<p class="hint">
The result is uncertain. Retry the same request to avoid duplicate evidence.
</p>{/if}
<div class="actions">
<Button onclick={() => (open = false)} disabled={busy}>Cancel</Button>
<Button
type="submit"
variant="primary"
loading={busy}
disabled={!uncertain && (loading || !!loadError)}
>{uncertain
? 'Retry same request'
: destination
? 'Attach evidence'
: 'Open case with evidence'}</Button
>
</div>
</form>
</Dialog>
<style>
form,
fieldset {
display: grid;
gap: var(--space-4);
min-width: 0;
}
fieldset {
border: 0;
margin: 0;
padding: 0;
}
.paired {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
}
.hint {
margin: 0;
font-size: var(--text-sm);
color: var(--text-secondary);
line-height: var(--leading-body);
}
.actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--space-3);
}
</style>