atagen

malice

public
Star0
Watch0
1b8216b21783de0a0678016d39e3812915ee62f2
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
web/src/lib/ThreadPane.svelteSvelte380 lines13.9 KB
1
<script lang="ts" context="module">
2
  import { sessionUser } from './store';
3
  import { releaseStaged as release } from './staging';
4
  import type { Mention } from './mentions';
5
  import type { StagedAttachment, StickerHandlers } from './staging';
6
7
  interface ThreadDraft {
8
    text: string;
9
    picked: Mention[];
10
    staged: StagedAttachment[];
11
  }
12
13
  const drafts = new Map<string, ThreadDraft>();
14
  let draftAccount: string | null | undefined;
15
  sessionUser.subscribe((user) => {
16
    if (user === draftAccount) return;
17
    draftAccount = user;
18
    for (const saved of drafts.values()) release(saved.staged);
19
    drafts.clear();
20
  });
21
</script>
22
23
<script lang="ts">
24
  import { tick } from 'svelte';
25
  import MessageRow from './MessageRow.svelte';
26
  import EmojiText from './EmojiText.svelte';
27
  import Composer from './Composer.svelte';
28
  import { editMessage, markRead, paginateRoom, reactToMessage, sendMessage, uploadAttachment } from './api';
29
  import { parseCommand, unescapeCommand } from './commands';
30
  import { customEmotes, resolveEmotes } from './emotes';
31
  import { mentionsFromHtml, resolveMentions } from './mentions';
32
  import { releaseStaged, stageFiles } from './staging';
33
  import { pushBanner, threadKey, threadTimelines, timelines } from './store';
34
  import { pinnedThreads, togglePinnedThread } from './threads';
35
  import type { ImagePack, LinkPreview, Person, QuickEntry, ReplyReference, TimelineEvent, TimelineItem } from './types';
36
37
  export let roomId: string;
38
  export let rootEventId: string;
39
  export let roomName: string;
40
  export let joined: boolean;
41
  export let self: string | null;
42
  export let people: Record<string, Person> = {};
43
  export let senderAvatarFor: (userId: string) => string | null = () => null;
44
  export let quickReactions: QuickEntry[] = [];
45
  export let packs: ImagePack[] = [];
46
  export let linkPreviews: Record<string, LinkPreview | null> = {};
47
  export let loadPreview: ((url: string) => void) | null = null;
48
  export let onQuickChange: (list: QuickEntry[]) => void = () => {};
49
  export let onUser: ((userId: string, x: number, y: number, menu: boolean) => void) | null = null;
50
  export let onMore: ((event: TimelineEvent, x: number, y: number) => void) | null = null;
51
  export let onMentionStart: () => void = () => {};
52
  export let stickers: StickerHandlers | null = null;
53
  export let focusEventId: string | null = null;
54
  export let onClose: () => void;
55
56
  const GROUP_GAP_MS = 5 * 60 * 1000;
57
58
  let list: HTMLDivElement | null = null;
59
  let thread: HTMLDivElement | null = null;
60
  let composerRef: Composer | null = null;
61
  let draft = '';
62
  let picked: Mention[] = [];
63
  let staged: StagedAttachment[] = [];
64
  let replyingTo: ReplyReference | null = null;
65
  let editing: { eventId: string; original: string; stash: string } | null = null;
66
  let sending = false;
67
  let uploadName: string | null = null;
68
  let loadedKey = '';
69
  let paginating = false;
70
  let reacting = false;
71
  let stickToBottom = true;
72
  let lastRead = '';
73
74
  $: key = threadKey(roomId, rootEventId);
75
  $: items = $threadTimelines[key] ?? [];
76
  $: roomRoot = ($timelines[roomId] ?? []).find((item) => item.kind === 'event' && item.event.event_id === rootEventId) ?? null;
77
  $: events = threadEvents(items, roomRoot);
78
  $: hasStart = items.some((item) => item.kind === 'start' || (item.kind === 'event' && item.event.event_id === rootEventId));
