atagen

malice

public
Star0
Watch0
b9b85b76355e2e8d0874d95d161058b25e5d3111
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
web/src/lib/ContextTimeline.svelteSvelte260 lines10.2 KB
1
<script lang="ts">
2
  import { onDestroy, onMount, tick } from 'svelte';
3
  import { closeEventContext, eventContext, eventContextAfter, eventContextBefore } from './api';
4
  import EmojiText from './EmojiText.svelte';
5
  import MessageRow from './MessageRow.svelte';
6
  import { formatDay } from './format';
7
  import { TimelineViewport } from './timeline-viewport';
8
  import type { EventContext, TimelineEvent, TimelineItem } from './types';
9
10
  export let context: EventContext;
11
  export let roomId: string;
12
  export let eventId: string;
13
  export let roomName: string;
14
  export let self: string | null = null;
15
  export let canPaginate = true;
16
  export let onClose: () => void;
17
  export let onLive: (roomId: string, eventId: string, threadRoot: string | null) => void;
18
  export let closeLabel = 'Back to mentions';
19
20
  let rows: TimelineItem[] = [];
21
  $: targetThread = rows.reduce<string | null>((found, item) => found ?? (item.kind === 'event' && item.event.event_id === eventId ? item.event.thread_root : null), null);
22
  let beforeToken: string | null = null;
23
  let afterToken: string | null = null;
24
  let beforeEnded = false;
25
  let afterEnded = false;
26
  let beforeExtended = false;
27
  let afterExtended = false;
28
  let beforeBusy = false;
29
  let afterBusy = false;
30
  let error = '';
31
  let list: HTMLDivElement;
32
  let contentNode: HTMLDivElement;
33
  let backButton: HTMLButtonElement;
34
  let viewport: TimelineViewport | null = null;
35
  let observer: ResizeObserver | null = null;
36
  let generation = 0;
37
  let mounted = false;
38
  let alive = true;
39
  let identity = '';
40
  let centered = false;
41
  let contextId: string | null = null;
42
  let contextFor = '';
43
44
  const stable = (event: TimelineEvent) => event.event_id || event.item_id;
45
  const key = (item: TimelineItem) => item.kind === 'event'
46
    ? `event-${stable(item.event)}`
47
    : item.kind === 'day' ? `day-${item.timestamp_ms}` : item.kind;
48
  function normalize(items: TimelineItem[]): TimelineItem[] {
49
    const seen = new Set<string>();
50
    const result: TimelineItem[] = [];
51
    for (const item of items) {
52
      if (item.kind === 'readMarker' || item.kind === 'start' || item.kind === 'unknown' || item.kind === 'historyGap') continue;
53
      if (item.kind === 'event') {
54
        const eventKey = stable(item.event);
55
        if (seen.has(eventKey)) continue;
56
        seen.add(eventKey);
57
      }
58
      result.push(item);
59
    }
60
    return result;
61
  }
62
  function merge(base: TimelineItem[], incoming: TimelineItem[]): TimelineItem[] {
63
    const events = new Map<string, TimelineItem>();
64
    const days = new Map<number, TimelineItem>();
65
    for (const item of [...base, ...incoming]) {
66
      if (item.kind === 'event') events.set(stable(item.event), item);
67
      else if (item.kind === 'day') days.set(item.timestamp_ms, item);
68
    }
69
    return [...events.values(), ...days.values()].sort((a, b) => timestamp(a) - timestamp(b));
70
  }
71
  function timestamp(item: TimelineItem): number {
72
    if (item.kind === 'event') return item.event.timestamp_ms;
73
    if (item.kind === 'day') return item.timestamp_ms;
74
    return 0;
75
  }
