atagen

malice

public
Star0
Watch0
8909d39b777b6223ad52240456fc8b82edc70be3
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git

Blame: web/src/lib/Composer.svelte

dda384a360e0atagen2026-10-25 10:27 UTC1
<script lang="ts">
dda384a360e0atagen2026-10-25 10:27 UTC2
  import { tick } from 'svelte';
dda384a360e0atagen2026-10-25 10:27 UTC3
  import ComposerText from './ComposerText.svelte';
dda384a360e0atagen2026-10-25 10:27 UTC4
  import EmojiText from './EmojiText.svelte';
dda384a360e0atagen2026-10-25 10:27 UTC5
  import MxcAvatar from './MxcAvatar.svelte';
dda384a360e0atagen2026-10-25 10:27 UTC6
  import StickerPicker from './StickerPicker.svelte';
dda384a360e0atagen2026-10-25 10:27 UTC7
  import { mediaUrl } from './api';
dda384a360e0atagen2026-10-25 10:27 UTC8
  import { COMMAND_USAGE, commandQueryAt, matchCommands, type CommandName } from './commands';
dda384a360e0atagen2026-10-25 10:27 UTC9
  import { markMentions } from './composer-marks';
dda384a360e0atagen2026-10-25 10:27 UTC10
  import { loadEmoji, type EmojiEntry } from './emoji';
dda384a360e0atagen2026-10-25 10:27 UTC11
  import { completeShortcode, emoteQueryAt, rankEmotes, resolveEmotes, type Emote, type EmoteCandidate, type EmoteQuery } from './emotes';
dda384a360e0atagen2026-10-25 10:27 UTC12
  import { insideOpenFence } from './markdown';
dda384a360e0atagen2026-10-25 10:27 UTC13
  import { mentionQueryAt, rankPeople, type Mention, type MentionQuery } from './mentions';
dda384a360e0atagen2026-10-25 10:27 UTC14
  import type { StagedAttachment, StickerHandlers } from './staging';
dda384a360e0atagen2026-10-25 10:27 UTC15
  import { contentPreview, senderLabel } from './types';
dda384a360e0atagen2026-10-25 10:27 UTC16
  import type { ImagePack, PackImage, Person, ReplyReference } from './types';
dda384a360e0atagen2026-10-25 10:27 UTC17
dda384a360e0atagen2026-10-25 10:27 UTC18
  export let value: string;
dda384a360e0atagen2026-10-25 10:27 UTC19
  export let inputId: string;
dda384a360e0atagen2026-10-25 10:27 UTC20
  export let label: string;
dda384a360e0atagen2026-10-25 10:27 UTC21
  export let placeholder: string;
dda384a360e0atagen2026-10-25 10:27 UTC22
  export let ready = true;
dda384a360e0atagen2026-10-25 10:27 UTC23
  export let readyTitle = 'Send message';
dda384a360e0atagen2026-10-25 10:27 UTC24
  export let picked: Mention[] = [];
dda384a360e0atagen2026-10-25 10:27 UTC25
  export let onPick: (mention: Mention) => void;
dda384a360e0atagen2026-10-25 10:27 UTC26
  export let staged: StagedAttachment[] = [];
dda384a360e0atagen2026-10-25 10:27 UTC27
  export let onStage: (files: File[]) => void;
dda384a360e0atagen2026-10-25 10:27 UTC28
  export let onUnstage: (id: number) => void;
dda384a360e0atagen2026-10-25 10:27 UTC29
  export let uploadBusy = false;
dda384a360e0atagen2026-10-25 10:27 UTC30
  export let uploadName: string | null = null;
dda384a360e0atagen2026-10-25 10:27 UTC31
  export let editing: { original: string } | null = null;
dda384a360e0atagen2026-10-25 10:27 UTC32
  export let onCancelEdit: () => void = () => {};
dda384a360e0atagen2026-10-25 10:27 UTC33
  export let replyingTo: ReplyReference | null = null;
dda384a360e0atagen2026-10-25 10:27 UTC34
  export let onCancelReply: () => void = () => {};
dda384a360e0atagen2026-10-25 10:27 UTC35
  export let people: Record<string, Person> = {};
dda384a360e0atagen2026-10-25 10:27 UTC36
  export let self: string | null = null;
dda384a360e0atagen2026-10-25 10:27 UTC37
  export let emotes: Emote[] = [];
dda384a360e0atagen2026-10-25 10:27 UTC38
  export let packs: ImagePack[] = [];
dda384a360e0atagen2026-10-25 10:27 UTC39
  export let stickers: StickerHandlers | null = null;
dda384a360e0atagen2026-10-25 10:27 UTC40
  export let commands = false;
dda384a360e0atagen2026-10-25 10:27 UTC41
  export let onSubmit: () => void;
dda384a360e0atagen2026-10-25 10:27 UTC42
  export let onEditLast: () => boolean = () => false;
dda384a360e0atagen2026-10-25 10:27 UTC43
  export let onMentionStart: () => void = () => {};
dda384a360e0atagen2026-10-25 10:27 UTC44
dda384a360e0atagen2026-10-25 10:27 UTC45
  let field: HTMLTextAreaElement | null = null;
dda384a360e0atagen2026-10-25 10:27 UTC46
  let marksEl: HTMLDivElement | null = null;
dda384a360e0atagen2026-10-25 10:27 UTC47
  let fileInput: HTMLInputElement | null = null;
dda384a360e0atagen2026-10-25 10:27 UTC48
  let composing = false;
dda384a360e0atagen2026-10-25 10:27 UTC49
  let stickersOpen = false;
dda384a360e0atagen2026-10-25 10:27 UTC50
dda384a360e0atagen2026-10-25 10:27 UTC51
  let mentionQuery: MentionQuery | null = null;
