atagen

malice

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

Blame: web/src/lib/ThreadPane.svelte

dda384a360e0atagen2026-10-25 10:27 UTC1
<script lang="ts" context="module">
dda384a360e0atagen2026-10-25 10:27 UTC2
  import { sessionUser } from './store';
dda384a360e0atagen2026-10-25 10:27 UTC3
  import { releaseStaged as release } from './staging';
dda384a360e0atagen2026-10-25 10:27 UTC4
  import type { Mention } from './mentions';
dda384a360e0atagen2026-10-25 10:27 UTC5
  import type { StagedAttachment } from './staging';
dda384a360e0atagen2026-10-25 10:27 UTC6
dda384a360e0atagen2026-10-25 10:27 UTC7
  interface ThreadDraft {
dda384a360e0atagen2026-10-25 10:27 UTC8
    text: string;
dda384a360e0atagen2026-10-25 10:27 UTC9
    picked: Mention[];
dda384a360e0atagen2026-10-25 10:27 UTC10
    staged: StagedAttachment[];
dda384a360e0atagen2026-10-25 10:27 UTC11
  }
dda384a360e0atagen2026-10-25 10:27 UTC12
dda384a360e0atagen2026-10-25 10:27 UTC13
  const drafts = new Map<string, ThreadDraft>();
dda384a360e0atagen2026-10-25 10:27 UTC14
  let draftAccount: string | null | undefined;
dda384a360e0atagen2026-10-25 10:27 UTC15
  sessionUser.subscribe((user) => {
dda384a360e0atagen2026-10-25 10:27 UTC16
    if (user === draftAccount) return;
dda384a360e0atagen2026-10-25 10:27 UTC17
    draftAccount = user;
dda384a360e0atagen2026-10-25 10:27 UTC18
    for (const saved of drafts.values()) release(saved.staged);
dda384a360e0atagen2026-10-25 10:27 UTC19
    drafts.clear();
dda384a360e0atagen2026-10-25 10:27 UTC20
  });
dda384a360e0atagen2026-10-25 10:27 UTC21
</script>
dda384a360e0atagen2026-10-25 10:27 UTC22
7ebc2472a9e3atagen2026-10-25 09:53 UTC23
<script lang="ts">
7ebc2472a9e3atagen2026-10-25 09:53 UTC24
  import { tick } from 'svelte';
7ebc2472a9e3atagen2026-10-25 09:53 UTC25
  import MessageRow from './MessageRow.svelte';
7ebc2472a9e3atagen2026-10-25 09:53 UTC26
  import EmojiText from './EmojiText.svelte';
dda384a360e0atagen2026-10-25 10:27 UTC27
  import Composer from './Composer.svelte';
dda384a360e0atagen2026-10-25 10:27 UTC28
  import { editMessage, markRead, paginateRoom, reactToMessage, sendMessage, uploadAttachment } from './api';
dda384a360e0atagen2026-10-25 10:27 UTC29
  import { parseCommand, unescapeCommand } from './commands';
dda384a360e0atagen2026-10-25 10:27 UTC30
  import { customEmotes, resolveEmotes } from './emotes';
dda384a360e0atagen2026-10-25 10:27 UTC31
  import { mentionsFromHtml, resolveMentions } from './mentions';
dda384a360e0atagen2026-10-25 10:27 UTC32
  import { releaseStaged, stageFiles } from './staging';
7ebc2472a9e3atagen2026-10-25 09:53 UTC33
  import { pushBanner, threadKey, threadTimelines, timelines } from './store';
7ebc2472a9e3atagen2026-10-25 09:53 UTC34
  import { pinnedThreads, togglePinnedThread } from './threads';
7ebc2472a9e3atagen2026-10-25 09:53 UTC35
  import type { ImagePack, LinkPreview, Person, QuickReaction, ReplyReference, TimelineEvent, TimelineItem } from './types';
7ebc2472a9e3atagen2026-10-25 09:53 UTC36
7ebc2472a9e3atagen2026-10-25 09:53 UTC37
  export let roomId: string;
7ebc2472a9e3atagen2026-10-25 09:53 UTC38
  export let rootEventId: string;
7ebc2472a9e3atagen2026-10-25 09:53 UTC39
  export let roomName: string;
7ebc2472a9e3atagen2026-10-25 09:53 UTC40
  export let joined: boolean;
7ebc2472a9e3atagen2026-10-25 09:53 UTC41
  export let self: string | null;
7ebc2472a9e3atagen2026-10-25 09:53 UTC42
  export let people: Record<string, Person> = {};
7ebc2472a9e3atagen2026-10-25 09:53 UTC43
  export let senderAvatarFor: (userId: string) => string | null = () => null;
7ebc2472a9e3atagen2026-10-25 09:53 UTC44
  export let quickReactions: QuickReaction[] = [];
