atagen

malice

public
Star0
Watch0
e153508681c5c91d170d860a395f773ef22afa18
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git

Blame: web/src/lib/RoomTimelineRows.svelte

f93c7e88ea05atagen2026-10-25 10:54 UTC1
<script lang="ts">
f93c7e88ea05atagen2026-10-25 10:54 UTC2
  import EmojiText from './EmojiText.svelte';
f93c7e88ea05atagen2026-10-25 10:54 UTC3
  import MessageRow from './MessageRow.svelte';
f93c7e88ea05atagen2026-10-25 10:54 UTC4
  import { formatDay } from './format';
d07bca5da4aeatagen2026-10-26 03:48 UTC5
  import { expand, motionActive, rowIn, rowOut, setRowMotion } from './motion';
f93c7e88ea05atagen2026-10-25 10:54 UTC6
  import type { PendingMessage } from './pending';
d07bca5da4aeatagen2026-10-26 03:48 UTC7
  import { buildGroups, type Group, lastOwnSent, lastSenderIs, pendingToEvent } from './timeline-groups';
f93c7e88ea05atagen2026-10-25 10:54 UTC8
  import type { ImagePack, LinkPreview, Person, QuickReaction, RecentHistoryState, ReplyReference, TimelineEvent, TimelineItem } from './types';
f93c7e88ea05atagen2026-10-25 10:54 UTC9
f93c7e88ea05atagen2026-10-25 10:54 UTC10
  export let items: TimelineItem[];
f93c7e88ea05atagen2026-10-25 10:54 UTC11
  export let pending: PendingMessage[] = [];
f93c7e88ea05atagen2026-10-25 10:54 UTC12
  export let joined: boolean;
f93c7e88ea05atagen2026-10-25 10:54 UTC13
  export let self: string | null;
f93c7e88ea05atagen2026-10-25 10:54 UTC14
  export let selfName: string | null = null;
f93c7e88ea05atagen2026-10-25 10:54 UTC15
  export let people: Record<string, Person> = {};
f93c7e88ea05atagen2026-10-25 10:54 UTC16
  export let senderAvatarFor: (userId: string) => string | null;
f93c7e88ea05atagen2026-10-25 10:54 UTC17
  export let reacting: boolean;
f93c7e88ea05atagen2026-10-25 10:54 UTC18
  export let quickReactions: QuickReaction[] = [];
f93c7e88ea05atagen2026-10-25 10:54 UTC19
  export let packs: ImagePack[] = [];
f93c7e88ea05atagen2026-10-25 10:54 UTC20
  export let linkPreviews: Record<string, LinkPreview | null> = {};
f93c7e88ea05atagen2026-10-25 10:54 UTC21
  export let loadPreview: ((url: string) => void) | null = null;
f93c7e88ea05atagen2026-10-25 10:54 UTC22
  export let editingEventId: string | null = null;
f93c7e88ea05atagen2026-10-25 10:54 UTC23
  export let recentState: RecentHistoryState | undefined = undefined;
f93c7e88ea05atagen2026-10-25 10:54 UTC24
  export let recentRetrying = false;
f93c7e88ea05atagen2026-10-25 10:54 UTC25
  export let paginating = false;
f93c7e88ea05atagen2026-10-25 10:54 UTC26
  export let fillingGap: string | null = null;
d07bca5da4aeatagen2026-10-26 03:48 UTC27
  export let following = true;
f93c7e88ea05atagen2026-10-25 10:54 UTC28
  export let onReact: (eventId: string, key: string, shortcode?: string) => void;
f93c7e88ea05atagen2026-10-25 10:54 UTC29
  export let onQuickChange: (list: QuickReaction[]) => void;
f93c7e88ea05atagen2026-10-25 10:54 UTC30
  export let onUser: (userId: string, x: number, y: number, menu: boolean) => void;
f93c7e88ea05atagen2026-10-25 10:54 UTC31
  export let onCloneSticker: (eventId: string, body: string, x: number, y: number) => void;
