atagen

malice

public
Star0
Watch0
f499972067e75178cb090fa6f5b44c8c4e7d3d79
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
web/src/lib/RoomTimelineRows.svelteSvelte286 lines11.0 KB
1
<script lang="ts">
2
  import EmojiText from './EmojiText.svelte';
3
  import MessageRow from './MessageRow.svelte';
4
  import { formatDay } from './format';
5
  import { expand, motionActive, rowIn, rowOut, setRowMotion } from './motion';
6
  import type { PendingMessage } from './pending';
7
  import { buildGroups, type Group, lastOwnSent, lastSenderIs, pendingToEvent } from './timeline-groups';
8
  import type { ImagePack, LinkPreview, Person, QuickReaction, RecentHistoryState, ReplyReference, TimelineEvent, TimelineItem } from './types';
9
10
  export let items: TimelineItem[];
11
  export let pending: PendingMessage[] = [];
12
  export let joined: boolean;
13
  export let self: string | null;
14
  export let selfName: string | null = null;
15
  export let people: Record<string, Person> = {};
16
  export let senderAvatarFor: (userId: string) => string | null;
17
  export let reacting: boolean;
18
  export let quickReactions: QuickReaction[] = [];
19
  export let packs: ImagePack[] = [];
20
  export let linkPreviews: Record<string, LinkPreview | null> = {};
21
  export let loadPreview: ((url: string) => void) | null = null;
22
  export let editingEventId: string | null = null;
23
  export let recentState: RecentHistoryState | undefined = undefined;
24
  export let recentRetrying = false;
25
  export let paginating = false;
26
  export let fillingGap: string | null = null;
27
  export let following = true;
28
  export let onReact: (eventId: string, key: string, shortcode?: string) => void;
29
  export let onQuickChange: (list: QuickReaction[]) => void;
30
  export let onUser: (userId: string, x: number, y: number, menu: boolean) => void;
31
  export let onCloneSticker: (eventId: string, body: string, x: number, y: number) => void;
32
  export let onMore: (event: TimelineEvent, x: number, y: number) => void;
33
  export let onOpenThread: (rootEventId: string) => void;
34
  export let onReply: (target: ReplyReference) => void;
35
  export let onJump: (eventId: string) => void;
36
  export let onEdit: (eventId: string, body: string) => void;
37
  export let onPaginate: () => void;
38
  export let onGrew: () => void;
39
  export let onFillGap: (gapId: string) => void;
40
  export let onRetryRecent: () => void;
41
  export let onRetryPending: (id: string) => void;
42
  export let onDiscardPending: (id: string) => void;
43
44
  const INITIAL_GROUPS = 150;
45
  const READ_CAP = 400;
46
  const READ_CONTEXT = 20;
47
  const STEP = 30;
48
  const MAX_GROUPS = 300;
49
  const JUMP_HALF = 60;
50
  const LIVE_MAX = 8;
51
52
  let ready = false;
53
  let start = 0;
54
  let end = 0;
55
  let tail = true;
56
  let startKey: string | null = null;
57
  let endKey: string | null = null;
58
  let quiet = false;
59
  let mounted = false;
60
  let shownKeys: string[] = [];
61
  let shownPending = new Map<string, PendingMessage['status']>();
62
  let firstKey: string | undefined;
63
64
  function eventKey(list: Group[], from: number, step: 1 | -1): string | null {
65
    for (let i = from; i >= 0 && i < list.length; i += step) if (list[i].item.kind === 'event') return list[i].key;
66
    return null;
67
  }
68
69
  function settle(list: Group[]): void {
70
    startKey = eventKey(list, start, 1);
71
    endKey = eventKey(list, end - 1, -1);
72
    tail = end >= list.length;
73
  }
74
75
  function reconcile(list: Group[], follow: boolean): void {
76
    if (!ready) {
77
      if (list.length === 0) return;
78
      start = Math.max(0, list.length - INITIAL_GROUPS);
79
      const marker = list.findIndex((g) => g.item.kind === 'readMarker');
80
      if (marker >= 0 && marker < start) start = Math.max(0, marker - READ_CONTEXT, list.length - READ_CAP);
81
      end = list.length;
82
      ready = true;
83
      settle(list);
84
      return;
85
    }
86
    const oldest = start === 0;
87
    let s = startKey === null ? start : list.findIndex((g) => g.key === startKey);
88
    if (s < 0) s = start;
89
    if (oldest) s = 0;
90
    let e = list.length;
91
    if (!tail && !follow) {
92
      const at = endKey === null ? -1 : list.findIndex((g) => g.key === endKey);
93
      e = at >= 0 ? at + 1 : Math.min(end, list.length);
94
    }
95
    s = Math.min(s, e);
96
    if (e - s > MAX_GROUPS) {
97
      if (follow && e === list.length) s = e - MAX_GROUPS;
98
      else if (oldest) e = s + MAX_GROUPS;
99
      else s = e - MAX_GROUPS;
100
    }
101
    start = s;
102
    end = e;
103
    settle(list);
104
  }
