atagen

malice

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