7ebc2472a9e3atagen2026-10-25 09:53 UTC45
  export let packs: ImagePack[] = [];
7ebc2472a9e3atagen2026-10-25 09:53 UTC46
  export let linkPreviews: Record<string, LinkPreview | null> = {};
7ebc2472a9e3atagen2026-10-25 09:53 UTC47
  export let loadPreview: ((url: string) => void) | null = null;
7ebc2472a9e3atagen2026-10-25 09:53 UTC48
  export let onQuickChange: (list: QuickReaction[]) => void = () => {};
7ebc2472a9e3atagen2026-10-25 09:53 UTC49
  export let onUser: ((userId: string, x: number, y: number, menu: boolean) => void) | null = null;
7ebc2472a9e3atagen2026-10-25 09:53 UTC50
  export let onMore: ((event: TimelineEvent, x: number, y: number) => void) | null = null;
dda384a360e0atagen2026-10-25 10:27 UTC51
  export let onMentionStart: () => void = () => {};
7ebc2472a9e3atagen2026-10-25 09:53 UTC52
  export let onClose: () => void;
7ebc2472a9e3atagen2026-10-25 09:53 UTC53
7ebc2472a9e3atagen2026-10-25 09:53 UTC54
  const GROUP_GAP_MS = 5 * 60 * 1000;
7ebc2472a9e3atagen2026-10-25 09:53 UTC55
7ebc2472a9e3atagen2026-10-25 09:53 UTC56
  let list: HTMLDivElement | null = null;
dda384a360e0atagen2026-10-25 10:27 UTC57
  let composerRef: Composer | null = null;
7ebc2472a9e3atagen2026-10-25 09:53 UTC58
  let draft = '';
dda384a360e0atagen2026-10-25 10:27 UTC59
  let picked: Mention[] = [];
dda384a360e0atagen2026-10-25 10:27 UTC60
  let staged: StagedAttachment[] = [];
7ebc2472a9e3atagen2026-10-25 09:53 UTC61
  let replyingTo: ReplyReference | null = null;
dda384a360e0atagen2026-10-25 10:27 UTC62
  let editing: { eventId: string; original: string; stash: string } | null = null;
7ebc2472a9e3atagen2026-10-25 09:53 UTC63
  let sending = false;
dda384a360e0atagen2026-10-25 10:27 UTC64
  let uploadName: string | null = null;
dda384a360e0atagen2026-10-25 10:27 UTC65
  let loadedKey = '';
7ebc2472a9e3atagen2026-10-25 09:53 UTC66
  let paginating = false;
7ebc2472a9e3atagen2026-10-25 09:53 UTC67
  let reacting = false;
7ebc2472a9e3atagen2026-10-25 09:53 UTC68
  let stickToBottom = true;
7ebc2472a9e3atagen2026-10-25 09:53 UTC69
  let lastRead = '';
7ebc2472a9e3atagen2026-10-25 09:53 UTC70
7ebc2472a9e3atagen2026-10-25 09:53 UTC71
  $: key = threadKey(roomId, rootEventId);
7ebc2472a9e3atagen2026-10-25 09:53 UTC72
  $: items = $threadTimelines[key] ?? [];
7ebc2472a9e3atagen2026-10-25 09:53 UTC73
  $: roomRoot = ($timelines[roomId] ?? []).find((item) => item.kind === 'event' && item.event.event_id === rootEventId) ?? null;
7ebc2472a9e3atagen2026-10-25 09:53 UTC74
  $: events = threadEvents(items, roomRoot);
dda384a360e0atagen2026-10-25 10:27 UTC75
  $: hasStart = items.some((item) => item.kind === 'start' || (item.kind === 'event' && item.event.event_id === rootEventId));
7ebc2472a9e3atagen2026-10-25 09:53 UTC76
  $: pinned = ($pinnedThreads[roomId] ?? []).includes(rootEventId);
dda384a360e0atagen2026-10-25 10:27 UTC77
  $: emotes = customEmotes(packs);
dda384a360e0atagen2026-10-25 10:27 UTC78
  $: switchTo(key);
7ebc2472a9e3atagen2026-10-25 09:53 UTC79
  $: void follow(events);
7ebc2472a9e3atagen2026-10-25 09:53 UTC80
7ebc2472a9e3atagen2026-10-25 09:53 UTC81
  function threadEvents(list: TimelineItem[], root: TimelineItem | null): TimelineEvent[] {
7ebc2472a9e3atagen2026-10-25 09:53 UTC82
    const out = list.flatMap((item) => (item.kind === 'event' ? [item.event] : []));
7ebc2472a9e3atagen2026-10-25 09:53 UTC83
    if (root?.kind === 'event' && !out.some((event) => event.event_id === rootEventId)) out.unshift(root.event);
7ebc2472a9e3atagen2026-10-25 09:53 UTC84
    return out;
7ebc2472a9e3atagen2026-10-25 09:53 UTC85
  }
