atagen

malice

public
Star0
Watch0
2c899da1562b65dc510bf13e5a1dd2a880b016ce
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
web/src/lib/MentionsInbox.svelteSvelte499 lines23.8 KB
1
<script lang="ts">
2
  import { get } from 'svelte/store';
3
  import { onDestroy, onMount, tick } from 'svelte';
4
  import { mentionContext, mentionsPage, recoverMentions, retryMentionContext, setMentionsRead, markMentionsReadThrough } from './api';
5
  import ContextTimeline from './ContextTimeline.svelte';
6
  import EmojiText from './EmojiText.svelte';
7
  import MessageRow from './MessageRow.svelte';
8
  import { mentionsSnapshot, publishMentionsPage, rooms, sessionUser } from './store';
9
  import { TimelineViewport } from './timeline-viewport';
10
  import { ApiError } from './transport';
11
  import type { TimelineItem } from './types';
12
  import { mentionKey } from './mentions-model';
13
  import type { MentionEntry, MentionsPage, SavedMentionContext } from './mentions-model';
14
15
  export let self: string;
16
  export let onLive: (roomId: string, eventId: string, threadRoot: string | null) => void;
17
18
  let page: MentionsPage | null = null;
19
  let entries: MentionEntry[] = [];
20
  let selected: MentionEntry | null = null;
21
  let saved: SavedMentionContext | null = null;
22
  let list: HTMLDivElement;
23
  let viewport: TimelineViewport | null = null;
24
  let observer: ResizeObserver | null = null;
25
  let error = '';
26
  let busy = false;
27
  let actionBusy = false;
28
  let loadingContext = false;
29
  let mounted = false;
30
  let alive = true;
31
  let generation = 0;
32
  let contextGeneration = 0;
33
  let depth = 1;
34
  let pendingRevision = 0;
35
  let refreshTimer: ReturnType<typeof setTimeout> | null = null;
36
  let renderedAccount = '';
37
  let selectedRevision = -1;
38
  let contextKey = '';
39
  let contextRequest: { key: string; revision: number } | null = null;
40
  let queuedContext: MentionEntry | null = null;
41
  let pendingExplicitOpen = false;
42
  let savedListTop = 0;
43
  $: unreadCount = $mentionsSnapshot?.boundary.account === self ? $mentionsSnapshot.unread_count : page?.unread_count ?? 0;
44
  $: joinedRooms = new Map($rooms.filter((room) => room.membership === 'Joined').map((room) => [room.room_id, room]));
45
  $: selectedRoom = selected ? joinedRooms.get(selected.key.room_id) : undefined;
46
  $: contextCanPaginate = !!selectedRoom;
47
48
  function itemEvent(item: TimelineItem) {
49
    return item.kind === 'event' ? item.event : null;
50
  }
51
  function roomName(entry: MentionEntry): string {
52
    return entry.room_name || joinedRooms.get(entry.key.room_id)?.name || entry.key.room_id;
53
  }
54
  const GAP_LABELS: Record<string, string> = {
55
    SyncDiscontinuity: 'Sync was interrupted',
56
    LimitedTimeline: 'Too many messages arrived at once',
57
    RoomLeft: 'You left the room',
58
    ContextUnavailable: 'Context could not be loaded',
59
    NotificationHistoryUnavailable: 'Notification history unavailable',
60
    CandidateExpired: 'A message could not be read',
61
    StoreReset: 'Inbox was reset',
62
    StoreFailed: 'Inbox could not be saved',
63
    HookTimedOut: 'Checking a message timed out'
64
  };
65
66
  function gapLabel(reason: string): string {
67
    return GAP_LABELS[reason] ?? reason;
68
  }
69
70
  function reasonLabel(reason: string): string {
71
    return reason === 'Direct' ? 'Direct mention' : reason === 'Reply' ? 'Reply' : reason === 'Keyword' ? 'Keyword' : 'Room mention';
72
  }
