atagen

malice

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