atagen

malice

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