atagen

malice

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

Blame: web/src/lib/ThreadPane.svelte

7ebc2472a9e3atagen2026-10-25 09:53 UTC1
<script lang="ts">
7ebc2472a9e3atagen2026-10-25 09:53 UTC2
  import { tick } from 'svelte';
7ebc2472a9e3atagen2026-10-25 09:53 UTC3
  import MessageRow from './MessageRow.svelte';
7ebc2472a9e3atagen2026-10-25 09:53 UTC4
  import EmojiText from './EmojiText.svelte';
7ebc2472a9e3atagen2026-10-25 09:53 UTC5
  import { editMessage, markRead, paginateRoom, reactToMessage, sendMessage } from './api';
7ebc2472a9e3atagen2026-10-25 09:53 UTC6
  import { pushBanner, threadKey, threadTimelines, timelines } from './store';
7ebc2472a9e3atagen2026-10-25 09:53 UTC7
  import { pinnedThreads, togglePinnedThread } from './threads';
7ebc2472a9e3atagen2026-10-25 09:53 UTC8
  import type { ImagePack, LinkPreview, Person, QuickReaction, ReplyReference, TimelineEvent, TimelineItem } from './types';
7ebc2472a9e3atagen2026-10-25 09:53 UTC9
7ebc2472a9e3atagen2026-10-25 09:53 UTC10
  export let roomId: string;
7ebc2472a9e3atagen2026-10-25 09:53 UTC11
  export let rootEventId: string;
7ebc2472a9e3atagen2026-10-25 09:53 UTC12
  export let roomName: string;
7ebc2472a9e3atagen2026-10-25 09:53 UTC13
  export let joined: boolean;
7ebc2472a9e3atagen2026-10-25 09:53 UTC14
  export let self: string | null;
7ebc2472a9e3atagen2026-10-25 09:53 UTC15
  export let people: Record<string, Person> = {};
7ebc2472a9e3atagen2026-10-25 09:53 UTC16
  export let senderAvatarFor: (userId: string) => string | null = () => null;
7ebc2472a9e3atagen2026-10-25 09:53 UTC17
  export let quickReactions: QuickReaction[] = [];
7ebc2472a9e3atagen2026-10-25 09:53 UTC18
  export let packs: ImagePack[] = [];
7ebc2472a9e3atagen2026-10-25 09:53 UTC19
  export let linkPreviews: Record<string, LinkPreview | null> = {};
7ebc2472a9e3atagen2026-10-25 09:53 UTC20
  export let loadPreview: ((url: string) => void) | null = null;
7ebc2472a9e3atagen2026-10-25 09:53 UTC21
  export let onQuickChange: (list: QuickReaction[]) => void = () => {};
7ebc2472a9e3atagen2026-10-25 09:53 UTC22
  export let onUser: ((userId: string, x: number, y: number, menu: boolean) => void) | null = null;
7ebc2472a9e3atagen2026-10-25 09:53 UTC23
  export let onMore: ((event: TimelineEvent, x: number, y: number) => void) | null = null;
7ebc2472a9e3atagen2026-10-25 09:53 UTC24
  export let onClose: () => void;
7ebc2472a9e3atagen2026-10-25 09:53 UTC25
7ebc2472a9e3atagen2026-10-25 09:53 UTC26
  const GROUP_GAP_MS = 5 * 60 * 1000;
7ebc2472a9e3atagen2026-10-25 09:53 UTC27
7ebc2472a9e3atagen2026-10-25 09:53 UTC28
  let list: HTMLDivElement | null = null;
7ebc2472a9e3atagen2026-10-25 09:53 UTC29
  let composerEl: HTMLTextAreaElement | null = null;
7ebc2472a9e3atagen2026-10-25 09:53 UTC30
  let draft = '';
7ebc2472a9e3atagen2026-10-25 09:53 UTC31
  let replyingTo: ReplyReference | null = null;
7ebc2472a9e3atagen2026-10-25 09:53 UTC32
  let editing: { eventId: string } | null = null;
7ebc2472a9e3atagen2026-10-25 09:53 UTC33
  let sending = false;
7ebc2472a9e3atagen2026-10-25 09:53 UTC34
  let paginating = false;
7ebc2472a9e3atagen2026-10-25 09:53 UTC35
  let reacting = false;
