atagen

malice

public
Star0
Watch0
06339d2de37a2adbd7c9697702da5360fad61c36
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
web/src/lib/ReactionPicker.svelteSvelte251 lines8.7 KB
1
<script lang="ts">
2
  import EmojiText from './EmojiText.svelte';
3
  import { onMount } 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, QuickReaction } from './types';
9
  import { hasUsage } from './packs';
10
11
  export let packs: ImagePack[];
12
  export let quick: QuickReaction[];
13
  export let onPick: (key: string, shortcode: string | null) => void;
14
  export let onQuickChange: (list: QuickReaction[]) => void;
15
  export let onClose: () => void;
16
  export let embedded = false;
17
18
  let root: HTMLDivElement;
19
  let input: HTMLInputElement;
20
  let query = '';
21
  let customising = embedded;
22
  let groups: EmojiGroup[] | null = null;
23
  let loadFailed = false;
24
25
  onMount(() => {
26
    if (!embedded) input.focus();
27
    loadEmoji().then(
28
      (g) => (groups = g),
29
      () => (loadFailed = true)
30
    );
31
  });
32
33
  $: q = query.trim().toLowerCase().replace(/^:|:$/g, '');
34
  $: filteredPacks = packs
35
    .map((pack) => ({ pack, images: pack.images.filter((i) => imageMatches(i, q)) }))
36
    .filter((p) => p.images.length > 0);
37
  $: filteredGroups = searchEmoji(groups ?? [], q);
38
  $: nothingFound = q !== '' && groups !== null && filteredPacks.length === 0 && filteredGroups.length === 0;
39
40
  function imageMatches(image: PackImage, needle: string): boolean {
41
    if (!hasUsage(image, 'Emoticon')) return false;
42
    if (!needle) return true;
43
    return image.shortcode.toLowerCase().includes(needle) || (image.body ?? '').toLowerCase().includes(needle);
44
  }
45
46
  function packName(pack: ImagePack): string {
47
    return pack.display_name ?? 'Emoji pack';
48
  }
49
50
  function isQuick(key: string): boolean {
51
    return quick.some((r) => r.key === key);
52
  }
53
54
  function choose(key: string, shortcode: string | null): void {
55
    if (!customising) {
56
      onPick(key, shortcode);
57
      return;
58
    }
59
    if (isQuick(key)) onQuickChange(quick.filter((r) => r.key !== key));
60
    else onQuickChange([...quick, { key, shortcode }]);
61
  }
62
63
  function quickLabel(r: QuickReaction): string {
64
    return r.shortcode ? `:${r.shortcode}:` : r.key;
65
  }
66
67
  function onKeydown(e: KeyboardEvent): void {
68
    if (e.key !== 'Escape') return;
69
    e.stopPropagation();
70
    onClose();
71
  }
72
73
  function closeOutside(e: PointerEvent): void {
74
    if (!embedded && !root.contains(e.target as Node)) onClose();
75
  }
76
</script>
77
78
<svelte:window on:pointerdown={closeOutside} />
79
80
<div
81
  class="picker"
82
  class:embedded
83
  role="dialog"
84
  tabindex="-1"
85
  aria-label={customising ? 'Customise quick reactions' : 'Pick a reaction'}
86
  bind:this={root}
87
  on:keydown={onKeydown}
88
>
89
  <input
90
    class="search"
91
    type="search"
92
    placeholder="Search emoji"
93
    aria-label="Search emoji"
94
    autocomplete="off"
95
    bind:this={input}
96
    bind:value={query}
97
  />