79
  $: pinned = ($pinnedThreads[roomId] ?? []).includes(rootEventId);
80
  $: emotes = customEmotes(packs);
81
  $: switchTo(key);
82
  $: void follow(events);
83
  let focused: string | null = null;
84
  $: void focusOn(focusEventId, events);
85
86
  function threadEvents(list: TimelineItem[], root: TimelineItem | null): TimelineEvent[] {
87
    const out = list.flatMap((item) => (item.kind === 'event' ? [item.event] : []));
88
    if (root?.kind === 'event' && !out.some((event) => event.event_id === rootEventId)) out.unshift(root.event);
89
    return out;
90
  }
91
92
  function showSender(index: number): boolean {
93
    if (index === 0) return true;
94
    const prev = events[index - 1];
95
    const cur = events[index];
96
    return prev.sender.user_id !== cur.sender.user_id || Math.abs(cur.timestamp_ms - prev.timestamp_ms) > GROUP_GAP_MS;
97
  }
98
99
  function switchTo(next: string): void {
100
    if (next === loadedKey) return;
101
    if (loadedKey) drafts.set(loadedKey, { text: editing ? editing.stash : draft, picked, staged });
102
    loadedKey = next;
103
    const saved = drafts.get(next);
104
    draft = saved?.text ?? '';
105
    picked = saved?.picked ?? [];
106
    staged = saved?.staged ?? [];
107
    replyingTo = null;
108
    editing = null;
109
    stickToBottom = true;
110
    lastRead = '';
111
    focused = null;
112
  }
113
114
  async function focusOn(target: string | null, list: TimelineEvent[]): Promise<void> {
115
    if (!target || target === focused || !list.some((event) => event.event_id === target)) return;
116
    focused = target;
117
    stickToBottom = false;
118
    await tick();
119
    const row = list.length && thread ? thread.querySelector<HTMLElement>(`[data-event="${CSS.escape(target)}"]`) : null;
120
    if (!row) return;
121
    row.scrollIntoView({ block: 'center', behavior: 'smooth' });
122
    row.classList.remove('flash');
123
    void row.offsetWidth;
124
    row.classList.add('flash');
125
  }
126
127
  async function follow(_events: TimelineEvent[]): Promise<void> {
128
    await tick();
129
    if (list && stickToBottom) list.scrollTop = list.scrollHeight;
130
    sendReceipt();
131
  }
132
133
  function sendReceipt(): void {
134
    if (document.hidden || !document.hasFocus() || !stickToBottom) return;
135
    const latest = [...events].reverse().find((event) => event.event_id);
136
    if (!latest?.event_id || latest.event_id === lastRead) return;
137
    lastRead = latest.event_id;
138
    markRead(roomId, latest.event_id, rootEventId).catch(() => undefined);
139
  }
140
141
  function onScroll(): void {
142
    if (!list) return;
143
    const wasAtBottom = stickToBottom;
144
    stickToBottom = list.scrollHeight - list.scrollTop - list.clientHeight < 2;
145
    if (stickToBottom && !wasAtBottom) sendReceipt();
146
    if (list.scrollTop < 60 && !hasStart) void paginate();
147
  }
148
149
  async function paginate(): Promise<void> {
150
    if (paginating) return;
151
    paginating = true;
152
    try {
153
      await paginateRoom(roomId, rootEventId);
154
    } catch (e) {
155
      pushBanner(e instanceof Error ? e.message : 'Loading older replies failed');
156
    } finally {
157
      paginating = false;
158
    }
159
  }
160
161
  async function react(eventId: string, emoji: string, shortcode?: string): Promise<void> {
162
    if (reacting) return;
163
    reacting = true;
164
    try {
165
      await reactToMessage(roomId, eventId, emoji, shortcode, rootEventId);
166
    } catch (e) {
167
      pushBanner(e instanceof Error ? e.message : 'Reaction failed');
168
    } finally {
169
      reacting = false;
170
    }
171
  }
172
173
  function startReply(target: ReplyReference): void {
174
    if (editing) cancelEdit();
175
    replyingTo = target;
176
    composerRef?.focus();
177
  }
