atagen

malice

public
Star0
Watch0
00ca0561eced72aa8fda32228099a5df8c82527d
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git

Blame: web/src/lib/ContextTimeline.svelte

668ab5c30531atagen2026-10-25 03:13 UTC1
<script lang="ts">
668ab5c30531atagen2026-10-25 03:13 UTC2
  import { onDestroy, onMount, tick } from 'svelte';
249e439c27e5atagen2026-10-25 13:26 UTC3
  import { closeEventContext, eventContext, eventContextAfter, eventContextBefore } from './api';
668ab5c30531atagen2026-10-25 03:13 UTC4
  import EmojiText from './EmojiText.svelte';
668ab5c30531atagen2026-10-25 03:13 UTC5
  import MessageRow from './MessageRow.svelte';
668ab5c30531atagen2026-10-25 03:13 UTC6
  import { formatDay } from './format';
668ab5c30531atagen2026-10-25 03:13 UTC7
  import { TimelineViewport } from './timeline-viewport';
668ab5c30531atagen2026-10-25 03:13 UTC8
  import type { EventContext, TimelineEvent, TimelineItem } from './types';
668ab5c30531atagen2026-10-25 03:13 UTC9
668ab5c30531atagen2026-10-25 03:13 UTC10
  export let context: EventContext;
668ab5c30531atagen2026-10-25 03:13 UTC11
  export let roomId: string;
668ab5c30531atagen2026-10-25 03:13 UTC12
  export let eventId: string;
668ab5c30531atagen2026-10-25 03:13 UTC13
  export let roomName: string;
668ab5c30531atagen2026-10-25 03:13 UTC14
  export let self: string | null = null;
668ab5c30531atagen2026-10-25 03:13 UTC15
  export let canPaginate = true;
668ab5c30531atagen2026-10-25 03:13 UTC16
  export let onClose: () => void;
4f91f89d99faatagen2026-10-25 10:47 UTC17
  export let onLive: (roomId: string, eventId: string, threadRoot: string | null) => void;
668ab5c30531atagen2026-10-25 03:13 UTC18
  export let closeLabel = 'Back to mentions';
668ab5c30531atagen2026-10-25 03:13 UTC19
668ab5c30531atagen2026-10-25 03:13 UTC20
  let rows: TimelineItem[] = [];
4f91f89d99faatagen2026-10-25 10:47 UTC21
  $: targetThread = rows.reduce<string | null>((found, item) => found ?? (item.kind === 'event' && item.event.event_id === eventId ? item.event.thread_root : null), null);
668ab5c30531atagen2026-10-25 03:13 UTC22
  let beforeToken: string | null = null;
668ab5c30531atagen2026-10-25 03:13 UTC23
  let afterToken: string | null = null;
668ab5c30531atagen2026-10-25 03:13 UTC24
  let beforeEnded = false;
668ab5c30531atagen2026-10-25 03:13 UTC25
  let afterEnded = false;
668ab5c30531atagen2026-10-25 03:13 UTC26
  let beforeExtended = false;
668ab5c30531atagen2026-10-25 03:13 UTC27
  let afterExtended = false;
668ab5c30531atagen2026-10-25 03:13 UTC28
  let beforeBusy = false;
668ab5c30531atagen2026-10-25 03:13 UTC29
  let afterBusy = false;
668ab5c30531atagen2026-10-25 03:13 UTC30
  let error = '';
668ab5c30531atagen2026-10-25 03:13 UTC31
  let list: HTMLDivElement;
668ab5c30531atagen2026-10-25 03:13 UTC32
  let contentNode: HTMLDivElement;
668ab5c30531atagen2026-10-25 03:13 UTC33
  let backButton: HTMLButtonElement;
668ab5c30531atagen2026-10-25 03:13 UTC34
  let viewport: TimelineViewport | null = null;
668ab5c30531atagen2026-10-25 03:13 UTC35
  let observer: ResizeObserver | null = null;
