atagen

malice

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