7ebc2472a9e3atagen2026-10-25 09:53 UTC86
7ebc2472a9e3atagen2026-10-25 09:53 UTC87
  function showSender(index: number): boolean {
7ebc2472a9e3atagen2026-10-25 09:53 UTC88
    if (index === 0) return true;
7ebc2472a9e3atagen2026-10-25 09:53 UTC89
    const prev = events[index - 1];
7ebc2472a9e3atagen2026-10-25 09:53 UTC90
    const cur = events[index];
7ebc2472a9e3atagen2026-10-25 09:53 UTC91
    return prev.sender.user_id !== cur.sender.user_id || Math.abs(cur.timestamp_ms - prev.timestamp_ms) > GROUP_GAP_MS;
7ebc2472a9e3atagen2026-10-25 09:53 UTC92
  }
7ebc2472a9e3atagen2026-10-25 09:53 UTC93
dda384a360e0atagen2026-10-25 10:27 UTC94
  function switchTo(next: string): void {
dda384a360e0atagen2026-10-25 10:27 UTC95
    if (next === loadedKey) return;
dda384a360e0atagen2026-10-25 10:27 UTC96
    if (loadedKey) drafts.set(loadedKey, { text: editing ? editing.stash : draft, picked, staged });
dda384a360e0atagen2026-10-25 10:27 UTC97
    loadedKey = next;
dda384a360e0atagen2026-10-25 10:27 UTC98
    const saved = drafts.get(next);
dda384a360e0atagen2026-10-25 10:27 UTC99
    draft = saved?.text ?? '';
dda384a360e0atagen2026-10-25 10:27 UTC100
    picked = saved?.picked ?? [];
dda384a360e0atagen2026-10-25 10:27 UTC101
    staged = saved?.staged ?? [];
7ebc2472a9e3atagen2026-10-25 09:53 UTC102
    replyingTo = null;
7ebc2472a9e3atagen2026-10-25 09:53 UTC103
    editing = null;
7ebc2472a9e3atagen2026-10-25 09:53 UTC104
    stickToBottom = true;
7ebc2472a9e3atagen2026-10-25 09:53 UTC105
    lastRead = '';
7ebc2472a9e3atagen2026-10-25 09:53 UTC106
  }
7ebc2472a9e3atagen2026-10-25 09:53 UTC107
7ebc2472a9e3atagen2026-10-25 09:53 UTC108
  async function follow(_events: TimelineEvent[]): Promise<void> {
7ebc2472a9e3atagen2026-10-25 09:53 UTC109
    await tick();
7ebc2472a9e3atagen2026-10-25 09:53 UTC110
    if (list && stickToBottom) list.scrollTop = list.scrollHeight;
7ebc2472a9e3atagen2026-10-25 09:53 UTC111
    sendReceipt();
7ebc2472a9e3atagen2026-10-25 09:53 UTC112
  }
7ebc2472a9e3atagen2026-10-25 09:53 UTC113
7ebc2472a9e3atagen2026-10-25 09:53 UTC114
  function sendReceipt(): void {
7ebc2472a9e3atagen2026-10-25 09:53 UTC115
    if (document.hidden || !document.hasFocus() || !stickToBottom) return;
7ebc2472a9e3atagen2026-10-25 09:53 UTC116
    const latest = [...events].reverse().find((event) => event.event_id);
7ebc2472a9e3atagen2026-10-25 09:53 UTC117
    if (!latest?.event_id || latest.event_id === lastRead) return;
7ebc2472a9e3atagen2026-10-25 09:53 UTC118
    lastRead = latest.event_id;
7ebc2472a9e3atagen2026-10-25 09:53 UTC119
    markRead(roomId, latest.event_id, rootEventId).catch(() => undefined);
7ebc2472a9e3atagen2026-10-25 09:53 UTC120
  }
7ebc2472a9e3atagen2026-10-25 09:53 UTC121
7ebc2472a9e3atagen2026-10-25 09:53 UTC122
  function onScroll(): void {
7ebc2472a9e3atagen2026-10-25 09:53 UTC123
    if (!list) return;
7ebc2472a9e3atagen2026-10-25 09:53 UTC124
    const wasAtBottom = stickToBottom;
7ebc2472a9e3atagen2026-10-25 09:53 UTC125
    stickToBottom = list.scrollHeight - list.scrollTop - list.clientHeight < 2;
7ebc2472a9e3atagen2026-10-25 09:53 UTC126
    if (stickToBottom && !wasAtBottom) sendReceipt();
7ebc2472a9e3atagen2026-10-25 09:53 UTC127
    if (list.scrollTop < 60 && !hasStart) void paginate();
7ebc2472a9e3atagen2026-10-25 09:53 UTC128
  }
