| 1 | <script lang="ts"> |
| 2 | import EmojiText from './EmojiText.svelte'; |
| 3 | import MessageRow from './MessageRow.svelte'; |
| 4 | import { formatDay } from './format'; |
| 5 | import type { PendingMessage } from './pending'; |
| 6 | import { buildGroups, lastOwnSent, lastSenderIs, pendingToEvent } from './timeline-groups'; |
| 7 | import type { ImagePack, LinkPreview, Person, QuickReaction, RecentHistoryState, ReplyReference, TimelineEvent, TimelineItem } from './types'; |
| 8 | |
| 9 | export let items: TimelineItem[]; |
| 10 | export let pending: PendingMessage[] = []; |
| 11 | export let joined: boolean; |
| 12 | export let self: string | null; |
| 13 | export let selfName: string | null = null; |
| 14 | export let people: Record<string, Person> = {}; |
| 15 | export let senderAvatarFor: (userId: string) => string | null; |
| 16 | export let reacting: boolean; |
| 17 | export let quickReactions: QuickReaction[] = []; |
| 18 | export let packs: ImagePack[] = []; |
| 19 | export let linkPreviews: Record<string, LinkPreview | null> = {}; |
| 20 | export let loadPreview: ((url: string) => void) | null = null; |
| 21 | export let editingEventId: string | null = null; |
| 22 | export let recentState: RecentHistoryState | undefined = undefined; |
| 23 | export let recentRetrying = false; |
| 24 | export let paginating = false; |
| 25 | export let fillingGap: string | null = null; |
| 26 | export let onReact: (eventId: string, key: string, shortcode?: string) => void; |
| 27 | export let onQuickChange: (list: QuickReaction[]) => void; |
| 28 | export let onUser: (userId: string, x: number, y: number, menu: boolean) => void; |
| 29 | export let onCloneSticker: (eventId: string, body: string, x: number, y: number) => void; |
| 30 | export let onMore: (event: TimelineEvent, x: number, y: number) => void; |
| 31 | export let onOpenThread: (rootEventId: string) => void; |
| 32 | export let onReply: (target: ReplyReference) => void; |
| 33 | export let onJump: (eventId: string) => void; |
| 34 | export let onEdit: (eventId: string, body: string) => void; |
| 35 | export let onPaginate: () => void; |
| 36 | export let onFillGap: (gapId: string) => void; |
| 37 | export let onRetryRecent: () => void; |
| 38 | export let onRetryPending: (id: string) => void; |
| 39 | export let onDiscardPending: (id: string) => void; |
| 40 | |
| 41 | $: hasStart = items.some((item) => item.kind === 'start'); |
| 42 | $: grouped = buildGroups(items); |
| 43 | $: lastSentId = lastOwnSent(items); |
| 44 | $: pendingFollowsSelf = lastSenderIs(items, self); |
| 45 | </script> |
| 46 | |
| 47 | <div class="timeline-top"> |
| 48 | {#if recentState === 'Loading'} |
| 49 | <p class="side-note">Loading recent history…</p> |
| 50 | {:else if recentState && typeof recentState === 'object'} |
| 51 | <div class="history-gap"> |
| 52 | <span>Recent history could not be loaded</span> |
| 53 | <button type="button" class="ghost-btn" disabled={recentRetrying} on:click={onRetryRecent}> |
| 54 | {recentRetrying ? 'Loading…' : 'Retry'} |
| 55 | </button> |
| 56 | </div> |
| 57 | {/if} |
| 58 | {#if hasStart} |
| 59 | <p class="start-note">Beginning of history</p> |
| 60 | {:else} |
| 61 | <button type="button" class="ghost-btn" disabled={paginating || recentState === 'Loading'} on:click={onPaginate}> |
| 62 | {paginating ? 'Loading…' : 'Load older messages'} |
| 63 | </button> |
| 64 | {/if} |
| 65 | </div> |
| 66 | {#if grouped.length === 0} |
| 67 | <p class="side-note timeline-empty">No messages yet</p> |
| 68 | {:else} |
| 69 | {#each grouped as g (g.key)} |
| 70 | {#if g.item.kind === 'day'} |
| 71 | <div class="day-row"><span>{formatDay(g.item.timestamp_ms)}</span></div> |
| 72 | {:else if g.item.kind === 'readMarker'} |
| 73 | <div class="new-row"><span>New</span></div> |
| 74 | {:else if g.item.kind === 'start'} |
| 75 | <p class="start-note">Beginning of history</p> |
| 76 | {:else if g.item.kind === 'historyGap'} |
| 77 | {@const gap = g.item.gap} |
| 78 | <div class="history-gap"> |
| 79 | <span>{gap.failure ?? 'Some history is not loaded'}</span> |
| 80 | <button type="button" class="ghost-btn" disabled={!!fillingGap || gap.lifecycle === 'Unavailable'} on:click={() => onFillGap(gap.id)}> |
| 81 | {fillingGap === gap.id ? 'Loading…' : gap.lifecycle === 'Unavailable' ? 'History unavailable' : 'Fetch older history'} |
| 82 | </button> |
| 83 | </div> |
| 84 | {:else if g.item.kind === 'unknown'} |
| 85 | <p class="unknown-note">Unsupported event ({g.item.label})</p> |
| 86 | {:else} |
| 87 | <MessageRow |
| 88 | itemId={g.key} |
| 89 | event={g.item.event} |
| 90 | showSender={g.showSender} |
| 91 | senderAvatar={senderAvatarFor(g.item.event.sender.user_id) ?? g.item.event.sender.avatar} |
| 92 | {reacting} |
| 93 | {onReact} |
| 94 | {quickReactions} |
| 95 | {packs} |
| 96 | {linkPreviews} |
| 97 | {loadPreview} |
| 98 | {onQuickChange} |
| 99 | {people} |
| 100 | {self} |
| 101 | {onUser} |
| 102 | {onCloneSticker} |
| 103 | {onMore} |
| 104 | onOpenThread={joined || g.item.event.thread_summary ? onOpenThread : null} |
| 105 | onReply={joined ? onReply : null} |
| 106 | {onJump} |
| 107 | onEdit={joined ? onEdit : null} |
| 108 | editing={editingEventId !== null && editingEventId === g.item.event.event_id} |
| 109 | showSent={g.item.event.item_id === lastSentId && pending.length === 0} |
| 110 | /> |
| 111 | {/if} |
| 112 | {/each} |
| 113 | {/if} |
| 114 | {#each pending as p, i (p.id)} |
| 115 | <MessageRow |
| 116 | itemId={`pending-${p.id}`} |
| 117 | event={pendingToEvent(p, self, selfName)} |
| 118 | {onJump} |
| 119 | showSender={i === 0 && !pendingFollowsSelf} |
| 120 | senderAvatar={senderAvatarFor(self ?? '')} |
| 121 | {reacting} |
| 122 | {onReact} |
| 123 | {quickReactions} |
| 124 | {packs} |
| 125 | {linkPreviews} |
| 126 | {loadPreview} |
| 127 | {onQuickChange} |
| 128 | /> |
| 129 | {#if p.status === 'failed'} |
| 130 | <div class="pending-actions"> |
| 131 | <span class="pending-error" role="alert"><EmojiText text={p.error ?? ''} /></span> |
| 132 | <button type="button" class="ghost-btn" on:click={() => onRetryPending(p.id)}>Retry</button> |
| 133 | <button type="button" class="ghost-btn danger" on:click={() => onDiscardPending(p.id)}>Discard</button> |
| 134 | </div> |
| 135 | {/if} |
| 136 | {/each} |