73
  function captureLabel(state: MentionEntry['capture']): string {
74
    if (state.kind === 'Failed') return state.message;
75
    return state.kind === 'Ready' ? 'Context saved' : state.kind === 'Partial' ? 'Partial context saved' : state.kind === 'Pending' ? 'Saving context' : 'Context unavailable';
76
  }
77
  function keyOf(entry: MentionEntry): string { return mentionKey(entry.key); }
78
  function bindViewport(node: HTMLDivElement) {
79
    list = node;
80
    viewport?.releaseScroll();
81
    observer?.disconnect();
82
    node.scrollTop = savedListTop;
83
    viewport = new TimelineViewport(node, null);
84
    const content = node.querySelector<HTMLElement>('.entry-content');
85
    observer = new ResizeObserver(() => viewport?.restore());
86
    if (content) observer.observe(content);
87
    void tick().then(() => {
88
      if (!alive || selected || list !== node) return;
89
      node.scrollTop = savedListTop;
90
      viewport?.capture();
91
    });
92
    return {
93
      destroy() {
94
        if (!selected) savedListTop = node.scrollTop;
95
        observer?.disconnect();
96
        observer = null;
97
        viewport?.releaseScroll();
98
        viewport = null;
99
      }
100
    };
101
  }
102
  function openContext(entry: MentionEntry): void {
103
    if (!selected) savedListTop = list?.scrollTop ?? savedListTop;
104
    void loadContext(entry);
105
  }
106
  function currentAccount(account: string): boolean {
107
    return alive && account === self && get(sessionUser) === account;
108
  }
109
  function captureViewport(): void { viewport?.capture(); }
110
  async function restoreViewport(): Promise<void> {
111
    await tick();
112
    if (alive) viewport?.restore();
113
  }
114
  async function fetchPage(cursor: string | null): Promise<MentionsPage> {
115
    return mentionsPage(cursor, 50);
116
  }
117
  async function fetchRange(targetDepth: number, request: number, account: string): Promise<void> {
118
    busy = true;
119
    error = '';
120
    const anchorPage = page;
121
    const anchorRevision = anchorPage?.revision ?? 0;
122
    let succeeded = false;
123
    try {
124
      const first = await fetchPage(null);
125
      if (!currentAccount(account) || request !== generation || first.boundary.account !== account) return;
126
      const firstSnapshot = first;
127
      const gathered = [...first.entries];
128
      let loadedDepth = 1;
129
      let cursor = first.next_cursor;
130
      let last = first;
131
      for (let index = 1; index < targetDepth && cursor; index++) {
132
        last = await fetchPage(cursor);
133
        if (!currentAccount(account) || request !== generation || last.boundary.account !== account) return;
134
        gathered.push(...last.entries);
135
        loadedDepth++;
136
        cursor = last.next_cursor;
137
      }
138
      const mirror = get(mentionsSnapshot);
139
      if (mirror?.boundary.account === account && mirror.revision > firstSnapshot.revision) {
140
        pendingRevision = Math.max(pendingRevision, mirror.revision);
141
      }
142
      if (firstSnapshot.revision < anchorRevision) return;
143
      captureViewport();
144
      const unique = new Map<string, MentionEntry>();
145
      for (const entry of gathered) unique.set(keyOf(entry), entry);
146
      if (mirror?.boundary.account === account) {
147
        for (const update of mirror.entries) {
148
          const key = keyOf(update);
149
          const previous = unique.get(key);
150
          if (previous && update.revision >= previous.revision) unique.set(key, update);
151
        }
152
      }
153
      const replacement = [...unique.values()];
154
      page = { ...firstSnapshot, next_cursor: last.next_cursor, end_reached: last.end_reached, entries: replacement };
155
      entries = replacement;
156
      if (selected) {
157
        const latestSelected = replacement.find((entry) => keyOf(entry) === keyOf(selected!));
158
        if (latestSelected && latestSelected.revision > selected.revision) selected = latestSelected;
159
        void loadContext(selected, false);
160
      }
161
      publishMentionsPage(firstSnapshot);
162
      if (pendingRevision <= firstSnapshot.revision) pendingRevision = 0;
163
      depth = loadedDepth;
164
      succeeded = true;
165
      await restoreViewport();
166
    } catch (cause) {
167
      if (currentAccount(account) && request === generation) error = cause instanceof Error ? cause.message : 'Could not load mentions';
168
    } finally {
169
      if (currentAccount(account) && request === generation) {
170
        busy = false;
171
        if (succeeded && pendingRevision > (page?.revision ?? 0)) scheduleRefresh(pendingRevision);
172
      }
173
    }
174
  }