7ebc2472a9e3atagen2026-10-25 09:53 UTC36
  let stickToBottom = true;
7ebc2472a9e3atagen2026-10-25 09:53 UTC37
  let lastRead = '';
7ebc2472a9e3atagen2026-10-25 09:53 UTC38
7ebc2472a9e3atagen2026-10-25 09:53 UTC39
  $: key = threadKey(roomId, rootEventId);
7ebc2472a9e3atagen2026-10-25 09:53 UTC40
  $: items = $threadTimelines[key] ?? [];
7ebc2472a9e3atagen2026-10-25 09:53 UTC41
  $: roomRoot = ($timelines[roomId] ?? []).find((item) => item.kind === 'event' && item.event.event_id === rootEventId) ?? null;
7ebc2472a9e3atagen2026-10-25 09:53 UTC42
  $: events = threadEvents(items, roomRoot);
7ebc2472a9e3atagen2026-10-25 09:53 UTC43
  $: hasStart = items.some((item) => item.kind === 'start');
7ebc2472a9e3atagen2026-10-25 09:53 UTC44
  $: pinned = ($pinnedThreads[roomId] ?? []).includes(rootEventId);
7ebc2472a9e3atagen2026-10-25 09:53 UTC45
  $: if (key) reset();
7ebc2472a9e3atagen2026-10-25 09:53 UTC46
  $: void follow(events);
7ebc2472a9e3atagen2026-10-25 09:53 UTC47
7ebc2472a9e3atagen2026-10-25 09:53 UTC48
  function threadEvents(list: TimelineItem[], root: TimelineItem | null): TimelineEvent[] {
7ebc2472a9e3atagen2026-10-25 09:53 UTC49
    const out = list.flatMap((item) => (item.kind === 'event' ? [item.event] : []));
7ebc2472a9e3atagen2026-10-25 09:53 UTC50
    if (root?.kind === 'event' && !out.some((event) => event.event_id === rootEventId)) out.unshift(root.event);
7ebc2472a9e3atagen2026-10-25 09:53 UTC51
    return out;
7ebc2472a9e3atagen2026-10-25 09:53 UTC52
  }
7ebc2472a9e3atagen2026-10-25 09:53 UTC53
7ebc2472a9e3atagen2026-10-25 09:53 UTC54
  function showSender(index: number): boolean {
7ebc2472a9e3atagen2026-10-25 09:53 UTC55
    if (index === 0) return true;
7ebc2472a9e3atagen2026-10-25 09:53 UTC56
    const prev = events[index - 1];
7ebc2472a9e3atagen2026-10-25 09:53 UTC57
    const cur = events[index];
7ebc2472a9e3atagen2026-10-25 09:53 UTC58
    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 UTC59
  }
7ebc2472a9e3atagen2026-10-25 09:53 UTC60
7ebc2472a9e3atagen2026-10-25 09:53 UTC61
  function reset(): void {
7ebc2472a9e3atagen2026-10-25 09:53 UTC62
    draft = '';
7ebc2472a9e3atagen2026-10-25 09:53 UTC63
    replyingTo = null;
7ebc2472a9e3atagen2026-10-25 09:53 UTC64
    editing = null;
7ebc2472a9e3atagen2026-10-25 09:53 UTC65
    stickToBottom = true;
7ebc2472a9e3atagen2026-10-25 09:53 UTC66
    lastRead = '';
7ebc2472a9e3atagen2026-10-25 09:53 UTC67
  }
7ebc2472a9e3atagen2026-10-25 09:53 UTC68
7ebc2472a9e3atagen2026-10-25 09:53 UTC69
  async function follow(_events: TimelineEvent[]): Promise<void> {
7ebc2472a9e3atagen2026-10-25 09:53 UTC70
    await tick();
7ebc2472a9e3atagen2026-10-25 09:53 UTC71
    if (list && stickToBottom) list.scrollTop = list.scrollHeight;
7ebc2472a9e3atagen2026-10-25 09:53 UTC72
    sendReceipt();
7ebc2472a9e3atagen2026-10-25 09:53 UTC73
  }
