atagen

malice

public
Star0
Watch0
7ebc2472a9e309499f0bd7be196d0c1bc4add938
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
web/src/lib/ThreadPane.svelteSvelte269 lines10.3 KB
1
<script lang="ts">
2
  import { tick } from 'svelte';
3
  import MessageRow from './MessageRow.svelte';
4
  import EmojiText from './EmojiText.svelte';
5
  import { editMessage, markRead, paginateRoom, reactToMessage, sendMessage } from './api';
6
  import { pushBanner, threadKey, threadTimelines, timelines } from './store';
7
  import { pinnedThreads, togglePinnedThread } from './threads';
8
  import type { ImagePack, LinkPreview, Person, QuickReaction, ReplyReference, TimelineEvent, TimelineItem } from './types';
9
10
  export let roomId: string;
11
  export let rootEventId: string;
12
  export let roomName: string;
13
  export let joined: boolean;
14
  export let self: string | null;
15
  export let people: Record<string, Person> = {};
16
  export let senderAvatarFor: (userId: string) => string | null = () => null;
17
  export let quickReactions: QuickReaction[] = [];
18
  export let packs: ImagePack[] = [];
19
  export let linkPreviews: Record<string, LinkPreview | null> = {};
20
  export let loadPreview: ((url: string) => void) | null = null;
21
  export let onQuickChange: (list: QuickReaction[]) => void = () => {};
22
  export let onUser: ((userId: string, x: number, y: number, menu: boolean) => void) | null = null;
23
  export let onMore: ((event: TimelineEvent, x: number, y: number) => void) | null = null;
24
  export let onClose: () => void;
25
26
  const GROUP_GAP_MS = 5 * 60 * 1000;
27
28
  let list: HTMLDivElement | null = null;
29
  let composerEl: HTMLTextAreaElement | null = null;
30
  let draft = '';
31
  let replyingTo: ReplyReference | null = null;
32
  let editing: { eventId: string } | null = null;
33
  let sending = false;
34
  let paginating = false;
35
  let reacting = false;
36
  let stickToBottom = true;
37
  let lastRead = '';
38
39
  $: key = threadKey(roomId, rootEventId);
40
  $: items = $threadTimelines[key] ?? [];
41
  $: roomRoot = ($timelines[roomId] ?? []).find((item) => item.kind === 'event' && item.event.event_id === rootEventId) ?? null;
42
  $: events = threadEvents(items, roomRoot);
43
  $: hasStart = items.some((item) => item.kind === 'start');
44
  $: pinned = ($pinnedThreads[roomId] ?? []).includes(rootEventId);
45
  $: if (key) reset();
46
  $: void follow(events);
47
48
  function threadEvents(list: TimelineItem[], root: TimelineItem | null): TimelineEvent[] {
49
    const out = list.flatMap((item) => (item.kind === 'event' ? [item.event] : []));
50
    if (root?.kind === 'event' && !out.some((event) => event.event_id === rootEventId)) out.unshift(root.event);
51
    return out;
52
  }
53
54
  function showSender(index: number): boolean {
55
    if (index === 0) return true;
56
    const prev = events[index - 1];
57
    const cur = events[index];
58
    return prev.sender.user_id !== cur.sender.user_id || Math.abs(cur.timestamp_ms - prev.timestamp_ms) > GROUP_GAP_MS;
59
  }
60
61
  function reset(): void {
62
    draft = '';
63
    replyingTo = null;
64
    editing = null;
65
    stickToBottom = true;
66
    lastRead = '';
67
  }
68
69
  async function follow(_events: TimelineEvent[]): Promise<void> {
70
    await tick();
71
    if (list && stickToBottom) list.scrollTop = list.scrollHeight;
72
    sendReceipt();
73
  }
74
75
  function sendReceipt(): void {
76
    if (document.hidden || !document.hasFocus() || !stickToBottom) return;
77
    const latest = [...events].reverse().find((event) => event.event_id);
78
    if (!latest?.event_id || latest.event_id === lastRead) return;
79
    lastRead = latest.event_id;
80
    markRead(roomId, latest.event_id, rootEventId).catch(() => undefined);
81
  }
82
83
  function onScroll(): void {
84
    if (!list) return;
85
    const wasAtBottom = stickToBottom;
86
    stickToBottom = list.scrollHeight - list.scrollTop - list.clientHeight < 2;
87
    if (stickToBottom && !wasAtBottom) sendReceipt();
88
    if (list.scrollTop < 60 && !hasStart) void paginate();
89
  }
90
91
  async function paginate(): Promise<void> {
92
    if (paginating) return;
93
    paginating = true;
94
    try {
95
      await paginateRoom(roomId, rootEventId);
96
    } catch (e) {
97
      pushBanner(e instanceof Error ? e.message : 'Loading older replies failed');
98
    } finally {
99
      paginating = false;
100
    }
101
  }
102
103
  async function react(eventId: string, emoji: string, shortcode?: string): Promise<void> {
104
    if (reacting) return;
105
    reacting = true;
106
    try {
107
      await reactToMessage(roomId, eventId, emoji, shortcode, rootEventId);
108
    } catch (e) {
109
      pushBanner(e instanceof Error ? e.message : 'Reaction failed');
110
    } finally {
111
      reacting = false;
112
    }
113
  }
114
115
  function startReply(target: ReplyReference): void {
116
    editing = null;
117
    replyingTo = target;
118
    composerEl?.focus();
119
  }
120
121
  function startEdit(eventId: string, body: string): void {
122
    replyingTo = null;
123
    editing = { eventId };
124
    draft = body;
125
    void tick().then(() => composerEl?.focus());
126
  }