dda384a360e0atagen2026-10-25 10:27 UTC52
  let mentionIndex = 0;
dda384a360e0atagen2026-10-25 10:27 UTC53
  $: mentionCandidates = mentionQuery ? rankPeople(Object.values(people), mentionQuery.query, self) : [];
dda384a360e0atagen2026-10-25 10:27 UTC54
  $: if (mentionIndex >= mentionCandidates.length) mentionIndex = 0;
dda384a360e0atagen2026-10-25 10:27 UTC55
dda384a360e0atagen2026-10-25 10:27 UTC56
  let commandQuery: string | null = null;
dda384a360e0atagen2026-10-25 10:27 UTC57
  let commandIndex = 0;
dda384a360e0atagen2026-10-25 10:27 UTC58
  $: commandCandidates = commands && commandQuery !== null ? matchCommands(commandQuery) : [];
dda384a360e0atagen2026-10-25 10:27 UTC59
  $: if (commandIndex >= commandCandidates.length) commandIndex = 0;
dda384a360e0atagen2026-10-25 10:27 UTC60
dda384a360e0atagen2026-10-25 10:27 UTC61
  let emoteQuery: EmoteQuery | null = null;
dda384a360e0atagen2026-10-25 10:27 UTC62
  let emoteIndex = 0;
dda384a360e0atagen2026-10-25 10:27 UTC63
  let emojiList: EmojiEntry[] = [];
dda384a360e0atagen2026-10-25 10:27 UTC64
  $: emoteCandidates = emoteQuery ? rankEmotes(emoteQuery.query, emotes, emojiList) : [];
dda384a360e0atagen2026-10-25 10:27 UTC65
  $: if (emoteIndex >= emoteCandidates.length) emoteIndex = 0;
dda384a360e0atagen2026-10-25 10:27 UTC66
dda384a360e0atagen2026-10-25 10:27 UTC67
  $: marks = markMentions(value, picked, resolveEmotes(value, emotes).map((e) => `:${e.shortcode}:`));
dda384a360e0atagen2026-10-25 10:27 UTC68
dda384a360e0atagen2026-10-25 10:27 UTC69
  export function focus(): void {
dda384a360e0atagen2026-10-25 10:27 UTC70
    field?.focus();
dda384a360e0atagen2026-10-25 10:27 UTC71
  }
dda384a360e0atagen2026-10-25 10:27 UTC72
dda384a360e0atagen2026-10-25 10:27 UTC73
  export async function focusEnd(): Promise<void> {
dda384a360e0atagen2026-10-25 10:27 UTC74
    await tick();
dda384a360e0atagen2026-10-25 10:27 UTC75
    field?.focus();
dda384a360e0atagen2026-10-25 10:27 UTC76
    field?.setSelectionRange(value.length, value.length);
dda384a360e0atagen2026-10-25 10:27 UTC77
  }
dda384a360e0atagen2026-10-25 10:27 UTC78
dda384a360e0atagen2026-10-25 10:27 UTC79
  export function closeFinders(): void {
dda384a360e0atagen2026-10-25 10:27 UTC80
    mentionQuery = null;
dda384a360e0atagen2026-10-25 10:27 UTC81
    commandQuery = null;
dda384a360e0atagen2026-10-25 10:27 UTC82
    emoteQuery = null;
dda384a360e0atagen2026-10-25 10:27 UTC83
    stickersOpen = false;
dda384a360e0atagen2026-10-25 10:27 UTC84
  }
dda384a360e0atagen2026-10-25 10:27 UTC85
dda384a360e0atagen2026-10-25 10:27 UTC86
  function syncMarks(node: HTMLTextAreaElement) {
dda384a360e0atagen2026-10-25 10:27 UTC87
    const sync = () => {
dda384a360e0atagen2026-10-25 10:27 UTC88
      if (!marksEl) return;
dda384a360e0atagen2026-10-25 10:27 UTC89
      marksEl.style.width = `${node.clientWidth}px`;
dda384a360e0atagen2026-10-25 10:27 UTC90
      marksEl.scrollTop = node.scrollTop;
dda384a360e0atagen2026-10-25 10:27 UTC91
      marksEl.scrollLeft = node.scrollLeft;
dda384a360e0atagen2026-10-25 10:27 UTC92
    };
dda384a360e0atagen2026-10-25 10:27 UTC93
    const observer = new ResizeObserver(sync);
dda384a360e0atagen2026-10-25 10:27 UTC94
    observer.observe(node);
dda384a360e0atagen2026-10-25 10:27 UTC95
    node.addEventListener('scroll', sync);
dda384a360e0atagen2026-10-25 10:27 UTC96
    return {
dda384a360e0atagen2026-10-25 10:27 UTC97
      destroy: () => {
dda384a360e0atagen2026-10-25 10:27 UTC98
        observer.disconnect();
dda384a360e0atagen2026-10-25 10:27 UTC99
        node.removeEventListener('scroll', sync);
dda384a360e0atagen2026-10-25 10:27 UTC100
      }
dda384a360e0atagen2026-10-25 10:27 UTC101
    };
dda384a360e0atagen2026-10-25 10:27 UTC102
  }
dda384a360e0atagen2026-10-25 10:27 UTC103
dda384a360e0atagen2026-10-25 10:27 UTC104
  function ensureEmoji(): void {
dda384a360e0atagen2026-10-25 10:27 UTC105
    if (emojiList.length) return;
dda384a360e0atagen2026-10-25 10:27 UTC106
    loadEmoji()
dda384a360e0atagen2026-10-25 10:27 UTC107
      .then((groups) => (emojiList = groups.flatMap((g) => g.emojis)))
dda384a360e0atagen2026-10-25 10:27 UTC108
      .catch(() => undefined);
dda384a360e0atagen2026-10-25 10:27 UTC109
  }
