atagen

malice

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