| 1 | <script lang="ts"> |
| 2 | import { createEventDispatcher } from 'svelte'; |
| 3 | import ContextMenu, { type MenuItem } from './ContextMenu.svelte'; |
| 4 | import MxcAvatar from './MxcAvatar.svelte'; |
| 5 | import { messageMediaUrl, runCommand, uploadMedia } from './api'; |
| 6 | import { |
| 7 | allPacks, |
| 8 | clonePackDestinations, |
| 9 | editableChoices, |
| 10 | hasUsage, |
| 11 | packLabel, |
| 12 | packSlug, |
| 13 | packTarget, |
| 14 | shortcodeFor, |
| 15 | validShortcode |
| 16 | } from './packs'; |
| 17 | import type { PackTarget } from './transport'; |
| 18 | import type { ImagePack, ImagePacks, PackImage, PackUsage, RoomSummary } from './types'; |
| 19 | |
| 20 | export let packs: ImagePacks; |
| 21 | export let rooms: RoomSummary[]; |
| 22 | export let onError: (message: string) => void; |
| 23 | |
| 24 | const dispatch = createEventDispatcher<{ close: void }>(); |
| 25 | |
| 26 | let selectedId: string | null = 'user'; |
| 27 | let creating = false; |
| 28 | let newRoom = ''; |
| 29 | let newName = ''; |
| 30 | let confirmDelete = false; |
| 31 | let uploading: { done: number; total: number } | null = null; |
| 32 | let menu: { items: MenuItem[]; x: number; y: number } | null = null; |
| 33 | let fileInput: HTMLInputElement; |
| 34 | let avatarInput: HTMLInputElement; |
| 35 | |
| 36 | const roomName = (roomId: string) => rooms.find((r) => r.room_id === roomId)?.name ?? roomId; |
| 37 | |
| 38 | $: list = allPacks(packs); |
| 39 | $: userPack = list.find((p) => p.source === 'User') ?? null; |
| 40 | $: roomPacks = list.filter((p) => p.source !== 'User' && p.editable); |
| 41 | $: otherPacks = list.filter((p) => p.source !== 'User' && !p.editable); |
| 42 | $: packRooms = rooms.filter((r) => r.can_edit_packs && r.membership === 'Joined'); |
| 43 | $: selected = selectedId === 'user' ? userPack : (list.find((p) => p.id === selectedId) ?? null); |
| 44 | $: target = selectedId === 'user' ? ({ kind: 'user' } as PackTarget) : selected ? packTarget(selected) : null; |
| 45 | $: editable = selectedId === 'user' || (selected?.editable ?? false); |
| 46 | $: if (selectedId) confirmDelete = false; |
| 47 | |
| 48 | async function run(work: () => Promise<unknown>, failure: string): Promise<boolean> { |
| 49 | try { |
| 50 | await work(); |
| 51 | return true; |
| 52 | } catch (e) { |
| 53 | onError(e instanceof Error ? e.message : failure); |
| 54 | return false; |
| 55 | } |
| 56 | } |
| 57 | |
| 58 | async function createPack(): Promise<void> { |
| 59 | const name = newName.trim(); |
| 60 | if (!newRoom || !name) return; |
| 61 | const state_key = packSlug(name); |
| 62 | const ok = await run( |
| 63 | () => runCommand({ op: 'create_room_pack', room_id: newRoom, state_key, display_name: name }), |
| 64 | 'Could not create the pack' |
| 65 | ); |
| 66 | if (!ok) return; |
| 67 | creating = false; |
| 68 | newName = ''; |
| 69 | selectedId = `room:${newRoom}:${state_key}`; |
| 70 | } |
| 71 | |
| 72 | async function rename(event: Event): Promise<void> { |
| 73 | const name = (event.currentTarget as HTMLInputElement).value.trim(); |
| 74 | if (!target || !name || name === (selected?.display_name ?? '')) return; |
| 75 | await run(() => runCommand({ op: 'set_pack_meta', target: target!, display_name: name }), 'Could not rename the pack'); |
| 76 | } |
| 77 | |
| 78 | async function changeAvatar(): Promise<void> { |
| 79 | const file = avatarInput.files?.[0]; |
| 80 | avatarInput.value = ''; |
| 81 | if (!file || !target) return; |
| 82 | const t = target; |
| 83 | await run(async () => { |
| 84 | const { mxc } = await uploadMedia(file); |
| 85 | await runCommand({ op: 'set_pack_meta', target: t, avatar: mxc }); |
| 86 | }, 'Could not change the pack avatar'); |
| 87 | } |
| 88 | |
| 89 | async function addImages(): Promise<void> { |
| 90 | const files = [...(fileInput.files ?? [])].filter((f) => f.type.startsWith('image/')); |
| 91 | fileInput.value = ''; |
| 92 | if (!files.length || !target) return; |
| 93 | const t = target; |
| 94 | const taken = new Set(selected?.images.map((i) => i.shortcode) ?? []); |
| 95 | uploading = { done: 0, total: files.length }; |
| 96 | for (const file of files) { |
| 97 | const shortcode = shortcodeFor(file.name, taken); |
| 98 | await run(async () => { |
| 99 | const { mxc } = await uploadMedia(file); |
| 100 | const size = await imageSize(file); |
| 101 | const info = { mimetype: file.type || null, size_bytes: file.size, width: size?.width ?? null, height: size?.height ?? null }; |
| 102 | await runCommand({ op: 'put_pack_image', target: t, shortcode, mxc, body: null, usage: null, info }); |
| 103 | }, `Could not add ${file.name}`); |
| 104 | uploading = { done: uploading.done + 1, total: files.length }; |
| 105 | } |
| 106 | uploading = null; |
| 107 | } |
| 108 | |
| 109 | async function imageSize(file: File): Promise<{ width: number; height: number } | null> { |
| 110 | try { |
| 111 | const bitmap = await createImageBitmap(file); |
| 112 | const size = { width: bitmap.width, height: bitmap.height }; |
| 113 | bitmap.close(); |
| 114 | return size; |
| 115 | } catch { |
| 116 | return null; |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | async function renameImage(image: PackImage, event: Event): Promise<void> { |
| 121 | const input = event.currentTarget as HTMLInputElement; |
| 122 | const to = input.value.trim(); |
| 123 | if (!target || to === image.shortcode) return; |
| 124 | if (!validShortcode(to) || selected?.images.some((i) => i.shortcode === to)) { |
| 125 | input.value = image.shortcode; |
| 126 | onError(`Can't rename to "${to}"`); |
| 127 | return; |
| 128 | } |
| 129 | await run(() => runCommand({ op: 'rename_pack_image', target: target!, from: image.shortcode, to }), 'Could not rename'); |
| 130 | } |
| 131 | |
| 132 | async function toggleUsage(image: PackImage, usage: PackUsage): Promise<void> { |
| 133 | if (!target) return; |
| 134 | const current: PackUsage[] = image.usage.length ? image.usage : ['Emoticon', 'Sticker']; |
| 135 | const next = current.includes(usage) ? current.filter((u) => u !== usage) : [...current, usage]; |
| 136 | if (next.length === 0) return; |
| 137 | await run( |
| 138 | () => |
| 139 | runCommand({ |
| 140 | op: 'put_pack_image', |
| 141 | target: target!, |
| 142 | shortcode: image.shortcode, |
| 143 | mxc: image.media_id, |
| 144 | body: image.body, |
| 145 | usage: next |
| 146 | }), |
| 147 | 'Could not change how the image is used' |
| 148 | ); |
| 149 | } |
| 150 | |
| 151 | async function removeImage(image: PackImage): Promise<void> { |
| 152 | if (!target) return; |
| 153 | await run(() => runCommand({ op: 'remove_pack_image', target: target!, shortcode: image.shortcode }), 'Could not remove'); |
| 154 | } |
| 155 | |
| 156 | async function setEverywhere(enabled: boolean): Promise<void> { |
| 157 | if (!selected || selected.source === 'User') return; |
| 158 | const { room_id, state_key } = selected.source.Room; |
| 159 | await run(() => runCommand({ op: 'set_pack_enabled', room_id, state_key, enabled }), 'Could not change the pack'); |
| 160 | } |
| 161 | |
| 162 | async function deletePack(): Promise<void> { |
| 163 | if (!target) return; |
| 164 | const ok = await run(() => runCommand({ op: 'delete_pack', target: target! }), 'Could not delete the pack'); |
| 165 | if (ok) selectedId = 'user'; |
| 166 | } |
| 167 | |
| 168 | function cloneImageMenu(pack: ImagePack, image: PackImage, ev: MouseEvent): void { |
| 169 | menu = { |
| 170 | x: ev.clientX, |
| 171 | y: ev.clientY, |
| 172 | items: editableChoices(packs, roomName).map((choice) => ({ |
| 173 | label: `Clone to ${choice.label}`, |
| 174 | action: () => |
| 175 | void run( |
| 176 | () => runCommand({ op: 'clone_pack_image', from_pack: pack.id, shortcode: image.shortcode, to: choice.target }), |
| 177 | 'Could not clone the image' |
| 178 | ) |
| 179 | })) |
| 180 | }; |
| 181 | } |
| 182 | |
| 183 | function clonePackMenu(pack: ImagePack, ev: MouseEvent): void { |
| 184 | menu = { |
| 185 | x: ev.clientX, |
| 186 | y: ev.clientY, |
| 187 | items: clonePackDestinations(pack, packRooms, roomName).map((d) => ({ |
| 188 | label: d.label, |
| 189 | action: () => |
| 190 | void run(async () => { |
| 191 | const res = (await runCommand({ op: 'clone_pack', from_pack: pack.id, to: d.target, display_name: d.display_name })) as { |
| 192 | skipped?: string[]; |
| 193 | }; |
| 194 | if (res?.skipped?.length) onError(`Couldn't copy ${res.skipped.map((s) => `:${s}:`).join(', ')}`); |
| 195 | }, 'Could not clone the pack') |
| 196 | })) |
| 197 | }; |
| 198 | } |
| 199 | </script> |
| 200 | |
| 201 | <div class="modal packs" role="dialog" aria-modal="true" aria-label="Sticker and emoji packs"> |
| 202 | <div class="modal-tabs" role="tablist" aria-label="Packs"> |
| 203 | <button type="button" role="tab" aria-selected="true" class="active">Packs</button> |
| 204 | <button type="button" class="icon-btn modal-x" aria-label="Close packs" on:click={() => dispatch('close')}> |
| 205 | <span aria-hidden="true">✕</span> |
| 206 | </button> |
| 207 | </div> |
| 208 | |
| 209 | <div class="layout"> |
| 210 | <nav class="pack-list" aria-label="Packs"> |
| 211 | <button type="button" class="pack-item" class:active={selectedId === 'user'} on:click={() => (selectedId = 'user')}> |
| 212 | <MxcAvatar mxc={userPack?.avatar ?? null} label="Your pack" size={28} shape="rounded" /> |
| 213 | <span>{userPack?.display_name ?? 'Your pack'}</span> |
| 214 | </button> |
| 215 | {#if roomPacks.length} |
| 216 | <h4>Room packs</h4> |
| 217 | {#each roomPacks as pack (pack.id)} |
| 218 | <button type="button" class="pack-item" class:active={selectedId === pack.id} on:click={() => (selectedId = pack.id)}> |
| 219 | <MxcAvatar mxc={pack.avatar} label={packLabel(pack, roomName)} size={28} shape="rounded" /> |
| 220 | <span>{packLabel(pack, roomName)}</span> |
| 221 | </button> |
| 222 | {/each} |
| 223 | {/if} |
| 224 | {#if otherPacks.length} |
| 225 | <h4>Other packs</h4> |
| 226 | {#each otherPacks as pack (pack.id)} |
| 227 | <button type="button" class="pack-item" class:active={selectedId === pack.id} on:click={() => (selectedId = pack.id)}> |
| 228 | <MxcAvatar mxc={pack.avatar} label={packLabel(pack, roomName)} size={28} shape="rounded" /> |
| 229 | <span>{packLabel(pack, roomName)}</span> |
| 230 | </button> |
| 231 | {/each} |
| 232 | {/if} |
| 233 | {#if packRooms.length} |
| 234 | {#if creating} |
| 235 | <form class="new-pack" on:submit|preventDefault={() => void createPack()}> |
| 236 | <select bind:value={newRoom} aria-label="Room"> |
| 237 | <option value="" disabled>Room</option> |
| 238 | {#each packRooms as room (room.room_id)} |
| 239 | <option value={room.room_id}>{room.name}</option> |
| 240 | {/each} |
| 241 | </select> |
| 242 | <input type="text" placeholder="Pack name" bind:value={newName} aria-label="Pack name" /> |
| 243 | <div class="row"> |
| 244 | <button type="button" class="ghost-btn" on:click={() => (creating = false)}>Cancel</button> |
| 245 | <button type="submit" class="primary-btn" disabled={!newRoom || !newName.trim()}>Create</button> |
| 246 | </div> |
| 247 | </form> |
| 248 | {:else} |
| 249 | <button type="button" class="ghost-btn new-btn" on:click={() => (creating = true)}>New room pack</button> |
| 250 | {/if} |
| 251 | {/if} |
| 252 | </nav> |
| 253 | |
| 254 | <section class="editor" aria-label="Pack"> |
| 255 | {#if editable && target} |
| 256 | <div class="meta"> |
| 257 | <button type="button" class="avatar-btn" title="Change pack avatar" on:click={() => avatarInput.click()}> |
| 258 | <MxcAvatar mxc={selected?.avatar ?? null} label={selected?.display_name ?? 'Pack'} size={48} shape="rounded" /> |
| 259 | </button> |
| 260 | <input |
| 261 | class="name" |
| 262 | type="text" |
| 263 | value={selected?.display_name ?? ''} |
| 264 | placeholder={selectedId === 'user' ? 'Your pack' : 'Pack name'} |
| 265 | aria-label="Pack name" |
| 266 | on:change={(e) => void rename(e)} |
| 267 | /> |
| 268 | <input type="file" accept="image/*" class="visually-hidden" bind:this={avatarInput} on:change={() => void changeAvatar()} /> |
| 269 | </div> |
| 270 | <div class="row"> |
| 271 | {#if selected && selected.source !== 'User'} |
| 272 | <label class="check"> |
| 273 | <input type="checkbox" checked={selected.enabled_everywhere} on:change={(e) => void setEverywhere(e.currentTarget.checked)} /> |
| 274 | Use everywhere |
| 275 | </label> |
| 276 | {/if} |
| 277 | <span class="spacer"></span> |
| 278 | <button type="button" class="primary-btn" disabled={uploading !== null} on:click={() => fileInput.click()}> |
| 279 | {uploading ? `Uploading ${uploading.done}/${uploading.total}` : 'Add images'} |
| 280 | </button> |
| 281 | <input type="file" accept="image/*" multiple class="visually-hidden" bind:this={fileInput} on:change={() => void addImages()} /> |
| 282 | {#if selected} |
| 283 | {#if confirmDelete} |
| 284 | <button type="button" class="ghost-btn" on:click={() => (confirmDelete = false)}>Keep</button> |
| 285 | <button type="button" class="ghost-btn danger" on:click={() => void deletePack()}>Delete pack</button> |
| 286 | {:else} |
| 287 | <button type="button" class="ghost-btn danger" on:click={() => (confirmDelete = true)}>Delete</button> |
| 288 | {/if} |
| 289 | {/if} |
| 290 | </div> |
| 291 | <div class="images"> |
| 292 | {#each selected?.images ?? [] as image (image.shortcode)} |
| 293 | <div class="image"> |
| 294 | <img src={messageMediaUrl(image.media_id, 'Thumbnail')} alt={image.shortcode} loading="lazy" /> |
| 295 | <input type="text" value={image.shortcode} aria-label="Shortcode" on:change={(e) => void renameImage(image, e)} /> |
| 296 | <div class="usage"> |
| 297 | <button type="button" aria-pressed={hasUsage(image, 'Emoticon')} title="Emoji" on:click={() => void toggleUsage(image, 'Emoticon')}>☺</button> |
| 298 | <button type="button" aria-pressed={hasUsage(image, 'Sticker')} title="Sticker" on:click={() => void toggleUsage(image, 'Sticker')}>▣</button> |
| 299 | <button type="button" class="remove" title="Remove" aria-label={`Remove ${image.shortcode}`} on:click={() => void removeImage(image)}>✕</button> |
| 300 | </div> |
| 301 | </div> |
| 302 | {:else} |
| 303 | <p class="empty">No images yet</p> |
| 304 | {/each} |
| 305 | </div> |
| 306 | {:else if selected} |
| 307 | <div class="meta"> |
| 308 | <MxcAvatar mxc={selected.avatar} label={packLabel(selected, roomName)} size={48} shape="rounded" /> |
| 309 | <strong class="name">{packLabel(selected, roomName)}</strong> |
| 310 | <span class="spacer"></span> |
| 311 | <button type="button" class="primary-btn" on:click={(ev) => selected && clonePackMenu(selected, ev)}>Clone pack</button> |
| 312 | </div> |
| 313 | <div class="images"> |
| 314 | {#each selected.images as image (image.shortcode)} |
| 315 | <div class="image"> |
| 316 | <img src={messageMediaUrl(image.media_id, 'Thumbnail')} alt={image.shortcode} loading="lazy" /> |
| 317 | <span class="code">{image.shortcode}</span> |
| 318 | <button type="button" class="ghost-btn" on:click={(ev) => selected && cloneImageMenu(selected, image, ev)}>Clone</button> |
| 319 | </div> |
| 320 | {/each} |
| 321 | </div> |
| 322 | {/if} |
| 323 | </section> |
| 324 | </div> |
| 325 | </div> |
| 326 | |
| 327 | {#if menu} |
| 328 | <ContextMenu items={menu.items} x={menu.x} y={menu.y} onClose={() => (menu = null)} /> |
| 329 | {/if} |
| 330 | |
| 331 | <style> |
| 332 | .packs { width: min(820px, calc(100vw - 32px)); max-height: calc(100vh - 64px); display: flex; flex-direction: column; } |
| 333 | .layout { display: flex; gap: 14px; min-height: 0; flex: 1; } |
| 334 | .pack-list { flex: none; width: 210px; display: flex; flex-direction: column; gap: 2px; overflow-y: auto; } |
| 335 | h4 { margin: 10px 6px 4px; font-size: 12px; color: var(--muted); font-weight: 650; } |
| 336 | .pack-item { |
| 337 | display: flex; |
| 338 | align-items: center; |
| 339 | gap: 8px; |
| 340 | padding: 6px 8px; |
| 341 | border: none; |
| 342 | border-radius: 8px; |
| 343 | background: transparent; |
| 344 | color: var(--text); |
| 345 | font: inherit; |
| 346 | font-size: 13.5px; |
| 347 | text-align: left; |
| 348 | cursor: pointer; |
| 349 | } |
| 350 | .pack-item span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| 351 | .pack-item:hover { background: var(--hover); } |
| 352 | .pack-item.active { background: color-mix(in srgb, var(--accent) 20%, transparent); } |
| 353 | .new-btn { margin-top: 8px; } |
| 354 | .new-pack { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; } |
| 355 | .editor { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10px; overflow-y: auto; } |
| 356 | .meta { display: flex; align-items: center; gap: 10px; } |
| 357 | .avatar-btn { padding: 0; border: none; background: none; cursor: pointer; border-radius: 10px; } |
| 358 | .name { flex: 1; font-size: 15px; } |
| 359 | .row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } |
| 360 | .spacer { flex: 1; } |
| 361 | .check { display: flex; align-items: center; gap: 6px; font-size: 13px; } |
| 362 | input[type='text'], select { |
| 363 | background: var(--bg-soft); |
| 364 | border: 1px solid var(--border); |
| 365 | border-radius: 6px; |
| 366 | color: var(--text); |
| 367 | padding: 6px 8px; |
| 368 | font: inherit; |
| 369 | font-size: 13px; |
| 370 | min-width: 0; |
| 371 | } |
| 372 | .images { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 8px; } |
| 373 | .image { |
| 374 | display: flex; |
| 375 | flex-direction: column; |
| 376 | gap: 4px; |
| 377 | padding: 6px; |
| 378 | border: 1px solid var(--border); |
| 379 | border-radius: 8px; |
| 380 | background: var(--bg-soft); |
| 381 | } |
| 382 | .image img { width: 100%; aspect-ratio: 1; object-fit: contain; } |
| 383 | .image input { width: 100%; font-size: 12px; padding: 3px 6px; } |
| 384 | .code { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| 385 | .usage { display: flex; gap: 4px; } |
| 386 | .usage button { |
| 387 | flex: 1; |
| 388 | padding: 2px 0; |
| 389 | border: 1px solid var(--border); |
| 390 | border-radius: 6px; |
| 391 | background: transparent; |
| 392 | color: var(--muted); |
| 393 | cursor: pointer; |
| 394 | } |
| 395 | .usage button[aria-pressed='true'] { color: var(--accent); border-color: var(--accent); } |
| 396 | .usage .remove { flex: none; padding: 2px 6px; } |
| 397 | .usage .remove:hover { color: var(--danger); border-color: var(--danger); } |
| 398 | .empty { color: var(--muted); font-size: 13px; } |
| 399 | </style> |