| 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, QuickEntry, 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: QuickEntry[] = []; |
| 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: QuickEntry[]) => 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} |