atagen

malice

public
Star0
Watch0
17723bb70856104761f7b2d9cba1e5f0d9100c0a
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git

Blame: web/src/lib/PackManager.svelte

d095131eec0eatagen2026-10-24 08:39 UTC1
<script lang="ts">
d095131eec0eatagen2026-10-24 08:39 UTC2
  import { createEventDispatcher } from 'svelte';
132eeceeb362atagen2026-10-25 03:34 UTC3
  import PackCloneDialog from './PackCloneDialog.svelte';
d095131eec0eatagen2026-10-24 08:39 UTC4
  import MxcAvatar from './MxcAvatar.svelte';
445bf199a9cfatagen2026-10-25 02:56 UTC5
  import EmojiText from './EmojiText.svelte';
d095131eec0eatagen2026-10-24 08:39 UTC6
  import { messageMediaUrl, runCommand, uploadMedia } from './api';
132eeceeb362atagen2026-10-25 03:34 UTC7
  import { disabledPackImages, setPackImageEnabled } from './pack-preferences';
132eeceeb362atagen2026-10-25 03:34 UTC8
  import { imageEnabled } from './pack-preferences-model';
132eeceeb362atagen2026-10-25 03:34 UTC9
  import type { CloneSource } from './pack-clone';
d095131eec0eatagen2026-10-24 08:39 UTC10
  import {
d095131eec0eatagen2026-10-24 08:39 UTC11
    allPacks,
132eeceeb362atagen2026-10-25 03:34 UTC12
    freePackStateKey,
d095131eec0eatagen2026-10-24 08:39 UTC13
    hasUsage,
d095131eec0eatagen2026-10-24 08:39 UTC14
    packLabel,
d095131eec0eatagen2026-10-24 08:39 UTC15
    packTarget,
d095131eec0eatagen2026-10-24 08:39 UTC16
    shortcodeFor,
d095131eec0eatagen2026-10-24 08:39 UTC17
    validShortcode
d095131eec0eatagen2026-10-24 08:39 UTC18
  } from './packs';
d095131eec0eatagen2026-10-24 08:39 UTC19
  import type { PackTarget } from './transport';
d095131eec0eatagen2026-10-24 08:39 UTC20
  import type { ImagePack, ImagePacks, PackImage, PackUsage, RoomSummary } from './types';
d095131eec0eatagen2026-10-24 08:39 UTC21
d095131eec0eatagen2026-10-24 08:39 UTC22
  export let packs: ImagePacks;
d095131eec0eatagen2026-10-24 08:39 UTC23
  export let rooms: RoomSummary[];
d095131eec0eatagen2026-10-24 08:39 UTC24
  export let onError: (message: string) => void;
d095131eec0eatagen2026-10-24 08:39 UTC25
d095131eec0eatagen2026-10-24 08:39 UTC26
  const dispatch = createEventDispatcher<{ close: void }>();
d095131eec0eatagen2026-10-24 08:39 UTC27
d095131eec0eatagen2026-10-24 08:39 UTC28
  let selectedId: string | null = 'user';
d095131eec0eatagen2026-10-24 08:39 UTC29
  let creating = false;
d095131eec0eatagen2026-10-24 08:39 UTC30
  let newRoom = '';
d095131eec0eatagen2026-10-24 08:39 UTC31
  let newName = '';
d095131eec0eatagen2026-10-24 08:39 UTC32
  let confirmDelete = false;
d095131eec0eatagen2026-10-24 08:39 UTC33
  let uploading: { done: number; total: number } | null = null;
132eeceeb362atagen2026-10-25 03:34 UTC34
  let cloneSource: CloneSource | null = null;
d095131eec0eatagen2026-10-24 08:39 UTC35
  let fileInput: HTMLInputElement;
d095131eec0eatagen2026-10-24 08:39 UTC36
  let avatarInput: HTMLInputElement;
d095131eec0eatagen2026-10-24 08:39 UTC37
d095131eec0eatagen2026-10-24 08:39 UTC38
  const roomName = (roomId: string) => rooms.find((r) => r.room_id === roomId)?.name ?? roomId;
132eeceeb362atagen2026-10-25 03:34 UTC39
  const sidebarPackLabel = (pack: ImagePack) => {
132eeceeb362atagen2026-10-25 03:34 UTC40
    const label = packLabel(pack, roomName);
132eeceeb362atagen2026-10-25 03:34 UTC41
    if (pack.source === 'User' || !pack.display_name) return label;
132eeceeb362atagen2026-10-25 03:34 UTC42
    return `${label} · ${roomName(pack.source.Room.room_id)}`;
132eeceeb362atagen2026-10-25 03:34 UTC43
  };
132eeceeb362atagen2026-10-25 03:34 UTC44
  const packScope = (pack: ImagePack) => {
132eeceeb362atagen2026-10-25 03:34 UTC45
    if (pack.source === 'User') return 'Personal pack synced to your account';
132eeceeb362atagen2026-10-25 03:34 UTC46
    const roomId = pack.source.Room.room_id;
132eeceeb362atagen2026-10-25 03:34 UTC47
    return `${rooms.find((room) => room.room_id === roomId)?.space ? 'Space' : 'Room'} pack in ${roomName(roomId)}`;
132eeceeb362atagen2026-10-25 03:34 UTC48
  };
d095131eec0eatagen2026-10-24 08:39 UTC49
d095131eec0eatagen2026-10-24 08:39 UTC50
  $: list = allPacks(packs);
d095131eec0eatagen2026-10-24 08:39 UTC51
  $: userPack = list.find((p) => p.source === 'User') ?? null;
d095131eec0eatagen2026-10-24 08:39 UTC52
  $: roomPacks = list.filter((p) => p.source !== 'User' && p.editable);
d095131eec0eatagen2026-10-24 08:39 UTC53
  $: otherPacks = list.filter((p) => p.source !== 'User' && !p.editable);
d095131eec0eatagen2026-10-24 08:39 UTC54
  $: packRooms = rooms.filter((r) => r.can_edit_packs && r.membership === 'Joined');
132eeceeb362atagen2026-10-25 03:34 UTC55
  $: spacePackRooms = packRooms.filter((room) => room.space);