175
  function scheduleRefresh(revision: number): void {
176
    pendingRevision = Math.max(pendingRevision, revision);
177
    if (busy || !mounted) return;
178
    if (refreshTimer) clearTimeout(refreshTimer);
179
    refreshTimer = setTimeout(() => {
180
      refreshTimer = null;
181
      if (!alive || busy) return;
182
      const request = ++generation;
183
      void fetchRange(Math.max(1, depth), request, self);
184
    }, 200);
185
  }
186
  async function loadMore(): Promise<void> {
187
    if (!page?.next_cursor || page.end_reached || busy) return;
188
    const cursor = page.next_cursor;
189
    const request = ++generation;
190
    const account = self;
191
    busy = true;
192
    error = '';
193
    let succeeded = false;
194
    try {
195
      const next = await fetchPage(cursor);
196
      if (!currentAccount(account) || request !== generation || next.boundary.account !== account) return;
197
      const current = page;
198
      if (!current || next.revision < current.revision) return;
199
      captureViewport();
200
      const unique = new Map(entries.map((entry) => [keyOf(entry), entry]));
201
      for (const entry of next.entries) unique.set(keyOf(entry), entry);
202
      page = { ...current, next_cursor: next.next_cursor, end_reached: next.end_reached, entries: [...unique.values()] };
203
      entries = page.entries;
204
      const mirror = get(mentionsSnapshot);
205
      if (mirror?.boundary.account === account) {
206
        for (const update of mirror.entries) {
207
          const key = keyOf(update);
208
          const previous = unique.get(key);
209
          if (previous && update.revision >= previous.revision) unique.set(key, update);
210
        }
211
        entries = [...unique.values()];
212
        page = { ...page, entries };
213
      }
214
      depth++;
215
      succeeded = true;
216
      await restoreViewport();
217
    } catch (cause) {
218
      if (currentAccount(account) && request === generation) error = cause instanceof Error ? cause.message : 'Could not load mentions';
219
    } finally {
220
      if (currentAccount(account) && request === generation) {
221
        busy = false;
222
        if (succeeded && pendingRevision > (page?.revision ?? 0)) scheduleRefresh(pendingRevision);
223
      }
224
    }
225
  }
