atagen

malice

public
Star0
Watch0
38a9760b78e47d373dfb35d3882a586dc9587211
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
web/src/lib/ReactionPicker.svelteSvelte334 lines12.4 KB
1
<script lang="ts">
2
  import EmojiText from './EmojiText.svelte';
3
  import { onMount, tick } from 'svelte';
4
  import { mediaUrl, messageMediaUrl } from './api';
5
  import Emoji from './Emoji.svelte';
6
  import { loadEmoji, searchEmoji } from './emoji';
7
  import type { EmojiGroup } from './emoji';
8
  import type { ImagePack, PackImage, QuickEntry, QuickSeparator } from './types';
9
  import { hasUsage } from './packs';
10
  import { isSeparator, normaliseQuick, sameQuick } from './quick-reactions-model';
11
  import { flipAfter, reorder } from './reorder';
12
  import { moveBy, moveToSlot } from './reorder-model';
13
14
  export let packs: ImagePack[];
15
  export let quick: QuickEntry[];
16
  export let onPick: (key: string, shortcode: string | null) => void;
17
  export let onQuickChange: (list: QuickEntry[]) => void;
18
  export let onClose: () => void;
19
  export let embedded = false;
20
21
  let root: HTMLDivElement;
22
  let input: HTMLInputElement;
23
  let row: HTMLDivElement;
24
  let query = '';
25
  let customising = embedded;
26
  let groups: EmojiGroup[] | null = null;
27
  let loadFailed = false;
28
  let draftSeparator: QuickSeparator | null = null;
29
  const separatorIds = new WeakMap<QuickSeparator, number>();
30
  let separatorCount = 0;
31
32
  onMount(() => {
33
    if (!embedded) input.focus();
34
    loadEmoji().then(
35
      (g) => (groups = g),
36
      () => (loadFailed = true)
37
    );
38
  });
39
40
  $: q = query.trim().toLowerCase().replace(/^:|:$/g, '');
41
  $: filteredPacks = packs
42
    .map((pack) => ({ pack, images: pack.images.filter((i) => imageMatches(i, q)) }))
43
    .filter((p) => p.images.length > 0);
44
  $: filteredGroups = searchEmoji(groups ?? [], q);
45
  $: working = draftSeparator ? [...quick, draftSeparator] : quick;
46
  $: canSeparate = working.length > 0 && !isSeparator(working[working.length - 1]);
47
  $: nothingFound = q !== '' && groups !== null && filteredPacks.length === 0 && filteredGroups.length === 0;
48
49
  function imageMatches(image: PackImage, needle: string): boolean {
50
    if (!hasUsage(image, 'Emoticon')) return false;
51
    if (!needle) return true;
52
    return image.shortcode.toLowerCase().includes(needle) || (image.body ?? '').toLowerCase().includes(needle);
53
  }
54
55
  function packName(pack: ImagePack): string {
56
    return pack.display_name ?? 'Emoji pack';
57
  }
58
59
  function isQuick(key: string): boolean {
60
    return quick.some((r) => !isSeparator(r) && r.key === key);
61
  }
62
63
  function commit(list: QuickEntry[]): void {
64
    const next = normaliseQuick(list);
65
    draftSeparator = null;
66
    if (!sameQuick(next, quick)) onQuickChange(next);
67
  }
68
69
  function choose(key: string, shortcode: string | null): void {
70
    if (!customising) {
71
      onPick(key, shortcode);
72
      return;
73
    }
74
    if (isQuick(key)) commit(working.filter((r) => isSeparator(r) || r.key !== key));
75
    else commit([...working, { key, shortcode }]);
76
  }
77
78
  function entryKey(entry: QuickEntry): string {
79
    if (!isSeparator(entry)) return entry.key;
80
    let id = separatorIds.get(entry);
81
    if (id === undefined) separatorIds.set(entry, (id = separatorCount++));
82
    return `separator:${id}`;
83
  }
84
85
  function entryLabel(entry: QuickEntry): string {
86
    if (isSeparator(entry)) return 'separator';
87
    return entry.shortcode ? `:${entry.shortcode}:` : entry.key;
88
  }
89
90
  function remove(index: number): void {
91
    commit(working.filter((_, i) => i !== index));
92
  }
93
94
  function moveItem(from: number, slot: number): void {
95
    const next = moveToSlot(working, from, slot);
96
    if (next !== working) commit(next);
97
  }