7ebc2472a9e3atagen2026-10-25 09:53 UTC74
7ebc2472a9e3atagen2026-10-25 09:53 UTC75
  function sendReceipt(): void {
7ebc2472a9e3atagen2026-10-25 09:53 UTC76
    if (document.hidden || !document.hasFocus() || !stickToBottom) return;
7ebc2472a9e3atagen2026-10-25 09:53 UTC77
    const latest = [...events].reverse().find((event) => event.event_id);
7ebc2472a9e3atagen2026-10-25 09:53 UTC78
    if (!latest?.event_id || latest.event_id === lastRead) return;
7ebc2472a9e3atagen2026-10-25 09:53 UTC79
    lastRead = latest.event_id;
7ebc2472a9e3atagen2026-10-25 09:53 UTC80
    markRead(roomId, latest.event_id, rootEventId).catch(() => undefined);
7ebc2472a9e3atagen2026-10-25 09:53 UTC81
  }
7ebc2472a9e3atagen2026-10-25 09:53 UTC82
7ebc2472a9e3atagen2026-10-25 09:53 UTC83
  function onScroll(): void {
7ebc2472a9e3atagen2026-10-25 09:53 UTC84
    if (!list) return;
7ebc2472a9e3atagen2026-10-25 09:53 UTC85
    const wasAtBottom = stickToBottom;
7ebc2472a9e3atagen2026-10-25 09:53 UTC86
    stickToBottom = list.scrollHeight - list.scrollTop - list.clientHeight < 2;
7ebc2472a9e3atagen2026-10-25 09:53 UTC87
    if (stickToBottom && !wasAtBottom) sendReceipt();
7ebc2472a9e3atagen2026-10-25 09:53 UTC88
    if (list.scrollTop < 60 && !hasStart) void paginate();
7ebc2472a9e3atagen2026-10-25 09:53 UTC89
  }
7ebc2472a9e3atagen2026-10-25 09:53 UTC90
7ebc2472a9e3atagen2026-10-25 09:53 UTC91
  async function paginate(): Promise<void> {
7ebc2472a9e3atagen2026-10-25 09:53 UTC92
    if (paginating) return;
7ebc2472a9e3atagen2026-10-25 09:53 UTC93
    paginating = true;
7ebc2472a9e3atagen2026-10-25 09:53 UTC94
    try {
7ebc2472a9e3atagen2026-10-25 09:53 UTC95
      await paginateRoom(roomId, rootEventId);
7ebc2472a9e3atagen2026-10-25 09:53 UTC96
    } catch (e) {
7ebc2472a9e3atagen2026-10-25 09:53 UTC97
      pushBanner(e instanceof Error ? e.message : 'Loading older replies failed');
7ebc2472a9e3atagen2026-10-25 09:53 UTC98
    } finally {
7ebc2472a9e3atagen2026-10-25 09:53 UTC99
      paginating = false;
7ebc2472a9e3atagen2026-10-25 09:53 UTC100
    }
7ebc2472a9e3atagen2026-10-25 09:53 UTC101
  }
7ebc2472a9e3atagen2026-10-25 09:53 UTC102
7ebc2472a9e3atagen2026-10-25 09:53 UTC103
  async function react(eventId: string, emoji: string, shortcode?: string): Promise<void> {
7ebc2472a9e3atagen2026-10-25 09:53 UTC104
    if (reacting) return;
7ebc2472a9e3atagen2026-10-25 09:53 UTC105
    reacting = true;
7ebc2472a9e3atagen2026-10-25 09:53 UTC106
    try {
7ebc2472a9e3atagen2026-10-25 09:53 UTC107
      await reactToMessage(roomId, eventId, emoji, shortcode, rootEventId);
7ebc2472a9e3atagen2026-10-25 09:53 UTC108
    } catch (e) {
7ebc2472a9e3atagen2026-10-25 09:53 UTC109
      pushBanner(e instanceof Error ? e.message : 'Reaction failed');
7ebc2472a9e3atagen2026-10-25 09:53 UTC110
    } finally {
7ebc2472a9e3atagen2026-10-25 09:53 UTC111
      reacting = false;
7ebc2472a9e3atagen2026-10-25 09:53 UTC112
    }
7ebc2472a9e3atagen2026-10-25 09:53 UTC113
  }