f93c7e88ea05atagen2026-10-25 10:54 UTC32
  export let onMore: (event: TimelineEvent, x: number, y: number) => void;
f93c7e88ea05atagen2026-10-25 10:54 UTC33
  export let onOpenThread: (rootEventId: string) => void;
f93c7e88ea05atagen2026-10-25 10:54 UTC34
  export let onReply: (target: ReplyReference) => void;
f93c7e88ea05atagen2026-10-25 10:54 UTC35
  export let onJump: (eventId: string) => void;
f93c7e88ea05atagen2026-10-25 10:54 UTC36
  export let onEdit: (eventId: string, body: string) => void;
f93c7e88ea05atagen2026-10-25 10:54 UTC37
  export let onPaginate: () => void;
d07bca5da4aeatagen2026-10-26 03:48 UTC38
  export let onGrew: () => void;
f93c7e88ea05atagen2026-10-25 10:54 UTC39
  export let onFillGap: (gapId: string) => void;
f93c7e88ea05atagen2026-10-25 10:54 UTC40
  export let onRetryRecent: () => void;
f93c7e88ea05atagen2026-10-25 10:54 UTC41
  export let onRetryPending: (id: string) => void;
f93c7e88ea05atagen2026-10-25 10:54 UTC42
  export let onDiscardPending: (id: string) => void;
f93c7e88ea05atagen2026-10-25 10:54 UTC43
d07bca5da4aeatagen2026-10-26 03:48 UTC44
  const INITIAL_GROUPS = 150;
d07bca5da4aeatagen2026-10-26 03:48 UTC45
  const READ_CAP = 400;
d07bca5da4aeatagen2026-10-26 03:48 UTC46
  const READ_CONTEXT = 20;
d07bca5da4aeatagen2026-10-26 03:48 UTC47
  const STEP = 30;
d07bca5da4aeatagen2026-10-26 03:48 UTC48
  const MAX_GROUPS = 300;
d07bca5da4aeatagen2026-10-26 03:48 UTC49
  const JUMP_HALF = 60;
d07bca5da4aeatagen2026-10-26 03:48 UTC50
  const LIVE_MAX = 8;
d07bca5da4aeatagen2026-10-26 03:48 UTC51
d07bca5da4aeatagen2026-10-26 03:48 UTC52
  let ready = false;
d07bca5da4aeatagen2026-10-26 03:48 UTC53
  let start = 0;
d07bca5da4aeatagen2026-10-26 03:48 UTC54
  let end = 0;
d07bca5da4aeatagen2026-10-26 03:48 UTC55
  let tail = true;
d07bca5da4aeatagen2026-10-26 03:48 UTC56
  let startKey: string | null = null;
d07bca5da4aeatagen2026-10-26 03:48 UTC57
  let endKey: string | null = null;
d07bca5da4aeatagen2026-10-26 03:48 UTC58
  let quiet = false;
d07bca5da4aeatagen2026-10-26 03:48 UTC59
  let mounted = false;
d07bca5da4aeatagen2026-10-26 03:48 UTC60
  let shownKeys: string[] = [];
d07bca5da4aeatagen2026-10-26 03:48 UTC61
  let shownPending = new Map<string, PendingMessage['status']>();
d07bca5da4aeatagen2026-10-26 03:48 UTC62
  let firstKey: string | undefined;
d07bca5da4aeatagen2026-10-26 03:48 UTC63
d07bca5da4aeatagen2026-10-26 03:48 UTC64
  function eventKey(list: Group[], from: number, step: 1 | -1): string | null {
d07bca5da4aeatagen2026-10-26 03:48 UTC65
    for (let i = from; i >= 0 && i < list.length; i += step) if (list[i].item.kind === 'event') return list[i].key;
d07bca5da4aeatagen2026-10-26 03:48 UTC66
    return null;
d07bca5da4aeatagen2026-10-26 03:48 UTC67
  }