7ebc2472a9e3atagen2026-10-25 09:53 UTC129
7ebc2472a9e3atagen2026-10-25 09:53 UTC130
  async function paginate(): Promise<void> {
7ebc2472a9e3atagen2026-10-25 09:53 UTC131
    if (paginating) return;
7ebc2472a9e3atagen2026-10-25 09:53 UTC132
    paginating = true;
7ebc2472a9e3atagen2026-10-25 09:53 UTC133
    try {
7ebc2472a9e3atagen2026-10-25 09:53 UTC134
      await paginateRoom(roomId, rootEventId);
7ebc2472a9e3atagen2026-10-25 09:53 UTC135
    } catch (e) {
7ebc2472a9e3atagen2026-10-25 09:53 UTC136
      pushBanner(e instanceof Error ? e.message : 'Loading older replies failed');
7ebc2472a9e3atagen2026-10-25 09:53 UTC137
    } finally {
7ebc2472a9e3atagen2026-10-25 09:53 UTC138
      paginating = false;
7ebc2472a9e3atagen2026-10-25 09:53 UTC139
    }
7ebc2472a9e3atagen2026-10-25 09:53 UTC140
  }
7ebc2472a9e3atagen2026-10-25 09:53 UTC141
7ebc2472a9e3atagen2026-10-25 09:53 UTC142
  async function react(eventId: string, emoji: string, shortcode?: string): Promise<void> {
7ebc2472a9e3atagen2026-10-25 09:53 UTC143
    if (reacting) return;
7ebc2472a9e3atagen2026-10-25 09:53 UTC144
    reacting = true;
7ebc2472a9e3atagen2026-10-25 09:53 UTC145
    try {
7ebc2472a9e3atagen2026-10-25 09:53 UTC146
      await reactToMessage(roomId, eventId, emoji, shortcode, rootEventId);
7ebc2472a9e3atagen2026-10-25 09:53 UTC147
    } catch (e) {
7ebc2472a9e3atagen2026-10-25 09:53 UTC148
      pushBanner(e instanceof Error ? e.message : 'Reaction failed');
7ebc2472a9e3atagen2026-10-25 09:53 UTC149
    } finally {
7ebc2472a9e3atagen2026-10-25 09:53 UTC150
      reacting = false;
7ebc2472a9e3atagen2026-10-25 09:53 UTC151
    }
7ebc2472a9e3atagen2026-10-25 09:53 UTC152
  }
7ebc2472a9e3atagen2026-10-25 09:53 UTC153
7ebc2472a9e3atagen2026-10-25 09:53 UTC154
  function startReply(target: ReplyReference): void {
dda384a360e0atagen2026-10-25 10:27 UTC155
    if (editing) cancelEdit();
7ebc2472a9e3atagen2026-10-25 09:53 UTC156
    replyingTo = target;
dda384a360e0atagen2026-10-25 10:27 UTC157
    composerRef?.focus();
7ebc2472a9e3atagen2026-10-25 09:53 UTC158
  }
7ebc2472a9e3atagen2026-10-25 09:53 UTC159
7ebc2472a9e3atagen2026-10-25 09:53 UTC160
  function startEdit(eventId: string, body: string): void {
dda384a360e0atagen2026-10-25 10:27 UTC161
    const stash = editing ? editing.stash : draft;
7ebc2472a9e3atagen2026-10-25 09:53 UTC162
    replyingTo = null;
dda384a360e0atagen2026-10-25 10:27 UTC163
    editing = { eventId, original: body, stash };
dda384a360e0atagen2026-10-25 10:27 UTC164
    const target = events.find((event) => event.event_id === eventId);
dda384a360e0atagen2026-10-25 10:27 UTC165
    if (target?.content.kind === 'Message') picked = [...picked, ...mentionsFromHtml(target.content.message.formatted_body)];
7ebc2472a9e3atagen2026-10-25 09:53 UTC166
    draft = body;
dda384a360e0atagen2026-10-25 10:27 UTC167
    void composerRef?.focusEnd();
dda384a360e0atagen2026-10-25 10:27 UTC168
  }
dda384a360e0atagen2026-10-25 10:27 UTC169
dda384a360e0atagen2026-10-25 10:27 UTC170
  function cancelEdit(): void {
dda384a360e0atagen2026-10-25 10:27 UTC171
    if (!editing) return;
dda384a360e0atagen2026-10-25 10:27 UTC172
    draft = editing.stash;
dda384a360e0atagen2026-10-25 10:27 UTC173
    editing = null;
dda384a360e0atagen2026-10-25 10:27 UTC174
    composerRef?.focus();
dda384a360e0atagen2026-10-25 10:27 UTC175
  }
