atagen

malice

public
Star0
Watch0
6381c09db9bfd2f6f31341eb87efa1dfcf65d2e0
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';
668ab5c30531atagen2026-10-25 03:13 UTC3
  import { 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;
668ab5c30531atagen2026-10-25 03:13 UTC17
  export let onLive: (roomId: string) => 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[] = [];
668ab5c30531atagen2026-10-25 03:13 UTC21
  let beforeToken: string | null = null;
668ab5c30531atagen2026-10-25 03:13 UTC22
  let afterToken: string | null = null;
668ab5c30531atagen2026-10-25 03:13 UTC23
  let beforeEnded = false;
668ab5c30531atagen2026-10-25 03:13 UTC24
  let afterEnded = false;
668ab5c30531atagen2026-10-25 03:13 UTC25
  let beforeExtended = false;
668ab5c30531atagen2026-10-25 03:13 UTC26
  let afterExtended = false;
668ab5c30531atagen2026-10-25 03:13 UTC27
  let beforeBusy = false;
668ab5c30531atagen2026-10-25 03:13 UTC28
  let afterBusy = false;
668ab5c30531atagen2026-10-25 03:13 UTC29
  let error = '';
668ab5c30531atagen2026-10-25 03:13 UTC30
  let list: HTMLDivElement;
668ab5c30531atagen2026-10-25 03:13 UTC31
  let contentNode: HTMLDivElement;
668ab5c30531atagen2026-10-25 03:13 UTC32
  let backButton: HTMLButtonElement;
668ab5c30531atagen2026-10-25 03:13 UTC33
  let viewport: TimelineViewport | null = null;
668ab5c30531atagen2026-10-25 03:13 UTC34
  let observer: ResizeObserver | null = null;
668ab5c30531atagen2026-10-25 03:13 UTC35
  let generation = 0;
668ab5c30531atagen2026-10-25 03:13 UTC36
  let mounted = false;
668ab5c30531atagen2026-10-25 03:13 UTC37
  let alive = true;
668ab5c30531atagen2026-10-25 03:13 UTC38
  let identity = '';
668ab5c30531atagen2026-10-25 03:13 UTC39
  let centered = false;
668ab5c30531atagen2026-10-25 03:13 UTC40
668ab5c30531atagen2026-10-25 03:13 UTC41
  const stable = (event: TimelineEvent) => event.event_id || event.item_id;
668ab5c30531atagen2026-10-25 03:13 UTC42
  const key = (item: TimelineItem) => item.kind === 'event'
668ab5c30531atagen2026-10-25 03:13 UTC43
    ? `event-${stable(item.event)}`
668ab5c30531atagen2026-10-25 03:13 UTC44
    : item.kind === 'day' ? `day-${item.timestamp_ms}` : item.kind;
668ab5c30531atagen2026-10-25 03:13 UTC45
  function normalize(items: TimelineItem[]): TimelineItem[] {
668ab5c30531atagen2026-10-25 03:13 UTC46
    const seen = new Set<string>();
668ab5c30531atagen2026-10-25 03:13 UTC47
    const result: TimelineItem[] = [];
668ab5c30531atagen2026-10-25 03:13 UTC48
    for (const item of items) {
43c2cc753052atagen2026-10-25 07:50 UTC49
      if (item.kind === 'readMarker' || item.kind === 'start' || item.kind === 'unknown' || item.kind === 'historyGap') continue;
668ab5c30531atagen2026-10-25 03:13 UTC50
      if (item.kind === 'event') {
668ab5c30531atagen2026-10-25 03:13 UTC51
        const eventKey = stable(item.event);
668ab5c30531atagen2026-10-25 03:13 UTC52
        if (seen.has(eventKey)) continue;
668ab5c30531atagen2026-10-25 03:13 UTC53
        seen.add(eventKey);
668ab5c30531atagen2026-10-25 03:13 UTC54
      }
668ab5c30531atagen2026-10-25 03:13 UTC55
      result.push(item);
668ab5c30531atagen2026-10-25 03:13 UTC56
    }
668ab5c30531atagen2026-10-25 03:13 UTC57
    return result;
668ab5c30531atagen2026-10-25 03:13 UTC58
  }
668ab5c30531atagen2026-10-25 03:13 UTC59
  function merge(base: TimelineItem[], incoming: TimelineItem[]): TimelineItem[] {
668ab5c30531atagen2026-10-25 03:13 UTC60
    const events = new Map<string, TimelineItem>();
668ab5c30531atagen2026-10-25 03:13 UTC61
    const days = new Map<number, TimelineItem>();
668ab5c30531atagen2026-10-25 03:13 UTC62
    for (const item of [...base, ...incoming]) {
668ab5c30531atagen2026-10-25 03:13 UTC63
      if (item.kind === 'event') events.set(stable(item.event), item);
668ab5c30531atagen2026-10-25 03:13 UTC64
      else if (item.kind === 'day') days.set(item.timestamp_ms, item);
668ab5c30531atagen2026-10-25 03:13 UTC65
    }
668ab5c30531atagen2026-10-25 03:13 UTC66
    return [...events.values(), ...days.values()].sort((a, b) => timestamp(a) - timestamp(b));
668ab5c30531atagen2026-10-25 03:13 UTC67
  }
668ab5c30531atagen2026-10-25 03:13 UTC68
  function timestamp(item: TimelineItem): number {
668ab5c30531atagen2026-10-25 03:13 UTC69
    if (item.kind === 'event') return item.event.timestamp_ms;
668ab5c30531atagen2026-10-25 03:13 UTC70
    if (item.kind === 'day') return item.timestamp_ms;
668ab5c30531atagen2026-10-25 03:13 UTC71
    return 0;
668ab5c30531atagen2026-10-25 03:13 UTC72
  }
668ab5c30531atagen2026-10-25 03:13 UTC73
  function refresh(next: EventContext): void {
668ab5c30531atagen2026-10-25 03:13 UTC74
    const nextIdentity = `${roomId}\u0000${eventId}`;
668ab5c30531atagen2026-10-25 03:13 UTC75
    if (identity !== nextIdentity) {
668ab5c30531atagen2026-10-25 03:13 UTC76
      generation++;
668ab5c30531atagen2026-10-25 03:13 UTC77
      identity = nextIdentity;
668ab5c30531atagen2026-10-25 03:13 UTC78
      rows = [];
668ab5c30531atagen2026-10-25 03:13 UTC79
      beforeToken = null;
668ab5c30531atagen2026-10-25 03:13 UTC80
      afterToken = null;
668ab5c30531atagen2026-10-25 03:13 UTC81
      beforeEnded = false;
668ab5c30531atagen2026-10-25 03:13 UTC82
      afterEnded = false;
668ab5c30531atagen2026-10-25 03:13 UTC83
      beforeExtended = false;
668ab5c30531atagen2026-10-25 03:13 UTC84
      afterExtended = false;
668ab5c30531atagen2026-10-25 03:13 UTC85
      beforeBusy = false;
668ab5c30531atagen2026-10-25 03:13 UTC86
      afterBusy = false;
668ab5c30531atagen2026-10-25 03:13 UTC87
      error = '';
668ab5c30531atagen2026-10-25 03:13 UTC88
      centered = false;
668ab5c30531atagen2026-10-25 03:13 UTC89
    }
668ab5c30531atagen2026-10-25 03:13 UTC90
    const request = generation;
668ab5c30531atagen2026-10-25 03:13 UTC91
    viewport?.capture();
668ab5c30531atagen2026-10-25 03:13 UTC92
    const initial = normalize([...next.events_before, ...(next.event ? [next.event] : []), ...next.events_after]);
668ab5c30531atagen2026-10-25 03:13 UTC93
    rows = rows.length ? merge(rows, initial) : initial;
668ab5c30531atagen2026-10-25 03:13 UTC94
    if (!beforeExtended) {
668ab5c30531atagen2026-10-25 03:13 UTC95
      beforeToken = next.start_token;
668ab5c30531atagen2026-10-25 03:13 UTC96
      beforeEnded = !beforeToken;
668ab5c30531atagen2026-10-25 03:13 UTC97
    }
668ab5c30531atagen2026-10-25 03:13 UTC98
    if (!afterExtended) {
668ab5c30531atagen2026-10-25 03:13 UTC99
      afterToken = next.end_token;
668ab5c30531atagen2026-10-25 03:13 UTC100
      afterEnded = !afterToken;
668ab5c30531atagen2026-10-25 03:13 UTC101
    }
668ab5c30531atagen2026-10-25 03:13 UTC102
    tick().then(() => {
668ab5c30531atagen2026-10-25 03:13 UTC103
      if (!alive || request !== generation) return;
668ab5c30531atagen2026-10-25 03:13 UTC104
      viewport?.restore();
668ab5c30531atagen2026-10-25 03:13 UTC105
    });
668ab5c30531atagen2026-10-25 03:13 UTC106
  }
668ab5c30531atagen2026-10-25 03:13 UTC107
  $: if (context) {
668ab5c30531atagen2026-10-25 03:13 UTC108
    roomId;
668ab5c30531atagen2026-10-25 03:13 UTC109
    eventId;
668ab5c30531atagen2026-10-25 03:13 UTC110
    refresh(context);
668ab5c30531atagen2026-10-25 03:13 UTC111
  }
668ab5c30531atagen2026-10-25 03:13 UTC112
  $: if (mounted && list) {
668ab5c30531atagen2026-10-25 03:13 UTC113
    context;
668ab5c30531atagen2026-10-25 03:13 UTC114
    const anchorRoom = roomId;
668ab5c30531atagen2026-10-25 03:13 UTC115
    const anchorEvent = eventId;
668ab5c30531atagen2026-10-25 03:13 UTC116
    tick().then(() => {
668ab5c30531atagen2026-10-25 03:13 UTC117
      if (!alive || anchorRoom !== roomId || anchorEvent !== eventId || !context) return;
668ab5c30531atagen2026-10-25 03:13 UTC118
      if (!centered) {
668ab5c30531atagen2026-10-25 03:13 UTC119
        const target = list.querySelector<HTMLElement>('[data-target="true"]');
668ab5c30531atagen2026-10-25 03:13 UTC120
        target?.scrollIntoView({ block: 'center', behavior: 'auto' });
668ab5c30531atagen2026-10-25 03:13 UTC121
        centered = true;
668ab5c30531atagen2026-10-25 03:13 UTC122
        viewport?.capture();
668ab5c30531atagen2026-10-25 03:13 UTC123
      }
668ab5c30531atagen2026-10-25 03:13 UTC124
    });
668ab5c30531atagen2026-10-25 03:13 UTC125
  }
668ab5c30531atagen2026-10-25 03:13 UTC126
668ab5c30531atagen2026-10-25 03:13 UTC127
  async function load(direction: 'before' | 'after'): Promise<void> {
668ab5c30531atagen2026-10-25 03:13 UTC128
    const token = direction === 'before' ? beforeToken : afterToken;
668ab5c30531atagen2026-10-25 03:13 UTC129
    if (!token || !canPaginate || (direction === 'before' ? beforeBusy : afterBusy)) return;
668ab5c30531atagen2026-10-25 03:13 UTC130
    const request = generation;
668ab5c30531atagen2026-10-25 03:13 UTC131
    if (direction === 'before') beforeBusy = true;
668ab5c30531atagen2026-10-25 03:13 UTC132
    else afterBusy = true;
668ab5c30531atagen2026-10-25 03:13 UTC133
    error = '';
668ab5c30531atagen2026-10-25 03:13 UTC134
    try {
668ab5c30531atagen2026-10-25 03:13 UTC135
      const requestedRoom = roomId;
668ab5c30531atagen2026-10-25 03:13 UTC136
      const requestedEvent = eventId;
668ab5c30531atagen2026-10-25 03:13 UTC137
      const page = direction === 'before'
668ab5c30531atagen2026-10-25 03:13 UTC138
        ? await eventContextBefore(requestedRoom, requestedEvent, token, 20)
668ab5c30531atagen2026-10-25 03:13 UTC139
        : await eventContextAfter(requestedRoom, requestedEvent, token, 20);
668ab5c30531atagen2026-10-25 03:13 UTC140
      if (!alive || request !== generation || requestedRoom !== roomId || requestedEvent !== eventId) return;
668ab5c30531atagen2026-10-25 03:13 UTC141
      viewport?.capture();
668ab5c30531atagen2026-10-25 03:13 UTC142
      rows = merge(rows, page.events);
668ab5c30531atagen2026-10-25 03:13 UTC143
      if (direction === 'before') {
668ab5c30531atagen2026-10-25 03:13 UTC144
        beforeExtended = true;
668ab5c30531atagen2026-10-25 03:13 UTC145
        beforeToken = page.next_token;
668ab5c30531atagen2026-10-25 03:13 UTC146
        beforeEnded = page.end_reached || !page.next_token;
668ab5c30531atagen2026-10-25 03:13 UTC147
      } else {
668ab5c30531atagen2026-10-25 03:13 UTC148
        afterExtended = true;
668ab5c30531atagen2026-10-25 03:13 UTC149
        afterToken = page.next_token;
668ab5c30531atagen2026-10-25 03:13 UTC150
        afterEnded = page.end_reached || !page.next_token;
668ab5c30531atagen2026-10-25 03:13 UTC151
      }
668ab5c30531atagen2026-10-25 03:13 UTC152
      await tick();
668ab5c30531atagen2026-10-25 03:13 UTC153
      if (alive && request === generation) viewport?.restore();
668ab5c30531atagen2026-10-25 03:13 UTC154
    } catch (cause) {
668ab5c30531atagen2026-10-25 03:13 UTC155
      if (alive && request === generation) error = cause instanceof Error ? cause.message : 'Could not load messages';
668ab5c30531atagen2026-10-25 03:13 UTC156
    } finally {
668ab5c30531atagen2026-10-25 03:13 UTC157
      if (alive && request === generation) {
668ab5c30531atagen2026-10-25 03:13 UTC158
        if (direction === 'before') beforeBusy = false;
668ab5c30531atagen2026-10-25 03:13 UTC159
        else afterBusy = false;
668ab5c30531atagen2026-10-25 03:13 UTC160
      }
668ab5c30531atagen2026-10-25 03:13 UTC161
    }
668ab5c30531atagen2026-10-25 03:13 UTC162
  }
668ab5c30531atagen2026-10-25 03:13 UTC163
668ab5c30531atagen2026-10-25 03:13 UTC164
  function handleScroll(): void {
668ab5c30531atagen2026-10-25 03:13 UTC165
    if (!viewport?.consumeScroll()) viewport?.capture();
668ab5c30531atagen2026-10-25 03:13 UTC166
  }
668ab5c30531atagen2026-10-25 03:13 UTC167
  onDestroy(() => {
668ab5c30531atagen2026-10-25 03:13 UTC168
    alive = false;
668ab5c30531atagen2026-10-25 03:13 UTC169
    generation++;
668ab5c30531atagen2026-10-25 03:13 UTC170
    observer?.disconnect();
668ab5c30531atagen2026-10-25 03:13 UTC171
    viewport?.releaseScroll();
668ab5c30531atagen2026-10-25 03:13 UTC172
  });
668ab5c30531atagen2026-10-25 03:13 UTC173
  onMount(() => {
668ab5c30531atagen2026-10-25 03:13 UTC174
    mounted = true;
668ab5c30531atagen2026-10-25 03:13 UTC175
    tick().then(() => {
668ab5c30531atagen2026-10-25 03:13 UTC176
      if (!alive || !list) return;
668ab5c30531atagen2026-10-25 03:13 UTC177
      viewport = new TimelineViewport(list, roomId);
668ab5c30531atagen2026-10-25 03:13 UTC178
      observer = new ResizeObserver(() => viewport?.restore());
668ab5c30531atagen2026-10-25 03:13 UTC179
      if (contentNode) observer.observe(contentNode);
668ab5c30531atagen2026-10-25 03:13 UTC180
      backButton?.focus();
668ab5c30531atagen2026-10-25 03:13 UTC181
    });
668ab5c30531atagen2026-10-25 03:13 UTC182
  });
668ab5c30531atagen2026-10-25 03:13 UTC183
</script>
668ab5c30531atagen2026-10-25 03:13 UTC184
668ab5c30531atagen2026-10-25 03:13 UTC185
<section class="context-timeline">
668ab5c30531atagen2026-10-25 03:13 UTC186
  <header>
668ab5c30531atagen2026-10-25 03:13 UTC187
    <button bind:this={backButton} onclick={onClose}><EmojiText text={closeLabel} /></button>
668ab5c30531atagen2026-10-25 03:13 UTC188
    <strong><EmojiText text={roomName} /></strong>
668ab5c30531atagen2026-10-25 03:13 UTC189
    <button disabled={!canPaginate} onclick={() => onLive(roomId)}>Go to current messages</button>
668ab5c30531atagen2026-10-25 03:13 UTC190
  </header>
668ab5c30531atagen2026-10-25 03:13 UTC191
  {#if error}<div class="error" role="alert"><EmojiText text={error} /></div>{/if}
668ab5c30531atagen2026-10-25 03:13 UTC192
  <button class="page-control" disabled={!beforeToken || beforeEnded || beforeBusy || !canPaginate} onclick={() => load('before')}>
668ab5c30531atagen2026-10-25 03:13 UTC193
    {beforeBusy ? 'Loading…' : 'Load earlier messages'}
668ab5c30531atagen2026-10-25 03:13 UTC194
  </button>
668ab5c30531atagen2026-10-25 03:13 UTC195
  <div class="messages" bind:this={list} onscroll={handleScroll}>
668ab5c30531atagen2026-10-25 03:13 UTC196
    <div class="content" bind:this={contentNode}>
668ab5c30531atagen2026-10-25 03:13 UTC197
      {#each rows as item (key(item))}
668ab5c30531atagen2026-10-25 03:13 UTC198
        {#if item.kind === 'day'}
668ab5c30531atagen2026-10-25 03:13 UTC199
          <div class="day"><span><EmojiText text={formatDay(item.timestamp_ms)} /></span></div>
668ab5c30531atagen2026-10-25 03:13 UTC200
        {:else if item.kind === 'event'}
668ab5c30531atagen2026-10-25 03:13 UTC201
          <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 UTC202
            <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 UTC203
          </div>
668ab5c30531atagen2026-10-25 03:13 UTC204
        {/if}
668ab5c30531atagen2026-10-25 03:13 UTC205
      {/each}
668ab5c30531atagen2026-10-25 03:13 UTC206
    </div>
668ab5c30531atagen2026-10-25 03:13 UTC207
  </div>
668ab5c30531atagen2026-10-25 03:13 UTC208
  <button class="page-control" disabled={!afterToken || afterEnded || afterBusy || !canPaginate} onclick={() => load('after')}>
668ab5c30531atagen2026-10-25 03:13 UTC209
    {afterBusy ? 'Loading…' : 'Load later messages'}
668ab5c30531atagen2026-10-25 03:13 UTC210
  </button>
668ab5c30531atagen2026-10-25 03:13 UTC211
</section>
668ab5c30531atagen2026-10-25 03:13 UTC212
668ab5c30531atagen2026-10-25 03:13 UTC213
<style>
668ab5c30531atagen2026-10-25 03:13 UTC214
  .context-timeline { display:flex; flex-direction:column; min-width:0; min-height:0; height:100%; }
668ab5c30531atagen2026-10-25 03:13 UTC215
  header { display:flex; align-items:center; gap:.6rem; padding:.5rem; border-bottom:1px solid var(--border); flex-wrap:wrap; }
668ab5c30531atagen2026-10-25 03:13 UTC216
  header strong { flex:1; min-width:0; overflow-wrap:anywhere; }
668ab5c30531atagen2026-10-25 03:13 UTC217
  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 UTC218
  button:disabled { opacity:.55; cursor:default; }
668ab5c30531atagen2026-10-25 03:13 UTC219
  .messages { flex:1; min-height:0; overflow:auto; overflow-anchor:none; padding:.5rem; }
668ab5c30531atagen2026-10-25 03:13 UTC220
  .content { min-width:0; }
668ab5c30531atagen2026-10-25 03:13 UTC221
  .mentioned { outline:2px solid var(--accent); border-radius:.5rem; }
668ab5c30531atagen2026-10-25 03:13 UTC222
  .day { display:flex; align-items:center; gap:.6rem; color:var(--muted); font-size:.8rem; margin:.75rem 0; }
668ab5c30531atagen2026-10-25 03:13 UTC223
  .day::before,.day::after { content:''; height:1px; background:var(--border); flex:1; }
668ab5c30531atagen2026-10-25 03:13 UTC224
  .page-control { align-self:center; margin:.4rem; }
668ab5c30531atagen2026-10-25 03:13 UTC225
  .error { color:var(--danger); padding:.5rem; overflow-wrap:anywhere; }
668ab5c30531atagen2026-10-25 03:13 UTC226
</style>