d07bca5da4aeatagen2026-10-26 03:48 UTC68
d07bca5da4aeatagen2026-10-26 03:48 UTC69
  function settle(list: Group[]): void {
d07bca5da4aeatagen2026-10-26 03:48 UTC70
    startKey = eventKey(list, start, 1);
d07bca5da4aeatagen2026-10-26 03:48 UTC71
    endKey = eventKey(list, end - 1, -1);
d07bca5da4aeatagen2026-10-26 03:48 UTC72
    tail = end >= list.length;
d07bca5da4aeatagen2026-10-26 03:48 UTC73
  }
d07bca5da4aeatagen2026-10-26 03:48 UTC74
d07bca5da4aeatagen2026-10-26 03:48 UTC75
  function reconcile(list: Group[], follow: boolean): void {
d07bca5da4aeatagen2026-10-26 03:48 UTC76
    if (!ready) {
d07bca5da4aeatagen2026-10-26 03:48 UTC77
      if (list.length === 0) return;
d07bca5da4aeatagen2026-10-26 03:48 UTC78
      start = Math.max(0, list.length - INITIAL_GROUPS);
d07bca5da4aeatagen2026-10-26 03:48 UTC79
      const marker = list.findIndex((g) => g.item.kind === 'readMarker');
d07bca5da4aeatagen2026-10-26 03:48 UTC80
      if (marker >= 0 && marker < start) start = Math.max(0, marker - READ_CONTEXT, list.length - READ_CAP);
d07bca5da4aeatagen2026-10-26 03:48 UTC81
      end = list.length;
d07bca5da4aeatagen2026-10-26 03:48 UTC82
      ready = true;
d07bca5da4aeatagen2026-10-26 03:48 UTC83
      settle(list);
d07bca5da4aeatagen2026-10-26 03:48 UTC84
      return;
d07bca5da4aeatagen2026-10-26 03:48 UTC85
    }
d07bca5da4aeatagen2026-10-26 03:48 UTC86
    const oldest = start === 0;
d07bca5da4aeatagen2026-10-26 03:48 UTC87
    let s = startKey === null ? start : list.findIndex((g) => g.key === startKey);
d07bca5da4aeatagen2026-10-26 03:48 UTC88
    if (s < 0) s = start;
d07bca5da4aeatagen2026-10-26 03:48 UTC89
    if (oldest) s = 0;
d07bca5da4aeatagen2026-10-26 03:48 UTC90
    let e = list.length;
d07bca5da4aeatagen2026-10-26 03:48 UTC91
    if (!tail && !follow) {
d07bca5da4aeatagen2026-10-26 03:48 UTC92
      const at = endKey === null ? -1 : list.findIndex((g) => g.key === endKey);
d07bca5da4aeatagen2026-10-26 03:48 UTC93
      e = at >= 0 ? at + 1 : Math.min(end, list.length);
d07bca5da4aeatagen2026-10-26 03:48 UTC94
    }
d07bca5da4aeatagen2026-10-26 03:48 UTC95
    s = Math.min(s, e);
d07bca5da4aeatagen2026-10-26 03:48 UTC96
    if (e - s > MAX_GROUPS) {
d07bca5da4aeatagen2026-10-26 03:48 UTC97
      if (follow && e === list.length) s = e - MAX_GROUPS;
d07bca5da4aeatagen2026-10-26 03:48 UTC98
      else if (oldest) e = s + MAX_GROUPS;
d07bca5da4aeatagen2026-10-26 03:48 UTC99
      else s = e - MAX_GROUPS;
d07bca5da4aeatagen2026-10-26 03:48 UTC100
    }
d07bca5da4aeatagen2026-10-26 03:48 UTC101
    start = s;
d07bca5da4aeatagen2026-10-26 03:48 UTC102
    end = e;
d07bca5da4aeatagen2026-10-26 03:48 UTC103
    settle(list);
d07bca5da4aeatagen2026-10-26 03:48 UTC104
  }
d07bca5da4aeatagen2026-10-26 03:48 UTC105
d07bca5da4aeatagen2026-10-26 03:48 UTC106
  export function atTail(): boolean {
d07bca5da4aeatagen2026-10-26 03:48 UTC107
    return tail;
d07bca5da4aeatagen2026-10-26 03:48 UTC108
  }