dda384a360e0atagen2026-10-25 10:27 UTC176
dda384a360e0atagen2026-10-25 10:27 UTC177
  function editLastOwn(): boolean {
dda384a360e0atagen2026-10-25 10:27 UTC178
    for (let i = events.length - 1; i >= 0; i -= 1) {
dda384a360e0atagen2026-10-25 10:27 UTC179
      const event = events[i];
dda384a360e0atagen2026-10-25 10:27 UTC180
      if (!event.own) continue;
dda384a360e0atagen2026-10-25 10:27 UTC181
      if (!event.editable || !event.event_id || event.content.kind !== 'Message' || event.content.message.kind !== 'Text') return false;
dda384a360e0atagen2026-10-25 10:27 UTC182
      startEdit(event.event_id, event.content.message.body);
dda384a360e0atagen2026-10-25 10:27 UTC183
      return true;
dda384a360e0atagen2026-10-25 10:27 UTC184
    }
dda384a360e0atagen2026-10-25 10:27 UTC185
    return false;
dda384a360e0atagen2026-10-25 10:27 UTC186
  }
dda384a360e0atagen2026-10-25 10:27 UTC187
dda384a360e0atagen2026-10-25 10:27 UTC188
  function stage(files: File[]): void {
dda384a360e0atagen2026-10-25 10:27 UTC189
    const accepted = stageFiles(files, pushBanner);
dda384a360e0atagen2026-10-25 10:27 UTC190
    if (accepted.length) staged = [...staged, ...accepted];
dda384a360e0atagen2026-10-25 10:27 UTC191
    composerRef?.focus();
dda384a360e0atagen2026-10-25 10:27 UTC192
  }
dda384a360e0atagen2026-10-25 10:27 UTC193
dda384a360e0atagen2026-10-25 10:27 UTC194
  function unstage(id: number): void {
dda384a360e0atagen2026-10-25 10:27 UTC195
    releaseStaged(staged.filter((a) => a.id === id));
dda384a360e0atagen2026-10-25 10:27 UTC196
    staged = staged.filter((a) => a.id !== id);
7ebc2472a9e3atagen2026-10-25 09:53 UTC197
  }