132eeceeb362atagen2026-10-25 03:34 UTC56
  $: regularPackRooms = packRooms.filter((room) => !room.space);
d095131eec0eatagen2026-10-24 08:39 UTC57
  $: selected = selectedId === 'user' ? userPack : (list.find((p) => p.id === selectedId) ?? null);
d095131eec0eatagen2026-10-24 08:39 UTC58
  $: target = selectedId === 'user' ? ({ kind: 'user' } as PackTarget) : selected ? packTarget(selected) : null;
d095131eec0eatagen2026-10-24 08:39 UTC59
  $: editable = selectedId === 'user' || (selected?.editable ?? false);
d095131eec0eatagen2026-10-24 08:39 UTC60
  $: if (selectedId) confirmDelete = false;
d095131eec0eatagen2026-10-24 08:39 UTC61
d095131eec0eatagen2026-10-24 08:39 UTC62
  async function run(work: () => Promise<unknown>, failure: string): Promise<boolean> {
d095131eec0eatagen2026-10-24 08:39 UTC63
    try {
d095131eec0eatagen2026-10-24 08:39 UTC64
      await work();
d095131eec0eatagen2026-10-24 08:39 UTC65
      return true;
d095131eec0eatagen2026-10-24 08:39 UTC66
    } catch (e) {
d095131eec0eatagen2026-10-24 08:39 UTC67
      onError(e instanceof Error ? e.message : failure);
d095131eec0eatagen2026-10-24 08:39 UTC68
      return false;
d095131eec0eatagen2026-10-24 08:39 UTC69
    }
d095131eec0eatagen2026-10-24 08:39 UTC70
  }
d095131eec0eatagen2026-10-24 08:39 UTC71
d095131eec0eatagen2026-10-24 08:39 UTC72
  async function createPack(): Promise<void> {
d095131eec0eatagen2026-10-24 08:39 UTC73
    const name = newName.trim();
d095131eec0eatagen2026-10-24 08:39 UTC74
    if (!newRoom || !name) return;
132eeceeb362atagen2026-10-25 03:34 UTC75
    const state_key = freePackStateKey(packs, newRoom, name);
d095131eec0eatagen2026-10-24 08:39 UTC76
    const ok = await run(
d095131eec0eatagen2026-10-24 08:39 UTC77
      () => runCommand({ op: 'create_room_pack', room_id: newRoom, state_key, display_name: name }),
d095131eec0eatagen2026-10-24 08:39 UTC78
      'Could not create the pack'
d095131eec0eatagen2026-10-24 08:39 UTC79
    );
d095131eec0eatagen2026-10-24 08:39 UTC80
    if (!ok) return;
d095131eec0eatagen2026-10-24 08:39 UTC81
    creating = false;
d095131eec0eatagen2026-10-24 08:39 UTC82
    newName = '';
d095131eec0eatagen2026-10-24 08:39 UTC83
    selectedId = `room:${newRoom}:${state_key}`;
d095131eec0eatagen2026-10-24 08:39 UTC84
  }
d095131eec0eatagen2026-10-24 08:39 UTC85
d095131eec0eatagen2026-10-24 08:39 UTC86
  async function rename(event: Event): Promise<void> {
d095131eec0eatagen2026-10-24 08:39 UTC87
    const name = (event.currentTarget as HTMLInputElement).value.trim();
d095131eec0eatagen2026-10-24 08:39 UTC88
    if (!target || !name || name === (selected?.display_name ?? '')) return;
d095131eec0eatagen2026-10-24 08:39 UTC89
    await run(() => runCommand({ op: 'set_pack_meta', target: target!, display_name: name }), 'Could not rename the pack');
d095131eec0eatagen2026-10-24 08:39 UTC90
  }
d095131eec0eatagen2026-10-24 08:39 UTC91
d095131eec0eatagen2026-10-24 08:39 UTC92
  async function changeAvatar(): Promise<void> {
d095131eec0eatagen2026-10-24 08:39 UTC93
    const file = avatarInput.files?.[0];
d095131eec0eatagen2026-10-24 08:39 UTC94
    avatarInput.value = '';
d095131eec0eatagen2026-10-24 08:39 UTC95
    if (!file || !target) return;
d095131eec0eatagen2026-10-24 08:39 UTC96
    const t = target;
d095131eec0eatagen2026-10-24 08:39 UTC97
    await run(async () => {
d095131eec0eatagen2026-10-24 08:39 UTC98
      const { mxc } = await uploadMedia(file);
d095131eec0eatagen2026-10-24 08:39 UTC99
      await runCommand({ op: 'set_pack_meta', target: t, avatar: mxc });
d095131eec0eatagen2026-10-24 08:39 UTC100
    }, 'Could not change the pack avatar');
d095131eec0eatagen2026-10-24 08:39 UTC101
  }
d095131eec0eatagen2026-10-24 08:39 UTC102
d095131eec0eatagen2026-10-24 08:39 UTC103
  async function addImages(): Promise<void> {
d095131eec0eatagen2026-10-24 08:39 UTC104
    const files = [...(fileInput.files ?? [])].filter((f) => f.type.startsWith('image/'));
d095131eec0eatagen2026-10-24 08:39 UTC105
    fileInput.value = '';
d095131eec0eatagen2026-10-24 08:39 UTC106
    if (!files.length || !target) return;
d095131eec0eatagen2026-10-24 08:39 UTC107
    const t = target;
d095131eec0eatagen2026-10-24 08:39 UTC108
    const taken = new Set(selected?.images.map((i) => i.shortcode) ?? []);
d095131eec0eatagen2026-10-24 08:39 UTC109
    uploading = { done: 0, total: files.length };
d095131eec0eatagen2026-10-24 08:39 UTC110
    for (const file of files) {
d095131eec0eatagen2026-10-24 08:39 UTC111
      const shortcode = shortcodeFor(file.name, taken);
d095131eec0eatagen2026-10-24 08:39 UTC112
      await run(async () => {
d095131eec0eatagen2026-10-24 08:39 UTC113
        const { mxc } = await uploadMedia(file);
d095131eec0eatagen2026-10-24 08:39 UTC114
        const size = await imageSize(file);
d095131eec0eatagen2026-10-24 08:39 UTC115
        const info = { mimetype: file.type || null, size_bytes: file.size, width: size?.width ?? null, height: size?.height ?? null };
d095131eec0eatagen2026-10-24 08:39 UTC116
        await runCommand({ op: 'put_pack_image', target: t, shortcode, mxc, body: null, usage: null, info });
d095131eec0eatagen2026-10-24 08:39 UTC117
      }, `Could not add ${file.name}`);
d095131eec0eatagen2026-10-24 08:39 UTC118
      uploading = { done: uploading.done + 1, total: files.length };
d095131eec0eatagen2026-10-24 08:39 UTC119
    }
d095131eec0eatagen2026-10-24 08:39 UTC120
    uploading = null;
d095131eec0eatagen2026-10-24 08:39 UTC121
  }