dda384a360e0atagen2026-10-25 10:27 UTC110
dda384a360e0atagen2026-10-25 10:27 UTC111
  function caret(): number {
dda384a360e0atagen2026-10-25 10:27 UTC112
    return field?.selectionStart ?? value.length;
dda384a360e0atagen2026-10-25 10:27 UTC113
  }
dda384a360e0atagen2026-10-25 10:27 UTC114
dda384a360e0atagen2026-10-25 10:27 UTC115
  async function placeCaret(at: number): Promise<void> {
dda384a360e0atagen2026-10-25 10:27 UTC116
    await tick();
dda384a360e0atagen2026-10-25 10:27 UTC117
    field?.focus();
dda384a360e0atagen2026-10-25 10:27 UTC118
    field?.setSelectionRange(at, at);
dda384a360e0atagen2026-10-25 10:27 UTC119
  }
dda384a360e0atagen2026-10-25 10:27 UTC120
dda384a360e0atagen2026-10-25 10:27 UTC121
  function updateQueries(): void {
dda384a360e0atagen2026-10-25 10:27 UTC122
    if (!field) return;
dda384a360e0atagen2026-10-25 10:27 UTC123
    const at = caret();
dda384a360e0atagen2026-10-25 10:27 UTC124
    const nextEmote = emoteQueryAt(field.value, at);
dda384a360e0atagen2026-10-25 10:27 UTC125
    if (nextEmote?.start !== emoteQuery?.start) emoteIndex = 0;
dda384a360e0atagen2026-10-25 10:27 UTC126
    emoteQuery = nextEmote;
dda384a360e0atagen2026-10-25 10:27 UTC127
    const nextCommand = commands ? commandQueryAt(field.value, at) : null;
dda384a360e0atagen2026-10-25 10:27 UTC128
    if (nextCommand !== commandQuery) commandIndex = 0;
dda384a360e0atagen2026-10-25 10:27 UTC129
    commandQuery = nextCommand;
dda384a360e0atagen2026-10-25 10:27 UTC130
    const nextMention = mentionQueryAt(field.value, at);
dda384a360e0atagen2026-10-25 10:27 UTC131
    if (nextMention && !mentionQuery) onMentionStart();
dda384a360e0atagen2026-10-25 10:27 UTC132
    if (nextMention?.start !== mentionQuery?.start) mentionIndex = 0;
dda384a360e0atagen2026-10-25 10:27 UTC133
    mentionQuery = nextMention;
dda384a360e0atagen2026-10-25 10:27 UTC134
  }
dda384a360e0atagen2026-10-25 10:27 UTC135
dda384a360e0atagen2026-10-25 10:27 UTC136
  async function onInput(): Promise<void> {
dda384a360e0atagen2026-10-25 10:27 UTC137
    if (field && value.includes(':')) {
dda384a360e0atagen2026-10-25 10:27 UTC138
      ensureEmoji();
dda384a360e0atagen2026-10-25 10:27 UTC139
      const done = completeShortcode(field.value, caret(), emojiList);
dda384a360e0atagen2026-10-25 10:27 UTC140
      if (done) {
dda384a360e0atagen2026-10-25 10:27 UTC141
        value = done.text;
dda384a360e0atagen2026-10-25 10:27 UTC142
        await placeCaret(done.caret);
dda384a360e0atagen2026-10-25 10:27 UTC143
      }
dda384a360e0atagen2026-10-25 10:27 UTC144
    }
dda384a360e0atagen2026-10-25 10:27 UTC145
    updateQueries();
dda384a360e0atagen2026-10-25 10:27 UTC146
  }
dda384a360e0atagen2026-10-25 10:27 UTC147
dda384a360e0atagen2026-10-25 10:27 UTC148
  async function pickCommand(name: CommandName): Promise<void> {
dda384a360e0atagen2026-10-25 10:27 UTC149
    const rest = value.slice(caret()).replace(/^\S*/, '');
dda384a360e0atagen2026-10-25 10:27 UTC150
    value = `/${name}${rest.startsWith(' ') ? rest : ` ${rest}`}`;
dda384a360e0atagen2026-10-25 10:27 UTC151
    commandQuery = null;
dda384a360e0atagen2026-10-25 10:27 UTC152
    await placeCaret(name.length + 2);
dda384a360e0atagen2026-10-25 10:27 UTC153
  }
dda384a360e0atagen2026-10-25 10:27 UTC154
dda384a360e0atagen2026-10-25 10:27 UTC155
  async function pickEmote(candidate: EmoteCandidate): Promise<void> {
dda384a360e0atagen2026-10-25 10:27 UTC156
    if (!emoteQuery) return;
dda384a360e0atagen2026-10-25 10:27 UTC157
    const inserted = candidate.kind === 'emoji' ? candidate.emoji : `:${candidate.shortcode}: `;
dda384a360e0atagen2026-10-25 10:27 UTC158
    const start = emoteQuery.start;
dda384a360e0atagen2026-10-25 10:27 UTC159
    value = value.slice(0, start) + inserted + value.slice(caret());
dda384a360e0atagen2026-10-25 10:27 UTC160
    emoteQuery = null;
dda384a360e0atagen2026-10-25 10:27 UTC161
    await placeCaret(start + inserted.length);
dda384a360e0atagen2026-10-25 10:27 UTC162
  }