668ab5c30531atagen2026-10-25 03:13 UTC36
  let generation = 0;
668ab5c30531atagen2026-10-25 03:13 UTC37
  let mounted = false;
668ab5c30531atagen2026-10-25 03:13 UTC38
  let alive = true;
668ab5c30531atagen2026-10-25 03:13 UTC39
  let identity = '';
668ab5c30531atagen2026-10-25 03:13 UTC40
  let centered = false;
249e439c27e5atagen2026-10-25 13:26 UTC41
  let contextId: string | null = null;
249e439c27e5atagen2026-10-25 13:26 UTC42
  let contextFor = '';
668ab5c30531atagen2026-10-25 03:13 UTC43
668ab5c30531atagen2026-10-25 03:13 UTC44
  const stable = (event: TimelineEvent) => event.event_id || event.item_id;
668ab5c30531atagen2026-10-25 03:13 UTC45
  const key = (item: TimelineItem) => item.kind === 'event'
668ab5c30531atagen2026-10-25 03:13 UTC46
    ? `event-${stable(item.event)}`
668ab5c30531atagen2026-10-25 03:13 UTC47
    : item.kind === 'day' ? `day-${item.timestamp_ms}` : item.kind;
668ab5c30531atagen2026-10-25 03:13 UTC48
  function normalize(items: TimelineItem[]): TimelineItem[] {
668ab5c30531atagen2026-10-25 03:13 UTC49
    const seen = new Set<string>();
668ab5c30531atagen2026-10-25 03:13 UTC50
    const result: TimelineItem[] = [];
668ab5c30531atagen2026-10-25 03:13 UTC51
    for (const item of items) {
43c2cc753052atagen2026-10-25 07:50 UTC52
      if (item.kind === 'readMarker' || item.kind === 'start' || item.kind === 'unknown' || item.kind === 'historyGap') continue;
668ab5c30531atagen2026-10-25 03:13 UTC53
      if (item.kind === 'event') {
668ab5c30531atagen2026-10-25 03:13 UTC54
        const eventKey = stable(item.event);
668ab5c30531atagen2026-10-25 03:13 UTC55
        if (seen.has(eventKey)) continue;
668ab5c30531atagen2026-10-25 03:13 UTC56
        seen.add(eventKey);
668ab5c30531atagen2026-10-25 03:13 UTC57
      }
668ab5c30531atagen2026-10-25 03:13 UTC58
      result.push(item);
668ab5c30531atagen2026-10-25 03:13 UTC59
    }
668ab5c30531atagen2026-10-25 03:13 UTC60
    return result;
668ab5c30531atagen2026-10-25 03:13 UTC61
  }
668ab5c30531atagen2026-10-25 03:13 UTC62
  function merge(base: TimelineItem[], incoming: TimelineItem[]): TimelineItem[] {
668ab5c30531atagen2026-10-25 03:13 UTC63
    const events = new Map<string, TimelineItem>();
668ab5c30531atagen2026-10-25 03:13 UTC64
    const days = new Map<number, TimelineItem>();
668ab5c30531atagen2026-10-25 03:13 UTC65
    for (const item of [...base, ...incoming]) {
668ab5c30531atagen2026-10-25 03:13 UTC66
      if (item.kind === 'event') events.set(stable(item.event), item);
668ab5c30531atagen2026-10-25 03:13 UTC67
      else if (item.kind === 'day') days.set(item.timestamp_ms, item);
668ab5c30531atagen2026-10-25 03:13 UTC68
    }
668ab5c30531atagen2026-10-25 03:13 UTC69
    return [...events.values(), ...days.values()].sort((a, b) => timestamp(a) - timestamp(b));
668ab5c30531atagen2026-10-25 03:13 UTC70
  }