178
179
  function startEdit(eventId: string, body: string): void {
180
    const stash = editing ? editing.stash : draft;
181
    replyingTo = null;
182
    editing = { eventId, original: body, stash };
183
    const target = events.find((event) => event.event_id === eventId);
184
    if (target?.content.kind === 'Message') picked = [...picked, ...mentionsFromHtml(target.content.message.formatted_body)];
185
    draft = body;
186
    void composerRef?.focusEnd();
187
  }
188
189
  function cancelEdit(): void {
190
    if (!editing) return;
191
    draft = editing.stash;
192
    editing = null;
193
    composerRef?.focus();
194
  }
195
196
  function editLastOwn(): boolean {
197
    for (let i = events.length - 1; i >= 0; i -= 1) {
198
      const event = events[i];
199
      if (!event.own) continue;
200
      if (!event.editable || !event.event_id || event.content.kind !== 'Message' || event.content.message.kind !== 'Text') return false;
201
      startEdit(event.event_id, event.content.message.body);
202
      return true;
203
    }
204
    return false;
205
  }
206
207
  function stage(files: File[]): void {
208
    const accepted = stageFiles(files, pushBanner);
209
    if (accepted.length) staged = [...staged, ...accepted];
210
    composerRef?.focus();
211
  }
212
213
  function unstage(id: number): void {
214
    releaseStaged(staged.filter((a) => a.id === id));
215
    staged = staged.filter((a) => a.id !== id);
216
  }
217
218
  async function submit(): Promise<void> {
219
    if (sending) return;
220
    const body = draft.trim();
221
    if (editing) {
222
      const { eventId, original } = editing;
223
      cancelEdit();
224
      draft = '';
225
      if (!body || body === original.trim()) return;
226
      editMessage(roomId, eventId, body, resolveMentions(body, picked), resolveEmotes(body, emotes), rootEventId).catch((e) =>
227
        pushBanner(e instanceof Error ? e.message : 'Edit failed')
228
      );
229
      picked = [];
230
      return;
231
    }
232
    const files = staged;
233
    if (!body && files.length === 0) return;
234
    const command = parseCommand(body);
235
    if (command && command.name !== 'me' && command.name !== 'notice') {
236
      pushBanner(`/${command.name} only works in the room composer`);
237
      return;
238
    }
239
    const text = command ? command.args : unescapeCommand(body);
240
    if (command && (!text || files.length)) {
241
      pushBanner(`/${command.name} needs something to say and no attachments`);
242
      return;
243
    }
244
    const reply = replyingTo;
245
    const mentions = resolveMentions(text, picked);
246
    const used = resolveEmotes(text, emotes);
247
    draft = '';
248
    picked = [];
249
    staged = [];
250
    replyingTo = null;
251
    stickToBottom = true;
252
    sending = true;
253
    try {
254
      if (files.length === 0) {
255
        await sendMessage(roomId, text, reply?.event_id ?? null, mentions, command ? (command.name === 'me' ? 'emote' : 'notice') : 'text', used, rootEventId);
256
      } else {
257
        let first = true;
258
        for (const attachment of files) {
259
          uploadName = attachment.file.name || 'attachment';
260
          try {
261
            if (first) await uploadAttachment(roomId, attachment.file, text || null, reply?.event_id ?? null, mentions, used, rootEventId);
262
            else await uploadAttachment(roomId, attachment.file, null, null, [], [], rootEventId);
263
            first = false;
264
          } catch (e) {
265
            pushBanner(e instanceof Error ? e.message : 'Upload failed');
266
          }
267
        }
268
      }
269
    } catch (e) {
270
      pushBanner(e instanceof Error ? e.message : 'Sending to the thread failed');
271
    } finally {
272
      releaseStaged(files);
273
      uploadName = null;
274
      sending = false;
275
    }
276
  }
277
</script>
278
279
<svelte:window on:focus={sendReceipt} />
280
<svelte:document on:visibilitychange={sendReceipt} />
281
282
<div class="thread-pane" bind:this={thread}>
283
  <header class="thread-head">