dda384a360e0atagen2026-10-25 10:27 UTC163
dda384a360e0atagen2026-10-25 10:27 UTC164
  async function pickMention(person: Person): Promise<void> {
dda384a360e0atagen2026-10-25 10:27 UTC165
    if (!mentionQuery) return;
dda384a360e0atagen2026-10-25 10:27 UTC166
    const inserted = `${person.name} `;
dda384a360e0atagen2026-10-25 10:27 UTC167
    const start = mentionQuery.start;
dda384a360e0atagen2026-10-25 10:27 UTC168
    value = value.slice(0, start) + inserted + value.slice(caret());
dda384a360e0atagen2026-10-25 10:27 UTC169
    onPick({ user_id: person.user_id, text: person.name });
dda384a360e0atagen2026-10-25 10:27 UTC170
    mentionQuery = null;
dda384a360e0atagen2026-10-25 10:27 UTC171
    await placeCaret(start + inserted.length);
dda384a360e0atagen2026-10-25 10:27 UTC172
  }
dda384a360e0atagen2026-10-25 10:27 UTC173
dda384a360e0atagen2026-10-25 10:27 UTC174
  function step<T>(list: T[], index: number, key: string): number {
dda384a360e0atagen2026-10-25 10:27 UTC175
    return (index + (key === 'ArrowDown' ? 1 : -1) + list.length) % list.length;
dda384a360e0atagen2026-10-25 10:27 UTC176
  }
dda384a360e0atagen2026-10-25 10:27 UTC177
dda384a360e0atagen2026-10-25 10:27 UTC178
  function onKey(ev: KeyboardEvent): void {
dda384a360e0atagen2026-10-25 10:27 UTC179
    if (ev.isComposing || composing) return;
dda384a360e0atagen2026-10-25 10:27 UTC180
    if (emoteQuery && emoteCandidates.length > 0) {
dda384a360e0atagen2026-10-25 10:27 UTC181
      if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
dda384a360e0atagen2026-10-25 10:27 UTC182
        ev.preventDefault();
dda384a360e0atagen2026-10-25 10:27 UTC183
        emoteIndex = step(emoteCandidates, emoteIndex, ev.key);
dda384a360e0atagen2026-10-25 10:27 UTC184
        return;
dda384a360e0atagen2026-10-25 10:27 UTC185
      }
dda384a360e0atagen2026-10-25 10:27 UTC186
      if ((ev.key === 'Enter' && !ev.shiftKey) || ev.key === 'Tab') {
dda384a360e0atagen2026-10-25 10:27 UTC187
        ev.preventDefault();
dda384a360e0atagen2026-10-25 10:27 UTC188
        void pickEmote(emoteCandidates[emoteIndex]);
dda384a360e0atagen2026-10-25 10:27 UTC189
        return;
dda384a360e0atagen2026-10-25 10:27 UTC190
      }
dda384a360e0atagen2026-10-25 10:27 UTC191
      if (ev.key === 'Escape') {
dda384a360e0atagen2026-10-25 10:27 UTC192
        ev.stopPropagation();
dda384a360e0atagen2026-10-25 10:27 UTC193
        emoteQuery = null;
dda384a360e0atagen2026-10-25 10:27 UTC194
        return;
dda384a360e0atagen2026-10-25 10:27 UTC195
      }
dda384a360e0atagen2026-10-25 10:27 UTC196
    }
dda384a360e0atagen2026-10-25 10:27 UTC197
    if (commandQuery !== null && commandCandidates.length > 0) {
dda384a360e0atagen2026-10-25 10:27 UTC198
      if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
dda384a360e0atagen2026-10-25 10:27 UTC199
        ev.preventDefault();
dda384a360e0atagen2026-10-25 10:27 UTC200
        commandIndex = step(commandCandidates, commandIndex, ev.key);
dda384a360e0atagen2026-10-25 10:27 UTC201
        return;
dda384a360e0atagen2026-10-25 10:27 UTC202
      }
dda384a360e0atagen2026-10-25 10:27 UTC203
      const exact = commandCandidates.length === 1 && commandCandidates[0] === commandQuery;
dda384a360e0atagen2026-10-25 10:27 UTC204
      if (ev.key === 'Tab' || (ev.key === 'Enter' && !ev.shiftKey && !exact)) {
dda384a360e0atagen2026-10-25 10:27 UTC205
        ev.preventDefault();
dda384a360e0atagen2026-10-25 10:27 UTC206
        void pickCommand(commandCandidates[commandIndex]);
dda384a360e0atagen2026-10-25 10:27 UTC207
        return;
dda384a360e0atagen2026-10-25 10:27 UTC208
      }
dda384a360e0atagen2026-10-25 10:27 UTC209
      if (ev.key === 'Escape') {
dda384a360e0atagen2026-10-25 10:27 UTC210
        ev.stopPropagation();
dda384a360e0atagen2026-10-25 10:27 UTC211
        commandQuery = null;
dda384a360e0atagen2026-10-25 10:27 UTC212
        return;
dda384a360e0atagen2026-10-25 10:27 UTC213
      }
dda384a360e0atagen2026-10-25 10:27 UTC214
    }
dda384a360e0atagen2026-10-25 10:27 UTC215
    if (mentionQuery && mentionCandidates.length > 0) {
dda384a360e0atagen2026-10-25 10:27 UTC216
      if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
dda384a360e0atagen2026-10-25 10:27 UTC217
        ev.preventDefault();
dda384a360e0atagen2026-10-25 10:27 UTC218
        mentionIndex = step(mentionCandidates, mentionIndex, ev.key);
dda384a360e0atagen2026-10-25 10:27 UTC219
        return;
dda384a360e0atagen2026-10-25 10:27 UTC220
      }