668ab5c30531atagen2026-10-25 03:13 UTC71
  function timestamp(item: TimelineItem): number {
668ab5c30531atagen2026-10-25 03:13 UTC72
    if (item.kind === 'event') return item.event.timestamp_ms;
668ab5c30531atagen2026-10-25 03:13 UTC73
    if (item.kind === 'day') return item.timestamp_ms;
668ab5c30531atagen2026-10-25 03:13 UTC74
    return 0;
668ab5c30531atagen2026-10-25 03:13 UTC75
  }
668ab5c30531atagen2026-10-25 03:13 UTC76
  function refresh(next: EventContext): void {
668ab5c30531atagen2026-10-25 03:13 UTC77
    const nextIdentity = `${roomId}\u0000${eventId}`;
668ab5c30531atagen2026-10-25 03:13 UTC78
    if (identity !== nextIdentity) {
668ab5c30531atagen2026-10-25 03:13 UTC79
      generation++;
668ab5c30531atagen2026-10-25 03:13 UTC80
      identity = nextIdentity;
249e439c27e5atagen2026-10-25 13:26 UTC81
      closeSession();
668ab5c30531atagen2026-10-25 03:13 UTC82
      rows = [];
668ab5c30531atagen2026-10-25 03:13 UTC83
      beforeToken = null;
668ab5c30531atagen2026-10-25 03:13 UTC84
      afterToken = null;
668ab5c30531atagen2026-10-25 03:13 UTC85
      beforeEnded = false;
668ab5c30531atagen2026-10-25 03:13 UTC86
      afterEnded = false;
668ab5c30531atagen2026-10-25 03:13 UTC87
      beforeExtended = false;
668ab5c30531atagen2026-10-25 03:13 UTC88
      afterExtended = false;
668ab5c30531atagen2026-10-25 03:13 UTC89
      beforeBusy = false;
668ab5c30531atagen2026-10-25 03:13 UTC90
      afterBusy = false;
668ab5c30531atagen2026-10-25 03:13 UTC91
      error = '';
668ab5c30531atagen2026-10-25 03:13 UTC92
      centered = false;
668ab5c30531atagen2026-10-25 03:13 UTC93
    }
668ab5c30531atagen2026-10-25 03:13 UTC94
    const request = generation;
668ab5c30531atagen2026-10-25 03:13 UTC95
    viewport?.capture();
249e439c27e5atagen2026-10-25 13:26 UTC96
    const initial = normalize([...next.events_before, next.event, ...next.events_after]);
668ab5c30531atagen2026-10-25 03:13 UTC97
    rows = rows.length ? merge(rows, initial) : initial;
668ab5c30531atagen2026-10-25 03:13 UTC98
    if (!beforeExtended) {
668ab5c30531atagen2026-10-25 03:13 UTC99
      beforeToken = next.start_token;
668ab5c30531atagen2026-10-25 03:13 UTC100
      beforeEnded = !beforeToken;
668ab5c30531atagen2026-10-25 03:13 UTC101
    }
668ab5c30531atagen2026-10-25 03:13 UTC102
    if (!afterExtended) {
668ab5c30531atagen2026-10-25 03:13 UTC103
      afterToken = next.end_token;
668ab5c30531atagen2026-10-25 03:13 UTC104
      afterEnded = !afterToken;
668ab5c30531atagen2026-10-25 03:13 UTC105
    }
668ab5c30531atagen2026-10-25 03:13 UTC106
    tick().then(() => {
668ab5c30531atagen2026-10-25 03:13 UTC107
      if (!alive || request !== generation) return;
668ab5c30531atagen2026-10-25 03:13 UTC108
      viewport?.restore();
668ab5c30531atagen2026-10-25 03:13 UTC109
    });
668ab5c30531atagen2026-10-25 03:13 UTC110
  }
668ab5c30531atagen2026-10-25 03:13 UTC111
  $: if (context) {
668ab5c30531atagen2026-10-25 03:13 UTC112
    roomId;
668ab5c30531atagen2026-10-25 03:13 UTC113
    eventId;
668ab5c30531atagen2026-10-25 03:13 UTC114
    refresh(context);
668ab5c30531atagen2026-10-25 03:13 UTC115
  }