7ebc2472a9e3atagen2026-10-25 09:53 UTC114
7ebc2472a9e3atagen2026-10-25 09:53 UTC115
  function startReply(target: ReplyReference): void {
7ebc2472a9e3atagen2026-10-25 09:53 UTC116
    editing = null;
7ebc2472a9e3atagen2026-10-25 09:53 UTC117
    replyingTo = target;
7ebc2472a9e3atagen2026-10-25 09:53 UTC118
    composerEl?.focus();
7ebc2472a9e3atagen2026-10-25 09:53 UTC119
  }
7ebc2472a9e3atagen2026-10-25 09:53 UTC120
7ebc2472a9e3atagen2026-10-25 09:53 UTC121
  function startEdit(eventId: string, body: string): void {
7ebc2472a9e3atagen2026-10-25 09:53 UTC122
    replyingTo = null;
7ebc2472a9e3atagen2026-10-25 09:53 UTC123
    editing = { eventId };
7ebc2472a9e3atagen2026-10-25 09:53 UTC124
    draft = body;
7ebc2472a9e3atagen2026-10-25 09:53 UTC125
    void tick().then(() => composerEl?.focus());
7ebc2472a9e3atagen2026-10-25 09:53 UTC126
  }
7ebc2472a9e3atagen2026-10-25 09:53 UTC127
7ebc2472a9e3atagen2026-10-25 09:53 UTC128
  async function submit(): Promise<void> {
7ebc2472a9e3atagen2026-10-25 09:53 UTC129
    const body = draft.trim();
7ebc2472a9e3atagen2026-10-25 09:53 UTC130
    if (!body || sending) return;
7ebc2472a9e3atagen2026-10-25 09:53 UTC131
    sending = true;
7ebc2472a9e3atagen2026-10-25 09:53 UTC132
    const reply = replyingTo;
7ebc2472a9e3atagen2026-10-25 09:53 UTC133
    const edit = editing;
7ebc2472a9e3atagen2026-10-25 09:53 UTC134
    try {
7ebc2472a9e3atagen2026-10-25 09:53 UTC135
      if (edit) await editMessage(roomId, edit.eventId, body, [], [], rootEventId);
7ebc2472a9e3atagen2026-10-25 09:53 UTC136
      else await sendMessage(roomId, body, reply?.event_id ?? null, [], 'text', [], rootEventId);
7ebc2472a9e3atagen2026-10-25 09:53 UTC137
      draft = '';
7ebc2472a9e3atagen2026-10-25 09:53 UTC138
      replyingTo = null;
7ebc2472a9e3atagen2026-10-25 09:53 UTC139
      editing = null;
7ebc2472a9e3atagen2026-10-25 09:53 UTC140
      stickToBottom = true;
7ebc2472a9e3atagen2026-10-25 09:53 UTC141
    } catch (e) {
7ebc2472a9e3atagen2026-10-25 09:53 UTC142
      pushBanner(e instanceof Error ? e.message : 'Sending to the thread failed');
7ebc2472a9e3atagen2026-10-25 09:53 UTC143
    } finally {
7ebc2472a9e3atagen2026-10-25 09:53 UTC144
      sending = false;
7ebc2472a9e3atagen2026-10-25 09:53 UTC145
    }
7ebc2472a9e3atagen2026-10-25 09:53 UTC146
  }
7ebc2472a9e3atagen2026-10-25 09:53 UTC147
7ebc2472a9e3atagen2026-10-25 09:53 UTC148
  function onKey(e: KeyboardEvent): void {
7ebc2472a9e3atagen2026-10-25 09:53 UTC149
    if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
7ebc2472a9e3atagen2026-10-25 09:53 UTC150
      e.preventDefault();
7ebc2472a9e3atagen2026-10-25 09:53 UTC151
      void submit();
7ebc2472a9e3atagen2026-10-25 09:53 UTC152
    } else if (e.key === 'Escape' && (editing || replyingTo)) {
7ebc2472a9e3atagen2026-10-25 09:53 UTC153
      e.preventDefault();
7ebc2472a9e3atagen2026-10-25 09:53 UTC154
      if (editing) draft = '';
7ebc2472a9e3atagen2026-10-25 09:53 UTC155
      editing = null;
7ebc2472a9e3atagen2026-10-25 09:53 UTC156
      replyingTo = null;
7ebc2472a9e3atagen2026-10-25 09:53 UTC157
    }