226
  async function loadContext(entry: MentionEntry, explicit = true): Promise<void> {
227
    const id = keyOf(entry);
228
    if (explicit) pendingExplicitOpen = true;
229
    if (contextRequest?.key === id) {
230
      if (entry.revision > contextRequest.revision) queuedContext = entry;
231
      return;
232
    }
233
    if (contextKey !== id) {
234
      if (!selected) savedListTop = list?.scrollTop ?? savedListTop;
235
      saved = null;
236
      selectedRevision = -1;
237
    }
238
    contextKey = id;
239
    selectedRevision = entry.revision;
240
    selected = entry;
241
    loadingContext = true;
242
    error = '';
243
    const account = self;
244
    const request = ++contextGeneration;
245
    contextRequest = { key: id, revision: entry.revision };
246
    try {
247
      const result = await mentionContext(entry.key.room_id, entry.key.event_id);
248
      if (!currentAccount(account) || request !== contextGeneration || contextKey !== id || mentionKey(result.key) !== id) return;
249
      saved = result;
250
      if (pendingExplicitOpen) {
251
        pendingExplicitOpen = false;
252
        const current = entries.find((item) => keyOf(item) === id);
253
        if (current && !current.read) {
254
          await setMentionsRead([current.key], true);
255
          if (currentAccount(account) && request === contextGeneration) scheduleRefresh(get(mentionsSnapshot)?.revision ?? page?.revision ?? 0);
256
        }
257
      }
258
    } catch (cause) {
259
      if (currentAccount(account) && request === contextGeneration) {
260
        pendingExplicitOpen = false;
261
        if (cause instanceof ApiError && cause.status === 404) {
262
          backToList();
263
          error = 'This mention is no longer available';
264
          scheduleRefresh(page?.revision ?? 0);
265
        } else {
266
          error = cause instanceof Error ? cause.message : 'Could not load saved context';
267
        }
268
      }
269
    } finally {
270
      if (currentAccount(account) && request === contextGeneration) {
271
        contextRequest = null;
272
        loadingContext = false;
273
        const queued = queuedContext;
274
        queuedContext = null;
275
        if (queued && keyOf(queued) === id && queued.revision !== selectedRevision) void loadContext(queued, false);
276
      }
277
    }
278
  }
279
  async function setRead(entry: MentionEntry, read: boolean): Promise<void> {
280
    if (actionBusy) return;
281
    actionBusy = true;
282
    const account = self;
283
    try {
284
      await setMentionsRead([entry.key], read);
285
      if (!currentAccount(account)) return;
286
      const mirror = get(mentionsSnapshot);
287
      if (mirror?.boundary.account === account) scheduleRefresh(mirror.revision);
288
      else scheduleRefresh((page?.revision ?? 0) + 1);
289
    } catch (cause) {
290
      if (currentAccount(account)) error = cause instanceof Error ? cause.message : 'Could not update mention';
291
    } finally {
292
      if (currentAccount(account)) actionBusy = false;
293
    }
294
  }
295
  async function markAllRead(): Promise<void> {
296
    if (!page || actionBusy) return;
297
    actionBusy = true;
298
    const account = self;
299
    const boundary = page.boundary;
300
    try {
301
      await markMentionsReadThrough(boundary);
302
      if (!currentAccount(account)) return;
303
      const mirror = get(mentionsSnapshot);
304
      scheduleRefresh(mirror?.boundary.account === account ? mirror.revision : page?.revision ?? 0);
305
    } catch (cause) {
306
      if (currentAccount(account)) error = cause instanceof Error ? cause.message : 'Could not mark mentions read';
307
    } finally {
308
      if (currentAccount(account)) actionBusy = false;
309
    }
310
  }
311
  async function recover(): Promise<void> {
312
    if (actionBusy) return;
313
    actionBusy = true;
314
    const account = self;
315
    try {
316
      await recoverMentions();
317
      if (!currentAccount(account)) return;
318
      scheduleRefresh(get(mentionsSnapshot)?.revision ?? page?.revision ?? 0);
319
    } catch (cause) {
320
      if (currentAccount(account)) error = cause instanceof Error ? cause.message : 'Could not recover mention history';
321
    } finally {
322
      if (currentAccount(account)) actionBusy = false;
323
    }
324
  }
325
  async function retryCapture(): Promise<void> {
326
    if (!selected || !selectedRoom || actionBusy || loadingContext) return;
327
    actionBusy = true;
328
    const request = contextGeneration;
329
    const account = self;
330
    try {
331
      const result = await retryMentionContext(selected.key.room_id, selected.key.event_id);
332
      if (!currentAccount(account) || request !== contextGeneration || mentionKey(result.key) !== contextKey) return;
333
      saved = result;
334
      scheduleRefresh(get(mentionsSnapshot)?.revision ?? page?.revision ?? 0);
335
    } catch (cause) {
336
      if (currentAccount(account) && request === contextGeneration) error = cause instanceof Error ? cause.message : 'Could not retry context capture';
337
    } finally {
338
      if (currentAccount(account)) actionBusy = false;
339
    }
340
  }