d07bca5da4aeatagen2026-10-26 03:48 UTC109
d07bca5da4aeatagen2026-10-26 03:48 UTC110
  export function has(eventId: string): boolean {
d07bca5da4aeatagen2026-10-26 03:48 UTC111
    return grouped.some((g) => g.item.kind === 'event' && g.item.event.event_id === eventId);
d07bca5da4aeatagen2026-10-26 03:48 UTC112
  }
d07bca5da4aeatagen2026-10-26 03:48 UTC113
d07bca5da4aeatagen2026-10-26 03:48 UTC114
  export function reveal(eventId: string): boolean {
d07bca5da4aeatagen2026-10-26 03:48 UTC115
    const at = grouped.findIndex((g) => g.item.kind === 'event' && g.item.event.event_id === eventId);
d07bca5da4aeatagen2026-10-26 03:48 UTC116
    if (at < 0 || (at >= start && at < end)) return false;
d07bca5da4aeatagen2026-10-26 03:48 UTC117
    quiet = true;
d07bca5da4aeatagen2026-10-26 03:48 UTC118
    start = Math.max(0, at - JUMP_HALF);
d07bca5da4aeatagen2026-10-26 03:48 UTC119
    end = Math.min(grouped.length, at + JUMP_HALF);
d07bca5da4aeatagen2026-10-26 03:48 UTC120
    settle(grouped);
d07bca5da4aeatagen2026-10-26 03:48 UTC121
    return true;
d07bca5da4aeatagen2026-10-26 03:48 UTC122
  }
d07bca5da4aeatagen2026-10-26 03:48 UTC123
d07bca5da4aeatagen2026-10-26 03:48 UTC124
  export function resetToTail(): boolean {
d07bca5da4aeatagen2026-10-26 03:48 UTC125
    if (tail) return false;
d07bca5da4aeatagen2026-10-26 03:48 UTC126
    quiet = true;
d07bca5da4aeatagen2026-10-26 03:48 UTC127
    start = Math.max(0, grouped.length - INITIAL_GROUPS);
d07bca5da4aeatagen2026-10-26 03:48 UTC128
    end = grouped.length;
d07bca5da4aeatagen2026-10-26 03:48 UTC129
    settle(grouped);
d07bca5da4aeatagen2026-10-26 03:48 UTC130
    return true;
d07bca5da4aeatagen2026-10-26 03:48 UTC131
  }
d07bca5da4aeatagen2026-10-26 03:48 UTC132
d07bca5da4aeatagen2026-10-26 03:48 UTC133
  export function grow(direction: 'up' | 'down'): boolean {
d07bca5da4aeatagen2026-10-26 03:48 UTC134
    if (direction === 'up') {
d07bca5da4aeatagen2026-10-26 03:48 UTC135
      if (start === 0) return false;
d07bca5da4aeatagen2026-10-26 03:48 UTC136
      quiet = true;
d07bca5da4aeatagen2026-10-26 03:48 UTC137
      start = Math.max(0, start - STEP);
d07bca5da4aeatagen2026-10-26 03:48 UTC138
      if (end - start > MAX_GROUPS) end = start + MAX_GROUPS;
d07bca5da4aeatagen2026-10-26 03:48 UTC139
    } else {
d07bca5da4aeatagen2026-10-26 03:48 UTC140
      if (end >= grouped.length) return false;
d07bca5da4aeatagen2026-10-26 03:48 UTC141
      quiet = true;
d07bca5da4aeatagen2026-10-26 03:48 UTC142
      end = Math.min(grouped.length, end + STEP);
d07bca5da4aeatagen2026-10-26 03:48 UTC143
      if (end - start > MAX_GROUPS) start = end - MAX_GROUPS;
d07bca5da4aeatagen2026-10-26 03:48 UTC144
    }
d07bca5da4aeatagen2026-10-26 03:48 UTC145
    settle(grouped);
d07bca5da4aeatagen2026-10-26 03:48 UTC146
    return true;
d07bca5da4aeatagen2026-10-26 03:48 UTC147
  }