7ebc2472a9e3atagen2026-10-25 09:53 UTC158
  }
7ebc2472a9e3atagen2026-10-25 09:53 UTC159
7ebc2472a9e3atagen2026-10-25 09:53 UTC160
  function grow(node: HTMLTextAreaElement, _value: string) {
7ebc2472a9e3atagen2026-10-25 09:53 UTC161
    const fit = () => {
7ebc2472a9e3atagen2026-10-25 09:53 UTC162
      node.style.height = 'auto';
7ebc2472a9e3atagen2026-10-25 09:53 UTC163
      node.style.height = `${Math.min(node.scrollHeight, 200)}px`;
7ebc2472a9e3atagen2026-10-25 09:53 UTC164
    };
7ebc2472a9e3atagen2026-10-25 09:53 UTC165
    fit();
7ebc2472a9e3atagen2026-10-25 09:53 UTC166
    return { update: fit };
7ebc2472a9e3atagen2026-10-25 09:53 UTC167
  }
7ebc2472a9e3atagen2026-10-25 09:53 UTC168
</script>
7ebc2472a9e3atagen2026-10-25 09:53 UTC169
7ebc2472a9e3atagen2026-10-25 09:53 UTC170
<svelte:window on:focus={sendReceipt} />
7ebc2472a9e3atagen2026-10-25 09:53 UTC171
<svelte:document on:visibilitychange={sendReceipt} />
7ebc2472a9e3atagen2026-10-25 09:53 UTC172
7ebc2472a9e3atagen2026-10-25 09:53 UTC173
<aside class="thread-pane" aria-label={`Thread in ${roomName}`}>
7ebc2472a9e3atagen2026-10-25 09:53 UTC174
  <header class="thread-head">
7ebc2472a9e3atagen2026-10-25 09:53 UTC175
    <h2>Thread</h2>
7ebc2472a9e3atagen2026-10-25 09:53 UTC176
    <span class="thread-room"><EmojiText text={roomName} /></span>
7ebc2472a9e3atagen2026-10-25 09:53 UTC177
    <span class="spacer"></span>
7ebc2472a9e3atagen2026-10-25 09:53 UTC178
    <button
7ebc2472a9e3atagen2026-10-25 09:53 UTC179
      type="button"
7ebc2472a9e3atagen2026-10-25 09:53 UTC180
      class="icon-btn"
7ebc2472a9e3atagen2026-10-25 09:53 UTC181
      class:active={pinned}
7ebc2472a9e3atagen2026-10-25 09:53 UTC182
      aria-pressed={pinned}
7ebc2472a9e3atagen2026-10-25 09:53 UTC183
      aria-label={pinned ? 'Unpin thread' : 'Pin thread'}
7ebc2472a9e3atagen2026-10-25 09:53 UTC184
      title={pinned ? 'Unpin thread' : 'Pin thread'}
7ebc2472a9e3atagen2026-10-25 09:53 UTC185
      on:click={() => void togglePinnedThread(roomId, rootEventId)}
7ebc2472a9e3atagen2026-10-25 09:53 UTC186
    >
7ebc2472a9e3atagen2026-10-25 09:53 UTC187
      <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 UTC188
    </button>
7ebc2472a9e3atagen2026-10-25 09:53 UTC189
    <button type="button" class="icon-btn" aria-label="Close thread" on:click={onClose}>
7ebc2472a9e3atagen2026-10-25 09:53 UTC190
      <span aria-hidden="true">✕</span>
7ebc2472a9e3atagen2026-10-25 09:53 UTC191
    </button>
7ebc2472a9e3atagen2026-10-25 09:53 UTC192
  </header>
7ebc2472a9e3atagen2026-10-25 09:53 UTC193
  <div class="thread-list" bind:this={list} on:scroll={onScroll}>