341
  async function retryEntry(entry: MentionEntry): Promise<void> {
342
    if (selected?.revision !== entry.revision || keyOf(selected) !== keyOf(entry)) await loadContext(entry, false);
343
    else await tick();
344
    await retryCapture();
345
  }
346
  function backToList(): void {
347
    contextGeneration++;
348
    contextRequest = null;
349
    queuedContext = null;
350
    loadingContext = false;
351
    pendingExplicitOpen = false;
352
    selected = null;
353
    saved = null;
354
    contextKey = '';
355
  }
356
357
  const unsubscribe = mentionsSnapshot.subscribe((snapshot) => {
358
    if (!snapshot || snapshot.boundary.account !== self) return;
359
    if (snapshot.revision < (page?.revision ?? -1)) return;
360
    const updated = new Map(snapshot.entries.map((entry) => [keyOf(entry), entry]));
361
    if (entries.length) {
362
      captureViewport();
363
      entries = entries.map((entry) => {
364
        const update = updated.get(keyOf(entry));
365
        return update && update.revision >= entry.revision ? update : entry;
366
      });
367
      if (page) page = { ...page, entries };
368
      void restoreViewport();
369
    }
370
    if (selected) {
371
      const update = snapshot.entries.find((entry) => keyOf(entry) === keyOf(selected!));
372
      if (update && update.revision > selected.revision) selected = update;
373
    }
374
    if (busy) {
375
      pendingRevision = Math.max(pendingRevision, snapshot.revision);
376
      return;
377
    }
378
    if (!page || snapshot.revision > page.revision) scheduleRefresh(snapshot.revision);
379
  });
380
  $: if (self && self !== renderedAccount) {
381
    generation++;
382
    contextGeneration++;
383
    page = null;
384
    entries = [];
385
    error = '';
386
    selected = null;
387
    saved = null;
388
    depth = 1;
389
    busy = false;
390
    actionBusy = false;
391
    loadingContext = false;
392
    contextRequest = null;
393
    queuedContext = null;
394
    pendingExplicitOpen = false;
395
    pendingRevision = 0;
396
    savedListTop = 0;
397
    if (refreshTimer) clearTimeout(refreshTimer);
398
    renderedAccount = self;
399
    if (mounted) void fetchRange(1, generation, self);
400
  }
401
  $: if (selected && selected.revision !== selectedRevision) void loadContext(selected, false);
402
  onMount(() => {
403
    mounted = true;
404
    if (!page) void fetchRange(1, ++generation, self);
405
  });
406
  onDestroy(() => {
407
    alive = false;
408
    generation++;
409
    contextGeneration++;
410
    unsubscribe();
411
    observer?.disconnect();
412
    viewport?.releaseScroll();
413
    if (refreshTimer) clearTimeout(refreshTimer);
414
  });