668ab5c30531atagen2026-10-25 03:13 UTC116
  $: if (mounted && list) {
668ab5c30531atagen2026-10-25 03:13 UTC117
    context;
668ab5c30531atagen2026-10-25 03:13 UTC118
    const anchorRoom = roomId;
668ab5c30531atagen2026-10-25 03:13 UTC119
    const anchorEvent = eventId;
668ab5c30531atagen2026-10-25 03:13 UTC120
    tick().then(() => {
668ab5c30531atagen2026-10-25 03:13 UTC121
      if (!alive || anchorRoom !== roomId || anchorEvent !== eventId || !context) return;
668ab5c30531atagen2026-10-25 03:13 UTC122
      if (!centered) {
668ab5c30531atagen2026-10-25 03:13 UTC123
        const target = list.querySelector<HTMLElement>('[data-target="true"]');
668ab5c30531atagen2026-10-25 03:13 UTC124
        target?.scrollIntoView({ block: 'center', behavior: 'auto' });
668ab5c30531atagen2026-10-25 03:13 UTC125
        centered = true;
668ab5c30531atagen2026-10-25 03:13 UTC126
        viewport?.capture();
668ab5c30531atagen2026-10-25 03:13 UTC127
      }
668ab5c30531atagen2026-10-25 03:13 UTC128
    });
668ab5c30531atagen2026-10-25 03:13 UTC129
  }
668ab5c30531atagen2026-10-25 03:13 UTC130
249e439c27e5atagen2026-10-25 13:26 UTC131
  function closeSession(): void {
249e439c27e5atagen2026-10-25 13:26 UTC132
    if (contextId) void closeEventContext(contextId).catch(() => undefined);
249e439c27e5atagen2026-10-25 13:26 UTC133
    contextId = null;
249e439c27e5atagen2026-10-25 13:26 UTC134
  }
249e439c27e5atagen2026-10-25 13:26 UTC135
249e439c27e5atagen2026-10-25 13:26 UTC136
  /** The saved capture has no session, so paging opens one on demand and takes over its tokens. */
249e439c27e5atagen2026-10-25 13:26 UTC137
  async function openSession(): Promise<string | null> {
249e439c27e5atagen2026-10-25 13:26 UTC138
    if (contextId && contextFor === identity) return contextId;
249e439c27e5atagen2026-10-25 13:26 UTC139
    const forIdentity = identity;
249e439c27e5atagen2026-10-25 13:26 UTC140
    const opened = await eventContext(roomId, eventId, 20);
249e439c27e5atagen2026-10-25 13:26 UTC141
    if (!alive || forIdentity !== identity) {
249e439c27e5atagen2026-10-25 13:26 UTC142
      void closeEventContext(opened.context_id).catch(() => undefined);
249e439c27e5atagen2026-10-25 13:26 UTC143
      return null;
249e439c27e5atagen2026-10-25 13:26 UTC144
    }
249e439c27e5atagen2026-10-25 13:26 UTC145
    closeSession();
249e439c27e5atagen2026-10-25 13:26 UTC146
    contextId = opened.context_id;
249e439c27e5atagen2026-10-25 13:26 UTC147
    contextFor = forIdentity;
249e439c27e5atagen2026-10-25 13:26 UTC148
    rows = merge(rows, normalize([...opened.context.events_before, opened.context.event, ...opened.context.events_after]));
249e439c27e5atagen2026-10-25 13:26 UTC149
    if (!beforeExtended) beforeToken = opened.context.start_token;
249e439c27e5atagen2026-10-25 13:26 UTC150
    if (!afterExtended) afterToken = opened.context.end_token;
249e439c27e5atagen2026-10-25 13:26 UTC151
    return contextId;
249e439c27e5atagen2026-10-25 13:26 UTC152
  }