105
106
  export function atTail(): boolean {
107
    return tail;
108
  }
109
110
  export function has(eventId: string): boolean {
111
    return grouped.some((g) => g.item.kind === 'event' && g.item.event.event_id === eventId);
112
  }
113
114
  export function reveal(eventId: string): boolean {
115
    const at = grouped.findIndex((g) => g.item.kind === 'event' && g.item.event.event_id === eventId);
116
    if (at < 0 || (at >= start && at < end)) return false;
117
    quiet = true;
118
    start = Math.max(0, at - JUMP_HALF);
119
    end = Math.min(grouped.length, at + JUMP_HALF);
120
    settle(grouped);
121
    return true;
122
  }
123
124
  export function resetToTail(): boolean {
125
    if (tail) return false;
126
    quiet = true;
127
    start = Math.max(0, grouped.length - INITIAL_GROUPS);
128
    end = grouped.length;
129
    settle(grouped);
130
    return true;
131
  }
132
133
  export function grow(direction: 'up' | 'down'): boolean {
134
    if (direction === 'up') {
135
      if (start === 0) return false;
136
      quiet = true;
137
      start = Math.max(0, start - STEP);
138
      if (end - start > MAX_GROUPS) end = start + MAX_GROUPS;
139
    } else {
140
      if (end >= grouped.length) return false;
141
      quiet = true;
142
      end = Math.min(grouped.length, end + STEP);
143
      if (end - start > MAX_GROUPS) start = end - MAX_GROUPS;
144
    }
145
    settle(grouped);
146
    return true;
147
  }
148
149
  function loadOlder(): void {
150
    if (grow('up')) onGrew();
151
    else onPaginate();
152
  }
153
154
  function trackMotion(vis: Group[], list: Group[], pend: PendingMessage[]): void {
155
    const keys = vis.map((g) => g.key);
156
    const pendingNow = new Map<string, PendingMessage['status']>(pend.map((p) => [`pending-${p.id}`, p.status]));
157
    const enter = new Set<string>();
158
    const leave = new Set<string>();
159
    const prepended = firstKey !== list[0]?.key;
160
    if (mounted && !quiet && !prepended && shownKeys.length > 0) {
161
      const old = new Set(shownKeys);
162
      const now = new Set(keys);
163
      const listKeys = new Set(list.map((g) => g.key));
164
      const added = keys.filter((k) => !old.has(k));
165
      const gone = shownKeys.filter((k) => !now.has(k) && !listKeys.has(k));
166
      if (added.length <= LIVE_MAX && gone.length <= LIVE_MAX) {
167
        const byKey = new Map(vis.map((g) => [g.key, g.item] as const));
168
        const echoed = shownPending.size > 0 && !motionActive();
169
        const lastOld = keys.lastIndexOf(shownKeys[shownKeys.length - 1]);
170
        for (const k of added) {
171
          const item = byKey.get(k);
172
          const appended = lastOld >= 0 && keys.indexOf(k) > lastOld;
173
          if ((appended || k === 'readMarker') && !(echoed && item?.kind === 'event' && item.event.own)) enter.add(k);
174
        }
175
        for (const k of gone) leave.add(k);
176
      }
177
      for (const id of pendingNow.keys()) if (!shownPending.has(id)) enter.add(id);
178
      for (const [id, status] of shownPending) if (!pendingNow.has(id) && status === 'failed') leave.add(id);
179
    }
180
    mounted = true;
181
    quiet = false;
182
    shownKeys = keys;
183
    shownPending = pendingNow;
184
    firstKey = list[0]?.key;
185
    setRowMotion(enter, leave);
186
  }
187
188
  $: hasStart = items.some((item) => item.kind === 'start');
189
  $: grouped = buildGroups(items);
190
  $: reconcile(grouped, following);
191
  $: visible = grouped.slice(start, end);
192
  $: trackMotion(visible, grouped, tail ? pending : []);
193
  $: lastSentId = lastOwnSent(items);
194
  $: pendingFollowsSelf = lastSenderIs(items, self);
