atagen

malice

public
Star0
Watch0
6f8d26e8d14c7991087fe7565acdf3807c3eb6b1
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
web/src/lib/Composer.svelteSvelte439 lines16.4 KB
1
<script lang="ts">
2
  import { tick } from 'svelte';
3
  import ComposerText from './ComposerText.svelte';
4
  import EmojiText from './EmojiText.svelte';
5
  import MxcAvatar from './MxcAvatar.svelte';
6
  import StickerPicker from './StickerPicker.svelte';
7
  import { mediaUrl } from './api';
8
  import { COMMAND_USAGE, commandQueryAt, matchCommands, type CommandName } from './commands';
9
  import { markMentions } from './composer-marks';
10
  import { loadEmoji, type EmojiEntry } from './emoji';
11
  import { completeShortcode, emoteQueryAt, rankEmotes, resolveEmotes, type Emote, type EmoteCandidate, type EmoteQuery } from './emotes';
12
  import { insideOpenFence } from './markdown';
13
  import { mentionQueryAt, rankPeople, type Mention, type MentionQuery } from './mentions';
14
  import type { StagedAttachment, StickerHandlers } from './staging';
15
  import { contentPreview, senderLabel } from './types';
16
  import type { ImagePack, PackImage, Person, ReplyReference } from './types';
17
18
  export let value: string;
19
  export let inputId: string;
20
  export let label: string;
21
  export let placeholder: string;
22
  export let ready = true;
23
  export let readyTitle = 'Send message';
24
  export let picked: Mention[] = [];
25
  export let onPick: (mention: Mention) => void;
26
  export let staged: StagedAttachment[] = [];
27
  export let onStage: (files: File[]) => void;
28
  export let onUnstage: (id: number) => void;
29
  export let uploadBusy = false;
30
  export let uploadName: string | null = null;
31
  export let editing: { original: string } | null = null;
32
  export let onCancelEdit: () => void = () => {};
33
  export let replyingTo: ReplyReference | null = null;
34
  export let onCancelReply: () => void = () => {};
35
  export let people: Record<string, Person> = {};
36
  export let self: string | null = null;
37
  export let emotes: Emote[] = [];
38
  export let packs: ImagePack[] = [];
39
  export let stickers: StickerHandlers | null = null;
40
  export let commands = false;
41
  export let onSubmit: () => void;
42
  export let onEditLast: () => boolean = () => false;
43
  export let onMentionStart: () => void = () => {};
44
45
  let field: HTMLTextAreaElement | null = null;
46
  let marksEl: HTMLDivElement | null = null;
47
  let fileInput: HTMLInputElement | null = null;
48
  let composing = false;
49
  let stickersOpen = false;
50
51
  let mentionQuery: MentionQuery | null = null;
52
  let mentionIndex = 0;
53
  $: mentionCandidates = mentionQuery ? rankPeople(Object.values(people), mentionQuery.query, self) : [];
54
  $: if (mentionIndex >= mentionCandidates.length) mentionIndex = 0;
55
56
  let commandQuery: string | null = null;
57
  let commandIndex = 0;
58
  $: commandCandidates = commands && commandQuery !== null ? matchCommands(commandQuery) : [];
59
  $: if (commandIndex >= commandCandidates.length) commandIndex = 0;
60
61
  let emoteQuery: EmoteQuery | null = null;
62
  let emoteIndex = 0;
63
  let emojiList: EmojiEntry[] = [];
64
  $: emoteCandidates = emoteQuery ? rankEmotes(emoteQuery.query, emotes, emojiList) : [];
65
  $: if (emoteIndex >= emoteCandidates.length) emoteIndex = 0;
66
67
  $: marks = markMentions(value, picked, resolveEmotes(value, emotes).map((e) => `:${e.shortcode}:`));
68
69
  export function focus(): void {
70
    field?.focus();
71
  }
72
73
  export async function focusEnd(): Promise<void> {
74
    await tick();
75
    field?.focus();
76
    field?.setSelectionRange(value.length, value.length);
77
  }