dda384a360e0atagen2026-10-25 10:27 UTC221
      if ((ev.key === 'Enter' && !ev.shiftKey) || ev.key === 'Tab') {
dda384a360e0atagen2026-10-25 10:27 UTC222
        ev.preventDefault();
dda384a360e0atagen2026-10-25 10:27 UTC223
        void pickMention(mentionCandidates[mentionIndex]);
dda384a360e0atagen2026-10-25 10:27 UTC224
        return;
dda384a360e0atagen2026-10-25 10:27 UTC225
      }
dda384a360e0atagen2026-10-25 10:27 UTC226
      if (ev.key === 'Escape') {
dda384a360e0atagen2026-10-25 10:27 UTC227
        ev.stopPropagation();
dda384a360e0atagen2026-10-25 10:27 UTC228
        mentionQuery = null;
dda384a360e0atagen2026-10-25 10:27 UTC229
        return;
dda384a360e0atagen2026-10-25 10:27 UTC230
      }
dda384a360e0atagen2026-10-25 10:27 UTC231
    }
dda384a360e0atagen2026-10-25 10:27 UTC232
    if (ev.key === 'Enter' && !ev.shiftKey && !insideOpenFence(value, caret())) {
dda384a360e0atagen2026-10-25 10:27 UTC233
      ev.preventDefault();
dda384a360e0atagen2026-10-25 10:27 UTC234
      onSubmit();
dda384a360e0atagen2026-10-25 10:27 UTC235
    } else if (ev.key === 'Escape' && editing) {
dda384a360e0atagen2026-10-25 10:27 UTC236
      ev.stopPropagation();
dda384a360e0atagen2026-10-25 10:27 UTC237
      onCancelEdit();
dda384a360e0atagen2026-10-25 10:27 UTC238
    } else if (ev.key === 'Escape' && replyingTo) {
dda384a360e0atagen2026-10-25 10:27 UTC239
      ev.stopPropagation();
dda384a360e0atagen2026-10-25 10:27 UTC240
      onCancelReply();
dda384a360e0atagen2026-10-25 10:27 UTC241
    } else if (ev.key === 'ArrowUp' && !value && !editing && onEditLast()) {
dda384a360e0atagen2026-10-25 10:27 UTC242
      ev.preventDefault();
dda384a360e0atagen2026-10-25 10:27 UTC243
    }
dda384a360e0atagen2026-10-25 10:27 UTC244
  }
dda384a360e0atagen2026-10-25 10:27 UTC245
dda384a360e0atagen2026-10-25 10:27 UTC246
  function onPaste(ev: ClipboardEvent): void {
dda384a360e0atagen2026-10-25 10:27 UTC247
    const data = ev.clipboardData;
dda384a360e0atagen2026-10-25 10:27 UTC248
    // Office apps put a rendered image next to the copied text, so only take the image when there is no plain text.
dda384a360e0atagen2026-10-25 10:27 UTC249
    if (!data || data.types.includes('text/plain')) return;
dda384a360e0atagen2026-10-25 10:27 UTC250
    const images = Array.from(data.files).filter((f) => f.type.startsWith('image/'));
dda384a360e0atagen2026-10-25 10:27 UTC251
    if (images.length === 0) return;
dda384a360e0atagen2026-10-25 10:27 UTC252
    ev.preventDefault();
dda384a360e0atagen2026-10-25 10:27 UTC253
    onStage(images);
dda384a360e0atagen2026-10-25 10:27 UTC254
  }
dda384a360e0atagen2026-10-25 10:27 UTC255
dda384a360e0atagen2026-10-25 10:27 UTC256
  function onFileChosen(): void {
dda384a360e0atagen2026-10-25 10:27 UTC257
    onStage(Array.from(fileInput?.files ?? []));
dda384a360e0atagen2026-10-25 10:27 UTC258
    if (fileInput) fileInput.value = '';
dda384a360e0atagen2026-10-25 10:27 UTC259
  }
dda384a360e0atagen2026-10-25 10:27 UTC260
dda384a360e0atagen2026-10-25 10:27 UTC261
  function onCaretKey(e: KeyboardEvent): void {
dda384a360e0atagen2026-10-25 10:27 UTC262
    if ((e.key.startsWith('Arrow') && e.key !== 'ArrowUp' && e.key !== 'ArrowDown') || e.key === 'Home' || e.key === 'End') updateQueries();
dda384a360e0atagen2026-10-25 10:27 UTC263
  }