195
</script>
196
197
<div class="timeline-top">
198
  {#if recentState === 'Loading'}
199
    <p class="side-note" transition:expand>Loading recent history…</p>
200
  {:else if recentState && typeof recentState === 'object'}
201
    <div class="history-gap" transition:expand>
202
      <span>Recent history could not be loaded</span>
203
      <button type="button" class="ghost-btn" disabled={recentRetrying} on:click={onRetryRecent}>
204
        {recentRetrying ? 'Loading…' : 'Retry'}
205
      </button>
206
    </div>
207
  {/if}
208
  {#if hasStart && start === 0}
209
    <p class="start-note" transition:expand>Beginning of history</p>
210
  {:else}
211
    <button type="button" class="ghost-btn" transition:expand disabled={paginating || recentState === 'Loading'} on:click={loadOlder}>
212
      {paginating ? 'Loading…' : 'Load older messages'}
213
    </button>
214
  {/if}
215
</div>
216
{#if grouped.length === 0}
217
  <p class="side-note timeline-empty" transition:expand>No messages yet</p>
218
{:else}
219
  {#each visible as g (g.key)}
220
    {#if g.item.kind === 'day'}
221
      <div class="day-row" data-row={g.key} in:rowIn|global out:rowOut|global><span>{formatDay(g.item.timestamp_ms)}</span></div>
222
    {:else if g.item.kind === 'readMarker'}
223
      <div class="new-row" data-row={g.key} in:rowIn|global out:rowOut|global><span>New</span></div>
224
    {:else if g.item.kind === 'start'}
225
      <p class="start-note" data-row={g.key} in:rowIn|global out:rowOut|global>Beginning of history</p>
226
    {:else if g.item.kind === 'historyGap'}
227
      {@const gap = g.item.gap}
228
      <div class="history-gap" data-row={g.key} in:rowIn|global out:rowOut|global>
229
        <span>{gap.failure ?? 'Some history is not loaded'}</span>
230
        <button type="button" class="ghost-btn" disabled={!!fillingGap || gap.lifecycle === 'Unavailable'} on:click={() => onFillGap(gap.id)}>
231
          {fillingGap === gap.id ? 'Loading…' : gap.lifecycle === 'Unavailable' ? 'History unavailable' : 'Fetch older history'}
232
        </button>
233
      </div>
234
    {:else if g.item.kind === 'unknown'}
235
      <p class="unknown-note" data-row={g.key} in:rowIn|global out:rowOut|global>Unsupported event ({g.item.label})</p>
236
    {:else}
237
      <MessageRow
238
        itemId={g.key}
239
        event={g.item.event}
240
        showSender={g.showSender}
241
        senderAvatar={senderAvatarFor(g.item.event.sender.user_id) ?? g.item.event.sender.avatar}
242
        {reacting}
243
        {onReact}
244
        {quickReactions}
245
        {packs}
246
        {linkPreviews}
247
        {loadPreview}
248
        {onQuickChange}
249
        {people}
250
        {self}
251
        {onUser}
252
        {onCloneSticker}
253
        {onMore}
254
        onOpenThread={joined || g.item.event.thread_summary ? onOpenThread : null}
255
        onReply={joined ? onReply : null}
256
        {onJump}
257
        onEdit={joined ? onEdit : null}
258
        editing={editingEventId !== null && editingEventId === g.item.event.event_id}
259
        showSent={g.item.event.item_id === lastSentId && pending.length === 0}
260
      />
261
    {/if}
262
  {/each}
263
{/if}
264
{#each tail ? pending : [] as p, i (p.id)}
265
  <MessageRow
266
    itemId={`pending-${p.id}`}
267
    event={pendingToEvent(p, self, selfName)}
268
    {onJump}
269
    showSender={i === 0 && !pendingFollowsSelf}
270
    senderAvatar={senderAvatarFor(self ?? '')}
271
    {reacting}
272
    {onReact}
273
    {quickReactions}
274
    {packs}
275
    {linkPreviews}
276
    {loadPreview}
277
    {onQuickChange}
278
  />
279
  {#if p.status === 'failed'}
280
    <div class="pending-actions" transition:expand>
281
      <span class="pending-error" role="alert"><EmojiText text={p.error ?? ''} /></span>
282
      <button type="button" class="ghost-btn" on:click={() => onRetryPending(p.id)}>Retry</button>
283
      <button type="button" class="ghost-btn danger" on:click={() => onDiscardPending(p.id)}>Discard</button>
284
    </div>
285
  {/if}
286
{/each}