atagen

malice

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