From f2b2541b46b57ba93fbc6782631643e39f77ad38 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sun, 18 Oct 2026 16:20:29 -0400 Subject: [PATCH] Compact archived evidence transcripts (Refs #159) --- .../moderation/EvidenceMessages.svelte | 178 ++++++++++++------ .../moderation/MatchEvidenceDialog.svelte | 7 +- src/lib/ui/moderation-evidence.ts | 4 +- src/routes/moderation/cases/[id]/+page.svelte | 65 ++----- tests/client/match-evidence.test.ts | 2 +- tests/client/moderation-case-page.test.ts | 64 +++++++ 6 files changed, 210 insertions(+), 110 deletions(-) diff --git a/src/lib/ui/components/moderation/EvidenceMessages.svelte b/src/lib/ui/components/moderation/EvidenceMessages.svelte index cfbd8cc..b3642ee 100644 --- a/src/lib/ui/components/moderation/EvidenceMessages.svelte +++ b/src/lib/ui/components/moderation/EvidenceMessages.svelte @@ -1,55 +1,102 @@ -

- Selected messages and surrounding context in archive order. Messages between separate selections - may be omitted. -

+
+

{context}

+

+ Highlighted lines are subject messages. Messages between separate selections may be omitted. +

+
    - {#each entries as entry} - {#if entry.type === 'message'} - {@const highlighted = - !!entry.messageId && highlightedMessageIds.includes(entry.messageId)} - {@const selected = !!entry.messageId && selectedMessageIds.includes(entry.messageId)} -
  1. -
    - {entry.message.player} - {#if entry.message.channel === 'team'}Team chat{/if} - {#if highlighted}Subject message - {:else if selected}Selected context - {:else}Surrounding context{/if} -
    - -

    {entry.message.message}

    - {#if entry.messageId}
    - Message identifier{entry.messageId} -
    {/if} -
  2. - {/if} + {#each messages as entry} + {@const highlighted = !!entry.messageId && highlightedMessageIds.includes(entry.messageId)} + {@const selected = !!entry.messageId && selectedMessageIds.includes(entry.messageId)} + {@const realTime = timestampISO(entry.context.realTime)} +
  3. + + {#if realTime}{:else}Unknown time{/if} + + {entry.message.player} +

    + {highlighted + ? 'Subject message: ' + : selected + ? 'Selected context: ' + : 'Surrounding context: '}{#if entry.message.channel === 'team'}Team chat{' '}{/if}{entry + .message.message} +

    +
  4. {/each}
+
+ Message details +
    + {#each messages as entry} +
  1. + {entry.message.player} + GUID {entry.message.guid} + Original log time · {entry.message.time} + {#if Number.isInteger(entry.context.round)}Round {entry.context.round}{/if} + {#if entry.messageId}Message identifier {entry.messageId}{/if} +
  2. + {/each} +
+
diff --git a/src/lib/ui/components/moderation/MatchEvidenceDialog.svelte b/src/lib/ui/components/moderation/MatchEvidenceDialog.svelte index 51c5e30..55ca1e8 100644 --- a/src/lib/ui/components/moderation/MatchEvidenceDialog.svelte +++ b/src/lib/ui/components/moderation/MatchEvidenceDialog.svelte @@ -250,7 +250,12 @@ {excerpt.length} messages in this excerpt. Selected subject messages are highlighted. Messages already preserved as evidence in this case are not attached again.

- + {#if error}{/if} {#if uncertain}

diff --git a/src/lib/ui/moderation-evidence.ts b/src/lib/ui/moderation-evidence.ts index 60c4127..8a415ef 100644 --- a/src/lib/ui/moderation-evidence.ts +++ b/src/lib/ui/moderation-evidence.ts @@ -30,8 +30,8 @@ export function preservedConversation(value: unknown): { } | null { if (!value || typeof value !== 'object') return null; const snapshot = value as Record; - if (!Array.isArray(snapshot.entries)) return null; - const entries = snapshot.entries.filter( + const candidates = Array.isArray(snapshot.entries) ? snapshot.entries : [snapshot.entry]; + const entries = candidates.filter( (entry): entry is Extract => { if (!entry || typeof entry !== 'object' || entry.type !== 'message') return false; return ( diff --git a/src/routes/moderation/cases/[id]/+page.svelte b/src/routes/moderation/cases/[id]/+page.svelte index a6ef59b..411659d 100644 --- a/src/routes/moderation/cases/[id]/+page.svelte +++ b/src/routes/moderation/cases/[id]/+page.svelte @@ -189,17 +189,6 @@ typeof value.entry?.message?.player === 'string' ? (value.entry.message.player as string) : null, - time: - typeof value.entry?.message?.time === 'string' - ? (value.entry.message.time as string) - : null, - realTime: - typeof value.entry?.context?.realTime === 'string' - ? (value.entry.context.realTime as string) - : null, - round: Number.isInteger(value.entry?.context?.round) - ? (value.entry.context.round as number) - : null, original: JSON.stringify(value, null, 2), excerpt: preservedConversation(value) }; @@ -302,45 +291,32 @@ {@const archive = archiveContext(evidence.snapshot)}

  • - {archive?.player ?? 'Archived message'}{#if archive?.round !== null && archive?.round !== undefined}Round {archive.round}{/if} + {archive?.player ?? 'Archived conversation'}
    {#if archive?.excerpt} - + {:else}
    {evidence.text}
    {/if} -

    {evidence.source}

    - {#if archive}
    - {#if archive.matchId}View match #{archive.matchId}{/if} - {#if archive.realTime}Estimated message time · {$timeFormat.dateTime( - archive.realTime - )}{/if} - {#if archive.time}Original log time · {archive.time}{/if} - {#if archive.startedAt}Match started · {$timeFormat.dateTime( - archive.startedAt - )}{/if} -
    {/if} + {#if archive?.matchId}View match #{archive.matchId}{/if}

    Preserved by {evidence.actor} ·

    - {#if archive}
    - Preserved archive snapshot -
    {archive.original}
    -
    {/if} +
    + Technical details +

    {evidence.source}

    +
    {archive?.original ?? evidence.snapshot}
    +
  • {/each} @@ -694,15 +670,10 @@ } .hint, dt, - .source-context, summary { color: var(--text-secondary); font-size: var(--text-sm); } - .source-context { - display: grid; - gap: var(--space-2); - } .notice { border: 1px solid var(--border-subtle); border-radius: var(--radius-md); diff --git a/tests/client/match-evidence.test.ts b/tests/client/match-evidence.test.ts index d18578f..aef6dbf 100644 --- a/tests/client/match-evidence.test.ts +++ b/tests/client/match-evidence.test.ts @@ -83,7 +83,7 @@ it('selects messages across participants, previews their context, and attaches t ); expect(fetch.mock.calls[0][0]).toContain('game=t5&guid=11'); expect(document.querySelector('dialog')?.textContent).toContain('GUID 22'); - expect(document.querySelector('dialog')?.textContent).toContain('Message time unknown'); + expect(document.querySelector('dialog')?.textContent).toContain('Unknown time'); const excerpt = document.querySelector('dialog [aria-label="Preserved conversation excerpt"]')!; expect(excerpt.querySelectorAll('li')).toHaveLength(3); expect(excerpt.querySelector('.highlighted')?.textContent).toContain('A concerning reply'); diff --git a/tests/client/moderation-case-page.test.ts b/tests/client/moderation-case-page.test.ts index 1e95047..0b5f8f6 100644 --- a/tests/client/moderation-case-page.test.ts +++ b/tests/client/moderation-case-page.test.ts @@ -205,6 +205,18 @@ it('preserves original evidence context, linked restrictions, and staff history expect(document.body.textContent).toContain('Read-only moderation access'); expect(document.body.textContent).toContain('Round 9'); expect(document.body.textContent).toContain('Original log time · 12:34'); + const evidence = document.querySelector('[aria-labelledby="evidence-title"]')!; + expect( + evidence.querySelector('[aria-label="Preserved conversation excerpt"]')?.textContent + ).toContain('abusive message'); + const technical = [...evidence.querySelectorAll('details')].find( + (node) => node.querySelector('summary')?.textContent === 'Technical details' + )!; + expect(technical.open).toBe(false); + expect(technical.textContent).toContain('Archived chat · theater · match 17'); + expect(JSON.parse(technical.querySelector('pre')!.textContent!)).toEqual( + JSON.parse(detail().case.evidence[0].snapshot!) + ); expect(document.body.textContent).toContain('Check the earlier warning.'); expect(document.body.textContent).toContain('Prior warning ignored'); expect(document.body.textContent).toContain('Policy pending'); @@ -223,6 +235,58 @@ it('preserves original evidence context, linked restrictions, and staff history expect(document.querySelector('dialog')).toBeNull(); }); +it('summarizes a preserved conversation across midnight and keeps identifiers on demand', () => { + const initial = detail(); + const archive = JSON.parse(initial.case.evidence[0].snapshot!); + archive.entries = [ + { + ...archive.entry, + messageId: 'subject-message', + context: { realTime: '2026-10-16T03:59:00Z', round: 4 }, + message: { ...archive.entry.message, message: 'First message', channel: 'team' } + }, + { + ...archive.entry, + messageId: 'witness-message', + context: { realTime: '2026-10-16T04:01:00Z', round: 9 }, + message: { ...archive.entry.message, guid: '42', player: 'Witness', message: 'Reply' } + }, + { + ...archive.entry, + messageId: 'unknown-time', + context: { realTime: null, round: null } + } + ]; + archive.selectedMessageIds = ['subject-message', 'witness-message']; + archive.highlightedMessageIds = ['subject-message']; + initial.case.evidence[0].snapshot = JSON.stringify(archive); + show(initial); + const evidence = document.querySelector('[aria-labelledby="evidence-title"]')!; + const excerpt = evidence.querySelector('[aria-label="Preserved conversation excerpt"]')!; + const rows = excerpt.querySelectorAll('li'); + expect(rows).toHaveLength(3); + expect(rows[0].classList.contains('highlighted')).toBe(true); + expect(rows[1].classList.contains('highlighted')).toBe(false); + expect(rows[0].textContent).toContain('Team chat'); + expect(rows[0].textContent).toContain('First message'); + expect(rows[0].querySelector('b')).toBeNull(); + expect(rows[1].textContent).toContain('Selected context'); + expect(rows[2].textContent).toContain('Unknown time'); + expect(excerpt.textContent).not.toContain(guid); + expect(excerpt.textContent).not.toContain('subject-message'); + expect(excerpt.textContent).not.toContain('Round'); + const context = excerpt.previousElementSibling!.textContent!; + expect(context).toContain('Kino Der Toten · Match #17'); + expect(context).toContain('Oct 15, 2026 – Oct 16, 2026'); + expect(context).toContain('Rounds 4–9 (some unknown)'); + expect(context).toContain('Times are estimates.'); + const details = excerpt.nextElementSibling as HTMLDetailsElement; + expect(details.open).toBe(false); + expect(details.textContent).toContain(`GUID ${guid}`); + expect(details.textContent).toContain('Message identifier subject-message'); + expect(details.textContent).toContain('Message identifier witness-message'); +}); + it('locks case restrictions to the recorded identity and revision without closing the case', async () => { const updated = detail(); updated.case.revision = 5;