98
99
  async function onChipKeydown(e: KeyboardEvent, index: number, entry: QuickEntry): Promise<void> {
100
    if (!(e.altKey || e.shiftKey) || (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight')) return;
101
    e.preventDefault();
102
    const next = moveBy(working, index, e.key === 'ArrowLeft' ? -1 : 1);
103
    if (next === working) return;
104
    const id = entryKey(entry);
105
    void flipAfter(row, () => commit(next));
106
    await tick();
107
    const grabs = row.querySelectorAll<HTMLElement>('.grab');
108
    (row.querySelector<HTMLElement>(`[data-reorder="${CSS.escape(id)}"] .grab`) ?? grabs[Math.min(index, grabs.length - 1)])?.focus();
109
  }
110
111
  function onKeydown(e: KeyboardEvent): void {
112
    if (e.key !== 'Escape') return;
113
    e.stopPropagation();
114
    onClose();
115
  }
116
117
  function closeOutside(e: PointerEvent): void {
118
    if (!embedded && !root.contains(e.target as Node)) onClose();
119
  }
120
</script>
121
122
<svelte:window on:pointerdown={closeOutside} />
123
124
<div
125
  class="picker"
126
  class:embedded
127
  role="dialog"
128
  tabindex="-1"
129
  aria-label={customising ? 'Customise quick reactions' : 'Pick a reaction'}
130
  bind:this={root}
131
  on:keydown={onKeydown}
132
>
133
  <input
134
    class="search"
135
    type="search"
136
    placeholder="Search emoji"
137
    aria-label="Search emoji"
138
    autocomplete="off"
139
    bind:this={input}
140
    bind:value={query}
141
  />
142
143
  {#if customising}
144
    <div class="quick" role="group" aria-label="Quick reactions">
145
      <div class="quick-head">
146
        <span>Quick reactions</span>
147
        <button type="button" class="add-sep" disabled={!canSeparate} on:click={() => (draftSeparator = { separator: true })}>
148
          Add separator
149
        </button>
150
      </div>
151
      <div class="quick-row" bind:this={row} use:reorder={{ onMove: moveItem }}>
152
        {#each working as entry, i (entryKey(entry))}
153
          <span class="quick-item" class:separator={isSeparator(entry)} data-reorder={entryKey(entry)}>
154
            <button
155
              type="button"
156
              class="grab"
157
              data-reorder-handle
158
              aria-label={`Move ${entryLabel(entry)}, ${i + 1} of ${working.length}`}
159
              on:keydown={(e) => onChipKeydown(e, i, entry)}
160
            >
161
              {#if isSeparator(entry)}
162
                <span class="bar"></span>
163
              {:else if entry.key.startsWith('mxc://')}
164
                <img src={mediaUrl(entry.key, 64) ?? ''} alt="" draggable="false" />
165
              {:else}
166
                <Emoji emoji={entry.key} size={18} />
167
              {/if}
168
            </button>
169
            <button
170
              type="button"
171
              class="remove"
172
              aria-label={`Remove ${entryLabel(entry)} from quick reactions`}
173
              on:click={() => remove(i)}
174
            >
175
              ×
176
            </button>
177
          </span>
178
        {:else}
179
          <span class="muted">None. Pick emoji below to add them.</span>
180
        {/each}
181
      </div>
182
    </div>
183
  {/if}
184
185
  <div class="scroll" aria-busy={groups === null && !loadFailed}>
186
    {#each filteredPacks as { pack, images } (pack.id)}
187
      <section class="group" aria-label={packName(pack)}>
188
        <h3><EmojiText text={packName(pack)} /></h3>
189
        <div class="grid">
190
          {#each images as image (image.reaction_key + image.shortcode)}
191
            <button
192
              type="button"
193
              class="cell"
194
              class:chosen={customising && isQuick(image.reaction_key)}
195
              title={`:${image.shortcode}:`}
196
              aria-label={`:${image.shortcode}:`}
197
              aria-pressed={customising ? isQuick(image.reaction_key) : undefined}
198
              on:click={() => choose(image.reaction_key, image.shortcode)}
199
            >
200
              <img src={messageMediaUrl(image.media_id, 'Thumbnail')} alt="" loading="lazy" />
201
            </button>
202
          {/each}
203
        </div>
204
      </section>
205
    {/each}
206
207
    {#if loadFailed}
208
      <p class="status" role="alert">Could not load the emoji list.</p>
209
    {:else if groups === null}
210
      <p class="status" role="status">Loading emoji…</p>
211
    {:else}
212
      {#each filteredGroups as g (g.name)}
213
        <section class="group" aria-label={g.name}>
214
          <h3>{g.name}</h3>
215
          <div class="grid">
216
            {#each g.emojis as e (e.emoji)}
217
              <button
218
                type="button"
219
                class="cell"
220
                class:chosen={customising && isQuick(e.emoji)}
221
                title={e.label}
222
                aria-label={e.label}
223
                aria-pressed={customising ? isQuick(e.emoji) : undefined}
224
                on:click={() => choose(e.emoji, e.shortcodes[0] ?? null)}
225
              >
226
                <Emoji emoji={e.emoji} size={24} lazy />
227
              </button>
228
            {/each}
229
          </div>
230
        </section>
231
      {/each}
232
    {/if}
233
    {#if nothingFound}
234
      <p class="status" role="status">No emoji match "<EmojiText text={query.trim()} />".</p>
235
    {/if}
236
  </div>
237
238
  {#if !embedded}
239
    <div class="foot">
240
      <button type="button" class="mode" aria-pressed={customising} on:click={() => (customising = !customising)}>
241
        {customising ? 'Done' : 'Customise quick reactions'}
242
      </button>
243
    </div>
244
  {/if}
245
</div>
246
247
<style>
248
  .picker.embedded { width: 100%; height: 340px; box-shadow: none; }
249
  .picker {
250
    outline: none;
251
    display: flex; flex-direction: column; gap: 6px;
252
    width: 320px; height: 380px; max-width: 100%; max-height: 100%;
253
    padding: 8px;
254
    background: var(--panel);
255
    border: 1px solid var(--border);
256
    border-radius: var(--r-md);
257
    box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5);
258
  }
259
  .search {
260
    background: var(--bg-soft);
261
    border: 1px solid var(--border);
262
    border-radius: var(--r-md);
263
    color: var(--text);
264
    padding: 6px 10px;
265
    font-size: 13px;
266
  }
267
  .search:focus-visible { outline: none; border-color: var(--accent); }
268
  .muted { color: var(--muted); font-size: 12px; }
269
  .quick { border: 1px solid var(--border); border-radius: var(--r-md); padding: 6px; background: var(--bg-soft); }
270
  .quick-head { display: flex; align-items: center; justify-content: space-between; font-size: 12px; font-weight: 650; margin-bottom: 4px; }
271
  .add-sep {
272
    background: transparent; border: 1px solid var(--border); border-radius: var(--r-sm);
273
    color: var(--text); font-size: 11px; font-weight: 400; padding: 1px 6px; cursor: pointer;
274
    transition: border-color var(--pop);
275
  }
276
  .add-sep:hover:not(:disabled) { border-color: var(--accent); }
277
  .add-sep:disabled { opacity: 0.4; cursor: default; }
278
  .quick-row { position: relative; display: flex; flex-wrap: wrap; gap: 4px; min-height: 28px; align-items: center; }
279
  .quick-item {
280
    display: inline-flex; align-items: center; gap: 2px;
281
    padding: 1px 2px 1px 0;
282
    border: 1px solid var(--border); border-radius: var(--r-sm);
283
    font-size: 16px;
284
    user-select: none;
285
  }
286
  .quick-item.separator { border-style: dashed; }
287
  .quick-item:has(.grab:focus-visible) { border-color: var(--accent); }
288
  .quick-item:global([data-dragging]) {
289
    z-index: 2; background: var(--bg-soft); border-color: var(--accent);
290
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
291
  }
292
  .quick-item img { width: 18px; height: 18px; object-fit: contain; }
293
  .grab {
294
    display: inline-flex; align-items: center; height: 20px; padding: 0 2px 0 6px;
295
    background: transparent; border: none; color: inherit; font: inherit;
296
    cursor: grab; touch-action: none; -webkit-touch-callout: none; user-select: none;
297
  }
298
  .grab:focus-visible { outline: none; }
299
  .quick-item:global([data-dragging]) .grab { cursor: grabbing; }
300
  .separator .grab { padding: 0 6px; }
301
  .bar { width: 1px; height: 14px; background: var(--muted); }
302
  @media (pointer: coarse) {
303
    .grab { height: 30px; }
304
  }
305
  .remove {
306
    background: transparent; border: none; color: var(--muted);
307
    cursor: pointer; font-size: 14px; line-height: 1; padding: 2px 5px; border-radius: var(--r-sm);
308
  }
309
  .remove:hover { color: var(--danger); }
310
  .scroll { flex: 1; min-height: 0; overflow-y: auto; }
311
  .group h3 {
312
    margin: 6px 2px 2px; font-size: 11px; font-weight: 650;
313
    text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted);
314
  }
315
  .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(32px, 1fr)); gap: 2px; }
316
  .cell {
317
    display: grid; place-items: center;
318
    height: 32px; padding: 0;
319
    background: transparent; border: 1px solid transparent; border-radius: var(--r-sm);
320
    font-size: 20px; cursor: pointer; color: var(--text);
321
  }
322
  .cell img { width: 24px; height: 24px; object-fit: contain; }
323
  .cell:hover:not(:disabled), .cell:focus-visible { background: var(--hover); border-color: var(--border); outline: none; }
324
  .cell.chosen { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 16%, transparent); }
325
  .cell:disabled { opacity: 0.3; cursor: default; }
326
  .status { color: var(--muted); font-size: 13px; text-align: center; margin: 16px 0; }
327
  .foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
328
  .mode {
329
    margin-left: auto;
330
    background: transparent; border: 1px solid var(--border); border-radius: var(--r-sm);
331
    color: var(--text); font-size: 12px; padding: 3px 8px; cursor: pointer;
332
  }
333
  .mode:hover, .mode[aria-pressed='true'] { border-color: var(--accent); }
334
</style>