78
79
  export function closeFinders(): void {
80
    mentionQuery = null;
81
    commandQuery = null;
82
    emoteQuery = null;
83
    stickersOpen = false;
84
  }
85
86
  function syncMarks(node: HTMLTextAreaElement) {
87
    const sync = () => {
88
      if (!marksEl) return;
89
      marksEl.style.width = `${node.clientWidth}px`;
90
      marksEl.scrollTop = node.scrollTop;
91
      marksEl.scrollLeft = node.scrollLeft;
92
    };
93
    const observer = new ResizeObserver(sync);
94
    observer.observe(node);
95
    node.addEventListener('scroll', sync);
96
    return {
97
      destroy: () => {
98
        observer.disconnect();
99
        node.removeEventListener('scroll', sync);
100
      }
101
    };
102
  }
103
104
  function ensureEmoji(): void {
105
    if (emojiList.length) return;
106
    loadEmoji()
107
      .then((groups) => (emojiList = groups.flatMap((g) => g.emojis)))
108
      .catch(() => undefined);
109
  }
110
111
  function caret(): number {
112
    return field?.selectionStart ?? value.length;
113
  }
114
115
  async function placeCaret(at: number): Promise<void> {
116
    await tick();
117
    field?.focus();
118
    field?.setSelectionRange(at, at);
119
  }
120
121
  function updateQueries(): void {
122
    if (!field) return;
123
    const at = caret();
124
    const nextEmote = emoteQueryAt(field.value, at);
125
    if (nextEmote?.start !== emoteQuery?.start) emoteIndex = 0;
126
    emoteQuery = nextEmote;
127
    const nextCommand = commands ? commandQueryAt(field.value, at) : null;
128
    if (nextCommand !== commandQuery) commandIndex = 0;
129
    commandQuery = nextCommand;
130
    const nextMention = mentionQueryAt(field.value, at);
131
    if (nextMention && !mentionQuery) onMentionStart();
132
    if (nextMention?.start !== mentionQuery?.start) mentionIndex = 0;
133
    mentionQuery = nextMention;
134
  }
135
136
  async function onInput(): Promise<void> {
137
    if (field && value.includes(':')) {
138
      ensureEmoji();
139
      const done = completeShortcode(field.value, caret(), emojiList);
140
      if (done) {
141
        value = done.text;
142
        await placeCaret(done.caret);
143
      }
144
    }
145
    updateQueries();
146
  }
147
148
  async function pickCommand(name: CommandName): Promise<void> {
149
    const rest = value.slice(caret()).replace(/^\S*/, '');
150
    value = `/${name}${rest.startsWith(' ') ? rest : ` ${rest}`}`;
151
    commandQuery = null;
152
    await placeCaret(name.length + 2);
153
  }
154
155
  async function pickEmote(candidate: EmoteCandidate): Promise<void> {
156
    if (!emoteQuery) return;
157
    const inserted = candidate.kind === 'emoji' ? candidate.emoji : `:${candidate.shortcode}: `;
158
    const start = emoteQuery.start;
159
    value = value.slice(0, start) + inserted + value.slice(caret());
160
    emoteQuery = null;
161
    await placeCaret(start + inserted.length);
162
  }
163
164
  async function pickMention(person: Person): Promise<void> {
165
    if (!mentionQuery) return;
166
    const inserted = `${person.name} `;
167
    const start = mentionQuery.start;
168
    value = value.slice(0, start) + inserted + value.slice(caret());
169
    onPick({ user_id: person.user_id, text: person.name });
170
    mentionQuery = null;
171
    await placeCaret(start + inserted.length);
172
  }
173
174
  function step<T>(list: T[], index: number, key: string): number {
175
    return (index + (key === 'ArrowDown' ? 1 : -1) + list.length) % list.length;
176
  }
