Commit d095131eec0e
tarball18 files changed+1214-18
@@ -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
+where337
+ 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
+ handle851
+ .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
+ handle865
+ .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
+ handle895
+ .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
+ handle910
+ .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
+ handle923
+ .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
+ handle933
+ .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
+ handle943
+ .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 = handle957
+ .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 = handle972
+ .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 = handle987
+ .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.@@ -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))@@ -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 request175
+ | Request::UploadRoomAvatar { bytes: slot, .. }176
+ | Request::UploadMedia { bytes: slot } = &mut request176177
{177178
*slot = bytes.map(|array| array.to_vec()).unwrap_or_default();178179
}@@ -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
+ <button2127
+ 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
+ <StickerPicker2144
+ 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
<textarea20612155
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">@@ -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;@@ -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',@@ -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
+ <button285
+ 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
<button282295
type="button"@@ -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
+ validShortcode16
+ } 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: next146
+ }),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
+ <input261
+ 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 everywhere275
+ </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>@@ -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
];@@ -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>@@ -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 = withStickers24
+ .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
+ <button60
+ 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
+ <button88
+ 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>@@ -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);@@ -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
+ stem57
+ .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
+ name74
+ .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) => string92
+): 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: name100
+ }))101
+ ];102
+}@@ -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: file247
+ });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',@@ -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
}@@ -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 unsubscribe96124
* function. onDisconnect fires whenever the underlying connection drops;@@ -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'] === true368370
};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
- images749
+ images,750
+ editable: raw['editable'] === true,751
+ enabled_everywhere: raw['enabled_everywhere'] === true746752
};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
};@@ -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
+ usage14
+});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: false25
+});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
+});