76
  function refresh(next: EventContext): void {
77
    const nextIdentity = `${roomId}\u0000${eventId}`;
78
    if (identity !== nextIdentity) {
79
      generation++;
80
      identity = nextIdentity;
81
      closeSession();
82
      rows = [];
83
      beforeToken = null;
84
      afterToken = null;
85
      beforeEnded = false;
86
      afterEnded = false;
87
      beforeExtended = false;
88
      afterExtended = false;
89
      beforeBusy = false;
90
      afterBusy = false;
91
      error = '';
92
      centered = false;
93
    }
94
    const request = generation;
95
    viewport?.capture();
96
    const initial = normalize([...next.events_before, next.event, ...next.events_after]);
97
    rows = rows.length ? merge(rows, initial) : initial;
98
    if (!beforeExtended) {
99
      beforeToken = next.start_token;
100
      beforeEnded = !beforeToken;
101
    }
102
    if (!afterExtended) {
103
      afterToken = next.end_token;
104
      afterEnded = !afterToken;
105
    }
106
    tick().then(() => {
107
      if (!alive || request !== generation) return;
108
      viewport?.restore();
109
    });
110
  }
111
  $: if (context) {
112
    roomId;
113
    eventId;
114
    refresh(context);
115
  }
116
  $: if (mounted && list) {
117
    context;
118
    const anchorRoom = roomId;
119
    const anchorEvent = eventId;
120
    tick().then(() => {
121
      if (!alive || anchorRoom !== roomId || anchorEvent !== eventId || !context) return;
122
      if (!centered) {
123
        const target = list.querySelector<HTMLElement>('[data-target="true"]');
124
        target?.scrollIntoView({ block: 'center', behavior: 'auto' });
125
        centered = true;
126
        viewport?.capture();
127
      }
128
    });
129
  }
130
131
  function closeSession(): void {
132
    if (contextId) void closeEventContext(contextId).catch(() => undefined);
133
    contextId = null;
134
  }
135
136
  /** The saved capture has no session, so paging opens one on demand and takes over its tokens. */
137
  async function openSession(): Promise<string | null> {
138
    if (contextId && contextFor === identity) return contextId;
139
    const forIdentity = identity;
140
    const opened = await eventContext(roomId, eventId, 20);
141
    if (!alive || forIdentity !== identity) {
142
      void closeEventContext(opened.context_id).catch(() => undefined);
143
      return null;
144
    }
145
    closeSession();
146
    contextId = opened.context_id;
147
    contextFor = forIdentity;
148
    rows = merge(rows, normalize([...opened.context.events_before, opened.context.event, ...opened.context.events_after]));
149
    if (!beforeExtended) beforeToken = opened.context.start_token;
150
    if (!afterExtended) afterToken = opened.context.end_token;
151
    return contextId;
152
  }
153
154
  async function load(direction: 'before' | 'after'): Promise<void> {
155
    const token = direction === 'before' ? beforeToken : afterToken;
156
    if (!token || !canPaginate || (direction === 'before' ? beforeBusy : afterBusy)) return;
157
    const request = generation;
158
    if (direction === 'before') beforeBusy = true;
159
    else afterBusy = true;
160
    error = '';
161
    try {
162
      const session = await openSession();
163
      if (!session || !alive || request !== generation) return;
164
      const from = direction === 'before' ? beforeToken : afterToken;
165
      if (!from) {
166
        if (direction === 'before') beforeEnded = true;
167
        else afterEnded = true;
168
        return;
169
      }
170
      const page = direction === 'before'
171
        ? await eventContextBefore(session, from, 20)
172
        : await eventContextAfter(session, from, 20);
173
      if (!alive || request !== generation) return;
174
      viewport?.capture();
175
      rows = merge(rows, page.events);
176
      if (direction === 'before') {
177
        beforeExtended = true;
178
        beforeToken = page.next_token;
179
        beforeEnded = !page.next_token;
180
      } else {
181
        afterExtended = true;
182
        afterToken = page.next_token;
183
        afterEnded = !page.next_token;
184
      }
185
      await tick();
186
      if (alive && request === generation) viewport?.restore();
187
    } catch (cause) {
188
      if (alive && request === generation) error = cause instanceof Error ? cause.message : 'Could not load messages';
189
    } finally {
190
      if (alive && request === generation) {
191
        if (direction === 'before') beforeBusy = false;
192
        else afterBusy = false;
193
      }
194
    }
195
  }