dda384a360e0atagen2026-10-25 10:27 UTC264
</script>
dda384a360e0atagen2026-10-25 10:27 UTC265
dda384a360e0atagen2026-10-25 10:27 UTC266
{#if editing}
dda384a360e0atagen2026-10-25 10:27 UTC267
  <div class="reply-bar" role="status">
dda384a360e0atagen2026-10-25 10:27 UTC268
    <span class="reply-bar-label">
dda384a360e0atagen2026-10-25 10:27 UTC269
      Editing message
dda384a360e0atagen2026-10-25 10:27 UTC270
      <span class="reply-bar-text"><EmojiText text={editing.original} /></span>
dda384a360e0atagen2026-10-25 10:27 UTC271
    </span>
dda384a360e0atagen2026-10-25 10:27 UTC272
    <button type="button" class="icon-btn" aria-label="Cancel edit" title="Cancel edit (Esc)" on:click={onCancelEdit}>
dda384a360e0atagen2026-10-25 10:27 UTC273
      <span aria-hidden="true">✕</span>
dda384a360e0atagen2026-10-25 10:27 UTC274
    </button>
dda384a360e0atagen2026-10-25 10:27 UTC275
  </div>
dda384a360e0atagen2026-10-25 10:27 UTC276
{:else if replyingTo && replyingTo.details.kind === 'Ready'}
dda384a360e0atagen2026-10-25 10:27 UTC277
  <div class="reply-bar" role="status">
dda384a360e0atagen2026-10-25 10:27 UTC278
    <span class="reply-bar-label">
dda384a360e0atagen2026-10-25 10:27 UTC279
      Replying to <strong><EmojiText text={people[replyingTo.details.sender.user_id]?.name ?? senderLabel(replyingTo.details.sender)} /></strong>
dda384a360e0atagen2026-10-25 10:27 UTC280
      <span class="reply-bar-text"><EmojiText text={contentPreview(replyingTo.details.content)} /></span>
dda384a360e0atagen2026-10-25 10:27 UTC281
    </span>
dda384a360e0atagen2026-10-25 10:27 UTC282
    <button type="button" class="icon-btn" aria-label="Cancel reply" title="Cancel reply (Esc)" on:click={onCancelReply}>
dda384a360e0atagen2026-10-25 10:27 UTC283
      <span aria-hidden="true">✕</span>
dda384a360e0atagen2026-10-25 10:27 UTC284
    </button>
dda384a360e0atagen2026-10-25 10:27 UTC285
  </div>
dda384a360e0atagen2026-10-25 10:27 UTC286
{/if}
dda384a360e0atagen2026-10-25 10:27 UTC287
{#if staged.length > 0}
dda384a360e0atagen2026-10-25 10:27 UTC288
  <ul class="staged" aria-label="Attachments to send">
dda384a360e0atagen2026-10-25 10:27 UTC289
    {#each staged as attachment (attachment.id)}
dda384a360e0atagen2026-10-25 10:27 UTC290
      <li class="staged-item">
dda384a360e0atagen2026-10-25 10:27 UTC291
        {#if attachment.previewUrl}
dda384a360e0atagen2026-10-25 10:27 UTC292
          <img src={attachment.previewUrl} alt={attachment.file.name || 'Pasted image'} />
dda384a360e0atagen2026-10-25 10:27 UTC293
        {:else}
dda384a360e0atagen2026-10-25 10:27 UTC294
          <span class="staged-name"><EmojiText text={attachment.file.name || 'attachment'} /></span>
dda384a360e0atagen2026-10-25 10:27 UTC295
        {/if}
dda384a360e0atagen2026-10-25 10:27 UTC296
        <button
dda384a360e0atagen2026-10-25 10:27 UTC297
          type="button"
dda384a360e0atagen2026-10-25 10:27 UTC298
          class="icon-btn staged-remove"
dda384a360e0atagen2026-10-25 10:27 UTC299
          aria-label={`Remove ${attachment.file.name || 'attachment'}`}
dda384a360e0atagen2026-10-25 10:27 UTC300
          title="Remove"
dda384a360e0atagen2026-10-25 10:27 UTC301
          on:click={() => onUnstage(attachment.id)}
dda384a360e0atagen2026-10-25 10:27 UTC302
        >
dda384a360e0atagen2026-10-25 10:27 UTC303
          <span aria-hidden="true">✕</span>
dda384a360e0atagen2026-10-25 10:27 UTC304
        </button>
dda384a360e0atagen2026-10-25 10:27 UTC305
      </li>
dda384a360e0atagen2026-10-25 10:27 UTC306
    {/each}
dda384a360e0atagen2026-10-25 10:27 UTC307
  </ul>
dda384a360e0atagen2026-10-25 10:27 UTC308
{/if}
dda384a360e0atagen2026-10-25 10:27 UTC309
<form class="composer" class:replying={replyingTo || editing} class:staging={staged.length > 0} on:submit|preventDefault={onSubmit}>
dda384a360e0atagen2026-10-25 10:27 UTC310
  <label class="visually-hidden" for={inputId}>{label}</label>
dda384a360e0atagen2026-10-25 10:27 UTC311
  <input type="file" multiple bind:this={fileInput} class="visually-hidden" aria-label="Attach a file" on:change={onFileChosen} />
dda384a360e0atagen2026-10-25 10:27 UTC312
  <button
dda384a360e0atagen2026-10-25 10:27 UTC313
    type="button"
dda384a360e0atagen2026-10-25 10:27 UTC314
    class="icon-btn"
dda384a360e0atagen2026-10-25 10:27 UTC315
    class:busy={uploadBusy}
dda384a360e0atagen2026-10-25 10:27 UTC316
    title={uploadBusy ? `Uploading ${uploadName ?? 'file'}…` : 'Attach an image or file'}
dda384a360e0atagen2026-10-25 10:27 UTC317
    aria-label="Attach an image or file"
dda384a360e0atagen2026-10-25 10:27 UTC318
    aria-busy={uploadBusy}
dda384a360e0atagen2026-10-25 10:27 UTC319
    disabled={!ready}
dda384a360e0atagen2026-10-25 10:27 UTC320
    on:click={() => fileInput?.click()}
dda384a360e0atagen2026-10-25 10:27 UTC321
  >
dda384a360e0atagen2026-10-25 10:27 UTC322
    <svg viewBox="0 0 24 24" width="19" height="19" aria-hidden="true">
dda384a360e0atagen2026-10-25 10:27 UTC323
      <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" />
dda384a360e0atagen2026-10-25 10:27 UTC324
    </svg>
dda384a360e0atagen2026-10-25 10:27 UTC325
  </button>
dda384a360e0atagen2026-10-25 10:27 UTC326
  {#if stickers}
dda384a360e0atagen2026-10-25 10:27 UTC327
    {@const handlers = stickers}
dda384a360e0atagen2026-10-25 10:27 UTC328
    <button
dda384a360e0atagen2026-10-25 10:27 UTC329
      type="button"
dda384a360e0atagen2026-10-25 10:27 UTC330
      class="icon-btn sticker-btn"
dda384a360e0atagen2026-10-25 10:27 UTC331
      title="Stickers"
dda384a360e0atagen2026-10-25 10:27 UTC332
      aria-label="Stickers"
dda384a360e0atagen2026-10-25 10:27 UTC333
      aria-expanded={stickersOpen}
dda384a360e0atagen2026-10-25 10:27 UTC334
      disabled={!ready}
dda384a360e0atagen2026-10-25 10:27 UTC335
      on:click={() => (stickersOpen = !stickersOpen)}
dda384a360e0atagen2026-10-25 10:27 UTC336
    >
dda384a360e0atagen2026-10-25 10:27 UTC337
      <svg viewBox="0 0 24 24" width="19" height="19" aria-hidden="true">
dda384a360e0atagen2026-10-25 10:27 UTC338
        <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" />
dda384a360e0atagen2026-10-25 10:27 UTC339
        <path d="M13 20v-5a2 2 0 0 1 2-2h5" fill="none" stroke="currentColor" stroke-width="1.8" />
dda384a360e0atagen2026-10-25 10:27 UTC340
      </svg>
dda384a360e0atagen2026-10-25 10:27 UTC341
    </button>
dda384a360e0atagen2026-10-25 10:27 UTC342
    {#if stickersOpen}
dda384a360e0atagen2026-10-25 10:27 UTC343
      <div class="sticker-pop">
dda384a360e0atagen2026-10-25 10:27 UTC344
        <StickerPicker
dda384a360e0atagen2026-10-25 10:27 UTC345
          {packs}
dda384a360e0atagen2026-10-25 10:27 UTC346
          roomName={handlers.roomName}
dda384a360e0atagen2026-10-25 10:27 UTC347
          onSend={(pack, image) => ((stickersOpen = false), handlers.onSend(pack, image))}
dda384a360e0atagen2026-10-25 10:27 UTC348
          onClone={(pack, image, x, y) => ((stickersOpen = false), handlers.onClone(pack, image, x, y))}
dda384a360e0atagen2026-10-25 10:27 UTC349
          onClonePack={(pack, x, y) => ((stickersOpen = false), handlers.onClonePack(pack, x, y))}
dda384a360e0atagen2026-10-25 10:27 UTC350
          onManage={() => ((stickersOpen = false), handlers.onManage())}
dda384a360e0atagen2026-10-25 10:27 UTC351
          onClose={() => (stickersOpen = false)}
dda384a360e0atagen2026-10-25 10:27 UTC352
        />
dda384a360e0atagen2026-10-25 10:27 UTC353
      </div>
dda384a360e0atagen2026-10-25 10:27 UTC354
    {/if}
dda384a360e0atagen2026-10-25 10:27 UTC355
  {/if}
dda384a360e0atagen2026-10-25 10:27 UTC356
  <div class="composer-field" class:composing>
dda384a360e0atagen2026-10-25 10:27 UTC357
    {#if commandQuery !== null && commandCandidates.length > 0}
dda384a360e0atagen2026-10-25 10:27 UTC358
      <ul class="mention-finder" role="listbox" aria-label="Commands">
dda384a360e0atagen2026-10-25 10:27 UTC359
        {#each commandCandidates as name, i (name)}
dda384a360e0atagen2026-10-25 10:27 UTC360
          <li
dda384a360e0atagen2026-10-25 10:27 UTC361
            role="option"
dda384a360e0atagen2026-10-25 10:27 UTC362
            aria-selected={i === commandIndex}
dda384a360e0atagen2026-10-25 10:27 UTC363
            class:active={i === commandIndex}
dda384a360e0atagen2026-10-25 10:27 UTC364
            on:pointerdown|preventDefault={() => void pickCommand(name)}
dda384a360e0atagen2026-10-25 10:27 UTC365
            on:pointerenter={() => (commandIndex = i)}
dda384a360e0atagen2026-10-25 10:27 UTC366
          >
dda384a360e0atagen2026-10-25 10:27 UTC367
            <span class="mention-name">/{name}</span>
dda384a360e0atagen2026-10-25 10:27 UTC368
            <span class="mention-id">{COMMAND_USAGE[name]}</span>
dda384a360e0atagen2026-10-25 10:27 UTC369
          </li>
dda384a360e0atagen2026-10-25 10:27 UTC370
        {/each}
dda384a360e0atagen2026-10-25 10:27 UTC371
      </ul>
dda384a360e0atagen2026-10-25 10:27 UTC372
    {/if}
dda384a360e0atagen2026-10-25 10:27 UTC373
    {#if emoteQuery && emoteCandidates.length > 0}
dda384a360e0atagen2026-10-25 10:27 UTC374
      <ul class="mention-finder" role="listbox" aria-label="Emotes">
dda384a360e0atagen2026-10-25 10:27 UTC375
        {#each emoteCandidates as candidate, i (candidate.kind + candidate.shortcode)}
dda384a360e0atagen2026-10-25 10:27 UTC376
          <li
dda384a360e0atagen2026-10-25 10:27 UTC377
            role="option"
dda384a360e0atagen2026-10-25 10:27 UTC378
            aria-selected={i === emoteIndex}
dda384a360e0atagen2026-10-25 10:27 UTC379
            class:active={i === emoteIndex}
dda384a360e0atagen2026-10-25 10:27 UTC380
            on:pointerdown|preventDefault={() => void pickEmote(candidate)}
dda384a360e0atagen2026-10-25 10:27 UTC381
            on:pointerenter={() => (emoteIndex = i)}
dda384a360e0atagen2026-10-25 10:27 UTC382
          >
dda384a360e0atagen2026-10-25 10:27 UTC383
            {#if candidate.kind === 'custom'}
dda384a360e0atagen2026-10-25 10:27 UTC384
              <img class="finder-emote" src={mediaUrl(candidate.mxc, 64) ?? ''} alt="" />
dda384a360e0atagen2026-10-25 10:27 UTC385
            {:else}
dda384a360e0atagen2026-10-25 10:27 UTC386
              <span class="finder-emote"><EmojiText text={candidate.emoji} /></span>
dda384a360e0atagen2026-10-25 10:27 UTC387
            {/if}
dda384a360e0atagen2026-10-25 10:27 UTC388
            <span class="mention-name">:{candidate.shortcode}:</span>
dda384a360e0atagen2026-10-25 10:27 UTC389
          </li>
dda384a360e0atagen2026-10-25 10:27 UTC390
        {/each}
dda384a360e0atagen2026-10-25 10:27 UTC391
      </ul>
dda384a360e0atagen2026-10-25 10:27 UTC392
    {/if}
dda384a360e0atagen2026-10-25 10:27 UTC393
    {#if mentionQuery && mentionCandidates.length > 0}
dda384a360e0atagen2026-10-25 10:27 UTC394
      <ul class="mention-finder" role="listbox" aria-label="Mention someone">
dda384a360e0atagen2026-10-25 10:27 UTC395
        {#each mentionCandidates as person, i (person.user_id)}
dda384a360e0atagen2026-10-25 10:27 UTC396
          <li
dda384a360e0atagen2026-10-25 10:27 UTC397
            role="option"
dda384a360e0atagen2026-10-25 10:27 UTC398
            aria-selected={i === mentionIndex}
dda384a360e0atagen2026-10-25 10:27 UTC399
            class:active={i === mentionIndex}
dda384a360e0atagen2026-10-25 10:27 UTC400
            on:pointerdown|preventDefault={() => void pickMention(person)}
dda384a360e0atagen2026-10-25 10:27 UTC401
            on:pointerenter={() => (mentionIndex = i)}
dda384a360e0atagen2026-10-25 10:27 UTC402
          >
dda384a360e0atagen2026-10-25 10:27 UTC403
            <MxcAvatar mxc={person.avatar} label={person.name} size={22} />
dda384a360e0atagen2026-10-25 10:27 UTC404
            <span class="mention-name"><EmojiText text={person.name} /></span>
dda384a360e0atagen2026-10-25 10:27 UTC405
            <span class="mention-id">{person.user_id}</span>
dda384a360e0atagen2026-10-25 10:27 UTC406
          </li>
dda384a360e0atagen2026-10-25 10:27 UTC407
        {/each}
dda384a360e0atagen2026-10-25 10:27 UTC408
      </ul>
dda384a360e0atagen2026-10-25 10:27 UTC409
    {/if}
dda384a360e0atagen2026-10-25 10:27 UTC410
    <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>
dda384a360e0atagen2026-10-25 10:27 UTC411
    <textarea
dda384a360e0atagen2026-10-25 10:27 UTC412
      id={inputId}
dda384a360e0atagen2026-10-25 10:27 UTC413
      bind:this={field}
dda384a360e0atagen2026-10-25 10:27 UTC414
      bind:value
dda384a360e0atagen2026-10-25 10:27 UTC415
      use:syncMarks
dda384a360e0atagen2026-10-25 10:27 UTC416
      rows="1"
dda384a360e0atagen2026-10-25 10:27 UTC417
      {placeholder}
dda384a360e0atagen2026-10-25 10:27 UTC418
      aria-label={`${label}. Enter to send, Shift Enter for a new line.`}
dda384a360e0atagen2026-10-25 10:27 UTC419
      on:keydown={onKey}
dda384a360e0atagen2026-10-25 10:27 UTC420
      on:input={() => void onInput()}
dda384a360e0atagen2026-10-25 10:27 UTC421
      on:compositionstart={() => (composing = true)}
dda384a360e0atagen2026-10-25 10:27 UTC422
      on:compositionend={() => (composing = false)}
dda384a360e0atagen2026-10-25 10:27 UTC423
      on:click={updateQueries}
dda384a360e0atagen2026-10-25 10:27 UTC424
      on:focus={ensureEmoji}
dda384a360e0atagen2026-10-25 10:27 UTC425
      on:keyup={onCaretKey}
dda384a360e0atagen2026-10-25 10:27 UTC426
      on:blur={() => ((mentionQuery = null), (commandQuery = null), (emoteQuery = null), (composing = false))}
dda384a360e0atagen2026-10-25 10:27 UTC427
      on:paste={onPaste}
dda384a360e0atagen2026-10-25 10:27 UTC428
      disabled={!ready}
dda384a360e0atagen2026-10-25 10:27 UTC429
    ></textarea>
dda384a360e0atagen2026-10-25 10:27 UTC430
  </div>
dda384a360e0atagen2026-10-25 10:27 UTC431
  <button
dda384a360e0atagen2026-10-25 10:27 UTC432
    type="submit"
dda384a360e0atagen2026-10-25 10:27 UTC433
    class="primary-btn"
dda384a360e0atagen2026-10-25 10:27 UTC434
    disabled={(!value.trim() && staged.length === 0) || !ready || (staged.length > 0 && uploadBusy)}
dda384a360e0atagen2026-10-25 10:27 UTC435
    title={ready ? readyTitle : 'Waiting for the room to open'}
dda384a360e0atagen2026-10-25 10:27 UTC436
  >
dda384a360e0atagen2026-10-25 10:27 UTC437
    {editing ? 'Save' : 'Send'}
dda384a360e0atagen2026-10-25 10:27 UTC438
  </button>
dda384a360e0atagen2026-10-25 10:27 UTC439
</form>