7ebc2472a9e3atagen2026-10-25 09:53 UTC198
7ebc2472a9e3atagen2026-10-25 09:53 UTC199
  async function submit(): Promise<void> {
dda384a360e0atagen2026-10-25 10:27 UTC200
    if (sending) return;
7ebc2472a9e3atagen2026-10-25 09:53 UTC201
    const body = draft.trim();
dda384a360e0atagen2026-10-25 10:27 UTC202
    if (editing) {
dda384a360e0atagen2026-10-25 10:27 UTC203
      const { eventId, original } = editing;
dda384a360e0atagen2026-10-25 10:27 UTC204
      cancelEdit();
dda384a360e0atagen2026-10-25 10:27 UTC205
      draft = '';
dda384a360e0atagen2026-10-25 10:27 UTC206
      if (!body || body === original.trim()) return;
dda384a360e0atagen2026-10-25 10:27 UTC207
      editMessage(roomId, eventId, body, resolveMentions(body, picked), resolveEmotes(body, emotes), rootEventId).catch((e) =>
dda384a360e0atagen2026-10-25 10:27 UTC208
        pushBanner(e instanceof Error ? e.message : 'Edit failed')
dda384a360e0atagen2026-10-25 10:27 UTC209
      );
dda384a360e0atagen2026-10-25 10:27 UTC210
      picked = [];
dda384a360e0atagen2026-10-25 10:27 UTC211
      return;
dda384a360e0atagen2026-10-25 10:27 UTC212
    }
dda384a360e0atagen2026-10-25 10:27 UTC213
    const files = staged;
dda384a360e0atagen2026-10-25 10:27 UTC214
    if (!body && files.length === 0) return;
dda384a360e0atagen2026-10-25 10:27 UTC215
    const command = parseCommand(body);
dda384a360e0atagen2026-10-25 10:27 UTC216
    if (command && command.name !== 'me' && command.name !== 'notice') {
dda384a360e0atagen2026-10-25 10:27 UTC217
      pushBanner(`/${command.name} only works in the room composer`);
dda384a360e0atagen2026-10-25 10:27 UTC218
      return;
dda384a360e0atagen2026-10-25 10:27 UTC219
    }
dda384a360e0atagen2026-10-25 10:27 UTC220
    const text = command ? command.args : unescapeCommand(body);
dda384a360e0atagen2026-10-25 10:27 UTC221
    if (command && (!text || files.length)) {
dda384a360e0atagen2026-10-25 10:27 UTC222
      pushBanner(`/${command.name} needs something to say and no attachments`);
dda384a360e0atagen2026-10-25 10:27 UTC223
      return;
dda384a360e0atagen2026-10-25 10:27 UTC224
    }
7ebc2472a9e3atagen2026-10-25 09:53 UTC225
    const reply = replyingTo;
dda384a360e0atagen2026-10-25 10:27 UTC226
    const mentions = resolveMentions(text, picked);
dda384a360e0atagen2026-10-25 10:27 UTC227
    const used = resolveEmotes(text, emotes);
dda384a360e0atagen2026-10-25 10:27 UTC228
    draft = '';
dda384a360e0atagen2026-10-25 10:27 UTC229
    picked = [];
dda384a360e0atagen2026-10-25 10:27 UTC230
    staged = [];
dda384a360e0atagen2026-10-25 10:27 UTC231
    replyingTo = null;
dda384a360e0atagen2026-10-25 10:27 UTC232
    stickToBottom = true;
dda384a360e0atagen2026-10-25 10:27 UTC233
    sending = true;
7ebc2472a9e3atagen2026-10-25 09:53 UTC234
    try {
dda384a360e0atagen2026-10-25 10:27 UTC235
      if (files.length === 0) {
dda384a360e0atagen2026-10-25 10:27 UTC236
        await sendMessage(roomId, text, reply?.event_id ?? null, mentions, command ? (command.name === 'me' ? 'emote' : 'notice') : 'text', used, rootEventId);
dda384a360e0atagen2026-10-25 10:27 UTC237
      } else {
dda384a360e0atagen2026-10-25 10:27 UTC238
        let first = true;
dda384a360e0atagen2026-10-25 10:27 UTC239
        for (const attachment of files) {
dda384a360e0atagen2026-10-25 10:27 UTC240
          uploadName = attachment.file.name || 'attachment';
dda384a360e0atagen2026-10-25 10:27 UTC241
          try {
dda384a360e0atagen2026-10-25 10:27 UTC242
            if (first) await uploadAttachment(roomId, attachment.file, text || null, reply?.event_id ?? null, mentions, used, rootEventId);
dda384a360e0atagen2026-10-25 10:27 UTC243
            else await uploadAttachment(roomId, attachment.file, null, null, [], [], rootEventId);
dda384a360e0atagen2026-10-25 10:27 UTC244
            first = false;
dda384a360e0atagen2026-10-25 10:27 UTC245
          } catch (e) {
dda384a360e0atagen2026-10-25 10:27 UTC246
            pushBanner(e instanceof Error ? e.message : 'Upload failed');
dda384a360e0atagen2026-10-25 10:27 UTC247
          }
dda384a360e0atagen2026-10-25 10:27 UTC248
        }
dda384a360e0atagen2026-10-25 10:27 UTC249
      }
7ebc2472a9e3atagen2026-10-25 09:53 UTC250
    } catch (e) {
7ebc2472a9e3atagen2026-10-25 09:53 UTC251
      pushBanner(e instanceof Error ? e.message : 'Sending to the thread failed');
7ebc2472a9e3atagen2026-10-25 09:53 UTC252
    } finally {
dda384a360e0atagen2026-10-25 10:27 UTC253
      releaseStaged(files);
dda384a360e0atagen2026-10-25 10:27 UTC254
      uploadName = null;
7ebc2472a9e3atagen2026-10-25 09:53 UTC255
      sending = false;
7ebc2472a9e3atagen2026-10-25 09:53 UTC256
    }
7ebc2472a9e3atagen2026-10-25 09:53 UTC257
  }
7ebc2472a9e3atagen2026-10-25 09:53 UTC258
</script>
7ebc2472a9e3atagen2026-10-25 09:53 UTC259
7ebc2472a9e3atagen2026-10-25 09:53 UTC260
<svelte:window on:focus={sendReceipt} />
7ebc2472a9e3atagen2026-10-25 09:53 UTC261
<svelte:document on:visibilitychange={sendReceipt} />
7ebc2472a9e3atagen2026-10-25 09:53 UTC262
dda384a360e0atagen2026-10-25 10:27 UTC263
<div class="thread-pane">
7ebc2472a9e3atagen2026-10-25 09:53 UTC264
  <header class="thread-head">
7ebc2472a9e3atagen2026-10-25 09:53 UTC265
    <h2>Thread</h2>
7ebc2472a9e3atagen2026-10-25 09:53 UTC266
    <span class="thread-room"><EmojiText text={roomName} /></span>
7ebc2472a9e3atagen2026-10-25 09:53 UTC267
    <span class="spacer"></span>
7ebc2472a9e3atagen2026-10-25 09:53 UTC268
    <button
7ebc2472a9e3atagen2026-10-25 09:53 UTC269
      type="button"
7ebc2472a9e3atagen2026-10-25 09:53 UTC270
      class="icon-btn"
7ebc2472a9e3atagen2026-10-25 09:53 UTC271
      class:active={pinned}
7ebc2472a9e3atagen2026-10-25 09:53 UTC272
      aria-pressed={pinned}
