| 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> |