atagen

malice

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