249e439c27e5atagen2026-10-25 13:26 UTC153
668ab5c30531atagen2026-10-25 03:13 UTC154
  async function load(direction: 'before' | 'after'): Promise<void> {
668ab5c30531atagen2026-10-25 03:13 UTC155
    const token = direction === 'before' ? beforeToken : afterToken;
668ab5c30531atagen2026-10-25 03:13 UTC156
    if (!token || !canPaginate || (direction === 'before' ? beforeBusy : afterBusy)) return;
668ab5c30531atagen2026-10-25 03:13 UTC157
    const request = generation;
668ab5c30531atagen2026-10-25 03:13 UTC158
    if (direction === 'before') beforeBusy = true;
668ab5c30531atagen2026-10-25 03:13 UTC159
    else afterBusy = true;
668ab5c30531atagen2026-10-25 03:13 UTC160
    error = '';
668ab5c30531atagen2026-10-25 03:13 UTC161
    try {
249e439c27e5atagen2026-10-25 13:26 UTC162
      const session = await openSession();
249e439c27e5atagen2026-10-25 13:26 UTC163
      if (!session || !alive || request !== generation) return;
249e439c27e5atagen2026-10-25 13:26 UTC164
      const from = direction === 'before' ? beforeToken : afterToken;
249e439c27e5atagen2026-10-25 13:26 UTC165
      if (!from) {
249e439c27e5atagen2026-10-25 13:26 UTC166
        if (direction === 'before') beforeEnded = true;
249e439c27e5atagen2026-10-25 13:26 UTC167
        else afterEnded = true;
249e439c27e5atagen2026-10-25 13:26 UTC168
        return;
249e439c27e5atagen2026-10-25 13:26 UTC169
      }
668ab5c30531atagen2026-10-25 03:13 UTC170
      const page = direction === 'before'
249e439c27e5atagen2026-10-25 13:26 UTC171
        ? await eventContextBefore(session, from, 20)
249e439c27e5atagen2026-10-25 13:26 UTC172
        : await eventContextAfter(session, from, 20);
249e439c27e5atagen2026-10-25 13:26 UTC173
      if (!alive || request !== generation) return;
668ab5c30531atagen2026-10-25 03:13 UTC174
      viewport?.capture();
668ab5c30531atagen2026-10-25 03:13 UTC175
      rows = merge(rows, page.events);
668ab5c30531atagen2026-10-25 03:13 UTC176
      if (direction === 'before') {
668ab5c30531atagen2026-10-25 03:13 UTC177
        beforeExtended = true;
668ab5c30531atagen2026-10-25 03:13 UTC178
        beforeToken = page.next_token;
249e439c27e5atagen2026-10-25 13:26 UTC179
        beforeEnded = !page.next_token;
668ab5c30531atagen2026-10-25 03:13 UTC180
      } else {
668ab5c30531atagen2026-10-25 03:13 UTC181
        afterExtended = true;
668ab5c30531atagen2026-10-25 03:13 UTC182
        afterToken = page.next_token;
249e439c27e5atagen2026-10-25 13:26 UTC183
        afterEnded = !page.next_token;
668ab5c30531atagen2026-10-25 03:13 UTC184
      }
668ab5c30531atagen2026-10-25 03:13 UTC185
      await tick();
668ab5c30531atagen2026-10-25 03:13 UTC186
      if (alive && request === generation) viewport?.restore();
668ab5c30531atagen2026-10-25 03:13 UTC187
    } catch (cause) {
668ab5c30531atagen2026-10-25 03:13 UTC188
      if (alive && request === generation) error = cause instanceof Error ? cause.message : 'Could not load messages';
668ab5c30531atagen2026-10-25 03:13 UTC189
    } finally {
668ab5c30531atagen2026-10-25 03:13 UTC190
      if (alive && request === generation) {
668ab5c30531atagen2026-10-25 03:13 UTC191
        if (direction === 'before') beforeBusy = false;
668ab5c30531atagen2026-10-25 03:13 UTC192
        else afterBusy = false;
668ab5c30531atagen2026-10-25 03:13 UTC193
      }
668ab5c30531atagen2026-10-25 03:13 UTC194
    }
668ab5c30531atagen2026-10-25 03:13 UTC195
  }