177
178
  function onKey(ev: KeyboardEvent): void {
179
    if (ev.isComposing || composing) return;
180
    if (emoteQuery && emoteCandidates.length > 0) {
181
      if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
182
        ev.preventDefault();
183
        emoteIndex = step(emoteCandidates, emoteIndex, ev.key);
184
        return;
185
      }
186
      if ((ev.key === 'Enter' && !ev.shiftKey) || ev.key === 'Tab') {
187
        ev.preventDefault();
188
        void pickEmote(emoteCandidates[emoteIndex]);
189
        return;
190
      }
191
      if (ev.key === 'Escape') {
192
        ev.stopPropagation();
193
        emoteQuery = null;
194
        return;
195
      }
196
    }
197
    if (commandQuery !== null && commandCandidates.length > 0) {
198
      if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
199
        ev.preventDefault();
200
        commandIndex = step(commandCandidates, commandIndex, ev.key);
201
        return;
202
      }
203
      const exact = commandCandidates.length === 1 && commandCandidates[0] === commandQuery;
204
      if (ev.key === 'Tab' || (ev.key === 'Enter' && !ev.shiftKey && !exact)) {
205
        ev.preventDefault();
206
        void pickCommand(commandCandidates[commandIndex]);
207
        return;
208
      }
209
      if (ev.key === 'Escape') {
210
        ev.stopPropagation();
211
        commandQuery = null;
212
        return;
213
      }
214
    }
215
    if (mentionQuery && mentionCandidates.length > 0) {
216
      if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
217
        ev.preventDefault();
218
        mentionIndex = step(mentionCandidates, mentionIndex, ev.key);
219
        return;
220
      }
221
      if ((ev.key === 'Enter' && !ev.shiftKey) || ev.key === 'Tab') {
222
        ev.preventDefault();
223
        void pickMention(mentionCandidates[mentionIndex]);
224
        return;
225
      }
226
      if (ev.key === 'Escape') {
227
        ev.stopPropagation();
228
        mentionQuery = null;
229
        return;
230
      }
231
    }
232
    if (ev.key === 'Enter' && !ev.shiftKey && !insideOpenFence(value, caret())) {
233
      ev.preventDefault();
234
      onSubmit();
235
    } else if (ev.key === 'Escape' && editing) {
236
      ev.stopPropagation();
237
      onCancelEdit();
238
    } else if (ev.key === 'Escape' && replyingTo) {
239
      ev.stopPropagation();
240
      onCancelReply();
241
    } else if (ev.key === 'ArrowUp' && !value && !editing && onEditLast()) {
242
      ev.preventDefault();
243
    }
244
  }
245
246
  function onPaste(ev: ClipboardEvent): void {
247
    const data = ev.clipboardData;
248
    // Office apps put a rendered image next to the copied text, so only take the image when there is no plain text.
249
    if (!data || data.types.includes('text/plain')) return;
250
    const images = Array.from(data.files).filter((f) => f.type.startsWith('image/'));
251
    if (images.length === 0) return;
252
    ev.preventDefault();
253
    onStage(images);
254
  }
255
256
  function onFileChosen(): void {
257
    onStage(Array.from(fileInput?.files ?? []));
258
    if (fileInput) fileInput.value = '';
259
  }
260
261
  function onCaretKey(e: KeyboardEvent): void {
262
    if ((e.key.startsWith('Arrow') && e.key !== 'ArrowUp' && e.key !== 'ArrowDown') || e.key === 'Home' || e.key === 'End') updateQueries();
263
  }
