atagen

malice

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