atagen

malice

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