415
</script>
416
417
<section class="mentions-inbox">
418
  {#if selected}
419
    {#if error}<div class="error" role="alert"><EmojiText text={error} /><button disabled={loadingContext} onclick={() => void loadContext(selected!)}>Retry</button></div>{/if}
420
    {#if saved?.context}
421
      <ContextTimeline context={saved.context} roomId={selected.key.room_id} eventId={selected.key.event_id} roomName={roomName(selected)} {self} canPaginate={contextCanPaginate} onClose={backToList} {onLive} />
422
      {#if selected.capture.kind === 'Partial' || selected.capture.kind === 'Failed' || selected.capture.kind === 'Unavailable' || saved.state.kind === 'Partial' || saved.state.kind === 'Failed'}<button class="capture-retry" disabled={!selectedRoom || actionBusy} onclick={retryCapture}>Retry context capture</button>{/if}
423
      {#if !contextCanPaginate}<p class="status">This room is no longer joined. Saved context remains available.</p>{/if}
424
      {#if saved.state.kind === 'Partial'}<p class="status">This saved context is partial.</p>{/if}
425
      {#if saved.state.kind === 'Pending'}<p class="status">Saving context…</p>{/if}
426
      {#if saved.state.kind === 'Failed'}<p class="status" role="alert"><EmojiText text={saved.state.message} /></p>{/if}
427
    {:else}
428
      <header><button onclick={backToList}>Back to mentions</button><strong><EmojiText text={roomName(selected)} /></strong></header>
429
      {#if loadingContext}<p class="status">Loading saved context…</p>
430
      {:else if saved?.state.kind === 'Failed'}<p class="status" role="alert"><EmojiText text={saved.state.message} /></p>
431
      {:else if saved?.state.kind === 'Unavailable'}<p class="status">Saved context is unavailable.</p>
432
      {:else if saved?.state.kind === 'Partial'}<p class="status">Saved context is partial and currently unavailable.</p>
433
      {:else}<p class="status">Saved context is not available yet.</p>{/if}
434
      {@const preview = itemEvent(selected.event)}
435
      {#if preview}<MessageRow itemId={preview.item_id} event={preview} showSender={true} reacting={false} onReact={() => {}} readOnly={true} showSent={false} {self} senderAvatar={preview.sender.avatar} />{/if}
436
      {#if selected.capture.kind === 'Failed' || selected.capture.kind === 'Partial' || selected.capture.kind === 'Unavailable'}
437
        <button disabled={!selectedRoom || actionBusy} onclick={retryCapture}>Retry context capture</button>
438
      {/if}
439
    {/if}
440
  {:else}
441
    <header><h2>Mentions {#if page}<span>({unreadCount} unread)</span>{/if}</h2><button disabled={!page || unreadCount === 0 || actionBusy} onclick={markAllRead}>Mark all read</button></header>
442
    <details class="coverage">
443
      <summary>{page?.coverage.recovering ? 'Recovering mention history…' : 'History coverage'}{#if page?.coverage.gaps.length} · Incomplete{/if}</summary>
444
      <div class="coverage-body">
445
        {#if page}
446
          {#if page.coverage.recovering}<p>History recovery is in progress.</p>{/if}
447
          {#if page.coverage.truncated}<p>Results are truncated.</p>{/if}
448
          {#if page.coverage.historical_supported === false}<p>Historical mention search is unsupported.</p>{/if}
449
          {#if page.coverage.unresolved_candidates}<p>{page.coverage.unresolved_candidates} possible mentions could not be resolved.</p>{/if}
450
          {#if page.coverage.gaps.length}<p>{page.coverage.gaps.length} history gaps remain.</p>{/if}
451
          {#if page.coverage.earliest_timestamp_ms}<p>Oldest observed event from {new Date(page.coverage.earliest_timestamp_ms).toLocaleString()}.</p>{/if}
452
          {#each page.coverage.gaps as gap}<p><EmojiText text={gap.room_id ? joinedRooms.get(gap.room_id)?.name ?? gap.room_id : 'Unknown room'} /> · {gapLabel(gap.reason)}</p>{/each}
453
        {:else}<p>Coverage is loading.</p>{/if}
454
        <button disabled={actionBusy || page?.coverage.recovering} onclick={recover}>Recover history</button>
455
      </div>
456
    </details>
457
    {#if error}<div class="error" role="alert"><EmojiText text={error} /><button disabled={busy} onclick={() => fetchRange(depth, ++generation, self)}>Retry</button></div>{/if}
458
    <div class="entries" use:bindViewport onscroll={() => { if (!viewport?.consumeScroll()) viewport?.capture(); }}>
459
      <div class="entry-content">
460
      {#each entries as entry (keyOf(entry))}
461
        {@const event = itemEvent(entry.event)}
462
        <article class:unread={!entry.read}>
463
          <div class="entry-head"><button class="room" onclick={() => openContext(entry)}><EmojiText text={roomName(entry)} /></button><time>{new Date(entry.timestamp_ms).toLocaleString()}</time><span>{entry.read ? 'Read' : 'Unread'}</span></div>
464
          <div class="labels">{#each entry.reasons as reason}<span>{reasonLabel(reason)}</span>{/each}<span>{captureLabel(entry.capture)}</span>{#if entry.notification_suppressed}<span>Notifications suppressed</span>{/if}</div>
465
          {#if event}<MessageRow itemId={keyOf(entry)} {event} showSender={true} reacting={false} onReact={() => {}} readOnly={true} showSent={false} {self} senderAvatar={event.sender.avatar} />{:else}<p>Mention message unavailable</p>{/if}
466
          <div class="entry-actions"><button onclick={() => openContext(entry)}>Open saved context</button><button disabled={actionBusy} onclick={() => void setRead(entry, !entry.read)}>{entry.read ? 'Mark unread' : 'Mark read'}</button>{#if entry.capture.kind === 'Failed' || entry.capture.kind === 'Partial' || entry.capture.kind === 'Unavailable'}<button disabled={!joinedRooms.has(entry.key.room_id) || actionBusy} onclick={() => void retryEntry(entry)}>Retry capture</button>{/if}</div>
467
        </article>
468
      {/each}
469
      </div>
470
    </div>
471
    <footer><button disabled={!page?.next_cursor || page.end_reached || busy} onclick={loadMore}>{busy ? 'Loading…' : 'Load older mentions'}</button></footer>
472
    {#if entries.length === 0 && !busy && !error}<p class="status">No mentions yet.</p>{/if}
473
  {/if}
474
</section>
475
476
<style>
477
  .mentions-inbox { display:flex; flex-direction:column; min-width:0; min-height:0; height:100%; color:var(--text); }
478
  header { display:flex; align-items:center; gap:.6rem; padding:.6rem; border-bottom:1px solid var(--border); }
479
  header h2 { flex:1; margin:0; font-size:1rem; }
480
  header h2 span { color:var(--muted); font-size:.85em; }
481
  button { color:var(--text); background:var(--panel); border:1px solid var(--border); border-radius:.4rem; padding:.4rem .65rem; cursor:pointer; }
482
  button:disabled { opacity:.55; cursor:default; }
483
  .entries { flex:1; min-height:0; overflow:auto; overflow-anchor:none; padding:.5rem; }
484
  article { border:1px solid var(--border); border-radius:.6rem; padding:.5rem; margin-bottom:.6rem; }
485
  article.unread { border-color:var(--accent); }
486
  .entry-head,.entry-actions,.labels { display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; }
487
  .entry-head .room { font-weight:600; text-align:left; }
488
  .entry-head time,.entry-head span { color:var(--muted); font-size:.8rem; }
489
  .entry-head time { margin-left:auto; }
490
  .labels { margin:.35rem 0; color:var(--muted); font-size:.78rem; }
491
  .labels span { border:1px solid var(--border); border-radius:1rem; padding:.1rem .4rem; }
492
  .entry-actions { justify-content:flex-end; margin-top:.4rem; }
493
  footer { display:flex; justify-content:center; padding:.45rem; }
494
  .coverage { padding:.55rem .75rem; background:var(--panel); border-bottom:1px solid var(--border); }
495
  .coverage-body { max-height:min(15rem, 35vh); overflow:auto; }
496
  .coverage p,.status { margin:.25rem 0; color:var(--muted); }
497
  .error { display:flex; align-items:center; gap:.5rem; padding:.5rem; color:var(--danger); }
498
  .capture-retry { align-self:flex-start; margin:.4rem; }
499
</style>