Commit 132eeceeb362
tarball28 files changed+1135-146
@@ -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",@@ -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"@@ -0,0 +1,69 @@
1
+# Macltok timeline repair handoff2
+3
+## Publication status4
+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 API12
+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
+```json20
+{21
+ "Start": {22
+ "room_id": "!room:example.org",23
+ "policy": {24
+ "BridgeShortGaps": {25
+ "threshold_ms": 1500026
+ }27
+ },28
+ "budget": {29
+ "max_pages": 10,30
+ "max_events": 1000,31
+ "max_duration_ms": 1500032
+ }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 publication42
+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 limits46
+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 limitation56
+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 work60
+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
+## Verification66
+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.@@ -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 = handle1140
- .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 = handle1155
- .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 = handle1170
- .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
}@@ -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",@@ -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"] }@@ -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'}@@ -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>@@ -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: false48
+ };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
+};@@ -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: null162
+ }),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 me328
+ </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 everywhere351
+ </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 me362
+ </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
+ <PackCloneDialog375
+ 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>@@ -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
}@@ -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)}@@ -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
+}@@ -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
}@@ -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
}@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');79
}@@ -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
+@@ -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: true15
+ });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: boolean47
+): DisabledPackImages {48
+ const current = hasOwn(disabled, packId) ? disabled[packId] : [];49
+ const nextShortcodes = enabled50
+ ? 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: DisabledPackImages72
+): 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
+}@@ -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
+}@@ -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 | null149
+): 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), usage161
+ };162
+}163
+81164
export interface CloneDestination {82165
label: string;83166
target: PackTarget;@@ -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
@@ -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
| HistoryRepairRequest6969
| MentionsRequest;7070
@@ -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 {@@ -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
}@@ -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
+});@@ -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
});@@ -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: false25
+};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
+});@@ -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
+});