Commit ecb8598b38af
tarball46 files changed+1918-403
@@ -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",@@ -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" }@@ -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, confirmed6262
by the server, or failed. Opening a room loads its timeline; scrolling up loads6363
older history. Images and avatars are served through authenticated, bounded64
-media endpoints; SVG content is not rendered. Link previews use URL text only65
-and do not fetch third-party pages.64
+media endpoints; SVG content is not rendered. Link previews embedded by the65
+sender always show. Otherwise the homeserver fetches previews in unencrypted66
+rooms, and in encrypted rooms only when the user enables it in the Security67
+panel. Room admins can disable previews per room.6668
6769
The Security panel accepts incoming device-verification requests and supports6870
emoji/number comparison, confirmation, rejection, and recovery-key entry.@@ -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::NotFound5656
}@@ -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(@@ -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
handle240
- .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 = url418
+ .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_BYTES423
+ || 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(""));@@ -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))@@ -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,@@ -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"@@ -45,3 +45,15 @@ lto = true
4545
codegen-units = 14646
panic = "abort"4747
strip = true48
+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" }@@ -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(),@@ -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"@@ -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")?,@@ -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
.await216221
}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 nothing224
+ 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 verifier343
+ 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 finishes349
+ previous: Option<Box<SessionRecord>>,350
+ },351
+}352
+353
+/// authorization codes live for minutes, so a login this old was abandoned354
+const OAUTH_PENDING_FOR_MS: u64 = 60 * 60 * 1000;355
+356
+/// the authorization server answers in the query, or the fragment when asked to357
+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
+ query361
+ .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.homeserver317374
}318375
319
- pub(crate) fn user_id(&self) -> &OwnedUserId {376
+ /// a pending oauth login is nobody yet, unless it resumes someone377
+ 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 it387
+ 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_MS408
+ }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 callback454
+ 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 begun516
+ /// somewhere else517
+ #[error("no OAuth login is waiting for this callback")]518
+ NoPendingOAuth,425519
/// what was typed can't name a homeserver, so nothing was sent anywhere426520
#[error("that isn't a usable homeserver, {0}")]427521
InvalidHomeserver(&'static str),@@ -586,15 +680,99 @@ impl Session {
586680
.build()587681
.await588682
.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: client688
+ .oauth()689
+ .client_id()690
+ .ok_or(AccountError::MissingSession)?691
+ .clone(),692
+ state: authorization.state.clone(),693
+ authorization: Box::new(694
+ client695
+ .oauth()696
+ .pending_authorization(&authorization.state)697
+ .await698
+ .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 the716
+ /// redirect brought back. the pending login is in `sessions`, so nothing else is needed717
+ 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).await723
+ }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.authentication743
+ else {744
+ return Err(AccountError::NoPendingOAuth);745
+ };746
+ let client = matrix_client_builder(sessions.store())747
+ .homeserver_url(&record.homeserver)748
+ .build()749
+ .await750
+ .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 oauth755
+ .restore_pending_authorization(state, *authorization)756
+ .await757
+ {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).await774
+ }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,@@ -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 a97
- /// discard that couldn't finish, is deleted on the way past98
+ /// discard that couldn't finish, is deleted on the way past, and so is an oauth login whose99
+ /// redirect never came back. one that resumed an account puts that account back98100
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's117
+ /// state, so the host needs to have kept nothing across the redirect118
+ 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 account111144
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 and270
+ /// is left alone271
+ 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
+ true285
+ }286
+ None => {287
+ self.remove(account).await;288
+ false289
+ }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 account275336
/// 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())@@ -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).await73
+ }74
+6975
pub(crate) async fn register(&self, media: Vec<MediaRegistration>) {7076
if !media.is_empty() {7177
self.registry.lock().await.insert_all(media);@@ -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 yet273
- 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 stop276274
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
.await318320
.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 ticks740739
self.spawn_command("upload", reply, async move {741
- ready742
- .wait_for(|subscribed| *subscribed)743
- .await744
- .map_err(|_| Error::new(ErrorKind::Store, "reading the media send queue failed"))?;745740
media::queue_upload(room, upload, max_bytes).await746741
});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.uploads760
- .insert(room.room_id().to_owned(), ready.clone());761
- ready762
- }763
-764
- /// the sdk resumes uploads left over from last time, and they deserve progress too765
- 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@@ -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 members37
-/// has the sdk track them all, and its own background `/keys/query` then catches up38
-/// their devices. the one time key claim and the room key share stay on the send path36
+/// the sdk's own pre-send work, run early. a send landing mid-share waits on the37
+/// 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
- .await44
- .map_err(|source| Error::from_sdk("syncing room members failed", source))?;45
- }46
- Ok(())40
+ room.prepare_encryption()41
+ .await42
+ .map_err(|source| Error::from_sdk("preparing room encryption failed", source))4743
}@@ -185,9 +185,14 @@ impl TimelineSession {
185185
.await186186
.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
timeline190
- .toggle_reaction(&item_id(target), &key)195
+ .toggle_reaction_with_shortcode(&item_id(target), &key, shortcode.as_deref())191196
.await192197
.map(drop)193198
.map_err(timeline_error)@@ -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 are35
+/// kept a while, misses too, and asks for one url at once share one request36
+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 again41
+ 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
+ lookup83
+ }84
+ }85
+ };86
+ lookup.await87
+ }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 for108
+ // 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 minutes110
+ 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
+ None117
+ }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_BUILDER127
+ .is_supported(&versions)128
+ {129
+ self.client130
+ .send(authenticated_media::get_media_preview::v1::Request::new(131
+ url.to_owned(),132
+ ))133
+ .await?134
+ .data135
+ } else {136
+ #[allow(deprecated)]137
+ let request = media::get_media_preview::v3::Request::new(url.to_owned());138
+ self.client.send(request).await?.data139
+ };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 self153
+ .entries154
+ .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
+}@@ -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't118
-/// overtake its own progress119
-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 ticks175
- Lagged,176
-}177
-178129
/// the one place upload events leave from, so an upload is never reported after it179
-/// ended. progress comes in from the room forwarders, the rest from the client-wide180
-/// channel, which exists before any upload does130
+/// ended. the client-wide channel exists before any upload does. it holds 32 updates and131
+/// progress ticks share it, so this keeps draining while the event stream is full181132
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 about173
+/// that upload is queued after it174
+fn queue_upload_event(pending: &mut VecDeque<Event>, event: Event) {175
+ if matches!(event, Event::MediaUploadProgress { .. })176
+ && let Some(waiting) = pending177
+ .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 from227
-/// anywhere but its room's forwarder goes in `ended` until the forwarder's own end for228
-/// it arrives, since until then the forwarder can still be holding progress for it211
+/// the sdk sends progress ticks from a detached task, so a tick can land after its212
+/// upload's end. it trails by one task hop, so a short memory of ends covers it213
+const REMEMBERED_ENDS: usize = 256;214
+215
+/// everything reported gets exactly one `MediaUploadFinished` and nothing after it229216
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
ending323310
.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 coming316
+ 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_ENDS320
+ && 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 it422
+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
}@@ -63,6 +63,8 @@ pub enum ErrorKind {
6363
NoCrossSigningIdentity,6464
RecoveryRejected,6565
Network,66
+ /// the homeserver wants us to slow down. try again later67
+ 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 source276
+ .as_client_api_error()277
+ .is_some_and(|api| api.status_code.as_u16() == 429) =>278
+ {279
+ ErrorKind::RateLimited280
+ }272281
HttpError::Api(_) => ErrorKind::Server,273282
HttpError::RefreshToken(source) => classify_refresh_token_error(source),274283
HttpError::Cached(source) => classify_http_error(source),@@ -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,@@ -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 follows126127
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) = self464
+ .shared465
+ .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) = self474
+ .shared475
+ .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 again545
+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 subscription594627
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 owed647
+ 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
+ None663
+ }664
+ Fetch::Again => None,665
+ }666
+ }667
+668
+ /// whether another fetch was asked for while this one ran669
+ 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
+ false678
+ }679
+612680
/// state, sequence and send move together under the one lock, which is what lets613681
/// `subscribe` take a snapshot and a receiver with nothing between them614682
fn publish(&self, change: impl FnOnce(&mut State) -> Option<StateChange>) {@@ -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 it240
+ shortcode: Option<String>,239241
},240242
/// works on a closed room too, it just can't refuse to move the marker backwards241243
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` again660
+ ImagePacksChanged,657661
/// replaces whatever you had for this timeline. every diff after it applies to it658662
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 dropped1499
+ pub link_previews: Vec<LinkPreview>,1500
+}1501
+1502
+/// a link card, from the sender's own message or from `ClientHandle::url_preview`. page1503
+/// text passes through whoever made it unfiltered, so it arrives trimmed to a sane length1504
+#[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 about1509
+ pub url: String,1510
+ /// where the page says it lives, when that's somewhere else1511
+ 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 one1641
+ pub shortcode: Option<String>,1642
+ /// the custom emote itself, when the key is an image1643
+ 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 own1650
+/// pack, each joined room's packs, and the rooms the user enabled everywhere1651
+#[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 everywhere1656
+ pub global: Vec<ImagePack>,1657
+ /// each joined room's own packs, rooms without any left out1658
+ 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 the1663
+ /// rest of the enabled ones, each pack once1664
+ #[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
+ packs1676
+ }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 calls1684
+ 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 image1710
+ 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 both1716
+ 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]@@ -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 them20
+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 = content34
+ .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
+ packs42
+ .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 = room57
+ .get_state_events(StateEventType::from(ROOM_PACKS))58
+ .await59
+ .map_err(|source| Error::from_sdk("reading image packs failed", source))?;60
+ let by_state_key: BTreeMap<String, Raw<JsonObject>> = events61
+ .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_key71
+ .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(client88
+ .account()89
+ .account_data_raw(GlobalAccountDataEventType::from(event_type))90
+ .await91
+ .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 dropped96
+fn pack(id: String, source: ImagePackSource, content: &Raw<JsonObject>) -> Option<ImagePack> {97
+ let info = content98
+ .get_field::<PackInfo>("pack")99
+ .ok()100
+ .flatten()101
+ .unwrap_or_default();102
+ let pack_usage = usage(&info.usage);103
+ let images: Vec<PackImage> = content104
+ .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: info133
+ .avatar_url134
+ .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> = usage144
+ .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
+}@@ -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 name18
+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 needs25
+/// when it's encrypted. embedded reply content has no media identity, so its encrypted images26
+/// go without27
+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_previews35
+ .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 card43
+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` when95
+/// nothing in it is worth a card96
+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 own121
+/// default, then the room admins'. encrypted rooms are the host's call122
+pub(crate) async fn url_previews_allowed(room: &Room) -> Result<bool, Error> {123
+ let room_choice = room124
+ .account_data(RoomAccountDataEventType::from(ROOM_PREVIEW_URLS))125
+ .await126
+ .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
+ .await133
+ .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
+ .await139
+ .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 strings166
+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
+}@@ -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
@@ -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;@@ -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.media57
+ .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_identity284284
.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(),@@ -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
.await351353
}@@ -582,6 +584,21 @@ impl ClientHandle {
582584
room_summaries(&self.client, self.spaces().await).await583585
}584586
587
+ pub async fn image_packs(&self) -> Result<ImagePacks, Error> {588
+ image_packs(&self.client).await589
+ }590
+591
+ /// the homeserver's preview of a page, `None` when it has none. the homeserver sees every592
+ /// url asked about, so a host decides whether encrypted rooms ask at all593
+ pub async fn url_preview(&self, url: &str) -> Result<Option<LinkPreview>, Error> {594
+ self.media.url_preview(url).await595
+ }596
+597
+ /// whether the room and the user's settings let link previews show here598
+ pub async fn url_previews_allowed(&self, room_id: &RoomId) -> Result<bool, Error> {599
+ url_previews_allowed(&self.room(room_id)?).await600
+ }601
+585602
/// browse a space the way the server sees it, rooms you haven't joined included586603
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 gone9901011
pub(crate) async fn closed(&self) {9911012
self.0.closed().await;@@ -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
+ urlPreviewSettings54
+ } 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 lands9098
$: {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}@@ -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
}@@ -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: null9596
}@@ -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: null123
+ }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
+};@@ -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} />@@ -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
@@ -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
<button129133
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>@@ -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
+};@@ -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
+ <input206
+ 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;@@ -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[] }> {@@ -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
+}@@ -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
parseVerificationState1114
} 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') {@@ -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',@@ -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
}@@ -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 }>;@@ -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
+ images726
+ };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
+}