668ab5c30531atagen2026-10-25 03:13 UTC196
668ab5c30531atagen2026-10-25 03:13 UTC197
  function handleScroll(): void {
668ab5c30531atagen2026-10-25 03:13 UTC198
    if (!viewport?.consumeScroll()) viewport?.capture();
668ab5c30531atagen2026-10-25 03:13 UTC199
  }
668ab5c30531atagen2026-10-25 03:13 UTC200
  onDestroy(() => {
249e439c27e5atagen2026-10-25 13:26 UTC201
    closeSession();
668ab5c30531atagen2026-10-25 03:13 UTC202
    alive = false;
668ab5c30531atagen2026-10-25 03:13 UTC203
    generation++;
668ab5c30531atagen2026-10-25 03:13 UTC204
    observer?.disconnect();
668ab5c30531atagen2026-10-25 03:13 UTC205
    viewport?.releaseScroll();
668ab5c30531atagen2026-10-25 03:13 UTC206
  });
668ab5c30531atagen2026-10-25 03:13 UTC207
  onMount(() => {
668ab5c30531atagen2026-10-25 03:13 UTC208
    mounted = true;
668ab5c30531atagen2026-10-25 03:13 UTC209
    tick().then(() => {
668ab5c30531atagen2026-10-25 03:13 UTC210
      if (!alive || !list) return;
668ab5c30531atagen2026-10-25 03:13 UTC211
      viewport = new TimelineViewport(list, roomId);
668ab5c30531atagen2026-10-25 03:13 UTC212
      observer = new ResizeObserver(() => viewport?.restore());
668ab5c30531atagen2026-10-25 03:13 UTC213
      if (contentNode) observer.observe(contentNode);
668ab5c30531atagen2026-10-25 03:13 UTC214
      backButton?.focus();
668ab5c30531atagen2026-10-25 03:13 UTC215
    });
668ab5c30531atagen2026-10-25 03:13 UTC216
  });
668ab5c30531atagen2026-10-25 03:13 UTC217
</script>
668ab5c30531atagen2026-10-25 03:13 UTC218
668ab5c30531atagen2026-10-25 03:13 UTC219
<section class="context-timeline">
668ab5c30531atagen2026-10-25 03:13 UTC220
  <header>
668ab5c30531atagen2026-10-25 03:13 UTC221
    <button bind:this={backButton} onclick={onClose}><EmojiText text={closeLabel} /></button>
668ab5c30531atagen2026-10-25 03:13 UTC222
    <strong><EmojiText text={roomName} /></strong>
4f91f89d99faatagen2026-10-25 10:47 UTC223
    <button disabled={!canPaginate} onclick={() => onLive(roomId, eventId, targetThread)}>Go to current messages</button>
668ab5c30531atagen2026-10-25 03:13 UTC224
  </header>