d095131eec0eatagen2026-10-24 08:39 UTC122
d095131eec0eatagen2026-10-24 08:39 UTC123
  async function imageSize(file: File): Promise<{ width: number; height: number } | null> {
d095131eec0eatagen2026-10-24 08:39 UTC124
    try {
d095131eec0eatagen2026-10-24 08:39 UTC125
      const bitmap = await createImageBitmap(file);
d095131eec0eatagen2026-10-24 08:39 UTC126
      const size = { width: bitmap.width, height: bitmap.height };
d095131eec0eatagen2026-10-24 08:39 UTC127
      bitmap.close();
d095131eec0eatagen2026-10-24 08:39 UTC128
      return size;
d095131eec0eatagen2026-10-24 08:39 UTC129
    } catch {
d095131eec0eatagen2026-10-24 08:39 UTC130
      return null;
d095131eec0eatagen2026-10-24 08:39 UTC131
    }
d095131eec0eatagen2026-10-24 08:39 UTC132
  }
d095131eec0eatagen2026-10-24 08:39 UTC133
d095131eec0eatagen2026-10-24 08:39 UTC134
  async function renameImage(image: PackImage, event: Event): Promise<void> {
d095131eec0eatagen2026-10-24 08:39 UTC135
    const input = event.currentTarget as HTMLInputElement;
d095131eec0eatagen2026-10-24 08:39 UTC136
    const to = input.value.trim();
d095131eec0eatagen2026-10-24 08:39 UTC137
    if (!target || to === image.shortcode) return;
d095131eec0eatagen2026-10-24 08:39 UTC138
    if (!validShortcode(to) || selected?.images.some((i) => i.shortcode === to)) {
d095131eec0eatagen2026-10-24 08:39 UTC139
      input.value = image.shortcode;
d095131eec0eatagen2026-10-24 08:39 UTC140
      onError(`Can't rename to "${to}"`);
d095131eec0eatagen2026-10-24 08:39 UTC141
      return;
d095131eec0eatagen2026-10-24 08:39 UTC142
    }
d095131eec0eatagen2026-10-24 08:39 UTC143
    await run(() => runCommand({ op: 'rename_pack_image', target: target!, from: image.shortcode, to }), 'Could not rename');
d095131eec0eatagen2026-10-24 08:39 UTC144
  }
d095131eec0eatagen2026-10-24 08:39 UTC145
132eeceeb362atagen2026-10-25 03:34 UTC146
  async function renameImageBody(image: PackImage, event: Event): Promise<void> {
132eeceeb362atagen2026-10-25 03:34 UTC147
    const input = event.currentTarget as HTMLInputElement;
132eeceeb362atagen2026-10-25 03:34 UTC148
    const body = input.value.trim() || image.shortcode;
132eeceeb362atagen2026-10-25 03:34 UTC149
    const currentBody = image.body?.trim() || image.shortcode;
132eeceeb362atagen2026-10-25 03:34 UTC150
    if (!target || body === currentBody) {
132eeceeb362atagen2026-10-25 03:34 UTC151
      input.value = currentBody;
132eeceeb362atagen2026-10-25 03:34 UTC152
      return;
132eeceeb362atagen2026-10-25 03:34 UTC153
    }
132eeceeb362atagen2026-10-25 03:34 UTC154
    const ok = await run(
132eeceeb362atagen2026-10-25 03:34 UTC155
      () => runCommand({
132eeceeb362atagen2026-10-25 03:34 UTC156
        op: 'put_pack_image',
132eeceeb362atagen2026-10-25 03:34 UTC157
        target: target!,
132eeceeb362atagen2026-10-25 03:34 UTC158
        shortcode: image.shortcode,
132eeceeb362atagen2026-10-25 03:34 UTC159
        mxc: image.media_id,
132eeceeb362atagen2026-10-25 03:34 UTC160
        body,
132eeceeb362atagen2026-10-25 03:34 UTC161
        usage: null
132eeceeb362atagen2026-10-25 03:34 UTC162
      }),
132eeceeb362atagen2026-10-25 03:34 UTC163
      'Could not rename the sticker'
132eeceeb362atagen2026-10-25 03:34 UTC164
    );
132eeceeb362atagen2026-10-25 03:34 UTC165
    if (!ok) input.value = currentBody;
132eeceeb362atagen2026-10-25 03:34 UTC166
  }
132eeceeb362atagen2026-10-25 03:34 UTC167
d095131eec0eatagen2026-10-24 08:39 UTC168
  async function toggleUsage(image: PackImage, usage: PackUsage): Promise<void> {
d095131eec0eatagen2026-10-24 08:39 UTC169
    if (!target) return;
d095131eec0eatagen2026-10-24 08:39 UTC170
    const current: PackUsage[] = image.usage.length ? image.usage : ['Emoticon', 'Sticker'];
d095131eec0eatagen2026-10-24 08:39 UTC171
    const next = current.includes(usage) ? current.filter((u) => u !== usage) : [...current, usage];
d095131eec0eatagen2026-10-24 08:39 UTC172
    if (next.length === 0) return;
d095131eec0eatagen2026-10-24 08:39 UTC173
    await run(
d095131eec0eatagen2026-10-24 08:39 UTC174
      () =>
d095131eec0eatagen2026-10-24 08:39 UTC175
        runCommand({
d095131eec0eatagen2026-10-24 08:39 UTC176
          op: 'put_pack_image',
d095131eec0eatagen2026-10-24 08:39 UTC177
          target: target!,
d095131eec0eatagen2026-10-24 08:39 UTC178
          shortcode: image.shortcode,
d095131eec0eatagen2026-10-24 08:39 UTC179
          mxc: image.media_id,
d095131eec0eatagen2026-10-24 08:39 UTC180
          body: image.body,
d095131eec0eatagen2026-10-24 08:39 UTC181
          usage: next
d095131eec0eatagen2026-10-24 08:39 UTC182
        }),
d095131eec0eatagen2026-10-24 08:39 UTC183
      'Could not change how the image is used'
d095131eec0eatagen2026-10-24 08:39 UTC184
    );
d095131eec0eatagen2026-10-24 08:39 UTC185
  }