7ebc2472a9e3atagen2026-10-25 09:53 UTC194
    {#if !hasStart && events.length > 1}
7ebc2472a9e3atagen2026-10-25 09:53 UTC195
      <button type="button" class="ghost-btn older" disabled={paginating} on:click={() => void paginate()}>
7ebc2472a9e3atagen2026-10-25 09:53 UTC196
        {paginating ? 'Loading…' : 'Load older replies'}
7ebc2472a9e3atagen2026-10-25 09:53 UTC197
      </button>
7ebc2472a9e3atagen2026-10-25 09:53 UTC198
    {/if}
7ebc2472a9e3atagen2026-10-25 09:53 UTC199
    {#if events.length === 0}
7ebc2472a9e3atagen2026-10-25 09:53 UTC200
      <p class="side-note">Loading thread…</p>
7ebc2472a9e3atagen2026-10-25 09:53 UTC201
    {/if}
7ebc2472a9e3atagen2026-10-25 09:53 UTC202
    {#each events as event, i (event.item_id || event.event_id)}
7ebc2472a9e3atagen2026-10-25 09:53 UTC203
      <div class:root={event.event_id === rootEventId}>
7ebc2472a9e3atagen2026-10-25 09:53 UTC204
        <MessageRow
7ebc2472a9e3atagen2026-10-25 09:53 UTC205
          itemId={`thread-${event.item_id || event.event_id}`}
7ebc2472a9e3atagen2026-10-25 09:53 UTC206
          {event}
7ebc2472a9e3atagen2026-10-25 09:53 UTC207
          showSender={showSender(i) || event.event_id === rootEventId}
7ebc2472a9e3atagen2026-10-25 09:53 UTC208
          senderAvatar={senderAvatarFor(event.sender.user_id) ?? event.sender.avatar}
7ebc2472a9e3atagen2026-10-25 09:53 UTC209
          {reacting}
7ebc2472a9e3atagen2026-10-25 09:53 UTC210
          onReact={(eventId, emoji, shortcode) => void react(eventId, emoji, shortcode)}
7ebc2472a9e3atagen2026-10-25 09:53 UTC211
          {quickReactions}
7ebc2472a9e3atagen2026-10-25 09:53 UTC212
          {packs}
7ebc2472a9e3atagen2026-10-25 09:53 UTC213
          {linkPreviews}
7ebc2472a9e3atagen2026-10-25 09:53 UTC214
          {loadPreview}
7ebc2472a9e3atagen2026-10-25 09:53 UTC215
          {onQuickChange}
7ebc2472a9e3atagen2026-10-25 09:53 UTC216
          {people}
7ebc2472a9e3atagen2026-10-25 09:53 UTC217
          {self}
7ebc2472a9e3atagen2026-10-25 09:53 UTC218
          {onUser}
7ebc2472a9e3atagen2026-10-25 09:53 UTC219
          {onMore}
7ebc2472a9e3atagen2026-10-25 09:53 UTC220
          onReply={joined ? startReply : null}
7ebc2472a9e3atagen2026-10-25 09:53 UTC221
          onEdit={joined ? startEdit : null}
7ebc2472a9e3atagen2026-10-25 09:53 UTC222
          editing={editing?.eventId === event.event_id}
7ebc2472a9e3atagen2026-10-25 09:53 UTC223
          showSent={false}
7ebc2472a9e3atagen2026-10-25 09:53 UTC224
        />
7ebc2472a9e3atagen2026-10-25 09:53 UTC225
      </div>
7ebc2472a9e3atagen2026-10-25 09:53 UTC226
    {/each}
7ebc2472a9e3atagen2026-10-25 09:53 UTC227
  </div>
7ebc2472a9e3atagen2026-10-25 09:53 UTC228
  {#if joined}
7ebc2472a9e3atagen2026-10-25 09:53 UTC229
    <form class="thread-composer" on:submit|preventDefault={() => void submit()}>
7ebc2472a9e3atagen2026-10-25 09:53 UTC230
      {#if replyingTo || editing}
7ebc2472a9e3atagen2026-10-25 09:53 UTC231
        <div class="thread-context">
7ebc2472a9e3atagen2026-10-25 09:53 UTC232
          <span>{editing ? 'Editing' : 'Replying'}</span>
7ebc2472a9e3atagen2026-10-25 09:53 UTC233
          <button type="button" class="icon-btn" aria-label="Cancel" on:click={() => { if (editing) draft = ''; editing = null; replyingTo = null; }}>
7ebc2472a9e3atagen2026-10-25 09:53 UTC234
            <span aria-hidden="true">✕</span>
7ebc2472a9e3atagen2026-10-25 09:53 UTC235
          </button>
7ebc2472a9e3atagen2026-10-25 09:53 UTC236
        </div>
7ebc2472a9e3atagen2026-10-25 09:53 UTC237
      {/if}
7ebc2472a9e3atagen2026-10-25 09:53 UTC238
      <div class="thread-input">
7ebc2472a9e3atagen2026-10-25 09:53 UTC239
        <textarea
7ebc2472a9e3atagen2026-10-25 09:53 UTC240
          bind:this={composerEl}
7ebc2472a9e3atagen2026-10-25 09:53 UTC241
          bind:value={draft}
7ebc2472a9e3atagen2026-10-25 09:53 UTC242
          use:grow={draft}
7ebc2472a9e3atagen2026-10-25 09:53 UTC243
          rows="1"
7ebc2472a9e3atagen2026-10-25 09:53 UTC244
          placeholder="Reply in thread"
7ebc2472a9e3atagen2026-10-25 09:53 UTC245
          aria-label="Reply in thread"
7ebc2472a9e3atagen2026-10-25 09:53 UTC246
          on:keydown={onKey}
7ebc2472a9e3atagen2026-10-25 09:53 UTC247
        ></textarea>
7ebc2472a9e3atagen2026-10-25 09:53 UTC248
        <button type="submit" class="primary-btn" disabled={sending || !draft.trim()}>{editing ? 'Save' : 'Send'}</button>
7ebc2472a9e3atagen2026-10-25 09:53 UTC249
      </div>
7ebc2472a9e3atagen2026-10-25 09:53 UTC250
    </form>
7ebc2472a9e3atagen2026-10-25 09:53 UTC251
  {/if}
7ebc2472a9e3atagen2026-10-25 09:53 UTC252
</aside>
7ebc2472a9e3atagen2026-10-25 09:53 UTC253
7ebc2472a9e3atagen2026-10-25 09:53 UTC254
<style>
7ebc2472a9e3atagen2026-10-25 09:53 UTC255
  .thread-pane { display: flex; flex-direction: column; min-width: 0; min-height: 0; height: 100%; background: var(--bg-elev, var(--bg)); border-left: 1px solid var(--border); animation: pop-in var(--pop); }
7ebc2472a9e3atagen2026-10-25 09:53 UTC256
  .thread-head { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-bottom: 1px solid var(--border); }
7ebc2472a9e3atagen2026-10-25 09:53 UTC257
  .thread-head h2 { margin: 0; font-size: 14px; font-weight: 650; }
7ebc2472a9e3atagen2026-10-25 09:53 UTC258
  .thread-room { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
7ebc2472a9e3atagen2026-10-25 09:53 UTC259
  .spacer { flex: 1; }
7ebc2472a9e3atagen2026-10-25 09:53 UTC260
  .icon-btn.active { color: var(--accent); }
7ebc2472a9e3atagen2026-10-25 09:53 UTC261
  .thread-list { flex: 1; min-height: 0; overflow-y: auto; padding: 8px 10px; }
7ebc2472a9e3atagen2026-10-25 09:53 UTC262
  .older { display: block; margin: 0 auto 8px; }
7ebc2472a9e3atagen2026-10-25 09:53 UTC263
  .root { padding-bottom: 6px; margin-bottom: 6px; border-bottom: 1px solid var(--border); }
7ebc2472a9e3atagen2026-10-25 09:53 UTC264
  .thread-composer { border-top: 1px solid var(--border); padding: 8px 10px; display: flex; flex-direction: column; gap: 6px; }
7ebc2472a9e3atagen2026-10-25 09:53 UTC265
  .thread-context { display: flex; align-items: center; justify-content: space-between; font-size: 12px; color: var(--muted); }
7ebc2472a9e3atagen2026-10-25 09:53 UTC266
  .thread-input { display: flex; align-items: flex-end; gap: 8px; }
7ebc2472a9e3atagen2026-10-25 09:53 UTC267
  .thread-input textarea { flex: 1; min-width: 0; resize: none; background: var(--bg-soft); border: 1px solid var(--border); border-radius: var(--r-sm); color: var(--text); padding: 7px 9px; line-height: 1.4; }
7ebc2472a9e3atagen2026-10-25 09:53 UTC268
  .thread-input textarea:focus { outline: none; border-color: var(--accent); }
7ebc2472a9e3atagen2026-10-25 09:53 UTC269
</style>