98
99
  {#if customising}
100
    <div class="quick" role="group" aria-label="Quick reactions">
101
      <div class="quick-head">
102
        <span>Quick reactions</span>
103
      </div>
104
      <div class="quick-row">
105
        {#each quick as r (r.key)}
106
          <span class="quick-item">
107
            {#if r.key.startsWith('mxc://')}
108
              <img src={mediaUrl(r.key, 64) ?? ''} alt={quickLabel(r)} />
109
            {:else}
110
              <Emoji emoji={r.key} size={18} />
111
            {/if}
112
            <button
113
              type="button"
114
              class="remove"
115
              aria-label={`Remove ${quickLabel(r)} from quick reactions`}
116
              on:click={() => onQuickChange(quick.filter((x) => x.key !== r.key))}
117
            >
118
              ×
119
            </button>
120
          </span>
121
        {:else}
122
          <span class="muted">None. Pick emoji below to add them.</span>
123
        {/each}
124
      </div>
125
    </div>
126
  {/if}
127
128
  <div class="scroll" aria-busy={groups === null && !loadFailed}>
129
    {#each filteredPacks as { pack, images } (pack.id)}
130
      <section class="group" aria-label={packName(pack)}>
131
        <h3><EmojiText text={packName(pack)} /></h3>
132
        <div class="grid">
133
          {#each images as image (image.reaction_key + image.shortcode)}
134
            <button
135
              type="button"
136
              class="cell"
137
              class:chosen={customising && isQuick(image.reaction_key)}
138
              title={`:${image.shortcode}:`}
139
              aria-label={`:${image.shortcode}:`}
140
              aria-pressed={customising ? isQuick(image.reaction_key) : undefined}
141
              on:click={() => choose(image.reaction_key, image.shortcode)}
142
            >
143
              <img src={messageMediaUrl(image.media_id, 'Thumbnail')} alt="" loading="lazy" />
144
            </button>
145
          {/each}
146
        </div>
147
      </section>
148
    {/each}
149
150
    {#if loadFailed}
151
      <p class="status" role="alert">Could not load the emoji list.</p>
152
    {:else if groups === null}
153
      <p class="status" role="status">Loading emoji…</p>
154
    {:else}
155
      {#each filteredGroups as g (g.name)}
156
        <section class="group" aria-label={g.name}>
157
          <h3>{g.name}</h3>
158
          <div class="grid">
159
            {#each g.emojis as e (e.emoji)}
160
              <button
161
                type="button"
162
                class="cell"
163
                class:chosen={customising && isQuick(e.emoji)}
164
                title={e.label}
165
                aria-label={e.label}
166
                aria-pressed={customising ? isQuick(e.emoji) : undefined}
167
                on:click={() => choose(e.emoji, e.shortcodes[0] ?? null)}
168
              >
169
                <Emoji emoji={e.emoji} size={24} lazy />
170
              </button>
171
            {/each}
172
          </div>
173
        </section>
174
      {/each}
175
    {/if}
176
    {#if nothingFound}
177
      <p class="status" role="status">No emoji match "<EmojiText text={query.trim()} />".</p>
178
    {/if}
179
  </div>
180
181
  {#if !embedded}
182
    <div class="foot">
183
      <button type="button" class="mode" aria-pressed={customising} on:click={() => (customising = !customising)}>
184
        {customising ? 'Done' : 'Customise quick reactions'}
185
      </button>
186
    </div>
187
  {/if}
188
</div>
189
190
<style>
191
  .picker.embedded { width: 100%; height: 340px; box-shadow: none; }
192
  .picker {
193
    outline: none;
194
    display: flex; flex-direction: column; gap: 6px;
195
    width: 320px; height: 380px; max-width: 100%; max-height: 100%;
196
    padding: 8px;
197
    background: var(--panel);
198
    border: 1px solid var(--border);
199
    border-radius: var(--r-md);
200
    box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5);
201
  }
202
  .search {
203
    background: var(--bg-soft);
204
    border: 1px solid var(--border);
205
    border-radius: var(--r-md);
206
    color: var(--text);
207
    padding: 6px 10px;
208
    font-size: 13px;
209
  }
210
  .search:focus-visible { outline: none; border-color: var(--accent); }
211
  .muted { color: var(--muted); font-size: 12px; }
212
  .quick { border: 1px solid var(--border); border-radius: var(--r-md); padding: 6px; background: var(--bg-soft); }
213
  .quick-head { display: flex; justify-content: space-between; font-size: 12px; font-weight: 650; margin-bottom: 4px; }
214
  .quick-row { display: flex; flex-wrap: wrap; gap: 4px; min-height: 28px; align-items: center; }
215
  .quick-item {
216
    display: inline-flex; align-items: center; gap: 2px;
217
    padding: 1px 2px 1px 6px;
218
    border: 1px solid var(--border); border-radius: var(--r-sm);
219
    font-size: 16px;
220
  }
221
  .quick-item img { width: 18px; height: 18px; object-fit: contain; }
222
  .remove {
223
    background: transparent; border: none; color: var(--muted);
224
    cursor: pointer; font-size: 14px; line-height: 1; padding: 2px 5px; border-radius: var(--r-sm);
225
  }
226
  .remove:hover { color: var(--danger); }
227
  .scroll { flex: 1; min-height: 0; overflow-y: auto; }
228
  .group h3 {
229
    margin: 6px 2px 2px; font-size: 11px; font-weight: 650;
230
    text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted);
231
  }
232
  .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(32px, 1fr)); gap: 2px; }
233
  .cell {
234
    display: grid; place-items: center;
235
    height: 32px; padding: 0;
236
    background: transparent; border: 1px solid transparent; border-radius: var(--r-sm);
237
    font-size: 20px; cursor: pointer; color: var(--text);
238
  }
239
  .cell img { width: 24px; height: 24px; object-fit: contain; }
240
  .cell:hover:not(:disabled), .cell:focus-visible { background: var(--hover); border-color: var(--border); outline: none; }
241
  .cell.chosen { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 16%, transparent); }
242
  .cell:disabled { opacity: 0.3; cursor: default; }
243
  .status { color: var(--muted); font-size: 13px; text-align: center; margin: 16px 0; }
244
  .foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
245
  .mode {
246
    margin-left: auto;
247
    background: transparent; border: 1px solid var(--border); border-radius: var(--r-sm);
248
    color: var(--text); font-size: 12px; padding: 3px 8px; cursor: pointer;
249
  }
250
  .mode:hover, .mode[aria-pressed='true'] { border-color: var(--accent); }
251
</style>