d095131eec0eatagen2026-10-24 08:39 UTC186
d095131eec0eatagen2026-10-24 08:39 UTC187
  async function removeImage(image: PackImage): Promise<void> {
d095131eec0eatagen2026-10-24 08:39 UTC188
    if (!target) return;
d095131eec0eatagen2026-10-24 08:39 UTC189
    await run(() => runCommand({ op: 'remove_pack_image', target: target!, shortcode: image.shortcode }), 'Could not remove');
d095131eec0eatagen2026-10-24 08:39 UTC190
  }
d095131eec0eatagen2026-10-24 08:39 UTC191
d095131eec0eatagen2026-10-24 08:39 UTC192
  async function setEverywhere(enabled: boolean): Promise<void> {
d095131eec0eatagen2026-10-24 08:39 UTC193
    if (!selected || selected.source === 'User') return;
d095131eec0eatagen2026-10-24 08:39 UTC194
    const { room_id, state_key } = selected.source.Room;
d095131eec0eatagen2026-10-24 08:39 UTC195
    await run(() => runCommand({ op: 'set_pack_enabled', room_id, state_key, enabled }), 'Could not change the pack');
d095131eec0eatagen2026-10-24 08:39 UTC196
  }
d095131eec0eatagen2026-10-24 08:39 UTC197
d095131eec0eatagen2026-10-24 08:39 UTC198
  async function deletePack(): Promise<void> {
d095131eec0eatagen2026-10-24 08:39 UTC199
    if (!target) return;
d095131eec0eatagen2026-10-24 08:39 UTC200
    const ok = await run(() => runCommand({ op: 'delete_pack', target: target! }), 'Could not delete the pack');
d095131eec0eatagen2026-10-24 08:39 UTC201
    if (ok) selectedId = 'user';
d095131eec0eatagen2026-10-24 08:39 UTC202
  }
d095131eec0eatagen2026-10-24 08:39 UTC203
132eeceeb362atagen2026-10-25 03:34 UTC204
  function openImageClone(image: PackImage): void {
132eeceeb362atagen2026-10-25 03:34 UTC205
    if (selected) cloneSource = { kind: 'image', pack: selected, image };
d095131eec0eatagen2026-10-24 08:39 UTC206
  }
d095131eec0eatagen2026-10-24 08:39 UTC207
132eeceeb362atagen2026-10-25 03:34 UTC208
  async function togglePersonalImage(image: PackImage, enabled: boolean): Promise<void> {
132eeceeb362atagen2026-10-25 03:34 UTC209
    if (!selected) return;
132eeceeb362atagen2026-10-25 03:34 UTC210
    await run(
132eeceeb362atagen2026-10-25 03:34 UTC211
      () => setPackImageEnabled(selected!.id, image.shortcode, enabled),
132eeceeb362atagen2026-10-25 03:34 UTC212
      'Could not save your image preference'
132eeceeb362atagen2026-10-25 03:34 UTC213
    );
d095131eec0eatagen2026-10-24 08:39 UTC214
  }
d095131eec0eatagen2026-10-24 08:39 UTC215
</script>
d095131eec0eatagen2026-10-24 08:39 UTC216
d095131eec0eatagen2026-10-24 08:39 UTC217
<div class="modal packs" role="dialog" aria-modal="true" aria-label="Sticker and emoji packs">
d095131eec0eatagen2026-10-24 08:39 UTC218
  <div class="modal-tabs" role="tablist" aria-label="Packs">
d095131eec0eatagen2026-10-24 08:39 UTC219
    <button type="button" role="tab" aria-selected="true" class="active">Packs</button>
d095131eec0eatagen2026-10-24 08:39 UTC220
    <button type="button" class="icon-btn modal-x" aria-label="Close packs" on:click={() => dispatch('close')}>
d095131eec0eatagen2026-10-24 08:39 UTC221
      <span aria-hidden="true">✕</span>
d095131eec0eatagen2026-10-24 08:39 UTC222
    </button>
d095131eec0eatagen2026-10-24 08:39 UTC223
  </div>
d095131eec0eatagen2026-10-24 08:39 UTC224
d095131eec0eatagen2026-10-24 08:39 UTC225
  <div class="layout">
d095131eec0eatagen2026-10-24 08:39 UTC226
    <nav class="pack-list" aria-label="Packs">
d095131eec0eatagen2026-10-24 08:39 UTC227
      <button type="button" class="pack-item" class:active={selectedId === 'user'} on:click={() => (selectedId = 'user')}>
d095131eec0eatagen2026-10-24 08:39 UTC228
        <MxcAvatar mxc={userPack?.avatar ?? null} label="Your pack" size={28} shape="rounded" />
445bf199a9cfatagen2026-10-25 02:56 UTC229
        <span><EmojiText text={userPack?.display_name ?? 'Your pack'} /></span>
d095131eec0eatagen2026-10-24 08:39 UTC230
      </button>
