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