d07bca5da4aeatagen2026-10-26 03:48 UTC148
d07bca5da4aeatagen2026-10-26 03:48 UTC149
  function loadOlder(): void {
d07bca5da4aeatagen2026-10-26 03:48 UTC150
    if (grow('up')) onGrew();
d07bca5da4aeatagen2026-10-26 03:48 UTC151
    else onPaginate();
d07bca5da4aeatagen2026-10-26 03:48 UTC152
  }
d07bca5da4aeatagen2026-10-26 03:48 UTC153
d07bca5da4aeatagen2026-10-26 03:48 UTC154
  function trackMotion(vis: Group[], list: Group[], pend: PendingMessage[]): void {
d07bca5da4aeatagen2026-10-26 03:48 UTC155
    const keys = vis.map((g) => g.key);
d07bca5da4aeatagen2026-10-26 03:48 UTC156
    const pendingNow = new Map<string, PendingMessage['status']>(pend.map((p) => [`pending-${p.id}`, p.status]));
d07bca5da4aeatagen2026-10-26 03:48 UTC157
    const enter = new Set<string>();
d07bca5da4aeatagen2026-10-26 03:48 UTC158
    const leave = new Set<string>();
d07bca5da4aeatagen2026-10-26 03:48 UTC159
    const prepended = firstKey !== list[0]?.key;
d07bca5da4aeatagen2026-10-26 03:48 UTC160
    if (mounted && !quiet && !prepended && shownKeys.length > 0) {
d07bca5da4aeatagen2026-10-26 03:48 UTC161
      const old = new Set(shownKeys);
d07bca5da4aeatagen2026-10-26 03:48 UTC162
      const now = new Set(keys);
d07bca5da4aeatagen2026-10-26 03:48 UTC163
      const listKeys = new Set(list.map((g) => g.key));
d07bca5da4aeatagen2026-10-26 03:48 UTC164
      const added = keys.filter((k) => !old.has(k));
d07bca5da4aeatagen2026-10-26 03:48 UTC165
      const gone = shownKeys.filter((k) => !now.has(k) && !listKeys.has(k));
d07bca5da4aeatagen2026-10-26 03:48 UTC166
      if (added.length <= LIVE_MAX && gone.length <= LIVE_MAX) {
d07bca5da4aeatagen2026-10-26 03:48 UTC167
        const byKey = new Map(vis.map((g) => [g.key, g.item] as const));
d07bca5da4aeatagen2026-10-26 03:48 UTC168
        const echoed = shownPending.size > 0 && !motionActive();
d07bca5da4aeatagen2026-10-26 03:48 UTC169
        const lastOld = keys.lastIndexOf(shownKeys[shownKeys.length - 1]);
d07bca5da4aeatagen2026-10-26 03:48 UTC170
        for (const k of added) {
d07bca5da4aeatagen2026-10-26 03:48 UTC171
          const item = byKey.get(k);
d07bca5da4aeatagen2026-10-26 03:48 UTC172
          const appended = lastOld >= 0 && keys.indexOf(k) > lastOld;
d07bca5da4aeatagen2026-10-26 03:48 UTC173
          if ((appended || k === 'readMarker') && !(echoed && item?.kind === 'event' && item.event.own)) enter.add(k);
d07bca5da4aeatagen2026-10-26 03:48 UTC174
        }
d07bca5da4aeatagen2026-10-26 03:48 UTC175
        for (const k of gone) leave.add(k);
d07bca5da4aeatagen2026-10-26 03:48 UTC176
      }
d07bca5da4aeatagen2026-10-26 03:48 UTC177
      for (const id of pendingNow.keys()) if (!shownPending.has(id)) enter.add(id);
d07bca5da4aeatagen2026-10-26 03:48 UTC178
      for (const [id, status] of shownPending) if (!pendingNow.has(id) && status === 'failed') leave.add(id);
d07bca5da4aeatagen2026-10-26 03:48 UTC179
    }
d07bca5da4aeatagen2026-10-26 03:48 UTC180
    mounted = true;
d07bca5da4aeatagen2026-10-26 03:48 UTC181
    quiet = false;
d07bca5da4aeatagen2026-10-26 03:48 UTC182
    shownKeys = keys;
d07bca5da4aeatagen2026-10-26 03:48 UTC183
    shownPending = pendingNow;
d07bca5da4aeatagen2026-10-26 03:48 UTC184
    firstKey = list[0]?.key;
d07bca5da4aeatagen2026-10-26 03:48 UTC185
    setRowMotion(enter, leave);
d07bca5da4aeatagen2026-10-26 03:48 UTC186
  }