d095131eec0eatagen2026-10-24 08:39 UTC231
      {#if roomPacks.length}
132eeceeb362atagen2026-10-25 03:34 UTC232
        <h4>Room and space packs</h4>
d095131eec0eatagen2026-10-24 08:39 UTC233
        {#each roomPacks as pack (pack.id)}
d095131eec0eatagen2026-10-24 08:39 UTC234
          <button type="button" class="pack-item" class:active={selectedId === pack.id} on:click={() => (selectedId = pack.id)}>
132eeceeb362atagen2026-10-25 03:34 UTC235
            <MxcAvatar mxc={pack.avatar} label={sidebarPackLabel(pack)} size={28} shape="rounded" />
132eeceeb362atagen2026-10-25 03:34 UTC236
            <span><EmojiText text={sidebarPackLabel(pack)} /></span>
d095131eec0eatagen2026-10-24 08:39 UTC237
          </button>
d095131eec0eatagen2026-10-24 08:39 UTC238
        {/each}
d095131eec0eatagen2026-10-24 08:39 UTC239
      {/if}
d095131eec0eatagen2026-10-24 08:39 UTC240
      {#if otherPacks.length}
d095131eec0eatagen2026-10-24 08:39 UTC241
        <h4>Other packs</h4>
d095131eec0eatagen2026-10-24 08:39 UTC242
        {#each otherPacks as pack (pack.id)}
d095131eec0eatagen2026-10-24 08:39 UTC243
          <button type="button" class="pack-item" class:active={selectedId === pack.id} on:click={() => (selectedId = pack.id)}>
132eeceeb362atagen2026-10-25 03:34 UTC244
            <MxcAvatar mxc={pack.avatar} label={sidebarPackLabel(pack)} size={28} shape="rounded" />
132eeceeb362atagen2026-10-25 03:34 UTC245
            <span><EmojiText text={sidebarPackLabel(pack)} /></span>
d095131eec0eatagen2026-10-24 08:39 UTC246
          </button>
d095131eec0eatagen2026-10-24 08:39 UTC247
        {/each}
d095131eec0eatagen2026-10-24 08:39 UTC248
      {/if}
d095131eec0eatagen2026-10-24 08:39 UTC249
      {#if packRooms.length}
d095131eec0eatagen2026-10-24 08:39 UTC250
        {#if creating}
d095131eec0eatagen2026-10-24 08:39 UTC251
          <form class="new-pack" on:submit|preventDefault={() => void createPack()}>
132eeceeb362atagen2026-10-25 03:34 UTC252
            <select bind:value={newRoom} aria-label="Space or room">
132eeceeb362atagen2026-10-25 03:34 UTC253
              <option value="" disabled>Space or room</option>
132eeceeb362atagen2026-10-25 03:34 UTC254
              {#each spacePackRooms as room (room.room_id)}
132eeceeb362atagen2026-10-25 03:34 UTC255
                <option value={room.room_id}>Space · {room.name}</option>
132eeceeb362atagen2026-10-25 03:34 UTC256
              {/each}
132eeceeb362atagen2026-10-25 03:34 UTC257
              {#each regularPackRooms as room (room.room_id)}
132eeceeb362atagen2026-10-25 03:34 UTC258
                <option value={room.room_id}>Room · {room.name}</option>
d095131eec0eatagen2026-10-24 08:39 UTC259
              {/each}
d095131eec0eatagen2026-10-24 08:39 UTC260
            </select>
d095131eec0eatagen2026-10-24 08:39 UTC261
            <input type="text" placeholder="Pack name" bind:value={newName} aria-label="Pack name" />
d095131eec0eatagen2026-10-24 08:39 UTC262
            <div class="row">
d095131eec0eatagen2026-10-24 08:39 UTC263
              <button type="button" class="ghost-btn" on:click={() => (creating = false)}>Cancel</button>
d095131eec0eatagen2026-10-24 08:39 UTC264
              <button type="submit" class="primary-btn" disabled={!newRoom || !newName.trim()}>Create</button>
d095131eec0eatagen2026-10-24 08:39 UTC265
            </div>
d095131eec0eatagen2026-10-24 08:39 UTC266
          </form>
d095131eec0eatagen2026-10-24 08:39 UTC267
        {:else}
132eeceeb362atagen2026-10-25 03:34 UTC268
          <button type="button" class="ghost-btn new-btn" on:click={() => (creating = true)}>New pack</button>
d095131eec0eatagen2026-10-24 08:39 UTC269
        {/if}
d095131eec0eatagen2026-10-24 08:39 UTC270
      {/if}
d095131eec0eatagen2026-10-24 08:39 UTC271
    </nav>
d095131eec0eatagen2026-10-24 08:39 UTC272
d095131eec0eatagen2026-10-24 08:39 UTC273
    <section class="editor" aria-label="Pack">
132eeceeb362atagen2026-10-25 03:34 UTC274
      {#if selectedId === 'user'}
132eeceeb362atagen2026-10-25 03:34 UTC275
        <p class="scope">Personal pack synced to your account</p>
132eeceeb362atagen2026-10-25 03:34 UTC276
      {:else if selected}
132eeceeb362atagen2026-10-25 03:34 UTC277
        <p class="scope">{packScope(selected)}</p>
132eeceeb362atagen2026-10-25 03:34 UTC278
      {/if}
d095131eec0eatagen2026-10-24 08:39 UTC279
      {#if editable && target}
d095131eec0eatagen2026-10-24 08:39 UTC280
        <div class="meta">
d095131eec0eatagen2026-10-24 08:39 UTC281
          <button type="button" class="avatar-btn" title="Change pack avatar" on:click={() => avatarInput.click()}>
d095131eec0eatagen2026-10-24 08:39 UTC282
            <MxcAvatar mxc={selected?.avatar ?? null} label={selected?.display_name ?? 'Pack'} size={48} shape="rounded" />
d095131eec0eatagen2026-10-24 08:39 UTC283
          </button>
d095131eec0eatagen2026-10-24 08:39 UTC284
          <input
d095131eec0eatagen2026-10-24 08:39 UTC285
            class="name"
d095131eec0eatagen2026-10-24 08:39 UTC286
            type="text"
d095131eec0eatagen2026-10-24 08:39 UTC287
            value={selected?.display_name ?? ''}
d095131eec0eatagen2026-10-24 08:39 UTC288
            placeholder={selectedId === 'user' ? 'Your pack' : 'Pack name'}
d095131eec0eatagen2026-10-24 08:39 UTC289
            aria-label="Pack name"
d095131eec0eatagen2026-10-24 08:39 UTC290
            on:change={(e) => void rename(e)}
d095131eec0eatagen2026-10-24 08:39 UTC291
          />
d095131eec0eatagen2026-10-24 08:39 UTC292
          <input type="file" accept="image/*" class="visually-hidden" bind:this={avatarInput} on:change={() => void changeAvatar()} />
132eeceeb362atagen2026-10-25 03:34 UTC293
          {#if selected}
132eeceeb362atagen2026-10-25 03:34 UTC294
            <button type="button" class="ghost-btn" on:click={() => selected && (cloneSource = { kind: 'pack', pack: selected })}>Clone pack</button>
132eeceeb362atagen2026-10-25 03:34 UTC295
          {/if}
d095131eec0eatagen2026-10-24 08:39 UTC296
        </div>
d095131eec0eatagen2026-10-24 08:39 UTC297
        <div class="row">
d095131eec0eatagen2026-10-24 08:39 UTC298
          {#if selected && selected.source !== 'User'}
d095131eec0eatagen2026-10-24 08:39 UTC299
            <label class="check">
d095131eec0eatagen2026-10-24 08:39 UTC300
              <input type="checkbox" checked={selected.enabled_everywhere} on:change={(e) => void setEverywhere(e.currentTarget.checked)} />
d095131eec0eatagen2026-10-24 08:39 UTC301
              Use everywhere
d095131eec0eatagen2026-10-24 08:39 UTC302
            </label>
d095131eec0eatagen2026-10-24 08:39 UTC303
          {/if}
d095131eec0eatagen2026-10-24 08:39 UTC304
          <span class="spacer"></span>
d095131eec0eatagen2026-10-24 08:39 UTC305
          <button type="button" class="primary-btn" disabled={uploading !== null} on:click={() => fileInput.click()}>
d095131eec0eatagen2026-10-24 08:39 UTC306
            {uploading ? `Uploading ${uploading.done}/${uploading.total}` : 'Add images'}
d095131eec0eatagen2026-10-24 08:39 UTC307
          </button>
d095131eec0eatagen2026-10-24 08:39 UTC308
          <input type="file" accept="image/*" multiple class="visually-hidden" bind:this={fileInput} on:change={() => void addImages()} />
d095131eec0eatagen2026-10-24 08:39 UTC309
          {#if selected}
d095131eec0eatagen2026-10-24 08:39 UTC310
            {#if confirmDelete}
d095131eec0eatagen2026-10-24 08:39 UTC311
              <button type="button" class="ghost-btn" on:click={() => (confirmDelete = false)}>Keep</button>
d095131eec0eatagen2026-10-24 08:39 UTC312
              <button type="button" class="ghost-btn danger" on:click={() => void deletePack()}>Delete pack</button>
d095131eec0eatagen2026-10-24 08:39 UTC313
            {:else}
d095131eec0eatagen2026-10-24 08:39 UTC314
              <button type="button" class="ghost-btn danger" on:click={() => (confirmDelete = true)}>Delete</button>
d095131eec0eatagen2026-10-24 08:39 UTC315
            {/if}
d095131eec0eatagen2026-10-24 08:39 UTC316
          {/if}
d095131eec0eatagen2026-10-24 08:39 UTC317
        </div>
132eeceeb362atagen2026-10-25 03:34 UTC318
        <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>
d095131eec0eatagen2026-10-24 08:39 UTC319
        <div class="images">
d095131eec0eatagen2026-10-24 08:39 UTC320
          {#each selected?.images ?? [] as image (image.shortcode)}
d095131eec0eatagen2026-10-24 08:39 UTC321
            <div class="image">
132eeceeb362atagen2026-10-25 03:34 UTC322
              <img class:hidden={!imageEnabled($disabledPackImages, selected?.id ?? 'user', image.shortcode)} src={messageMediaUrl(image.media_id, 'Thumbnail')} alt={image.shortcode} loading="lazy" />
d095131eec0eatagen2026-10-24 08:39 UTC323
              <input type="text" value={image.shortcode} aria-label="Shortcode" on:change={(e) => void renameImage(image, e)} />
132eeceeb362atagen2026-10-25 03:34 UTC324
              <input type="text" value={image.body?.trim() || image.shortcode} aria-label="Sticker name" on:change={(e) => void renameImageBody(image, e)} />
132eeceeb362atagen2026-10-25 03:34 UTC325
              <label class="personal-check">
132eeceeb362atagen2026-10-25 03:34 UTC326
                <input type="checkbox" checked={imageEnabled($disabledPackImages, selected?.id ?? 'user', image.shortcode)} on:change={(e) => void togglePersonalImage(image, e.currentTarget.checked)} />
132eeceeb362atagen2026-10-25 03:34 UTC327
                Use for me
132eeceeb362atagen2026-10-25 03:34 UTC328
              </label>
d095131eec0eatagen2026-10-24 08:39 UTC329
              <div class="usage">
132eeceeb362atagen2026-10-25 03:34 UTC330
                <button type="button" aria-pressed={hasUsage(image, 'Emoticon')} title="Emoji for everyone" aria-label="Emoji for everyone" on:click={() => void toggleUsage(image, 'Emoticon')}>☺</button>
132eeceeb362atagen2026-10-25 03:34 UTC331
                <button type="button" aria-pressed={hasUsage(image, 'Sticker')} title="Sticker for everyone" aria-label="Sticker for everyone" on:click={() => void toggleUsage(image, 'Sticker')}>▣</button>
132eeceeb362atagen2026-10-25 03:34 UTC332
                <button type="button" class="ghost-btn clone" on:click={() => openImageClone(image)}>Clone</button>
d095131eec0eatagen2026-10-24 08:39 UTC333
                <button type="button" class="remove" title="Remove" aria-label={`Remove ${image.shortcode}`} on:click={() => void removeImage(image)}>✕</button>
d095131eec0eatagen2026-10-24 08:39 UTC334
              </div>
d095131eec0eatagen2026-10-24 08:39 UTC335
            </div>
d095131eec0eatagen2026-10-24 08:39 UTC336
          {:else}
d095131eec0eatagen2026-10-24 08:39 UTC337
            <p class="empty">No images yet</p>
d095131eec0eatagen2026-10-24 08:39 UTC338
          {/each}
d095131eec0eatagen2026-10-24 08:39 UTC339
        </div>
d095131eec0eatagen2026-10-24 08:39 UTC340
      {:else if selected}
d095131eec0eatagen2026-10-24 08:39 UTC341
        <div class="meta">
d095131eec0eatagen2026-10-24 08:39 UTC342
          <MxcAvatar mxc={selected.avatar} label={packLabel(selected, roomName)} size={48} shape="rounded" />
445bf199a9cfatagen2026-10-25 02:56 UTC343
          <strong class="name"><EmojiText text={packLabel(selected, roomName)} /></strong>
d095131eec0eatagen2026-10-24 08:39 UTC344
          <span class="spacer"></span>
132eeceeb362atagen2026-10-25 03:34 UTC345
          <button type="button" class="primary-btn" on:click={() => (cloneSource = { kind: 'pack', pack: selected })}>Clone pack</button>
d095131eec0eatagen2026-10-24 08:39 UTC346
        </div>
132eeceeb362atagen2026-10-25 03:34 UTC347
        {#if selected.source !== 'User'}
132eeceeb362atagen2026-10-25 03:34 UTC348
          <label class="check">
132eeceeb362atagen2026-10-25 03:34 UTC349
            <input type="checkbox" checked={selected.enabled_everywhere} on:change={(e) => void setEverywhere(e.currentTarget.checked)} />
132eeceeb362atagen2026-10-25 03:34 UTC350
            Use everywhere
132eeceeb362atagen2026-10-25 03:34 UTC351
          </label>
132eeceeb362atagen2026-10-25 03:34 UTC352
        {/if}
132eeceeb362atagen2026-10-25 03:34 UTC353
        <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>
d095131eec0eatagen2026-10-24 08:39 UTC354
        <div class="images">
d095131eec0eatagen2026-10-24 08:39 UTC355
          {#each selected.images as image (image.shortcode)}
d095131eec0eatagen2026-10-24 08:39 UTC356
            <div class="image">
132eeceeb362atagen2026-10-25 03:34 UTC357
              <img class:hidden={!imageEnabled($disabledPackImages, selected?.id ?? 'user', image.shortcode)} src={messageMediaUrl(image.media_id, 'Thumbnail')} alt={image.shortcode} loading="lazy" />
132eeceeb362atagen2026-10-25 03:34 UTC358
              <span class="code">{image.body?.trim() ? `${image.body.trim()} · ${image.shortcode}` : image.shortcode}</span>
132eeceeb362atagen2026-10-25 03:34 UTC359
              <label class="personal-check">
132eeceeb362atagen2026-10-25 03:34 UTC360
                <input type="checkbox" checked={imageEnabled($disabledPackImages, selected?.id ?? 'user', image.shortcode)} on:change={(e) => void togglePersonalImage(image, e.currentTarget.checked)} />
132eeceeb362atagen2026-10-25 03:34 UTC361
                Use for me
132eeceeb362atagen2026-10-25 03:34 UTC362
              </label>
132eeceeb362atagen2026-10-25 03:34 UTC363
              <button type="button" class="ghost-btn" on:click={() => openImageClone(image)}>Clone</button>
d095131eec0eatagen2026-10-24 08:39 UTC364
            </div>
d095131eec0eatagen2026-10-24 08:39 UTC365
          {/each}
d095131eec0eatagen2026-10-24 08:39 UTC366
        </div>
d095131eec0eatagen2026-10-24 08:39 UTC367
      {/if}
d095131eec0eatagen2026-10-24 08:39 UTC368
    </section>
d095131eec0eatagen2026-10-24 08:39 UTC369
  </div>
d095131eec0eatagen2026-10-24 08:39 UTC370
</div>
d095131eec0eatagen2026-10-24 08:39 UTC371
132eeceeb362atagen2026-10-25 03:34 UTC372
{#if cloneSource}
132eeceeb362atagen2026-10-25 03:34 UTC373
  <div class="modal-wrap">
132eeceeb362atagen2026-10-25 03:34 UTC374
    <PackCloneDialog
132eeceeb362atagen2026-10-25 03:34 UTC375
      source={cloneSource}
132eeceeb362atagen2026-10-25 03:34 UTC376
      {packs}
132eeceeb362atagen2026-10-25 03:34 UTC377
      {rooms}
132eeceeb362atagen2026-10-25 03:34 UTC378
      onClose={() => (cloneSource = null)}
132eeceeb362atagen2026-10-25 03:34 UTC379
      {onError}
132eeceeb362atagen2026-10-25 03:34 UTC380
    />
132eeceeb362atagen2026-10-25 03:34 UTC381
  </div>
d095131eec0eatagen2026-10-24 08:39 UTC382
{/if}
d095131eec0eatagen2026-10-24 08:39 UTC383
d095131eec0eatagen2026-10-24 08:39 UTC384
<style>
0e50e0d137afatagen2026-10-26 05:50 UTC385
  .packs { width: min(820px, calc(100vw - 32px)); max-height: calc(100dvh - 64px); display: flex; flex-direction: column; }
d095131eec0eatagen2026-10-24 08:39 UTC386
  .layout { display: flex; gap: 14px; min-height: 0; flex: 1; }
d095131eec0eatagen2026-10-24 08:39 UTC387
  .pack-list { flex: none; width: 210px; display: flex; flex-direction: column; gap: 2px; overflow-y: auto; }
d095131eec0eatagen2026-10-24 08:39 UTC388
  h4 { margin: 10px 6px 4px; font-size: 12px; color: var(--muted); font-weight: 650; }
d095131eec0eatagen2026-10-24 08:39 UTC389
  .pack-item {
d095131eec0eatagen2026-10-24 08:39 UTC390
    display: flex;
d095131eec0eatagen2026-10-24 08:39 UTC391
    align-items: center;
d095131eec0eatagen2026-10-24 08:39 UTC392
    gap: 8px;
d095131eec0eatagen2026-10-24 08:39 UTC393
    padding: 6px 8px;
d095131eec0eatagen2026-10-24 08:39 UTC394
    border: none;
05a48e9e7942atagen2026-10-24 09:32 UTC395
    border-radius: var(--r-md);
d095131eec0eatagen2026-10-24 08:39 UTC396
    background: transparent;
d095131eec0eatagen2026-10-24 08:39 UTC397
    color: var(--text);
d095131eec0eatagen2026-10-24 08:39 UTC398
    font: inherit;
d095131eec0eatagen2026-10-24 08:39 UTC399
    font-size: 13.5px;
d095131eec0eatagen2026-10-24 08:39 UTC400
    text-align: left;
d095131eec0eatagen2026-10-24 08:39 UTC401
    cursor: pointer;
d095131eec0eatagen2026-10-24 08:39 UTC402
  }
d095131eec0eatagen2026-10-24 08:39 UTC403
  .pack-item span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
d095131eec0eatagen2026-10-24 08:39 UTC404
  .pack-item:hover { background: var(--hover); }
d095131eec0eatagen2026-10-24 08:39 UTC405
  .pack-item.active { background: color-mix(in srgb, var(--accent) 20%, transparent); }
d095131eec0eatagen2026-10-24 08:39 UTC406
  .new-btn { margin-top: 8px; }
d095131eec0eatagen2026-10-24 08:39 UTC407
  .new-pack { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
132eeceeb362atagen2026-10-25 03:34 UTC408
  .scope, .preference-note { margin: 0; color: var(--muted); font-size: 12px; }
d095131eec0eatagen2026-10-24 08:39 UTC409
  .editor { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10px; overflow-y: auto; }
d095131eec0eatagen2026-10-24 08:39 UTC410
  .meta { display: flex; align-items: center; gap: 10px; }
05a48e9e7942atagen2026-10-24 09:32 UTC411
  .avatar-btn { padding: 0; border: none; background: none; cursor: pointer; border-radius: var(--r-md); }
d095131eec0eatagen2026-10-24 08:39 UTC412
  .name { flex: 1; font-size: 15px; }
d095131eec0eatagen2026-10-24 08:39 UTC413
  .row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
d095131eec0eatagen2026-10-24 08:39 UTC414
  .spacer { flex: 1; }
d095131eec0eatagen2026-10-24 08:39 UTC415
  .check { display: flex; align-items: center; gap: 6px; font-size: 13px; }
d095131eec0eatagen2026-10-24 08:39 UTC416
  input[type='text'], select {
d095131eec0eatagen2026-10-24 08:39 UTC417
    background: var(--bg-soft);
d095131eec0eatagen2026-10-24 08:39 UTC418
    border: 1px solid var(--border);
05a48e9e7942atagen2026-10-24 09:32 UTC419
    border-radius: var(--r-sm);
d095131eec0eatagen2026-10-24 08:39 UTC420
    color: var(--text);
d095131eec0eatagen2026-10-24 08:39 UTC421
    padding: 6px 8px;
d095131eec0eatagen2026-10-24 08:39 UTC422
    font: inherit;
d095131eec0eatagen2026-10-24 08:39 UTC423
    font-size: 13px;
d095131eec0eatagen2026-10-24 08:39 UTC424
    min-width: 0;
d095131eec0eatagen2026-10-24 08:39 UTC425
  }
d095131eec0eatagen2026-10-24 08:39 UTC426
  .images { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 8px; }
d095131eec0eatagen2026-10-24 08:39 UTC427
  .image {
d095131eec0eatagen2026-10-24 08:39 UTC428
    display: flex;
d095131eec0eatagen2026-10-24 08:39 UTC429
    flex-direction: column;
d095131eec0eatagen2026-10-24 08:39 UTC430
    gap: 4px;
d095131eec0eatagen2026-10-24 08:39 UTC431
    padding: 6px;
d095131eec0eatagen2026-10-24 08:39 UTC432
    border: 1px solid var(--border);
05a48e9e7942atagen2026-10-24 09:32 UTC433
    border-radius: var(--r-md);
d095131eec0eatagen2026-10-24 08:39 UTC434
    background: var(--bg-soft);
d095131eec0eatagen2026-10-24 08:39 UTC435
  }
d095131eec0eatagen2026-10-24 08:39 UTC436
  .image img { width: 100%; aspect-ratio: 1; object-fit: contain; }
132eeceeb362atagen2026-10-25 03:34 UTC437
  .image img.hidden { opacity: 0.35; }
d095131eec0eatagen2026-10-24 08:39 UTC438
  .image input { width: 100%; font-size: 12px; padding: 3px 6px; }
132eeceeb362atagen2026-10-25 03:34 UTC439
  .personal-check { display: flex; align-items: center; gap: 5px; font-size: 12px; color: var(--muted); }
d095131eec0eatagen2026-10-24 08:39 UTC440
  .code { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
d095131eec0eatagen2026-10-24 08:39 UTC441
  .usage { display: flex; gap: 4px; }
d095131eec0eatagen2026-10-24 08:39 UTC442
  .usage button {
d095131eec0eatagen2026-10-24 08:39 UTC443
    flex: 1;
d095131eec0eatagen2026-10-24 08:39 UTC444
    padding: 2px 0;
d095131eec0eatagen2026-10-24 08:39 UTC445
    border: 1px solid var(--border);
05a48e9e7942atagen2026-10-24 09:32 UTC446
    border-radius: var(--r-sm);
d095131eec0eatagen2026-10-24 08:39 UTC447
    background: transparent;
d095131eec0eatagen2026-10-24 08:39 UTC448
    color: var(--muted);
d095131eec0eatagen2026-10-24 08:39 UTC449
    cursor: pointer;
d095131eec0eatagen2026-10-24 08:39 UTC450
  }
d095131eec0eatagen2026-10-24 08:39 UTC451
  .usage button[aria-pressed='true'] { color: var(--accent); border-color: var(--accent); }
d095131eec0eatagen2026-10-24 08:39 UTC452
  .usage .remove { flex: none; padding: 2px 6px; }
d095131eec0eatagen2026-10-24 08:39 UTC453
  .usage .remove:hover { color: var(--danger); border-color: var(--danger); }
132eeceeb362atagen2026-10-25 03:34 UTC454
  .usage .clone { flex: none; padding: 2px 6px; font-size: 12px; }
d095131eec0eatagen2026-10-24 08:39 UTC455
  .empty { color: var(--muted); font-size: 13px; }
132eeceeb362atagen2026-10-25 03:34 UTC456
  .modal-wrap { position: fixed; inset: 0; z-index: 70; display: grid; place-items: center; background: rgb(0 0 0 / 55%); }
d095131eec0eatagen2026-10-24 08:39 UTC457
</style>