atagen

malice

public
Star0
Watch0

Commit ecb8598b38af

tarball

atagen <boss@atagen.co>2026-10-22 13:19 UTC

46 files changed+1918-403
vendor: update macltok and wire image packs, emote reactions and link previews

macltok now calls fork-only matrix-sdk APIs, and cargo patches do not reach dependents, so both workspaces carry the [patch.crates-io] block pointing at the sibling ~/code/matrix-rust-sdk checkout. A sender's bundled MSC4095 previews always show, while homeserver previews are fetched only outside encrypted rooms unless dev.malice.url_previews opts in, since the homeserver sees every url asked about. A stored OAuthPending record restores as no session because malice only does password login.
Modified file: Cargo.lock+1 additions−20 deletions
@@ -1945,6 +1945,7 @@ dependencies = [
19451945 "imbl",
19461946 "js-sys",
19471947 "matrix-sdk",
1948+ "matrix-sdk-base",
19481949 "matrix-sdk-crypto",
19491950 "matrix-sdk-store-encryption",
19501951 "matrix-sdk-test",
@@ -2171,8 +2172,6 @@ dependencies = [
21712172 [[package]]
21722173 name = "matrix-sdk"
21732174 version = "0.18.0"
2174-source = "registry+https://github.com/rust-lang/crates.io-index"
2175-checksum = "7083d580527511ac5d9369e03b9f2b20902e76949f1b3964051f978e4d3756ae"
21762175 dependencies = [
21772176 "anymap2",
21782177 "aquamarine",
@@ -2235,8 +2234,6 @@ dependencies = [
22352234 [[package]]
22362235 name = "matrix-sdk-base"
22372236 version = "0.18.0"
2238-source = "registry+https://github.com/rust-lang/crates.io-index"
2239-checksum = "e09a917eb1f7643d9d9a06b2f131e2ceb39df6910a7b830dac18bfd6db37a1f5"
22402237 dependencies = [
22412238 "as_variant",
22422239 "assert_matches",
@@ -2266,8 +2263,6 @@ dependencies = [
22662263 [[package]]
22672264 name = "matrix-sdk-common"
22682265 version = "0.18.0"
2269-source = "registry+https://github.com/rust-lang/crates.io-index"
2270-checksum = "2b9d1e0fee0f090180ef9457034adc547e80de9e6e3eb2e63aaac68a8476f836"
22712266 dependencies = [
22722267 "eyeball-im",
22732268 "futures-core",
@@ -2290,8 +2285,6 @@ dependencies = [
22902285 [[package]]
22912286 name = "matrix-sdk-crypto"
22922287 version = "0.18.0"
2293-source = "registry+https://github.com/rust-lang/crates.io-index"
2294-checksum = "c54afd2a326f51c13a6ad44ec86315a688fffeb3f1e287fb343e0e1836a3bdaf"
22952288 dependencies = [
22962289 "aes",
22972290 "aquamarine",
@@ -2332,8 +2325,6 @@ dependencies = [
23322325 [[package]]
23332326 name = "matrix-sdk-indexeddb"
23342327 version = "0.18.0"
2335-source = "registry+https://github.com/rust-lang/crates.io-index"
2336-checksum = "fef37395fffb7c916f7109ab0d16d8ca599403dd8164d08c0d966176b66ede47"
23372328 dependencies = [
23382329 "async-trait",
23392330 "base64 0.22.1",
@@ -2365,8 +2356,6 @@ dependencies = [
23652356 [[package]]
23662357 name = "matrix-sdk-sqlite"
23672358 version = "0.18.0"
2368-source = "registry+https://github.com/rust-lang/crates.io-index"
2369-checksum = "a49133429271005745f8d5a05362d2ba6567274777250236799927ca30dc0670"
23702359 dependencies = [
23712360 "as_variant",
23722361 "async-trait",
@@ -2393,8 +2382,6 @@ dependencies = [
23932382 [[package]]
23942383 name = "matrix-sdk-store-encryption"
23952384 version = "0.18.0"
2396-source = "registry+https://github.com/rust-lang/crates.io-index"
2397-checksum = "2f48f304e553fb6200b1d7d1f77a88fd182076d4b25624e9dbfa42d6a37de35e"
23982385 dependencies = [
23992386 "base64 0.22.1",
24002387 "blake3",
@@ -2415,8 +2402,6 @@ dependencies = [
24152402 [[package]]
24162403 name = "matrix-sdk-test"
24172404 version = "0.18.0"
2418-source = "registry+https://github.com/rust-lang/crates.io-index"
2419-checksum = "8023d4315fff35598c2d4a955920c1273c6aaab4dcd9ed405238a882581649bf"
24202405 dependencies = [
24212406 "as_variant",
24222407 "ctor",
@@ -2439,8 +2424,6 @@ dependencies = [
24392424 [[package]]
24402425 name = "matrix-sdk-test-macros"
24412426 version = "0.18.0"
2442-source = "registry+https://github.com/rust-lang/crates.io-index"
2443-checksum = "d97d6a0e5b1401c530b3c5d18cb98257d255d396bacf3903beda6718a2f4c4c5"
24442427 dependencies = [
24452428 "quote",
24462429 "syn 2.0.119",
@@ -2449,8 +2432,6 @@ dependencies = [
24492432 [[package]]
24502433 name = "matrix-sdk-ui"
24512434 version = "0.18.0"
2452-source = "registry+https://github.com/rust-lang/crates.io-index"
2453-checksum = "6d1f2b8a8cf57164d7cee4eb61c0b9f074b2e11d766a9391b7db0b9f2cbd83d8"
24542435 dependencies = [
24552436 "as_variant",
24562437 "async-rx",
Modified file: Cargo.toml+12 additions
@@ -13,3 +13,15 @@ http = "1"
1313
1414 [workspace.lints.rust]
1515 unsafe_code = "forbid"
16+
17+[patch.crates-io]
18+matrix-sdk = { path = "../matrix-rust-sdk/crates/matrix-sdk" }
19+matrix-sdk-base = { path = "../matrix-rust-sdk/crates/matrix-sdk-base" }
20+matrix-sdk-common = { path = "../matrix-rust-sdk/crates/matrix-sdk-common" }
21+matrix-sdk-crypto = { path = "../matrix-rust-sdk/crates/matrix-sdk-crypto" }
22+matrix-sdk-indexeddb = { path = "../matrix-rust-sdk/crates/matrix-sdk-indexeddb" }
23+matrix-sdk-sqlite = { path = "../matrix-rust-sdk/crates/matrix-sdk-sqlite" }
24+matrix-sdk-store-encryption = { path = "../matrix-rust-sdk/crates/matrix-sdk-store-encryption" }
25+matrix-sdk-test = { path = "../matrix-rust-sdk/testing/matrix-sdk-test" }
26+matrix-sdk-test-macros = { path = "../matrix-rust-sdk/testing/matrix-sdk-test-macros" }
27+matrix-sdk-ui = { path = "../matrix-rust-sdk/crates/matrix-sdk-ui" }
Modified file: README.md+4 additions−2 deletions
@@ -61,8 +61,10 @@ network address used at sign-in, not a space. Sending a message returns a queued
6161 command, then the timeline shows whether delivery is queued, sending, confirmed
6262 by the server, or failed. Opening a room loads its timeline; scrolling up loads
6363 older history. Images and avatars are served through authenticated, bounded
64-media endpoints; SVG content is not rendered. Link previews use URL text only
65-and do not fetch third-party pages.
64+media endpoints; SVG content is not rendered. Link previews embedded by the
65+sender always show. Otherwise the homeserver fetches previews in unencrypted
66+rooms, and in encrypted rooms only when the user enables it in the Security
67+panel. Room admins can disable previews per room.
6668
6769 The Security panel accepts incoming device-verification requests and supports
6870 emoji/number comparison, confirmation, rejection, and recovery-key entry.
Modified file: crates/malice-core/src/error.rs+1 additions−1 deletions
@@ -50,7 +50,7 @@ impl From<macltok::Error> for CoreError {
5050 "the sync engine stopped; sign in again",
5151 );
5252 }
53- ErrorKind::QueueFull | ErrorKind::RoomBusy => Failure::Busy,
53+ ErrorKind::QueueFull | ErrorKind::RoomBusy | ErrorKind::RateLimited => Failure::Busy,
5454 ErrorKind::RoomUnknown | ErrorKind::MediaUnknown | ErrorKind::MediaUnavailable => {
5555 Failure::NotFound
5656 }
Modified file: crates/malice-core/src/feed.rs+11 additions−2 deletions
@@ -2,8 +2,9 @@ use std::collections::HashMap;
22
33 use futures_util::{Stream, StreamExt, stream};
44 use macltok::{
5- ConnectionState, EncryptionState, MirrorHandle, RoomSummary, SessionStatus, State, StateChange,
6- TimelineChange, TimelineId, TimelineItem, UploadOutcome, VerificationState, imbl::Vector,
5+ ConnectionState, EncryptionState, ImagePacks, MirrorHandle, RoomSummary, SessionStatus, State,
6+ StateChange, TimelineChange, TimelineId, TimelineItem, UploadOutcome, VerificationState,
7+ imbl::Vector,
78 };
89 use serde::Serialize;
910
@@ -16,6 +17,10 @@ pub enum WireEvent {
1617 connection: String,
1718 encryption: Option<EncryptionState>,
1819 verification: Option<VerificationState>,
20+ image_packs: ImagePacks,
21+ },
22+ ImagePacks {
23+ packs: ImagePacks,
1924 },
2025 Rooms {
2126 rooms: Vec<RoomSummary>,
@@ -85,6 +90,7 @@ pub fn snapshot_event(state: &State) -> WireEvent {
8590 connection: connection_label(&state.connection),
8691 encryption: state.encryption.clone(),
8792 verification: state.verification.clone(),
93+ image_packs: state.image_packs.clone(),
8894 }
8995 }
9096
@@ -133,6 +139,9 @@ pub fn wire_events(change: &StateChange) -> Vec<WireEvent> {
133139 StateChange::Verification(state) => vec![WireEvent::Verification {
134140 state: state.clone(),
135141 }],
142+ StateChange::ImagePacks(packs) => vec![WireEvent::ImagePacks {
143+ packs: packs.clone(),
144+ }],
136145 StateChange::Failed(failure) => vec![error(&failure.message)],
137146 StateChange::SessionSaveFailed(_) => {
138147 vec![error(
Modified file: crates/malice-core/src/request.rs+54 additions−1 deletions
@@ -66,6 +66,8 @@ pub enum Request {
6666 event_id: String,
6767 #[serde(default)]
6868 key: String,
69+ #[serde(default)]
70+ shortcode: Option<String>,
6971 },
7072 Read {
7173 #[serde(default)]
@@ -106,6 +108,12 @@ pub enum Request {
106108 event_type: String,
107109 content: serde_json::Value,
108110 },
111+ UrlPreview {
112+ #[serde(default)]
113+ room_id: String,
114+ #[serde(default)]
115+ url: String,
116+ },
109117 }
110118
111119 impl Request {
@@ -227,6 +235,7 @@ pub async fn dispatch(
227235 room_id,
228236 event_id,
229237 key,
238+ shortcode,
230239 } => {
231240 let room_id = parse_room_id(&room_id)?;
232241 let event_id = parse_event_id(&event_id)?;
@@ -236,8 +245,13 @@ pub async fn dispatch(
236245 "key must be 1..32 characters, or an mxc uri up to 255",
237246 ));
238247 }
248+ let shortcode = match shortcode.as_deref().map(str::trim) {
249+ Some("") | None => None,
250+ Some(code) if code.chars().count() <= 100 => Some(code.to_owned()),
251+ Some(_) => return Err(CoreError::bad_request("shortcode must be up to 100 characters")),
252+ };
239253 handle
240- .toggle_reaction(TimelineId::Room(room_id), event_id, key)
254+ .toggle_reaction(TimelineId::Room(room_id), event_id, key, shortcode)
241255 .await?;
242256 Ok(Reply::Done)
243257 }
@@ -362,6 +376,17 @@ pub async fn dispatch(
362376 .await?;
363377 Ok(Reply::Done)
364378 }
379+ Request::UrlPreview { room_id, url } => {
380+ let room_id = parse_room_id(&room_id)?;
381+ let url = preview_url(&url)?;
382+ if !handle.url_previews_allowed(&room_id).await? {
383+ return Ok(Reply::Json(serde_json::Value::Null));
384+ }
385+ let preview = handle.url_preview(url).await?;
386+ let value = serde_json::to_value(preview)
387+ .map_err(|_| CoreError::bad_request("preview is not valid json"))?;
388+ Ok(Reply::Json(value))
389+ }
365390 }
366391 }
367392
@@ -386,11 +411,39 @@ fn account_data_type(event_type: &str) -> Result<GlobalAccountDataEventType, Cor
386411 Ok(GlobalAccountDataEventType::from(event_type))
387412 }
388413
414+const MAX_PREVIEW_URL_BYTES: usize = 2048;
415+
416+fn preview_url(url: &str) -> Result<&str, CoreError> {
417+ let host = url
418+ .strip_prefix("https://")
419+ .or_else(|| url.strip_prefix("http://"))
420+ .and_then(|rest| rest.split(['/', '?', '#']).next())
421+ .filter(|host| !host.is_empty());
422+ if url.len() > MAX_PREVIEW_URL_BYTES
423+ || host.is_none()
424+ || url.chars().any(|c| c.is_whitespace() || c.is_control())
425+ {
426+ return Err(CoreError::bad_request("url must be a short http(s) url"));
427+ }
428+ Ok(url)
429+}
430+
389431 #[cfg(test)]
390432 mod tests {
391433 use super::*;
392434
393435 #[test]
436+ fn preview_urls() {
437+ assert!(preview_url("https://example.org/a?b=c").is_ok());
438+ assert!(preview_url("http://example.org").is_ok());
439+ assert!(preview_url("ftp://example.org").is_err());
440+ assert!(preview_url("https://").is_err());
441+ assert!(preview_url("https:///path").is_err());
442+ assert!(preview_url("https://exa mple.org").is_err());
443+ assert!(preview_url(&format!("https://e.org/{}", "a".repeat(2048))).is_err());
444+ }
445+
446+ #[test]
394447 fn reaction_keys() {
395448 assert!(valid_reaction_key("👍"));
396449 assert!(!valid_reaction_key(""));
Modified file: crates/malice-server/src/routes.rs+34 additions
@@ -69,6 +69,8 @@ struct ReactBody {
6969 event_id: String,
7070 #[serde(default)]
7171 key: String,
72+ #[serde(default)]
73+ shortcode: Option<String>,
7274 }
7375
7476 #[derive(Debug, Deserialize)]
@@ -118,6 +120,14 @@ struct AccountDataQuery {
118120 }
119121
120122 #[derive(Debug, Deserialize)]
123+struct UrlPreviewQuery {
124+ #[serde(default)]
125+ room_id: String,
126+ #[serde(default)]
127+ url: String,
128+}
129+
130+#[derive(Debug, Deserialize)]
121131 struct AccountDataBody {
122132 event_type: String,
123133 content: serde_json::Value,
@@ -538,6 +548,7 @@ async fn rooms_react(
538548 room_id: body.0.room_id,
539549 event_id: body.0.event_id,
540550 key: body.0.key,
551+ shortcode: body.0.shortcode,
541552 },
542553 )
543554 .await,
@@ -738,6 +749,28 @@ async fn get_account_data(
738749 )
739750 }
740751
752+async fn get_url_preview(
753+ State(state): State<AppState>,
754+ headers: HeaderMap,
755+ Query(query): Query<UrlPreviewQuery>,
756+) -> Response {
757+ let (user, _) = match auth(&state, &headers).await {
758+ Ok(pair) => pair,
759+ Err(error) => return error.into_response(),
760+ };
761+ respond(
762+ dispatch(
763+ &user.handle,
764+ &user.mirror,
765+ Request::UrlPreview {
766+ room_id: query.room_id,
767+ url: query.url,
768+ },
769+ )
770+ .await,
771+ )
772+}
773+
741774 async fn set_account_data(
742775 State(state): State<AppState>,
743776 headers: HeaderMap,
@@ -811,6 +844,7 @@ pub fn router(state: AppState, web_dir: Option<String>) -> Router {
811844 "/account-data",
812845 get(get_account_data).post(set_account_data),
813846 )
847+ .route("/url-preview", get(get_url_preview))
814848 .route("/profile", get(get_profile))
815849 .route("/media", get(get_media))
816850 .route("/message-media", get(get_message_media))
Modified file: crates/malice-server/src/state.rs+1 additions−1 deletions
@@ -154,7 +154,7 @@ impl AppState {
154154 restore_error(error)
155155 })?;
156156 match restore {
157- None => Err(RestoreError::NotFound),
157+ None | Some(Restore::OAuthPending) => Err(RestoreError::NotFound),
158158 Some(Restore::SoftLoggedOut(soft)) => Err(RestoreError::SoftLoggedOut {
159159 user_id: soft.user_id.to_string(),
160160 homeserver: soft.homeserver,
Modified file: crates/malice-worker/Cargo.lock+9 additions−16 deletions
@@ -1731,6 +1731,7 @@ dependencies = [
17311731 "imbl",
17321732 "js-sys",
17331733 "matrix-sdk",
1734+ "matrix-sdk-base",
17341735 "matrix-sdk-crypto",
17351736 "matrix-sdk-store-encryption",
17361737 "matrix-sdk-ui",
@@ -1925,8 +1926,6 @@ dependencies = [
19251926 [[package]]
19261927 name = "matrix-sdk"
19271928 version = "0.18.0"
1928-source = "registry+https://github.com/rust-lang/crates.io-index"
1929-checksum = "7083d580527511ac5d9369e03b9f2b20902e76949f1b3964051f978e4d3756ae"
19301929 dependencies = [
19311930 "anymap2",
19321931 "aquamarine",
@@ -1986,8 +1985,6 @@ dependencies = [
19861985 [[package]]
19871986 name = "matrix-sdk-base"
19881987 version = "0.18.0"
1989-source = "registry+https://github.com/rust-lang/crates.io-index"
1990-checksum = "e09a917eb1f7643d9d9a06b2f131e2ceb39df6910a7b830dac18bfd6db37a1f5"
19911988 dependencies = [
19921989 "as_variant",
19931990 "async-trait",
@@ -2013,8 +2010,6 @@ dependencies = [
20132010 [[package]]
20142011 name = "matrix-sdk-common"
20152012 version = "0.18.0"
2016-source = "registry+https://github.com/rust-lang/crates.io-index"
2017-checksum = "2b9d1e0fee0f090180ef9457034adc547e80de9e6e3eb2e63aaac68a8476f836"
20182013 dependencies = [
20192014 "eyeball-im",
20202015 "futures-core",
@@ -2037,8 +2032,6 @@ dependencies = [
20372032 [[package]]
20382033 name = "matrix-sdk-crypto"
20392034 version = "0.18.0"
2040-source = "registry+https://github.com/rust-lang/crates.io-index"
2041-checksum = "c54afd2a326f51c13a6ad44ec86315a688fffeb3f1e287fb343e0e1836a3bdaf"
20422035 dependencies = [
20432036 "aes",
20442037 "aquamarine",
@@ -2078,8 +2071,6 @@ dependencies = [
20782071 [[package]]
20792072 name = "matrix-sdk-indexeddb"
20802073 version = "0.18.0"
2081-source = "registry+https://github.com/rust-lang/crates.io-index"
2082-checksum = "fef37395fffb7c916f7109ab0d16d8ca599403dd8164d08c0d966176b66ede47"
20832074 dependencies = [
20842075 "async-trait",
20852076 "base64 0.22.1",
@@ -2111,8 +2102,6 @@ dependencies = [
21112102 [[package]]
21122103 name = "matrix-sdk-sqlite"
21132104 version = "0.18.0"
2114-source = "registry+https://github.com/rust-lang/crates.io-index"
2115-checksum = "a49133429271005745f8d5a05362d2ba6567274777250236799927ca30dc0670"
21162105 dependencies = [
21172106 "as_variant",
21182107 "async-trait",
@@ -2139,8 +2128,6 @@ dependencies = [
21392128 [[package]]
21402129 name = "matrix-sdk-store-encryption"
21412130 version = "0.18.0"
2142-source = "registry+https://github.com/rust-lang/crates.io-index"
2143-checksum = "2f48f304e553fb6200b1d7d1f77a88fd182076d4b25624e9dbfa42d6a37de35e"
21442131 dependencies = [
21452132 "base64 0.22.1",
21462133 "blake3",
@@ -2161,8 +2148,6 @@ dependencies = [
21612148 [[package]]
21622149 name = "matrix-sdk-ui"
21632150 version = "0.18.0"
2164-source = "registry+https://github.com/rust-lang/crates.io-index"
2165-checksum = "6d1f2b8a8cf57164d7cee4eb61c0b9f074b2e11d766a9391b7db0b9f2cbd83d8"
21662151 dependencies = [
21672152 "as_variant",
21682153 "async-rx",
@@ -4528,3 +4513,11 @@ name = "zmij"
45284513 version = "1.0.23"
45294514 source = "registry+https://github.com/rust-lang/crates.io-index"
45304515 checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
4516+
4517+[[patch.unused]]
4518+name = "matrix-sdk-test"
4519+version = "0.18.0"
4520+
4521+[[patch.unused]]
4522+name = "matrix-sdk-test-macros"
4523+version = "0.18.0"
Modified file: crates/malice-worker/Cargo.toml+12 additions
@@ -45,3 +45,15 @@ lto = true
4545 codegen-units = 1
4646 panic = "abort"
4747 strip = true
48+
49+[patch.crates-io]
50+matrix-sdk = { path = "../../../matrix-rust-sdk/crates/matrix-sdk" }
51+matrix-sdk-base = { path = "../../../matrix-rust-sdk/crates/matrix-sdk-base" }
52+matrix-sdk-common = { path = "../../../matrix-rust-sdk/crates/matrix-sdk-common" }
53+matrix-sdk-crypto = { path = "../../../matrix-rust-sdk/crates/matrix-sdk-crypto" }
54+matrix-sdk-indexeddb = { path = "../../../matrix-rust-sdk/crates/matrix-sdk-indexeddb" }
55+matrix-sdk-sqlite = { path = "../../../matrix-rust-sdk/crates/matrix-sdk-sqlite" }
56+matrix-sdk-store-encryption = { path = "../../../matrix-rust-sdk/crates/matrix-sdk-store-encryption" }
57+matrix-sdk-test = { path = "../../../matrix-rust-sdk/testing/matrix-sdk-test" }
58+matrix-sdk-test-macros = { path = "../../../matrix-rust-sdk/testing/matrix-sdk-test-macros" }
59+matrix-sdk-ui = { path = "../../../matrix-rust-sdk/crates/matrix-sdk-ui" }
Modified file: crates/malice-worker/src/lib.rs+1 additions−1 deletions
@@ -79,7 +79,7 @@ impl Host {
7979 StoreConfig::indexeddb(store_name(&account), passphrase),
8080 ));
8181 match Session::restore(sessions.clone()).await {
82- Ok(None) => Ok(to_js_value(&serde_json::json!({ "status": "none" }))),
82+ Ok(None | Some(Restore::OAuthPending)) => Ok(to_js_value(&serde_json::json!({ "status": "none" }))),
8383 Ok(Some(Restore::SoftLoggedOut(soft))) => Ok(to_js_value(&serde_json::json!({
8484 "status": "soft_logged_out",
8585 "user_id": soft.user_id.to_string(),
Modified file: vendor/macltok/Cargo.toml+2 additions−1 deletions
@@ -27,10 +27,11 @@ futures-util = { version = "0.3.31", default-features = false, features = ["std"
2727 http = "1"
2828 imbl = { version = "6.1.0", default-features = false }
2929 matrix-sdk = { version = "0.18.0", default-features = false, features = ["e2e-encryption"] }
30+matrix-sdk-base = { version = "0.18.0", default-features = false }
3031 matrix-sdk-crypto = { version = "0.18.0", default-features = false }
3132 matrix-sdk-store-encryption = { version = "0.18.0", optional = true }
3233 matrix-sdk-ui = { version = "0.18.0", default-features = false }
33-ruma = { version = "0.16.0", default-features = false }
34+ruma = { version = "0.16.0", default-features = false, features = ["unstable-msc2545", "unstable-msc4095"] }
3435 serde = { version = "1.0.219", features = ["derive", "rc"], optional = true }
3536 thiserror = "2.0.16"
3637 mime = "0.3.17"
Modified file: vendor/macltok/examples/headless.rs+1 additions−1 deletions
@@ -23,7 +23,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
2323 )
2424 .await?
2525 }
26- None => {
26+ None | Some(Restore::OAuthPending) => {
2727 Session::login_password(
2828 PasswordLogin::new(
2929 required("MATRIX_HOMESERVER")?,
Modified file: vendor/macltok/src/account.rs+198 additions−11 deletions
@@ -11,13 +11,14 @@ use matrix_sdk::{
1111 AuthSession,
1212 matrix::MatrixSession,
1313 oauth::{
14- ClientId, ClientRegistrationData, CsrfToken, OAuthSession, UserSession,
14+ ClientId, ClientRegistrationData, CsrfToken, OAuthSession, PendingAuthorization,
15+ UserSession,
1516 registration::{ApplicationType, ClientMetadata, Localized, OAuthGrantType},
1617 },
1718 },
1819 cross_process_lock::CrossProcessLockConfig,
1920 encryption::{BackupDownloadStrategy, EncryptionSettings},
20- ruma::{OwnedDeviceId, OwnedUserId, serde::Raw},
21+ ruma::{MilliSecondsSinceUnixEpoch, OwnedDeviceId, OwnedUserId, serde::Raw},
2122 };
2223 use serde::{Deserialize, Serialize};
2324 use thiserror::Error;
@@ -167,6 +168,7 @@ pub struct PendingOAuthLogin {
167168 tracking: Option<(Tracking, StoreLock)>,
168169 authorization_url: String,
169170 state: Option<CsrfToken>,
171+ previous: Option<SessionRecord>,
170172 }
171173
172174 impl PendingOAuthLogin {
@@ -200,6 +202,9 @@ impl PendingOAuthLogin {
200202 let oauth = self.client.oauth();
201203 if let Err(error) = oauth.finish_login(callback_url.into()).await {
202204 oauth.abort_login(&state).await;
205+ if let Some(previous) = self.previous.take() {
206+ put_back(self.sessions.as_ref(), previous).await;
207+ }
203208 return Err(account_error("OAuth login", error));
204209 }
205210 let (tracking, store_lock) = self
@@ -215,10 +220,36 @@ impl PendingOAuthLogin {
215220 .await
216221 }
217222
218- pub async fn abort(mut self) {
219- if let Some(state) = self.state.take() {
220- self.client.oauth().abort_login(&state).await;
223+ /// puts back what the store held before, or deletes the store if it held nothing
224+ pub async fn abort(self) {
225+ let Self {
226+ client,
227+ sessions,
228+ tracking,
229+ state,
230+ previous,
231+ ..
232+ } = self;
233+ let Some(state) = state else {
234+ return;
235+ };
236+ client.oauth().abort_login(&state).await;
237+ drop(client);
238+ match previous {
239+ Some(previous) => put_back(sessions.as_ref(), previous).await,
240+ None => {
241+ if let Err(error) = sessions.discard().await {
242+ tracing::warn!(%error, "deleting an abandoned OAuth login's store failed");
243+ }
244+ }
221245 }
246+ drop(tracking);
247+ }
248+}
249+
250+async fn put_back(sessions: &dyn SessionStore, previous: SessionRecord) {
251+ if let Err(error) = sessions.save(&previous).await {
252+ tracing::warn!(%error, "putting back the record an OAuth login replaced failed");
222253 }
223254 }
224255
@@ -308,6 +339,32 @@ enum AuthenticationRecord {
308339 meta: SessionMeta,
309340 client_id: Option<ClientId>,
310341 },
342+ /// an oauth login waiting on its redirect. `authorization` holds the pkce verifier
343+ PendingOAuth {
344+ client_id: ClientId,
345+ state: CsrfToken,
346+ authorization: Box<PendingAuthorization>,
347+ started_ms: u64,
348+ /// what the store held before, put back if the login never finishes
349+ previous: Option<Box<SessionRecord>>,
350+ },
351+}
352+
353+/// authorization codes live for minutes, so a login this old was abandoned
354+const OAUTH_PENDING_FOR_MS: u64 = 60 * 60 * 1000;
355+
356+/// the authorization server answers in the query, or the fragment when asked to
357+pub(crate) fn callback_state(callback: &Url) -> Option<String> {
358+ let query = callback.query_pairs();
359+ let fragment = url::form_urlencoded::parse(callback.fragment().unwrap_or_default().as_bytes());
360+ query
361+ .chain(fragment)
362+ .find(|(key, _)| key == "state")
363+ .map(|(_, state)| state.into_owned())
364+}
365+
366+fn now_ms() -> u64 {
367+ MilliSecondsSinceUnixEpoch::now().get().into()
311368 }
312369
313370 impl SessionRecord {
@@ -316,17 +373,47 @@ impl SessionRecord {
316373 &self.homeserver
317374 }
318375
319- pub(crate) fn user_id(&self) -> &OwnedUserId {
376+ /// a pending oauth login is nobody yet, unless it resumes someone
377+ pub(crate) fn user_id(&self) -> Option<&OwnedUserId> {
320378 match &self.authentication {
321- AuthenticationRecord::Matrix(session) => &session.meta.user_id,
322- AuthenticationRecord::OAuth { user, .. } => &user.meta.user_id,
323- AuthenticationRecord::SoftLoggedOut { meta, .. } => &meta.user_id,
379+ AuthenticationRecord::Matrix(session) => Some(&session.meta.user_id),
380+ AuthenticationRecord::OAuth { user, .. } => Some(&user.meta.user_id),
381+ AuthenticationRecord::SoftLoggedOut { meta, .. } => Some(&meta.user_id),
382+ AuthenticationRecord::PendingOAuth { previous, .. } => previous.as_ref()?.user_id(),
383+ }
384+ }
385+
386+ /// what a login should resume. a pending oauth login stands in for the record before it
387+ pub(crate) fn resumable(self) -> Option<Self> {
388+ match self.authentication {
389+ AuthenticationRecord::PendingOAuth { previous, .. } => {
390+ previous.and_then(|previous| previous.resumable())
391+ }
392+ _ => Some(self),
324393 }
325394 }
326395
396+ pub(crate) fn pending_oauth_state(&self) -> Option<&str> {
397+ match &self.authentication {
398+ AuthenticationRecord::PendingOAuth { state, .. } => Some(state.secret()),
399+ _ => None,
400+ }
401+ }
402+
403+ pub(crate) fn oauth_expired(&self) -> bool {
404+ let AuthenticationRecord::PendingOAuth { started_ms, .. } = &self.authentication else {
405+ return false;
406+ };
407+ now_ms().saturating_sub(*started_ms) > OAUTH_PENDING_FOR_MS
408+ }
409+
327410 pub(crate) fn soft_logout(&self) -> Option<SoftLogout> {
328- let AuthenticationRecord::SoftLoggedOut { meta, client_id } = &self.authentication else {
329- return None;
411+ let (meta, client_id) = match &self.authentication {
412+ AuthenticationRecord::SoftLoggedOut { meta, client_id } => (meta, client_id),
413+ AuthenticationRecord::PendingOAuth { previous, .. } => {
414+ return previous.as_ref()?.soft_logout();
415+ }
416+ _ => return None,
330417 };
331418 Some(SoftLogout {
332419 homeserver: self.homeserver.clone(),
@@ -351,6 +438,7 @@ impl SessionRecord {
351438 }
352439 }
353440 soft @ AuthenticationRecord::SoftLoggedOut { .. } => soft,
441+ pending @ AuthenticationRecord::PendingOAuth { .. } => pending,
354442 };
355443 Self {
356444 homeserver: self.homeserver,
@@ -362,6 +450,8 @@ impl SessionRecord {
362450 pub enum Restore {
363451 Resumed(Session),
364452 SoftLoggedOut(SoftLogout),
453+ /// an oauth login is waiting on its redirect. `Session::finish_oauth` takes the callback
454+ OAuthPending,
365455 }
366456
367457 /// who to log in again as. the store keeps this device's e2ee keys, so a login
@@ -422,6 +512,10 @@ pub enum AccountError {
422512 UnsupportedAuthentication,
423513 #[error("the OAuth callback was already submitted")]
424514 OAuthCallbackAlreadySubmitted,
515+ /// nothing is waiting for this callback. it was finished, abandoned, expired, or begun
516+ /// somewhere else
517+ #[error("no OAuth login is waiting for this callback")]
518+ NoPendingOAuth,
425519 /// what was typed can't name a homeserver, so nothing was sent anywhere
426520 #[error("that isn't a usable homeserver, {0}")]
427521 InvalidHomeserver(&'static str),
@@ -586,15 +680,99 @@ impl Session {
586680 .build()
587681 .await
588682 .map_err(|error| account_error("starting OAuth login", error))?;
683+ let previous = sessions.load().await?.and_then(SessionRecord::resumable);
684+ let pending = SessionRecord {
685+ homeserver: client.homeserver().to_string(),
686+ authentication: AuthenticationRecord::PendingOAuth {
687+ client_id: client
688+ .oauth()
689+ .client_id()
690+ .ok_or(AccountError::MissingSession)?
691+ .clone(),
692+ state: authorization.state.clone(),
693+ authorization: Box::new(
694+ client
695+ .oauth()
696+ .pending_authorization(&authorization.state)
697+ .await
698+ .ok_or(AccountError::MissingSession)?,
699+ ),
700+ started_ms: now_ms(),
701+ previous: previous.clone().map(Box::new),
702+ },
703+ };
704+ sessions.save(&pending).await?;
589705 Ok(PendingOAuthLogin {
590706 client,
591707 sessions,
592708 tracking: Some((tracking, store_lock)),
593709 authorization_url: authorization.url.to_string(),
594710 state: Some(authorization.state),
711+ previous,
595712 })
596713 }
597714
715+ /// finishes a login `begin_oauth` started in another process, from the callback the
716+ /// redirect brought back. the pending login is in `sessions`, so nothing else is needed
717+ pub async fn finish_oauth(
718+ sessions: Arc<dyn SessionStore>,
719+ callback_url: &str,
720+ ) -> Result<Self, AccountError> {
721+ let store_lock = sessions.store().lock().await?;
722+ Self::finish_oauth_locked(sessions, store_lock, callback_url).await
723+ }
724+
725+ pub(crate) async fn finish_oauth_locked(
726+ sessions: Arc<dyn SessionStore>,
727+ store_lock: StoreLock,
728+ callback_url: &str,
729+ ) -> Result<Self, AccountError> {
730+ let callback = Url::parse(callback_url)
731+ .map_err(|error| account_error("parsing the OAuth callback URL", error))?;
732+ let record = sessions.load().await?.ok_or(AccountError::NoPendingOAuth)?;
733+ if record.pending_oauth_state() != callback_state(&callback).as_deref() {
734+ return Err(AccountError::NoPendingOAuth);
735+ }
736+ let AuthenticationRecord::PendingOAuth {
737+ client_id,
738+ state,
739+ authorization,
740+ previous,
741+ ..
742+ } = record.authentication
743+ else {
744+ return Err(AccountError::NoPendingOAuth);
745+ };
746+ let client = matrix_client_builder(sessions.store())
747+ .homeserver_url(&record.homeserver)
748+ .build()
749+ .await
750+ .map_err(|error| account_error("reopening the Matrix store", error))?;
751+ let tracking = Tracking::start(&client, sessions.clone())?;
752+ let oauth = client.oauth();
753+ oauth.restore_registered_client(client_id);
754+ let finished = match oauth
755+ .restore_pending_authorization(state, *authorization)
756+ .await
757+ {
758+ Ok(()) => oauth.finish_login(callback.into()).await,
759+ Err(error) => Err(error.into()),
760+ };
761+ if let Err(error) = finished {
762+ match previous {
763+ Some(previous) => put_back(sessions.as_ref(), *previous).await,
764+ None => {
765+ drop((tracking, client));
766+ if let Err(error) = sessions.discard().await {
767+ tracing::warn!(%error, "deleting a failed OAuth login's store failed");
768+ }
769+ }
770+ }
771+ return Err(account_error("OAuth login", error));
772+ }
773+ Self::saved(client, sessions, tracking, store_lock).await
774+ }
775+
598776 /// Restore the record `sessions` holds, or `None` when it holds none.
599777 /// The caller must also handle rejection and explicit logout.
600778 pub async fn restore(sessions: Arc<dyn SessionStore>) -> Result<Option<Restore>, AccountError> {
@@ -606,6 +784,15 @@ impl Session {
606784 AuthenticationRecord::OAuth { client_id, user } => {
607785 OAuthSession { client_id, user }.into()
608786 }
787+ AuthenticationRecord::PendingOAuth { .. } if record.oauth_expired() => {
788+ let previous = record.resumable();
789+ match previous {
790+ Some(previous) => put_back(sessions.as_ref(), previous).await,
791+ None => sessions.discard().await?,
792+ }
793+ return Box::pin(Self::restore(sessions)).await;
794+ }
795+ AuthenticationRecord::PendingOAuth { .. } => return Ok(Some(Restore::OAuthPending)),
609796 AuthenticationRecord::SoftLoggedOut { meta, client_id } => {
610797 return Ok(Some(Restore::SoftLoggedOut(SoftLogout {
611798 homeserver: record.homeserver,
Modified file: vendor/macltok/src/accounts.rs+71 additions−8 deletions
@@ -6,7 +6,8 @@ use ruma::TransactionId;
66
77 use crate::{
88 AccountError, EncryptedSessionStore, OAuthLogin, PasswordLogin, PendingOAuthLogin, Restore,
9- Session, SessionRecord, SessionStore, SoftLogout, StoreConfig, account::homeserver_input,
9+ Session, SessionRecord, SessionStore, SoftLogout, StoreConfig,
10+ account::{callback_state, homeserver_input},
1011 engine::StoreLock,
1112 };
1213
@@ -94,18 +95,50 @@ impl Accounts {
9495 }
9596
9697 /// every account with a record. a store left without one, by a login that died halfway or a
97- /// discard that couldn't finish, is deleted on the way past
98+ /// discard that couldn't finish, is deleted on the way past, and so is an oauth login whose
99+ /// redirect never came back. one that resumed an account puts that account back
98100 pub async fn list(&self) -> Result<Vec<Account>, AccountError> {
99101 let mut accounts = Vec::new();
100102 for account in self.slots().await? {
101103 match account.sessions.load().await {
102104 Ok(None) => self.sweep(&account).await,
105+ Ok(Some(record)) if record.oauth_expired() => {
106+ if self.expire(&account).await {
107+ accounts.push(account);
108+ }
109+ }
103110 Ok(Some(_)) | Err(_) => accounts.push(account),
104111 }
105112 }
106113 Ok(accounts)
107114 }
108115
116+ /// finishes an oauth login begun before a redirect, finding its account by the callback's
117+ /// state, so the host needs to have kept nothing across the redirect
118+ pub async fn finish_oauth(
119+ &self,
120+ callback_url: &str,
121+ ) -> Result<(Account, Session), AccountError> {
122+ let state = url::Url::parse(callback_url)
123+ .ok()
124+ .as_ref()
125+ .and_then(callback_state)
126+ .ok_or(AccountError::NoPendingOAuth)?;
127+ for account in self.slots().await? {
128+ let Ok(Some(record)) = account.sessions.load().await else {
129+ continue;
130+ };
131+ if record.pending_oauth_state() != Some(state.as_str()) {
132+ continue;
133+ }
134+ let store_lock = account.sessions.store().lock().await?;
135+ let session =
136+ Session::finish_oauth_locked(account.sessions(), store_lock, callback_url).await?;
137+ return Ok((account, session));
138+ }
139+ Err(AccountError::NoPendingOAuth)
140+ }
141+
109142 /// a soft-logged-out account the login names is resumed as its own device, keeping its keys.
110143 /// anyone else gets a new account
111144 pub async fn login_password(
@@ -193,9 +226,10 @@ impl Accounts {
193226 if !resumes(&record) {
194227 continue;
195228 }
196- return match record.soft_logout() {
197- Some(soft) => Ok(Found::SoftLoggedOut(account, soft)),
198- None => Err(AccountError::AlreadyLoggedIn(record.user_id().clone())),
229+ return match (record.soft_logout(), record.user_id()) {
230+ (Some(soft), _) => Ok(Found::SoftLoggedOut(account, soft)),
231+ (None, Some(user_id)) => Err(AccountError::AlreadyLoggedIn(user_id.clone())),
232+ (None, None) => continue,
199233 };
200234 }
201235 Ok(Found::Nobody)
@@ -227,9 +261,36 @@ impl Accounts {
227261 let Ok(_store_lock) = account.sessions.store().lock().await else {
228262 return;
229263 };
230- if !matches!(account.sessions.load().await, Ok(None)) {
231- return;
264+ if matches!(account.sessions.load().await, Ok(None)) {
265+ self.remove(account).await;
232266 }
267+ }
268+
269+ /// whether the account is still there. a login running in another tab holds the lock and
270+ /// is left alone
271+ async fn expire(&self, account: &Account) -> bool {
272+ let Ok(_store_lock) = account.sessions.store().lock().await else {
273+ return true;
274+ };
275+ let Ok(Some(record)) = account.sessions.load().await else {
276+ return true;
277+ };
278+ if !record.oauth_expired() {
279+ return true;
280+ }
281+ match record.resumable() {
282+ Some(previous) => {
283+ let _ = account.sessions.save(&previous).await;
284+ true
285+ }
286+ None => {
287+ self.remove(account).await;
288+ false
289+ }
290+ }
291+ }
292+
293+ async fn remove(&self, account: &Account) {
233294 if account.sessions.discard().await.is_err() {
234295 return;
235296 }
@@ -274,7 +335,9 @@ impl Accounts {
274335 /// is compared by name as well as by url, so a delegated `example.org` still finds the account
275336 /// discovery took to `matrix.example.org`
276337 fn record_names(record: &SessionRecord, homeserver: &str, username: &str) -> bool {
277- let user_id = record.user_id();
338+ let Some(user_id) = record.user_id() else {
339+ return false;
340+ };
278341 let username = username.trim();
279342 let user = if username.starts_with('@') {
280343 username.eq_ignore_ascii_case(user_id.as_str())
Modified file: vendor/macltok/src/engine/media.rs+9 additions−3 deletions
@@ -31,10 +31,10 @@ use matrix_sdk_crypto::{AttachmentDecryptor, MediaEncryptionInfo};
3131 use ruma::MxcUri;
3232 use tokio::sync::Mutex;
3333
34-use super::executor::EngineBoxFuture;
34+use super::{executor::EngineBoxFuture, url_preview::UrlPreviews};
3535 use crate::{
36- AttachmentEncryption, AttachmentUpload, Error, ErrorKind, Media, MediaFile, MediaId, MediaKind,
37- MediaLocation, MediaSize, TimelineId,
36+ AttachmentEncryption, AttachmentUpload, Error, ErrorKind, LinkPreview, Media, MediaFile,
37+ MediaId, MediaKind, MediaLocation, MediaSize, TimelineId,
3838 normalise::{MediaRegistration, MediaScope, MediaSourceInfo},
3939 upload::prepare_upload,
4040 };
@@ -54,11 +54,13 @@ pub(crate) struct MediaService {
5454 registry: Mutex<MediaRegistry>,
5555 flights: Mutex<HashMap<String, WeakShared<FlightFuture>>>,
5656 max_bytes: usize,
57+ url_previews: UrlPreviews,
5758 }
5859
5960 impl MediaService {
6061 pub(crate) fn new(client: Client, max_bytes: usize, max_sources: usize) -> Self {
6162 Self {
63+ url_previews: UrlPreviews::new(client.clone()),
6264 client,
6365 registry: Mutex::new(MediaRegistry::new(max_sources)),
6466 flights: Mutex::new(HashMap::new()),
@@ -66,6 +68,10 @@ impl MediaService {
6668 }
6769 }
6870
71+ pub(crate) async fn url_preview(&self, url: &str) -> Result<Option<LinkPreview>, Error> {
72+ self.url_previews.preview(url).await
73+ }
74+
6975 pub(crate) async fn register(&self, media: Vec<MediaRegistration>) {
7076 if !media.is_empty() {
7177 self.registry.lock().await.insert_all(media);
Modified file: vendor/macltok/src/engine/mod.rs+12 additions−45 deletions
@@ -6,6 +6,7 @@ mod prepare;
66 mod room;
77 mod session;
88 mod timeline;
9+mod url_preview;
910 mod watchers;
1011
1112 use std::{
@@ -41,6 +42,7 @@ use crate::{
4142 AccountCommand, ClientHandle, Command, ConnectionState, EncryptionCommand, EngineErrorKind,
4243 EngineFailure, Error, ErrorKind, Event, EventStream, LifecycleCommand, MediaCommand,
4344 NotificationCommand, RoomCommand, Termination, TimelineCommand,
45+ normalise::{ROOM_PACKS, ROOM_PREVIEW_URLS},
4446 transport::{DriverPort, Request, Responder, transport_pair},
4547 };
4648
@@ -245,7 +247,6 @@ struct Queues {
245247 encryption: mpsc::Receiver<Job<EncryptionCommand>>,
246248 session_changes: broadcast::Receiver<SessionChange>,
247249 persistence: Option<Persistence>,
248- upload_reports: mpsc::Receiver<watchers::RoomUpload>,
249250 }
250251
251252 /// requests other devices send us, handed from the sdk's event handler to the encryption worker
@@ -269,9 +270,6 @@ pub struct Engine {
269270 notifications: mpsc::Sender<Job<NotificationCommand>>,
270271 encryption: mpsc::Sender<Job<EncryptionCommand>>,
271272 queues: Option<Queues>,
272- /// one progress forwarder per room that has uploaded, and whether it's subscribed yet
273- uploads: HashMap<OwnedRoomId, watch::Receiver<bool>>,
274- upload_reports: mpsc::Sender<watchers::RoomUpload>,
275273 /// the client can outlive us, so the handler has to come off it when we stop
276274 verification_handler: Option<matrix_sdk::event_handler::EventHandlerHandle>,
277275 stop: mpsc::UnboundedSender<Termination>,
@@ -313,6 +311,10 @@ impl Engine {
313311 let sync = Arc::new(
314312 SyncService::builder(client.clone())
315313 .with_offline_mode()
314+ .with_room_list_extra_required_state(vec![
315+ (ROOM_PACKS.into(), "*".to_owned()),
316+ (ROOM_PREVIEW_URLS.into(), String::new()),
317+ ])
316318 .build()
317319 .await
318320 .map_err(EngineError::SyncService)?,
@@ -332,9 +334,9 @@ impl Engine {
332334 config.event_capacity,
333335 );
334336 client.send_queue().enable_upload_progress(true);
337+ client.send_queue().enable_global_upload_progress(true);
335338 let (note_sender, notes) = mpsc::unbounded_channel();
336339 let (stop, stops) = mpsc::unbounded_channel();
337- let (upload_reports, upload_report_queue) = mpsc::channel(config.event_capacity);
338340 let (mutations, mutation_queue) = mpsc::channel(config.command_capacity);
339341 let (notifications, notification_queue) = mpsc::channel(config.command_capacity);
340342 let (encryption, encryption_queue) = mpsc::channel(config.command_capacity);
@@ -361,10 +363,7 @@ impl Engine {
361363 encryption: encryption_queue,
362364 session_changes,
363365 persistence,
364- upload_reports: upload_report_queue,
365366 }),
366- upload_reports,
367- uploads: HashMap::new(),
368367 verification_handler: None,
369368 preparations: prepare::Preparations::default(),
370369 stop,
@@ -401,7 +400,6 @@ impl Engine {
401400 }
402401 }
403402 self.start_tasks();
404- self.resume_upload_forwarders().await;
405403 self.sync.start().await;
406404
407405 let mut shutdown_reply = None;
@@ -462,13 +460,12 @@ impl Engine {
462460 let client = &self.client;
463461 let events = || self.port.event_sender();
464462 let (notices, notice_queue) = mpsc::unbounded_channel();
465- let watchers: [(&'static str, EngineBoxFuture<'static, ()>); 12] = [
463+ let watchers: [(&'static str, EngineBoxFuture<'static, ()>); 13] = [
466464 (
467465 "upload tracker",
468466 Box::pin(watchers::uploads(
469467 client.clone(),
470468 client.send_queue().subscribe(),
471- queues.upload_reports,
472469 events(),
473470 )),
474471 ),
@@ -528,6 +525,10 @@ impl Engine {
528525 Box::pin(watchers::rooms(client.clone(), events())),
529526 ),
530527 (
528+ "image pack watcher",
529+ Box::pin(watchers::image_packs(client.clone(), events())),
530+ ),
531+ (
531532 "notification settings watcher",
532533 Box::pin(watchers::notification_settings(client.clone(), events())),
533534 ),
@@ -734,45 +735,12 @@ impl Engine {
734735 let Some(room) = self.client.get_room(upload.timeline_id.room_id()) else {
735736 return reply.done(Err(Error::room_unknown()));
736737 };
737- let mut ready = self.upload_forwarder(&room);
738738 let max_bytes = self.config.max_media_bytes;
739- // queueing before the forwarder listens would lose the first progress ticks
740739 self.spawn_command("upload", reply, async move {
741- ready
742- .wait_for(|subscribed| *subscribed)
743- .await
744- .map_err(|_| Error::new(ErrorKind::Store, "reading the media send queue failed"))?;
745740 media::queue_upload(room, upload, max_bytes).await
746741 });
747742 }
748743
749- fn upload_forwarder(&mut self, room: &matrix_sdk::Room) -> watch::Receiver<bool> {
750- self.uploads.retain(|_, ready| ready.has_changed().is_ok());
751- if let Some(ready) = self.uploads.get(room.room_id()) {
752- return ready.clone();
753- }
754- let (subscribed, ready) = watch::channel(false);
755- self.tasks.spawn(
756- TaskContext::Command("upload watcher"),
757- watchers::room_uploads(room.clone(), self.upload_reports.clone(), subscribed),
758- );
759- self.uploads
760- .insert(room.room_id().to_owned(), ready.clone());
761- ready
762- }
763-
764- /// the sdk resumes uploads left over from last time, and they deserve progress too
765- async fn resume_upload_forwarders(&mut self) {
766- let Ok(echoes) = self.client.send_queue().local_echoes().await else {
767- return;
768- };
769- for room_id in echoes.keys() {
770- if let Some(room) = self.client.get_room(room_id) {
771- self.upload_forwarder(&room);
772- }
773- }
774- }
775-
776744 fn route_timeline(&mut self, command: TimelineCommand, reply: Responder) {
777745 let sender = match self.rooms.get(command.room_id()) {
778746 Some(RoomSlot::Active { sender, .. }) => Some(sender.clone()),
@@ -942,7 +910,6 @@ impl Engine {
942910 self.port.close();
943911 self.tasks.shutdown().await;
944912 self.rooms.clear();
945- self.uploads.clear();
946913 self.media.clear().await;
947914 self.sync.stop().await;
948915 self.port
Modified file: vendor/macltok/src/engine/prepare.rs+5 additions−9 deletions
@@ -33,15 +33,11 @@ impl Preparations {
3333 }
3434 }
3535
36-/// the part of the sdk's pre-send encryption work it lets us reach. syncing the members
37-/// has the sdk track them all, and its own background `/keys/query` then catches up
38-/// their devices. the one time key claim and the room key share stay on the send path
36+/// the sdk's own pre-send work, run early. a send landing mid-share waits on the
37+/// sdk's per-room share lock instead of starting another (forked sdk, see design-decisions)
3938 async fn prepare(client: Client, room_id: OwnedRoomId) -> Result<(), Error> {
4039 let room = client.get_room(&room_id).ok_or_else(Error::room_unknown)?;
41- if room.encryption_state().is_encrypted() && !room.are_members_synced() {
42- room.sync_members()
43- .await
44- .map_err(|source| Error::from_sdk("syncing room members failed", source))?;
45- }
46- Ok(())
40+ room.prepare_encryption()
41+ .await
42+ .map_err(|source| Error::from_sdk("preparing room encryption failed", source))
4743 }
Modified file: vendor/macltok/src/engine/timeline.rs+7 additions−2 deletions
@@ -185,9 +185,14 @@ impl TimelineSession {
185185 .await
186186 .map_err(timeline_error)
187187 }),
188- TimelineCommand::ToggleReaction { target, key, .. } => Box::pin(async move {
188+ TimelineCommand::ToggleReaction {
189+ target,
190+ key,
191+ shortcode,
192+ ..
193+ } => Box::pin(async move {
189194 timeline
190- .toggle_reaction(&item_id(target), &key)
195+ .toggle_reaction_with_shortcode(&item_id(target), &key, shortcode.as_deref())
191196 .await
192197 .map(drop)
193198 .map_err(timeline_error)
Added file: vendor/macltok/src/engine/url_preview.rs+166 additions
@@ -0,0 +1,166 @@
1+use std::{
2+ collections::{HashMap, VecDeque},
3+ sync::{
4+ Arc,
5+ atomic::{AtomicBool, Ordering},
6+ },
7+ time::Duration,
8+};
9+
10+use futures_util::{FutureExt, future::WeakShared};
11+use matrix_sdk::{
12+ Client, HttpError,
13+ ruma::api::{
14+ Metadata,
15+ client::{authenticated_media, media},
16+ },
17+};
18+use ruma::{
19+ api::error::ErrorKind as ApiErrorKind,
20+ serde::{JsonObject, Raw},
21+ time::Instant,
22+};
23+use tokio::sync::Mutex;
24+
25+use super::executor::EngineBoxFuture;
26+use crate::{Error, LinkPreview, normalise::server_preview};
27+
28+const HIT_FOR: Duration = Duration::from_secs(60 * 60);
29+const MISS_FOR: Duration = Duration::from_secs(10 * 60);
30+const REMEMBERED: usize = 512;
31+
32+type Lookup = EngineBoxFuture<'static, Result<Option<LinkPreview>, Error>>;
33+
34+/// the homeserver rate limits `preview_url` and a busy timeline repeats links, so answers are
35+/// kept a while, misses too, and asks for one url at once share one request
36+pub(crate) struct UrlPreviews(Arc<Inner>);
37+
38+struct Inner {
39+ client: Client,
40+ /// a server that doesn't preview at all says so once and isn't asked again
41+ unsupported: AtomicBool,
42+ cache: Mutex<Cache>,
43+ lookups: Mutex<HashMap<String, WeakShared<Lookup>>>,
44+}
45+
46+#[derive(Default)]
47+struct Cache {
48+ entries: HashMap<String, (Instant, Option<LinkPreview>)>,
49+ order: VecDeque<String>,
50+}
51+
52+impl UrlPreviews {
53+ pub(crate) fn new(client: Client) -> Self {
54+ Self(Arc::new(Inner {
55+ client,
56+ unsupported: AtomicBool::new(false),
57+ cache: Mutex::new(Cache::default()),
58+ lookups: Mutex::new(HashMap::new()),
59+ }))
60+ }
61+
62+ pub(crate) async fn preview(&self, url: &str) -> Result<Option<LinkPreview>, Error> {
63+ if self.0.unsupported.load(Ordering::Relaxed) {
64+ return Ok(None);
65+ }
66+ if let Some(preview) = self.0.cache.lock().await.get(url) {
67+ return Ok(preview);
68+ }
69+ let lookup = {
70+ let mut lookups = self.0.lookups.lock().await;
71+ lookups.retain(|_, lookup| lookup.upgrade().is_some());
72+ match lookups.get(url).and_then(WeakShared::upgrade) {
73+ Some(lookup) => lookup,
74+ None => {
75+ let inner = self.0.clone();
76+ let owned = url.to_owned();
77+ let lookup: Lookup = Box::pin(async move { inner.look_up(owned).await });
78+ let lookup = lookup.shared();
79+ if let Some(weak) = lookup.downgrade() {
80+ lookups.insert(url.to_owned(), weak);
81+ }
82+ lookup
83+ }
84+ }
85+ };
86+ lookup.await
87+ }
88+}
89+
90+impl Inner {
91+ async fn look_up(&self, url: String) -> Result<Option<LinkPreview>, Error> {
92+ let data = match self.fetch(&url).await {
93+ Ok(data) => data,
94+ Err(error) => {
95+ let Some(api) = error.as_client_api_error() else {
96+ return Err(Error::from_sdk(
97+ "asking for a url preview failed",
98+ error.into(),
99+ ));
100+ };
101+ let unrecognised = matches!(api.error_kind(), Some(ApiErrorKind::Unrecognized))
102+ || api.status_code.as_u16() == 405;
103+ if unrecognised {
104+ self.unsupported.store(true, Ordering::Relaxed);
105+ return Ok(None);
106+ }
107+ // synapse answers 500 for a page it can't download, 502 when dns fails and 403 for
108+ // its blocklist (synapse/media/url_previewer.py). a real fault hides in the 500s too,
109+ // but a missing card is cheap and only cached ten minutes
110+ if !matches!(api.status_code.as_u16(), 400 | 403 | 404 | 500 | 502) {
111+ return Err(Error::from_sdk(
112+ "asking for a url preview failed",
113+ error.into(),
114+ ));
115+ }
116+ None
117+ }
118+ };
119+ let preview = data.and_then(|data| server_preview(&url, &data));
120+ self.cache.lock().await.insert(url, preview.clone());
121+ Ok(preview)
122+ }
123+
124+ async fn fetch(&self, url: &str) -> Result<Option<Raw<JsonObject>>, HttpError> {
125+ let versions = self.client.supported_versions().await?;
126+ let data = if authenticated_media::get_media_preview::v1::Request::PATH_BUILDER
127+ .is_supported(&versions)
128+ {
129+ self.client
130+ .send(authenticated_media::get_media_preview::v1::Request::new(
131+ url.to_owned(),
132+ ))
133+ .await?
134+ .data
135+ } else {
136+ #[allow(deprecated)]
137+ let request = media::get_media_preview::v3::Request::new(url.to_owned());
138+ self.client.send(request).await?.data
139+ };
140+ Ok(data.map(Raw::from_json))
141+ }
142+}
143+
144+impl Cache {
145+ fn get(&mut self, url: &str) -> Option<Option<LinkPreview>> {
146+ let (at, preview) = self.entries.get(url)?;
147+ let lasts = if preview.is_some() { HIT_FOR } else { MISS_FOR };
148+ (at.elapsed() < lasts).then(|| preview.clone())
149+ }
150+
151+ fn insert(&mut self, url: String, preview: Option<LinkPreview>) {
152+ if self
153+ .entries
154+ .insert(url.clone(), (Instant::now(), preview))
155+ .is_none()
156+ {
157+ self.order.push_back(url);
158+ }
159+ while self.entries.len() > REMEMBERED {
160+ let Some(oldest) = self.order.pop_front() else {
161+ break;
162+ };
163+ self.entries.remove(&oldest);
164+ }
165+ }
166+}
Modified file: vendor/macltok/src/engine/watchers.rs+206 additions−172 deletions
@@ -1,18 +1,31 @@
1-use std::{collections::HashSet, sync::Arc, time::Duration};
1+use std::{
2+ collections::{HashSet, VecDeque},
3+ sync::Arc,
4+ time::Duration,
5+};
26
37 use futures_util::StreamExt;
48 use matrix_sdk::{
5- Client, Room,
9+ Client,
610 ruma::events::{AnyMessageLikeEventContent, room::message::MessageType},
711 send_queue::{LocalEchoContent, RoomSendQueueUpdate, SendQueueUpdate},
812 };
13+use matrix_sdk_base::RoomInfoNotableUpdateReasons;
914 use matrix_sdk_ui::sync_service::{State as SyncState, SyncService};
10-use ruma::{OwnedRoomId, OwnedTransactionId, time::Instant};
11-use tokio::sync::{broadcast::error::RecvError, mpsc, watch};
15+use ruma::{
16+ OwnedRoomId, OwnedTransactionId,
17+ events::{AnyGlobalAccountDataEvent, AnySyncStateEvent},
18+ serde::Raw,
19+ time::Instant,
20+};
21+use tokio::sync::{Notify, broadcast::error::RecvError, watch};
1222
1323 use super::executor::TaskSet;
1424 use crate::{
15- ConnectionState, Event, UploadOutcome, encryption::encryption_state, error::classify_sdk_error,
25+ ConnectionState, Event, UploadOutcome,
26+ encryption::encryption_state,
27+ error::classify_sdk_error,
28+ normalise::{ENABLED_ROOMS, ROOM_PACKS, USER_PACK},
1629 transport::EventSender,
1730 };
1831
@@ -113,84 +126,20 @@ pub(super) async fn send_queue_errors(
113126 }
114127 }
115128
116-/// upload progress only goes out on the room's own channel, never the client-wide one.
117-/// the room's sends and cancels come along behind it, so an upload's end can't
118-/// overtake its own progress
119-pub(super) async fn room_uploads(
120- room: Room,
121- tracker: mpsc::Sender<RoomUpload>,
122- ready: watch::Sender<bool>,
123-) {
124- let room_id = room.room_id().to_owned();
125- let mut updates = match room.send_queue().subscribe().await {
126- Ok((_, updates)) => updates,
127- Err(error) => {
128- tracing::warn!(%room_id, %error, "reading the media send queue failed");
129- return;
130- }
131- };
132- ready.send_replace(true);
133- loop {
134- let report = match updates.recv().await {
135- Ok(RoomSendQueueUpdate::MediaUpload {
136- related_to,
137- file,
138- progress,
139- ..
140- }) => RoomUpload::Progress {
141- upload: (room_id.clone(), related_to),
142- uploaded_bytes: progress.current as u64,
143- total_bytes: progress.total as u64,
144- complete: file.is_some() && progress.current == progress.total,
145- },
146- Ok(RoomSendQueueUpdate::SentEvent {
147- transaction_id,
148- event_id,
149- }) => RoomUpload::Ended(
150- (room_id.clone(), transaction_id),
151- UploadOutcome::Sent { event_id },
152- ),
153- Ok(RoomSendQueueUpdate::CancelledLocalEvent { transaction_id }) => {
154- RoomUpload::Ended((room_id.clone(), transaction_id), UploadOutcome::Cancelled)
155- }
156- Ok(_) => continue,
157- Err(RecvError::Lagged(_)) => RoomUpload::Lagged,
158- Err(RecvError::Closed) => return,
159- };
160- if tracker.send(report).await.is_err() {
161- return;
162- }
163- }
164-}
165-
166-pub(super) enum RoomUpload {
167- Progress {
168- upload: Upload,
169- uploaded_bytes: u64,
170- total_bytes: u64,
171- complete: bool,
172- },
173- Ended(Upload, UploadOutcome),
174- /// an end may have been lost with the ticks
175- Lagged,
176-}
177-
178129 /// the one place upload events leave from, so an upload is never reported after it
179-/// ended. progress comes in from the room forwarders, the rest from the client-wide
180-/// channel, which exists before any upload does
130+/// ended. the client-wide channel exists before any upload does. it holds 32 updates and
131+/// progress ticks share it, so this keeps draining while the event stream is full
181132 pub(super) async fn uploads(
182133 client: Client,
183134 mut updates: tokio::sync::broadcast::Receiver<SendQueueUpdate>,
184- mut rooms_reports: mpsc::Receiver<RoomUpload>,
185135 events: EventSender,
186136 ) {
187137 let (_, mut rooms) = client.rooms_stream();
188- let mut tracker = UploadTracker {
189- live: attachment_echoes(&client).await,
190- ended: HashSet::new(),
191- };
138+ let mut tracker = UploadTracker::new(attachment_echoes(&client).await);
139+ let mut pending = VecDeque::new();
192140 loop {
193- let reports = tokio::select! {
141+ let reports: Vec<Event> = tokio::select! {
142+ biased;
194143 update = updates.recv() => match update {
195144 Ok(update) => tracker.update(update).into_iter().collect(),
196145 Err(RecvError::Lagged(skipped)) => {
@@ -199,10 +148,6 @@ pub(super) async fn uploads(
199148 }
200149 Err(RecvError::Closed) => return,
201150 },
202- Some(report) = rooms_reports.recv() => match report {
203- RoomUpload::Lagged => tracker.resnapshot(attachment_echoes(&client).await),
204- report => tracker.room(report).into_iter().collect(),
205- },
206151 update = rooms.next() => {
207152 if update.is_none() {
208153 return;
@@ -212,26 +157,77 @@ pub(super) async fn uploads(
212157 &UploadOutcome::RoomForgotten,
213158 )
214159 }
160+ permit = events.reserve(), if !pending.is_empty() => {
161+ let Ok(permit) = permit else { return };
162+ permit.send(pending.pop_front().expect("pending was checked"));
163+ continue;
164+ }
215165 };
216166 for event in reports {
217- if events.send(event).await.is_err() {
218- return;
219- }
167+ queue_upload_event(&mut pending, event);
220168 }
221169 }
222170 }
223171
172+/// a progress tick replaces a waiting one for the same upload, unless something else about
173+/// that upload is queued after it
174+fn queue_upload_event(pending: &mut VecDeque<Event>, event: Event) {
175+ if matches!(event, Event::MediaUploadProgress { .. })
176+ && let Some(waiting) = pending
177+ .iter_mut()
178+ .rev()
179+ .find(|waiting| upload_of(waiting) == upload_of(&event))
180+ && matches!(waiting, Event::MediaUploadProgress { .. })
181+ {
182+ *waiting = event;
183+ return;
184+ }
185+ pending.push_back(event);
186+}
187+
188+fn upload_of(event: &Event) -> Option<(&OwnedRoomId, &OwnedTransactionId)> {
189+ match event {
190+ Event::MediaUploadProgress {
191+ room_id,
192+ transaction_id,
193+ ..
194+ }
195+ | Event::MediaUploadFailed {
196+ room_id,
197+ transaction_id,
198+ ..
199+ }
200+ | Event::MediaUploadFinished {
201+ room_id,
202+ transaction_id,
203+ ..
204+ } => Some((room_id, transaction_id)),
205+ _ => None,
206+ }
207+}
208+
224209 type Upload = (OwnedRoomId, OwnedTransactionId);
225210
226-/// everything reported gets exactly one `MediaUploadFinished`. an upload ended from
227-/// anywhere but its room's forwarder goes in `ended` until the forwarder's own end for
228-/// it arrives, since until then the forwarder can still be holding progress for it
211+/// the sdk sends progress ticks from a detached task, so a tick can land after its
212+/// upload's end. it trails by one task hop, so a short memory of ends covers it
213+const REMEMBERED_ENDS: usize = 256;
214+
215+/// everything reported gets exactly one `MediaUploadFinished` and nothing after it
229216 struct UploadTracker {
230217 live: HashSet<Upload>,
231218 ended: HashSet<Upload>,
219+ ended_order: VecDeque<Upload>,
232220 }
233221
234222 impl UploadTracker {
223+ fn new(live: HashSet<Upload>) -> Self {
224+ Self {
225+ live,
226+ ended: HashSet::new(),
227+ ended_order: VecDeque::new(),
228+ }
229+ }
230+
235231 fn update(&mut self, update: SendQueueUpdate) -> Option<Event> {
236232 let room_id = update.room_id;
237233 let (transaction_id, outcome) = match update.update {
@@ -242,6 +238,26 @@ impl UploadTracker {
242238 }
243239 return None;
244240 }
241+ RoomSendQueueUpdate::MediaUpload {
242+ related_to,
243+ file,
244+ progress,
245+ ..
246+ } => {
247+ let upload = (room_id, related_to);
248+ if self.ended.contains(&upload) {
249+ return None;
250+ }
251+ self.live.insert(upload.clone());
252+ let (room_id, transaction_id) = upload;
253+ return Some(Event::MediaUploadProgress {
254+ room_id,
255+ transaction_id,
256+ uploaded_bytes: progress.current as u64,
257+ total_bytes: progress.total as u64,
258+ complete: file.is_some() && progress.current == progress.total,
259+ });
260+ }
245261 RoomSendQueueUpdate::SendError {
246262 transaction_id,
247263 error,
@@ -268,36 +284,7 @@ impl UploadTracker {
268284 }
269285 _ => return None,
270286 };
271- self.finish((room_id, transaction_id), outcome, true)
272- }
273-
274- fn room(&mut self, report: RoomUpload) -> Option<Event> {
275- match report {
276- RoomUpload::Progress {
277- upload,
278- uploaded_bytes,
279- total_bytes,
280- complete,
281- } => {
282- if self.ended.contains(&upload) {
283- return None;
284- }
285- self.live.insert(upload.clone());
286- let (room_id, transaction_id) = upload;
287- Some(Event::MediaUploadProgress {
288- room_id,
289- transaction_id,
290- uploaded_bytes,
291- total_bytes,
292- complete,
293- })
294- }
295- RoomUpload::Ended(upload, outcome) => {
296- self.ended.remove(&upload);
297- self.finish(upload, outcome, false)
298- }
299- RoomUpload::Lagged => None,
300- }
287+ self.finish((room_id, transaction_id), outcome)
301288 }
302289
303290 fn resnapshot(&mut self, current: HashSet<Upload>) -> Vec<Event> {
@@ -321,17 +308,23 @@ impl UploadTracker {
321308 .collect::<Vec<_>>();
322309 ending
323310 .into_iter()
324- .filter_map(|upload| self.finish(upload, outcome.clone(), true))
311+ .filter_map(|upload| self.finish(upload, outcome.clone()))
325312 .collect()
326313 }
327314
328- fn finish(&mut self, upload: Upload, outcome: UploadOutcome, remember: bool) -> Option<Event> {
315+ /// an end for an upload we never saw is remembered too, its ticks may still be coming
316+ fn finish(&mut self, upload: Upload, outcome: UploadOutcome) -> Option<Event> {
317+ if self.ended.insert(upload.clone()) {
318+ self.ended_order.push_back(upload.clone());
319+ if self.ended_order.len() > REMEMBERED_ENDS
320+ && let Some(oldest) = self.ended_order.pop_front()
321+ {
322+ self.ended.remove(&oldest);
323+ }
324+ }
329325 if !self.live.remove(&upload) {
330326 return None;
331327 }
332- if remember {
333- self.ended.insert(upload.clone());
334- }
335328 let (room_id, transaction_id) = upload;
336329 Some(Event::MediaUploadFinished {
337330 room_id,
@@ -425,6 +418,61 @@ pub(super) async fn rooms(client: Client, events: EventSender) {
425418 }
426419 }
427420
421+/// packs live in room state and account data, and leaving a room takes its packs with it
422+pub(super) async fn image_packs(client: Client, events: EventSender) {
423+ let changed = Arc::new(Notify::new());
424+ let handlers = [
425+ client.add_event_handler({
426+ let changed = changed.clone();
427+ move |event: Raw<AnySyncStateEvent>| {
428+ let changed = changed.clone();
429+ async move {
430+ if event_type(&event).as_deref() == Some(ROOM_PACKS) {
431+ changed.notify_one();
432+ }
433+ }
434+ }
435+ }),
436+ client.add_event_handler({
437+ let changed = changed.clone();
438+ move |event: Raw<AnyGlobalAccountDataEvent>| {
439+ let changed = changed.clone();
440+ async move {
441+ if matches!(
442+ event_type(&event).as_deref(),
443+ Some(USER_PACK | ENABLED_ROOMS)
444+ ) {
445+ changed.notify_one();
446+ }
447+ }
448+ }
449+ }),
450+ ];
451+ let mut info = client.room_info_notable_update_receiver();
452+ loop {
453+ tokio::select! {
454+ () = changed.notified() => {}
455+ update = info.recv() => match update {
456+ Ok(update) if !update.reasons.contains(RoomInfoNotableUpdateReasons::MEMBERSHIP) => {
457+ continue;
458+ }
459+ Ok(_) | Err(RecvError::Lagged(_)) => {}
460+ Err(RecvError::Closed) => break,
461+ },
462+ }
463+ if events.send(Event::ImagePacksChanged).await.is_err() {
464+ break;
465+ }
466+ }
467+ for handler in handlers {
468+ client.remove_event_handler(handler);
469+ }
470+}
471+
472+fn event_type<T>(event: &Raw<T>) -> Option<String> {
473+ event.get_field("type").ok().flatten()
474+}
475+
428476 pub(super) async fn notification_settings(client: Client, events: EventSender) {
429477 let settings = client.notification_settings().await;
430478 let mut changes = settings.subscribe_to_changes();
@@ -440,10 +488,10 @@ pub(super) async fn notification_settings(client: Client, events: EventSender) {
440488
441489 #[cfg(test)]
442490 mod tests {
443- use matrix_sdk::send_queue::{RoomSendQueueUpdate, SendQueueUpdate};
491+ use matrix_sdk::send_queue::{AbstractProgress, RoomSendQueueUpdate, SendQueueUpdate};
444492 use ruma::{OwnedTransactionId, event_id, owned_room_id};
445493
446- use super::{HashSet, RoomUpload, Upload, UploadTracker};
494+ use super::{HashSet, Upload, UploadTracker};
447495 use crate::{Event, UploadOutcome};
448496
449497 fn upload() -> Upload {
@@ -453,26 +501,30 @@ mod tests {
453501 )
454502 }
455503
456- fn tick() -> RoomUpload {
457- RoomUpload::Progress {
458- upload: upload(),
459- uploaded_bytes: 1,
460- total_bytes: 2,
461- complete: false,
504+ fn update(update: RoomSendQueueUpdate) -> SendQueueUpdate {
505+ SendQueueUpdate {
506+ room_id: upload().0,
507+ update,
462508 }
463509 }
464510
465- fn sent() -> UploadOutcome {
466- UploadOutcome::Sent {
467- event_id: event_id!("$sent:localhost").to_owned(),
468- }
511+ fn tick() -> SendQueueUpdate {
512+ update(RoomSendQueueUpdate::MediaUpload {
513+ related_to: upload().1,
514+ file: None,
515+ index: 0,
516+ progress: AbstractProgress {
517+ current: 1,
518+ total: 2,
519+ },
520+ })
469521 }
470522
471- fn tracker() -> UploadTracker {
472- UploadTracker {
473- live: HashSet::new(),
474- ended: HashSet::new(),
475- }
523+ fn sent() -> SendQueueUpdate {
524+ update(RoomSendQueueUpdate::SentEvent {
525+ transaction_id: upload().1,
526+ event_id: event_id!("$sent:localhost").to_owned(),
527+ })
476528 }
477529
478530 fn finished(event: Option<Event>) -> Option<UploadOutcome> {
@@ -483,9 +535,21 @@ mod tests {
483535 }
484536
485537 #[test]
538+ fn a_tick_trailing_its_end_says_nothing() {
539+ let mut tracker = UploadTracker::new(HashSet::new());
540+ assert!(tracker.update(tick()).is_some());
541+ assert!(matches!(
542+ finished(tracker.update(sent())),
543+ Some(UploadOutcome::Sent { .. })
544+ ));
545+ assert!(tracker.update(tick()).is_none());
546+ assert!(tracker.live.is_empty());
547+ }
548+
549+ #[test]
486550 fn a_stale_tick_after_a_missed_end_changes_nothing() {
487- let mut tracker = tracker();
488- assert!(tracker.room(tick()).is_some());
551+ let mut tracker = UploadTracker::new(HashSet::new());
552+ assert!(tracker.update(tick()).is_some());
489553 let missed = tracker.resnapshot(HashSet::new());
490554 assert!(matches!(
491555 missed.as_slice(),
@@ -494,38 +558,8 @@ mod tests {
494558 ..
495559 }]
496560 ));
497-
498- assert!(tracker.room(tick()).is_none());
499- let (room_id, transaction_id) = upload();
500- let global = SendQueueUpdate {
501- room_id,
502- update: RoomSendQueueUpdate::SentEvent {
503- transaction_id,
504- event_id: event_id!("$sent:localhost").to_owned(),
505- },
506- };
507- assert!(tracker.update(global).is_none());
508- assert!(tracker.room(RoomUpload::Ended(upload(), sent())).is_none());
509- assert!(tracker.live.is_empty() && tracker.ended.is_empty());
510- }
511-
512- #[test]
513- fn an_end_the_tracker_never_knew_about_still_follows_its_progress() {
514- let mut tracker = tracker();
515- let (room_id, transaction_id) = upload();
516- let global = SendQueueUpdate {
517- room_id,
518- update: RoomSendQueueUpdate::SentEvent {
519- transaction_id,
520- event_id: event_id!("$sent:localhost").to_owned(),
521- },
522- };
523- assert!(tracker.update(global).is_none());
524- assert!(tracker.room(tick()).is_some());
525- assert_eq!(
526- finished(tracker.room(RoomUpload::Ended(upload(), sent()))),
527- Some(sent())
528- );
529- assert!(tracker.live.is_empty() && tracker.ended.is_empty());
561+ assert!(tracker.update(tick()).is_none());
562+ assert!(tracker.update(sent()).is_none());
563+ assert!(tracker.live.is_empty());
530564 }
531565 }
Modified file: vendor/macltok/src/error.rs+9 additions
@@ -63,6 +63,8 @@ pub enum ErrorKind {
6363 NoCrossSigningIdentity,
6464 RecoveryRejected,
6565 Network,
66+ /// the homeserver wants us to slow down. try again later
67+ RateLimited,
6668 Server,
6769 Store,
6870 Timeline,
@@ -269,6 +271,13 @@ pub(crate) fn classify_sdk_error(source: &matrix_sdk::Error) -> ErrorKind {
269271 fn classify_http_error(source: &HttpError) -> ErrorKind {
270272 match source {
271273 HttpError::Reqwest(_) => ErrorKind::Network,
274+ HttpError::Api(_)
275+ if source
276+ .as_client_api_error()
277+ .is_some_and(|api| api.status_code.as_u16() == 429) =>
278+ {
279+ ErrorKind::RateLimited
280+ }
272281 HttpError::Api(_) => ErrorKind::Server,
273282 HttpError::RefreshToken(source) => classify_refresh_token_error(source),
274283 HttpError::Cached(source) => classify_http_error(source),
Modified file: vendor/macltok/src/lib.rs+10 additions−9 deletions
@@ -48,16 +48,17 @@ pub use mirror::{
4848 pub use model::{
4949 AccountCommand, AttachmentEncryption, AttachmentUpload, Change, Command, CommandOutput,
5050 ConnectionState, CreateRoomOptions, EncryptionCommand, EncryptionState, EngineErrorKind,
51- EngineFailure, Event, EventRef, HistoryState, KeyBackupState, LatestEvent, LifecycleCommand,
52- Media, MediaCommand, MediaDescriptor, MediaFile, MediaId, MediaKind, MediaLocation, MediaSize,
51+ EngineFailure, Event, EventRef, HistoryState, ImagePack, ImagePackSource, ImagePacks,
52+ KeyBackupState, LatestEvent, LifecycleCommand, LinkPreview, LinkPreviewImage, Media,
53+ MediaCommand, MediaDescriptor, MediaFile, MediaId, MediaKind, MediaLocation, MediaSize,
5354 MemberFilter, MemberMembership, MemberSummary, MembershipChange, Message, MessageContent,
54- MessageEditContent, MessageKind, MessageMentions, NotificationCommand, OwnDevice,
55- PowerLevelChanges, Profile, PushNotification, PushNotificationEvent, PushNotificationRoom,
56- PushNotificationSender, PushNotificationStatus, Reaction, RecoveryState, ReplyDetails,
57- ReplyReference, Retain, RichText, RoomCategory, RoomCommand, RoomHero, RoomListFilter,
58- RoomMatcher, RoomMembership, RoomNotificationMode, RoomNotificationSettings, RoomPowerLevels,
59- RoomPreset, RoomSummary, RoomVisibility, SendState, Sender, SpaceChild, Termination,
60- ThreadSummary, TimelineCommand, TimelineContent, TimelineDiff, TimelineDiffError,
55+ MessageEditContent, MessageKind, MessageMentions, NotificationCommand, OwnDevice, PackImage,
56+ PackUsage, PowerLevelChanges, Profile, PushNotification, PushNotificationEvent,
57+ PushNotificationRoom, PushNotificationSender, PushNotificationStatus, Reaction, RecoveryState,
58+ ReplyDetails, ReplyReference, Retain, RichText, RoomCategory, RoomCommand, RoomHero,
59+ RoomListFilter, RoomMatcher, RoomMembership, RoomNotificationMode, RoomNotificationSettings,
60+ RoomPowerLevels, RoomPreset, RoomSummary, RoomVisibility, SendState, Sender, SpaceChild,
61+ Termination, ThreadSummary, TimelineCommand, TimelineContent, TimelineDiff, TimelineDiffError,
6162 TimelineEvent, TimelineId, TimelineItem, TimelineItemId, TypingState, UploadDetails,
6263 UploadOutcome, UploadThumbnail, UtdCause, VerificationAction, VerificationFailure,
6364 VerificationState, VerificationTarget,
Modified file: vendor/macltok/src/mirror.rs+87 additions−19 deletions
@@ -20,7 +20,7 @@ use tokio::sync::broadcast;
2020
2121 use crate::{
2222 ClientHandle, Command, ConnectionState, EncryptionState, EngineFailure, ErrorKind, Event,
23- HistoryState, RoomSummary, TimelineCommand, TimelineDiff, TimelineId, TimelineItem,
23+ HistoryState, ImagePacks, RoomSummary, TimelineCommand, TimelineDiff, TimelineId, TimelineItem,
2424 TypingState, UploadOutcome, VerificationState, VerificationTarget,
2525 engine::executor::EngineBoxFuture,
2626 };
@@ -46,6 +46,7 @@ pub struct State {
4646 pub verification: Option<VerificationState>,
4747 pub verification_target: Option<VerificationTarget>,
4848 pub rooms: Vector<RoomSummary>,
49+ pub image_packs: ImagePacks,
4950 pub timelines: imbl::HashMap<TimelineId, TimelineState>,
5051 pub typing: imbl::HashMap<OwnedRoomId, Vec<OwnedUserId>>,
5152 pub uploads: imbl::HashMap<OwnedTransactionId, UploadState>,
@@ -125,6 +126,7 @@ pub enum StateChange {
125126 /// room modes arrive with the `Rooms` that follows
126127 NotificationSettings,
127128 Rooms(Vector<RoomSummary>),
129+ ImagePacks(ImagePacks),
128130 Timeline {
129131 timeline_id: TimelineId,
130132 change: TimelineChange,
@@ -169,6 +171,7 @@ pub struct Mirror {
169171 shared: Arc<Shared>,
170172 resyncs: HashMap<TimelineId, AbortHandle>,
171173 rooms_fetch: Option<AbortHandle>,
174+ packs_fetch: Option<AbortHandle>,
172175 }
173176
174177 impl fmt::Debug for Mirror {
@@ -201,6 +204,7 @@ impl Mirror {
201204 verification: None,
202205 verification_target: None,
203206 rooms: Vector::new(),
207+ image_packs: ImagePacks::default(),
204208 timelines: imbl::HashMap::new(),
205209 typing: imbl::HashMap::new(),
206210 uploads: imbl::HashMap::new(),
@@ -212,7 +216,8 @@ impl Mirror {
212216 state: Arc::new(state),
213217 sequence: 0,
214218 updates: Some(broadcast::channel(capacity.max(1)).0),
215- rooms_fetch: RoomsFetch::Idle,
219+ rooms_fetch: Fetch::Idle,
220+ packs_fetch: Fetch::Idle,
216221 }),
217222 });
218223 #[cfg(not(target_family = "wasm"))]
@@ -225,6 +230,7 @@ impl Mirror {
225230 shared: shared.clone(),
226231 resyncs: HashMap::new(),
227232 rooms_fetch: None,
233+ packs_fetch: None,
228234 };
229235 (mirror, MirrorHandle { shared })
230236 }
@@ -233,6 +239,7 @@ impl Mirror {
233239 pub fn run(mut self) -> EngineBoxFuture<'static, ()> {
234240 Box::pin(async move {
235241 self.refetch_rooms();
242+ self.refetch_image_packs();
236243 while let Some(event) = self.events.next().await {
237244 self.apply(event);
238245 }
@@ -286,6 +293,7 @@ impl Mirror {
286293 self.publish(|_| Some(StateChange::NotificationSettings));
287294 }
288295 Event::RoomsChanged => self.refetch_rooms(),
296+ Event::ImagePacksChanged => self.refetch_image_packs(),
289297 Event::EncryptionChanged(encryption) => self.publish(|state| {
290298 state.encryption = Some(encryption.clone());
291299 Some(StateChange::Encryption(encryption))
@@ -451,16 +459,22 @@ impl Mirror {
451459 }
452460
453461 fn refetch_rooms(&mut self) {
454- let mut published = self.shared.lock();
455- match published.rooms_fetch {
456- RoomsFetch::Idle => {
457- published.rooms_fetch = RoomsFetch::Running;
458- drop(published);
459- let task = spawn(fetch_rooms(self.handle.clone(), self.shared.clone()));
460- self.rooms_fetch = Some(task.abort_handle());
461- }
462- RoomsFetch::Running => published.rooms_fetch = RoomsFetch::Again,
463- RoomsFetch::Again => {}
462+ let fetch = fetch_rooms(self.handle.clone(), self.shared.clone());
463+ if let Some(task) = self
464+ .shared
465+ .start(|published| &mut published.rooms_fetch, fetch)
466+ {
467+ self.rooms_fetch = Some(task);
468+ }
469+ }
470+
471+ fn refetch_image_packs(&mut self) {
472+ let fetch = fetch_image_packs(self.handle.clone(), self.shared.clone());
473+ if let Some(task) = self
474+ .shared
475+ .start(|published| &mut published.packs_fetch, fetch)
476+ {
477+ self.packs_fetch = Some(task);
464478 }
465479 }
466480
@@ -484,7 +498,10 @@ impl Mirror {
484498 impl Drop for Mirror {
485499 fn drop(&mut self) {
486500 self.cancel_resyncs();
487- if let Some(task) = self.rooms_fetch.take() {
501+ for task in [self.rooms_fetch.take(), self.packs_fetch.take()]
502+ .into_iter()
503+ .flatten()
504+ {
488505 task.abort();
489506 }
490507 self.shared.lock().updates = None;
@@ -518,12 +535,28 @@ async fn fetch_rooms(handle: ClientHandle, shared: Arc<Shared>) {
518535 state.rooms = rooms.clone();
519536 Some(StateChange::Rooms(rooms))
520537 });
521- let mut published = shared.lock();
522- if published.rooms_fetch != RoomsFetch::Again {
523- published.rooms_fetch = RoomsFetch::Idle;
538+ if !shared.fetched(|published| &mut published.rooms_fetch) {
539+ return;
540+ }
541+ }
542+}
543+
544+/// a failed read keeps the packs from before, the next change asks again
545+async fn fetch_image_packs(handle: ClientHandle, shared: Arc<Shared>) {
546+ loop {
547+ match handle.image_packs().await {
548+ Ok(packs) => shared.publish(|state| {
549+ if state.image_packs == packs {
550+ return None;
551+ }
552+ state.image_packs = packs.clone();
553+ Some(StateChange::ImagePacks(packs))
554+ }),
555+ Err(error) => tracing::warn!(%error, "reading image packs failed"),
556+ }
557+ if !shared.fetched(|published| &mut published.packs_fetch) {
524558 return;
525559 }
526- published.rooms_fetch = RoomsFetch::Running;
527560 }
528561 }
529562
@@ -592,11 +625,12 @@ struct Published {
592625 sequence: u64,
593626 /// gone once the mirror stops, which ends every subscription
594627 updates: Option<broadcast::Sender<Update>>,
595- rooms_fetch: RoomsFetch,
628+ rooms_fetch: Fetch,
629+ packs_fetch: Fetch,
596630 }
597631
598632 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
599-enum RoomsFetch {
633+enum Fetch {
600634 Idle,
601635 Running,
602636 Again,
@@ -609,6 +643,40 @@ impl Shared {
609643 .unwrap_or_else(PoisonError::into_inner)
610644 }
611645
646+ /// one fetch at a time, with at most one more owed
647+ fn start(
648+ &self,
649+ fetch: impl FnOnce(&mut Published) -> &mut Fetch,
650+ task: impl Future<Output = ()> + SendOutsideWasm + 'static,
651+ ) -> Option<AbortHandle> {
652+ let mut published = self.lock();
653+ let fetch = fetch(&mut published);
654+ match *fetch {
655+ Fetch::Idle => {
656+ *fetch = Fetch::Running;
657+ drop(published);
658+ Some(spawn(task).abort_handle())
659+ }
660+ Fetch::Running => {
661+ *fetch = Fetch::Again;
662+ None
663+ }
664+ Fetch::Again => None,
665+ }
666+ }
667+
668+ /// whether another fetch was asked for while this one ran
669+ fn fetched(&self, fetch: impl FnOnce(&mut Published) -> &mut Fetch) -> bool {
670+ let mut published = self.lock();
671+ let fetch = fetch(&mut published);
672+ if *fetch == Fetch::Again {
673+ *fetch = Fetch::Running;
674+ return true;
675+ }
676+ *fetch = Fetch::Idle;
677+ false
678+ }
679+
612680 /// state, sequence and send move together under the one lock, which is what lets
613681 /// `subscribe` take a snapshot and a receiver with nothing between them
614682 fn publish(&self, change: impl FnOnce(&mut State) -> Option<StateChange>) {
Modified file: vendor/macltok/src/model.rs+117 additions−1 deletions
@@ -3,7 +3,7 @@ use std::{fmt, sync::Arc};
33 use imbl::Vector;
44 use ruma::{
55 MxcUri, OwnedDeviceId, OwnedEventId, OwnedMxcUri, OwnedRoomAliasId, OwnedRoomId,
6- OwnedRoomOrAliasId, OwnedServerName, OwnedTransactionId, OwnedUserId,
6+ OwnedRoomOrAliasId, OwnedServerName, OwnedTransactionId, OwnedUserId, RoomId,
77 events::{
88 AnyGlobalAccountDataEventContent, AnyRoomAccountDataEventContent, AnySyncTimelineEvent,
99 GlobalAccountDataEventType, RoomAccountDataEventType,
@@ -236,6 +236,8 @@ pub enum TimelineCommand {
236236 timeline_id: TimelineId,
237237 target: EventRef,
238238 key: String,
239+ /// a custom emote's shortcode, sent along so clients without the pack can label it
240+ shortcode: Option<String>,
239241 },
240242 /// works on a closed room too, it just can't refuse to move the marker backwards
241243 MarkRead {
@@ -654,6 +656,8 @@ pub enum Event {
654656 NotificationSettingsChanged,
655657 VerificationTargetChanged(VerificationTarget),
656658 RoomsChanged,
659+ /// an image pack came, went or changed somewhere. ask `image_packs` again
660+ ImagePacksChanged,
657661 /// replaces whatever you had for this timeline. every diff after it applies to it
658662 TimelineReset {
659663 timeline_id: TimelineId,
@@ -1490,6 +1494,36 @@ pub struct Message {
14901494 pub mentions: Option<MessageMentions>,
14911495 pub edited: bool,
14921496 pub media: Option<MediaDescriptor>,
1497+ /// previews the sender attached (msc4095), so showing them asks no one about the url.
1498+ /// one for a url the body doesn't contain is dropped
1499+ pub link_previews: Vec<LinkPreview>,
1500+}
1501+
1502+/// a link card, from the sender's own message or from `ClientHandle::url_preview`. page
1503+/// text passes through whoever made it unfiltered, so it arrives trimmed to a sane length
1504+#[derive(Clone, Debug, Eq, PartialEq)]
1505+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1506+#[non_exhaustive]
1507+pub struct LinkPreview {
1508+ /// the url as it appears in the message, or as it was asked about
1509+ pub url: String,
1510+ /// where the page says it lives, when that's somewhere else
1511+ pub canonical_url: Option<String>,
1512+ pub title: Option<String>,
1513+ pub description: Option<String>,
1514+ pub site_name: Option<String>,
1515+ pub image: Option<LinkPreviewImage>,
1516+}
1517+
1518+#[derive(Clone, Debug, Eq, PartialEq)]
1519+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1520+#[non_exhaustive]
1521+pub struct LinkPreviewImage {
1522+ pub media_id: MediaId,
1523+ pub mimetype: Option<String>,
1524+ pub size_bytes: Option<u64>,
1525+ pub width: Option<u64>,
1526+ pub height: Option<u64>,
14931527 }
14941528
14951529 #[derive(Clone, Debug, Eq, PartialEq)]
@@ -1603,11 +1637,93 @@ pub enum ReplyDetails {
16031637 #[non_exhaustive]
16041638 pub struct Reaction {
16051639 pub key: String,
1640+ /// a custom emote's shortcode, from whichever sender gave one
1641+ pub shortcode: Option<String>,
1642+ /// the custom emote itself, when the key is an image
1643+ pub image: Option<MediaId>,
16061644 pub count: usize,
16071645 pub own: bool,
16081646 pub senders: Vec<OwnedUserId>,
16091647 }
16101648
1649+/// every custom emote and sticker pack the account can reach, from the user's own
1650+/// pack, each joined room's packs, and the rooms the user enabled everywhere
1651+#[derive(Clone, Debug, Default, Eq, PartialEq)]
1652+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1653+#[non_exhaustive]
1654+pub struct ImagePacks {
1655+ /// the user's own pack, then the packs of rooms enabled everywhere
1656+ pub global: Vec<ImagePack>,
1657+ /// each joined room's own packs, rooms without any left out
1658+ pub rooms: imbl::HashMap<OwnedRoomId, Vec<ImagePack>>,
1659+}
1660+
1661+impl ImagePacks {
1662+ /// what a picker in this room offers. the user's pack, the room's own, then the
1663+ /// rest of the enabled ones, each pack once
1664+ #[must_use]
1665+ pub fn for_room(&self, room_id: Option<&RoomId>) -> Vec<&ImagePack> {
1666+ let is_user = |pack: &&ImagePack| pack.source == ImagePackSource::User;
1667+ let own = room_id.and_then(|room_id| self.rooms.get(room_id));
1668+ let mut packs: Vec<&ImagePack> = self.global.iter().filter(is_user).collect();
1669+ packs.extend(own.into_iter().flatten());
1670+ for pack in self.global.iter().filter(|pack| !is_user(pack)) {
1671+ if !packs.iter().any(|seen| seen.id == pack.id) {
1672+ packs.push(pack);
1673+ }
1674+ }
1675+ packs
1676+ }
1677+}
1678+
1679+#[derive(Clone, Debug, Eq, PartialEq)]
1680+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1681+#[non_exhaustive]
1682+pub struct ImagePack {
1683+ /// `user`, or `room:<room id>:<state key>`, the same across calls
1684+ pub id: String,
1685+ pub display_name: Option<String>,
1686+ pub avatar: Option<MediaId>,
1687+ pub attribution: Option<String>,
1688+ pub source: ImagePackSource,
1689+ pub images: Vec<PackImage>,
1690+}
1691+
1692+#[derive(Clone, Debug, Eq, PartialEq)]
1693+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1694+#[non_exhaustive]
1695+pub enum ImagePackSource {
1696+ User,
1697+ Room {
1698+ room_id: OwnedRoomId,
1699+ state_key: String,
1700+ },
1701+}
1702+
1703+#[derive(Clone, Debug, Eq, PartialEq)]
1704+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1705+#[non_exhaustive]
1706+pub struct PackImage {
1707+ pub shortcode: String,
1708+ pub media_id: MediaId,
1709+ /// what `toggle_reaction` takes to react with this image
1710+ pub reaction_key: String,
1711+ pub body: Option<String>,
1712+ pub mimetype: Option<String>,
1713+ pub width: Option<u64>,
1714+ pub height: Option<u64>,
1715+ /// the image's own, else its pack's, else both
1716+ pub usage: Vec<PackUsage>,
1717+}
1718+
1719+#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
1720+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1721+#[non_exhaustive]
1722+pub enum PackUsage {
1723+ Emoticon,
1724+ Sticker,
1725+}
1726+
16111727 #[derive(Clone, Debug, Eq, PartialEq)]
16121728 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
16131729 #[non_exhaustive]
Added file: vendor/macltok/src/normalise/image_pack.rs+152 additions
@@ -0,0 +1,152 @@
1+use std::collections::{BTreeMap, BTreeSet};
2+
3+use matrix_sdk::{Client, Room, deserialized_responses::RawAnySyncOrStrippedState};
4+use ruma::{
5+ OwnedRoomId,
6+ events::{
7+ GlobalAccountDataEventType, StateEventType,
8+ image_pack::{PackImage as SourceImage, PackInfo, PackUsage as SourceUsage},
9+ },
10+ serde::{JsonObject, Raw},
11+};
12+
13+use crate::{Error, ImagePack, ImagePackSource, ImagePacks, MediaId, PackImage, PackUsage};
14+
15+pub(crate) const ROOM_PACKS: &str = "im.ponies.room_emotes";
16+pub(crate) const USER_PACK: &str = "im.ponies.user_emotes";
17+pub(crate) const ENABLED_ROOMS: &str = "im.ponies.emote_rooms";
18+
19+/// rooms enabled everywhere that the user hasn't joined are skipped, we hold no state for them
20+pub(crate) async fn image_packs(client: &Client) -> Result<ImagePacks, Error> {
21+ let mut rooms = imbl::HashMap::new();
22+ for room in client.joined_rooms() {
23+ let packs = room_packs(&room).await?;
24+ if !packs.is_empty() {
25+ rooms.insert(room.room_id().to_owned(), packs);
26+ }
27+ }
28+ let mut global = Vec::new();
29+ if let Some(content) = account_data(client, USER_PACK).await? {
30+ global.extend(pack("user".to_owned(), ImagePackSource::User, &content));
31+ }
32+ if let Some(content) = account_data(client, ENABLED_ROOMS).await? {
33+ let enabled = content
34+ .get_field::<BTreeMap<OwnedRoomId, BTreeMap<String, Raw<JsonObject>>>>("rooms")
35+ .ok()
36+ .flatten()
37+ .unwrap_or_default();
38+ for (room_id, state_keys) in enabled {
39+ let packs = rooms.get(&room_id).into_iter().flatten();
40+ global.extend(
41+ packs
42+ .filter(|pack| match &pack.source {
43+ ImagePackSource::Room { state_key, .. } => {
44+ state_keys.contains_key(state_key)
45+ }
46+ ImagePackSource::User => false,
47+ })
48+ .cloned(),
49+ );
50+ }
51+ }
52+ Ok(ImagePacks { global, rooms })
53+}
54+
55+async fn room_packs(room: &Room) -> Result<Vec<ImagePack>, Error> {
56+ let events = room
57+ .get_state_events(StateEventType::from(ROOM_PACKS))
58+ .await
59+ .map_err(|source| Error::from_sdk("reading image packs failed", source))?;
60+ let by_state_key: BTreeMap<String, Raw<JsonObject>> = events
61+ .into_iter()
62+ .filter_map(|event| match event {
63+ RawAnySyncOrStrippedState::Sync(event) => Some((
64+ event.get_field::<String>("state_key").ok()??,
65+ event.get_field::<Raw<JsonObject>>("content").ok()??,
66+ )),
67+ RawAnySyncOrStrippedState::Stripped(_) => None,
68+ })
69+ .collect();
70+ Ok(by_state_key
71+ .into_iter()
72+ .filter_map(|(state_key, content)| {
73+ let source = ImagePackSource::Room {
74+ room_id: room.room_id().to_owned(),
75+ state_key: state_key.clone(),
76+ };
77+ pack(
78+ format!("room:{}:{state_key}", room.room_id()),
79+ source,
80+ &content,
81+ )
82+ })
83+ .collect())
84+}
85+
86+async fn account_data(client: &Client, event_type: &str) -> Result<Option<Raw<JsonObject>>, Error> {
87+ Ok(client
88+ .account()
89+ .account_data_raw(GlobalAccountDataEventType::from(event_type))
90+ .await
91+ .map_err(|source| Error::from_sdk("reading image packs failed", source))?
92+ .map(|content| content.cast_unchecked()))
93+}
94+
95+/// one bad image costs that image, not its pack. a pack with nothing usable left is dropped
96+fn pack(id: String, source: ImagePackSource, content: &Raw<JsonObject>) -> Option<ImagePack> {
97+ let info = content
98+ .get_field::<PackInfo>("pack")
99+ .ok()
100+ .flatten()
101+ .unwrap_or_default();
102+ let pack_usage = usage(&info.usage);
103+ let images: Vec<PackImage> = content
104+ .get_field::<BTreeMap<String, Raw<SourceImage>>>("images")
105+ .ok()
106+ .flatten()
107+ .unwrap_or_default()
108+ .into_iter()
109+ .filter_map(|(shortcode, image)| {
110+ let image = image.deserialize().ok()?;
111+ if !image.url.is_valid() {
112+ return None;
113+ }
114+ let info = image.info.as_ref();
115+ Some(PackImage {
116+ shortcode,
117+ media_id: MediaId::plain(&image.url),
118+ reaction_key: image.url.to_string(),
119+ body: image.body,
120+ mimetype: info.and_then(|info| info.mimetype.clone()),
121+ width: info.and_then(|info| info.width).map(u64::from),
122+ height: info.and_then(|info| info.height).map(u64::from),
123+ usage: usage(&image.usage)
124+ .or_else(|| pack_usage.clone())
125+ .unwrap_or_else(|| vec![PackUsage::Emoticon, PackUsage::Sticker]),
126+ })
127+ })
128+ .collect();
129+ (!images.is_empty()).then(|| ImagePack {
130+ id,
131+ display_name: info.display_name,
132+ avatar: info
133+ .avatar_url
134+ .filter(|uri| uri.is_valid())
135+ .map(|uri| MediaId::plain(&uri)),
136+ attribution: info.attribution,
137+ source,
138+ images,
139+ })
140+}
141+
142+fn usage(usage: &BTreeSet<SourceUsage>) -> Option<Vec<PackUsage>> {
143+ let usage: Vec<PackUsage> = usage
144+ .iter()
145+ .filter_map(|usage| match usage {
146+ SourceUsage::Emoticon => Some(PackUsage::Emoticon),
147+ SourceUsage::Sticker => Some(PackUsage::Sticker),
148+ _ => None,
149+ })
150+ .collect();
151+ (!usage.is_empty()).then_some(usage)
152+}
Added file: vendor/macltok/src/normalise/link_preview.rs+217 additions
@@ -0,0 +1,217 @@
1+use matrix_sdk::{Room, deserialized_responses::RawAnySyncOrStrippedState};
2+use ruma::{
3+ MxcUri,
4+ events::{
5+ GlobalAccountDataEventType, RoomAccountDataEventType, StateEventType,
6+ room::{
7+ MediaSource,
8+ message::{MessageType, PreviewImageSource, UrlPreview},
9+ },
10+ },
11+ serde::{JsonObject, Raw},
12+};
13+
14+use super::media::{MediaRegistration, MediaSourceInfo, media_id};
15+use crate::{Error, LinkPreview, LinkPreviewImage, MediaId, MediaKind};
16+
17+/// room state an admin sets, and room account data a user sets, under the same name
18+pub(crate) const ROOM_PREVIEW_URLS: &str = "org.matrix.room.preview_urls";
19+const USER_PREVIEW_URLS: &str = "org.matrix.preview_urls";
20+
21+const SHORT_TEXT: usize = 256;
22+const LONG_TEXT: usize = 1024;
23+
24+/// the previews a text message's sender attached, each with the registration its image needs
25+/// when it's encrypted. embedded reply content has no media identity, so its encrypted images
26+/// go without
27+pub(crate) fn sender_previews(
28+ identity: Option<&str>,
29+ message: &MessageType,
30+) -> Vec<(LinkPreview, Option<MediaRegistration>)> {
31+ let MessageType::Text(text) = message else {
32+ return Vec::new();
33+ };
34+ text.url_previews
35+ .iter()
36+ .flatten()
37+ .enumerate()
38+ .filter_map(|(index, preview)| sender_preview(identity, &text.body, index, preview))
39+ .collect()
40+}
41+
42+/// the matched url has to be in the body, or a sender could caption any link with any card
43+fn sender_preview(
44+ identity: Option<&str>,
45+ body: &str,
46+ index: usize,
47+ preview: &UrlPreview,
48+) -> Option<(LinkPreview, Option<MediaRegistration>)> {
49+ let url = preview.matched_url.as_deref()?;
50+ if url.is_empty() || !body.contains(url) || !preview.contains_preview() {
51+ return None;
52+ }
53+ let (image, registration) = match &preview.image {
54+ None => (None, None),
55+ Some(image) => {
56+ let (media_id, registration) = match &image.source {
57+ PreviewImageSource::Url(uri) => (plain(uri)?, None),
58+ PreviewImageSource::EncryptedImage(file) => {
59+ let id = media_id(identity?, &format!("preview{index}"));
60+ let registration = MediaRegistration {
61+ id: id.clone(),
62+ kind: MediaKind::Image,
63+ full: MediaSourceInfo {
64+ source: MediaSource::Encrypted(Box::new(file.clone())),
65+ mimetype: image.mimetype.clone(),
66+ declared_size: image.size.map(u64::from),
67+ },
68+ thumbnail: None,
69+ };
70+ (id, Some(registration))
71+ }
72+ };
73+ let image = LinkPreviewImage {
74+ media_id,
75+ mimetype: image.mimetype.clone(),
76+ size_bytes: image.size.map(u64::from),
77+ width: image.width.map(u64::from),
78+ height: image.height.map(u64::from),
79+ };
80+ (Some(image), registration)
81+ }
82+ };
83+ let preview = LinkPreview {
84+ url: url.to_owned(),
85+ canonical_url: preview.url.clone().filter(|canonical| canonical != url),
86+ title: clamp(preview.title.as_deref(), SHORT_TEXT),
87+ description: clamp(preview.description.as_deref(), LONG_TEXT),
88+ site_name: None,
89+ image,
90+ };
91+ Some((preview, registration))
92+}
93+
94+/// what the homeserver's `preview_url` answered, a loose map of `og:` keys. `None` when
95+/// nothing in it is worth a card
96+pub(crate) fn server_preview(url: &str, data: &Raw<JsonObject>) -> Option<LinkPreview> {
97+ let text = |key: &str| data.get_field::<String>(key).ok().flatten();
98+ let image = text("og:image")
99+ .as_deref()
100+ .and_then(|uri| plain(<&MxcUri>::from(uri)))
101+ .map(|media_id| LinkPreviewImage {
102+ media_id,
103+ mimetype: text("og:image:type"),
104+ size_bytes: number(data, "matrix:image:size"),
105+ width: number(data, "og:image:width"),
106+ height: number(data, "og:image:height"),
107+ });
108+ let preview = LinkPreview {
109+ url: url.to_owned(),
110+ canonical_url: text("og:url").filter(|canonical| canonical != url),
111+ title: clamp(text("og:title").as_deref(), SHORT_TEXT),
112+ description: clamp(text("og:description").as_deref(), LONG_TEXT),
113+ site_name: clamp(text("og:site_name").as_deref(), SHORT_TEXT),
114+ image,
115+ };
116+ (preview.title.is_some() || preview.description.is_some() || preview.image.is_some())
117+ .then_some(preview)
118+}
119+
120+/// element's order, so both clients agree. the user's choice for this room, then their own
121+/// default, then the room admins'. encrypted rooms are the host's call
122+pub(crate) async fn url_previews_allowed(room: &Room) -> Result<bool, Error> {
123+ let room_choice = room
124+ .account_data(RoomAccountDataEventType::from(ROOM_PREVIEW_URLS))
125+ .await
126+ .map_err(|source| Error::from_sdk("reading url preview settings failed", source))?
127+ .and_then(|event| disabled(&event.get_field::<Raw<JsonObject>>("content").ok()??));
128+ let own_default = || async {
129+ room.client()
130+ .account()
131+ .account_data_raw(GlobalAccountDataEventType::from(USER_PREVIEW_URLS))
132+ .await
133+ .map(|content| content.and_then(|content| disabled(&content.cast_unchecked())))
134+ .map_err(|source| Error::from_sdk("reading url preview settings failed", source))
135+ };
136+ let admins = || async {
137+ room.get_state_event(StateEventType::from(ROOM_PREVIEW_URLS), "")
138+ .await
139+ .map(|event| match event {
140+ Some(RawAnySyncOrStrippedState::Sync(event)) => {
141+ disabled(&event.get_field::<Raw<JsonObject>>("content").ok()??)
142+ }
143+ _ => None,
144+ })
145+ .map_err(|source| Error::from_sdk("reading url preview settings failed", source))
146+ };
147+ let disabled = match room_choice {
148+ Some(disabled) => disabled,
149+ None => match own_default().await? {
150+ Some(disabled) => disabled,
151+ None => admins().await?.unwrap_or(false),
152+ },
153+ };
154+ Ok(!disabled)
155+}
156+
157+fn disabled(content: &Raw<JsonObject>) -> Option<bool> {
158+ content.get_field::<bool>("disable").ok().flatten()
159+}
160+
161+fn plain(uri: &MxcUri) -> Option<MediaId> {
162+ uri.is_valid().then(|| MediaId::plain(uri))
163+}
164+
165+/// servers send dimensions as numbers or as the page's own strings
166+fn number(data: &Raw<JsonObject>, key: &str) -> Option<u64> {
167+ data.get_field::<u64>(key).ok().flatten().or_else(|| {
168+ data.get_field::<String>(key)
169+ .ok()
170+ .flatten()?
171+ .trim()
172+ .parse()
173+ .ok()
174+ })
175+}
176+
177+fn clamp(text: Option<&str>, chars: usize) -> Option<String> {
178+ let text = text?.trim();
179+ (!text.is_empty()).then(|| match text.char_indices().nth(chars) {
180+ Some((end, _)) => text[..end].trim_end().to_owned(),
181+ None => text.to_owned(),
182+ })
183+}
184+
185+#[cfg(test)]
186+mod tests {
187+ use ruma::{events::room::message::MessageType, serde::Raw};
188+ use serde_json::json;
189+
190+ use super::sender_previews;
191+
192+ #[test]
193+ fn a_sender_cannot_caption_a_link_the_message_does_not_contain() {
194+ let message: MessageType = Raw::new(&json!({
195+ "msgtype": "m.text",
196+ "body": "look at https://example.org/real",
197+ "com.beeper.linkpreviews": [
198+ {
199+ "matched_url": "https://example.org/real",
200+ "og:title": "real",
201+ "og:image": "mxc://example.org/image",
202+ },
203+ { "matched_url": "https://bank.example/login", "og:title": "your bank" },
204+ ],
205+ }))
206+ .unwrap()
207+ .cast_unchecked()
208+ .deserialize()
209+ .unwrap();
210+ let previews = sender_previews(Some("1#item"), &message);
211+ assert_eq!(previews.len(), 1);
212+ let (preview, registration) = &previews[0];
213+ assert_eq!(preview.url, "https://example.org/real");
214+ assert_eq!(preview.title.as_deref(), Some("real"));
215+ assert!(preview.image.is_some() && registration.is_none());
216+ }
217+}
Modified file: vendor/macltok/src/normalise/media.rs+20 additions−9 deletions
@@ -10,6 +10,7 @@ use ruma::events::{
1010 sticker::StickerEventContent,
1111 };
1212
13+use super::link_preview::sender_previews;
1314 use crate::{MediaDescriptor, MediaId, MediaKind};
1415
1516 static NEXT_MEDIA_SCOPE: AtomicU64 = AtomicU64::new(1);
@@ -54,20 +55,30 @@ pub(crate) struct MediaSourceInfo {
5455 pub(crate) declared_size: Option<u64>,
5556 }
5657
57-pub(super) fn media_registration_for_item(
58+pub(super) fn media_registrations_for_item(
5859 item: &SdkTimelineItem,
5960 scope: &MediaScope,
60-) -> Option<MediaRegistration> {
61- let event = item.as_event()?;
61+) -> Vec<MediaRegistration> {
62+ let Some(event) = item.as_event() else {
63+ return Vec::new();
64+ };
6265 let identity = media_identity(item, scope);
63- let content = match event.content() {
64- TimelineItemContent::MsgLike(content) => content,
65- _ => return None,
66+ let TimelineItemContent::MsgLike(content) = event.content() else {
67+ return Vec::new();
6668 };
6769 match &content.kind {
68- MsgLikeKind::Message(message) => media_registration(&identity, message.msgtype()),
69- MsgLikeKind::Sticker(sticker) => media_sticker_registration(&identity, sticker.content()),
70- _ => None,
70+ MsgLikeKind::Message(message) => media_registration(&identity, message.msgtype())
71+ .into_iter()
72+ .chain(
73+ sender_previews(Some(&identity), message.msgtype())
74+ .into_iter()
75+ .filter_map(|(_, registration)| registration),
76+ )
77+ .collect(),
78+ MsgLikeKind::Sticker(sticker) => media_sticker_registration(&identity, sticker.content())
79+ .into_iter()
80+ .collect(),
81+ _ => Vec::new(),
7182 }
7283 }
7384
Modified file: vendor/macltok/src/normalise/mod.rs+4 additions
@@ -1,8 +1,12 @@
1+mod image_pack;
2+mod link_preview;
13 mod media;
24 mod member;
35 mod room;
46 mod timeline;
57
8+pub(crate) use image_pack::{ENABLED_ROOMS, ROOM_PACKS, USER_PACK, image_packs};
9+pub(crate) use link_preview::{ROOM_PREVIEW_URLS, server_preview, url_previews_allowed};
610 pub(crate) use media::{MediaRegistration, MediaScope, MediaSourceInfo};
711 pub(crate) use member::member_summary;
812 pub(crate) use room::notification_mode;
Modified file: vendor/macltok/src/normalise/timeline.rs+13 additions−5 deletions
@@ -11,7 +11,7 @@ use matrix_sdk_ui::{
1111 },
1212 };
1313 use ruma::{
14- OwnedEventId, UserId,
14+ MxcUri, OwnedEventId, UserId,
1515 events::{
1616 StateEventContentChange,
1717 room::message::{MessageFormat, MessageType},
@@ -24,9 +24,10 @@ use crate::{
2424 TimelineEvent, TimelineItem, TimelineItemId, UtdCause,
2525 };
2626
27+use super::link_preview::sender_previews;
2728 use super::media::{
2829 MediaRegistration, MediaScope, media_descriptor_for_sticker, media_from_message,
29- media_identity, media_registration_for_item,
30+ media_identity, media_registrations_for_item,
3031 };
3132
3233 #[derive(Default)]
@@ -52,9 +53,8 @@ struct Normaliser<'a> {
5253
5354 impl Normaliser<'_> {
5455 fn item(&mut self, item: &SdkTimelineItem) -> TimelineItem {
55- if let Some(media) = media_registration_for_item(item, self.scope) {
56- self.media.push(media);
57- }
56+ self.media
57+ .extend(media_registrations_for_item(item, self.scope));
5858 match item.kind() {
5959 TimelineItemKind::Event(event) => {
6060 TimelineItem::Event(Box::new(self.event(item, event)))
@@ -282,6 +282,10 @@ fn msg_like(content: &MsgLikeContent, media_identity: Option<&str>) -> TimelineC
282282 edited: message.is_edited(),
283283 media: media_identity
284284 .and_then(|identity| media_from_message(identity, message.msgtype())),
285+ link_previews: sender_previews(media_identity, message.msgtype())
286+ .into_iter()
287+ .map(|(preview, _)| preview)
288+ .collect(),
285289 }),
286290 MsgLikeKind::Sticker(sticker) => {
287291 let sticker = sticker.content();
@@ -421,6 +425,10 @@ fn reactions(event: &EventTimelineItem, current_user_id: Option<&UserId>) -> Vec
421425 .flat_map(|reactions| reactions.iter())
422426 .map(|(key, senders)| Reaction {
423427 key: key.clone(),
428+ shortcode: senders.values().find_map(|info| info.shortcode.clone()),
429+ image: <&MxcUri>::from(key.as_str())
430+ .is_valid()
431+ .then(|| MediaId::plain(key)),
424432 count: senders.len(),
425433 own: current_user_id.is_some_and(|user_id| senders.contains_key(user_id)),
426434 senders: senders.keys().cloned().collect(),
Modified file: vendor/macltok/src/transport.rs+27 additions−6 deletions
@@ -27,17 +27,17 @@ use tokio::sync::{OnceCell, mpsc, oneshot, watch};
2727
2828 use crate::{
2929 AccountCommand, AttachmentUpload, Command, CommandOutput, ConnectionState, CreateRoomOptions,
30- EncryptionCommand, EncryptionState, Error, ErrorKind, Event, EventRef, InteractiveAuth,
31- InteractiveAuthOutcome, LifecycleCommand, Media, MediaCommand, MediaId, MediaLocation,
32- MediaSize, MemberFilter, MemberSummary, MessageContent, MessageEditContent,
33- NotificationCommand, OwnDevice, PowerLevelChanges, Profile, PushNotification,
34- PushNotificationEvent, PushNotificationStatus, RoomCommand, RoomListFilter,
30+ EncryptionCommand, EncryptionState, Error, ErrorKind, Event, EventRef, ImagePacks,
31+ InteractiveAuth, InteractiveAuthOutcome, LifecycleCommand, LinkPreview, Media, MediaCommand,
32+ MediaId, MediaLocation, MediaSize, MemberFilter, MemberSummary, MessageContent,
33+ MessageEditContent, NotificationCommand, OwnDevice, PowerLevelChanges, Profile,
34+ PushNotification, PushNotificationEvent, PushNotificationStatus, RoomCommand, RoomListFilter,
3535 RoomNotificationMode, RoomNotificationSettings, RoomPowerLevels, RoomSummary, SpaceChild,
3636 TimelineCommand, TimelineId, VerificationAction, VerificationTarget,
3737 encryption::encryption_state,
3838 engine::{MediaService, StoreLock},
3939 model::{PushNotificationRoom, PushNotificationSender},
40- normalise::{member_summary, room_summaries, space_child},
40+ normalise::{image_packs, member_summary, room_summaries, space_child, url_previews_allowed},
4141 };
4242
4343 #[derive(Clone)]
@@ -341,11 +341,13 @@ impl ClientHandle {
341341 timeline_id: TimelineId,
342342 target: impl Into<EventRef>,
343343 key: impl Into<String>,
344+ shortcode: Option<String>,
344345 ) -> Result<(), Error> {
345346 self.run(Command::Timeline(TimelineCommand::ToggleReaction {
346347 timeline_id,
347348 target: target.into(),
348349 key: key.into(),
350+ shortcode,
349351 }))
350352 .await
351353 }
@@ -582,6 +584,21 @@ impl ClientHandle {
582584 room_summaries(&self.client, self.spaces().await).await
583585 }
584586
587+ pub async fn image_packs(&self) -> Result<ImagePacks, Error> {
588+ image_packs(&self.client).await
589+ }
590+
591+ /// the homeserver's preview of a page, `None` when it has none. the homeserver sees every
592+ /// url asked about, so a host decides whether encrypted rooms ask at all
593+ pub async fn url_preview(&self, url: &str) -> Result<Option<LinkPreview>, Error> {
594+ self.media.url_preview(url).await
595+ }
596+
597+ /// whether the room and the user's settings let link previews show here
598+ pub async fn url_previews_allowed(&self, room_id: &RoomId) -> Result<bool, Error> {
599+ url_previews_allowed(&self.room(room_id)?).await
600+ }
601+
585602 /// browse a space the way the server sees it, rooms you haven't joined included
586603 pub async fn space_hierarchy(&self, space_id: OwnedRoomId) -> SpaceHierarchy {
587604 SpaceHierarchy(SpaceRoomList::new(self.client.clone(), space_id).await)
@@ -986,6 +1003,10 @@ impl EventSender {
9861003 self.0.send(event).await.map_err(|_| SendEventError)
9871004 }
9881005
1006+ pub(crate) async fn reserve(&self) -> Result<mpsc::Permit<'_, Event>, SendEventError> {
1007+ self.0.reserve().await.map_err(|_| SendEventError)
1008+ }
1009+
9891010 /// resolves once every `EventStream` is gone
9901011 pub(crate) async fn closed(&self) {
9911012 self.0.closed().await;
Modified file: web/src/App.svelte+42 additions−8 deletions
@@ -33,6 +33,7 @@
3333 membersByRoom,
3434 membersLoading,
3535 pushBanner,
36+ imagePacks,
3637 resetClientState,
3738 rooms,
3839 selectedRoomId,
@@ -44,9 +45,16 @@
4445 import { discardPending, pendingByRoom, queueSend, resetPending, retryPending } from './lib/pending';
4546 import type { PendingMessage } from './lib/pending';
4647 import { loadQuickReactions, quickReactions, saveQuickReactions } from './lib/quick-reactions';
48+ import {
49+ linkPreviews,
50+ loadUrlPreviewSettings,
51+ requestLinkPreview,
52+ saveUrlPreviewSettings,
53+ urlPreviewSettings
54+ } from './lib/link-previews';
4755 import { notifyTyping, stopTyping } from './lib/typing';
4856 import { formatDay } from './lib/format';
49- import { parseEncryptionState, parseOwnProfile, parseVerificationState } from './lib/types';
57+ import { packsForRoom, parseEncryptionState, parseOwnProfile, parseVerificationState } from './lib/types';
5058 import type { MemberSummary, OwnProfile, QuickReaction, RoomSummary, SendState, TimelineEvent, TimelineItem } from './lib/types';
5159
5260 let roomsLoading = false;
@@ -89,10 +97,16 @@
8997 // account data is read from the local cache, which is empty on a fresh device until the first sync lands
9098 $: {
9199 const online = $connection === 'online';
92- if (online && !wasOnline) void loadQuickReactions();
100+ if (online && !wasOnline) {
101+ void loadQuickReactions();
102+ void loadUrlPreviewSettings();
103+ }
93104 wasOnline = online;
94105 }
95106 $: selectedRoom = $rooms.find((r) => r.room_id === $selectedRoomId) ?? null;
107+ $: roomPacks = packsForRoom($imagePacks, $selectedRoomId);
108+ $: previewsAllowed = selectedRoom !== null && (!selectedRoom.encrypted || $urlPreviewSettings.encrypted_rooms);
109+ $: loadPreview = previewsAllowed && $selectedRoomId ? previewLoader($selectedRoomId) : null;
96110 $: spaceRooms = $rooms.filter((r) => r.space);
97111 $: invites = $rooms.filter((r) => r.membership === 'Invited');
98112 $: query = search.trim().toLowerCase();
@@ -213,6 +227,7 @@
213227 void loadProfile();
214228 void loadEncryption();
215229 void loadQuickReactions();
230+ void loadUrlPreviewSettings();
216231 } else {
217232 softLogout.set(session.soft_logout ?? null);
218233 }
@@ -268,6 +283,7 @@
268283 void loadProfile();
269284 void loadEncryption();
270285 void loadQuickReactions();
286+ void loadUrlPreviewSettings();
271287 }
272288
273289 async function doLogout(): Promise<void> {
@@ -498,12 +514,12 @@
498514 if (fileInput) fileInput.value = '';
499515 }
500516 }
501- async function react(eventId: string, key: string): Promise<void> {
517+ async function react(eventId: string, key: string, shortcode?: string): Promise<void> {
502518 const roomId = $selectedRoomId;
503519 if (!roomId || !eventId || reacting) return;
504520 reacting = true;
505521 try {
506- await reactToMessage(roomId, eventId, key);
522+ await reactToMessage(roomId, eventId, key, shortcode);
507523 } catch (e) {
508524 pushBanner(e instanceof Error ? e.message : 'Reaction failed');
509525 } finally {
@@ -511,6 +527,18 @@
511527 }
512528 }
513529
530+ function previewLoader(roomId: string): (url: string) => void {
531+ return (url) => requestLinkPreview(roomId, url);
532+ }
533+
534+ async function changeEncryptedPreviews(enabled: boolean): Promise<void> {
535+ try {
536+ await saveUrlPreviewSettings({ encrypted_rooms: enabled });
537+ } catch (e) {
538+ pushBanner(e instanceof Error ? e.message : 'Could not save link preview setting');
539+ }
540+ }
541+
514542 async function changeQuickReactions(list: QuickReaction[]): Promise<void> {
515543 try {
516544 await saveQuickReactions(list);
@@ -1083,9 +1111,11 @@
10831111 showSender={g.showSender}
10841112 senderAvatar={senderAvatarFor(g.item.event.sender.user_id)}
10851113 {reacting}
1086- onReact={(eventId, key) => void react(eventId, key)}
1114+ onReact={(eventId, key, shortcode) => void react(eventId, key, shortcode)}
10871115 quickReactions={$quickReactions}
1088- packs={[]}
1116+ packs={roomPacks}
1117+ linkPreviews={$linkPreviews}
1118+ {loadPreview}
10891119 onQuickChange={(list) => void changeQuickReactions(list)}
10901120 />
10911121 {/if}
@@ -1098,9 +1128,11 @@
10981128 showSender={pendingShowSender(i)}
10991129 senderAvatar={senderAvatarFor($sessionUser ?? '')}
11001130 {reacting}
1101- onReact={(eventId, key) => void react(eventId, key)}
1131+ onReact={(eventId, key, shortcode) => void react(eventId, key, shortcode)}
11021132 quickReactions={$quickReactions}
1103- packs={[]}
1133+ packs={roomPacks}
1134+ linkPreviews={$linkPreviews}
1135+ {loadPreview}
11041136 onQuickChange={(list) => void changeQuickReactions(list)}
11051137 />
11061138 {#if p.status === 'failed'}
@@ -1204,6 +1236,8 @@
12041236 encryptionState={$encryption}
12051237 verificationState={$verification}
12061238 onAction={() => void loadEncryption()}
1239+ encryptedPreviews={$urlPreviewSettings.encrypted_rooms}
1240+ onEncryptedPreviews={(enabled) => void changeEncryptedPreviews(enabled)}
12071241 on:close={() => (dialog = null)}
12081242 />
12091243 {:else}
Modified file: web/src/lib/LinkCard.stories.ts+5 additions−4 deletions
@@ -21,6 +21,7 @@ const [link] = findUrls('https://example.org/rabbit-hole/chapter-1?ref=matrix');
2121 function preview(overrides: Partial<LinkPreview> = {}): LinkPreview {
2222 return {
2323 url: link.href,
24+ canonical_url: null,
2425 title: 'Down the Rabbit-Hole',
2526 description:
2627 'Alice was beginning to get very tired of sitting by her sister on the bank, and of having nothing to do.',
@@ -47,13 +48,13 @@ export const UrlOnly: Story = {
4748 };
4849 export const TextOnly: Story = { args: { preview: preview() } };
4950 export const Thumbnail: Story = {
50- args: { preview: preview({ image: { media_id: 'mxc://example.org/icon', width: 200, height: 200 } }) }
51+ args: { preview: preview({ image: { media_id: 'mxc://example.org/icon', mimetype: null, size_bytes: null, width: 200, height: 200 } }) }
5152 };
5253 export const Banner: Story = {
53- args: { preview: preview({ image: { media_id: 'mxc://example.org/banner', width: 1200, height: 630 } }) }
54+ args: { preview: preview({ image: { media_id: 'mxc://example.org/banner', mimetype: null, size_bytes: null, width: 1200, height: 630 } }) }
5455 };
5556 export const UnknownImageSize: Story = {
56- args: { preview: preview({ image: { media_id: 'mxc://example.org/banner', width: null, height: null } }) }
57+ args: { preview: preview({ image: { media_id: 'mxc://example.org/banner', mimetype: null, size_bytes: null, width: null, height: null } }) }
5758 };
5859 export const NoSiteName: Story = {
5960 args: { preview: preview({ site_name: null, description: null }) },
@@ -63,7 +64,7 @@ export const NoSiteName: Story = {
6364 }
6465 };
6566 export const ImageFails: Story = {
66- args: { preview: preview({ image: { media_id: 'mxc://example.org/gone', width: 1200, height: 630 } }) },
67+ args: { preview: preview({ image: { media_id: 'mxc://example.org/gone', mimetype: null, size_bytes: null, width: 1200, height: 630 } }) },
6768 play: async ({ canvasElement }) => {
6869 await waitFor(() => expect(canvasElement.querySelector('img')).toBeNull());
6970 }
Modified file: web/src/lib/MessageRow.stories.ts+84 additions−4 deletions
@@ -28,7 +28,7 @@ function event(content: TimelineContent, overrides: Partial<TimelineEvent> = {})
2828 function text(body: string): TimelineContent {
2929 return {
3030 kind: 'Message',
31- message: { kind: 'Text', body, geo_uri: null, formatted_body: null, edited: false, media: null }
31+ message: { kind: 'Text', body, geo_uri: null, formatted_body: null, edited: false, media: null, link_previews: [] }
3232 };
3333 }
3434
@@ -90,6 +90,7 @@ export const WithRichLink: Story = {
9090 url: 'https://example.org/rabbit-hole',
9191 title: 'Down the Rabbit-Hole',
9292 description: 'Alice was beginning to get very tired of sitting by her sister on the bank.',
93+ canonical_url: null,
9394 site_name: 'Wonderland Press',
9495 image: null
9596 }
@@ -100,13 +101,61 @@ export const WithRichLink: Story = {
100101 await expect(canvas.getByText('example.net')).toBeVisible();
101102 }
102103 };
104+export const BundledPreview: Story = {
105+ args: {
106+ event: event({
107+ kind: 'Message',
108+ message: {
109+ kind: 'Text',
110+ body: 'Notes are at https://example.org/rabbit-hole',
111+ geo_uri: null,
112+ formatted_body: null,
113+ edited: false,
114+ media: null,
115+ link_previews: [
116+ {
117+ url: 'https://example.org/rabbit-hole',
118+ canonical_url: null,
119+ title: 'Down the Rabbit-Hole',
120+ description: 'Embedded by the sender.',
121+ site_name: 'Wonderland Press',
122+ image: null
123+ }
124+ ]
125+ }
126+ }),
127+ loadPreview: fn()
128+ },
129+ play: async ({ args, canvas }) => {
130+ await expect(canvas.getByText('Down the Rabbit-Hole')).toBeVisible();
131+ await expect(args.loadPreview).not.toHaveBeenCalled();
132+ }
133+};
134+export const LoadsMissingPreview: Story = {
135+ args: { event: event(text('Notes are at https://example.org/rabbit-hole')), loadPreview: fn() },
136+ play: async ({ args }) => {
137+ await expect(args.loadPreview).toHaveBeenCalledWith('https://example.org/rabbit-hole');
138+ }
139+};
140+export const ImageReaction: Story = {
141+ args: {
142+ event: event(text('Tea party at six'), {
143+ reactions: [
144+ { key: 'mxc://example.org/rabbit', count: 2, own: false, senders: [], shortcode: 'rabbit', image: 'mxc://example.org/rabbit' }
145+ ]
146+ })
147+ },
148+ play: async ({ canvas }) => {
149+ await expect(canvas.getByAltText('rabbit')).toBeVisible();
150+ }
151+};
103152 export const Reactions: Story = {
104153 args: {
105154 event: event(text('Tea party at six'), {
106155 reactions: [
107- { key: '🎉', count: 3, own: true, senders: [] },
108- { key: '😮', count: 1, own: false, senders: [] },
109- { key: 'mxc://example.org/rabbit', count: 2, own: false, senders: [] }
156+ { key: '🎉', count: 3, own: true, senders: [], shortcode: null, image: null },
157+ { key: '😮', count: 1, own: false, senders: [], shortcode: null, image: null },
158+ { key: 'mxc://example.org/rabbit', count: 2, own: false, senders: [], shortcode: null, image: null }
110159 ]
111160 })
112161 }
@@ -143,3 +192,34 @@ export const JumboEmoji: Story = {
143192 await expect(imgs[0]).toHaveAttribute('draggable', 'false');
144193 }
145194 };
195+export const PackEmotePick: Story = {
196+ args: {
197+ packs: [
198+ {
199+ id: 'user',
200+ display_name: 'My emotes',
201+ avatar: null,
202+ attribution: null,
203+ source: 'User',
204+ images: [
205+ {
206+ shortcode: 'rabbit',
207+ media_id: 'mxc://example.org/rabbit',
208+ reaction_key: 'mxc://example.org/rabbit',
209+ body: null,
210+ mimetype: null,
211+ width: null,
212+ height: null,
213+ usage: ['Emoticon']
214+ }
215+ ]
216+ }
217+ ]
218+ },
219+ play: async ({ args, canvas }) => {
220+ await userEvent.click(canvas.getByText('Curiouser and curiouser!'));
221+ await userEvent.click(canvas.getByRole('button', { name: 'Add reaction to message from Alice' }));
222+ await userEvent.click(await canvas.findByRole('button', { name: ':rabbit:' }));
223+ await expect(args.onReact).toHaveBeenCalledWith('$event1', 'mxc://example.org/rabbit', 'rabbit');
224+ }
225+};
Modified file: web/src/lib/MessageRow.svelte+27 additions−9 deletions
@@ -5,7 +5,7 @@
55 import EmojiText from './EmojiText.svelte';
66 import ReactionPicker from './ReactionPicker.svelte';
77 import LinkCard from './LinkCard.svelte';
8- import { mediaUrl } from './api';
8+ import { mediaUrl, messageMediaUrl } from './api';
99 import type { ImagePack, LinkPreview, QuickReaction, SendState, TimelineContent, TimelineEvent } from './types';
1010 import { senderLabel } from './types';
1111 import { formatClock, formatDay, initialsOf, senderHue } from './format';
@@ -15,12 +15,13 @@
1515 export let event: TimelineEvent;
1616 export let showSender: boolean;
1717 export let reacting: boolean;
18- export let onReact: (eventId: string, key: string) => void;
18+ export let onReact: (eventId: string, key: string, shortcode?: string) => void;
1919 export let senderAvatar: string | null = null;
2020 export let quickReactions: QuickReaction[] = [];
2121 export let packs: ImagePack[] = [];
2222 export let onQuickChange: (list: QuickReaction[]) => void = () => {};
23- export let linkPreviews: Record<string, LinkPreview> = {};
23+ export let linkPreviews: Record<string, LinkPreview | null> = {};
24+ export let loadPreview: ((url: string) => void) | null = null;
2425
2526 const PICKER_WIDTH = 320;
2627 const PICKER_HEIGHT = 380;
@@ -53,6 +54,12 @@
5354 $: displayName = event.sender.name_ambiguous ? `${name} (${event.sender.user_id})` : name;
5455 $: interactive = event.content.kind === 'Message' || event.content.kind === 'Sticker' || event.content.kind === 'Poll';
5556 $: previews = event.content.kind === 'Message' ? findUrls(event.content.message.body) : [];
57+ $: bundled = event.content.kind === 'Message' ? event.content.message.link_previews : [];
58+ $: if (loadPreview) {
59+ for (const link of previews) {
60+ if (!bundled.some((b) => b.url === link.href) && !(link.href in linkPreviews)) loadPreview(link.href);
61+ }
62+ }
5663
5764 function sendLabel(state: SendState): string {
5865 if (state.kind === 'Failed') return state.will_retry ? 'failed — will retry' : 'failed — not delivered';
@@ -90,9 +97,18 @@
9097 pickerOpen = false;
9198 }
9299
93- function pick(key: string) {
100+ function isImageKey(key: string): boolean {
101+ return key.startsWith('mxc://') || packs.some((p) => p.images.some((i) => i.reaction_key === key));
102+ }
103+
104+ function react(key: string, shortcode: string | null) {
105+ if (shortcode && isImageKey(key)) onReact(event.event_id ?? '', key, shortcode);
106+ else onReact(event.event_id ?? '', key);
107+ }
108+
109+ function pick(key: string, shortcode: string | null) {
94110 pickerOpen = false;
95- onReact(event.event_id ?? '', key);
111+ react(key, shortcode);
96112 }
97113
98114 function closeOnScroll(e: Event) {
@@ -187,7 +203,7 @@
187203 title={`React ${label}`}
188204 aria-label={`React ${label} to message from ${displayName}`}
189205 disabled={reacting}
190- on:click={() => onReact(event.event_id ?? '', r.key)}
206+ on:click={() => react(r.key, r.shortcode)}
191207 >
192208 {#if r.key.startsWith('mxc://')}
193209 <img class="emote" src={mediaUrl(r.key) ?? ''} alt="" />
@@ -210,7 +226,7 @@
210226 </div>
211227 {#if pickerOpen}
212228 <div class="picker-pop" style={pickerStyle} bind:this={pickerPop}>
213- <ReactionPicker {packs} quick={quickReactions} onPick={(key) => pick(key)} {onQuickChange} onClose={closePicker} />
229+ <ReactionPicker {packs} quick={quickReactions} onPick={pick} {onQuickChange} onClose={closePicker} />
214230 </div>
215231 {/if}
216232 {/if}
@@ -268,7 +284,7 @@
268284 {#if previews.length > 0}
269285 <div class="link-previews">
270286 {#each previews as preview (preview.href)}
271- <LinkCard link={preview} preview={linkPreviews[preview.href] ?? null} />
287+ <LinkCard link={preview} preview={bundled.find((b) => b.url === preview.href) ?? linkPreviews[preview.href] ?? null} />
272288 {/each}
273289 </div>
274290 {/if}
@@ -294,7 +310,9 @@
294310 disabled={!event.event_id || reacting}
295311 on:click={() => event.event_id && onReact(event.event_id, r.key)}
296312 >
297- {#if r.key.startsWith('mxc://')}
313+ {#if r.image}
314+ <img class="emote" src={messageMediaUrl(r.image, 'Thumbnail')} alt={r.shortcode ?? ''} title={r.shortcode ?? ''} />
315+ {:else if r.key.startsWith('mxc://')}
298316 <img class="emote" src={mediaUrl(r.key) ?? ''} alt="" />
299317 {:else}
300318 <Emoji emoji={r.key} size={16} />
Modified file: web/src/lib/ReactionPicker.stories.ts+15 additions−8 deletions
@@ -1,25 +1,32 @@
11 import type { Meta, StoryObj } from '@storybook/svelte-vite';
22 import { expect, fn, userEvent, waitFor } from 'storybook/test';
33 import ReactionPicker from './ReactionPicker.svelte';
4-import type { ImagePack, QuickReaction } from './types';
4+import type { ImagePack, PackImage, PackUsage, QuickReaction } from './types';
5+
6+function image(shortcode: string, body: string | null, usage: PackUsage[]): PackImage {
7+ const mxc = `mxc://example.org/${shortcode}`;
8+ return { shortcode, media_id: mxc, reaction_key: mxc, body, mimetype: null, width: null, height: null, usage };
9+}
510
611 const packs: ImagePack[] = [
712 {
813 id: 'user',
914 display_name: 'My emotes',
10- avatar_url: null,
11- source: { kind: 'User' },
15+ avatar: null,
16+ attribution: null,
17+ source: 'User',
1218 images: [
13- { shortcode: 'rabbit', url: 'mxc://example.org/rabbit', body: 'White rabbit', usage: ['emoticon'] },
14- { shortcode: 'teapot', url: 'mxc://example.org/teapot', body: null, usage: ['emoticon', 'sticker'] }
19+ image('rabbit', 'White rabbit', ['Emoticon']),
20+ image('teapot', null, ['Emoticon', 'Sticker'])
1521 ]
1622 },
1723 {
1824 id: 'room',
1925 display_name: 'Tea party stickers',
20- avatar_url: null,
21- source: { kind: 'Room', room_id: '!tea:example.org', state_key: 'tea' },
22- images: [{ shortcode: 'dormouse', url: 'mxc://example.org/dormouse', body: null, usage: ['sticker'] }]
26+ avatar: null,
27+ attribution: null,
28+ source: { Room: { room_id: '!tea:example.org', state_key: 'tea' } },
29+ images: [image('dormouse', null, ['Sticker'])]
2330 }
2431 ];
2532
Modified file: web/src/lib/ReactionPicker.svelte+13 additions−9 deletions
@@ -1,6 +1,6 @@
11 <script lang="ts">
22 import { onMount } from 'svelte';
3- import { mediaUrl } from './api';
3+ import { mediaUrl, messageMediaUrl } from './api';
44 import Emoji from './Emoji.svelte';
55 import { loadEmoji, searchEmoji } from './emoji';
66 import type { EmojiGroup } from './emoji';
@@ -41,6 +41,10 @@
4141 return image.shortcode.toLowerCase().includes(needle) || (image.body ?? '').toLowerCase().includes(needle);
4242 }
4343
44+ function packName(pack: ImagePack): string {
45+ return pack.display_name ?? 'Emoji pack';
46+ }
47+
4448 function isQuick(key: string): boolean {
4549 return quick.some((r) => r.key === key);
4650 }
@@ -121,21 +125,21 @@
121125
122126 <div class="scroll" aria-busy={groups === null && !loadFailed}>
123127 {#each filteredPacks as { pack, images } (pack.id)}
124- <section class="group" aria-label={pack.display_name}>
125- <h3>{pack.display_name}</h3>
128+ <section class="group" aria-label={packName(pack)}>
129+ <h3>{packName(pack)}</h3>
126130 <div class="grid">
127- {#each images as image (image.url + image.shortcode)}
131+ {#each images as image (image.reaction_key + image.shortcode)}
128132 <button
129133 type="button"
130134 class="cell"
131- class:chosen={customising && isQuick(image.url)}
135+ class:chosen={customising && isQuick(image.reaction_key)}
132136 title={`:${image.shortcode}:`}
133137 aria-label={`:${image.shortcode}:`}
134- aria-pressed={customising ? isQuick(image.url) : undefined}
135- disabled={customising && full && !isQuick(image.url)}
136- on:click={() => choose(image.url, image.shortcode)}
138+ aria-pressed={customising ? isQuick(image.reaction_key) : undefined}
139+ disabled={customising && full && !isQuick(image.reaction_key)}
140+ on:click={() => choose(image.reaction_key, image.shortcode)}
137141 >
138- <img src={mediaUrl(image.url) ?? ''} alt="" loading="lazy" />
142+ <img src={messageMediaUrl(image.media_id, 'Thumbnail')} alt="" loading="lazy" />
139143 </button>
140144 {/each}
141145 </div>
Modified file: web/src/lib/SecurityPanel.stories.ts+11 additions−2 deletions
@@ -1,5 +1,5 @@
11 import type { Meta, StoryObj } from '@storybook/svelte-vite';
2-import { fn } from 'storybook/test';
2+import { expect, fn, userEvent } from 'storybook/test';
33 import SecurityPanel from './SecurityPanel.svelte';
44
55 const unverified = { device_id: 'ABCDEFGHIJ', device_verified: false, recovery: 'Disabled', key_backup: 'Disabled' } as const;
@@ -7,7 +7,7 @@ const unverified = { device_id: 'ABCDEFGHIJ', device_verified: false, recovery:
77 const meta = {
88 title: 'Panels/SecurityPanel',
99 component: SecurityPanel,
10- args: { encryptionState: unverified, verificationState: null, onAction: fn() }
10+ args: { encryptionState: unverified, verificationState: null, onAction: fn(), onEncryptedPreviews: fn() }
1111 } satisfies Meta<typeof SecurityPanel>;
1212
1313 export default meta;
@@ -37,3 +37,12 @@ export const Comparison: Story = {
3737 }
3838 };
3939 export const Cancelled: Story = { args: { verificationState: { kind: 'Cancelled', reason: 'User cancelled' } } };
40+
41+export const EncryptedPreviews: Story = {
42+ play: async ({ args, canvas }) => {
43+ const box = canvas.getByRole('checkbox', { name: 'Fetch link previews in encrypted rooms' });
44+ await expect(box).not.toBeChecked();
45+ await userEvent.click(box);
46+ await expect(args.onEncryptedPreviews).toHaveBeenCalledWith(true);
47+ }
48+};
Modified file: web/src/lib/SecurityPanel.svelte+15 additions
@@ -14,6 +14,8 @@
1414 export let encryptionState: EncryptionState | null;
1515 export let verificationState: VerificationState | null;
1616 export let onAction: () => void = () => undefined;
17+ export let encryptedPreviews = false;
18+ export let onEncryptedPreviews: (enabled: boolean) => void = () => undefined;
1719
1820 let recoveryKey = '';
1921 let busy: VerifyAction | 'recover' | 'start' | null = null;
@@ -197,6 +199,18 @@
197199 </div>
198200 {/if}
199201 </section>
202+ <section aria-label="Link previews">
203+ <h3 class="sec-h">Link previews</h3>
204+ <label class="check">
205+ <input
206+ type="checkbox"
207+ checked={encryptedPreviews}
208+ on:change={(e) => onEncryptedPreviews(e.currentTarget.checked)}
209+ />
210+ <span>Fetch link previews in encrypted rooms</span>
211+ </label>
212+ <p class="sec-note">Your homeserver sees every link it fetches a preview for.</p>
213+ </section>
200214 </div>
201215
202216 <style>
@@ -208,6 +222,7 @@
208222 .pill.ok { background: rgba(63, 185, 127, 0.18); border-color: #3fb97f; color: #9fe3c3; }
209223 .pill.warn { background: rgba(232, 178, 90, 0.15); border-color: var(--warn); color: var(--warn); }
210224 code { font-size: 12px; background: var(--bg-soft); padding: 1px 6px; border-radius: 6px; }
225+ .check { display: flex; align-items: center; gap: 8px; font-size: 13px; }
211226 .row { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
212227 .emoji-grid {
213228 list-style: none; margin: 8px 0; padding: 0;
Modified file: web/src/lib/api.ts+3 additions−2 deletions
@@ -37,6 +37,7 @@ export const sendMessage = (room_id: string, body: string) => transport.sendMess
3737 export const paginateRoom = (room_id: string) => transport.paginateRoom(room_id);
3838 export const setTyping = (room_id: string, typing: boolean) => transport.setTyping(room_id, typing);
3939 export const getAccountData = (eventType: string) => transport.getAccountData(eventType);
40+export const getUrlPreview = (room_id: string, url: string) => transport.getUrlPreview(room_id, url);
4041 export const setAccountData = (eventType: string, content: Record<string, unknown>) =>
4142 transport.setAccountData(eventType, content);
4243
@@ -49,8 +50,8 @@ export function createRoom(name: string, topic?: string): Promise<{ room_id: str
4950 }
5051
5152 export const leaveRoom = (room_id: string) => transport.leaveRoom(room_id);
52-export const reactToMessage = (room_id: string, event_id: string, key: string) =>
53- transport.reactToMessage(room_id, event_id, key);
53+export const reactToMessage = (room_id: string, event_id: string, key: string, shortcode?: string) =>
54+ transport.reactToMessage(room_id, event_id, key, shortcode);
5455 export const markRead = (room_id: string, event_id: string) => transport.markRead(room_id, event_id);
5556
5657 export function getMembers(room_id: string): Promise<{ members: MemberSummary[] }> {
Added file: web/src/lib/link-previews.ts+55 additions
@@ -0,0 +1,55 @@
1+import { get, writable } from 'svelte/store';
2+import { ApiError, getAccountData, getUrlPreview, setAccountData } from './api';
3+import { parseLinkPreview } from './types';
4+import type { LinkPreview } from './types';
5+
6+const EVENT_TYPE = 'dev.malice.url_previews';
7+const BACKOFF_MS = 30_000;
8+
9+export interface UrlPreviewSettings {
10+ encrypted_rooms: boolean;
11+}
12+
13+const DEFAULT_SETTINGS: UrlPreviewSettings = { encrypted_rooms: false };
14+
15+export const linkPreviews = writable<Record<string, LinkPreview | null>>({});
16+export const urlPreviewSettings = writable<UrlPreviewSettings>(DEFAULT_SETTINGS);
17+
18+const inFlight = new Set<string>();
19+let backoffUntil = 0;
20+
21+export function requestLinkPreview(roomId: string, url: string): void {
22+ if (inFlight.has(url) || url in get(linkPreviews) || Date.now() < backoffUntil) return;
23+ inFlight.add(url);
24+ getUrlPreview(roomId, url)
25+ .then((raw) => linkPreviews.update((all) => ({ ...all, [url]: parseLinkPreview(raw) })))
26+ .catch((e) => {
27+ if (e instanceof ApiError && e.status === 429) backoffUntil = Date.now() + BACKOFF_MS;
28+ })
29+ .finally(() => inFlight.delete(url));
30+}
31+
32+function parseSettings(raw: unknown): UrlPreviewSettings {
33+ if (typeof raw !== 'object' || raw === null) return DEFAULT_SETTINGS;
34+ return { encrypted_rooms: (raw as Record<string, unknown>)['encrypted_rooms'] === true };
35+}
36+
37+export async function loadUrlPreviewSettings(): Promise<void> {
38+ try {
39+ urlPreviewSettings.set(parseSettings(await getAccountData(EVENT_TYPE)));
40+ } catch {
41+ urlPreviewSettings.set(DEFAULT_SETTINGS);
42+ }
43+}
44+
45+export async function saveUrlPreviewSettings(settings: UrlPreviewSettings): Promise<void> {
46+ await setAccountData(EVENT_TYPE, { encrypted_rooms: settings.encrypted_rooms });
47+ urlPreviewSettings.set(settings);
48+}
49+
50+export function resetLinkPreviews(): void {
51+ linkPreviews.set({});
52+ urlPreviewSettings.set(DEFAULT_SETTINGS);
53+ inFlight.clear();
54+ backoffUntil = 0;
55+}
Modified file: web/src/lib/store.ts+12 additions
@@ -2,15 +2,19 @@ import { writable } from 'svelte/store';
22 import { getSession, subscribeEvents } from './api';
33 import type { SoftLogout } from './api';
44 import { isRecord } from './guards';
5+import { resetLinkPreviews } from './link-previews';
56 import { resetQuickReactions } from './quick-reactions';
67 import {
8+ EMPTY_IMAGE_PACKS,
79 parseEncryptionState,
10+ parseImagePacks,
811 parseRoomList,
912 parseTimelineList,
1013 parseVerificationState
1114 } from './types';
1215 import type {
1316 EncryptionState,
17+ ImagePacks,
1418 MemberSummary,
1519 RoomSummary,
1620 TimelineItem,
@@ -29,6 +33,7 @@ export const membersByRoom = writable<Record<string, MemberSummary[]>>({});
2933 export const membersLoading = writable<boolean>(false);
3034 export const encryption = writable<EncryptionState | null>(null);
3135 export const verification = writable<VerificationState | null>(null);
36+export const imagePacks = writable<ImagePacks>(EMPTY_IMAGE_PACKS);
3237
3338 export interface Banner {
3439 id: number;
@@ -57,7 +62,9 @@ export function resetClientState(): void {
5762 encryption.set(null);
5863 verification.set(null);
5964 banners.set([]);
65+ imagePacks.set(EMPTY_IMAGE_PACKS);
6066 resetQuickReactions();
67+ resetLinkPreviews();
6168 }
6269
6370 function applyTimelinePayload(roomId: string, payload: Record<string, unknown>): void {
@@ -94,6 +101,11 @@ function handlePayload(text: string): void {
94101 if (snapshotEncryption) encryption.set(snapshotEncryption);
95102 const snapshotVerification = parseVerificationState(data['verification']);
96103 if (snapshotVerification) verification.set(snapshotVerification);
104+ imagePacks.set(parseImagePacks(data['image_packs']));
105+ return;
106+ }
107+ if (kind === 'image_packs') {
108+ imagePacks.set(parseImagePacks(data['packs']));
97109 return;
98110 }
99111 if (kind === 'rooms') {
Modified file: web/src/lib/transport-http.ts+10 additions−2 deletions
@@ -101,8 +101,8 @@ export class HttpTransport implements Transport {
101101 leaveRoom(room_id: string): Promise<void> {
102102 return this.roomCommand(ROOM_COMMAND_PATHS.leave, { room_id });
103103 }
104- reactToMessage(room_id: string, event_id: string, key: string): Promise<void> {
105- return this.roomCommand(ROOM_COMMAND_PATHS.react, { room_id, event_id, key });
104+ reactToMessage(room_id: string, event_id: string, key: string, shortcode?: string): Promise<void> {
105+ return this.roomCommand(ROOM_COMMAND_PATHS.react, shortcode ? { room_id, event_id, key, shortcode } : { room_id, event_id, key });
106106 }
107107 markRead(room_id: string, event_id: string): Promise<void> {
108108 return this.roomCommand(ROOM_COMMAND_PATHS.read, { room_id, event_id });
@@ -147,6 +147,14 @@ export class HttpTransport implements Transport {
147147 return json(res, 'Could not load account data');
148148 }
149149
150+ async getUrlPreview(room_id: string, url: string): Promise<unknown | null> {
151+ const res = await fetch(
152+ `/api/url-preview?room_id=${encodeURIComponent(room_id)}&url=${encodeURIComponent(url)}`,
153+ { credentials: 'same-origin' }
154+ );
155+ return json(res, 'Could not load link preview');
156+ }
157+
150158 async setAccountData(eventType: string, content: Record<string, unknown>): Promise<void> {
151159 const res = await fetch('/api/account-data', {
152160 method: 'POST',
Modified file: web/src/lib/transport-worker.ts+5 additions−2 deletions
@@ -233,8 +233,8 @@ export class WorkerTransport implements Transport {
233233 leaveRoom(room_id: string): Promise<void> {
234234 return this.client.call('leave', { room_id });
235235 }
236- reactToMessage(room_id: string, event_id: string, key: string): Promise<void> {
237- return this.client.call('react', { room_id, event_id, key });
236+ reactToMessage(room_id: string, event_id: string, key: string, shortcode?: string): Promise<void> {
237+ return this.client.call('react', shortcode ? { room_id, event_id, key, shortcode } : { room_id, event_id, key });
238238 }
239239 markRead(room_id: string, event_id: string): Promise<void> {
240240 return this.client.call('read', { room_id, event_id });
@@ -248,6 +248,9 @@ export class WorkerTransport implements Transport {
248248 getAccountData(eventType: string): Promise<unknown | null> {
249249 return this.client.call('get_account_data', { event_type: eventType });
250250 }
251+ getUrlPreview(room_id: string, url: string): Promise<unknown | null> {
252+ return this.client.call('url_preview', { room_id, url });
253+ }
251254 setAccountData(eventType: string, content: Record<string, unknown>): Promise<void> {
252255 return this.client.call('set_account_data', { event_type: eventType, content });
253256 }
Modified file: web/src/lib/transport.ts+2 additions−1 deletions
@@ -39,11 +39,12 @@ export interface Transport {
3939 paginateRoom(room_id: string): Promise<void>;
4040 setTyping(room_id: string, typing: boolean): Promise<void>;
4141 getAccountData(eventType: string): Promise<unknown | null>;
42+ getUrlPreview(room_id: string, url: string): Promise<unknown | null>;
4243 setAccountData(eventType: string, content: Record<string, unknown>): Promise<void>;
4344 joinRoom(room: string): Promise<{ room_id: string }>;
4445 createRoom(name: string, topic?: string): Promise<{ room_id: string }>;
4546 leaveRoom(room_id: string): Promise<void>;
46- reactToMessage(room_id: string, event_id: string, key: string): Promise<void>;
47+ reactToMessage(room_id: string, event_id: string, key: string, shortcode?: string): Promise<void>;
4748 markRead(room_id: string, event_id: string): Promise<void>;
4849 getMembers(room_id: string): Promise<{ members: MemberSummary[] }>;
4950 getProfile(): Promise<{ display_name: string | null; avatar_url: string | null }>;
Modified file: web/src/lib/types.ts+146 additions−7 deletions
@@ -379,6 +379,8 @@ export interface Reaction {
379379 count: number;
380380 own: boolean;
381381 senders: string[];
382+ shortcode: string | null;
383+ image: string | null;
382384 }
383385
384386 export interface Sender {
@@ -425,6 +427,7 @@ export interface MessageBody {
425427 formatted_body: string | null;
426428 edited: boolean;
427429 media: MediaAttachment | null;
430+ link_previews: LinkPreview[];
428431 }
429432
430433 function parseMessageBody(raw: unknown): MessageBody {
@@ -435,7 +438,8 @@ function parseMessageBody(raw: unknown): MessageBody {
435438 geo_uri: optionalString(o, 'geo_uri'),
436439 formatted_body: optionalString(o, 'formatted_body'),
437440 edited: o['edited'] === true,
438- media: parseMediaAttachment(o['media'])
441+ media: parseMediaAttachment(o['media']),
442+ link_previews: parseLinkPreviews(o['link_previews'])
439443 };
440444 }
441445
@@ -590,7 +594,9 @@ function parseReactions(raw: unknown): Reaction[] {
590594 key: stringField(entry, 'key'),
591595 count: numberField(entry, 'count'),
592596 own: entry['own'] === true,
593- senders: stringList(entry['senders'])
597+ senders: stringList(entry['senders']),
598+ shortcode: optionalString(entry, 'shortcode'),
599+ image: optionalString(entry, 'image')
594600 });
595601 }
596602 return out;
@@ -648,20 +654,113 @@ export function parseTimelineList(raw: unknown): TimelineItem[] {
648654 return out;
649655 }
650656
651-export type PackUsage = 'emoticon' | 'sticker';
657+export type PackUsage = 'Emoticon' | 'Sticker';
652658 export interface PackImage {
653659 shortcode: string;
654- url: string;
660+ media_id: string;
661+ reaction_key: string;
655662 body: string | null;
663+ mimetype: string | null;
664+ width: number | null;
665+ height: number | null;
656666 usage: PackUsage[];
657667 }
668+export type PackSource = 'User' | { Room: { room_id: string; state_key: string } };
658669 export interface ImagePack {
659670 id: string;
660- display_name: string;
661- avatar_url: string | null;
662- source: { kind: 'User' } | { kind: 'Room'; room_id: string; state_key: string };
671+ display_name: string | null;
672+ avatar: string | null;
673+ attribution: string | null;
674+ source: PackSource;
663675 images: PackImage[];
664676 }
677+export interface ImagePacks {
678+ global: ImagePack[];
679+ rooms: Record<string, ImagePack[]>;
680+}
681+
682+function parsePackImage(raw: unknown): PackImage | null {
683+ if (!isRecord(raw)) return null;
684+ const shortcode = stringField(raw, 'shortcode');
685+ const mediaId = stringField(raw, 'media_id');
686+ if (!shortcode || !mediaId) return null;
687+ return {
688+ shortcode,
689+ media_id: mediaId,
690+ reaction_key: stringField(raw, 'reaction_key', mediaId),
691+ body: optionalString(raw, 'body'),
692+ mimetype: optionalString(raw, 'mimetype'),
693+ width: optionalNumber(raw, 'width'),
694+ height: optionalNumber(raw, 'height'),
695+ usage: stringList(raw['usage']).filter((u): u is PackUsage => u === 'Emoticon' || u === 'Sticker')
696+ };
697+}
698+
699+function parsePackSource(raw: unknown): PackSource {
700+ if (isRecord(raw) && isRecord(raw['Room'])) {
701+ return {
702+ Room: { room_id: stringField(raw['Room'], 'room_id'), state_key: stringField(raw['Room'], 'state_key') }
703+ };
704+ }
705+ return 'User';
706+}
707+
708+function parseImagePack(raw: unknown): ImagePack | null {
709+ if (!isRecord(raw)) return null;
710+ const id = stringField(raw, 'id');
711+ if (!id) return null;
712+ const images: PackImage[] = [];
713+ if (Array.isArray(raw['images'])) {
714+ for (const entry of raw['images']) {
715+ const image = parsePackImage(entry);
716+ if (image) images.push(image);
717+ }
718+ }
719+ return {
720+ id,
721+ display_name: optionalString(raw, 'display_name'),
722+ avatar: optionalString(raw, 'avatar'),
723+ attribution: optionalString(raw, 'attribution'),
724+ source: parsePackSource(raw['source']),
725+ images
726+ };
727+}
728+
729+function parsePackList(raw: unknown): ImagePack[] {
730+ if (!Array.isArray(raw)) return [];
731+ const out: ImagePack[] = [];
732+ for (const entry of raw) {
733+ const pack = parseImagePack(entry);
734+ if (pack) out.push(pack);
735+ }
736+ return out;
737+}
738+
739+export const EMPTY_IMAGE_PACKS: ImagePacks = { global: [], rooms: {} };
740+
741+export function parseImagePacks(raw: unknown): ImagePacks {
742+ if (!isRecord(raw)) return EMPTY_IMAGE_PACKS;
743+ const rooms: Record<string, ImagePack[]> = {};
744+ if (isRecord(raw['rooms'])) {
745+ for (const [roomId, list] of Object.entries(raw['rooms'])) rooms[roomId] = parsePackList(list);
746+ }
747+ return { global: parsePackList(raw['global']), rooms };
748+}
749+
750+export function packsForRoom(packs: ImagePacks, roomId: string | null): ImagePack[] {
751+ const seen = new Set<string>();
752+ const out: ImagePack[] = [];
753+ const add = (pack: ImagePack) => {
754+ if (seen.has(pack.id)) return;
755+ seen.add(pack.id);
756+ out.push(pack);
757+ };
758+ for (const pack of packs.global) if (pack.source === 'User') add(pack);
759+ if (roomId) for (const pack of packs.rooms[roomId] ?? []) add(pack);
760+ for (const pack of packs.global) add(pack);
761+ return out;
762+}
763+
665764 export const MAX_QUICK_REACTIONS = 8;
666765 export interface QuickReaction {
667766 key: string;
@@ -670,13 +769,53 @@ export interface QuickReaction {
670769
671770 export interface LinkPreviewImage {
672771 media_id: string;
772+ mimetype: string | null;
773+ size_bytes: number | null;
673774 width: number | null;
674775 height: number | null;
675776 }
676777 export interface LinkPreview {
677778 url: string;
779+ canonical_url: string | null;
678780 title: string | null;
679781 description: string | null;
680782 site_name: string | null;
681783 image: LinkPreviewImage | null;
682784 }
785+
786+function parseLinkPreviewImage(raw: unknown): LinkPreviewImage | null {
787+ if (!isRecord(raw)) return null;
788+ const mediaId = stringField(raw, 'media_id');
789+ if (!mediaId) return null;
790+ return {
791+ media_id: mediaId,
792+ mimetype: optionalString(raw, 'mimetype'),
793+ size_bytes: optionalNumber(raw, 'size_bytes'),
794+ width: optionalNumber(raw, 'width'),
795+ height: optionalNumber(raw, 'height')
796+ };
797+}
798+
799+export function parseLinkPreview(raw: unknown): LinkPreview | null {
800+ if (!isRecord(raw)) return null;
801+ const url = stringField(raw, 'url');
802+ if (!url) return null;
803+ return {
804+ url,
805+ canonical_url: optionalString(raw, 'canonical_url'),
806+ title: optionalString(raw, 'title'),
807+ description: optionalString(raw, 'description'),
808+ site_name: optionalString(raw, 'site_name'),
809+ image: parseLinkPreviewImage(raw['image'])
810+ };
811+}
812+
813+export function parseLinkPreviews(raw: unknown): LinkPreview[] {
814+ if (!Array.isArray(raw)) return [];
815+ const out: LinkPreview[] = [];
816+ for (const entry of raw) {
817+ const preview = parseLinkPreview(entry);
818+ if (preview) out.push(preview);
819+ }
820+ return out;
821+}