7ebc2472a9e3atagen2026-10-25 09:53 UTC273
      aria-label={pinned ? 'Unpin thread' : 'Pin thread'}
7ebc2472a9e3atagen2026-10-25 09:53 UTC274
      title={pinned ? 'Unpin thread' : 'Pin thread'}
7ebc2472a9e3atagen2026-10-25 09:53 UTC275
      on:click={() => void togglePinnedThread(roomId, rootEventId)}
7ebc2472a9e3atagen2026-10-25 09:53 UTC276
    >
7ebc2472a9e3atagen2026-10-25 09:53 UTC277
      <svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><path d="M9 3h6l-1 6 4 4v2h-5v6l-1 1-1-1v-6H6v-2l4-4z" fill={pinned ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" /></svg>
7ebc2472a9e3atagen2026-10-25 09:53 UTC278
    </button>
7ebc2472a9e3atagen2026-10-25 09:53 UTC279
    <button type="button" class="icon-btn" aria-label="Close thread" on:click={onClose}>
7ebc2472a9e3atagen2026-10-25 09:53 UTC280
      <span aria-hidden="true">✕</span>
7ebc2472a9e3atagen2026-10-25 09:53 UTC281
    </button>
7ebc2472a9e3atagen2026-10-25 09:53 UTC282
  </header>
7ebc2472a9e3atagen2026-10-25 09:53 UTC283
  <div class="thread-list" bind:this={list} on:scroll={onScroll}>
7ebc2472a9e3atagen2026-10-25 09:53 UTC284
    {#if !hasStart && events.length > 1}
7ebc2472a9e3atagen2026-10-25 09:53 UTC285
      <button type="button" class="ghost-btn older" disabled={paginating} on:click={() => void paginate()}>
7ebc2472a9e3atagen2026-10-25 09:53 UTC286
        {paginating ? 'Loading…' : 'Load older replies'}
7ebc2472a9e3atagen2026-10-25 09:53 UTC287
      </button>
7ebc2472a9e3atagen2026-10-25 09:53 UTC288
    {/if}
7ebc2472a9e3atagen2026-10-25 09:53 UTC289
    {#if events.length === 0}
7ebc2472a9e3atagen2026-10-25 09:53 UTC290
      <p class="side-note">Loading thread…</p>
7ebc2472a9e3atagen2026-10-25 09:53 UTC291
    {/if}
7ebc2472a9e3atagen2026-10-25 09:53 UTC292
    {#each events as event, i (event.item_id || event.event_id)}
7ebc2472a9e3atagen2026-10-25 09:53 UTC293
      <div class:root={event.event_id === rootEventId}>
7ebc2472a9e3atagen2026-10-25 09:53 UTC294
        <MessageRow
7ebc2472a9e3atagen2026-10-25 09:53 UTC295
          itemId={`thread-${event.item_id || event.event_id}`}
7ebc2472a9e3atagen2026-10-25 09:53 UTC296
          {event}
7ebc2472a9e3atagen2026-10-25 09:53 UTC297
          showSender={showSender(i) || event.event_id === rootEventId}
7ebc2472a9e3atagen2026-10-25 09:53 UTC298
          senderAvatar={senderAvatarFor(event.sender.user_id) ?? event.sender.avatar}
7ebc2472a9e3atagen2026-10-25 09:53 UTC299
          {reacting}
7ebc2472a9e3atagen2026-10-25 09:53 UTC300
          onReact={(eventId, emoji, shortcode) => void react(eventId, emoji, shortcode)}
7ebc2472a9e3atagen2026-10-25 09:53 UTC301
          {quickReactions}
7ebc2472a9e3atagen2026-10-25 09:53 UTC302
          {packs}
7ebc2472a9e3atagen2026-10-25 09:53 UTC303
          {linkPreviews}
7ebc2472a9e3atagen2026-10-25 09:53 UTC304
          {loadPreview}
7ebc2472a9e3atagen2026-10-25 09:53 UTC305
          {onQuickChange}
7ebc2472a9e3atagen2026-10-25 09:53 UTC306
          {people}
7ebc2472a9e3atagen2026-10-25 09:53 UTC307
          {self}
7ebc2472a9e3atagen2026-10-25 09:53 UTC308
          {onUser}
7ebc2472a9e3atagen2026-10-25 09:53 UTC309
          {onMore}
7ebc2472a9e3atagen2026-10-25 09:53 UTC310
          onReply={joined ? startReply : null}
7ebc2472a9e3atagen2026-10-25 09:53 UTC311
          onEdit={joined ? startEdit : null}
7ebc2472a9e3atagen2026-10-25 09:53 UTC312
          editing={editing?.eventId === event.event_id}
7ebc2472a9e3atagen2026-10-25 09:53 UTC313
          showSent={false}
7ebc2472a9e3atagen2026-10-25 09:53 UTC314
        />
7ebc2472a9e3atagen2026-10-25 09:53 UTC315
      </div>
7ebc2472a9e3atagen2026-10-25 09:53 UTC316
    {/each}
7ebc2472a9e3atagen2026-10-25 09:53 UTC317
  </div>
7ebc2472a9e3atagen2026-10-25 09:53 UTC318
  {#if joined}
dda384a360e0atagen2026-10-25 10:27 UTC319
    <div class="thread-composer">
dda384a360e0atagen2026-10-25 10:27 UTC320
      <Composer
dda384a360e0atagen2026-10-25 10:27 UTC321
        bind:this={composerRef}
dda384a360e0atagen2026-10-25 10:27 UTC322
        bind:value={draft}
dda384a360e0atagen2026-10-25 10:27 UTC323
        inputId={`thread-composer-${rootEventId}`}
dda384a360e0atagen2026-10-25 10:27 UTC324
        label="Reply in thread"
dda384a360e0atagen2026-10-25 10:27 UTC325
        placeholder="Reply in thread"
dda384a360e0atagen2026-10-25 10:27 UTC326
        {picked}
dda384a360e0atagen2026-10-25 10:27 UTC327
        onPick={(mention) => (picked = [...picked, mention])}
dda384a360e0atagen2026-10-25 10:27 UTC328
        {staged}
dda384a360e0atagen2026-10-25 10:27 UTC329
        onStage={stage}
dda384a360e0atagen2026-10-25 10:27 UTC330
        onUnstage={unstage}
dda384a360e0atagen2026-10-25 10:27 UTC331
        uploadBusy={uploadName !== null}
dda384a360e0atagen2026-10-25 10:27 UTC332
        {uploadName}
dda384a360e0atagen2026-10-25 10:27 UTC333
        editing={editing ? { original: editing.original } : null}
dda384a360e0atagen2026-10-25 10:27 UTC334
        onCancelEdit={cancelEdit}
dda384a360e0atagen2026-10-25 10:27 UTC335
        {replyingTo}
dda384a360e0atagen2026-10-25 10:27 UTC336
        onCancelReply={() => (replyingTo = null)}
dda384a360e0atagen2026-10-25 10:27 UTC337
        {people}
dda384a360e0atagen2026-10-25 10:27 UTC338
        {self}
dda384a360e0atagen2026-10-25 10:27 UTC339
        {emotes}
dda384a360e0atagen2026-10-25 10:27 UTC340
        {packs}
dda384a360e0atagen2026-10-25 10:27 UTC341
        onSubmit={() => void submit()}
dda384a360e0atagen2026-10-25 10:27 UTC342
        onEditLast={editLastOwn}
dda384a360e0atagen2026-10-25 10:27 UTC343
        {onMentionStart}
dda384a360e0atagen2026-10-25 10:27 UTC344
      />
dda384a360e0atagen2026-10-25 10:27 UTC345
    </div>
7ebc2472a9e3atagen2026-10-25 09:53 UTC346
  {/if}
dda384a360e0atagen2026-10-25 10:27 UTC347
</div>
7ebc2472a9e3atagen2026-10-25 09:53 UTC348
7ebc2472a9e3atagen2026-10-25 09:53 UTC349
<style>
dda384a360e0atagen2026-10-25 10:27 UTC350
  .thread-pane { display: flex; flex-direction: column; min-width: 0; min-height: 0; height: 100%; animation: pop-in var(--pop); }
7ebc2472a9e3atagen2026-10-25 09:53 UTC351
  .thread-head { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-bottom: 1px solid var(--border); }
7ebc2472a9e3atagen2026-10-25 09:53 UTC352
  .thread-head h2 { margin: 0; font-size: 14px; font-weight: 650; }
7ebc2472a9e3atagen2026-10-25 09:53 UTC353
  .thread-room { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
7ebc2472a9e3atagen2026-10-25 09:53 UTC354
  .spacer { flex: 1; }
7ebc2472a9e3atagen2026-10-25 09:53 UTC355
  .icon-btn.active { color: var(--accent); }
7ebc2472a9e3atagen2026-10-25 09:53 UTC356
  .thread-list { flex: 1; min-height: 0; overflow-y: auto; padding: 8px 10px; }
7ebc2472a9e3atagen2026-10-25 09:53 UTC357
  .older { display: block; margin: 0 auto 8px; }
7ebc2472a9e3atagen2026-10-25 09:53 UTC358
  .root { padding-bottom: 6px; margin-bottom: 6px; border-bottom: 1px solid var(--border); }
dda384a360e0atagen2026-10-25 10:27 UTC359
  .thread-composer { flex: none; }
7ebc2472a9e3atagen2026-10-25 09:53 UTC360
</style>