d07bca5da4aeatagen2026-10-26 03:48 UTC187
f93c7e88ea05atagen2026-10-25 10:54 UTC188
  $: hasStart = items.some((item) => item.kind === 'start');
f93c7e88ea05atagen2026-10-25 10:54 UTC189
  $: grouped = buildGroups(items);
d07bca5da4aeatagen2026-10-26 03:48 UTC190
  $: reconcile(grouped, following);
d07bca5da4aeatagen2026-10-26 03:48 UTC191
  $: visible = grouped.slice(start, end);
d07bca5da4aeatagen2026-10-26 03:48 UTC192
  $: trackMotion(visible, grouped, tail ? pending : []);
f93c7e88ea05atagen2026-10-25 10:54 UTC193
  $: lastSentId = lastOwnSent(items);
f93c7e88ea05atagen2026-10-25 10:54 UTC194
  $: pendingFollowsSelf = lastSenderIs(items, self);
f93c7e88ea05atagen2026-10-25 10:54 UTC195
</script>
f93c7e88ea05atagen2026-10-25 10:54 UTC196
f93c7e88ea05atagen2026-10-25 10:54 UTC197
<div class="timeline-top">
f93c7e88ea05atagen2026-10-25 10:54 UTC198
  {#if recentState === 'Loading'}
d07bca5da4aeatagen2026-10-26 03:48 UTC199
    <p class="side-note" transition:expand>Loading recent history…</p>
f93c7e88ea05atagen2026-10-25 10:54 UTC200
  {:else if recentState && typeof recentState === 'object'}
d07bca5da4aeatagen2026-10-26 03:48 UTC201
    <div class="history-gap" transition:expand>
f93c7e88ea05atagen2026-10-25 10:54 UTC202
      <span>Recent history could not be loaded</span>
f93c7e88ea05atagen2026-10-25 10:54 UTC203
      <button type="button" class="ghost-btn" disabled={recentRetrying} on:click={onRetryRecent}>
f93c7e88ea05atagen2026-10-25 10:54 UTC204
        {recentRetrying ? 'Loading…' : 'Retry'}
f93c7e88ea05atagen2026-10-25 10:54 UTC205
      </button>
f93c7e88ea05atagen2026-10-25 10:54 UTC206
    </div>
f93c7e88ea05atagen2026-10-25 10:54 UTC207
  {/if}
d07bca5da4aeatagen2026-10-26 03:48 UTC208
  {#if hasStart && start === 0}
d07bca5da4aeatagen2026-10-26 03:48 UTC209
    <p class="start-note" transition:expand>Beginning of history</p>
f93c7e88ea05atagen2026-10-25 10:54 UTC210
  {:else}
d07bca5da4aeatagen2026-10-26 03:48 UTC211
    <button type="button" class="ghost-btn" transition:expand disabled={paginating || recentState === 'Loading'} on:click={loadOlder}>
f93c7e88ea05atagen2026-10-25 10:54 UTC212
      {paginating ? 'Loading…' : 'Load older messages'}
f93c7e88ea05atagen2026-10-25 10:54 UTC213
    </button>
f93c7e88ea05atagen2026-10-25 10:54 UTC214
  {/if}
f93c7e88ea05atagen2026-10-25 10:54 UTC215
</div>
f93c7e88ea05atagen2026-10-25 10:54 UTC216
{#if grouped.length === 0}
d07bca5da4aeatagen2026-10-26 03:48 UTC217
  <p class="side-note timeline-empty" transition:expand>No messages yet</p>
f93c7e88ea05atagen2026-10-25 10:54 UTC218
{:else}
d07bca5da4aeatagen2026-10-26 03:48 UTC219
  {#each visible as g (g.key)}
f93c7e88ea05atagen2026-10-25 10:54 UTC220
    {#if g.item.kind === 'day'}
d07bca5da4aeatagen2026-10-26 03:48 UTC221
      <div class="day-row" data-row={g.key} in:rowIn|global out:rowOut|global><span>{formatDay(g.item.timestamp_ms)}</span></div>
f93c7e88ea05atagen2026-10-25 10:54 UTC222
    {:else if g.item.kind === 'readMarker'}
d07bca5da4aeatagen2026-10-26 03:48 UTC223
      <div class="new-row" data-row={g.key} in:rowIn|global out:rowOut|global><span>New</span></div>
f93c7e88ea05atagen2026-10-25 10:54 UTC224
    {:else if g.item.kind === 'start'}
d07bca5da4aeatagen2026-10-26 03:48 UTC225
      <p class="start-note" data-row={g.key} in:rowIn|global out:rowOut|global>Beginning of history</p>
f93c7e88ea05atagen2026-10-25 10:54 UTC226
    {:else if g.item.kind === 'historyGap'}
249e439c27e5atagen2026-10-25 13:26 UTC227
      {@const gap = g.item.gap}
d07bca5da4aeatagen2026-10-26 03:48 UTC228
      <div class="history-gap" data-row={g.key} in:rowIn|global out:rowOut|global>
249e439c27e5atagen2026-10-25 13:26 UTC229
        <span>{gap.failure ?? 'Some history is not loaded'}</span>
249e439c27e5atagen2026-10-25 13:26 UTC230
        <button type="button" class="ghost-btn" disabled={!!fillingGap || gap.lifecycle === 'Unavailable'} on:click={() => onFillGap(gap.id)}>
249e439c27e5atagen2026-10-25 13:26 UTC231
          {fillingGap === gap.id ? 'Loading…' : gap.lifecycle === 'Unavailable' ? 'History unavailable' : 'Fetch older history'}
249e439c27e5atagen2026-10-25 13:26 UTC232
        </button>
249e439c27e5atagen2026-10-25 13:26 UTC233
      </div>
f93c7e88ea05atagen2026-10-25 10:54 UTC234
    {:else if g.item.kind === 'unknown'}
d07bca5da4aeatagen2026-10-26 03:48 UTC235
      <p class="unknown-note" data-row={g.key} in:rowIn|global out:rowOut|global>Unsupported event ({g.item.label})</p>
f93c7e88ea05atagen2026-10-25 10:54 UTC236
    {:else}
f93c7e88ea05atagen2026-10-25 10:54 UTC237
      <MessageRow
f93c7e88ea05atagen2026-10-25 10:54 UTC238
        itemId={g.key}
f93c7e88ea05atagen2026-10-25 10:54 UTC239
        event={g.item.event}
f93c7e88ea05atagen2026-10-25 10:54 UTC240
        showSender={g.showSender}
f93c7e88ea05atagen2026-10-25 10:54 UTC241
        senderAvatar={senderAvatarFor(g.item.event.sender.user_id) ?? g.item.event.sender.avatar}
f93c7e88ea05atagen2026-10-25 10:54 UTC242
        {reacting}
f93c7e88ea05atagen2026-10-25 10:54 UTC243
        {onReact}
f93c7e88ea05atagen2026-10-25 10:54 UTC244
        {quickReactions}
f93c7e88ea05atagen2026-10-25 10:54 UTC245
        {packs}
f93c7e88ea05atagen2026-10-25 10:54 UTC246
        {linkPreviews}
f93c7e88ea05atagen2026-10-25 10:54 UTC247
        {loadPreview}
f93c7e88ea05atagen2026-10-25 10:54 UTC248
        {onQuickChange}
f93c7e88ea05atagen2026-10-25 10:54 UTC249
        {people}
f93c7e88ea05atagen2026-10-25 10:54 UTC250
        {self}
f93c7e88ea05atagen2026-10-25 10:54 UTC251
        {onUser}
f93c7e88ea05atagen2026-10-25 10:54 UTC252
        {onCloneSticker}
f93c7e88ea05atagen2026-10-25 10:54 UTC253
        {onMore}
f93c7e88ea05atagen2026-10-25 10:54 UTC254
        onOpenThread={joined || g.item.event.thread_summary ? onOpenThread : null}
f93c7e88ea05atagen2026-10-25 10:54 UTC255
        onReply={joined ? onReply : null}
f93c7e88ea05atagen2026-10-25 10:54 UTC256
        {onJump}
f93c7e88ea05atagen2026-10-25 10:54 UTC257
        onEdit={joined ? onEdit : null}
f93c7e88ea05atagen2026-10-25 10:54 UTC258
        editing={editingEventId !== null && editingEventId === g.item.event.event_id}
f93c7e88ea05atagen2026-10-25 10:54 UTC259
        showSent={g.item.event.item_id === lastSentId && pending.length === 0}
f93c7e88ea05atagen2026-10-25 10:54 UTC260
      />
f93c7e88ea05atagen2026-10-25 10:54 UTC261
    {/if}
f93c7e88ea05atagen2026-10-25 10:54 UTC262
  {/each}
f93c7e88ea05atagen2026-10-25 10:54 UTC263
{/if}
d07bca5da4aeatagen2026-10-26 03:48 UTC264
{#each tail ? pending : [] as p, i (p.id)}
f93c7e88ea05atagen2026-10-25 10:54 UTC265
  <MessageRow
f93c7e88ea05atagen2026-10-25 10:54 UTC266
    itemId={`pending-${p.id}`}
f93c7e88ea05atagen2026-10-25 10:54 UTC267
    event={pendingToEvent(p, self, selfName)}
f93c7e88ea05atagen2026-10-25 10:54 UTC268
    {onJump}
f93c7e88ea05atagen2026-10-25 10:54 UTC269
    showSender={i === 0 && !pendingFollowsSelf}
f93c7e88ea05atagen2026-10-25 10:54 UTC270
    senderAvatar={senderAvatarFor(self ?? '')}
f93c7e88ea05atagen2026-10-25 10:54 UTC271
    {reacting}
f93c7e88ea05atagen2026-10-25 10:54 UTC272
    {onReact}
f93c7e88ea05atagen2026-10-25 10:54 UTC273
    {quickReactions}
f93c7e88ea05atagen2026-10-25 10:54 UTC274
    {packs}
f93c7e88ea05atagen2026-10-25 10:54 UTC275
    {linkPreviews}
f93c7e88ea05atagen2026-10-25 10:54 UTC276
    {loadPreview}
f93c7e88ea05atagen2026-10-25 10:54 UTC277
    {onQuickChange}
f93c7e88ea05atagen2026-10-25 10:54 UTC278
  />
f93c7e88ea05atagen2026-10-25 10:54 UTC279
  {#if p.status === 'failed'}
d07bca5da4aeatagen2026-10-26 03:48 UTC280
    <div class="pending-actions" transition:expand>
f93c7e88ea05atagen2026-10-25 10:54 UTC281
      <span class="pending-error" role="alert"><EmojiText text={p.error ?? ''} /></span>
f93c7e88ea05atagen2026-10-25 10:54 UTC282
      <button type="button" class="ghost-btn" on:click={() => onRetryPending(p.id)}>Retry</button>
f93c7e88ea05atagen2026-10-25 10:54 UTC283
      <button type="button" class="ghost-btn danger" on:click={() => onDiscardPending(p.id)}>Discard</button>
f93c7e88ea05atagen2026-10-25 10:54 UTC284
    </div>
f93c7e88ea05atagen2026-10-25 10:54 UTC285
  {/if}
f93c7e88ea05atagen2026-10-25 10:54 UTC286
{/each}