264
</script>
265
266
{#if editing}
267
  <div class="reply-bar" role="status">
268
    <span class="reply-bar-label">
269
      Editing message
270
      <span class="reply-bar-text"><EmojiText text={editing.original} /></span>
271
    </span>
272
    <button type="button" class="icon-btn" aria-label="Cancel edit" title="Cancel edit (Esc)" on:click={onCancelEdit}>
273
      <span aria-hidden="true">✕</span>
274
    </button>
275
  </div>
276
{:else if replyingTo && replyingTo.details.kind === 'Ready'}
277
  <div class="reply-bar" role="status">
278
    <span class="reply-bar-label">
279
      Replying to <strong><EmojiText text={people[replyingTo.details.sender.user_id]?.name ?? senderLabel(replyingTo.details.sender)} /></strong>
280
      <span class="reply-bar-text"><EmojiText text={contentPreview(replyingTo.details.content)} /></span>
281
    </span>
282
    <button type="button" class="icon-btn" aria-label="Cancel reply" title="Cancel reply (Esc)" on:click={onCancelReply}>
283
      <span aria-hidden="true">✕</span>
284
    </button>
285
  </div>
286
{/if}
287
{#if staged.length > 0}
288
  <ul class="staged" aria-label="Attachments to send">
289
    {#each staged as attachment (attachment.id)}
290
      <li class="staged-item">
291
        {#if attachment.previewUrl}
292
          <img src={attachment.previewUrl} alt={attachment.file.name || 'Pasted image'} />
293
        {:else}
294
          <span class="staged-name"><EmojiText text={attachment.file.name || 'attachment'} /></span>
295
        {/if}
296
        <button
297
          type="button"
298
          class="icon-btn staged-remove"
299
          aria-label={`Remove ${attachment.file.name || 'attachment'}`}
300
          title="Remove"
301
          on:click={() => onUnstage(attachment.id)}
302
        >
303
          <span aria-hidden="true">✕</span>
304
        </button>
305
      </li>
306
    {/each}
307
  </ul>
308
{/if}
309
<form class="composer" class:replying={replyingTo || editing} class:staging={staged.length > 0} on:submit|preventDefault={onSubmit}>
310
  <label class="visually-hidden" for={inputId}>{label}</label>
311
  <input type="file" multiple bind:this={fileInput} class="visually-hidden" aria-label="Attach a file" on:change={onFileChosen} />
312
  <button
313
    type="button"
314
    class="icon-btn"
315
    class:busy={uploadBusy}
316
    title={uploadBusy ? `Uploading ${uploadName ?? 'file'}…` : 'Attach an image or file'}
317
    aria-label="Attach an image or file"
318
    aria-busy={uploadBusy}
319
    disabled={!ready}
320
    on:click={() => fileInput?.click()}
321
  >
322
    <svg viewBox="0 0 24 24" width="19" height="19" aria-hidden="true">
323
      <path d="M20 11.5l-8.2 8.2a5 5 0 0 1-7-7L13 4.4a3.3 3.3 0 0 1 4.7 4.7l-8.2 8.2a1.7 1.7 0 0 1-2.4-2.4l7.5-7.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
324
    </svg>
325
  </button>
326
  {#if stickers}
327
    {@const handlers = stickers}
328
    <button
329
      type="button"
330
      class="icon-btn sticker-btn"
331
      title="Stickers"
332
      aria-label="Stickers"
333
      aria-expanded={stickersOpen}
334
      disabled={!ready}
335
      on:click={() => (stickersOpen = !stickersOpen)}
336
    >
337
      <svg viewBox="0 0 24 24" width="19" height="19" aria-hidden="true">
338
        <path d="M4 6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v7l-7 7H6a2 2 0 0 1-2-2z" fill="none" stroke="currentColor" stroke-width="1.8" />
339
        <path d="M13 20v-5a2 2 0 0 1 2-2h5" fill="none" stroke="currentColor" stroke-width="1.8" />
340
      </svg>
341
    </button>
342
    {#if stickersOpen}
343
      <div class="sticker-pop">
344
        <StickerPicker
345
          {packs}
346
          roomName={handlers.roomName}
347
          onSend={(pack, image) => ((stickersOpen = false), handlers.onSend(pack, image))}
348
          onClone={(pack, image, x, y) => ((stickersOpen = false), handlers.onClone(pack, image, x, y))}
349
          onClonePack={(pack, x, y) => ((stickersOpen = false), handlers.onClonePack(pack, x, y))}
350
          onManage={() => ((stickersOpen = false), handlers.onManage())}
351
          onClose={() => (stickersOpen = false)}
352
        />
353
      </div>
354
    {/if}
355
  {/if}
356
  <div class="composer-field" class:composing>
357
    {#if commandQuery !== null && commandCandidates.length > 0}
358
      <ul class="mention-finder" role="listbox" aria-label="Commands">
359
        {#each commandCandidates as name, i (name)}
360
          <li
361
            role="option"
362
            aria-selected={i === commandIndex}
363
            class:active={i === commandIndex}
364
            on:pointerdown|preventDefault={() => void pickCommand(name)}
365
            on:pointerenter={() => (commandIndex = i)}
366
          >
367
            <span class="mention-name">/{name}</span>
368
            <span class="mention-id">{COMMAND_USAGE[name]}</span>
369
          </li>
370
        {/each}
371
      </ul>
372
    {/if}
373
    {#if emoteQuery && emoteCandidates.length > 0}
374
      <ul class="mention-finder" role="listbox" aria-label="Emotes">
375
        {#each emoteCandidates as candidate, i (candidate.kind + candidate.shortcode)}
376
          <li
377
            role="option"
378
            aria-selected={i === emoteIndex}
379
            class:active={i === emoteIndex}
380
            on:pointerdown|preventDefault={() => void pickEmote(candidate)}
381
            on:pointerenter={() => (emoteIndex = i)}
382
          >
383
            {#if candidate.kind === 'custom'}
384
              <img class="finder-emote" src={mediaUrl(candidate.mxc, 64) ?? ''} alt="" />
385
            {:else}
386
              <span class="finder-emote"><EmojiText text={candidate.emoji} /></span>
387
            {/if}
388
            <span class="mention-name">:{candidate.shortcode}:</span>
389
          </li>
390
        {/each}
391
      </ul>
392
    {/if}
393
    {#if mentionQuery && mentionCandidates.length > 0}
394
      <ul class="mention-finder" role="listbox" aria-label="Mention someone">
395
        {#each mentionCandidates as person, i (person.user_id)}
396
          <li
397
            role="option"
398
            aria-selected={i === mentionIndex}
399
            class:active={i === mentionIndex}
400
            on:pointerdown|preventDefault={() => void pickMention(person)}
401
            on:pointerenter={() => (mentionIndex = i)}
402
          >
403
            <MxcAvatar mxc={person.avatar} label={person.name} size={22} />
404
            <span class="mention-name"><EmojiText text={person.name} /></span>
405
            <span class="mention-id">{person.user_id}</span>
406
          </li>
407
        {/each}
408
      </ul>
409
    {/if}
410
    <div class="composer-marks" aria-hidden="true" bind:this={marksEl}>{#each marks as part}{#if part.mark}<mark><ComposerText text={part.text} /></mark>{:else}<ComposerText text={part.text} />{/if}{/each}&#8203;</div>
411
    <textarea
412
      id={inputId}
413
      bind:this={field}
414
      bind:value
415
      use:syncMarks
416
      rows="1"
417
      {placeholder}
418
      aria-label={`${label}. Enter to send, Shift Enter for a new line.`}
419
      on:keydown={onKey}
420
      on:input={() => void onInput()}
421
      on:compositionstart={() => (composing = true)}
422
      on:compositionend={() => (composing = false)}
423
      on:click={updateQueries}
424
      on:focus={ensureEmoji}
425
      on:keyup={onCaretKey}
426
      on:blur={() => ((mentionQuery = null), (commandQuery = null), (emoteQuery = null), (composing = false))}
427
      on:paste={onPaste}
428
      disabled={!ready}
429
    ></textarea>
430
  </div>
431
  <button
432
    type="submit"
433
    class="primary-btn"
434
    disabled={(!value.trim() && staged.length === 0) || !ready || (staged.length > 0 && uploadBusy)}
435
    title={ready ? readyTitle : 'Waiting for the room to open'}
436
  >
437
    {editing ? 'Save' : 'Send'}
438
  </button>
439
</form>