284
    <h2>Thread</h2>
285
    <span class="thread-room"><EmojiText text={roomName} /></span>
286
    <span class="spacer"></span>
287
    <button
288
      type="button"
289
      class="icon-btn"
290
      class:active={pinned}
291
      aria-pressed={pinned}
292
      aria-label={pinned ? 'Unpin thread' : 'Pin thread'}
293
      title={pinned ? 'Unpin thread' : 'Pin thread'}
294
      on:click={() => void togglePinnedThread(roomId, rootEventId)}
295
    >
296
      <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>
297
    </button>
298
    <button type="button" class="icon-btn" aria-label="Close thread" on:click={onClose}>
299
      <span aria-hidden="true">✕</span>
300
    </button>
301
  </header>
302
  <div class="thread-list" bind:this={list} on:scroll={onScroll}>
303
    {#if !hasStart && events.length > 1}
304
      <button type="button" class="ghost-btn older" disabled={paginating} on:click={() => void paginate()}>
305
        {paginating ? 'Loading…' : 'Load older replies'}
306
      </button>
307
    {/if}
308
    {#if events.length === 0}
309
      <p class="side-note">Loading thread…</p>
310
    {/if}
311
    {#each events as event, i (event.item_id || event.event_id)}
312
      <div class:root={event.event_id === rootEventId}>
313
        <MessageRow
314
          itemId={`thread-${event.item_id || event.event_id}`}
315
          {event}
316
          showSender={showSender(i) || event.event_id === rootEventId}
317
          senderAvatar={senderAvatarFor(event.sender.user_id) ?? event.sender.avatar}
318
          {reacting}
319
          onReact={(eventId, emoji, shortcode) => void react(eventId, emoji, shortcode)}
320
          {quickReactions}
321
          {packs}
322
          {linkPreviews}
323
          {loadPreview}
324
          {onQuickChange}
325
          {people}
326
          {self}
327
          {onUser}
328
          {onMore}
329
          onReply={joined ? startReply : null}
330
          onEdit={joined ? startEdit : null}
331
          editing={editing?.eventId === event.event_id}
332
          showSent={false}
333
        />
334
      </div>
335
    {/each}
336
  </div>
337
  {#if joined}
338
    <div class="thread-composer">
339
      <Composer
340
        bind:this={composerRef}
341
        bind:value={draft}
342
        inputId={`thread-composer-${rootEventId}`}
343
        label="Reply in thread"
344
        placeholder="Reply in thread"
345
        {picked}
346
        onPick={(mention) => (picked = [...picked, mention])}
347
        {staged}
348
        onStage={stage}
349
        onUnstage={unstage}
350
        uploadBusy={uploadName !== null}
351
        {uploadName}
352
        editing={editing ? { original: editing.original } : null}
353
        onCancelEdit={cancelEdit}
354
        {replyingTo}
355
        onCancelReply={() => (replyingTo = null)}
356
        {people}
357
        {self}
358
        {emotes}
359
        {packs}
360
        {stickers}
361
        onSubmit={() => void submit()}
362
        onEditLast={editLastOwn}
363
        {onMentionStart}
364
      />
365
    </div>
366
  {/if}
367
</div>
368
369
<style>
370
  .thread-pane { display: flex; flex-direction: column; min-width: 0; min-height: 0; height: 100%; animation: pop-in var(--pop); }
371
  .thread-head { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-bottom: 1px solid var(--border); }
372
  .thread-head h2 { margin: 0; font-size: 14px; font-weight: 650; }
373
  .thread-room { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
374
  .spacer { flex: 1; }
375
  .icon-btn.active { color: var(--accent); }
376
  .thread-list { flex: 1; min-height: 0; overflow-y: auto; padding: 8px 10px; }
377
  .older { display: block; margin: 0 auto 8px; }
378
  .root { padding-bottom: 6px; margin-bottom: 6px; border-bottom: 1px solid var(--border); }
379
  .thread-composer { flex: none; }
380
</style>