196
197
  function handleScroll(): void {
198
    if (!viewport?.consumeScroll()) viewport?.capture();
199
  }
200
  onDestroy(() => {
201
    closeSession();
202
    alive = false;
203
    generation++;
204
    observer?.disconnect();
205
    viewport?.releaseScroll();
206
  });
207
  onMount(() => {
208
    mounted = true;
209
    tick().then(() => {
210
      if (!alive || !list) return;
211
      viewport = new TimelineViewport(list, roomId);
212
      observer = new ResizeObserver(() => viewport?.restore());
213
      if (contentNode) observer.observe(contentNode);
214
      backButton?.focus();
215
    });
216
  });
217
</script>
218
219
<section class="context-timeline">
220
  <header>
221
    <button bind:this={backButton} onclick={onClose}><EmojiText text={closeLabel} /></button>
222
    <strong><EmojiText text={roomName} /></strong>
223
    <button disabled={!canPaginate} onclick={() => onLive(roomId, eventId, targetThread)}>Go to current messages</button>
224
  </header>
225
  {#if error}<div class="error" role="alert"><EmojiText text={error} /></div>{/if}
226
  <button class="page-control" disabled={!beforeToken || beforeEnded || beforeBusy || !canPaginate} onclick={() => load('before')}>
227
    {beforeBusy ? 'Loading…' : 'Load earlier messages'}
228
  </button>
229
  <div class="messages" bind:this={list} onscroll={handleScroll}>
230
    <div class="content" bind:this={contentNode}>
231
      {#each rows as item (key(item))}
232
        {#if item.kind === 'day'}
233
          <div class="day"><span><EmojiText text={formatDay(item.timestamp_ms)} /></span></div>
234
        {:else if item.kind === 'event'}
235
          <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}>
236
            <MessageRow itemId={stable(item.event)} event={item.event} showSender={true} reacting={false} onReact={() => {}} readOnly={true} showSent={false} {self} senderAvatar={item.event.sender.avatar} />
237
          </div>
238
        {/if}
239
      {/each}
240
    </div>
241
  </div>
242
  <button class="page-control" disabled={!afterToken || afterEnded || afterBusy || !canPaginate} onclick={() => load('after')}>
243
    {afterBusy ? 'Loading…' : 'Load later messages'}
244
  </button>
245
</section>
246
247
<style>
248
  .context-timeline { display:flex; flex-direction:column; min-width:0; min-height:0; height:100%; }
249
  header { display:flex; align-items:center; gap:.6rem; padding:.5rem; border-bottom:1px solid var(--border); flex-wrap:wrap; }
250
  header strong { flex:1; min-width:0; overflow-wrap:anywhere; }
251
  button { color:var(--text); background:var(--panel); border:1px solid var(--border); border-radius:.4rem; padding:.4rem .65rem; cursor:pointer; }
252
  button:disabled { opacity:.55; cursor:default; }
253
  .messages { flex:1; min-height:0; overflow:auto; overflow-anchor:none; padding:.5rem; }
254
  .content { min-width:0; }
255
  .mentioned { outline:2px solid var(--accent); border-radius:.5rem; }
256
  .day { display:flex; align-items:center; gap:.6rem; color:var(--muted); font-size:.8rem; margin:.75rem 0; }
257
  .day::before,.day::after { content:''; height:1px; background:var(--border); flex:1; }
258
  .page-control { align-self:center; margin:.4rem; }
259
  .error { color:var(--danger); padding:.5rem; overflow-wrap:anywhere; }
260
</style>