668ab5c30531atagen2026-10-25 03:13 UTC225
  {#if error}<div class="error" role="alert"><EmojiText text={error} /></div>{/if}
668ab5c30531atagen2026-10-25 03:13 UTC226
  <button class="page-control" disabled={!beforeToken || beforeEnded || beforeBusy || !canPaginate} onclick={() => load('before')}>
668ab5c30531atagen2026-10-25 03:13 UTC227
    {beforeBusy ? 'Loading…' : 'Load earlier messages'}
668ab5c30531atagen2026-10-25 03:13 UTC228
  </button>
668ab5c30531atagen2026-10-25 03:13 UTC229
  <div class="messages" bind:this={list} onscroll={handleScroll}>
668ab5c30531atagen2026-10-25 03:13 UTC230
    <div class="content" bind:this={contentNode}>
668ab5c30531atagen2026-10-25 03:13 UTC231
      {#each rows as item (key(item))}
668ab5c30531atagen2026-10-25 03:13 UTC232
        {#if item.kind === 'day'}
668ab5c30531atagen2026-10-25 03:13 UTC233
          <div class="day"><span><EmojiText text={formatDay(item.timestamp_ms)} /></span></div>
668ab5c30531atagen2026-10-25 03:13 UTC234
        {:else if item.kind === 'event'}
668ab5c30531atagen2026-10-25 03:13 UTC235
          <div class:mentioned={item.event.event_id === eventId} aria-label={item.event.event_id === eventId ? 'Mentioned message' : undefined} data-target={item.event.event_id === eventId ? 'true' : undefined}>
668ab5c30531atagen2026-10-25 03:13 UTC236
            <MessageRow itemId={stable(item.event)} event={item.event} showSender={true} reacting={false} onReact={() => {}} readOnly={true} showSent={false} {self} senderAvatar={item.event.sender.avatar} />
668ab5c30531atagen2026-10-25 03:13 UTC237
          </div>
668ab5c30531atagen2026-10-25 03:13 UTC238
        {/if}
668ab5c30531atagen2026-10-25 03:13 UTC239
      {/each}
668ab5c30531atagen2026-10-25 03:13 UTC240
    </div>
668ab5c30531atagen2026-10-25 03:13 UTC241
  </div>
668ab5c30531atagen2026-10-25 03:13 UTC242
  <button class="page-control" disabled={!afterToken || afterEnded || afterBusy || !canPaginate} onclick={() => load('after')}>
668ab5c30531atagen2026-10-25 03:13 UTC243
    {afterBusy ? 'Loading…' : 'Load later messages'}
668ab5c30531atagen2026-10-25 03:13 UTC244
  </button>
668ab5c30531atagen2026-10-25 03:13 UTC245
</section>
668ab5c30531atagen2026-10-25 03:13 UTC246
668ab5c30531atagen2026-10-25 03:13 UTC247
<style>
668ab5c30531atagen2026-10-25 03:13 UTC248
  .context-timeline { display:flex; flex-direction:column; min-width:0; min-height:0; height:100%; }
668ab5c30531atagen2026-10-25 03:13 UTC249
  header { display:flex; align-items:center; gap:.6rem; padding:.5rem; border-bottom:1px solid var(--border); flex-wrap:wrap; }
668ab5c30531atagen2026-10-25 03:13 UTC250
  header strong { flex:1; min-width:0; overflow-wrap:anywhere; }
668ab5c30531atagen2026-10-25 03:13 UTC251
  button { color:var(--text); background:var(--panel); border:1px solid var(--border); border-radius:.4rem; padding:.4rem .65rem; cursor:pointer; }
668ab5c30531atagen2026-10-25 03:13 UTC252
  button:disabled { opacity:.55; cursor:default; }
668ab5c30531atagen2026-10-25 03:13 UTC253
  .messages { flex:1; min-height:0; overflow:auto; overflow-anchor:none; padding:.5rem; }
668ab5c30531atagen2026-10-25 03:13 UTC254
  .content { min-width:0; }
668ab5c30531atagen2026-10-25 03:13 UTC255
  .mentioned { outline:2px solid var(--accent); border-radius:.5rem; }
668ab5c30531atagen2026-10-25 03:13 UTC256
  .day { display:flex; align-items:center; gap:.6rem; color:var(--muted); font-size:.8rem; margin:.75rem 0; }
668ab5c30531atagen2026-10-25 03:13 UTC257
  .day::before,.day::after { content:''; height:1px; background:var(--border); flex:1; }
668ab5c30531atagen2026-10-25 03:13 UTC258
  .page-control { align-self:center; margin:.4rem; }
668ab5c30531atagen2026-10-25 03:13 UTC259
  .error { color:var(--danger); padding:.5rem; overflow-wrap:anywhere; }
668ab5c30531atagen2026-10-25 03:13 UTC260
</style>