atagen

malice

public
Star0
Watch0

Commit d095131eec0e

tarball

atagen <boss@atagen.co>2026-10-24 08:50 UTC

18 files changed+1214-18
web: add a sticker picker, a pack manager and clone actions for stickers and packs

Cloning goes through macltok rehost_media so a copied sticker lives on your own homeserver and survives the original server purging it, and clone_pack reports the images it could not fetch instead of failing the whole pack. Pack targets travel as a tagged {kind} object that malice-core maps to ImagePackSource, and set_pack_meta tells an absent avatar (keep) from null (clear) since macltok takes a three-way Patch.
Modified file: crates/malice-core/src/request.rs+276 additions−9 deletions
@@ -1,8 +1,8 @@
11 use macltok::ruma::{events::GlobalAccountDataEventType, serde::Raw};
22 use macltok::{
3- AttachmentUpload, ClientHandle, CreateRoomOptions, MediaId, MediaSize, MemberFilter,
4- MessageContent, MessageEditContent, MirrorHandle, RichText, TimelineId, VerificationAction,
5- VerificationTarget,
3+ AttachmentUpload, ClientHandle, CreateRoomOptions, ImagePackSource, MediaId, MediaSize,
4+ MemberFilter, MessageContent, MessageEditContent, MirrorHandle, PackImageInfo, PackUsage,
5+ Patch, RichText, TimelineId, VerificationAction, VerificationTarget,
66 };
77 use serde::Deserialize;
88 use serde_json::json;
@@ -227,11 +227,128 @@ pub enum Request {
227227 PowerLevels {
228228 room_id: String,
229229 },
230+ UploadMedia {
231+ #[serde(skip)]
232+ bytes: Vec<u8>,
233+ },
234+ SendSticker {
235+ room_id: String,
236+ pack_id: String,
237+ shortcode: String,
238+ },
239+ CreateRoomPack {
240+ room_id: String,
241+ state_key: String,
242+ display_name: String,
243+ },
244+ DeletePack {
245+ target: PackTarget,
246+ },
247+ SetPackMeta {
248+ target: PackTarget,
249+ #[serde(default)]
250+ display_name: Option<String>,
251+ #[serde(default, deserialize_with = "present")]
252+ avatar: Option<Option<String>>,
253+ #[serde(default)]
254+ usage: Option<Vec<PackUsage>>,
255+ },
256+ PutPackImage {
257+ target: PackTarget,
258+ shortcode: String,
259+ mxc: String,
260+ #[serde(default)]
261+ body: Option<String>,
262+ #[serde(default)]
263+ usage: Option<Vec<PackUsage>>,
264+ #[serde(default)]
265+ info: Option<ImageInfo>,
266+ },
267+ RenamePackImage {
268+ target: PackTarget,
269+ from: String,
270+ to: String,
271+ },
272+ RemovePackImage {
273+ target: PackTarget,
274+ shortcode: String,
275+ },
276+ SetPackEnabled {
277+ room_id: String,
278+ state_key: String,
279+ enabled: bool,
280+ },
281+ ClonePackImage {
282+ from_pack: String,
283+ shortcode: String,
284+ to: PackTarget,
285+ },
286+ CloneSticker {
287+ room_id: String,
288+ event_id: String,
289+ to: PackTarget,
290+ shortcode: String,
291+ },
292+ ClonePack {
293+ from_pack: String,
294+ to: PackTarget,
295+ #[serde(default)]
296+ display_name: Option<String>,
297+ },
230298 Unignore {
231299 user_id: String,
232300 },
233301 }
234302
303+#[derive(Debug, Deserialize)]
304+#[serde(tag = "kind", rename_all = "snake_case")]
305+pub enum PackTarget {
306+ User,
307+ Room { room_id: String, state_key: String },
308+}
309+
310+impl PackTarget {
311+ fn source(self) -> Result<ImagePackSource, CoreError> {
312+ Ok(match self {
313+ Self::User => ImagePackSource::User,
314+ Self::Room { room_id, state_key } => ImagePackSource::Room {
315+ room_id: parse_room_id(&room_id)?,
316+ state_key: pack_text(state_key, "state_key")?,
317+ },
318+ })
319+ }
320+}
321+
322+#[derive(Debug, Deserialize)]
323+pub struct ImageInfo {
324+ #[serde(default)]
325+ mimetype: Option<String>,
326+ #[serde(default)]
327+ size_bytes: Option<u64>,
328+ #[serde(default)]
329+ width: Option<u64>,
330+ #[serde(default)]
331+ height: Option<u64>,
332+}
333+
334+/// Tells an absent field (keep) apart from an explicit null (clear).
335+fn present<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
336+where
337+ D: serde::Deserializer<'de>,
338+ T: Deserialize<'de>,
339+{
340+ Option::<T>::deserialize(deserializer).map(Some)
341+}
342+
343+fn pack_text(value: String, what: &str) -> Result<String, CoreError> {
344+ if value.is_empty() || value.len() > 255 {
345+ return Err(CoreError::bad_request(format!(
346+ "{what} must be 1..255 bytes"
347+ )));
348+ }
349+ Ok(value)
350+}
351+
235352 #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize)]
236353 #[serde(rename_all = "snake_case")]
237354 pub enum SendKind {
@@ -641,7 +758,7 @@ pub async fn dispatch(
641758 Ok(Reply::Done)
642759 }
643760 Request::UploadAvatar { bytes } => {
644- let content_type = avatar_content_type(&bytes)?;
761+ let content_type = image_upload_type(&bytes)?;
645762 handle.upload_avatar(content_type, bytes).await?;
646763 Ok(Reply::Done)
647764 }
@@ -705,7 +822,7 @@ pub async fn dispatch(
705822 }
706823 Request::UploadRoomAvatar { room_id, bytes } => {
707824 let room_id = parse_room_id(&room_id)?;
708- let content_type = avatar_content_type(&bytes)?;
825+ let content_type = image_upload_type(&bytes)?;
709826 let mxc = handle.upload_media(content_type, bytes).await?;
710827 handle.set_room_avatar(room_id, Some(mxc)).await?;
711828 Ok(Reply::Done)
@@ -720,6 +837,157 @@ pub async fn dispatch(
720837 .await?;
721838 Ok(Reply::Done)
722839 }
840+ Request::UploadMedia { bytes } => {
841+ let content_type = image_upload_type(&bytes)?;
842+ let mxc = handle.upload_media(content_type, bytes).await?;
843+ Ok(Reply::Json(json!({ "mxc": mxc })))
844+ }
845+ Request::SendSticker {
846+ room_id,
847+ pack_id,
848+ shortcode,
849+ } => {
850+ handle
851+ .send_sticker(
852+ parse_room_id(&room_id)?,
853+ pack_text(pack_id, "pack_id")?,
854+ pack_text(shortcode, "shortcode")?,
855+ )
856+ .await?;
857+ Ok(Reply::Done)
858+ }
859+ Request::CreateRoomPack {
860+ room_id,
861+ state_key,
862+ display_name,
863+ } => {
864+ handle
865+ .create_room_pack(
866+ parse_room_id(&room_id)?,
867+ pack_text(state_key, "state_key")?,
868+ pack_text(display_name.trim().to_owned(), "display_name")?,
869+ )
870+ .await?;
871+ Ok(Reply::Done)
872+ }
873+ Request::DeletePack { target } => {
874+ handle.delete_pack(target.source()?).await?;
875+ Ok(Reply::Done)
876+ }
877+ Request::SetPackMeta {
878+ target,
879+ display_name,
880+ avatar,
881+ usage,
882+ } => {
883+ let display_name = match display_name.map(|n| n.trim().to_owned()) {
884+ None => Patch::Keep,
885+ Some(name) if name.is_empty() => Patch::Clear,
886+ Some(name) => Patch::Set(pack_text(name, "display_name")?),
887+ };
888+ let avatar = match avatar {
889+ None => Patch::Keep,
890+ Some(None) => Patch::Clear,
891+ Some(Some(mxc)) => Patch::Set(parse_mxc(&mxc)?),
892+ };
893+ let usage = usage.map_or(Patch::Keep, Patch::Set);
894+ handle
895+ .set_pack_meta(target.source()?, display_name, avatar, usage)
896+ .await?;
897+ Ok(Reply::Done)
898+ }
899+ Request::PutPackImage {
900+ target,
901+ shortcode,
902+ mxc,
903+ body,
904+ usage,
905+ info,
906+ } => {
907+ let info =
908+ info.map(|i| PackImageInfo::new(i.mimetype, i.size_bytes, i.width, i.height));
909+ handle
910+ .put_pack_image(
911+ target.source()?,
912+ pack_text(shortcode, "shortcode")?,
913+ parse_mxc(&mxc)?,
914+ body,
915+ usage,
916+ info,
917+ )
918+ .await?;
919+ Ok(Reply::Done)
920+ }
921+ Request::RenamePackImage { target, from, to } => {
922+ handle
923+ .rename_pack_image(
924+ target.source()?,
925+ pack_text(from, "shortcode")?,
926+ pack_text(to, "shortcode")?,
927+ )
928+ .await?;
929+ Ok(Reply::Done)
930+ }
931+ Request::RemovePackImage { target, shortcode } => {
932+ handle
933+ .remove_pack_image(target.source()?, pack_text(shortcode, "shortcode")?)
934+ .await?;
935+ Ok(Reply::Done)
936+ }
937+ Request::SetPackEnabled {
938+ room_id,
939+ state_key,
940+ enabled,
941+ } => {
942+ handle
943+ .set_pack_enabled_everywhere(
944+ parse_room_id(&room_id)?,
945+ pack_text(state_key, "state_key")?,
946+ enabled,
947+ )
948+ .await?;
949+ Ok(Reply::Done)
950+ }
951+ Request::ClonePackImage {
952+ from_pack,
953+ shortcode,
954+ to,
955+ } => {
956+ let shortcode = handle
957+ .clone_pack_image(
958+ pack_text(from_pack, "pack_id")?,
959+ pack_text(shortcode, "shortcode")?,
960+ to.source()?,
961+ )
962+ .await?;
963+ Ok(Reply::Json(json!({ "shortcode": shortcode })))
964+ }
965+ Request::CloneSticker {
966+ room_id,
967+ event_id,
968+ to,
969+ shortcode,
970+ } => {
971+ let shortcode = handle
972+ .clone_sticker(
973+ parse_room_id(&room_id)?,
974+ parse_event_id(&event_id)?,
975+ to.source()?,
976+ pack_text(shortcode, "shortcode")?,
977+ )
978+ .await?;
979+ Ok(Reply::Json(json!({ "shortcode": shortcode })))
980+ }
981+ Request::ClonePack {
982+ from_pack,
983+ to,
984+ display_name,
985+ } => {
986+ let skipped = handle
987+ .clone_pack(pack_text(from_pack, "pack_id")?, to.source()?, display_name)
988+ .await?;
989+ Ok(Reply::Json(json!({ "skipped": skipped })))
990+ }
723991 Request::PowerLevels { room_id } => {
724992 let levels = handle.room_power_levels(&parse_room_id(&room_id)?).await?;
725993 let value = serde_json::to_value(levels)
@@ -737,14 +1005,13 @@ pub async fn dispatch(
7371005 }
7381006 }
7391007
740-fn avatar_content_type(bytes: &[u8]) -> Result<&'static str, CoreError> {
1008+fn image_upload_type(bytes: &[u8]) -> Result<&'static str, CoreError> {
7411009 if bytes.is_empty() || bytes.len() > 10 * 1024 * 1024 {
7421010 return Err(CoreError::bad_request(
743- "avatar must be between 1 byte and 10 MiB",
1011+ "image must be between 1 byte and 10 MiB",
7441012 ));
7451013 }
746- image_content_type(bytes)
747- .ok_or_else(|| CoreError::unsupported("avatar is not a supported image"))
1014+ image_content_type(bytes).ok_or_else(|| CoreError::unsupported("not a supported image"))
7481015 }
7491016
7501017 // The spec's power level range is a canonical-JSON integer, which tops out at 2^53 - 1.
Modified file: crates/malice-server/src/routes.rs+36 additions
@@ -549,6 +549,17 @@ async fn post_command(
549549 | Request::Ignore { .. }
550550 | Request::Unignore { .. }
551551 | Request::PowerLevels { .. }
552+ | Request::SendSticker { .. }
553+ | Request::CreateRoomPack { .. }
554+ | Request::DeletePack { .. }
555+ | Request::SetPackMeta { .. }
556+ | Request::PutPackImage { .. }
557+ | Request::RenamePackImage { .. }
558+ | Request::RemovePackImage { .. }
559+ | Request::SetPackEnabled { .. }
560+ | Request::ClonePackImage { .. }
561+ | Request::CloneSticker { .. }
562+ | Request::ClonePack { .. }
552563 ) {
553564 return ApiError::bad_request("op is not available as a command").into_response();
554565 }
@@ -604,6 +615,27 @@ async fn rooms_avatar(
604615 )
605616 }
606617
618+async fn media_upload(
619+ State(state): State<AppState>,
620+ headers: HeaderMap,
621+ bytes: axum::body::Bytes,
622+) -> Response {
623+ let (user, _) = match auth(&state, &headers).await {
624+ Ok(pair) => pair,
625+ Err(error) => return error.into_response(),
626+ };
627+ respond(
628+ dispatch(
629+ &user.handle,
630+ &user.mirror,
631+ Request::UploadMedia {
632+ bytes: bytes.to_vec(),
633+ },
634+ )
635+ .await,
636+ )
637+}
638+
607639 async fn rooms_paginate(
608640 State(state): State<AppState>,
609641 headers: HeaderMap,
@@ -1037,6 +1069,10 @@ pub fn router(state: AppState, web_dir: Option<String>) -> Router {
10371069 "/rooms/avatar",
10381070 post(rooms_avatar).layer(DefaultBodyLimit::max(10 * 1024 * 1024)),
10391071 )
1072+ .route(
1073+ "/media/upload",
1074+ post(media_upload).layer(DefaultBodyLimit::max(10 * 1024 * 1024)),
1075+ )
10401076 .route("/media", get(get_media))
10411077 .route("/message-media", get(get_message_media))
10421078 .route("/spaces/children", get(get_space_children))
Modified file: crates/malice-worker/src/lib.rs+2 additions−1 deletions
@@ -172,7 +172,8 @@ impl Host {
172172 .map_err(|error| error_value("bad_request", error.to_string()))?;
173173 if let Request::Upload { bytes: slot, .. }
174174 | Request::UploadAvatar { bytes: slot }
175- | Request::UploadRoomAvatar { bytes: slot, .. } = &mut request
175+ | Request::UploadRoomAvatar { bytes: slot, .. }
176+ | Request::UploadMedia { bytes: slot } = &mut request
176177 {
177178 *slot = bytes.map(|array| array.to_vec()).unwrap_or_default();
178179 }
Modified file: web/src/App.svelte+99 additions−2 deletions
@@ -5,6 +5,10 @@
55 import MxcAvatar from './lib/MxcAvatar.svelte';
66 import PinButton from './lib/PinButton.svelte';
77 import ContextMenu, { type MenuItem } from './lib/ContextMenu.svelte';
8+ import StickerPicker from './lib/StickerPicker.svelte';
9+ import PackManager from './lib/PackManager.svelte';
10+ import { clonePackDestinations, editableChoices, shortcodeFor } from './lib/packs';
11+ import type { CommandRequest } from './lib/transport';
812 import UserCard, { type UserAction } from './lib/UserCard.svelte';
913 import { allowed, isMuted, parsePowerLevels, type PowerLevels } from './lib/permissions';
1014 import { moveBefore, pinnedFirst, sortSpaces } from './lib/rail-order';
@@ -74,7 +78,7 @@
7478 import { lastSpaceRooms, lineHeight, markdownEnabled, messageSpacing, navFolded, railFolded } from './lib/appearance';
7579 import SettingsPanel from './lib/SettingsPanel.svelte';
7680 import { packsForRoom, parseEncryptionState, parseOwnProfile, parseVerificationState, contentPreview, senderLabel } from './lib/types';
77- import type { MemberSummary, OwnProfile, Person, QuickReaction, ReplyReference, RoomSummary, SendState, TimelineEvent, TimelineItem } from './lib/types';
81+ import type { ImagePack, MemberSummary, OwnProfile, PackImage, Person, QuickReaction, ReplyReference, RoomSummary, SendState, TimelineEvent, TimelineItem } from './lib/types';
7882
7983 let roomsLoading = false;
8084 let booted = false;
@@ -83,7 +87,8 @@
8387 let sidebarOpen = false;
8488 let membersOpen = false;
8589 let replyingTo: ReplyReference | null = null;
86- let dialog: 'join' | 'create' | 'security' | 'settings' | null = null;
90+ let dialog: 'join' | 'create' | 'security' | 'settings' | 'packs' | null = null;
91+ let stickersOpen = false;
8792 let joinTarget = '';
8893 let joinBusy = false;
8994 let createName = '';
@@ -1284,6 +1289,66 @@
12841289 menu = { items: roomMenuItems(room), x: ev.clientX, y: ev.clientY };
12851290 }
12861291
1292+ function roomName(roomId: string): string {
1293+ return $rooms.find((r) => r.room_id === roomId)?.name ?? roomId;
1294+ }
1295+
1296+ async function sendSticker(roomId: string, pack: ImagePack, image: PackImage): Promise<void> {
1297+ stickersOpen = false;
1298+ stickToBottom = true;
1299+ try {
1300+ await runCommand({ op: 'send_sticker', room_id: roomId, pack_id: pack.id, shortcode: image.shortcode });
1301+ if (peeking === roomId) stopPeeking();
1302+ } catch (e) {
1303+ pushBanner(e instanceof Error ? e.message : 'Could not send the sticker');
1304+ }
1305+ }
1306+
1307+ async function runClone(request: CommandRequest, failure: string): Promise<void> {
1308+ try {
1309+ const res = (await runCommand(request)) as { skipped?: string[] } | undefined;
1310+ if (res?.skipped?.length) pushBanner(`Couldn't copy ${res.skipped.map((s) => `:${s}:`).join(', ')}`);
1311+ } catch (e) {
1312+ pushBanner(e instanceof Error ? e.message : failure);
1313+ }
1314+ }
1315+
1316+ function openCloneImageMenu(pack: ImagePack, image: PackImage, x: number, y: number): void {
1317+ const items = editableChoices($imagePacks, roomName)
1318+ .filter((c) => c.pack?.id !== pack.id)
1319+ .map((choice) => ({
1320+ label: `Clone to ${choice.label}`,
1321+ action: () =>
1322+ void runClone({ op: 'clone_pack_image', from_pack: pack.id, shortcode: image.shortcode, to: choice.target }, 'Could not clone the sticker')
1323+ }));
1324+ menu = { items, x, y };
1325+ }
1326+
1327+ function openClonePackMenu(pack: ImagePack, x: number, y: number): void {
1328+ const packRooms = $rooms.filter((r) => r.can_edit_packs && r.membership === 'Joined');
1329+ const items = clonePackDestinations(pack, packRooms, roomName).map((d) => ({
1330+ label: d.label,
1331+ action: () => void runClone({ op: 'clone_pack', from_pack: pack.id, to: d.target, display_name: d.display_name }, 'Could not clone the pack')
1332+ }));
1333+ menu = { items, x, y };
1334+ }
1335+
1336+ function openCloneStickerMenu(eventId: string, body: string, x: number, y: number): void {
1337+ const roomId = $selectedRoomId;
1338+ if (!roomId) return;
1339+ const items = editableChoices($imagePacks, roomName).map((choice) => ({
1340+ label: `Clone to ${choice.label}`,
1341+ action: () => {
1342+ const taken = new Set(choice.pack?.images.map((i) => i.shortcode) ?? []);
1343+ void runClone(
1344+ { op: 'clone_sticker', room_id: roomId, event_id: eventId, to: choice.target, shortcode: shortcodeFor(body || 'sticker', taken) },
1345+ 'Could not clone the sticker'
1346+ );
1347+ }
1348+ }));
1349+ menu = { items, x, y };
1350+ }
1351+
12871352 function stopPeeking(): void {
12881353 peeking = null;
12891354 queueReadReceipt();
@@ -1932,6 +1997,7 @@
19321997 {people}
19331998 self={$sessionUser ?? null}
19341999 onUser={(userId, x, y, asMenu) => void openUser(userId, x, y, asMenu)}
2000+ onCloneSticker={openCloneStickerMenu}
19352001 onReply={selectedRoom.membership === 'Joined' ? startReply : null}
19362002 onJump={jumpTo}
19372003 onEdit={selectedRoom.membership === 'Joined' ? startEdit : null}
@@ -2057,6 +2123,34 @@
20572123 >
20582124 {uploadBusy ? `Uploading ${uploadName ?? 'file'}…` : '📎'}
20592125 </button>
2126+ <button
2127+ type="button"
2128+ class="icon-btn sticker-btn"
2129+ title="Stickers"
2130+ aria-label="Stickers"
2131+ aria-expanded={stickersOpen}
2132+ disabled={!($selectedRoomId && openReady[$selectedRoomId])}
2133+ on:click={() => (stickersOpen = !stickersOpen)}
2134+ >
2135+ <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
2136+ <path d="M4 6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v7l-7 7H6a2 2 0 0 1-2-2z" fill="none" stroke="currentColor" stroke-width="1.8" />
2137+ <path d="M13 20v-5a2 2 0 0 1 2-2h5" fill="none" stroke="currentColor" stroke-width="1.8" />
2138+ </svg>
2139+ </button>
2140+ {#if stickersOpen && $selectedRoomId}
2141+ {@const stickerRoom = $selectedRoomId}
2142+ <div class="sticker-pop">
2143+ <StickerPicker
2144+ packs={roomPacks}
2145+ {roomName}
2146+ onSend={(pack, image) => void sendSticker(stickerRoom, pack, image)}
2147+ onClone={(pack, image, x, y) => openCloneImageMenu(pack, image, x, y)}
2148+ onClonePack={(pack, x, y) => openClonePackMenu(pack, x, y)}
2149+ onManage={() => ((stickersOpen = false), (dialog = 'packs'))}
2150+ onClose={() => (stickersOpen = false)}
2151+ />
2152+ </div>
2153+ {/if}
20602154 <textarea
20612155 id="composer"
20622156 bind:this={composerEl}
@@ -2167,7 +2261,10 @@
21672261 packs={roomPacks}
21682262 onQuickChange={(list) => void changeQuickReactions(list)}
21692263 on:close={() => (dialog = null)}
2264+ on:packs={() => (dialog = 'packs')}
21702265 />
2266+ {:else if dialog === 'packs'}
2267+ <PackManager packs={$imagePacks} rooms={$rooms} onError={pushBanner} on:close={() => (dialog = null)} />
21712268 {:else}
21722269 <div class="modal" role="dialog" aria-modal="true" aria-label={dialog === 'join' ? 'Join a room' : 'Create a room'}>
21732270 <div class="modal-tabs" role="tablist" aria-label="Room actions">
Modified file: web/src/app.css+3 additions
@@ -581,6 +581,9 @@ input, textarea { font: inherit; }
581581 .reply-bar-text { margin-left: 6px; }
582582 .composer.replying, .composer.staging, .reply-bar + .staged { border-top: none; }
583583 .composer { position: relative; }
584+.sticker-pop { position: absolute; bottom: calc(100% - 6px); left: 16px; z-index: 30; }
585+.sticker-btn { align-self: center; padding: 4px 6px; }
586+.sticker-btn[aria-expanded='true'] { color: var(--accent); }
584587 .mention-finder {
585588 position: absolute;
586589 left: 16px;
Modified file: web/src/lib/MessageRow.stories.ts+2 additions
@@ -201,6 +201,8 @@ export const PackEmotePick: Story = {
201201 avatar: null,
202202 attribution: null,
203203 source: 'User',
204+ editable: true,
205+ enabled_everywhere: true,
204206 images: [
205207 {
206208 shortcode: 'rabbit',
Modified file: web/src/lib/MessageRow.svelte+13 additions
@@ -32,6 +32,7 @@
3232 export let showSent = true;
3333 export let self: string | null = null;
3434 export let onUser: ((userId: string, x: number, y: number, menu: boolean) => void) | null = null;
35+ export let onCloneSticker: ((eventId: string, body: string, x: number, y: number) => void) | null = null;
3536
3637 function openUser(ev: MouseEvent, menu: boolean): void {
3738 if (!onUser) return;
@@ -277,6 +278,18 @@
277278 >
278279 ☺+
279280 </button>
281+ {#if onCloneSticker && event.content.kind === 'Sticker'}
282+ {@const stickerBody = event.content.body}
283+ {@const stickerId = event.event_id}
284+ <button
285+ type="button"
286+ title="Clone sticker"
287+ aria-label="Clone sticker to one of your packs"
288+ on:click|stopPropagation={(ev) => onCloneSticker?.(stickerId, stickerBody, ev.clientX, ev.clientY)}
289+ >
290+ ⧉
291+ </button>
292+ {/if}
280293 {#if onReply}
281294 <button
282295 type="button"
Added file: web/src/lib/PackManager.svelte+399 additions
@@ -0,0 +1,399 @@
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>
Modified file: web/src/lib/ReactionPicker.stories.ts+4 additions
@@ -15,6 +15,8 @@ const packs: ImagePack[] = [
1515 avatar: null,
1616 attribution: null,
1717 source: 'User',
18+ editable: true,
19+ enabled_everywhere: true,
1820 images: [
1921 image('rabbit', 'White rabbit', ['Emoticon']),
2022 image('teapot', null, ['Emoticon', 'Sticker'])
@@ -26,6 +28,8 @@ const packs: ImagePack[] = [
2628 avatar: null,
2729 attribution: null,
2830 source: { Room: { room_id: '!tea:example.org', state_key: 'tea' } },
31+ editable: false,
32+ enabled_everywhere: true,
2933 images: [image('dormouse', null, ['Sticker'])]
3034 }
3135 ];
Modified file: web/src/lib/SettingsPanel.svelte+8 additions−1 deletions
@@ -21,7 +21,7 @@
2121 export let packs: ImagePack[] = [];
2222 export let onQuickChange: (list: QuickReaction[]) => void;
2323
24- const dispatch = createEventDispatcher<{ close: void }>();
24+ const dispatch = createEventDispatcher<{ close: void; packs: void }>();
2525
2626 $: quickIsDefault =
2727 quick.length === DEFAULT_QUICK_REACTIONS.length && quick.every((r, i) => r.key === DEFAULT_QUICK_REACTIONS[i].key);
@@ -116,6 +116,13 @@
116116 </div>
117117 <ReactionPicker embedded {quick} {packs} {onQuickChange} onPick={() => {}} onClose={() => dispatch('close')} />
118118 </section>
119+
120+ <section aria-label="Packs">
121+ <div class="sec-head">
122+ <h3 class="sec-h">Sticker and emoji packs</h3>
123+ <button type="button" class="ghost-btn" on:click={() => dispatch('packs')}>Manage</button>
124+ </div>
125+ </section>
119126 </div>
120127
121128 <style>
Added file: web/src/lib/StickerPicker.svelte+171 additions
@@ -0,0 +1,171 @@
1+<script lang="ts">
2+ import { onMount } from 'svelte';
3+ import { messageMediaUrl } from './api';
4+ import MxcAvatar from './MxcAvatar.svelte';
5+ import { packLabel, stickersOf } from './packs';
6+ import type { ImagePack, PackImage } from './types';
7+
8+ export let packs: ImagePack[];
9+ export let roomName: (roomId: string) => string;
10+ export let onSend: (pack: ImagePack, image: PackImage) => void;
11+ export let onClone: (pack: ImagePack, image: PackImage, x: number, y: number) => void;
12+ export let onClonePack: (pack: ImagePack, x: number, y: number) => void;
13+ export let onManage: () => void;
14+ export let onClose: () => void;
15+
16+ let root: HTMLDivElement;
17+ let search: HTMLInputElement;
18+ let query = '';
19+ let activeId: string | null = null;
20+
21+ $: withStickers = packs.filter((p) => stickersOf(p).length > 0);
22+ $: q = query.trim().toLowerCase();
23+ $: shown = withStickers
24+ .map((pack) => ({
25+ pack,
26+ images: stickersOf(pack).filter(
27+ (i) => !q || i.shortcode.toLowerCase().includes(q) || (i.body ?? '').toLowerCase().includes(q)
28+ )
29+ }))
30+ .filter((g) => g.images.length > 0 && (q || activeId === null || g.pack.id === activeId));
31+
32+ onMount(() => search?.focus());
33+
34+ function outside(ev: PointerEvent): void {
35+ if (!root.contains(ev.target as Node) && !(ev.target as Element).closest?.('.context-menu, .sticker-btn')) onClose();
36+ }
37+
38+ function key(ev: KeyboardEvent): void {
39+ if (ev.key === 'Escape' && !document.querySelector('.context-menu')) {
40+ ev.stopPropagation();
41+ onClose();
42+ }
43+ }
44+</script>
45+
46+<svelte:window on:pointerdown|capture={outside} on:keydown|capture={key} />
47+
48+<div class="sticker-picker" role="dialog" aria-label="Stickers" bind:this={root}>
49+ <div class="top">
50+ <input class="search" type="search" placeholder="Search stickers" bind:value={query} bind:this={search} />
51+ <button type="button" class="icon-btn" title="Manage packs" aria-label="Manage packs" on:click={onManage}>
52+ <span aria-hidden="true">⚙</span>
53+ </button>
54+ </div>
55+ {#if withStickers.length > 1}
56+ <div class="tabs" role="tablist" aria-label="Packs">
57+ <button type="button" role="tab" class="tab all" aria-selected={activeId === null} on:click={() => (activeId = null)}>All</button>
58+ {#each withStickers as pack (pack.id)}
59+ <button
60+ type="button"
61+ role="tab"
62+ class="tab"
63+ aria-selected={activeId === pack.id}
64+ title={packLabel(pack, roomName)}
65+ on:click={() => (activeId = pack.id)}
66+ >
67+ {#if pack.avatar}
68+ <MxcAvatar mxc={pack.avatar} label={packLabel(pack, roomName)} size={24} shape="rounded" />
69+ {:else}
70+ <img src={messageMediaUrl(stickersOf(pack)[0].media_id, 'Thumbnail')} alt={packLabel(pack, roomName)} />
71+ {/if}
72+ </button>
73+ {/each}
74+ </div>
75+ {/if}
76+ <div class="scroll">
77+ {#each shown as g (g.pack.id)}
78+ <section class="group" aria-label={packLabel(g.pack, roomName)}>
79+ <div class="group-head">
80+ <h3>{packLabel(g.pack, roomName)}</h3>
81+ {#if !g.pack.editable}
82+ <button type="button" class="clone-pack" on:click={(ev) => onClonePack(g.pack, ev.clientX, ev.clientY)}>Clone pack</button>
83+ {/if}
84+ </div>
85+ <div class="grid">
86+ {#each g.images as image (image.shortcode)}
87+ <button
88+ type="button"
89+ class="cell"
90+ title={image.body ?? image.shortcode}
91+ aria-label={image.body ?? image.shortcode}
92+ on:click={() => onSend(g.pack, image)}
93+ on:contextmenu|preventDefault={(ev) => onClone(g.pack, image, ev.clientX, ev.clientY)}
94+ >
95+ <img src={messageMediaUrl(image.media_id, 'Thumbnail')} alt="" loading="lazy" />
96+ </button>
97+ {/each}
98+ </div>
99+ </section>
100+ {:else}
101+ <p class="status">{withStickers.length === 0 ? 'No stickers yet' : `No stickers match "${query.trim()}"`}</p>
102+ {/each}
103+ </div>
104+</div>
105+
106+<style>
107+ .sticker-picker {
108+ display: flex;
109+ flex-direction: column;
110+ gap: 6px;
111+ width: 360px;
112+ height: 420px;
113+ max-width: calc(100vw - 16px);
114+ max-height: calc(100vh - 120px);
115+ padding: 8px;
116+ background: var(--panel);
117+ border: 1px solid var(--border);
118+ border-radius: 10px;
119+ box-shadow: 0 6px 24px rgb(0 0 0 / 0.5);
120+ }
121+ .top { display: flex; gap: 6px; }
122+ .search {
123+ flex: 1;
124+ background: var(--bg-soft);
125+ border: 1px solid var(--border);
126+ border-radius: 8px;
127+ color: var(--text);
128+ padding: 6px 10px;
129+ font-size: 13px;
130+ }
131+ .search:focus-visible { outline: none; border-color: var(--accent); }
132+ .tabs { display: flex; gap: 4px; overflow-x: auto; padding-bottom: 2px; }
133+ .tab {
134+ flex: none;
135+ display: grid;
136+ place-items: center;
137+ min-width: 32px;
138+ height: 32px;
139+ padding: 2px 6px;
140+ border: 1px solid transparent;
141+ border-radius: 8px;
142+ background: transparent;
143+ color: var(--muted);
144+ font-size: 12px;
145+ cursor: pointer;
146+ }
147+ .tab img { width: 24px; height: 24px; object-fit: contain; }
148+ .tab[aria-selected='true'] { border-color: var(--accent); color: var(--text); }
149+ .scroll { flex: 1; overflow-y: auto; min-height: 0; }
150+ .group-head { display: flex; align-items: center; justify-content: space-between; margin: 6px 2px 4px; }
151+ h3 { margin: 0; font-size: 12px; font-weight: 650; color: var(--muted); }
152+ .clone-pack {
153+ border: none;
154+ background: none;
155+ color: var(--accent);
156+ font-size: 12px;
157+ cursor: pointer;
158+ }
159+ .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(76px, 1fr)); gap: 4px; }
160+ .cell {
161+ aspect-ratio: 1;
162+ padding: 4px;
163+ border: 1px solid transparent;
164+ border-radius: 8px;
165+ background: transparent;
166+ cursor: pointer;
167+ }
168+ .cell:hover, .cell:focus-visible { background: var(--hover); border-color: var(--border); outline: none; }
169+ .cell img { width: 100%; height: 100%; object-fit: contain; }
170+ .status { color: var(--muted); font-size: 13px; text-align: center; margin-top: 24px; }
171+</style>
Modified file: web/src/lib/api.ts+1 additions
@@ -46,6 +46,7 @@ export const sendMessage = (
4646 export const runCommand = (request: CommandRequest) => transport.command(request);
4747 export const uploadAvatar = (file: File) => transport.uploadAvatar(file);
4848 export const uploadRoomAvatar = (room_id: string, file: File) => transport.uploadRoomAvatar(room_id, file);
49+export const uploadMedia = (file: File) => transport.uploadMedia(file);
4950 export const paginateRoom = (room_id: string) => transport.paginateRoom(room_id);
5051 export const setTyping = (room_id: string, typing: boolean) => transport.setTyping(room_id, typing);
5152 export const getAccountData = (eventType: string) => transport.getAccountData(eventType);
Added file: web/src/lib/packs.ts+102 additions
@@ -0,0 +1,102 @@
1+import type { PackTarget } from './transport';
2+import type { ImagePack, ImagePacks, PackImage, PackUsage } from './types';
3+
4+export interface PackChoice {
5+ label: string;
6+ target: PackTarget;
7+ pack: ImagePack | null;
8+}
9+
10+export function packTarget(pack: ImagePack): PackTarget {
11+ if (pack.source === 'User') return { kind: 'user' };
12+ return { kind: 'room', room_id: pack.source.Room.room_id, state_key: pack.source.Room.state_key };
13+}
14+
15+export function packLabel(pack: ImagePack, roomName: (roomId: string) => string): string {
16+ if (pack.display_name) return pack.display_name;
17+ if (pack.source === 'User') return 'Your pack';
18+ return roomName(pack.source.Room.room_id);
19+}
20+
21+export function allPacks(packs: ImagePacks): ImagePack[] {
22+ const seen = new Set<string>();
23+ const out: ImagePack[] = [];
24+ for (const pack of [...packs.global, ...Object.values(packs.rooms).flat()]) {
25+ if (seen.has(pack.id)) continue;
26+ seen.add(pack.id);
27+ out.push(pack);
28+ }
29+ return out;
30+}
31+
32+/** Packs you can write to, with your own pack first even before it exists. */
33+export function editableChoices(packs: ImagePacks, roomName: (roomId: string) => string): PackChoice[] {
34+ const all = allPacks(packs);
35+ const user = all.find((p) => p.source === 'User') ?? null;
36+ const choices: PackChoice[] = [{ label: user?.display_name ?? 'Your pack', target: { kind: 'user' }, pack: user }];
37+ for (const pack of all) {
38+ if (pack.source === 'User' || !pack.editable) continue;
39+ choices.push({ label: packLabel(pack, roomName), target: packTarget(pack), pack });
40+ }
41+ return choices;
42+}
43+
44+export function hasUsage(image: PackImage, usage: PackUsage): boolean {
45+ return image.usage.length === 0 || image.usage.includes(usage);
46+}
47+
48+export function stickersOf(pack: ImagePack): PackImage[] {
49+ return pack.images.filter((image) => hasUsage(image, 'Sticker'));
50+}
51+
52+/** A shortcode from a file name, unique within `taken`. */
53+export function shortcodeFor(filename: string, taken: Set<string>): string {
54+ const stem = filename.replace(/\.[^.]+$/, '');
55+ const base =
56+ stem
57+ .toLowerCase()
58+ .replace(/[^a-z0-9_-]+/g, '_')
59+ .replace(/^_+|_+$/g, '')
60+ .slice(0, 64) || 'image';
61+ let candidate = base;
62+ for (let n = 2; taken.has(candidate); n += 1) candidate = `${base}_${n}`;
63+ taken.add(candidate);
64+ return candidate;
65+}
66+
67+export function validShortcode(code: string): boolean {
68+ return /^[^\s:]{1,100}$/.test(code);
69+}
70+
71+export function packSlug(name: string): string {
72+ return (
73+ name
74+ .toLowerCase()
75+ .replace(/[^a-z0-9]+/g, '-')
76+ .replace(/^-+|-+$/g, '')
77+ .slice(0, 48) || 'pack'
78+ );
79+}
80+
81+export interface CloneDestination {
82+ label: string;
83+ target: PackTarget;
84+ display_name: string | null;
85+}
86+
87+/** Where a whole pack can be cloned to: merged into your pack, or as a new pack in a room you can edit. */
88+export function clonePackDestinations(
89+ pack: ImagePack,
90+ rooms: { room_id: string; name: string }[],
91+ roomName: (roomId: string) => string
92+): CloneDestination[] {
93+ const name = packLabel(pack, roomName);
94+ return [
95+ { label: 'Merge into your pack', target: { kind: 'user' }, display_name: null },
96+ ...rooms.map((room) => ({
97+ label: `New pack in ${room.name}`,
98+ target: { kind: 'room', room_id: room.room_id, state_key: packSlug(name) } as PackTarget,
99+ display_name: name
100+ }))
101+ ];
102+}
Modified file: web/src/lib/transport-http.ts+10 additions
@@ -238,6 +238,16 @@ export class HttpTransport implements Transport {
238238 return json(res, 'Avatar upload failed');
239239 }
240240
241+ async uploadMedia(file: File): Promise<{ mxc: string }> {
242+ const res = await fetch('/api/media/upload', {
243+ method: 'POST',
244+ credentials: 'same-origin',
245+ headers: { 'content-type': file.type || 'application/octet-stream' },
246+ body: file
247+ });
248+ return json(res, 'Upload failed');
249+ }
250+
241251 async uploadRoomAvatar(room_id: string, file: File): Promise<void> {
242252 const res = await fetch(`/api/rooms/avatar?room_id=${encodeURIComponent(room_id)}`, {
243253 method: 'POST',
Modified file: web/src/lib/transport-worker.ts+4 additions
@@ -289,6 +289,10 @@ export class WorkerTransport implements Transport {
289289 await this.client.call('upload_avatar', {}, await file.arrayBuffer());
290290 }
291291
292+ async uploadMedia(file: File): Promise<{ mxc: string }> {
293+ return this.client.call('upload_media', {}, await file.arrayBuffer());
294+ }
295+
292296 async uploadRoomAvatar(room_id: string, file: File): Promise<void> {
293297 await this.client.call('upload_room_avatar', { room_id }, await file.arrayBuffer());
294298 }
Modified file: web/src/lib/transport.ts+30 additions−2 deletions
@@ -1,4 +1,4 @@
1-import type { MemberSummary, RoomSummary } from './types';
1+import type { MemberSummary, PackUsage, RoomSummary } from './types';
22
33 export class ApiError extends Error {
44 status: number;
@@ -35,7 +35,34 @@ export type CommandRequest =
3535 | { op: 'set_room_avatar'; room_id: string; mxc: string | null }
3636 | { op: 'mute'; room_id: string; user_id: string; muted: boolean }
3737 | { op: 'ignore' | 'unignore'; user_id: string }
38- | { op: 'power_levels'; room_id: string };
38+ | { op: 'power_levels'; room_id: string }
39+ | { op: 'send_sticker'; room_id: string; pack_id: string; shortcode: string }
40+ | { op: 'create_room_pack'; room_id: string; state_key: string; display_name: string }
41+ | { op: 'delete_pack'; target: PackTarget }
42+ | {
43+ op: 'set_pack_meta';
44+ target: PackTarget;
45+ display_name?: string;
46+ avatar?: string | null;
47+ usage?: PackUsage[];
48+ }
49+ | {
50+ op: 'put_pack_image';
51+ target: PackTarget;
52+ shortcode: string;
53+ mxc: string;
54+ body: string | null;
55+ usage: PackUsage[] | null;
56+ info?: { mimetype: string | null; size_bytes: number | null; width: number | null; height: number | null };
57+ }
58+ | { op: 'rename_pack_image'; target: PackTarget; from: string; to: string }
59+ | { op: 'remove_pack_image'; target: PackTarget; shortcode: string }
60+ | { op: 'set_pack_enabled'; room_id: string; state_key: string; enabled: boolean }
61+ | { op: 'clone_pack_image'; from_pack: string; shortcode: string; to: PackTarget }
62+ | { op: 'clone_sticker'; room_id: string; event_id: string; to: PackTarget; shortcode: string }
63+ | { op: 'clone_pack'; from_pack: string; to: PackTarget; display_name: string | null };
64+
65+export type PackTarget = { kind: 'user' } | { kind: 'room'; room_id: string; state_key: string };
3966
4067 export type SendKind = 'text' | 'emote' | 'notice';
4168
@@ -91,6 +118,7 @@ export interface Transport {
91118 command(request: CommandRequest): Promise<unknown>;
92119 uploadAvatar(file: File): Promise<void>;
93120 uploadRoomAvatar(room_id: string, file: File): Promise<void>;
121+ uploadMedia(file: File): Promise<{ mxc: string }>;
94122 /**
95123 * Wire events as raw JSON text, snapshot first. Returns an unsubscribe
96124 * function. onDisconnect fires whenever the underlying connection drops;
Modified file: web/src/lib/types.ts+9 additions−3 deletions
@@ -41,6 +41,7 @@ export interface RoomSummary {
4141 parent_spaces: string[];
4242 children: string[];
4343 space_order: string | null;
44+ can_edit_packs: boolean;
4445 }
4546
4647 export interface MemberSummary {
@@ -364,7 +365,8 @@ export function parseRoomSummary(raw: unknown): RoomSummary | null {
364365 inviter: optionalString(raw, 'inviter'),
365366 parent_spaces: stringList(raw['parent_spaces']),
366367 children: stringList(raw['children']),
367- space_order: optionalString(raw, 'space_order')
368+ space_order: optionalString(raw, 'space_order'),
369+ can_edit_packs: raw['can_edit_packs'] === true
368370 };
369371 }
370372
@@ -693,6 +695,8 @@ export interface ImagePack {
693695 attribution: string | null;
694696 source: PackSource;
695697 images: PackImage[];
698+ editable: boolean;
699+ enabled_everywhere: boolean;
696700 }
697701 export interface ImagePacks {
698702 global: ImagePack[];
@@ -742,7 +746,9 @@ function parseImagePack(raw: unknown): ImagePack | null {
742746 avatar: optionalString(raw, 'avatar'),
743747 attribution: optionalString(raw, 'attribution'),
744748 source: parsePackSource(raw['source']),
745- images
749+ images,
750+ editable: raw['editable'] === true,
751+ enabled_everywhere: raw['enabled_everywhere'] === true
746752 };
747753 }
748754
@@ -771,7 +777,7 @@ export function packsForRoom(packs: ImagePacks, roomId: string | null): ImagePac
771777 const seen = new Set<string>();
772778 const out: ImagePack[] = [];
773779 const add = (pack: ImagePack) => {
774- if (seen.has(pack.id)) return;
780+ if (seen.has(pack.id) || pack.images.length === 0) return;
775781 seen.add(pack.id);
776782 out.push(pack);
777783 };
Added file: web/test/packs.test.ts+45 additions
@@ -0,0 +1,45 @@
1+import assert from 'node:assert/strict';
2+import { test } from 'node:test';
3+import { clonePackDestinations, editableChoices, packSlug, shortcodeFor, stickersOf } from '../src/lib/packs.ts';
4+
5+const image = (shortcode: string, usage: ('Emoticon' | 'Sticker')[]) => ({
6+ shortcode,
7+ media_id: `mxc://x/${shortcode}`,
8+ reaction_key: `mxc://x/${shortcode}`,
9+ body: null,
10+ mimetype: null,
11+ width: null,
12+ height: null,
13+ usage
14+});
15+
16+const roomPack = (state_key: string, editable: boolean) => ({
17+ id: `room:!r:x:${state_key}`,
18+ display_name: state_key,
19+ avatar: null,
20+ attribution: null,
21+ source: { Room: { room_id: '!r:x', state_key } },
22+ images: [image('a', ['Sticker']), image('b', ['Emoticon']), image('c', [])],
23+ editable,
24+ enabled_everywhere: false
25+});
26+
27+test('shortcodes come from file names and never clash', () => {
28+ const taken = new Set(['cat']);
29+ assert.equal(shortcodeFor('Cat.PNG', taken), 'cat_2');
30+ assert.equal(shortcodeFor('my cool sticker!.webp', taken), 'my_cool_sticker');
31+ assert.equal(shortcodeFor('.png', taken), 'image');
32+ assert.equal(packSlug('Cats & Dogs'), 'cats-dogs');
33+});
34+
35+test('stickers include images with no usage of their own', () => {
36+ assert.deepEqual(stickersOf(roomPack('s', true)).map((i) => i.shortcode), ['a', 'c']);
37+});
38+
39+test('your pack is always a destination, then editable room packs only', () => {
40+ const packs = { global: [], rooms: { '!r:x': [roomPack('mine', true), roomPack('theirs', false)] } };
41+ const choices = editableChoices(packs, () => 'Room');
42+ assert.deepEqual(choices.map((c) => c.target), [{ kind: 'user' }, { kind: 'room', room_id: '!r:x', state_key: 'mine' }]);
43+ const dests = clonePackDestinations(roomPack('Fancy Pack', false), [{ room_id: '!d:x', name: 'Dest' }], () => 'Room');
44+ assert.deepEqual(dests[1].target, { kind: 'room', room_id: '!d:x', state_key: 'fancy-pack' });
45+});