atagen

malice

public
Star0
Watch0

Commit 132eeceeb362

tarball

atagen <boss@atagen.co>2026-10-25 06:17 UTC

28 files changed+1135-146
packs: support room cloning and personal image controls
Modified file: Cargo.lock+11 additions−11 deletions
@@ -1935,7 +1935,7 @@ checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
19351935 [[package]]
19361936 name = "macltok"
19371937 version = "0.1.0"
1938-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/macltok?rev=80381e06a573f253c11a8b4c89366a9aa299ca55#80381e06a573f253c11a8b4c89366a9aa299ca55"
1938+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/macltok?rev=2656c1d26d6b12a7d19e2c8902c8c88ffe20f982#2656c1d26d6b12a7d19e2c8902c8c88ffe20f982"
19391939 dependencies = [
19401940 "futures-util",
19411941 "getrandom 0.2.17",
@@ -2166,7 +2166,7 @@ dependencies = [
21662166 [[package]]
21672167 name = "matrix-sdk"
21682168 version = "0.18.0"
2169-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=9b9b16982dcab5dd499547bb87e121b9d0cba6b5#9b9b16982dcab5dd499547bb87e121b9d0cba6b5"
2169+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=c0a29939d88bc8a55ae4f6bb05443913fb8dea85#c0a29939d88bc8a55ae4f6bb05443913fb8dea85"
21702170 dependencies = [
21712171 "anymap2",
21722172 "aquamarine",
@@ -2229,7 +2229,7 @@ dependencies = [
22292229 [[package]]
22302230 name = "matrix-sdk-base"
22312231 version = "0.18.0"
2232-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=9b9b16982dcab5dd499547bb87e121b9d0cba6b5#9b9b16982dcab5dd499547bb87e121b9d0cba6b5"
2232+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=c0a29939d88bc8a55ae4f6bb05443913fb8dea85#c0a29939d88bc8a55ae4f6bb05443913fb8dea85"
22332233 dependencies = [
22342234 "as_variant",
22352235 "assert_matches",
@@ -2259,7 +2259,7 @@ dependencies = [
22592259 [[package]]
22602260 name = "matrix-sdk-common"
22612261 version = "0.18.0"
2262-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=9b9b16982dcab5dd499547bb87e121b9d0cba6b5#9b9b16982dcab5dd499547bb87e121b9d0cba6b5"
2262+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=c0a29939d88bc8a55ae4f6bb05443913fb8dea85#c0a29939d88bc8a55ae4f6bb05443913fb8dea85"
22632263 dependencies = [
22642264 "eyeball-im",
22652265 "futures-core",
@@ -2282,7 +2282,7 @@ dependencies = [
22822282 [[package]]
22832283 name = "matrix-sdk-crypto"
22842284 version = "0.18.0"
2285-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=9b9b16982dcab5dd499547bb87e121b9d0cba6b5#9b9b16982dcab5dd499547bb87e121b9d0cba6b5"
2285+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=c0a29939d88bc8a55ae4f6bb05443913fb8dea85#c0a29939d88bc8a55ae4f6bb05443913fb8dea85"
22862286 dependencies = [
22872287 "aes",
22882288 "aquamarine",
@@ -2323,7 +2323,7 @@ dependencies = [
23232323 [[package]]
23242324 name = "matrix-sdk-indexeddb"
23252325 version = "0.18.0"
2326-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=9b9b16982dcab5dd499547bb87e121b9d0cba6b5#9b9b16982dcab5dd499547bb87e121b9d0cba6b5"
2326+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=c0a29939d88bc8a55ae4f6bb05443913fb8dea85#c0a29939d88bc8a55ae4f6bb05443913fb8dea85"
23272327 dependencies = [
23282328 "async-trait",
23292329 "base64 0.22.1",
@@ -2354,7 +2354,7 @@ dependencies = [
23542354 [[package]]
23552355 name = "matrix-sdk-sqlite"
23562356 version = "0.18.0"
2357-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=9b9b16982dcab5dd499547bb87e121b9d0cba6b5#9b9b16982dcab5dd499547bb87e121b9d0cba6b5"
2357+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=c0a29939d88bc8a55ae4f6bb05443913fb8dea85#c0a29939d88bc8a55ae4f6bb05443913fb8dea85"
23582358 dependencies = [
23592359 "as_variant",
23602360 "async-trait",
@@ -2381,7 +2381,7 @@ dependencies = [
23812381 [[package]]
23822382 name = "matrix-sdk-store-encryption"
23832383 version = "0.18.0"
2384-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=9b9b16982dcab5dd499547bb87e121b9d0cba6b5#9b9b16982dcab5dd499547bb87e121b9d0cba6b5"
2384+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=c0a29939d88bc8a55ae4f6bb05443913fb8dea85#c0a29939d88bc8a55ae4f6bb05443913fb8dea85"
23852385 dependencies = [
23862386 "base64 0.22.1",
23872387 "blake3",
@@ -2402,7 +2402,7 @@ dependencies = [
24022402 [[package]]
24032403 name = "matrix-sdk-test"
24042404 version = "0.18.0"
2405-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=9b9b16982dcab5dd499547bb87e121b9d0cba6b5#9b9b16982dcab5dd499547bb87e121b9d0cba6b5"
2405+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=c0a29939d88bc8a55ae4f6bb05443913fb8dea85#c0a29939d88bc8a55ae4f6bb05443913fb8dea85"
24062406 dependencies = [
24072407 "as_variant",
24082408 "ctor",
@@ -2425,7 +2425,7 @@ dependencies = [
24252425 [[package]]
24262426 name = "matrix-sdk-test-macros"
24272427 version = "0.18.0"
2428-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=9b9b16982dcab5dd499547bb87e121b9d0cba6b5#9b9b16982dcab5dd499547bb87e121b9d0cba6b5"
2428+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=c0a29939d88bc8a55ae4f6bb05443913fb8dea85#c0a29939d88bc8a55ae4f6bb05443913fb8dea85"
24292429 dependencies = [
24302430 "quote",
24312431 "syn 2.0.119",
@@ -2434,7 +2434,7 @@ dependencies = [
24342434 [[package]]
24352435 name = "matrix-sdk-ui"
24362436 version = "0.18.0"
2437-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=9b9b16982dcab5dd499547bb87e121b9d0cba6b5#9b9b16982dcab5dd499547bb87e121b9d0cba6b5"
2437+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=c0a29939d88bc8a55ae4f6bb05443913fb8dea85#c0a29939d88bc8a55ae4f6bb05443913fb8dea85"
24382438 dependencies = [
24392439 "as_variant",
24402440 "async-rx",
Modified file: Cargo.toml+1 additions−1 deletions
@@ -3,7 +3,7 @@ resolver = "3"
33 members = ["crates/malice-core", "crates/malice-server", "crates/malice-static"]
44
55 [workspace.dependencies]
6-macltok = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/macltok", rev = "80381e06a573f253c11a8b4c89366a9aa299ca55" }
6+macltok = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/macltok", rev = "2656c1d26d6b12a7d19e2c8902c8c88ffe20f982" }
77 ruma = { version = "0.16", default-features = false }
88 serde = { version = "1", features = ["derive"] }
99 serde_json = "1"
Added file: MACLTOK_TIMELINE_REPAIR_HANDOFF.md+69 additions
@@ -0,0 +1,69 @@
1+# Macltok timeline repair handoff
2+
3+## Publication status
4+
5+The SDK API is published on the user fork's `macltok` bookmark at `9b9b16982dcab5dd499547bb87e121b9d0cba6b5`. It includes durable reseeding, room clear cleanup, and support for replacement snapshots in existing UI timelines. `RoomEventCache::reseed(events, prev_batch)` takes events oldest first. Reverse a backward `/messages` chunk and pass its `end` token. A missing token means the beginning of accessible history.
6+
7+Macltok is published on the user fork's `vapour` bookmark at `80381e06a573f253c11a8b4c89366a9aa299ca55`, with the SDK revision above pinned in its manifest and lockfile. It is based on current `vapour`, whose head had not advanced during the final fetch and rebase. Update both `Cargo.toml` and `crates/malice-worker/Cargo.toml`, then regenerate their applicable lockfiles. Keep both transports on the same published revision.
8+
9+The repair action restores access to recent history while the cause of the missing interval remains under investigation. It does not fix the underlying gap loss.
10+
11+## Command and transport API
12+
13+The shared command is `Command::HistoryRepair(HistoryRepairCommand)`. Its variants are `Start { room_id, policy, budget }`, `Continue { room_id, budget }`, `Cancel { room_id }`, `StartRecent { room_id }`, `GetPolicy`, and `SetPolicy(policy)`. `Start` accepts an optional one off policy override. Omitting it uses the saved policy.
14+
15+The policy variants are `StartRecent`, `Bridge`, and `BridgeShortGaps { threshold_ms }`. A threshold is an integer number of milliseconds. Zero disables automatic bridging. The default policy is `StartRecent`.
16+
17+Serde uses externally tagged enums. A `HistoryRepairCommand` start value has this shape. The outer `Command` adds its `HistoryRepair` tag.
18+
19+```json
20+{
21+ "Start": {
22+ "room_id": "!room:example.org",
23+ "policy": {
24+ "BridgeShortGaps": {
25+ "threshold_ms": 15000
26+ }
27+ },
28+ "budget": {
29+ "max_pages": 10,
30+ "max_events": 1000,
31+ "max_duration_ms": 15000
32+ }
33+ }
34+}
35+```
36+
37+The corresponding `ClientHandle` methods are `history_repair`, `continue_history_repair`, `cancel_history_repair`, `start_recent_history`, `history_repair_policy`, and `set_history_repair_policy`. The report separates requested policy, selected strategy, coverage duration, threshold evaluation, bridge progress, recent availability, continuity, stop reason, and failure.
38+
39+Repair requires an open room. A successful command response can still contain a paused report with `failure` or `stop` set. Inspect `recent_available` before announcing restored history. `Verified` means a server page chain reached the retained event anchor. `AccessibleStart` means the server reached the beginning of history visible to this account. `Unresolved` must retain the gap explanation even when recent history is available. Permission and rate limit details remain in `failure.server`, while transport rejection remains a command error. Undecryptable events use the existing timeline item representation and key recovery flow.
40+
41+## Progress publication
42+
43+Consumers can restore the latest report from `State.repairs`, keyed by room ID. `Event::HistoryRepairChanged` carries progress. The final `Event::HistoryRepairPublished` is a hidden internal acknowledgement barrier. The mirror folds it into `State.repairs` and emits the same visible history repair update. Its acknowledgement field is skipped by serde, so transports should not expose it as application data.
44+
45+## Runtime behavior and limits
46+
47+The initial recent fetch is independent of the existing cache pagination cursor and is capped at 50 events. The SDK reseed takes events oldest first with a cursor for normal pagination into earlier history. Each repair budget accepts 1 to 100 pages, 50 to 10000 events, and 1 to 60000 milliseconds. Defaults are 10 pages, 1000 events, and 15000 milliseconds.
48+
49+The account `SyncService` pauses for the bounded initial recent fetch and cache install, then resumes while bridge pages are fetched. It pauses again for a bounded final head catchup. Repair closes cached thread timelines and pinned timelines, which must be reopened afterward.
50+
51+Continuation staging lives in actor memory. It clears when the room closes or the worker restarts. The installed recent history remains durable, and a new repair can be started afterward.
52+
53+Only one repair runs per room. Cancel signals the active operation, whose original command then returns a paused report. Await that result before requesting Continue or StartRecent. StartRecent abandons staged bridging and keeps the installed recent window. Fetch budgets restart for each continuation, while staged history is capped at 10000 events. Replacement also fences earlier pagination results. A final head that cannot overlap the staged range leaves it separate and reports unavailable history rather than joining disconnected events.
54+
55+## Coverage threshold limitation
56+
57+The sync position and `/messages` pagination cursor do not provide a trustworthy event time for the gap boundary. Receipt timestamps are also unreliable. The implementation therefore leaves `coverage_duration_ms` as `None` and `threshold_evaluated` as `false`. `BridgeShortGaps` starts at recent messages until upstream timing support can measure coverage reliably. A user can still explicitly choose `Bridge` for a repair.
58+
59+## Remaining Malice work
60+
61+Finish the room menu action and saved repair settings. The settings need policy lookup and update, the millisecond threshold, and a one off policy override for the room action. The room action needs busy and cancel states, plus continue bridging and start recent actions when a bridge pauses or cannot finish. Extend the shared frontend transport contract, the HTTP command adapter, and the worker command adapter together. Fold repair reports into the frontend room state and preserve them in snapshots.
62+
63+After a normal timeline reset, restore the visible scroll anchor. Preserve the composer draft when jumping to recent history and keep the composer usable under the existing room readiness rules. Keep recent history restoration distinct from continuity repair in the result shown to the user.
64+
65+## Verification
66+
67+Seven focused native integration tests passed for fresh cursor selection, failed fetch preservation, valid older pagination, bridge budget continuation, cancellation, all policy overlap paths, unknown coverage fallback, and mirror publication ordering. Seven focused unit tests passed for threshold boundaries, saved policy and coverage, old inbox compatibility, serde, and publication acknowledgement. The SDK live timeline regression passed with a pending local echo preserved through reseeding. The unit tests and locked IndexedDB WebAssembly check passed against the published SDK pin, with no local dependency patches.
68+
69+Browser runtime, encrypted room recovery, and complete worker restart end to end verification remain for Malice integration. A page refresh is not a full worker restart. Check that the encryption identity, pending sends, and composer draft survive. Exercise the reported offline gap using persistent stores and distinguish recent access from proven continuity. Investigating discarded gap markers and adding startup, subscription, and sync diagnostics remains prevention work. Full gates were intentionally not run.
Modified file: crates/malice-core/src/request.rs+25 additions−3 deletions
@@ -364,18 +364,21 @@ pub enum Request {
364364 from_pack: String,
365365 shortcode: String,
366366 to: PackTarget,
367+ usage: Option<Vec<PackUsage>>,
367368 },
368369 CloneSticker {
369370 room_id: String,
370371 event_id: String,
371372 to: PackTarget,
372373 shortcode: String,
374+ usage: Option<Vec<PackUsage>>,
373375 },
374376 ClonePack {
375377 from_pack: String,
376378 to: PackTarget,
377379 #[serde(default)]
378380 display_name: Option<String>,
381+ usage: Option<Vec<PackUsage>>,
379382 },
380383 Unignore {
381384 user_id: String,
@@ -431,6 +434,15 @@ fn pack_text(value: String, what: &str) -> Result<String, CoreError> {
431434 Ok(value)
432435 }
433436
437+fn clone_usage(usage: Option<Vec<PackUsage>>) -> Result<Option<Vec<PackUsage>>, CoreError> {
438+ if usage.as_ref().is_some_and(Vec::is_empty) {
439+ return Err(CoreError::bad_request(
440+ "choose at least one use for copied images",
441+ ));
442+ }
443+ Ok(usage)
444+}
445+
434446 #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize)]
435447 #[serde(rename_all = "snake_case")]
436448 pub enum SendKind {
@@ -1135,12 +1147,14 @@ pub async fn dispatch(
11351147 from_pack,
11361148 shortcode,
11371149 to,
1150+ usage,
11381151 } => {
11391152 let shortcode = handle
1140- .clone_pack_image(
1153+ .clone_pack_image_with_usage(
11411154 pack_text(from_pack, "pack_id")?,
11421155 pack_text(shortcode, "shortcode")?,
11431156 to.source()?,
1157+ clone_usage(usage)?,
11441158 )
11451159 .await?;
11461160 Ok(Reply::Json(json!({ "shortcode": shortcode })))
@@ -1150,13 +1164,15 @@ pub async fn dispatch(
11501164 event_id,
11511165 to,
11521166 shortcode,
1167+ usage,
11531168 } => {
11541169 let shortcode = handle
1155- .clone_sticker(
1170+ .clone_sticker_with_usage(
11561171 parse_room_id(&room_id)?,
11571172 parse_event_id(&event_id)?,
11581173 to.source()?,
11591174 pack_text(shortcode, "shortcode")?,
1175+ clone_usage(usage)?,
11601176 )
11611177 .await?;
11621178 Ok(Reply::Json(json!({ "shortcode": shortcode })))
@@ -1165,9 +1181,15 @@ pub async fn dispatch(
11651181 from_pack,
11661182 to,
11671183 display_name,
1184+ usage,
11681185 } => {
11691186 let skipped = handle
1170- .clone_pack(pack_text(from_pack, "pack_id")?, to.source()?, display_name)
1187+ .clone_pack_with_usage(
1188+ pack_text(from_pack, "pack_id")?,
1189+ to.source()?,
1190+ display_name,
1191+ clone_usage(usage)?,
1192+ )
11711193 .await?;
11721194 Ok(Reply::Json(json!({ "skipped": skipped })))
11731195 }
Modified file: crates/malice-worker/Cargo.lock+9 additions−9 deletions
@@ -1722,7 +1722,7 @@ checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
17221722 [[package]]
17231723 name = "macltok"
17241724 version = "0.1.0"
1725-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/macltok?rev=80381e06a573f253c11a8b4c89366a9aa299ca55#80381e06a573f253c11a8b4c89366a9aa299ca55"
1725+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/macltok?rev=2656c1d26d6b12a7d19e2c8902c8c88ffe20f982#2656c1d26d6b12a7d19e2c8902c8c88ffe20f982"
17261726 dependencies = [
17271727 "futures-util",
17281728 "getrandom 0.2.17",
@@ -1929,7 +1929,7 @@ dependencies = [
19291929 [[package]]
19301930 name = "matrix-sdk"
19311931 version = "0.18.0"
1932-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=9b9b16982dcab5dd499547bb87e121b9d0cba6b5#9b9b16982dcab5dd499547bb87e121b9d0cba6b5"
1932+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=c0a29939d88bc8a55ae4f6bb05443913fb8dea85#c0a29939d88bc8a55ae4f6bb05443913fb8dea85"
19331933 dependencies = [
19341934 "anymap2",
19351935 "aquamarine",
@@ -1989,7 +1989,7 @@ dependencies = [
19891989 [[package]]
19901990 name = "matrix-sdk-base"
19911991 version = "0.18.0"
1992-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=9b9b16982dcab5dd499547bb87e121b9d0cba6b5#9b9b16982dcab5dd499547bb87e121b9d0cba6b5"
1992+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=c0a29939d88bc8a55ae4f6bb05443913fb8dea85#c0a29939d88bc8a55ae4f6bb05443913fb8dea85"
19931993 dependencies = [
19941994 "as_variant",
19951995 "async-trait",
@@ -2015,7 +2015,7 @@ dependencies = [
20152015 [[package]]
20162016 name = "matrix-sdk-common"
20172017 version = "0.18.0"
2018-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=9b9b16982dcab5dd499547bb87e121b9d0cba6b5#9b9b16982dcab5dd499547bb87e121b9d0cba6b5"
2018+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=c0a29939d88bc8a55ae4f6bb05443913fb8dea85#c0a29939d88bc8a55ae4f6bb05443913fb8dea85"
20192019 dependencies = [
20202020 "eyeball-im",
20212021 "futures-core",
@@ -2038,7 +2038,7 @@ dependencies = [
20382038 [[package]]
20392039 name = "matrix-sdk-crypto"
20402040 version = "0.18.0"
2041-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=9b9b16982dcab5dd499547bb87e121b9d0cba6b5#9b9b16982dcab5dd499547bb87e121b9d0cba6b5"
2041+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=c0a29939d88bc8a55ae4f6bb05443913fb8dea85#c0a29939d88bc8a55ae4f6bb05443913fb8dea85"
20422042 dependencies = [
20432043 "aes",
20442044 "aquamarine",
@@ -2078,7 +2078,7 @@ dependencies = [
20782078 [[package]]
20792079 name = "matrix-sdk-indexeddb"
20802080 version = "0.18.0"
2081-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=9b9b16982dcab5dd499547bb87e121b9d0cba6b5#9b9b16982dcab5dd499547bb87e121b9d0cba6b5"
2081+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=c0a29939d88bc8a55ae4f6bb05443913fb8dea85#c0a29939d88bc8a55ae4f6bb05443913fb8dea85"
20822082 dependencies = [
20832083 "async-trait",
20842084 "base64 0.22.1",
@@ -2110,7 +2110,7 @@ dependencies = [
21102110 [[package]]
21112111 name = "matrix-sdk-sqlite"
21122112 version = "0.18.0"
2113-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=9b9b16982dcab5dd499547bb87e121b9d0cba6b5#9b9b16982dcab5dd499547bb87e121b9d0cba6b5"
2113+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=c0a29939d88bc8a55ae4f6bb05443913fb8dea85#c0a29939d88bc8a55ae4f6bb05443913fb8dea85"
21142114 dependencies = [
21152115 "as_variant",
21162116 "async-trait",
@@ -2137,7 +2137,7 @@ dependencies = [
21372137 [[package]]
21382138 name = "matrix-sdk-store-encryption"
21392139 version = "0.18.0"
2140-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=9b9b16982dcab5dd499547bb87e121b9d0cba6b5#9b9b16982dcab5dd499547bb87e121b9d0cba6b5"
2140+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=c0a29939d88bc8a55ae4f6bb05443913fb8dea85#c0a29939d88bc8a55ae4f6bb05443913fb8dea85"
21412141 dependencies = [
21422142 "base64 0.22.1",
21432143 "blake3",
@@ -2158,7 +2158,7 @@ dependencies = [
21582158 [[package]]
21592159 name = "matrix-sdk-ui"
21602160 version = "0.18.0"
2161-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=9b9b16982dcab5dd499547bb87e121b9d0cba6b5#9b9b16982dcab5dd499547bb87e121b9d0cba6b5"
2161+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=c0a29939d88bc8a55ae4f6bb05443913fb8dea85#c0a29939d88bc8a55ae4f6bb05443913fb8dea85"
21622162 dependencies = [
21632163 "as_variant",
21642164 "async-rx",
Modified file: crates/malice-worker/Cargo.toml+2 additions−2 deletions
@@ -14,7 +14,7 @@ publish = false
1414 crate-type = ["cdylib", "rlib"]
1515
1616 [target.'cfg(target_family = "wasm")'.dependencies]
17-macltok = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/macltok", rev = "80381e06a573f253c11a8b4c89366a9aa299ca55", features = ["indexeddb", "serde", "mirror"] }
17+macltok = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/macltok", rev = "2656c1d26d6b12a7d19e2c8902c8c88ffe20f982", features = ["indexeddb", "serde", "mirror"] }
1818 malice-core = { path = "../malice-core" }
1919 ruma = { version = "0.16", default-features = false }
2020 futures-util = { version = "0.3", default-features = false, features = ["std"] }
@@ -27,7 +27,7 @@ wasm-bindgen-futures = "=0.4.78"
2727 js-sys = "=0.3.105"
2828
2929 [dev-dependencies]
30-matrix-sdk = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "9b9b16982dcab5dd499547bb87e121b9d0cba6b5", default-features = false }
30+matrix-sdk = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "c0a29939d88bc8a55ae4f6bb05443913fb8dea85", default-features = false }
3131 wasm-bindgen-test = "0.3"
3232 web-sys = { version = "0.3", features = ["SharedWorkerGlobalScope"] }
3333 gloo-timers = { version = "0.3", features = ["futures"] }
Modified file: web/src/App.svelte+34 additions−30 deletions
@@ -11,7 +11,11 @@
1111 import ContextMenu, { type MenuItem } from './lib/ContextMenu.svelte';
1212 import StickerPicker from './lib/StickerPicker.svelte';
1313 import PackManager from './lib/PackManager.svelte';
14- import { clonePackDestinations, editableChoices, shortcodeFor } from './lib/packs';
14+ import PackCloneDialog from './lib/PackCloneDialog.svelte';
15+ import type { CloneSource } from './lib/pack-clone';
16+ import { allPacks, findSticker, reactionShortcode } from './lib/packs';
17+ import { disabledPackImages, loadPackPreferences } from './lib/pack-preferences';
18+ import { enabledPacks, enabledQuickReactions } from './lib/pack-preferences-model';
1519 import type { CommandRequest } from './lib/transport';
1620 import UserCard, { type UserAction } from './lib/UserCard.svelte';
1721 import { allowed, canRedact, groupMembers, isMuted, parsePowerLevels, type PowerLevels } from './lib/permissions';
@@ -120,6 +124,7 @@
120124 let replyingTo: ReplyReference | null = null;
121125 let dialog: 'join' | 'create' | 'security' | 'settings' | 'packs' | null = null;
122126 let stickersOpen = false;
127+ let cloneSource: CloneSource | null = null;
123128 let joinTarget = '';
124129 let joinBusy = false;
125130 let createName = '';
@@ -293,13 +298,15 @@
293298 const online = $connection === 'online';
294299 if (online && !wasOnline) {
295300 void loadQuickReactions();
301+ void loadPackPreferences();
296302 void loadUrlPreviewSettings();
297303 }
298304 wasOnline = online;
299305 }
300306 $: selectedRoom = $rooms.find((r) => r.room_id === $selectedRoomId) ?? null;
301- $: roomPacks = packsForRoom($imagePacks, $selectedRoomId);
307+ $: roomPacks = enabledPacks(packsForRoom($imagePacks, $selectedRoomId, $rooms), $disabledPackImages);
302308 $: roomEmotes = customEmotes(roomPacks);
309+ $: usableQuickReactions = enabledQuickReactions($quickReactions, allPacks($imagePacks), $disabledPackImages);
303310 $: previewsAllowed = selectedRoom !== null && (!selectedRoom.encrypted || $urlPreviewSettings.encrypted_rooms);
304311 $: loadPreview = previewsAllowed && $selectedRoomId ? previewLoader($selectedRoomId) : null;
305312 $: spaceRooms = $rooms.filter((r) => r.space);
@@ -485,6 +492,7 @@
485492 void loadProfile();
486493 void loadEncryption();
487494 void loadQuickReactions();
495+ void loadPackPreferences();
488496 void loadUrlPreviewSettings();
489497 } else {
490498 softLogout.set(session.soft_logout ?? null);
@@ -541,6 +549,7 @@
541549 void loadProfile();
542550 void loadEncryption();
543551 void loadQuickReactions();
552+ void loadPackPreferences();
544553 void loadUrlPreviewSettings();
545554 }
546555
@@ -561,6 +570,7 @@
561570 composer = '';
562571 drafts = {};
563572 dialog = null;
573+ cloneSource = null;
564574 membersOpen = false;
565575 sidebarOpen = false;
566576 profile = null;
@@ -946,6 +956,11 @@
946956 await sendMessage(roomId, said, null, mentions, name === 'me' ? 'emote' : 'notice', resolveEmotes(said, roomEmotes));
947957 break;
948958 }
959+ case 'sticker': {
960+ const { pack, sticker } = findSticker(enabledPacks(packsForRoom($imagePacks, roomId, $rooms), $disabledPackImages), required('a sticker name'));
961+ await runCommand({ op: 'send_sticker', room_id: roomId, pack_id: pack.id, shortcode: sticker.shortcode });
962+ break;
963+ }
949964 case 'msg': {
950965 const { user_id, rest } = target();
951966 const res = (await runCommand({ op: 'direct', user_id })) as { room_id: string };
@@ -1427,7 +1442,7 @@
14271442 if (!roomId || !eventId || reacting) return;
14281443 reacting = true;
14291444 try {
1430- await reactToMessage(roomId, eventId, key, shortcode);
1445+ await reactToMessage(roomId, eventId, key, reactionShortcode(key, shortcode, allPacks($imagePacks)));
14311446 } catch (e) {
14321447 pushBanner(e instanceof Error ? e.message : 'Reaction failed');
14331448 } finally {
@@ -1741,39 +1756,22 @@
17411756 }
17421757
17431758 function openCloneImageMenu(pack: ImagePack, image: PackImage, x: number, y: number): void {
1744- const items = editableChoices($imagePacks, roomName)
1745- .filter((c) => c.pack?.id !== pack.id)
1746- .map((choice) => ({
1747- label: `Clone to ${choice.label}`,
1748- action: () =>
1749- void runClone({ op: 'clone_pack_image', from_pack: pack.id, shortcode: image.shortcode, to: choice.target }, 'Could not clone the sticker')
1750- }));
1751- menu = { items, x, y };
1759+ stickersOpen = false;
1760+ menu = null;
1761+ cloneSource = { kind: 'image', pack, image };
17521762 }
17531763
17541764 function openClonePackMenu(pack: ImagePack, x: number, y: number): void {
1755- const packRooms = $rooms.filter((r) => r.can_edit_packs && r.membership === 'Joined');
1756- const items = clonePackDestinations(pack, packRooms, roomName).map((d) => ({
1757- label: d.label,
1758- action: () => void runClone({ op: 'clone_pack', from_pack: pack.id, to: d.target, display_name: d.display_name }, 'Could not clone the pack')
1759- }));
1760- menu = { items, x, y };
1765+ stickersOpen = false;
1766+ menu = null;
1767+ cloneSource = { kind: 'pack', pack };
17611768 }
17621769
17631770 function openCloneStickerMenu(eventId: string, body: string, x: number, y: number): void {
17641771 const roomId = $selectedRoomId;
17651772 if (!roomId) return;
1766- const items = editableChoices($imagePacks, roomName).map((choice) => ({
1767- label: `Clone to ${choice.label}`,
1768- action: () => {
1769- const taken = new Set(choice.pack?.images.map((i) => i.shortcode) ?? []);
1770- void runClone(
1771- { op: 'clone_sticker', room_id: roomId, event_id: eventId, to: choice.target, shortcode: shortcodeFor(body || 'sticker', taken) },
1772- 'Could not clone the sticker'
1773- );
1774- }
1775- }));
1776- menu = { items, x, y };
1773+ menu = null;
1774+ cloneSource = { kind: 'sticker', room_id: roomId, event_id: eventId, body };
17771775 }
17781776
17791777 async function openMessageMenu(event: TimelineEvent, x: number, y: number): Promise<void> {
@@ -2601,7 +2599,7 @@
26012599 senderAvatar={senderAvatarFor(g.item.event.sender.user_id) ?? g.item.event.sender.avatar}
26022600 {reacting}
26032601 onReact={(eventId, key, shortcode) => void react(eventId, key, shortcode)}
2604- quickReactions={$quickReactions}
2602+ quickReactions={usableQuickReactions}
26052603 packs={roomPacks}
26062604 linkPreviews={$linkPreviews}
26072605 {loadPreview}
@@ -2629,7 +2627,7 @@
26292627 senderAvatar={senderAvatarFor($sessionUser ?? '')}
26302628 {reacting}
26312629 onReact={(eventId, key, shortcode) => void react(eventId, key, shortcode)}
2632- quickReactions={$quickReactions}
2630+ quickReactions={usableQuickReactions}
26332631 packs={roomPacks}
26342632 linkPreviews={$linkPreviews}
26352633 {loadPreview}
@@ -2902,6 +2900,12 @@
29022900 </aside>
29032901 {/if}
29042902
2903+ {#if cloneSource}
2904+ <div class="modal-wrap" role="presentation" on:click|self={() => (cloneSource = null)}>
2905+ <PackCloneDialog source={cloneSource} packs={$imagePacks} rooms={$rooms} onError={pushBanner} onClose={() => (cloneSource = null)} />
2906+ </div>
2907+ {/if}
2908+
29052909 {#if dialog}
29062910 <div class="modal-wrap" role="presentation" on:click|self={() => (dialog = null)}>
29072911 {#if dialog === 'security'}
Added file: web/src/lib/PackCloneDialog.svelte+126 additions
@@ -0,0 +1,126 @@
1+<script lang="ts">
2+ import { runCommand } from './api';
3+ import type { CloneSource } from './pack-clone';
4+ import { cloneRequest, editableChoices, freePackStateKey, packLabel } from './packs';
5+ import type { PackTarget } from './transport';
6+ import type { ImagePacks, PackUsage, RoomSummary } from './types';
7+
8+ export let source: CloneSource;
9+ export let packs: ImagePacks;
10+ export let rooms: RoomSummary[];
11+ export let onClose: () => void;
12+ export let onError: (message: string) => void;
13+
14+ const roomName = (id: string) => rooms.find((room) => room.room_id === id)?.name ?? id;
15+ let destinationId = '';
16+ let roomId = '';
17+ let name = source.kind === 'sticker' ? 'Sticker pack' : packLabel(source.pack, roomName);
18+ let asSticker = true;
19+ let asEmoji = true;
20+ let busy = false;
21+ let error = '';
22+ let createdTarget: { roomId: string; name: string; target: PackTarget } | null = null;
23+
24+ $: choices = editableChoices(packs, roomName).filter((choice) =>
25+ source.kind !== 'pack' || choice.pack?.id !== source.pack.id);
26+ $: locations = rooms.filter((room) => room.can_edit_packs && room.membership === 'Joined')
27+ .sort((a, b) => Number(b.space) - Number(a.space) || a.name.localeCompare(b.name));
28+ $: if (!destinationId) destinationId = choices[0]?.pack?.id ?? (choices.length ? 'user' : 'new');
29+ $: if (!roomId && locations.length) roomId = locations[0].room_id;
30+ $: creating = destinationId === 'new';
31+
32+ function key(event: KeyboardEvent): void {
33+ if (event.key !== 'Escape') return;
34+ event.stopImmediatePropagation();
35+ if (!busy) onClose();
36+ }
37+
38+ async function copy(): Promise<void> {
39+ if (busy) return;
40+ const usage: PackUsage[] = [...(asSticker ? ['Sticker' as const] : []), ...(asEmoji ? ['Emoticon' as const] : [])];
41+ if (!usage.length) {
42+ error = 'Choose stickers, emoji, or both';
43+ return;
44+ }
45+ busy = true;
46+ error = '';
47+ try {
48+ const choice = choices.find((item) => (item.pack?.id ?? 'user') === destinationId);
49+ let target = choice?.target;
50+ const displayName = name.trim();
51+ if (creating) {
52+ if (!roomId || !displayName) throw new Error('Choose a space or room and give the pack a name');
53+ if (!createdTarget || createdTarget.roomId !== roomId || createdTarget.name !== displayName) {
54+ const stateKey = freePackStateKey(packs, roomId, displayName);
55+ target = { kind: 'room', room_id: roomId, state_key: stateKey };
56+ await runCommand({ op: 'create_room_pack', room_id: roomId, state_key: stateKey, display_name: displayName });
57+ createdTarget = { roomId, name: displayName, target };
58+ } else target = createdTarget.target;
59+ }
60+ if (!target) throw new Error('Choose a destination pack');
61+ const result = await runCommand(cloneRequest(source, target, usage, creating ? displayName : null, choice?.pack ?? null)) as { skipped?: string[] };
62+ if (result?.skipped?.length) onError(`Could not copy ${result.skipped.join(', ')}`);
63+ onClose();
64+ } catch (e) {
65+ error = e instanceof Error ? e.message : 'Could not copy the images';
66+ } finally {
67+ busy = false;
68+ }
69+ }
70+</script>
71+
72+<svelte:window on:keydown|capture={key} />
73+
74+<div class="modal clone" role="dialog" aria-modal="true" aria-label={source.kind === 'pack' ? 'Clone pack' : 'Clone image'}>
75+ <h2>{source.kind === 'pack' ? 'Clone pack' : 'Clone image'}</h2>
76+ <form on:submit|preventDefault={() => void copy()}>
77+ <fieldset disabled={busy}>
78+ <label class="field">
79+ <span>Destination pack</span>
80+ <select bind:value={destinationId}>
81+ {#each choices as choice}
82+ <option value={choice.pack?.id ?? 'user'}>{choice.label}</option>
83+ {/each}
84+ {#if locations.length}<option value="new">New pack in a space or room</option>{/if}
85+ </select>
86+ </label>
87+ {#if creating && locations.length}
88+ <label class="field">
89+ <span>Space or room</span>
90+ <select bind:value={roomId}>
91+ {#each locations as room (room.room_id)}
92+ <option value={room.room_id}>{room.space ? 'Space' : 'Room'} · {room.name}</option>
93+ {/each}
94+ </select>
95+ </label>
96+ <label class="field"><span>New pack name</span><input type="text" bind:value={name} required /></label>
97+ {:else if choices.length === 0}
98+ <p>No writable destination packs are available. Creating shared packs requires permission in a joined space or room.</p>
99+ {:else}
100+ <p>Copies are appended to the selected pack. Existing images stay in place and duplicate names get a numbered suffix.</p>
101+ {/if}
102+ <div class="uses" role="group" aria-label="Use copied images as">
103+ <label><input type="checkbox" bind:checked={asSticker} /> Stickers</label>
104+ <label><input type="checkbox" bind:checked={asEmoji} /> Emoji and reactions</label>
105+ </div>
106+ {#if error}<p class="error" role="alert">{error}</p>{/if}
107+ <div class="actions">
108+ <button type="button" class="ghost-btn" on:click={onClose}>Cancel</button>
109+ <button type="submit" class="primary-btn" disabled={(!choices.length && !locations.length) || (!asSticker && !asEmoji)}>{busy ? 'Copying…' : 'Copy'}</button>
110+ </div>
111+ </fieldset>
112+ </form>
113+</div>
114+
115+<style>
116+ .clone { width: min(460px, 100%); }
117+ h2 { margin: 0 0 16px; font-size: 18px; }
118+ fieldset { display: flex; flex-direction: column; gap: 14px; border: 0; padding: 0; margin: 0; min-width: 0; }
119+ .field { display: flex; flex-direction: column; gap: 6px; }
120+ input[type='text'], select { width: 100%; padding: 8px; background: var(--bg-soft); border: 1px solid var(--border); border-radius: var(--r-sm); color: var(--text); }
121+ p { margin: 0; font-size: 13px; color: var(--muted); }
122+ .uses, .actions { display: flex; flex-wrap: wrap; gap: 14px; }
123+ .uses label { display: flex; align-items: center; gap: 6px; font-size: 13px; }
124+ .actions { justify-content: flex-end; }
125+ .error { color: var(--danger); }
126+</style>
Added file: web/src/lib/PackManager.stories.ts+205 additions
@@ -0,0 +1,205 @@
1+import type { Meta, StoryObj } from '@storybook/svelte-vite';
2+import { expect, fn, userEvent, waitFor, within } from 'storybook/test';
3+import PackManager from './PackManager.svelte';
4+import { disabledPackImages } from './pack-preferences';
5+import type { ImagePack, ImagePacks, PackImage, RoomSummary } from './types';
6+
7+const roomId = '!wonderland:example.org';
8+const spaceId = '!tea-party:example.org';
9+const readOnlyRoomId = '!looking-glass:example.org';
10+
11+function room(
12+ room_id: string,
13+ name: string,
14+ space: boolean,
15+ can_edit_packs: boolean,
16+ children: string[] = []
17+): RoomSummary {
18+ return {
19+ room_id,
20+ name,
21+ membership: 'Joined',
22+ unread_messages: 0,
23+ unread_mentions: 0,
24+ unread_notifications: 0,
25+ encrypted: false,
26+ space,
27+ direct: false,
28+ favourite: false,
29+ low_priority: false,
30+ avatar: null,
31+ latest_event_timestamp_ms: null,
32+ latest_event: null,
33+ notification_mode: null,
34+ notification_mode_custom: false,
35+ topic: null,
36+ canonical_alias: null,
37+ joined_members: 1,
38+ invited_members: 0,
39+ heroes: [],
40+ successor: null,
41+ inviter: null,
42+ parent_spaces: space ? [] : [spaceId],
43+ children,
44+ space_order: null,
45+ can_edit_packs,
46+ pinned_events: [],
47+ can_pin: false
48+ };
49+}
50+
51+function image(shortcode: string): PackImage {
52+ const media_id = `mxc://example.org/${shortcode}`;
53+ return {
54+ shortcode,
55+ media_id,
56+ reaction_key: media_id,
57+ body: null,
58+ mimetype: 'image/png',
59+ width: 128,
60+ height: 128,
61+ usage: ['Emoticon', 'Sticker']
62+ };
63+}
64+
65+function pack(
66+ id: string,
67+ display_name: string,
68+ source: ImagePack['source'],
69+ editable: boolean,
70+ images: PackImage[] = []
71+): ImagePack {
72+ return { id, display_name, avatar: null, attribution: null, source, images, editable, enabled_everywhere: true };
73+}
74+
75+const rooms = [
76+ room(roomId, 'Wonderland', false, true),
77+ room(spaceId, 'Tea Party Space', true, true, [roomId]),
78+ room(readOnlyRoomId, 'Looking Glass Room', false, false)
79+];
80+
81+const packs: ImagePacks = {
82+ global: [pack('user', 'Personal pack', 'User', true)],
83+ rooms: {
84+ [roomId]: [pack('room-pack', 'Editable room pack', { Room: { room_id: roomId, state_key: 'editable-room' } }, true)],
85+ [spaceId]: [pack('space-pack', 'Editable space pack', { Room: { room_id: spaceId, state_key: 'editable-space' } }, true)],
86+ [readOnlyRoomId]: [
87+ pack(
88+ 'read-only-pack',
89+ 'Shared pack',
90+ { Room: { room_id: readOnlyRoomId, state_key: 'shared' } },
91+ false,
92+ [image('one'), image('two')]
93+ )
94+ ]
95+ }
96+};
97+
98+let accountDataPosts = 0;
99+let commandRequests: Record<string, unknown>[] = [];
100+
101+const meta = {
102+ title: 'Packs/PackManager',
103+ component: PackManager,
104+ args: { packs, rooms, onError: fn() }
105+} satisfies Meta<typeof PackManager>;
106+
107+export default meta;
108+type Story = StoryObj<typeof meta>;
109+
110+export const CloneReadOnlyPack: Story = {
111+ play: async ({ canvas }) => {
112+ await userEvent.click(canvas.getByText('Shared pack · Looking Glass Room'));
113+ await userEvent.click(canvas.getByRole('button', { name: 'Clone pack' }));
114+
115+ const dialog = canvas.getByRole('dialog', { name: 'Clone pack' });
116+ const cloneDialog = within(dialog);
117+ await expect(cloneDialog.getByRole('checkbox', { name: 'Stickers' })).toBeChecked();
118+ await expect(cloneDialog.getByRole('checkbox', { name: 'Emoji and reactions' })).toBeChecked();
119+
120+ const destination = cloneDialog.getByLabelText('Destination pack');
121+ await expect(within(destination).getByRole('option', { name: 'Editable room pack · Wonderland' })).toBeInTheDocument();
122+ await expect(within(destination).getByRole('option', { name: 'Editable space pack · Tea Party Space' })).toBeInTheDocument();
123+ await userEvent.selectOptions(destination, 'new');
124+
125+ const location = cloneDialog.getByLabelText('Space or room');
126+ await expect(within(location).getByRole('option', { name: 'Space · Tea Party Space' })).toBeInTheDocument();
127+ }
128+};
129+
130+export const CloneIntoSpacePack: Story = {
131+ beforeEach: () => {
132+ const originalFetch = window.fetch;
133+ commandRequests = [];
134+ window.fetch = async (input, init) => {
135+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
136+ const method = init?.method ?? (input instanceof Request ? input.method : 'GET');
137+ if (new URL(url, window.location.href).pathname === '/api/command' && method === 'POST') {
138+ commandRequests.push(JSON.parse(String(init?.body)) as Record<string, unknown>);
139+ return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });
140+ }
141+ return originalFetch(input, init);
142+ };
143+ return () => {
144+ window.fetch = originalFetch;
145+ };
146+ },
147+ play: async ({ canvas }) => {
148+ await userEvent.click(canvas.getByText('Shared pack · Looking Glass Room'));
149+ await userEvent.click(canvas.getByRole('button', { name: 'Clone pack' }));
150+
151+ const dialog = canvas.getByRole('dialog', { name: 'Clone pack' });
152+ const cloneDialog = within(dialog);
153+ await userEvent.selectOptions(cloneDialog.getByLabelText('Destination pack'), 'space-pack');
154+ await userEvent.click(cloneDialog.getByRole('button', { name: 'Copy' }));
155+
156+ await waitFor(() => expect(commandRequests).toHaveLength(1));
157+ await expect(commandRequests).toEqual([
158+ {
159+ op: 'clone_pack',
160+ from_pack: 'read-only-pack',
161+ to: { kind: 'room', room_id: spaceId, state_key: 'editable-space' },
162+ display_name: null,
163+ usage: ['Sticker', 'Emoticon']
164+ }
165+ ]);
166+ await expect(canvas.queryByRole('dialog', { name: 'Clone pack' })).toBeNull();
167+ }
168+};
169+
170+export const PersonalImagePreference: Story = {
171+ beforeEach: () => {
172+ const originalFetch = window.fetch;
173+ accountDataPosts = 0;
174+ disabledPackImages.set({});
175+ window.fetch = async (input, init) => {
176+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
177+ const method = init?.method ?? (input instanceof Request ? input.method : 'GET');
178+ if (new URL(url, window.location.href).pathname === '/api/account-data' && method === 'POST') {
179+ accountDataPosts += 1;
180+ return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });
181+ }
182+ return originalFetch(input, init);
183+ };
184+ return () => {
185+ window.fetch = originalFetch;
186+ };
187+ },
188+ play: async ({ canvas }) => {
189+ await userEvent.click(canvas.getByText('Shared pack · Looking Glass Room'));
190+ const card = canvas.getByAltText('one').closest('.image') as HTMLElement | null;
191+ if (!card) throw new Error('Image one has no card');
192+ const checkbox = within(card).getByRole('checkbox', { name: 'Use for me' });
193+
194+ await expect(checkbox).toBeChecked();
195+ await userEvent.click(checkbox);
196+ await expect(checkbox).not.toBeChecked();
197+ await expect(card).toBeInTheDocument();
198+ await waitFor(() => expect(accountDataPosts).toBe(1));
199+
200+ await userEvent.click(checkbox);
201+ await expect(checkbox).toBeChecked();
202+ await expect(card).toBeInTheDocument();
203+ await waitFor(() => expect(accountDataPosts).toBe(2));
204+ }
205+};
Modified file: web/src/lib/PackManager.svelte+110 additions−53 deletions
@@ -1,16 +1,17 @@
11 <script lang="ts">
22 import { createEventDispatcher } from 'svelte';
3- import ContextMenu, { type MenuItem } from './ContextMenu.svelte';
3+ import PackCloneDialog from './PackCloneDialog.svelte';
44 import MxcAvatar from './MxcAvatar.svelte';
55 import EmojiText from './EmojiText.svelte';
66 import { messageMediaUrl, runCommand, uploadMedia } from './api';
7+ import { disabledPackImages, setPackImageEnabled } from './pack-preferences';
8+ import { imageEnabled } from './pack-preferences-model';
9+ import type { CloneSource } from './pack-clone';
710 import {
811 allPacks,
9- clonePackDestinations,
10- editableChoices,
12+ freePackStateKey,
1113 hasUsage,
1214 packLabel,
13- packSlug,
1415 packTarget,
1516 shortcodeFor,
1617 validShortcode
@@ -30,17 +31,29 @@
3031 let newName = '';
3132 let confirmDelete = false;
3233 let uploading: { done: number; total: number } | null = null;
33- let menu: { items: MenuItem[]; x: number; y: number } | null = null;
34+ let cloneSource: CloneSource | null = null;
3435 let fileInput: HTMLInputElement;
3536 let avatarInput: HTMLInputElement;
3637
3738 const roomName = (roomId: string) => rooms.find((r) => r.room_id === roomId)?.name ?? roomId;
39+ const sidebarPackLabel = (pack: ImagePack) => {
40+ const label = packLabel(pack, roomName);
41+ if (pack.source === 'User' || !pack.display_name) return label;
42+ return `${label} · ${roomName(pack.source.Room.room_id)}`;
43+ };
44+ const packScope = (pack: ImagePack) => {
45+ if (pack.source === 'User') return 'Personal pack synced to your account';
46+ const roomId = pack.source.Room.room_id;
47+ return `${rooms.find((room) => room.room_id === roomId)?.space ? 'Space' : 'Room'} pack in ${roomName(roomId)}`;
48+ };
3849
3950 $: list = allPacks(packs);
4051 $: userPack = list.find((p) => p.source === 'User') ?? null;
4152 $: roomPacks = list.filter((p) => p.source !== 'User' && p.editable);
4253 $: otherPacks = list.filter((p) => p.source !== 'User' && !p.editable);
4354 $: packRooms = rooms.filter((r) => r.can_edit_packs && r.membership === 'Joined');
55+ $: spacePackRooms = packRooms.filter((room) => room.space);
56+ $: regularPackRooms = packRooms.filter((room) => !room.space);
4457 $: selected = selectedId === 'user' ? userPack : (list.find((p) => p.id === selectedId) ?? null);
4558 $: target = selectedId === 'user' ? ({ kind: 'user' } as PackTarget) : selected ? packTarget(selected) : null;
4659 $: editable = selectedId === 'user' || (selected?.editable ?? false);
@@ -59,7 +72,7 @@
5972 async function createPack(): Promise<void> {
6073 const name = newName.trim();
6174 if (!newRoom || !name) return;
62- const state_key = packSlug(name);
75+ const state_key = freePackStateKey(packs, newRoom, name);
6376 const ok = await run(
6477 () => runCommand({ op: 'create_room_pack', room_id: newRoom, state_key, display_name: name }),
6578 'Could not create the pack'
@@ -130,6 +143,28 @@
130143 await run(() => runCommand({ op: 'rename_pack_image', target: target!, from: image.shortcode, to }), 'Could not rename');
131144 }
132145
146+ async function renameImageBody(image: PackImage, event: Event): Promise<void> {
147+ const input = event.currentTarget as HTMLInputElement;
148+ const body = input.value.trim() || image.shortcode;
149+ const currentBody = image.body?.trim() || image.shortcode;
150+ if (!target || body === currentBody) {
151+ input.value = currentBody;
152+ return;
153+ }
154+ const ok = await run(
155+ () => runCommand({
156+ op: 'put_pack_image',
157+ target: target!,
158+ shortcode: image.shortcode,
159+ mxc: image.media_id,
160+ body,
161+ usage: null
162+ }),
163+ 'Could not rename the sticker'
164+ );
165+ if (!ok) input.value = currentBody;
166+ }
167+
133168 async function toggleUsage(image: PackImage, usage: PackUsage): Promise<void> {
134169 if (!target) return;
135170 const current: PackUsage[] = image.usage.length ? image.usage : ['Emoticon', 'Sticker'];
@@ -166,36 +201,16 @@
166201 if (ok) selectedId = 'user';
167202 }
168203
169- function cloneImageMenu(pack: ImagePack, image: PackImage, ev: MouseEvent): void {
170- menu = {
171- x: ev.clientX,
172- y: ev.clientY,
173- items: editableChoices(packs, roomName).map((choice) => ({
174- label: `Clone to ${choice.label}`,
175- action: () =>
176- void run(
177- () => runCommand({ op: 'clone_pack_image', from_pack: pack.id, shortcode: image.shortcode, to: choice.target }),
178- 'Could not clone the image'
179- )
180- }))
181- };
204+ function openImageClone(image: PackImage): void {
205+ if (selected) cloneSource = { kind: 'image', pack: selected, image };
182206 }
183207
184- function clonePackMenu(pack: ImagePack, ev: MouseEvent): void {
185- menu = {
186- x: ev.clientX,
187- y: ev.clientY,
188- items: clonePackDestinations(pack, packRooms, roomName).map((d) => ({
189- label: d.label,
190- action: () =>
191- void run(async () => {
192- const res = (await runCommand({ op: 'clone_pack', from_pack: pack.id, to: d.target, display_name: d.display_name })) as {
193- skipped?: string[];
194- };
195- if (res?.skipped?.length) onError(`Couldn't copy ${res.skipped.map((s) => `:${s}:`).join(', ')}`);
196- }, 'Could not clone the pack')
197- }))
198- };
208+ async function togglePersonalImage(image: PackImage, enabled: boolean): Promise<void> {
209+ if (!selected) return;
210+ await run(
211+ () => setPackImageEnabled(selected!.id, image.shortcode, enabled),
212+ 'Could not save your image preference'
213+ );
199214 }
200215 </script>
201216
@@ -214,11 +229,11 @@
214229 <span><EmojiText text={userPack?.display_name ?? 'Your pack'} /></span>
215230 </button>
216231 {#if roomPacks.length}
217- <h4>Room packs</h4>
232+ <h4>Room and space packs</h4>
218233 {#each roomPacks as pack (pack.id)}
219234 <button type="button" class="pack-item" class:active={selectedId === pack.id} on:click={() => (selectedId = pack.id)}>
220- <MxcAvatar mxc={pack.avatar} label={packLabel(pack, roomName)} size={28} shape="rounded" />
221- <span><EmojiText text={packLabel(pack, roomName)} /></span>
235+ <MxcAvatar mxc={pack.avatar} label={sidebarPackLabel(pack)} size={28} shape="rounded" />
236+ <span><EmojiText text={sidebarPackLabel(pack)} /></span>
222237 </button>
223238 {/each}
224239 {/if}
@@ -226,18 +241,21 @@
226241 <h4>Other packs</h4>
227242 {#each otherPacks as pack (pack.id)}
228243 <button type="button" class="pack-item" class:active={selectedId === pack.id} on:click={() => (selectedId = pack.id)}>
229- <MxcAvatar mxc={pack.avatar} label={packLabel(pack, roomName)} size={28} shape="rounded" />
230- <span><EmojiText text={packLabel(pack, roomName)} /></span>
244+ <MxcAvatar mxc={pack.avatar} label={sidebarPackLabel(pack)} size={28} shape="rounded" />
245+ <span><EmojiText text={sidebarPackLabel(pack)} /></span>
231246 </button>
232247 {/each}
233248 {/if}
234249 {#if packRooms.length}
235250 {#if creating}
236251 <form class="new-pack" on:submit|preventDefault={() => void createPack()}>
237- <select bind:value={newRoom} aria-label="Room">
238- <option value="" disabled>Room</option>
239- {#each packRooms as room (room.room_id)}
240- <option value={room.room_id}>{room.name}</option>
252+ <select bind:value={newRoom} aria-label="Space or room">
253+ <option value="" disabled>Space or room</option>
254+ {#each spacePackRooms as room (room.room_id)}
255+ <option value={room.room_id}>Space · {room.name}</option>
256+ {/each}
257+ {#each regularPackRooms as room (room.room_id)}
258+ <option value={room.room_id}>Room · {room.name}</option>
241259 {/each}
242260 </select>
243261 <input type="text" placeholder="Pack name" bind:value={newName} aria-label="Pack name" />
@@ -247,12 +265,17 @@
247265 </div>
248266 </form>
249267 {:else}
250- <button type="button" class="ghost-btn new-btn" on:click={() => (creating = true)}>New room pack</button>
268+ <button type="button" class="ghost-btn new-btn" on:click={() => (creating = true)}>New pack</button>
251269 {/if}
252270 {/if}
253271 </nav>
254272
255273 <section class="editor" aria-label="Pack">
274+ {#if selectedId === 'user'}
275+ <p class="scope">Personal pack synced to your account</p>
276+ {:else if selected}
277+ <p class="scope">{packScope(selected)}</p>
278+ {/if}
256279 {#if editable && target}
257280 <div class="meta">
258281 <button type="button" class="avatar-btn" title="Change pack avatar" on:click={() => avatarInput.click()}>
@@ -267,6 +290,9 @@
267290 on:change={(e) => void rename(e)}
268291 />
269292 <input type="file" accept="image/*" class="visually-hidden" bind:this={avatarInput} on:change={() => void changeAvatar()} />
293+ {#if selected}
294+ <button type="button" class="ghost-btn" on:click={() => selected && (cloneSource = { kind: 'pack', pack: selected })}>Clone pack</button>
295+ {/if}
270296 </div>
271297 <div class="row">
272298 {#if selected && selected.source !== 'User'}
@@ -289,14 +315,21 @@
289315 {/if}
290316 {/if}
291317 </div>
318+ <p class="preference-note">Hiding an image with Use for me only affects you, and syncs to your account. Emoji and sticker settings apply for everyone.</p>
292319 <div class="images">
293320 {#each selected?.images ?? [] as image (image.shortcode)}
294321 <div class="image">
295- <img src={messageMediaUrl(image.media_id, 'Thumbnail')} alt={image.shortcode} loading="lazy" />
322+ <img class:hidden={!imageEnabled($disabledPackImages, selected?.id ?? 'user', image.shortcode)} src={messageMediaUrl(image.media_id, 'Thumbnail')} alt={image.shortcode} loading="lazy" />
296323 <input type="text" value={image.shortcode} aria-label="Shortcode" on:change={(e) => void renameImage(image, e)} />
324+ <input type="text" value={image.body?.trim() || image.shortcode} aria-label="Sticker name" on:change={(e) => void renameImageBody(image, e)} />
325+ <label class="personal-check">
326+ <input type="checkbox" checked={imageEnabled($disabledPackImages, selected?.id ?? 'user', image.shortcode)} on:change={(e) => void togglePersonalImage(image, e.currentTarget.checked)} />
327+ Use for me
328+ </label>
297329 <div class="usage">
298- <button type="button" aria-pressed={hasUsage(image, 'Emoticon')} title="Emoji" on:click={() => void toggleUsage(image, 'Emoticon')}>☺</button>
299- <button type="button" aria-pressed={hasUsage(image, 'Sticker')} title="Sticker" on:click={() => void toggleUsage(image, 'Sticker')}>▣</button>
330+ <button type="button" aria-pressed={hasUsage(image, 'Emoticon')} title="Emoji for everyone" aria-label="Emoji for everyone" on:click={() => void toggleUsage(image, 'Emoticon')}>☺</button>
331+ <button type="button" aria-pressed={hasUsage(image, 'Sticker')} title="Sticker for everyone" aria-label="Sticker for everyone" on:click={() => void toggleUsage(image, 'Sticker')}>▣</button>
332+ <button type="button" class="ghost-btn clone" on:click={() => openImageClone(image)}>Clone</button>
300333 <button type="button" class="remove" title="Remove" aria-label={`Remove ${image.shortcode}`} on:click={() => void removeImage(image)}>✕</button>
301334 </div>
302335 </div>
@@ -309,14 +342,25 @@
309342 <MxcAvatar mxc={selected.avatar} label={packLabel(selected, roomName)} size={48} shape="rounded" />
310343 <strong class="name"><EmojiText text={packLabel(selected, roomName)} /></strong>
311344 <span class="spacer"></span>
312- <button type="button" class="primary-btn" on:click={(ev) => selected && clonePackMenu(selected, ev)}>Clone pack</button>
345+ <button type="button" class="primary-btn" on:click={() => (cloneSource = { kind: 'pack', pack: selected })}>Clone pack</button>
313346 </div>
347+ {#if selected.source !== 'User'}
348+ <label class="check">
349+ <input type="checkbox" checked={selected.enabled_everywhere} on:change={(e) => void setEverywhere(e.currentTarget.checked)} />
350+ Use everywhere
351+ </label>
352+ {/if}
353+ <p class="preference-note">Hiding an image with Use for me only affects you, and syncs to your account. Emoji and sticker settings apply for everyone.</p>
314354 <div class="images">
315355 {#each selected.images as image (image.shortcode)}
316356 <div class="image">
317- <img src={messageMediaUrl(image.media_id, 'Thumbnail')} alt={image.shortcode} loading="lazy" />
318- <span class="code">{image.shortcode}</span>
319- <button type="button" class="ghost-btn" on:click={(ev) => selected && cloneImageMenu(selected, image, ev)}>Clone</button>
357+ <img class:hidden={!imageEnabled($disabledPackImages, selected?.id ?? 'user', image.shortcode)} src={messageMediaUrl(image.media_id, 'Thumbnail')} alt={image.shortcode} loading="lazy" />
358+ <span class="code">{image.body?.trim() ? `${image.body.trim()} · ${image.shortcode}` : image.shortcode}</span>
359+ <label class="personal-check">
360+ <input type="checkbox" checked={imageEnabled($disabledPackImages, selected?.id ?? 'user', image.shortcode)} on:change={(e) => void togglePersonalImage(image, e.currentTarget.checked)} />
361+ Use for me
362+ </label>
363+ <button type="button" class="ghost-btn" on:click={() => openImageClone(image)}>Clone</button>
320364 </div>
321365 {/each}
322366 </div>
@@ -325,8 +369,16 @@
325369 </div>
326370 </div>
327371
328-{#if menu}
329- <ContextMenu items={menu.items} x={menu.x} y={menu.y} onClose={() => (menu = null)} />
372+{#if cloneSource}
373+ <div class="modal-wrap">
374+ <PackCloneDialog
375+ source={cloneSource}
376+ {packs}
377+ {rooms}
378+ onClose={() => (cloneSource = null)}
379+ {onError}
380+ />
381+ </div>
330382 {/if}
331383
332384 <style>
@@ -353,6 +405,7 @@
353405 .pack-item.active { background: color-mix(in srgb, var(--accent) 20%, transparent); }
354406 .new-btn { margin-top: 8px; }
355407 .new-pack { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
408+ .scope, .preference-note { margin: 0; color: var(--muted); font-size: 12px; }
356409 .editor { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10px; overflow-y: auto; }
357410 .meta { display: flex; align-items: center; gap: 10px; }
358411 .avatar-btn { padding: 0; border: none; background: none; cursor: pointer; border-radius: var(--r-md); }
@@ -381,7 +434,9 @@
381434 background: var(--bg-soft);
382435 }
383436 .image img { width: 100%; aspect-ratio: 1; object-fit: contain; }
437+ .image img.hidden { opacity: 0.35; }
384438 .image input { width: 100%; font-size: 12px; padding: 3px 6px; }
439+ .personal-check { display: flex; align-items: center; gap: 5px; font-size: 12px; color: var(--muted); }
385440 .code { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
386441 .usage { display: flex; gap: 4px; }
387442 .usage button {
@@ -396,5 +451,7 @@
396451 .usage button[aria-pressed='true'] { color: var(--accent); border-color: var(--accent); }
397452 .usage .remove { flex: none; padding: 2px 6px; }
398453 .usage .remove:hover { color: var(--danger); border-color: var(--danger); }
454+ .usage .clone { flex: none; padding: 2px 6px; font-size: 12px; }
399455 .empty { color: var(--muted); font-size: 13px; }
456+ .modal-wrap { position: fixed; inset: 0; z-index: 70; display: grid; place-items: center; background: rgb(0 0 0 / 55%); }
400457 </style>
Modified file: web/src/lib/ReactionPicker.svelte+2 additions
@@ -7,6 +7,7 @@
77 import type { EmojiGroup } from './emoji';
88 import { MAX_QUICK_REACTIONS } from './types';
99 import type { ImagePack, PackImage, QuickReaction } from './types';
10+ import { hasUsage } from './packs';
1011
1112 export let packs: ImagePack[];
1213 export let quick: QuickReaction[];
@@ -39,6 +40,7 @@
3940 $: nothingFound = q !== '' && groups !== null && filteredPacks.length === 0 && filteredGroups.length === 0;
4041
4142 function imageMatches(image: PackImage, needle: string): boolean {
43+ if (!hasUsage(image, 'Emoticon')) return false;
4244 if (!needle) return true;
4345 return image.shortcode.toLowerCase().includes(needle) || (image.body ?? '').toLowerCase().includes(needle);
4446 }
Modified file: web/src/lib/StickerPicker.svelte+1 additions−3 deletions
@@ -79,9 +79,7 @@
7979 <section class="group" aria-label={packLabel(g.pack, roomName)}>
8080 <div class="group-head">
8181 <h3><EmojiText text={packLabel(g.pack, roomName)} /></h3>
82- {#if !g.pack.editable}
83- <button type="button" class="clone-pack" on:click={(ev) => onClonePack(g.pack, ev.clientX, ev.clientY)}>Clone pack</button>
84- {/if}
82+ <button type="button" class="clone-pack" on:click={(ev) => onClonePack(g.pack, ev.clientX, ev.clientY)}>Clone pack</button>
8583 </div>
8684 <div class="grid">
8785 {#each g.images as image (image.shortcode)}
Added file: web/src/lib/avatar-cache.ts+43 additions
@@ -0,0 +1,43 @@
1+export interface AvatarCache {
2+ match(key: string): Promise<Response | undefined>;
3+ put(key: string, response: Response): Promise<void>;
4+}
5+
6+export function createAvatarLoader(
7+ cache: () => Promise<AvatarCache>,
8+ load: (mxc: string) => Promise<Response>
9+): (key: string, mxc: string) => Promise<Response> {
10+ const pending = new Map<string, Promise<Response>>();
11+
12+ return async (key, mxc) => {
13+ const active = pending.get(mxc);
14+ if (active) return (await active).clone();
15+
16+ const request = (async () => {
17+ let store: AvatarCache | null = null;
18+ try {
19+ store = await cache();
20+ const cached = await store.match(key);
21+ if (cached) return cached;
22+ } catch {
23+ store = null;
24+ }
25+
26+ const response = await load(mxc);
27+ if (response.ok && store) {
28+ try {
29+ await store.put(key, response.clone());
30+ } catch {
31+ return response;
32+ }
33+ }
34+ return response;
35+ })();
36+ pending.set(mxc, request);
37+ try {
38+ return (await request).clone();
39+ } finally {
40+ if (pending.get(mxc) === request) pending.delete(mxc);
41+ }
42+ };
43+}
Modified file: web/src/lib/commands.ts+16 additions−2 deletions
@@ -8,6 +8,7 @@ export interface ParsedCommand {
88 export const COMMANDS = [
99 'me',
1010 'notice',
11+ 'sticker',
1112 'msg',
1213 'join',
1314 'leave',
@@ -76,6 +77,7 @@ export function splitTarget(args: string, people: Person[]): Target {
7677 export const COMMAND_USAGE: Record<CommandName, string> = {
7778 me: '<action>',
7879 notice: '<text>',
80+ sticker: '<sticker name>',
7981 msg: '<user> [message]',
8082 join: '<room>',
8183 leave: '',
@@ -102,6 +104,18 @@ export function commandQueryAt(text: string, caret: number): string | null {
102104 return match ? match[1].toLowerCase() : null;
103105 }
104106
105-export function matchCommands(prefix: string): CommandName[] {
106- return COMMANDS.filter((name) => name.startsWith(prefix));
107+export function matchCommands(query: string): CommandName[] {
108+ const q = query.toLowerCase();
109+ return COMMANDS.flatMap((name, order) => {
110+ if (name.startsWith(q)) return [{ name, score: -1, order }];
111+ let previous = -1;
112+ let score = 0;
113+ for (const letter of q) {
114+ const index = name.indexOf(letter, previous + 1);
115+ if (index < 0) return [];
116+ score += index - previous - 1;
117+ previous = index;
118+ }
119+ return [{ name, score, order }];
120+ }).sort((a, b) => a.score - b.score || a.order - b.order).map(({ name }) => name);
107121 }
Modified file: web/src/lib/discord-markdown.d.ts+2 additions
@@ -4,4 +4,6 @@ declare module '@cloudrac3r/discord-markdown' {
44 export type MarkdownNode = SingleASTNode;
55 export const rules: Record<string, ParserRule>;
66 export const markdownEngine: Pick<typeof import('simple-markdown'), 'parserFor' | 'sanitizeUrl'>;
7+ const markdown: { rules: typeof rules; markdownEngine: typeof markdownEngine };
8+ export default markdown;
79 }
Modified file: web/src/lib/markdown.ts+3 additions−1 deletions
@@ -1,7 +1,9 @@
1-import { markdownEngine, rules, type MarkdownNode } from '@cloudrac3r/discord-markdown';
1+import markdown, { type MarkdownNode } from '@cloudrac3r/discord-markdown';
22 import type { Emote } from './emotes';
33 import type { Mention } from './mentions';
44
5+const { markdownEngine, rules } = markdown;
6+
57 function escapeHtml(text: string): string {
68 return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
79 }
Added file: web/src/lib/pack-clone.ts+7 additions
@@ -0,0 +1,7 @@
1+import type { ImagePack, PackImage } from './types';
2+
3+export type CloneSource =
4+ | { kind: 'pack'; pack: ImagePack }
5+ | { kind: 'image'; pack: ImagePack; image: PackImage }
6+ | { kind: 'sticker'; room_id: string; event_id: string; body: string };
7+
Added file: web/src/lib/pack-preferences-model.ts+81 additions
@@ -0,0 +1,81 @@
1+import type { ImagePack, QuickReaction } from './types';
2+
3+export type DisabledPackImages = Record<string, string[]>;
4+
5+function isRecord(value: unknown): value is Record<string, unknown> {
6+ return typeof value === 'object' && value !== null && !Array.isArray(value);
7+}
8+
9+function setOwn<T>(record: Record<string, T>, key: string, value: T): void {
10+ Object.defineProperty(record, key, {
11+ configurable: true,
12+ enumerable: true,
13+ value,
14+ writable: true
15+ });
16+}
17+
18+function hasOwn(record: object, key: string): boolean {
19+ return Object.prototype.hasOwnProperty.call(record, key);
20+}
21+
22+export function parseDisabledPackImages(raw: unknown): DisabledPackImages {
23+ if (!isRecord(raw) || !isRecord(raw['disabled'])) return {};
24+ const disabled: DisabledPackImages = {};
25+ for (const [packId, rawShortcodes] of Object.entries(raw['disabled'])) {
26+ if (!Array.isArray(rawShortcodes)) continue;
27+ const seen = new Set<string>();
28+ const shortcodes = rawShortcodes.filter((value): value is string => {
29+ if (typeof value !== 'string' || value.length === 0 || seen.has(value)) return false;
30+ seen.add(value);
31+ return true;
32+ });
33+ if (shortcodes.length > 0) setOwn(disabled, packId, shortcodes);
34+ }
35+ return disabled;
36+}
37+
38+export function imageEnabled(disabled: DisabledPackImages, packId: string, shortcode: string): boolean {
39+ return !hasOwn(disabled, packId) || !disabled[packId].includes(shortcode);
40+}
41+
42+export function withImageEnabled(
43+ disabled: DisabledPackImages,
44+ packId: string,
45+ shortcode: string,
46+ enabled: boolean
47+): DisabledPackImages {
48+ const current = hasOwn(disabled, packId) ? disabled[packId] : [];
49+ const nextShortcodes = enabled
50+ ? current.filter((item) => item !== shortcode)
51+ : current.includes(shortcode) ? [...current] : [...current, shortcode];
52+ if (nextShortcodes.length === current.length && nextShortcodes.every((item, index) => item === current[index])) {
53+ return disabled;
54+ }
55+ const next = { ...disabled };
56+ if (nextShortcodes.length === 0) delete next[packId];
57+ else setOwn(next, packId, nextShortcodes);
58+ return next;
59+}
60+
61+export function enabledPacks(packs: ImagePack[], disabled: DisabledPackImages): ImagePack[] {
62+ return packs.map((pack) => ({
63+ ...pack,
64+ images: pack.images.filter((image) => imageEnabled(disabled, pack.id, image.shortcode))
65+ }));
66+}
67+
68+export function enabledQuickReactions(
69+ quick: QuickReaction[],
70+ packs: ImagePack[],
71+ disabled: DisabledPackImages
72+): QuickReaction[] {
73+ const hidden = new Set<string>();
74+ const enabled = new Set<string>();
75+ for (const pack of packs) {
76+ for (const image of pack.images) {
77+ (imageEnabled(disabled, pack.id, image.shortcode) ? enabled : hidden).add(image.reaction_key);
78+ }
79+ }
80+ return quick.filter((reaction) => !hidden.has(reaction.key) || enabled.has(reaction.key));
81+}
Added file: web/src/lib/pack-preferences.ts+61 additions
@@ -0,0 +1,61 @@
1+import { get, writable } from 'svelte/store';
2+import { getAccountData, setAccountData } from './api';
3+import { parseDisabledPackImages, withImageEnabled } from './pack-preferences-model';
4+import type { DisabledPackImages } from './pack-preferences-model';
5+
6+const EVENT_TYPE = 'dev.malice.pack_preferences';
7+
8+export const disabledPackImages = writable<DisabledPackImages>({});
9+
10+let loadGeneration = 0;
11+let accountGeneration = 0;
12+let pendingSaves = 0;
13+let persistedPreferences: DisabledPackImages = {};
14+let saveQueue: Promise<void> = Promise.resolve();
15+
16+export async function loadPackPreferences(): Promise<void> {
17+ if (pendingSaves > 0) return;
18+ const generation = ++loadGeneration;
19+ const epoch = accountGeneration;
20+ try {
21+ const preferences = parseDisabledPackImages(await getAccountData(EVENT_TYPE));
22+ if (generation !== loadGeneration || epoch !== accountGeneration || pendingSaves > 0) return;
23+ persistedPreferences = preferences;
24+ disabledPackImages.set(preferences);
25+ } catch {}
26+}
27+
28+export function setPackImageEnabled(packId: string, shortcode: string, enabled: boolean): Promise<void> {
29+ const previous = get(disabledPackImages);
30+ const next = withImageEnabled(previous, packId, shortcode, enabled);
31+ if (next === previous) return Promise.resolve();
32+
33+ const epoch = accountGeneration;
34+ loadGeneration += 1;
35+ pendingSaves += 1;
36+ disabledPackImages.set(next);
37+
38+ const save = saveQueue.then(async () => {
39+ if (epoch !== accountGeneration) return;
40+ await setAccountData(EVENT_TYPE, { disabled: next });
41+ if (epoch === accountGeneration) persistedPreferences = next;
42+ });
43+ saveQueue = save.catch(() => undefined);
44+ return save.catch((error: unknown) => {
45+ if (epoch === accountGeneration && get(disabledPackImages) === next) {
46+ loadGeneration += 1;
47+ disabledPackImages.set(persistedPreferences);
48+ }
49+ throw error;
50+ }).finally(() => {
51+ if (epoch === accountGeneration) pendingSaves -= 1;
52+ });
53+}
54+
55+export function resetPackPreferences(): void {
56+ accountGeneration += 1;
57+ loadGeneration += 1;
58+ pendingSaves = 0;
59+ persistedPreferences = {};
60+ disabledPackImages.set({});
61+}
Modified file: web/src/lib/packs.ts+86 additions−3 deletions
@@ -1,5 +1,6 @@
1-import type { PackTarget } from './transport';
2-import type { ImagePack, ImagePacks, PackImage, PackUsage } from './types';
1+import type { CommandRequest, PackTarget } from './transport';
2+import type { CloneSource } from './pack-clone';
3+import type { ImagePack, ImagePacks, PackImage, PackUsage, RoomSummary } from './types';
34
45 export interface PackChoice {
56 label: string;
@@ -29,6 +30,37 @@ export function allPacks(packs: ImagePacks): ImagePack[] {
2930 return out;
3031 }
3132
33+export function packsForRoom(
34+ packs: ImagePacks,
35+ roomId: string | null,
36+ rooms: Pick<RoomSummary, 'room_id' | 'parent_spaces' | 'children' | 'space'>[] = []
37+): ImagePack[] {
38+ const seen = new Set<string>();
39+ const out: ImagePack[] = [];
40+ const add = (pack: ImagePack) => {
41+ if (seen.has(pack.id) || pack.images.length === 0) return;
42+ seen.add(pack.id);
43+ out.push(pack);
44+ };
45+ for (const pack of packs.global) if (pack.source === 'User') add(pack);
46+ if (roomId) {
47+ const visited = new Set<string>();
48+ const queue = [roomId];
49+ for (const id of queue) {
50+ if (visited.has(id)) continue;
51+ visited.add(id);
52+ for (const pack of packs.rooms[id] ?? []) add(pack);
53+ const room = rooms.find((entry) => entry.room_id === id);
54+ queue.push(...(room?.parent_spaces ?? []));
55+ for (const space of rooms) {
56+ if (space.space && space.children.includes(id)) queue.push(space.room_id);
57+ }
58+ }
59+ }
60+ for (const pack of packs.global) add(pack);
61+ return out;
62+}
63+
3264 /** Packs you can write to, with your own pack first even before it exists. */
3365 export function editableChoices(packs: ImagePacks, roomName: (roomId: string) => string): PackChoice[] {
3466 const all = allPacks(packs);
@@ -36,7 +68,7 @@ export function editableChoices(packs: ImagePacks, roomName: (roomId: string) =>
3668 const choices: PackChoice[] = [{ label: user?.display_name ?? 'Your pack', target: { kind: 'user' }, pack: user }];
3769 for (const pack of all) {
3870 if (pack.source === 'User' || !pack.editable) continue;
39- choices.push({ label: packLabel(pack, roomName), target: packTarget(pack), pack });
71+ choices.push({ label: `${packLabel(pack, roomName)} · ${roomName(pack.source.Room.room_id)}`, target: packTarget(pack), pack });
4072 }
4173 return choices;
4274 }
@@ -49,6 +81,27 @@ export function stickersOf(pack: ImagePack): PackImage[] {
4981 return pack.images.filter((image) => hasUsage(image, 'Sticker'));
5082 }
5183
84+export function reactionShortcode(key: string, shortcode: string | undefined, packs: ImagePack[]): string | undefined {
85+ if (!key.startsWith('mxc://')) return undefined;
86+ const provided = shortcode?.trim();
87+ if (provided) return provided;
88+ for (const pack of packs) {
89+ const image = pack.images.find((item) => item.reaction_key === key);
90+ if (image) return image.shortcode;
91+ }
92+ return undefined;
93+}
94+
95+export function findSticker(packs: ImagePack[], shortcode: string): { pack: ImagePack; sticker: PackImage } {
96+ const name = shortcode.toLowerCase();
97+ const matches = packs.flatMap((pack) => stickersOf(pack)
98+ .filter((sticker) => sticker.shortcode.toLowerCase() === name)
99+ .map((sticker) => ({ pack, sticker })));
100+ if (matches.length === 0) throw new Error(`No sticker named ${shortcode} is available in this room`);
101+ if (matches.length > 1) throw new Error(`More than one sticker is named ${shortcode}, choose one from the sticker menu`);
102+ return matches[0];
103+}
104+
52105 /** A shortcode from a file name, unique within `taken`. */
53106 export function shortcodeFor(filename: string, taken: Set<string>): string {
54107 const stem = filename.replace(/\.[^.]+$/, '');
@@ -78,6 +131,36 @@ export function packSlug(name: string): string {
78131 );
79132 }
80133
134+export function freePackStateKey(packs: ImagePacks, roomId: string, name: string): string {
135+ const taken = new Set(allPacks(packs).flatMap((pack) =>
136+ pack.source !== 'User' && pack.source.Room.room_id === roomId ? [pack.source.Room.state_key] : []));
137+ const base = packSlug(name);
138+ let key = base;
139+ for (let n = 2; taken.has(key); n += 1) key = `${base}-${n}`;
140+ return key;
141+}
142+
143+export function cloneRequest(
144+ source: CloneSource,
145+ to: PackTarget,
146+ usage: PackUsage[],
147+ displayName: string | null,
148+ destination: ImagePack | null
149+): CommandRequest {
150+ if (usage.length === 0) throw new Error('Choose at least one use for the copied images');
151+ if (source.kind === 'pack') {
152+ return { op: 'clone_pack', from_pack: source.pack.id, to, display_name: displayName, usage };
153+ }
154+ if (source.kind === 'image') {
155+ return { op: 'clone_pack_image', from_pack: source.pack.id, shortcode: source.image.shortcode, to, usage };
156+ }
157+ const taken = new Set(destination?.images.map((image) => image.shortcode) ?? []);
158+ return {
159+ op: 'clone_sticker', room_id: source.room_id, event_id: source.event_id, to,
160+ shortcode: shortcodeFor(source.body || 'sticker', taken), usage
161+ };
162+}
163+
81164 export interface CloneDestination {
82165 label: string;
83166 target: PackTarget;
Modified file: web/src/lib/store.ts+2 additions
@@ -8,6 +8,7 @@ import type { SoftLogout } from './api';
88 import { isRecord } from './guards';
99 import { resetLinkPreviews } from './link-previews';
1010 import { resetQuickReactions } from './quick-reactions';
11+import { resetPackPreferences } from './pack-preferences';
1112 import {
1213 EMPTY_IMAGE_PACKS,
1314 parseEncryptionState,
@@ -97,6 +98,7 @@ export function resetClientState(): void {
9798 banners.set([]);
9899 imagePacks.set(EMPTY_IMAGE_PACKS);
99100 resetQuickReactions();
101+ resetPackPreferences();
100102 resetLinkPreviews();
101103 }
102104
Modified file: web/src/lib/transport.ts+3 additions−3 deletions
@@ -62,9 +62,9 @@ export type CommandRequest =
6262 | { op: 'rename_pack_image'; target: PackTarget; from: string; to: string }
6363 | { op: 'remove_pack_image'; target: PackTarget; shortcode: string }
6464 | { op: 'set_pack_enabled'; room_id: string; state_key: string; enabled: boolean }
65- | { op: 'clone_pack_image'; from_pack: string; shortcode: string; to: PackTarget }
66- | { op: 'clone_sticker'; room_id: string; event_id: string; to: PackTarget; shortcode: string }
67- | { op: 'clone_pack'; from_pack: string; to: PackTarget; display_name: string | null }
65+ | { op: 'clone_pack_image'; from_pack: string; shortcode: string; to: PackTarget; usage?: PackUsage[] }
66+ | { op: 'clone_sticker'; room_id: string; event_id: string; to: PackTarget; shortcode: string; usage?: PackUsage[] }
67+ | { op: 'clone_pack'; from_pack: string; to: PackTarget; display_name: string | null; usage?: PackUsage[] }
6868 | HistoryRepairRequest
6969 | MentionsRequest;
7070
Modified file: web/src/lib/types.ts+1 additions−13 deletions
@@ -818,19 +818,7 @@ export function parseImagePacks(raw: unknown): ImagePacks {
818818 return { global: parsePackList(raw['global']), rooms };
819819 }
820820
821-export function packsForRoom(packs: ImagePacks, roomId: string | null): ImagePack[] {
822- const seen = new Set<string>();
823- const out: ImagePack[] = [];
824- const add = (pack: ImagePack) => {
825- if (seen.has(pack.id) || pack.images.length === 0) return;
826- seen.add(pack.id);
827- out.push(pack);
828- };
829- for (const pack of packs.global) if (pack.source === 'User') add(pack);
830- if (roomId) for (const pack of packs.rooms[roomId] ?? []) add(pack);
831- for (const pack of packs.global) add(pack);
832- return out;
833-}
821+export { packsForRoom } from './packs';
834822
835823 export const MAX_QUICK_REACTIONS = 8;
836824 export interface QuickReaction {
Modified file: web/src/sw.ts+7 additions−10 deletions
@@ -1,6 +1,7 @@
11 import { statusForKind } from './lib/wire-status';
22 import { decodeAttachmentIv, importAttachmentKey, RangeDecryptor } from './lib/media-decrypt';
33 import { attachmentContentType } from './lib/media-sniff';
4+import { createAvatarLoader } from './lib/avatar-cache';
45
56 // Prepended by vite.config.ts's precacheManifestPlugin: every emitted chunk,
67 // asset, and the wasm, as root-relative URLs.
@@ -153,7 +154,7 @@ function mediaResponse(reply: MediaReply | null, rangeHeader: string | null, att
153154 const { body, status, contentRange } = sliceForRange(bytes, rangeHeader);
154155 const headers = new Headers({
155156 'Content-Type': reply.content_type ?? 'application/octet-stream',
156- 'Cache-Control': 'private, max-age=3600',
157+ 'Cache-Control': attachment ? 'private, max-age=3600' : 'private, max-age=31536000, immutable',
157158 'X-Content-Type-Options': 'nosniff'
158159 });
159160 if (attachment) {
@@ -434,14 +435,10 @@ async function cacheFirst(request: Request): Promise<Response> {
434435 }
435436
436437 /** Keyed by mxc alone, so the cached bytes outlive the access token and survive restarts. */
437-async function cachedAvatar(request: Request, mxc: string): Promise<Response> {
438- const cache = await caches.open(AVATAR_CACHE);
439- const cached = await cache.match(request);
440- if (cached) return cached;
441- const response = mediaResponse(await requestMedia('avatar', { mxc }), null, false);
442- if (response.ok) void cache.put(request, response.clone());
443- return response;
444-}
438+const cachedAvatar = createAvatarLoader(
439+ async () => caches.open(AVATAR_CACHE),
440+ async (mxc) => mediaResponse(await requestMedia('avatar', { mxc }), null, false)
441+);
445442
446443 async function networkFirstNavigation(request: Request): Promise<Response> {
447444 try {
@@ -460,7 +457,7 @@ sw.addEventListener('fetch', (event) => {
460457 if (url.pathname === '/api/media') {
461458 const mxc = url.searchParams.get('mxc');
462459 if (mxc) {
463- event.respondWith(cachedAvatar(event.request, mxc));
460+ event.respondWith(cachedAvatar(event.request.url, mxc));
464461 return;
465462 }
466463 }
Added file: web/test/avatar-cache.test.ts+93 additions
@@ -0,0 +1,93 @@
1+import assert from 'node:assert/strict';
2+import { test } from 'node:test';
3+import { createAvatarLoader, type AvatarCache } from '../src/lib/avatar-cache.ts';
4+
5+function memoryCache(): AvatarCache & { entries: Map<string, Response> } {
6+ const entries = new Map<string, Response>();
7+ return {
8+ entries,
9+ async match(key) {
10+ return entries.get(key)?.clone();
11+ },
12+ async put(key, response) {
13+ entries.set(key, response.clone());
14+ }
15+ };
16+}
17+
18+function image(body = 'avatar', status = 200): Response {
19+ return new Response(body, { status, headers: { 'Content-Type': 'image/png' } });
20+}
21+
22+test('avatar cache coalesces simultaneous misses and persists results', async () => {
23+ const store = memoryCache();
24+ let loads = 0;
25+ let release: ((response: Response) => void) | undefined;
26+ let signalLoadStarted: (() => void) | undefined;
27+ const loadStarted = new Promise<void>((resolve) => (signalLoadStarted = resolve));
28+ const loader = createAvatarLoader(async () => store, async () => {
29+ loads += 1;
30+ signalLoadStarted?.();
31+ return new Promise((resolve) => (release = resolve));
32+ });
33+
34+ const first = loader('/api/media?mxc=avatar', 'avatar');
35+ const second = loader('/api/media?mxc=avatar', 'avatar');
36+ await loadStarted;
37+ assert.equal(loads, 1);
38+ release?.(image());
39+ assert.equal(await (await first).text(), 'avatar');
40+ assert.equal(await (await second).text(), 'avatar');
41+
42+ const nextWorker = createAvatarLoader(async () => store, async () => {
43+ loads += 1;
44+ return image('unexpected');
45+ });
46+ assert.equal(await (await nextWorker('/api/media?mxc=avatar', 'avatar')).text(), 'avatar');
47+ assert.equal(loads, 1);
48+});
49+
50+test('avatar cache retries unsuccessful responses', async () => {
51+ const store = memoryCache();
52+ let loads = 0;
53+ const loader = createAvatarLoader(async () => store, async () => ++loads === 1 ? image('missing', 404) : image());
54+ assert.equal((await loader('/avatar', 'avatar')).status, 404);
55+ assert.equal(await (await loader('/avatar', 'avatar')).text(), 'avatar');
56+ assert.equal(loads, 2);
57+});
58+
59+test('avatar cache read failures still return the image', async () => {
60+ let loads = 0;
61+ const cache: AvatarCache = {
62+ async match() {
63+ throw new Error('read failed');
64+ },
65+ async put() {}
66+ };
67+ const loader = createAvatarLoader(async () => cache, async () => {
68+ loads += 1;
69+ return image();
70+ });
71+
72+ assert.equal(await (await loader('/avatar', 'avatar')).text(), 'avatar');
73+ assert.equal(loads, 1);
74+});
75+
76+test('avatar cache write failures still return the image', async () => {
77+ let loads = 0;
78+ const cache: AvatarCache = {
79+ async match() {
80+ return undefined;
81+ },
82+ async put() {
83+ throw new Error('write failed');
84+ }
85+ };
86+ const loader = createAvatarLoader(async () => cache, async () => {
87+ loads += 1;
88+ return image();
89+ });
90+
91+ assert.equal(await (await loader('/avatar', 'avatar')).text(), 'avatar');
92+ assert.equal(loads, 1);
93+});
Modified file: web/test/commands.test.ts+11 additions−1 deletions
@@ -12,10 +12,12 @@ const people = [
1212 test('parses a leading command and leaves text alone', () => {
1313 assert.deepEqual(parseCommand('/kick Bob being rude '), { name: 'kick', args: 'Bob being rude' });
1414 assert.deepEqual(parseCommand('/leave'), { name: 'leave', args: '' });
15+ assert.deepEqual(parseCommand('/sticker party_parrot'), { name: 'sticker', args: 'party_parrot' });
1516 assert.equal(parseCommand('hello /kick'), null);
1617 assert.equal(parseCommand('//kick bob'), null);
1718 assert.equal(unescapeCommand('//kick bob'), '/kick bob');
1819 assert.equal(isKnownCommand('kick'), true);
20+ assert.equal(isKnownCommand('sticker'), true);
1921 assert.equal(isKnownCommand('shrug'), false);
2022 });
2123
@@ -34,5 +36,13 @@ test('a leading slash word opens the command list', async () => {
3436 assert.equal(commandQueryAt('/', 1), '');
3537 assert.equal(commandQueryAt('/kick bob', 9), null);
3638 assert.equal(commandQueryAt('hi /ki', 6), null);
37- assert.deepEqual(matchCommands('un'), ['unban', 'unmute', 'unignore']);
39+ assert.deepEqual(matchCommands('un').slice(0, 3), ['unban', 'unmute', 'unignore']);
40+ assert.equal(matchCommands('in')[0], 'invite');
41+ assert.ok(matchCommands('in').includes('disinvite'));
42+ assert.deepEqual(matchCommands('stk'), ['sticker']);
43+ assert.equal(matchCommands('avatar')[0], 'roomavatar');
44+ assert.deepEqual(matchCommands('STK'), ['sticker']);
45+ assert.deepEqual(matchCommands('zz'), []);
46+ assert.deepEqual(matchCommands('kk'), ['kick']);
47+ assert.deepEqual(matchCommands('kkk'), []);
3848 });
Added file: web/test/pack-preferences.test.ts+67 additions
@@ -0,0 +1,67 @@
1+import assert from 'node:assert/strict';
2+import { test } from 'node:test';
3+import { enabledPacks, enabledQuickReactions, imageEnabled, parseDisabledPackImages, withImageEnabled } from '../src/lib/pack-preferences-model.ts';
4+
5+const image = (shortcode: string) => ({
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: ['Sticker' as const]
14+});
15+
16+const pack = {
17+ id: 'pack-a',
18+ display_name: 'Pack A',
19+ avatar: null,
20+ attribution: 'source',
21+ source: 'User' as const,
22+ images: [image('one'), image('two')],
23+ editable: true,
24+ enabled_everywhere: false
25+};
26+
27+test('disabled image preferences parse only safe nonempty string lists', () => {
28+ const raw = JSON.parse('{"disabled":{"pack-a":["one","one","",2,null],"empty":[],"wrong":"one","__proto__":["hidden"],"constructor":["other"]}}');
29+ const parsed = parseDisabledPackImages(raw);
30+ assert.deepEqual(parsed, JSON.parse('{"pack-a":["one"],"__proto__":["hidden"],"constructor":["other"]}'));
31+ assert.equal(Object.getPrototypeOf(parsed), Object.prototype);
32+ assert.equal(({} as Record<string, unknown>)['hidden'], undefined);
33+ assert.deepEqual(parseDisabledPackImages(null), {});
34+ assert.deepEqual(parseDisabledPackImages({ disabled: [] }), {});
35+});
36+
37+test('image preference updates are immutable and remove empty pack entries', () => {
38+ const original = { 'pack-a': ['one'] };
39+ const disabled = withImageEnabled(original, 'pack-a', 'two', false);
40+ assert.deepEqual(original, { 'pack-a': ['one'] });
41+ assert.deepEqual(disabled, { 'pack-a': ['one', 'two'] });
42+ assert.notEqual(disabled, original);
43+ assert.equal(imageEnabled(disabled, 'pack-a', 'two'), false);
44+
45+ const enabled = withImageEnabled(disabled, 'pack-a', 'two', true);
46+ const cleared = withImageEnabled(enabled, 'pack-a', 'one', true);
47+ assert.deepEqual(cleared, {});
48+ assert.equal(imageEnabled(cleared, 'pack-a', 'one'), true);
49+});
50+
51+test('enabled packs filter hidden images while preserving metadata and source input', () => {
52+ const disabled = { 'pack-a': ['one'] };
53+ const source = [pack];
54+ const result = enabledPacks(source, disabled);
55+ assert.deepEqual(result[0], { ...pack, images: [image('two')] });
56+ assert.notEqual(result, source);
57+ assert.notEqual(result[0], pack);
58+ assert.notEqual(result[0].images, pack.images);
59+ assert.deepEqual(pack.images.map((item) => item.shortcode), ['one', 'two']);
60+});
61+
62+test('personal hiding prunes quick reactions while preserving enabled copies and unrelated keys', () => {
63+ const quick = [{ key: 'mxc://x/one', shortcode: 'one' }, { key: '👍', shortcode: 'thumbsup' }];
64+ assert.deepEqual(enabledQuickReactions(quick, [pack], { 'pack-a': ['one'] }), [quick[1]]);
65+ assert.deepEqual(enabledQuickReactions(quick, [pack, { ...pack, id: 'pack-b' }], { 'pack-a': ['one'] }), quick);
66+ assert.deepEqual(enabledQuickReactions(quick, [], {}), quick);
67+});
Modified file: web/test/packs.test.ts+57 additions−1 deletions
@@ -1,6 +1,6 @@
11 import assert from 'node:assert/strict';
22 import { test } from 'node:test';
3-import { clonePackDestinations, editableChoices, packSlug, shortcodeFor, stickersOf } from '../src/lib/packs.ts';
3+import { clonePackDestinations, cloneRequest, editableChoices, findSticker, freePackStateKey, packsForRoom, packSlug, reactionShortcode, shortcodeFor, stickersOf } from '../src/lib/packs.ts';
44
55 const image = (shortcode: string, usage: ('Emoticon' | 'Sticker')[]) => ({
66 shortcode,
@@ -36,6 +36,23 @@ test('stickers include images with no usage of their own', () => {
3636 assert.deepEqual(stickersOf(roomPack('s', true)).map((i) => i.shortcode), ['a', 'c']);
3737 });
3838
39+test('custom reaction nicknames fall back to known packs without changing the reaction key', () => {
40+ const packs = [roomPack('mine', true)];
41+ assert.equal(reactionShortcode('mxc://x/a', undefined, packs), 'a');
42+ assert.equal(reactionShortcode('mxc://x/a', ' preferred ', packs), 'preferred');
43+ assert.equal(reactionShortcode('mxc://x/unknown', undefined, packs), undefined);
44+ assert.equal(reactionShortcode('👍', 'thumbsup', packs), undefined);
45+});
46+
47+test('sticker commands resolve names without matching emoticons or choosing ambiguous stickers', () => {
48+ const pack = roomPack('s', true);
49+ assert.equal(findSticker([pack], 'A').sticker.shortcode, 'a');
50+ assert.equal(findSticker([pack], 'c').pack, pack);
51+ assert.throws(() => findSticker([pack], 'b'), /No sticker named b/);
52+ assert.throws(() => findSticker([pack], 'missing'), /No sticker named missing/);
53+ assert.throws(() => findSticker([pack, roomPack('other', false)], 'a'), /More than one sticker/);
54+});
55+
3956 test('your pack is always a destination, then editable room packs only', () => {
4057 const packs = { global: [], rooms: { '!r:x': [roomPack('mine', true), roomPack('theirs', false)] } };
4158 const choices = editableChoices(packs, () => 'Room');
@@ -43,3 +60,42 @@ test('your pack is always a destination, then editable room packs only', () => {
4360 const dests = clonePackDestinations(roomPack('Fancy Pack', false), [{ room_id: '!d:x', name: 'Dest' }], () => 'Room');
4461 assert.deepEqual(dests[1].target, { kind: 'room', room_id: '!d:x', state_key: 'fancy-pack' });
4562 });
63+
64+test('new pack names avoid existing keys without changing merge destinations', () => {
65+ const packs = { global: [], rooms: { '!r:x': [roomPack('cats', true), roomPack('cats-2', true)] } };
66+ assert.equal(freePackStateKey(packs, '!r:x', 'Cats'), 'cats-3');
67+ assert.equal(freePackStateKey(packs, '!other:x', 'Cats'), 'cats');
68+ const destination = editableChoices(packs, () => 'Room')[1];
69+ const request = cloneRequest({ kind: 'pack', pack: roomPack('source', false) }, destination.target, ['Sticker', 'Emoticon'], null, destination.pack);
70+ assert.deepEqual(request, { op: 'clone_pack', from_pack: 'room:!r:x:source', to: destination.target, display_name: null, usage: ['Sticker', 'Emoticon'] });
71+});
72+
73+test('image and event clones keep explicit usage and event names avoid collisions', () => {
74+ const pack = roomPack('source', false);
75+ const to = { kind: 'user' as const };
76+ assert.deepEqual(cloneRequest({ kind: 'image', pack, image: pack.images[1] }, to, ['Sticker'], null, null), {
77+ op: 'clone_pack_image', from_pack: pack.id, shortcode: 'b', to, usage: ['Sticker']
78+ });
79+ const source = { kind: 'sticker' as const, room_id: '!r:x', event_id: '$event', body: 'a' };
80+ assert.deepEqual(cloneRequest(source, to, ['Sticker', 'Emoticon'], null, pack), {
81+ op: 'clone_sticker', room_id: '!r:x', event_id: '$event', shortcode: 'a_2', to, usage: ['Sticker', 'Emoticon']
82+ });
83+ assert.throws(() => cloneRequest(source, to, [], null, pack), /at least one use/);
84+});
85+
86+test('channel packs include all ancestor spaces once and do not cross to unrelated spaces', () => {
87+ const local = roomPack('local', false);
88+ const parent = { ...roomPack('parent', false), id: 'space-pack' };
89+ const ancestor = { ...roomPack('ancestor', false), id: 'ancestor-pack' };
90+ const unrelated = { ...roomPack('unrelated', false), id: 'unrelated-pack' };
91+ const global = { ...roomPack('global', false), id: 'global-pack' };
92+ const packs = { global: [global, parent], rooms: { '!r:x': [local], '!s:x': [parent], '!a:x': [ancestor], '!u:x': [unrelated] } };
93+ const rooms = [
94+ { room_id: '!r:x', parent_spaces: ['!s:x'], children: [], space: false },
95+ { room_id: '!s:x', parent_spaces: ['!a:x'], children: ['!r:x'], space: true },
96+ { room_id: '!a:x', parent_spaces: ['!s:x'], children: ['!s:x'], space: true },
97+ { room_id: '!u:x', parent_spaces: [], children: [], space: true }
98+ ];
99+ assert.deepEqual(packsForRoom(packs, '!r:x', rooms).map((pack) => pack.id), [local.id, parent.id, ancestor.id, global.id]);
100+ assert.deepEqual(packsForRoom(packs, '!r:x', [{ ...rooms[0], parent_spaces: [] }, ...rooms.slice(1)]).map((pack) => pack.id), [local.id, parent.id, ancestor.id, global.id]);
101+});