127
128
  async function submit(): Promise<void> {
129
    const body = draft.trim();
130
    if (!body || sending) return;
131
    sending = true;
132
    const reply = replyingTo;
133
    const edit = editing;
134
    try {
135
      if (edit) await editMessage(roomId, edit.eventId, body, [], [], rootEventId);
136
      else await sendMessage(roomId, body, reply?.event_id ?? null, [], 'text', [], rootEventId);
137
      draft = '';
138
      replyingTo = null;
139
      editing = null;
140
      stickToBottom = true;
141
    } catch (e) {
142
      pushBanner(e instanceof Error ? e.message : 'Sending to the thread failed');
143
    } finally {
144
      sending = false;
145
    }
146
  }
147
148
  function onKey(e: KeyboardEvent): void {
149
    if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
150
      e.preventDefault();
151
      void submit();
152
    } else if (e.key === 'Escape' && (editing || replyingTo)) {
153
      e.preventDefault();
154
      if (editing) draft = '';
155
      editing = null;
156
      replyingTo = null;
157
    }
158
  }
159
160
  function grow(node: HTMLTextAreaElement, _value: string) {
161
    const fit = () => {
162
      node.style.height = 'auto';
163
      node.style.height = `${Math.min(node.scrollHeight, 200)}px`;
164
    };
165
    fit();
166
    return { update: fit };
167
  }
168
</script>
169
170
<svelte:window on:focus={sendReceipt} />
171
<svelte:document on:visibilitychange={sendReceipt} />
172
173
<aside class="thread-pane" aria-label={`Thread in ${roomName}`}>
174
  <header class="thread-head">
175
    <h2>Thread</h2>
176
    <span class="thread-room"><EmojiText text={roomName} /></span>
177
    <span class="spacer"></span>
178
    <button
179
      type="button"
180
      class="icon-btn"
181
      class:active={pinned}
182
      aria-pressed={pinned}
183
      aria-label={pinned ? 'Unpin thread' : 'Pin thread'}
184
      title={pinned ? 'Unpin thread' : 'Pin thread'}
185
      on:click={() => void togglePinnedThread(roomId, rootEventId)}
186
    >
187
      <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>
188
    </button>
189
    <button type="button" class="icon-btn" aria-label="Close thread" on:click={onClose}>
190
      <span aria-hidden="true">✕</span>
191
    </button>
192
  </header>
193
  <div class="thread-list" bind:this={list} on:scroll={onScroll}>
194
    {#if !hasStart && events.length > 1}
195
      <button type="button" class="ghost-btn older" disabled={paginating} on:click={() => void paginate()}>
196
        {paginating ? 'Loading…' : 'Load older replies'}
197
      </button>
198
    {/if}
199
    {#if events.length === 0}
200
      <p class="side-note">Loading thread…</p>
201
    {/if}
202
    {#each events as event, i (event.item_id || event.event_id)}
203
      <div class:root={event.event_id === rootEventId}>
204
        <MessageRow
205
          itemId={`thread-${event.item_id || event.event_id}`}
206
          {event}
207
          showSender={showSender(i) || event.event_id === rootEventId}
208
          senderAvatar={senderAvatarFor(event.sender.user_id) ?? event.sender.avatar}
209
          {reacting}
210
          onReact={(eventId, emoji, shortcode) => void react(eventId, emoji, shortcode)}
211
          {quickReactions}
212
          {packs}
213
          {linkPreviews}
214
          {loadPreview}
215
          {onQuickChange}
216
          {people}
217
          {self}
218
          {onUser}
219
          {onMore}
220
          onReply={joined ? startReply : null}
221
          onEdit={joined ? startEdit : null}
222
          editing={editing?.eventId === event.event_id}
223
          showSent={false}
224
        />
225
      </div>
226
    {/each}
227
  </div>
228
  {#if joined}
229
    <form class="thread-composer" on:submit|preventDefault={() => void submit()}>
230
      {#if replyingTo || editing}
231
        <div class="thread-context">
232
          <span>{editing ? 'Editing' : 'Replying'}</span>
233
          <button type="button" class="icon-btn" aria-label="Cancel" on:click={() => { if (editing) draft = ''; editing = null; replyingTo = null; }}>
234
            <span aria-hidden="true">✕</span>
235
          </button>
236
        </div>
237
      {/if}
238
      <div class="thread-input">
239
        <textarea
240
          bind:this={composerEl}
241
          bind:value={draft}
242
          use:grow={draft}
243
          rows="1"
244
          placeholder="Reply in thread"
245
          aria-label="Reply in thread"
246
          on:keydown={onKey}
247
        ></textarea>
248
        <button type="submit" class="primary-btn" disabled={sending || !draft.trim()}>{editing ? 'Save' : 'Send'}</button>
249
      </div>
250
    </form>
251
  {/if}
252
</aside>
253
254
<style>
255
  .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); }
256
  .thread-head { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-bottom: 1px solid var(--border); }
257
  .thread-head h2 { margin: 0; font-size: 14px; font-weight: 650; }
258
  .thread-room { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
259
  .spacer { flex: 1; }
260
  .icon-btn.active { color: var(--accent); }
261
  .thread-list { flex: 1; min-height: 0; overflow-y: auto; padding: 8px 10px; }
262
  .older { display: block; margin: 0 auto 8px; }
263
  .root { padding-bottom: 6px; margin-bottom: 6px; border-bottom: 1px solid var(--border); }
264
  .thread-composer { border-top: 1px solid var(--border); padding: 8px 10px; display: flex; flex-direction: column; gap: 6px; }
265
  .thread-context { display: flex; align-items: center; justify-content: space-between; font-size: 12px; color: var(--muted); }
266
  .thread-input { display: flex; align-items: flex-end; gap: 8px; }
267
  .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; }
268
  .thread-input textarea:focus { outline: none; border-color: var(--accent); }
269
</style>