Commit 29f8ccacfdc7
tarball42 files changed+50-13722
@@ -1,2 +1,5 @@
11
[target.wasm32-unknown-unknown]22
rustflags = ["--cfg", "getrandom_backend=\"wasm_js\""]3
+4
+[net]5
+git-fetch-with-cli = true@@ -1871,7 +1871,6 @@ version = "0.35.0"
18711871
source = "registry+https://github.com/rust-lang/crates.io-index"18721872
checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f"18731873
dependencies = [1874
- "cc",18751874
"pkg-config",18761875
"vcpkg",18771876
]@@ -1936,6 +1935,7 @@ checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
19361935
[[package]]19371936
name = "macltok"19381937
version = "0.1.0"1938
+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/macltok?rev=7515819c2f3ed19dc742baec9701ccaa6bba83c5#7515819c2f3ed19dc742baec9701ccaa6bba83c5"19391939
dependencies = [19401940
"futures-util",19411941
"getrandom 0.2.17",@@ -1943,29 +1943,21 @@ dependencies = [
19431943
"getrandom 0.4.3",19441944
"http",19451945
"imbl",1946
- "js-sys",19471946
"matrix-sdk",19481947
"matrix-sdk-base",19491948
"matrix-sdk-crypto",19501949
"matrix-sdk-store-encryption",1951
- "matrix-sdk-test",19521950
"matrix-sdk-ui",1953
- "matrix_indexed_db_futures",19541951
"mime",19551952
"reqwest",19561953
"ruma",19571954
"serde",1958
- "serde_json",19591955
"sha2",19601956
"thiserror 2.0.21",19611957
"tokio",19621958
"tracing",19631959
"url",19641960
"uuid",1965
- "wasm-bindgen",1966
- "wasm-bindgen-futures",1967
- "web-sys",1968
- "wiremock",19691961
]19701962
19711963
[[package]]@@ -2172,6 +2164,7 @@ dependencies = [
21722164
[[package]]21732165
name = "matrix-sdk"21742166
version = "0.18.0"2167
+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=cabc67b5cc0529bcf548c004f2e7c889aed6c4e5#cabc67b5cc0529bcf548c004f2e7c889aed6c4e5"21752168
dependencies = [21762169
"anymap2",21772170
"aquamarine",@@ -2234,6 +2227,7 @@ dependencies = [
22342227
[[package]]22352228
name = "matrix-sdk-base"22362229
version = "0.18.0"2230
+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=cabc67b5cc0529bcf548c004f2e7c889aed6c4e5#cabc67b5cc0529bcf548c004f2e7c889aed6c4e5"22372231
dependencies = [22382232
"as_variant",22392233
"assert_matches",@@ -2263,6 +2257,7 @@ dependencies = [
22632257
[[package]]22642258
name = "matrix-sdk-common"22652259
version = "0.18.0"2260
+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=cabc67b5cc0529bcf548c004f2e7c889aed6c4e5#cabc67b5cc0529bcf548c004f2e7c889aed6c4e5"22662261
dependencies = [22672262
"eyeball-im",22682263
"futures-core",@@ -2285,6 +2280,7 @@ dependencies = [
22852280
[[package]]22862281
name = "matrix-sdk-crypto"22872282
version = "0.18.0"2283
+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=cabc67b5cc0529bcf548c004f2e7c889aed6c4e5#cabc67b5cc0529bcf548c004f2e7c889aed6c4e5"22882284
dependencies = [22892285
"aes",22902286
"aquamarine",@@ -2325,13 +2321,13 @@ dependencies = [
23252321
[[package]]23262322
name = "matrix-sdk-indexeddb"23272323
version = "0.18.0"2324
+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=cabc67b5cc0529bcf548c004f2e7c889aed6c4e5#cabc67b5cc0529bcf548c004f2e7c889aed6c4e5"23282325
dependencies = [23292326
"async-trait",23302327
"base64 0.22.1",23312328
"futures-util",23322329
"getrandom 0.4.3",23332330
"gloo-utils",2334
- "growable-bloom-filter",23352331
"hkdf",23362332
"js-sys",23372333
"matrix-sdk-base",@@ -2356,6 +2352,7 @@ dependencies = [
23562352
[[package]]23572353
name = "matrix-sdk-sqlite"23582354
version = "0.18.0"2355
+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=cabc67b5cc0529bcf548c004f2e7c889aed6c4e5#cabc67b5cc0529bcf548c004f2e7c889aed6c4e5"23592356
dependencies = [23602357
"as_variant",23612358
"async-trait",@@ -2382,6 +2379,7 @@ dependencies = [
23822379
[[package]]23832380
name = "matrix-sdk-store-encryption"23842381
version = "0.18.0"2382
+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=cabc67b5cc0529bcf548c004f2e7c889aed6c4e5#cabc67b5cc0529bcf548c004f2e7c889aed6c4e5"23852383
dependencies = [23862384
"base64 0.22.1",23872385
"blake3",@@ -2402,6 +2400,7 @@ dependencies = [
24022400
[[package]]24032401
name = "matrix-sdk-test"24042402
version = "0.18.0"2403
+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=cabc67b5cc0529bcf548c004f2e7c889aed6c4e5#cabc67b5cc0529bcf548c004f2e7c889aed6c4e5"24052404
dependencies = [24062405
"as_variant",24072406
"ctor",@@ -2424,6 +2423,7 @@ dependencies = [
24242423
[[package]]24252424
name = "matrix-sdk-test-macros"24262425
version = "0.18.0"2426
+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=cabc67b5cc0529bcf548c004f2e7c889aed6c4e5#cabc67b5cc0529bcf548c004f2e7c889aed6c4e5"24272427
dependencies = [24282428
"quote",24292429
"syn 2.0.119",@@ -2432,6 +2432,7 @@ dependencies = [
24322432
[[package]]24332433
name = "matrix-sdk-ui"24342434
version = "0.18.0"2435
+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=cabc67b5cc0529bcf548c004f2e7c889aed6c4e5#cabc67b5cc0529bcf548c004f2e7c889aed6c4e5"24352436
dependencies = [24362437
"as_variant",24372438
"async-rx",@@ -3,7 +3,7 @@ resolver = "3"
33
members = ["crates/malice-core", "crates/malice-server", "crates/malice-static"]44
55
[workspace.dependencies]6
-macltok = { path = "vendor/macltok" }6
+macltok = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/macltok", rev = "7515819c2f3ed19dc742baec9701ccaa6bba83c5" }77
ruma = { version = "0.16", default-features = false }88
serde = { version = "1", features = ["derive"] }99
serde_json = "1"@@ -13,15 +13,3 @@ 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" }@@ -31,7 +31,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
3131
[dev-dependencies]3232
http-body-util = "0.1"3333
tower = { version = "0.5", features = ["util"] }34
-matrix-sdk = { version = "0.18", default-features = false, features = ["testing"] }34
+matrix-sdk = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "cabc67b5cc0529bcf548c004f2e7c889aed6c4e5", default-features = false, features = ["testing"] }3535
reqwest = { version = "0.13", default-features = false, features = ["json", "query", "rustls"] }3636
tempfile = "3"3737
wiremock = "0.6"@@ -1,3 +1,6 @@
11
[target.wasm32-unknown-unknown]22
runner = "wasm-bindgen-test-runner"33
rustflags = ["--cfg", "getrandom_backend=\"wasm_js\""]4
+5
+[net]6
+git-fetch-with-cli = true@@ -1722,6 +1722,7 @@ checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
17221722
[[package]]17231723
name = "macltok"17241724
version = "0.1.0"1725
+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/macltok?rev=7515819c2f3ed19dc742baec9701ccaa6bba83c5#7515819c2f3ed19dc742baec9701ccaa6bba83c5"17251726
dependencies = [17261727
"futures-util",17271728
"getrandom 0.2.17",@@ -1926,6 +1927,7 @@ dependencies = [
19261927
[[package]]19271928
name = "matrix-sdk"19281929
version = "0.18.0"1930
+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=cabc67b5cc0529bcf548c004f2e7c889aed6c4e5#cabc67b5cc0529bcf548c004f2e7c889aed6c4e5"19291931
dependencies = [19301932
"anymap2",19311933
"aquamarine",@@ -1985,6 +1987,7 @@ dependencies = [
19851987
[[package]]19861988
name = "matrix-sdk-base"19871989
version = "0.18.0"1990
+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=cabc67b5cc0529bcf548c004f2e7c889aed6c4e5#cabc67b5cc0529bcf548c004f2e7c889aed6c4e5"19881991
dependencies = [19891992
"as_variant",19901993
"async-trait",@@ -2010,6 +2013,7 @@ dependencies = [
20102013
[[package]]20112014
name = "matrix-sdk-common"20122015
version = "0.18.0"2016
+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=cabc67b5cc0529bcf548c004f2e7c889aed6c4e5#cabc67b5cc0529bcf548c004f2e7c889aed6c4e5"20132017
dependencies = [20142018
"eyeball-im",20152019
"futures-core",@@ -2032,6 +2036,7 @@ dependencies = [
20322036
[[package]]20332037
name = "matrix-sdk-crypto"20342038
version = "0.18.0"2039
+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=cabc67b5cc0529bcf548c004f2e7c889aed6c4e5#cabc67b5cc0529bcf548c004f2e7c889aed6c4e5"20352040
dependencies = [20362041
"aes",20372042
"aquamarine",@@ -2071,6 +2076,7 @@ dependencies = [
20712076
[[package]]20722077
name = "matrix-sdk-indexeddb"20732078
version = "0.18.0"2079
+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=cabc67b5cc0529bcf548c004f2e7c889aed6c4e5#cabc67b5cc0529bcf548c004f2e7c889aed6c4e5"20742080
dependencies = [20752081
"async-trait",20762082
"base64 0.22.1",@@ -2102,6 +2108,7 @@ dependencies = [
21022108
[[package]]21032109
name = "matrix-sdk-sqlite"21042110
version = "0.18.0"2111
+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=cabc67b5cc0529bcf548c004f2e7c889aed6c4e5#cabc67b5cc0529bcf548c004f2e7c889aed6c4e5"21052112
dependencies = [21062113
"as_variant",21072114
"async-trait",@@ -2128,6 +2135,7 @@ dependencies = [
21282135
[[package]]21292136
name = "matrix-sdk-store-encryption"21302137
version = "0.18.0"2138
+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=cabc67b5cc0529bcf548c004f2e7c889aed6c4e5#cabc67b5cc0529bcf548c004f2e7c889aed6c4e5"21312139
dependencies = [21322140
"base64 0.22.1",21332141
"blake3",@@ -2148,6 +2156,7 @@ dependencies = [
21482156
[[package]]21492157
name = "matrix-sdk-ui"21502158
version = "0.18.0"2159
+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=cabc67b5cc0529bcf548c004f2e7c889aed6c4e5#cabc67b5cc0529bcf548c004f2e7c889aed6c4e5"21512160
dependencies = [21522161
"as_variant",21532162
"async-rx",@@ -4513,11 +4522,3 @@ name = "zmij"
45134522
version = "1.0.23"45144523
source = "registry+https://github.com/rust-lang/crates.io-index"45154524
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"@@ -14,7 +14,7 @@ publish = false
1414
crate-type = ["cdylib", "rlib"]1515
1616
[target.'cfg(target_family = "wasm")'.dependencies]17
-macltok = { path = "../../vendor/macltok", features = ["indexeddb", "serde", "mirror"] }17
+macltok = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/macltok", rev = "7515819c2f3ed19dc742baec9701ccaa6bba83c5", features = ["indexeddb", "serde", "mirror"] }1818
malice-core = { path = "../malice-core" }1919
ruma = { version = "0.16", default-features = false }2020
futures-util = { version = "0.3", default-features = false, features = ["std"] }@@ -45,15 +45,3 @@ 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" }@@ -87,6 +87,10 @@
8787
version = "0.1.0";8888
src = cleanSrc ./web;8989
npmDeps = webNodeModules pkgs;90
+ disallowedReferences = [91
+ (webNodeModules pkgs)92
+ pkgs.nodejs_2293
+ ];9094
nativeBuildInputs = [9195
pkgs.nodejs_229296
pkgs.importNpmLock.hooks.linkNodeModulesHook@@ -123,7 +127,10 @@
123127
pname = "malice-server";124128
version = "0.1.0";125129
src = cleanSrc ./.;126
- cargoLock.lockFile = ./Cargo.lock;130
+ cargoLock = {131
+ lockFile = ./Cargo.lock;132
+ allowBuiltinFetchGit = true;133
+ };127134
nativeBuildInputs = [ pkgs.pkg-config ];128135
buildInputs = [ pkgs.sqlite ];129136
cargoBuildFlags = [@@ -144,7 +151,10 @@
144151
pname = "malice-static";145152
version = "0.1.0";146153
src = cleanSrc ./.;147
- cargoLock.lockFile = ./Cargo.lock;154
+ cargoLock = {155
+ lockFile = ./Cargo.lock;156
+ allowBuiltinFetchGit = true;157
+ };148158
cargoBuildFlags = [149159
"-p"150160
"malice-static"@@ -173,7 +183,10 @@
173183
version = "0.1.0";174184
src = cleanSrc ./.;175185
cargoRoot = "crates/malice-worker";176
- cargoLock.lockFile = ./crates/malice-worker/Cargo.lock;186
+ cargoLock = {187
+ lockFile = ./crates/malice-worker/Cargo.lock;188
+ allowBuiltinFetchGit = true;189
+ };177190
nativeBuildInputs = [178191
(wasmBindgenCli pkgs)179192
pkgs.removeReferencesTo@@ -290,7 +303,10 @@
290303
version = "0.1.0";291304
src = cleanSrc ./.;292305
cargoRoot = "crates/malice-worker";293
- cargoLock.lockFile = ./crates/malice-worker/Cargo.lock;306
+ cargoLock = {307
+ lockFile = ./crates/malice-worker/Cargo.lock;308
+ allowBuiltinFetchGit = true;309
+ };294310
nativeBuildInputs = [295311
(wasmBindgenCli pkgs)296312
pkgs.firefox@@ -1,79 +0,0 @@
1
-[package]2
-name = "macltok"3
-version = "0.1.0"4
-edition = "2024"5
-description = "A compact application backend on top of the Matrix Rust SDK"6
-7
-[features]8
-default = []9
-mirror = []10
-serde = ["dep:serde", "imbl/serde"]11
-sqlite = ["matrix-sdk/sqlite", "serde", "dep:url", "dep:matrix-sdk-store-encryption"]12
-bundled-sqlite = ["sqlite", "matrix-sdk/bundled-sqlite"]13
-indexeddb = [14
- "matrix-sdk/indexeddb",15
- "serde",16
- "dep:url",17
- "dep:matrix-sdk-store-encryption",18
- "dep:indexed_db_futures",19
- "dep:js-sys",20
- "dep:wasm-bindgen",21
- "dep:wasm-bindgen-futures",22
- "dep:web-sys",23
-]24
-25
-[dependencies]26
-futures-util = { version = "0.3.31", default-features = false, features = ["std"] }27
-http = "1"28
-imbl = { version = "6.1.0", default-features = false }29
-matrix-sdk = { version = "0.18.0", default-features = false, features = ["e2e-encryption"] }30
-matrix-sdk-base = { version = "0.18.0", default-features = false }31
-matrix-sdk-crypto = { version = "0.18.0", default-features = false }32
-matrix-sdk-store-encryption = { version = "0.18.0", optional = true }33
-matrix-sdk-ui = { version = "0.18.0", default-features = false }34
-ruma = { version = "0.16.0", default-features = false, features = ["unstable-msc2545", "unstable-msc4095"] }35
-serde = { version = "1.0.219", features = ["derive", "rc"], optional = true }36
-thiserror = "2.0.16"37
-mime = "0.3.17"38
-sha2 = "0.10.9"39
-tokio = { version = "1.47.1", features = ["macros", "sync"] }40
-tracing = "0.1.41"41
-url = { version = "2.5.7", optional = true }42
-43
-[target.'cfg(target_family = "wasm")'.dependencies]44
-getrandom = { version = "0.2", features = ["js"] }45
-getrandom3 = { package = "getrandom", version = "0.3", features = ["wasm_js"] }46
-getrandom4 = { package = "getrandom", version = "0.4", features = ["wasm_js"] }47
-indexed_db_futures = { package = "matrix_indexed_db_futures", version = "0.7.0", optional = true, features = ["typed-arrays"] }48
-js-sys = { version = "0.3.105", optional = true }49
-matrix-sdk = { version = "0.18.0", default-features = false, features = ["js"] }50
-matrix-sdk-ui = { version = "0.18.0", default-features = false, features = ["js"] }51
-# the sdk's own wasm reqwest leaves out stream, which the bounded media download needs52
-reqwest = { version = "0.13.1", default-features = false, features = ["stream"] }53
-uuid = { version = "1", features = ["js"] }54
-wasm-bindgen = { version = "0.2.128", optional = true }55
-wasm-bindgen-futures = { version = "0.4.78", optional = true }56
-web-sys = { version = "0.3.105", optional = true, features = [57
- "IdbFactory",58
- "IdbOpenDbRequest",59
- "IdbRequest",60
- "Window",61
- "WorkerGlobalScope",62
-] }63
-64
-[dev-dependencies]65
-matrix-sdk = { version = "0.18.0", default-features = false, features = ["testing"] }66
-matrix-sdk-test = "0.18.0"67
-serde_json = "1.0.143"68
-tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread", "signal", "time"] }69
-wiremock = "0.6.5"70
-71
-[[example]]72
-name = "headless"73
-required-features = ["sqlite"]74
-75
-[lints.rust]76
-unsafe_code = "forbid"77
-78
-[lints.clippy]79
-all = "warn"@@ -1,119 +0,0 @@
1
-use std::sync::Arc;2
-3
-use macltok::{4
- EncryptedSessionStore, EngineConfig, Event, PasswordLogin, Restore, Retain, RoomMembership,5
- Session, SessionStore, StoreConfig, TimelineContent, TimelineDiff, TimelineId, TimelineItem,6
- imbl::Vector,7
-};8
-9
-#[tokio::main]10
-async fn main() -> Result<(), Box<dyn std::error::Error>> {11
- let store_path = std::path::PathBuf::from(required("MATRIX_STORE_PATH")?);12
- let passphrase = required("MATRIX_STORE_PASSPHRASE")?;13
- let sessions = Arc::new(EncryptedSessionStore::file(14
- store_path.with_extension("session"),15
- StoreConfig::sqlite(&store_path, &passphrase),16
- ));17
- let session = match Session::restore(sessions.clone()).await? {18
- Some(Restore::Resumed(session)) => session,19
- Some(Restore::SoftLoggedOut(soft)) => {20
- Session::login_password(21
- soft.password_login(required("MATRIX_PASSWORD")?),22
- sessions.clone(),23
- )24
- .await?25
- }26
- None | Some(Restore::OAuthPending) => {27
- Session::login_password(28
- PasswordLogin::new(29
- required("MATRIX_HOMESERVER")?,30
- required("MATRIX_USERNAME")?,31
- required("MATRIX_PASSWORD")?,32
- )33
- .with_device_display_name("macltok headless example"),34
- sessions.clone(),35
- )36
- .await?37
- }38
- };39
- let (engine, handle, mut events) = session.build(EngineConfig::default()).await?;40
- let running = tokio::spawn(engine.run());41
- let mut opened: Option<TimelineId> = None;42
- let mut items = Vector::new();43
- let mut stale = false;44
-45
- loop {46
- tokio::select! {47
- event = events.recv() => {48
- let Some(event) = event else { break };49
- match event {50
- Event::RoomsChanged if opened.is_none() => {51
- let Some(room) = handle52
- .rooms()53
- .await54
- .into_iter()55
- .find(|room| room.membership == RoomMembership::Joined)56
- else {57
- continue;58
- };59
- opened = Some(room.room_id.clone().into());60
- // answers arrive on their own, the event loop keeps draining meanwhile61
- let handle = handle.clone();62
- tokio::spawn(async move {63
- if let Err(error) = handle.open_room(room.room_id).await {64
- eprintln!("could not open {error}");65
- }66
- });67
- }68
- Event::TimelineReset { timeline_id, items: snapshot }69
- if opened.as_ref() == Some(&timeline_id) =>70
- {71
- items = snapshot;72
- stale = false;73
- print_messages(&items);74
- }75
- Event::TimelineChanged { timeline_id, diffs }76
- if !stale && opened.as_ref() == Some(&timeline_id) =>77
- {78
- if TimelineDiff::try_apply_all(diffs, &mut items).is_err() {79
- stale = true;80
- let handle = handle.clone();81
- tokio::spawn(async move { handle.resync(timeline_id).await });82
- continue;83
- }84
- print_messages(&items);85
- }86
- Event::Stopped => break,87
- _ => {}88
- }89
- }90
- result = tokio::signal::ctrl_c() => {91
- result?;92
- let handle = handle.clone();93
- tokio::spawn(async move { handle.shutdown().await });94
- }95
- }96
- }97
- let termination = running.await??;98
- drop((handle, events));99
- match termination.retain() {100
- Retain::RecordAndStore => {}101
- Retain::Store => sessions.soft_log_out().await?,102
- Retain::Nothing => sessions.discard().await?,103
- }104
- Ok(())105
-}106
-107
-fn print_messages(items: &Vector<TimelineItem>) {108
- for item in items {109
- if let TimelineItem::Event(event) = item110
- && let TimelineContent::Message(message) = &event.content111
- {112
- println!("{} {}", event.sender.name(), message.body);113
- }114
- }115
-}116
-117
-fn required(name: &str) -> Result<String, Box<dyn std::error::Error>> {118
- std::env::var(name).map_err(|_| format!("{name} is required").into())119
-}@@ -1,1021 +0,0 @@
1
-#[cfg(feature = "sqlite")]2
-use std::path::PathBuf;3
-#[cfg(not(target_family = "wasm"))]4
-use std::sync::atomic::{AtomicBool, Ordering};5
-use std::{collections::HashMap, sync::Arc};6
-7
-use matrix_sdk::{8
- Client, ClientBuilder, SendOutsideWasm, SessionChange, SessionMeta, SyncOutsideWasm,9
- ThreadingSupport,10
- authentication::{11
- AuthSession,12
- matrix::MatrixSession,13
- oauth::{14
- ClientId, ClientRegistrationData, CsrfToken, OAuthSession, PendingAuthorization,15
- UserSession,16
- registration::{ApplicationType, ClientMetadata, Localized, OAuthGrantType},17
- },18
- },19
- cross_process_lock::CrossProcessLockConfig,20
- encryption::{BackupDownloadStrategy, EncryptionSettings},21
- ruma::{MilliSecondsSinceUnixEpoch, OwnedDeviceId, OwnedUserId, serde::Raw},22
-};23
-use serde::{Deserialize, Serialize};24
-use thiserror::Error;25
-use tokio::sync::{broadcast, mpsc};26
-use url::Url;27
-28
-use crate::{29
- ClientHandle, Engine, EngineConfig, EngineError, ErrorKind, EventStream, ServerError,30
- SessionStore,31
- engine::{Persistence, StoreLock},32
- error::SourceError,33
-};34
-35
-/// a logged in client and the `SessionStore` its record lives in. the engine it builds36
-/// saves every token rotation there, unless told `without_persistence`37
-pub struct Session {38
- client: Client,39
- tracking: Tracking,40
- store_lock: StoreLock,41
-}42
-43
-/// taken as soon as the client exists, since the sdk can refresh before there's an engine44
-struct Tracking {45
- session_changes: broadcast::Receiver<SessionChange>,46
- persistence: Option<Persistence>,47
- #[cfg(not(target_family = "wasm"))]48
- inline: Arc<AtomicBool>,49
-}50
-51
-#[non_exhaustive]52
-pub struct PasswordLogin {53
- pub homeserver: String,54
- pub username: String,55
- pub password: String,56
- pub device_display_name: Option<String>,57
- /// set after a soft logout so the store's keys still belong to us58
- pub device_id: Option<OwnedDeviceId>,59
- /// let plain http through, for loopback hosts only60
- pub allow_insecure_loopback: bool,61
-}62
-63
-impl PasswordLogin {64
- #[must_use]65
- pub fn new(66
- homeserver: impl Into<String>,67
- username: impl Into<String>,68
- password: impl Into<String>,69
- ) -> Self {70
- Self {71
- homeserver: homeserver.into(),72
- username: username.into(),73
- password: password.into(),74
- device_display_name: None,75
- device_id: None,76
- allow_insecure_loopback: false,77
- }78
- }79
-80
- #[must_use]81
- pub fn with_device_display_name(mut self, name: impl Into<String>) -> Self {82
- self.device_display_name = Some(name.into());83
- self84
- }85
-86
- #[must_use]87
- pub fn with_device_id(mut self, device_id: OwnedDeviceId) -> Self {88
- self.device_id = Some(device_id);89
- self90
- }91
-92
- #[must_use]93
- pub fn allow_insecure_loopback(mut self, allow: bool) -> Self {94
- self.allow_insecure_loopback = allow;95
- self96
- }97
-}98
-99
-#[non_exhaustive]100
-pub struct OAuthLogin {101
- pub homeserver: String,102
- pub redirect_uri: String,103
- pub client_uri: String,104
- pub client_name: Option<String>,105
- pub client_id: Option<String>,106
- pub device_id: Option<OwnedDeviceId>,107
- pub application_type: OAuthApplicationType,108
- /// let plain http through, for loopback hosts only109
- pub allow_insecure_loopback: bool,110
-}111
-112
-impl OAuthLogin {113
- #[must_use]114
- pub fn new(115
- homeserver: impl Into<String>,116
- redirect_uri: impl Into<String>,117
- client_uri: impl Into<String>,118
- application_type: OAuthApplicationType,119
- ) -> Self {120
- Self {121
- homeserver: homeserver.into(),122
- redirect_uri: redirect_uri.into(),123
- client_uri: client_uri.into(),124
- client_name: None,125
- client_id: None,126
- device_id: None,127
- application_type,128
- allow_insecure_loopback: false,129
- }130
- }131
-132
- #[must_use]133
- pub fn with_client_name(mut self, name: impl Into<String>) -> Self {134
- self.client_name = Some(name.into());135
- self136
- }137
-138
- /// a statically registered client id, skipping dynamic registration139
- #[must_use]140
- pub fn with_client_id(mut self, client_id: impl Into<String>) -> Self {141
- self.client_id = Some(client_id.into());142
- self143
- }144
-145
- #[must_use]146
- pub fn with_device_id(mut self, device_id: OwnedDeviceId) -> Self {147
- self.device_id = Some(device_id);148
- self149
- }150
-151
- #[must_use]152
- pub fn allow_insecure_loopback(mut self, allow: bool) -> Self {153
- self.allow_insecure_loopback = allow;154
- self155
- }156
-}157
-158
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]159
-#[non_exhaustive]160
-pub enum OAuthApplicationType {161
- Native,162
- Web,163
-}164
-165
-pub struct PendingOAuthLogin {166
- client: Client,167
- sessions: Arc<dyn SessionStore>,168
- tracking: Option<(Tracking, StoreLock)>,169
- authorization_url: String,170
- state: Option<CsrfToken>,171
- previous: Option<SessionRecord>,172
-}173
-174
-impl PendingOAuthLogin {175
- #[must_use]176
- pub fn authorization_url(&self) -> &str {177
- &self.authorization_url178
- }179
-180
- #[must_use]181
- pub fn registered_client_id(&self) -> String {182
- self.client183
- .oauth()184
- .client_id()185
- .expect("OAuth client was registered before authorization")186
- .as_str()187
- .to_owned()188
- }189
-190
- #[must_use]191
- pub const fn is_pending(&self) -> bool {192
- self.state.is_some()193
- }194
-195
- pub async fn finish(&mut self, callback_url: &str) -> Result<Session, AccountError> {196
- let callback_url = Url::parse(callback_url)197
- .map_err(|error| account_error("parsing the OAuth callback URL", error))?;198
- let state = self199
- .state200
- .take()201
- .ok_or(AccountError::OAuthCallbackAlreadySubmitted)?;202
- let oauth = self.client.oauth();203
- if let Err(error) = oauth.finish_login(callback_url.into()).await {204
- oauth.abort_login(&state).await;205
- if let Some(previous) = self.previous.take() {206
- put_back(self.sessions.as_ref(), previous).await;207
- }208
- return Err(account_error("OAuth login", error));209
- }210
- let (tracking, store_lock) = self211
- .tracking212
- .take()213
- .ok_or(AccountError::OAuthCallbackAlreadySubmitted)?;214
- Session::saved(215
- self.client.clone(),216
- self.sessions.clone(),217
- tracking,218
- store_lock,219
- )220
- .await221
- }222
-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
- }245
- }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");253
- }254
-}255
-256
-pub struct StoreConfig {257
- location: StoreLocation,258
- passphrase: String,259
-}260
-261
-enum StoreLocation {262
- #[cfg(feature = "sqlite")]263
- Sqlite(PathBuf),264
- #[cfg(feature = "indexeddb")]265
- IndexedDb(String),266
-}267
-268
-impl StoreConfig {269
- #[cfg(feature = "sqlite")]270
- #[must_use]271
- pub fn sqlite(path: impl Into<PathBuf>, passphrase: impl Into<String>) -> Self {272
- Self {273
- location: StoreLocation::Sqlite(path.into()),274
- passphrase: passphrase.into(),275
- }276
- }277
-278
- #[cfg(feature = "indexeddb")]279
- #[must_use]280
- pub fn indexeddb(name: impl Into<String>, passphrase: impl Into<String>) -> Self {281
- Self {282
- location: StoreLocation::IndexedDb(name.into()),283
- passphrase: passphrase.into(),284
- }285
- }286
-287
- /// delete the store's databases, once nothing has them open288
- pub async fn delete(&self) -> Result<(), AccountError> {289
- match &self.location {290
- #[cfg(feature = "sqlite")]291
- StoreLocation::Sqlite(path) => crate::session_store::delete_sqlite_store(path)292
- .map_err(|error| account_error("deleting the Matrix store", error)),293
- #[cfg(feature = "indexeddb")]294
- StoreLocation::IndexedDb(name) => crate::session_store::delete_indexeddb_store(name)295
- .await296
- .map_err(|error| error.into_account_error("deleting the Matrix store")),297
- }298
- }299
-300
- /// taken before the client opens the store, and held until the engine and every301
- /// `ClientHandle` are gone302
- pub(crate) async fn lock(&self) -> Result<StoreLock, AccountError> {303
- match &self.location {304
- #[cfg(feature = "sqlite")]305
- StoreLocation::Sqlite(path) => StoreLock::file(path),306
- #[cfg(feature = "indexeddb")]307
- StoreLocation::IndexedDb(name) => StoreLock::web(name).await,308
- }309
- }310
-311
- pub(crate) fn passphrase(&self) -> &str {312
- &self.passphrase313
- }314
-315
- #[cfg(feature = "indexeddb")]316
- pub(crate) fn indexeddb_name(&self) -> &str {317
- let StoreLocation::IndexedDb(name) = &self.location;318
- name319
- }320
-}321
-322
-/// Serializable authentication secrets. A `SessionStore` keeps it, and the engine323
-/// replaces it there on every token rotation.324
-#[derive(Clone, Deserialize, Serialize)]325
-pub struct SessionRecord {326
- homeserver: String,327
- authentication: AuthenticationRecord,328
-}329
-330
-#[derive(Clone, Deserialize, Serialize)]331
-#[serde(tag = "type", content = "session", rename_all = "snake_case")]332
-enum AuthenticationRecord {333
- Matrix(MatrixSession),334
- OAuth {335
- client_id: ClientId,336
- user: UserSession,337
- },338
- SoftLoggedOut {339
- meta: SessionMeta,340
- client_id: Option<ClientId>,341
- },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()368
-}369
-370
-impl SessionRecord {371
- #[must_use]372
- pub fn homeserver(&self) -> &str {373
- &self.homeserver374
- }375
-376
- /// a pending oauth login is nobody yet, unless it resumes someone377
- pub(crate) fn user_id(&self) -> Option<&OwnedUserId> {378
- match &self.authentication {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),393
- }394
- }395
-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
-410
- pub(crate) fn soft_logout(&self) -> Option<SoftLogout> {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,417
- };418
- Some(SoftLogout {419
- homeserver: self.homeserver.clone(),420
- user_id: meta.user_id.clone(),421
- device_id: meta.device_id.clone(),422
- client_id: client_id423
- .as_ref()424
- .map(|client_id| client_id.as_str().to_owned()),425
- })426
- }427
-428
- pub(crate) fn soft_logged_out(self) -> Self {429
- let authentication = match self.authentication {430
- AuthenticationRecord::Matrix(session) => AuthenticationRecord::SoftLoggedOut {431
- meta: session.meta,432
- client_id: None,433
- },434
- AuthenticationRecord::OAuth { client_id, user } => {435
- AuthenticationRecord::SoftLoggedOut {436
- meta: user.meta,437
- client_id: Some(client_id),438
- }439
- }440
- soft @ AuthenticationRecord::SoftLoggedOut { .. } => soft,441
- pending @ AuthenticationRecord::PendingOAuth { .. } => pending,442
- };443
- Self {444
- homeserver: self.homeserver,445
- authentication,446
- }447
- }448
-}449
-450
-pub enum Restore {451
- Resumed(Session),452
- SoftLoggedOut(SoftLogout),453
- /// an oauth login is waiting on its redirect. `Session::finish_oauth` takes the callback454
- OAuthPending,455
-}456
-457
-/// who to log in again as. the store keeps this device's e2ee keys, so a login458
-/// as any other device would orphan them459
-#[derive(Clone, Debug)]460
-#[non_exhaustive]461
-pub struct SoftLogout {462
- pub homeserver: String,463
- pub user_id: OwnedUserId,464
- pub device_id: OwnedDeviceId,465
- /// set when the login was OAuth, for `OAuthLogin::with_client_id`466
- pub client_id: Option<String>,467
-}468
-469
-impl SoftLogout {470
- #[must_use]471
- pub fn password_login(&self, password: impl Into<String>) -> PasswordLogin {472
- PasswordLogin::new(self.homeserver.clone(), self.user_id.as_str(), password)473
- .with_device_id(self.device_id.clone())474
- .allow_insecure_loopback(true)475
- }476
-477
- #[must_use]478
- pub fn oauth_login(479
- &self,480
- redirect_uri: impl Into<String>,481
- client_uri: impl Into<String>,482
- application_type: OAuthApplicationType,483
- ) -> OAuthLogin {484
- let login = OAuthLogin::new(485
- self.homeserver.clone(),486
- redirect_uri,487
- client_uri,488
- application_type,489
- )490
- .with_device_id(self.device_id.clone())491
- .allow_insecure_loopback(true);492
- match &self.client_id {493
- Some(client_id) => login.with_client_id(client_id.clone()),494
- None => login,495
- }496
- }497
-}498
-499
-#[derive(Debug, Error)]500
-pub enum AccountError {501
- #[error("{context} failed")]502
- Operation {503
- context: &'static str,504
- kind: ErrorKind,505
- server: Option<ServerError>,506
- #[source]507
- source: Box<SourceError>,508
- },509
- #[error("the homeserver returned no authenticated session")]510
- MissingSession,511
- #[error("the SDK returned an unsupported authentication session")]512
- UnsupportedAuthentication,513
- #[error("the OAuth callback was already submitted")]514
- 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,519
- /// what was typed can't name a homeserver, so nothing was sent anywhere520
- #[error("that isn't a usable homeserver, {0}")]521
- InvalidHomeserver(&'static str),522
- /// `Accounts` already has a live login for them. a second would be another device523
- #[error("{0} is already logged in")]524
- AlreadyLoggedIn(OwnedUserId),525
- /// an IndexedDB delete is waiting on the store's other connections. it goes through526
- /// once they close, and opening the store again waits behind it527
- #[error("the store is still open elsewhere, it will be deleted once that closes")]528
- StoreDeletePending,529
- /// another engine, in this process or another, has the store. only one runs per store530
- #[error("another engine is using this store")]531
- StoreInUse,532
- /// the login worked but its first record didn't save. the server already has the533
- /// device, so retry the save rather than log in again534
- #[error("saving the new login's record failed")]535
- Unsaved {536
- session: Box<UnsavedSession>,537
- #[source]538
- source: Box<AccountError>,539
- },540
- #[error(transparent)]541
- Engine(#[from] EngineError),542
-}543
-544
-impl AccountError {545
- /// what the homeserver said, when it refused546
- #[must_use]547
- pub fn server(&self) -> Option<&ServerError> {548
- match self {549
- Self::Operation { server, .. } => server.as_ref(),550
- Self::Unsaved { source, .. } => source.server(),551
- _ => None,552
- }553
- }554
-}555
-556
-/// a live login whose record never reached its `SessionStore`557
-pub struct UnsavedSession {558
- session: Session,559
- sessions: Arc<dyn SessionStore>,560
-}561
-562
-impl UnsavedSession {563
- pub async fn save(self) -> Result<Session, AccountError> {564
- let saved = match session_record(&self.session.client) {565
- Ok(record) => self.sessions.save(&record).await,566
- Err(error) => Err(error),567
- };568
- match saved {569
- Ok(()) => Ok(self.session),570
- Err(source) => Err(AccountError::Unsaved {571
- session: Box::new(self),572
- source: Box::new(source),573
- }),574
- }575
- }576
-577
- /// the record to keep somewhere else, if the store won't take it578
- pub fn record(&self) -> Result<SessionRecord, AccountError> {579
- session_record(&self.session.client)580
- }581
-}582
-583
-impl std::fmt::Debug for UnsavedSession {584
- fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {585
- formatter586
- .debug_struct("UnsavedSession")587
- .finish_non_exhaustive()588
- }589
-}590
-591
-impl Session {592
- pub async fn login_password(593
- login: PasswordLogin,594
- sessions: Arc<dyn SessionStore>,595
- ) -> Result<Self, AccountError> {596
- homeserver_input(&login.homeserver, login.allow_insecure_loopback)?;597
- let store_lock = sessions.store().lock().await?;598
- Self::login_password_locked(login, sessions, store_lock).await599
- }600
-601
- pub(crate) async fn login_password_locked(602
- login: PasswordLogin,603
- sessions: Arc<dyn SessionStore>,604
- store_lock: StoreLock,605
- ) -> Result<Self, AccountError> {606
- let homeserver = homeserver_input(&login.homeserver, login.allow_insecure_loopback)?;607
- let client = matrix_client_builder(sessions.store())608
- .server_name_or_homeserver_url(homeserver)609
- .build()610
- .await611
- .map_err(|error| account_error("building the Matrix client", error))?;612
- let tracking = Tracking::start(&client, sessions.clone())?;613
- let mut request = client614
- .matrix_auth()615
- .login_username(login.username.trim(), &login.password)616
- .request_refresh_token();617
- if let Some(name) = login.device_display_name.as_deref() {618
- request = request.initial_device_display_name(name);619
- }620
- if let Some(device_id) = login.device_id.as_deref() {621
- request = request.device_id(device_id.as_str());622
- }623
- request624
- .send()625
- .await626
- .map_err(|error| account_error("password login", error))?;627
- Self::saved(client, sessions, tracking, store_lock).await628
- }629
-630
- /// the pending login lives only in this process, so the one that begins it has to finish it631
- pub async fn begin_oauth(632
- login: OAuthLogin,633
- sessions: Arc<dyn SessionStore>,634
- ) -> Result<PendingOAuthLogin, AccountError> {635
- homeserver_input(&login.homeserver, login.allow_insecure_loopback)?;636
- let store_lock = sessions.store().lock().await?;637
- Self::begin_oauth_locked(login, sessions, store_lock).await638
- }639
-640
- pub(crate) async fn begin_oauth_locked(641
- login: OAuthLogin,642
- sessions: Arc<dyn SessionStore>,643
- store_lock: StoreLock,644
- ) -> Result<PendingOAuthLogin, AccountError> {645
- let redirect_uri = Url::parse(login.redirect_uri.trim())646
- .map_err(|error| account_error("parsing the OAuth redirect URI", error))?;647
- let client_uri = Url::parse(login.client_uri.trim())648
- .map_err(|error| account_error("parsing the OAuth client URI", error))?;649
- let homeserver = homeserver_input(&login.homeserver, login.allow_insecure_loopback)?;650
- let client = matrix_client_builder(sessions.store())651
- .server_name_or_homeserver_url(homeserver)652
- .build()653
- .await654
- .map_err(|error| account_error("building the Matrix client", error))?;655
- let tracking = Tracking::start(&client, sessions.clone())?;656
- let application_type = match login.application_type {657
- OAuthApplicationType::Native => ApplicationType::Native,658
- OAuthApplicationType::Web => ApplicationType::Web,659
- };660
- let mut metadata = ClientMetadata::new(661
- application_type,662
- vec![OAuthGrantType::AuthorizationCode {663
- redirect_uris: vec![redirect_uri.clone()],664
- }],665
- Localized::new(client_uri, []),666
- );667
- metadata.client_name = login.client_name.map(|name| Localized::new(name, []));668
- let metadata = Raw::new(&metadata)669
- .map_err(|error| account_error("serializing OAuth client metadata", error))?;670
- let mut registration = ClientRegistrationData::new(metadata);671
- if let Some(client_id) = login.client_id {672
- registration.static_registrations = Some(HashMap::from([(673
- client.homeserver(),674
- ClientId::new(client_id),675
- )]));676
- }677
- let authorization = client678
- .oauth()679
- .login(redirect_uri, login.device_id, Some(registration), None)680
- .build()681
- .await682
- .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?;705
- Ok(PendingOAuthLogin {706
- client,707
- sessions,708
- tracking: Some((tracking, store_lock)),709
- authorization_url: authorization.url.to_string(),710
- state: Some(authorization.state),711
- previous,712
- })713
- }714
-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
-776
- /// Restore the record `sessions` holds, or `None` when it holds none.777
- /// The caller must also handle rejection and explicit logout.778
- pub async fn restore(sessions: Arc<dyn SessionStore>) -> Result<Option<Restore>, AccountError> {779
- let Some(record) = sessions.load().await? else {780
- return Ok(None);781
- };782
- let authentication = match record.authentication {783
- AuthenticationRecord::Matrix(session) => AuthSession::Matrix(session),784
- AuthenticationRecord::OAuth { client_id, user } => {785
- OAuthSession { client_id, user }.into()786
- }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)),796
- AuthenticationRecord::SoftLoggedOut { meta, client_id } => {797
- return Ok(Some(Restore::SoftLoggedOut(SoftLogout {798
- homeserver: record.homeserver,799
- user_id: meta.user_id,800
- device_id: meta.device_id,801
- client_id: client_id.map(|client_id| client_id.as_str().to_owned()),802
- })));803
- }804
- };805
- let store_lock = sessions.store().lock().await?;806
- let client = matrix_client_builder(sessions.store())807
- .homeserver_url(&record.homeserver)808
- .build()809
- .await810
- .map_err(|error| account_error("reopening the Matrix store", error))?;811
- let tracking = Tracking::start(&client, sessions)?;812
- client813
- .restore_session(authentication)814
- .await815
- .map_err(|error| account_error("restoring the authenticated session", error))?;816
- Ok(Some(Restore::Resumed(Self {817
- client,818
- tracking,819
- store_lock,820
- })))821
- }822
-823
- /// leave the record to the host, which then saves `ClientHandle::session_record()`824
- /// on every `SessionTokensChanged`825
- #[must_use]826
- pub fn without_persistence(mut self) -> Self {827
- self.tracking.persistence = None;828
- #[cfg(not(target_family = "wasm"))]829
- self.tracking.inline.store(false, Ordering::Release);830
- self831
- }832
-833
- pub async fn build(834
- self,835
- config: EngineConfig,836
- ) -> Result<(Engine, ClientHandle, EventStream), AccountError> {837
- Engine::with_persistence(838
- self.client,839
- config,840
- self.tracking.session_changes,841
- self.tracking.persistence,842
- Some(self.store_lock),843
- )844
- .await845
- .map_err(AccountError::from)846
- }847
-848
- async fn saved(849
- client: Client,850
- sessions: Arc<dyn SessionStore>,851
- tracking: Tracking,852
- store_lock: StoreLock,853
- ) -> Result<Self, AccountError> {854
- UnsavedSession {855
- session: Self {856
- client,857
- tracking,858
- store_lock,859
- },860
- sessions,861
- }862
- .save()863
- .await864
- }865
-}866
-867
-impl Tracking {868
- fn start(client: &Client, sessions: Arc<dyn SessionStore>) -> Result<Self, AccountError> {869
- let session_changes = client.subscribe_to_session_changes();870
- #[cfg_attr(target_family = "wasm", allow(unused_variables))]871
- let (results, inline_results) = mpsc::unbounded_channel();872
- #[cfg(not(target_family = "wasm"))]873
- let inline = save_inline(client, sessions.clone(), results)?;874
- Ok(Self {875
- session_changes,876
- persistence: Some(Persistence {877
- save: Box::new(move |client| {878
- let sessions = sessions.clone();879
- Box::pin(async move {880
- let record = session_record(&client).map_err(save_failed)?;881
- sessions.save(&record).await.map_err(save_failed)882
- })883
- }),884
- inline: inline_results,885
- }),886
- #[cfg(not(target_family = "wasm"))]887
- inline,888
- })889
- }890
-}891
-892
-/// the sdk calls this inside its refresh, after it has the new tokens and before it uses893
-/// them, so a store that writes there and then can't lose a refresh token to a crash894
-#[cfg(not(target_family = "wasm"))]895
-fn save_inline(896
- client: &Client,897
- sessions: Arc<dyn SessionStore>,898
- results: mpsc::UnboundedSender<Result<(), crate::Error>>,899
-) -> Result<Arc<AtomicBool>, AccountError> {900
- let enabled = Arc::new(AtomicBool::new(true));901
- let saving = enabled.clone();902
- client903
- .set_session_callbacks(904
- // only asked for with the cross process refresh lock, which we never enable905
- Box::new(|client| {906
- client907
- .session_tokens()908
- .ok_or_else(|| AccountError::MissingSession.into())909
- }),910
- Box::new(move |client| {911
- if !saving.load(Ordering::Acquire) {912
- return Ok(());913
- }914
- let record = session_record(&client)?;915
- if let Some(result) = sessions.save_blocking(&record) {916
- let _ = results.send(result.map_err(save_failed));917
- }918
- Ok(())919
- }),920
- )921
- .map_err(|error| account_error("registering the session callbacks", error))?;922
- Ok(enabled)923
-}924
-925
-fn save_failed(error: AccountError) -> crate::Error {926
- let message = format!("saving the session record failed, {error}");927
- crate::Error::with_source(ErrorKind::Store, message, error)928
-}929
-930
-pub(crate) fn session_record(client: &Client) -> Result<SessionRecord, AccountError> {931
- let authentication = match client.session().ok_or(AccountError::MissingSession)? {932
- AuthSession::Matrix(session) => AuthenticationRecord::Matrix(session),933
- AuthSession::OAuth(session) => {934
- let OAuthSession { client_id, user } = *session;935
- AuthenticationRecord::OAuth { client_id, user }936
- }937
- _ => return Err(AccountError::UnsupportedAuthentication),938
- };939
- Ok(SessionRecord {940
- homeserver: client.homeserver().to_string(),941
- authentication,942
- })943
-}944
-945
-fn encryption_settings() -> EncryptionSettings {946
- EncryptionSettings {947
- auto_enable_cross_signing: true,948
- backup_download_strategy: BackupDownloadStrategy::OneShot,949
- auto_enable_backups: true,950
- }951
-}952
-953
-/// `StoreLock` already keeps every other client off the store, and the sdk's lease would cost954
-/// a store write per locked section and a task renewing it every 50ms955
-fn matrix_client_builder(store: &StoreConfig) -> ClientBuilder {956
- let builder = Client::builder()957
- .with_encryption_settings(encryption_settings())958
- .with_threading_support(ThreadingSupport::Enabled {959
- with_subscriptions: false,960
- })961
- .handle_refresh_tokens()962
- .cross_process_store_config(CrossProcessLockConfig::SingleProcess);963
- match &store.location {964
- #[cfg(feature = "sqlite")]965
- StoreLocation::Sqlite(path) => builder.sqlite_store(path, Some(&store.passphrase)),966
- #[cfg(feature = "indexeddb")]967
- StoreLocation::IndexedDb(name) => builder.indexeddb_store(name, Some(&store.passphrase)),968
- }969
-}970
-971
-/// a bare server name goes through untouched, since that's what makes the sdk try `.well-known`972
-pub(crate) fn homeserver_input(973
- input: &str,974
- allow_insecure_loopback: bool,975
-) -> Result<&str, AccountError> {976
- let input = input.trim();977
- let invalid = |reason| Err(AccountError::InvalidHomeserver(reason));978
- if input.is_empty() {979
- return invalid("it's empty");980
- }981
- if input.contains(char::is_whitespace) {982
- return invalid("it has spaces in it");983
- }984
- if !input.contains("://") {985
- return match <&ruma::ServerName>::try_from(input) {986
- Ok(_) => Ok(input),987
- Err(_) => invalid("it's neither a server name nor a url"),988
- };989
- }990
- let Ok(url) = Url::parse(input) else {991
- return invalid("it isn't a valid url");992
- };993
- if !url.username().is_empty() || url.password().is_some() {994
- return invalid("it has a username in it");995
- }996
- let loopback = match url.host() {997
- None => return invalid("it has no host"),998
- Some(url::Host::Domain(domain)) => domain.eq_ignore_ascii_case("localhost"),999
- Some(url::Host::Ipv4(address)) => address.is_loopback(),1000
- Some(url::Host::Ipv6(address)) => address.is_loopback(),1001
- };1002
- match url.scheme() {1003
- "https" => Ok(input),1004
- "http" if allow_insecure_loopback && loopback => Ok(input),1005
- "http" if allow_insecure_loopback => invalid("plain http only reaches loopback hosts"),1006
- "http" => invalid("it isn't https"),1007
- _ => invalid("only http and https are spoken"),1008
- }1009
-}1010
-1011
-pub(crate) fn account_error(1012
- context: &'static str,1013
- source: impl std::error::Error + SendOutsideWasm + SyncOutsideWasm + 'static,1014
-) -> AccountError {1015
- AccountError::Operation {1016
- context,1017
- kind: crate::error::classify(&source),1018
- server: ServerError::find(&source),1019
- source: Box::new(source),1020
- }1021
-}@@ -1,470 +0,0 @@
1
-use std::sync::Arc;2
-#[cfg(feature = "sqlite")]3
-use std::{collections::BTreeSet, path::PathBuf};4
-5
-use ruma::TransactionId;6
-7
-use crate::{8
- AccountError, EncryptedSessionStore, OAuthLogin, PasswordLogin, PendingOAuthLogin, Restore,9
- Session, SessionRecord, SessionStore, SoftLogout, StoreConfig,10
- account::{callback_state, homeserver_input},11
- engine::StoreLock,12
-};13
-14
-/// every account one owner has, a store each under one root and one passphrase. whoever holds15
-/// the set can resume any soft-logged-out device in it with just its password, and the device's16
-/// store has its e2ee keys, so a set belongs to one person and never to a whole machine17
-pub struct Accounts {18
- root: Root,19
- passphrase: String,20
-}21
-22
-enum Root {23
- #[cfg(feature = "sqlite")]24
- Directory(PathBuf),25
- #[cfg(feature = "indexeddb")]26
- Prefix(String),27
-}28
-29
-/// one account's record and store30
-#[derive(Clone)]31
-pub struct Account {32
- id: String,33
- sessions: Arc<EncryptedSessionStore>,34
-}35
-36
-impl Account {37
- /// stable for the account's life, and safe in a cookie or a url38
- #[must_use]39
- pub fn id(&self) -> &str {40
- &self.id41
- }42
-43
- /// for `Termination::retain`. a discarded account drops out of `Accounts::list`44
- #[must_use]45
- pub fn sessions(&self) -> Arc<dyn SessionStore> {46
- self.sessions.clone()47
- }48
-49
- pub async fn restore(&self) -> Result<Option<Restore>, AccountError> {50
- Session::restore(self.sessions()).await51
- }52
-}53
-54
-impl std::fmt::Debug for Account {55
- fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {56
- formatter57
- .debug_struct("Account")58
- .field("id", &self.id)59
- .finish_non_exhaustive()60
- }61
-}62
-63
-enum Found {64
- Nobody,65
- SoftLoggedOut(Account, SoftLogout),66
-}67
-68
-impl Accounts {69
- /// each account is a store directory under `root` with its record beside it70
- #[cfg(feature = "sqlite")]71
- #[must_use]72
- pub fn sqlite(root: impl Into<PathBuf>, passphrase: impl Into<String>) -> Self {73
- Self {74
- root: Root::Directory(root.into()),75
- passphrase: passphrase.into(),76
- }77
- }78
-79
- /// each account is a store named `<prefix>::<id>`, listed in `<prefix>::macltok-accounts`80
- #[cfg(feature = "indexeddb")]81
- #[must_use]82
- pub fn indexeddb(prefix: impl Into<String>, passphrase: impl Into<String>) -> Self {83
- Self {84
- root: Root::Prefix(prefix.into()),85
- passphrase: passphrase.into(),86
- }87
- }88
-89
- /// `None` for anything `Account::id` couldn't have been90
- #[must_use]91
- pub fn account(&self, id: &str) -> Option<Account> {92
- let valid =93
- !id.is_empty() && id.len() <= 64 && id.bytes().all(|b| b.is_ascii_alphanumeric());94
- valid.then(|| self.open(id.to_owned()))95
- }96
-97
- /// every account with a record. a store left without one, by a login that died halfway or a98
- /// 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 back100
- pub async fn list(&self) -> Result<Vec<Account>, AccountError> {101
- let mut accounts = Vec::new();102
- for account in self.slots().await? {103
- match account.sessions.load().await {104
- 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
- }110
- Ok(Some(_)) | Err(_) => accounts.push(account),111
- }112
- }113
- Ok(accounts)114
- }115
-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
-142
- /// a soft-logged-out account the login names is resumed as its own device, keeping its keys.143
- /// anyone else gets a new account144
- pub async fn login_password(145
- &self,146
- login: PasswordLogin,147
- ) -> Result<(Account, Session), AccountError> {148
- homeserver_input(&login.homeserver, login.allow_insecure_loopback)?;149
- let found = self150
- .find(|record| record_names(record, &login.homeserver, &login.username))151
- .await?;152
- match found {153
- Found::SoftLoggedOut(account, soft) => {154
- let store_lock = account.sessions.store().lock().await?;155
- let login = PasswordLogin {156
- device_display_name: login.device_display_name,157
- ..soft.password_login(login.password)158
- };159
- let session =160
- Session::login_password_locked(login, account.sessions(), store_lock).await?;161
- Ok((account, session))162
- }163
- Found::Nobody => {164
- let (account, store_lock) = self.fresh().await?;165
- let login = PasswordLogin {166
- device_id: None,167
- ..login168
- };169
- match Session::login_password_locked(login, account.sessions(), store_lock).await {170
- Ok(session) => Ok((account, session)),171
- Err(error) => Err(self.abandon(&account, error).await),172
- }173
- }174
- }175
- }176
-177
- /// resumes the soft-logged-out account whose device `login` names, as178
- /// `SoftLogout::oauth_login` sets it. without one it's a new account, since which account an179
- /// OAuth login is for isn't known until it finishes180
- pub async fn begin_oauth(181
- &self,182
- login: OAuthLogin,183
- ) -> Result<(Account, PendingOAuthLogin), AccountError> {184
- homeserver_input(&login.homeserver, login.allow_insecure_loopback)?;185
- let device_id = login.device_id.clone();186
- let found = self187
- .find(|record| {188
- record189
- .soft_logout()190
- .is_some_and(|soft| device_id.as_ref() == Some(&soft.device_id))191
- })192
- .await?;193
- match found {194
- Found::SoftLoggedOut(account, soft) => {195
- let store_lock = account.sessions.store().lock().await?;196
- let login = OAuthLogin {197
- homeserver: soft.homeserver,198
- client_id: login.client_id.or(soft.client_id),199
- allow_insecure_loopback: true,200
- ..login201
- };202
- let pending =203
- Session::begin_oauth_locked(login, account.sessions(), store_lock).await?;204
- Ok((account, pending))205
- }206
- Found::Nobody => {207
- let (account, store_lock) = self.fresh().await?;208
- let login = OAuthLogin {209
- device_id: None,210
- ..login211
- };212
- match Session::begin_oauth_locked(login, account.sessions(), store_lock).await {213
- Ok(pending) => Ok((account, pending)),214
- Err(error) => Err(self.abandon(&account, error).await),215
- }216
- }217
- }218
- }219
-220
- /// a live login for the same person is refused rather than given a second device221
- async fn find(&self, resumes: impl Fn(&SessionRecord) -> bool) -> Result<Found, AccountError> {222
- for account in self.slots().await? {223
- let Ok(Some(record)) = account.sessions.load().await else {224
- continue;225
- };226
- if !resumes(&record) {227
- continue;228
- }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,233
- };234
- }235
- Ok(Found::Nobody)236
- }237
-238
- /// locked before anything else can see it, so `list` never sweeps a login still under way239
- async fn fresh(&self) -> Result<(Account, StoreLock), AccountError> {240
- let account = self.open(TransactionId::new().to_string());241
- let store_lock = account.sessions.store().lock().await?;242
- #[cfg(feature = "indexeddb")]243
- {244
- let Root::Prefix(prefix) = &self.root;245
- index::add(prefix, &account.id).await?;246
- }247
- Ok((account, store_lock))248
- }249
-250
- /// a failed first login leaves nothing behind. one whose record didn't save has a live device251
- /// on the server, so that stays for `UnsavedSession::save`252
- async fn abandon(&self, account: &Account, error: AccountError) -> AccountError {253
- if !matches!(error, AccountError::Unsaved { .. }) {254
- self.sweep(account).await;255
- }256
- error257
- }258
-259
- /// best effort, since whatever it leaves is found and swept again by the next `list`260
- async fn sweep(&self, account: &Account) {261
- let Ok(_store_lock) = account.sessions.store().lock().await else {262
- return;263
- };264
- if matches!(account.sessions.load().await, Ok(None)) {265
- self.remove(account).await;266
- }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) {294
- if account.sessions.discard().await.is_err() {295
- return;296
- }297
- #[cfg(feature = "indexeddb")]298
- {299
- let Root::Prefix(prefix) = &self.root;300
- let _ = index::remove(prefix, &account.id).await;301
- }302
- }303
-304
- fn open(&self, id: String) -> Account {305
- let sessions = match &self.root {306
- #[cfg(feature = "sqlite")]307
- Root::Directory(root) => EncryptedSessionStore::file(308
- root.join(format!("{id}.session")),309
- StoreConfig::sqlite(root.join(&id), self.passphrase.clone()),310
- ),311
- #[cfg(feature = "indexeddb")]312
- Root::Prefix(prefix) => EncryptedSessionStore::indexeddb(StoreConfig::indexeddb(313
- format!("{prefix}::{id}"),314
- self.passphrase.clone(),315
- )),316
- };317
- Account {318
- id,319
- sessions: Arc::new(sessions),320
- }321
- }322
-323
- async fn slots(&self) -> Result<Vec<Account>, AccountError> {324
- let ids = match &self.root {325
- #[cfg(feature = "sqlite")]326
- Root::Directory(root) => directory_slots(root)?,327
- #[cfg(feature = "indexeddb")]328
- Root::Prefix(prefix) => index::list(prefix).await?,329
- };330
- Ok(ids.iter().filter_map(|id| self.account(id)).collect())331
- }332
-}333
-334
-/// a localpart or a full user id names the same account, but only on the same server. the server335
-/// is compared by name as well as by url, so a delegated `example.org` still finds the account336
-/// discovery took to `matrix.example.org`337
-fn record_names(record: &SessionRecord, homeserver: &str, username: &str) -> bool {338
- let Some(user_id) = record.user_id() else {339
- return false;340
- };341
- let username = username.trim();342
- let user = if username.starts_with('@') {343
- username.eq_ignore_ascii_case(user_id.as_str())344
- } else {345
- username.eq_ignore_ascii_case(user_id.localpart())346
- };347
- let typed = authority(homeserver);348
- user && (typed.eq_ignore_ascii_case(user_id.server_name().as_str())349
- || typed.eq_ignore_ascii_case(authority(record.homeserver())))350
-}351
-352
-fn authority(homeserver: &str) -> &str {353
- let homeserver = homeserver.trim();354
- let rest = homeserver355
- .split_once("://")356
- .map_or(homeserver, |(_, rest)| rest);357
- rest.split('/').next().unwrap_or(rest)358
-}359
-360
-#[cfg(feature = "sqlite")]361
-fn directory_slots(root: &std::path::Path) -> Result<BTreeSet<String>, AccountError> {362
- use std::io::ErrorKind;363
-364
- let entries = match std::fs::read_dir(root) {365
- Ok(entries) => entries,366
- Err(error) if error.kind() == ErrorKind::NotFound => return Ok(BTreeSet::new()),367
- Err(error) => return Err(crate::account::account_error("listing the accounts", error)),368
- };369
- let mut ids = BTreeSet::new();370
- for entry in entries {371
- let entry =372
- entry.map_err(|error| crate::account::account_error("listing the accounts", error))?;373
- let name = entry.file_name();374
- let Some(name) = name.to_str() else {375
- continue;376
- };377
- if entry.path().is_dir() {378
- ids.insert(name.to_owned());379
- } else if let Some(id) = name.strip_suffix(".session") {380
- ids.insert(id.to_owned());381
- }382
- }383
- Ok(ids)384
-}385
-386
-/// a key per account, so two tabs adding at once can't lose one to the other387
-#[cfg(feature = "indexeddb")]388
-mod index {389
- use std::collections::BTreeSet;390
-391
- use indexed_db_futures::{392
- Build,393
- database::Database,394
- error::{Error, OpenDbError},395
- prelude::*,396
- transaction::{Transaction, TransactionMode},397
- };398
-399
- use crate::{AccountError, account::account_error};400
-401
- const ACCOUNTS: &str = "accounts";402
-403
- fn name(prefix: &str) -> String {404
- format!("{prefix}::macltok-accounts")405
- }406
-407
- async fn open(prefix: &str) -> Result<Database, OpenDbError> {408
- Database::open(&name(prefix))409
- .with_version(1u32)410
- .with_on_upgrade_needed(|_, transaction: &Transaction<'_>| -> Result<(), Error> {411
- transaction.db().create_object_store(ACCOUNTS).build()?;412
- Ok(())413
- })414
- .build()?415
- .await416
- }417
-418
- pub(super) async fn list(prefix: &str) -> Result<BTreeSet<String>, AccountError> {419
- let failed = |error| account_error("listing the accounts", error);420
- let database = open(prefix).await.map_err(failed)?;421
- let ids = keys(&database).await;422
- database.close();423
- ids.map_err(|error| account_error("listing the accounts", error))424
- }425
-426
- pub(super) async fn add(prefix: &str, id: &str) -> Result<(), AccountError> {427
- let failed = |error| account_error("adding the account", error);428
- let database = open(prefix).await.map_err(failed)?;429
- let added = write(&database, |store| {430
- store.put(true).with_key(id).build()?;431
- Ok(())432
- })433
- .await;434
- database.close();435
- added.map_err(|error| account_error("adding the account", error))436
- }437
-438
- pub(super) async fn remove(prefix: &str, id: &str) -> Result<(), AccountError> {439
- let failed = |error| account_error("removing the account", error);440
- let database = open(prefix).await.map_err(failed)?;441
- let removed = write(&database, |store| {442
- store.delete(id).build()?;443
- Ok(())444
- })445
- .await;446
- database.close();447
- removed.map_err(|error| account_error("removing the account", error))448
- }449
-450
- async fn keys(database: &Database) -> Result<BTreeSet<String>, Error> {451
- let transaction = database452
- .transaction(ACCOUNTS)453
- .with_mode(TransactionMode::Readonly)454
- .build()?;455
- let store = transaction.object_store(ACCOUNTS)?;456
- store.get_all_keys::<String>().await?.collect()457
- }458
-459
- async fn write(460
- database: &Database,461
- change: impl FnOnce(&indexed_db_futures::object_store::ObjectStore<'_>) -> Result<(), Error>,462
- ) -> Result<(), Error> {463
- let transaction = database464
- .transaction(ACCOUNTS)465
- .with_mode(TransactionMode::Readwrite)466
- .build()?;467
- change(&transaction.object_store(ACCOUNTS)?)?;468
- transaction.commit().await469
- }470
-}@@ -1,903 +0,0 @@
1
-use std::sync::Arc;2
-3
-use futures_util::StreamExt;4
-use matrix_sdk::executor::{JoinHandle, spawn};5
-use matrix_sdk::{6
- Client,7
- encryption::{8
- VerificationState,9
- backups::BackupState,10
- recovery::RecoveryState as SdkRecoveryState,11
- verification::{12
- SasState, SasVerification, Verification, VerificationRequest, VerificationRequestState,13
- },14
- },15
- ruma::events::key::verification::VerificationMethod,16
-};17
-use ruma::{OwnedDeviceId, UserId};18
-use tokio::sync::Mutex;19
-20
-use crate::{21
- EncryptionState, Error, ErrorKind, Event, KeyBackupState, RecoveryState, VerificationAction,22
- VerificationState as UiState, VerificationTarget, model::VerificationFailure,23
- transport::EventSender,24
-};25
-26
-pub(crate) fn encryption_state(27
- device_id: OwnedDeviceId,28
- recovery: SdkRecoveryState,29
- verification: VerificationState,30
- backup: BackupState,31
-) -> EncryptionState {32
- EncryptionState {33
- device_id,34
- device_verified: matches!(verification, VerificationState::Verified),35
- recovery: match recovery {36
- SdkRecoveryState::Unknown => RecoveryState::Unknown,37
- SdkRecoveryState::Disabled => RecoveryState::Disabled,38
- SdkRecoveryState::Incomplete => RecoveryState::Incomplete,39
- SdkRecoveryState::Enabled => RecoveryState::Enabled,40
- },41
- key_backup: match backup {42
- BackupState::Unknown => KeyBackupState::Unknown,43
- BackupState::Creating => KeyBackupState::Creating,44
- BackupState::Enabling => KeyBackupState::Enabling,45
- BackupState::Resuming => KeyBackupState::Resuming,46
- BackupState::Enabled => KeyBackupState::Enabled,47
- BackupState::Downloading => KeyBackupState::Downloading,48
- BackupState::Disabling => KeyBackupState::Disabling,49
- },50
- }51
-}52
-53
-#[derive(Default)]54
-pub(crate) struct VerificationController {55
- active: Arc<Mutex<VerificationSession>>,56
- task: Option<JoinHandle<()>>,57
-}58
-59
-#[derive(Default)]60
-struct VerificationSession {61
- request: Option<VerificationRequest>,62
- sas: Option<SasVerification>,63
-}64
-65
-impl VerificationController {66
- pub(crate) async fn apply(67
- &mut self,68
- sender: EventSender,69
- action: VerificationAction,70
- ) -> Result<(), Error> {71
- match action {72
- VerificationAction::Accept => self.accept().await,73
- VerificationAction::Confirm => {74
- // Retain a handle across the operation: the watcher clears75
- // `active` when it publishes a terminal event, but this clone76
- // still observes the shared SDK state afterwards.77
- let sas = self.active.lock().await.sas.clone();78
- match self.confirm().await {79
- Ok(()) => Ok(()),80
- Err(error) if error.kind == ErrorKind::VerificationInactive => Err(error),81
- // The confirm advanced local SDK state before its send failed,82
- // so the retained session could report success on retry without83
- // network traffic. Best-effort cancel (no lock held across the84
- // network), clear for restart, then report the terminal state.85
- Err(error) => {86
- // Stop the watcher first so it cannot emit a stale87
- // Confirmed from the local transition we are unwinding.88
- self.stop_task().await;89
- // The peer can complete or cancel the verification90
- // while our own Done send fails. The stopped watcher may91
- // already have published that terminal outcome (losing the92
- // claim below); otherwise this path owns it and must emit93
- // it instead of regressing to Cancelled.94
- if sas.as_ref().is_some_and(|sas| sas.is_done()) {95
- if take_verification(&self.active).await {96
- let _ =97
- sender.send(Event::VerificationChanged(UiState::Done)).await;98
- }99
- return Err(error);100
- }101
- if let Some(reason) = sas102
- .as_ref()103
- .and_then(|sas| sas.cancel_info())104
- .map(|info| info.reason().to_owned())105
- {106
- if take_verification(&self.active).await {107
- let _ = sender108
- .send(Event::VerificationChanged(UiState::Cancelled(reason)))109
- .await;110
- }111
- return Err(error);112
- }113
- if let Some(sas) = sas {114
- if sas.cancel().await.is_err() {115
- // The best-effort unwind already advanced116
- // local state; clearing here keeps the failed117
- // cancellation from looking retryable. The118
- // send is owned only if the watcher has not119
- // already claimed the session for its own120
- // terminal outcome.121
- if take_verification(&self.active).await {122
- let _ = sender123
- .send(Event::VerificationChanged(UiState::Cancelled(124
- error.to_string(),125
- )))126
- .await;127
- }128
- return Err(error);129
- }130
- // The peer can complete while the unwind cancel is in131
- // flight and the SDK call no-ops; the Done then owns the132
- // terminal outcome, so report it instead of attributing133
- // it to the send failure.134
- if sas.is_done() {135
- if take_verification(&self.active).await {136
- let _ = sender137
- .send(Event::VerificationChanged(UiState::Done))138
- .await;139
- }140
- return Err(error);141
- }142
- if let Some(info) = sas.cancel_info() {143
- // Our own unwind cancel just marked the flow; it does144
- // not own the outcome yet, so fall through and report145
- // the send failure as Cancelled below. Only a peer146
- // cancellation racing the unwind owns it instead.147
- if !info.cancelled_by_us() {148
- if take_verification(&self.active).await {149
- let _ = sender150
- .send(Event::VerificationChanged(UiState::Cancelled(151
- info.reason().to_owned(),152
- )))153
- .await;154
- }155
- return Err(Error::new(156
- ErrorKind::VerificationInactive,157
- info.reason().to_owned(),158
- ));159
- }160
- }161
- }162
- if take_verification(&self.active).await {163
- let _ = sender164
- .send(Event::VerificationChanged(UiState::Cancelled(165
- error.to_string(),166
- )))167
- .await;168
- }169
- Err(error)170
- }171
- }172
- }173
- VerificationAction::Reject => self.reject().await,174
- VerificationAction::Cancel => {175
- // The SDK no-ops a cancel of an already-settled flow, so a176
- // failed claim inside `cancel` already reported the true177
- // outcome as an error and owns no event here. Only the claim178
- // winner emits the local Cancelled.179
- let claimed = self.cancel_claimed(Some(&sender)).await?;180
- if claimed {181
- sender182
- .send(Event::VerificationChanged(UiState::Cancelled(183
- "cancelled by this client".to_owned(),184
- )))185
- .await186
- .map_err(|source| {187
- Error::with_source(188
- ErrorKind::Interrupted,189
- "event receiver has closed",190
- source,191
- )192
- })?;193
- }194
- Ok(())195
- }196
- }197
- }198
-199
- pub(crate) async fn start(200
- &mut self,201
- client: &Client,202
- sender: EventSender,203
- target: VerificationTarget,204
- ) -> Result<(), Error> {205
- client206
- .encryption()207
- .wait_for_e2ee_initialization_tasks()208
- .await;209
- let own_user_id = client.user_id().ok_or_else(|| {210
- Error::new(211
- ErrorKind::Unauthenticated,212
- "the Matrix client is not authenticated",213
- )214
- })?;215
- if self.has_active_verification().await {216
- self.cancel().await?;217
- } else {218
- self.stop_task().await;219
- }220
- let request = match &target {221
- VerificationTarget::OwnIdentity => {222
- request_identity_verification(client, own_user_id).await?223
- }224
- VerificationTarget::User(user_id) => {225
- request_identity_verification(client, user_id).await?226
- }227
- VerificationTarget::Device { user_id, device_id } => {228
- let device = client229
- .encryption()230
- .get_device(user_id, device_id)231
- .await232
- .map_err(|source| {233
- Error::with_source(234
- ErrorKind::Encryption,235
- "reading the verification device failed",236
- source,237
- )238
- })?239
- .ok_or_else(|| {240
- Error::new(241
- ErrorKind::VerificationUnsupported,242
- "the verification device is unavailable",243
- )244
- })?;245
- device246
- .request_verification_with_methods(vec![VerificationMethod::SasV1])247
- .await248
- .map_err(|source| {249
- Error::from_sdk("starting device verification failed", source)250
- })?251
- }252
- };253
- // subscribed before anything can wait on the event channel, or a quick peer's254
- // answer lands while we're blocked and nobody ever hears it255
- let changes = request.changes();256
- self.active.lock().await.request = Some(request.clone());257
- sender258
- .send(Event::VerificationTargetChanged(target))259
- .await260
- .map_err(|source| {261
- Error::with_source(ErrorKind::Interrupted, "event receiver has closed", source)262
- })?;263
- sender264
- .send(Event::VerificationChanged(UiState::Waiting))265
- .await266
- .map_err(|source| {267
- Error::with_source(ErrorKind::Interrupted, "event receiver has closed", source)268
- })?;269
- let active = self.active.clone();270
- self.task = Some(spawn(async move {271
- follow_request(request, changes, active, sender).await;272
- }));273
- Ok(())274
- }275
-276
- /// another device asked us. only taken when nothing else is in flight, and not accepted277
- /// until the user says so with `VerificationAction::Accept`278
- pub(crate) async fn adopt_incoming(279
- &mut self,280
- sender: EventSender,281
- request: VerificationRequest,282
- ) {283
- if request.we_started() || request.is_done() || request.cancel_info().is_some() {284
- return;285
- }286
- if self.has_active_verification().await {287
- tracing::debug!("ignoring a verification request while another is in flight");288
- return;289
- }290
- self.stop_task().await;291
- let changes = request.changes();292
- self.active.lock().await.request = Some(request.clone());293
- let incoming = UiState::Incoming {294
- user_id: request.other_user_id().to_owned(),295
- device_id: match request.state() {296
- VerificationRequestState::Requested {297
- other_device_data, ..298
- }299
- | VerificationRequestState::Ready {300
- other_device_data, ..301
- } => Some(other_device_data.device_id().to_owned()),302
- _ => None,303
- },304
- };305
- let target = VerificationTarget::User(request.other_user_id().to_owned());306
- for event in [307
- Event::VerificationTargetChanged(target),308
- Event::VerificationChanged(incoming),309
- ] {310
- if sender.send(event).await.is_err() {311
- return;312
- }313
- }314
- let active = self.active.clone();315
- self.task = Some(spawn(async move {316
- follow_request(request, changes, active, sender).await;317
- }));318
- }319
-320
- async fn accept(&self) -> Result<(), Error> {321
- let inactive = |message: &str| Error::new(ErrorKind::VerificationInactive, message);322
- let (request, sas) = {323
- let active = self.active.lock().await;324
- (active.request.clone(), active.sas.is_some())325
- };326
- let request = request.ok_or_else(|| inactive("no verification request is pending"))?;327
- if request.we_started() {328
- return Err(inactive("nobody asked us, we asked them"));329
- }330
- if request.is_done() {331
- return Err(inactive("verification is already complete"));332
- }333
- if let Some(info) = request.cancel_info() {334
- return Err(inactive(info.reason()));335
- }336
- if sas {337
- return Err(inactive("verification has already started"));338
- }339
- request340
- .accept_with_methods(vec![VerificationMethod::SasV1])341
- .await342
- .map_err(|source| Error::from_sdk("accepting verification request failed", source))343
- }344
-345
- async fn confirm(&self) -> Result<(), Error> {346
- let sas = self.active.lock().await.sas.clone().ok_or_else(|| {347
- Error::new(348
- ErrorKind::VerificationInactive,349
- "verification has not reached comparison",350
- )351
- })?;352
- // Report a settled flow truthfully instead of as "not reached353
- // comparison": the SDK would no-op the confirm with success.354
- if sas.is_done() {355
- return Err(Error::new(356
- ErrorKind::VerificationInactive,357
- "verification is already complete",358
- ));359
- }360
- if let Some(info) = sas.cancel_info() {361
- return Err(Error::new(362
- ErrorKind::VerificationInactive,363
- info.reason().to_owned(),364
- ));365
- }366
- // Only the short-auth-string stage is confirmable. The SDK reports the367
- // remote-MAC-arrived state through the same presentable flag, while an368
- // early confirm would locally no-op and falsely report success.369
- if !sas.can_be_presented() {370
- return Err(Error::new(371
- ErrorKind::VerificationInactive,372
- "verification has not reached comparison",373
- ));374
- }375
- // Our own confirm can move the flow to Done, so only Done that predates376
- // the send means the SDK no-op'd on a stale session; a cancellation377
- // racing the send must not report success.378
- let was_done = sas.is_done();379
- sas.confirm()380
- .await381
- .map_err(|source| Error::from_sdk("confirming verification failed", source))?;382
- if sas.is_done() {383
- if was_done {384
- return Err(Error::new(385
- ErrorKind::VerificationInactive,386
- "verification is already complete",387
- ));388
- }389
- } else if sas.is_cancelled() {390
- let reason = sas391
- .cancel_info()392
- .map(|info| info.reason().to_owned())393
- .unwrap_or_else(|| "verification was cancelled".to_owned());394
- return Err(Error::new(ErrorKind::VerificationInactive, reason));395
- }396
- Ok(())397
- }398
-399
- async fn reject(&self) -> Result<(), Error> {400
- let sas = self.active.lock().await.sas.clone().ok_or_else(|| {401
- Error::new(402
- ErrorKind::VerificationInactive,403
- "verification has not reached comparison",404
- )405
- })?;406
- // Report a settled flow truthfully instead of as "not reached407
- // comparison": the SDK would no-op the mismatch with success.408
- if sas.is_done() {409
- return Err(Error::new(410
- ErrorKind::VerificationInactive,411
- "verification is already complete",412
- ));413
- }414
- if let Some(info) = sas.cancel_info() {415
- return Err(Error::new(416
- ErrorKind::VerificationInactive,417
- info.reason().to_owned(),418
- ));419
- }420
- // Rejecting before the comparison stage would cancel a flow the user421
- // never compared, so it requires the same presentable state.422
- if !sas.can_be_presented() {423
- return Err(Error::new(424
- ErrorKind::VerificationInactive,425
- "verification has not reached comparison",426
- ));427
- }428
- sas.mismatch()429
- .await430
- .map_err(|source| Error::from_sdk("rejecting verification failed", source))?;431
- // A peer completing the flow concurrently must not be reported as432
- // rejected. A remote cancellation racing the send turns the SDK call433
- // into a no-op success, so report it instead of a false success.434
- if sas.is_done() {435
- return Err(Error::new(436
- ErrorKind::VerificationInactive,437
- "verification is already complete",438
- ));439
- }440
- if let Some(info) = sas.cancel_info() {441
- // Our own mismatch just marked the flow cancelled-by-us; that is the442
- // expected reject path and stays Ok. Only a peer cancel reports an443
- // error.444
- if !info.cancelled_by_us() {445
- return Err(Error::new(446
- ErrorKind::VerificationInactive,447
- info.reason().to_owned(),448
- ));449
- }450
- }451
- Ok(())452
- }453
- pub(crate) async fn cancel(&mut self) -> Result<(), Error> {454
- self.cancel_claimed(None).await.map(|_| ())455
- }456
-457
- /// Return whether this action won the claim for a local cancellation.458
- /// The optional sender reports a peer terminal outcome when it races after459
- /// the claim; starting a replacement verification does not publish it.460
- async fn cancel_claimed(&mut self, sender: Option<&EventSender>) -> Result<bool, Error> {461
- let (sas, request) = {462
- let active = self.active.lock().await;463
- (active.sas.clone(), active.request.clone())464
- };465
- if sas.is_none() && request.is_none() {466
- return Err(Error::new(467
- ErrorKind::VerificationInactive,468
- "no verification is active",469
- ));470
- }471
- // The watcher owns terminal emission and claims the session when it472
- // publishes. If the SDK is already terminal, the watcher either already473
- // reported it or is about to: leave session and watcher alone so the474
- // outcome is emitted exactly once, and report it instead of letting the475
- // SDK no-op success regress Done to "cancelled by this client".476
- if let Some(sas) = sas.as_ref() {477
- if sas.is_done() {478
- return Err(Error::new(479
- ErrorKind::VerificationInactive,480
- "verification is already complete",481
- ));482
- }483
- if let Some(info) = sas.cancel_info() {484
- return Err(Error::new(485
- ErrorKind::VerificationInactive,486
- info.reason().to_owned(),487
- ));488
- }489
- } else if let Some(request) = request.as_ref() {490
- if request.is_done() {491
- return Err(Error::new(492
- ErrorKind::VerificationInactive,493
- "verification is already complete",494
- ));495
- }496
- if let Some(info) = request.cancel_info() {497
- return Err(Error::new(498
- ErrorKind::VerificationInactive,499
- info.reason().to_owned(),500
- ));501
- }502
- }503
- // Own the terminal outcome from here: claim the session before504
- // stopping the watcher so a watcher that already claimed (and will505
- // emit its own terminal event) turns this into a truthful error506
- // instead of a duplicate local cancel; no lock is held across sends.507
- if !take_verification(&self.active).await {508
- // Re-check the SDK: the claim loss may be a stale session after a509
- // terminal outcome, or a concurrent start of a new flow. Only the510
- // terminal case reports here; a live session belongs to the new511
- // flow and must not be disturbed.512
- if let Some(sas) = sas.as_ref() {513
- if sas.is_done() {514
- return Err(Error::new(515
- ErrorKind::VerificationInactive,516
- "verification is already complete",517
- ));518
- }519
- if let Some(info) = sas.cancel_info() {520
- return Err(Error::new(521
- ErrorKind::VerificationInactive,522
- info.reason().to_owned(),523
- ));524
- }525
- } else if let Some(request) = request.as_ref() {526
- if request.is_done() {527
- return Err(Error::new(528
- ErrorKind::VerificationInactive,529
- "verification is already complete",530
- ));531
- }532
- if let Some(info) = request.cancel_info() {533
- return Err(Error::new(534
- ErrorKind::VerificationInactive,535
- info.reason().to_owned(),536
- ));537
- }538
- }539
- return Err(Error::new(540
- ErrorKind::VerificationInactive,541
- "no verification is active",542
- ));543
- }544
- self.stop_task().await;545
- if let Some(sas) = sas {546
- let result = sas.cancel().await;547
- if sas.is_done() {548
- report_claimed_terminal(sender, UiState::Done).await;549
- return Err(Error::new(550
- ErrorKind::VerificationInactive,551
- "verification is already complete",552
- ));553
- }554
- if let Some(info) = sas.cancel_info()555
- && !info.cancelled_by_us()556
- {557
- let reason = info.reason().to_owned();558
- report_claimed_terminal(sender, UiState::Cancelled(reason.clone())).await;559
- return Err(Error::new(ErrorKind::VerificationInactive, reason));560
- }561
- if let Err(source) = result {562
- let error = Error::from_sdk("cancelling verification failed", source);563
- report_claimed_terminal(sender, UiState::Cancelled(error.to_string())).await;564
- return Err(error);565
- }566
- Ok(true)567
- } else if let Some(request) = request {568
- let result = request.cancel().await;569
- if request.is_done() {570
- report_claimed_terminal(sender, UiState::Done).await;571
- return Err(Error::new(572
- ErrorKind::VerificationInactive,573
- "verification is already complete",574
- ));575
- }576
- if let Some(info) = request.cancel_info()577
- && !info.cancelled_by_us()578
- {579
- let reason = info.reason().to_owned();580
- report_claimed_terminal(sender, UiState::Cancelled(reason.clone())).await;581
- return Err(Error::new(ErrorKind::VerificationInactive, reason));582
- }583
- if let Err(source) = result {584
- let error = Error::from_sdk("cancelling verification failed", source);585
- report_claimed_terminal(sender, UiState::Cancelled(error.to_string())).await;586
- return Err(error);587
- }588
- Ok(true)589
- } else {590
- Ok(false)591
- }592
- }593
-594
- async fn has_active_verification(&self) -> bool {595
- let active = self.active.lock().await;596
- active.request.is_some() || active.sas.is_some()597
- }598
-599
- async fn stop_task(&mut self) {600
- if let Some(task) = self.task.take() {601
- task.abort();602
- let _ = task.await;603
- }604
- }605
-}606
-607
-/// shutdown forgets the flow without telling the sdk, a network send could hang it608
-impl Drop for VerificationController {609
- fn drop(&mut self) {610
- if let Some(task) = self.task.take() {611
- task.abort();612
- }613
- }614
-}615
-616
-async fn request_identity_verification(617
- client: &Client,618
- user_id: &UserId,619
-) -> Result<VerificationRequest, Error> {620
- let identity = client621
- .encryption()622
- .request_user_identity(user_id)623
- .await624
- .map_err(|source| Error::from_sdk("requesting the user identity failed", source))?625
- .ok_or_else(|| {626
- Error::new(627
- ErrorKind::NoCrossSigningIdentity,628
- "the user has no cross-signing identity",629
- )630
- })?;631
- identity632
- .request_verification_with_methods(vec![VerificationMethod::SasV1])633
- .await634
- .map_err(|source| Error::from_verification_request("starting verification failed", source))635
-}636
-637
-/// `changes` is subscribed by the caller before the request could move, and the current state638
-/// is handled first, since the stream only reports what happens after it was taken639
-async fn follow_request(640
- request: VerificationRequest,641
- changes: impl futures_util::Stream<Item = VerificationRequestState> + Unpin,642
- active: Arc<Mutex<VerificationSession>>,643
- sender: EventSender,644
-) {645
- let mut states = futures_util::stream::iter([request.state()]).chain(changes);646
- while let Some(state) = states.next().await {647
- match state {648
- // created is ours waiting on them, requested is theirs waiting on `Accept`649
- VerificationRequestState::Created { .. }650
- | VerificationRequestState::Requested { .. } => {}651
- VerificationRequestState::Ready { .. } => match request.start_sas().await {652
- Ok(Some(sas)) => {653
- follow_sas(sas, active.clone(), sender.clone()).await;654
- return;655
- }656
- Ok(None) => {}657
- Err(error) => {658
- tracing::warn!(error = %error, "starting SAS verification failed");659
- // Claim the session so this terminal outcome is emitted exactly660
- // once; a lost claim means an action path already owns it.661
- report_watcher_terminal(662
- &active,663
- &sender,664
- UiState::Failed(VerificationFailure::SasStartup(error.to_string())),665
- "SAS startup failure",666
- )667
- .await;668
- return;669
- }670
- },671
- VerificationRequestState::Transitioned { verification } => {672
- if let Verification::SasV1(sas) = verification {673
- follow_sas(sas, active.clone(), sender.clone()).await;674
- } else {675
- report_watcher_terminal(676
- &active,677
- &sender,678
- UiState::Cancelled(679
- "the other device selected an unsupported method".into(),680
- ),681
- "unsupported verification method",682
- )683
- .await;684
- }685
- return;686
- }687
- VerificationRequestState::Done => {688
- report_watcher_terminal(&active, &sender, UiState::Done, "completion").await;689
- return;690
- }691
- VerificationRequestState::Cancelled(info) => {692
- report_watcher_terminal(693
- &active,694
- &sender,695
- UiState::Cancelled(info.reason().to_owned()),696
- "cancellation",697
- )698
- .await;699
- return;700
- }701
- }702
- }703
- tracing::debug!("verification request watcher ended unexpectedly");704
- // The stream may end after the SDK already settled without delivering the705
- // terminal update; report the settled outcome instead of StreamEnded, and706
- // stay silent if an action path already claimed the session.707
- if request.is_done() {708
- report_watcher_terminal(&active, &sender, UiState::Done, "completion").await;709
- } else if let Some(info) = request.cancel_info() {710
- let reason = info.reason().to_owned();711
- report_watcher_terminal(&active, &sender, UiState::Cancelled(reason), "cancellation").await;712
- } else {713
- report_watcher_terminal(714
- &active,715
- &sender,716
- UiState::Failed(VerificationFailure::RequestStreamEnded),717
- "request stream termination",718
- )719
- .await;720
- }721
-}722
-723
-async fn follow_sas(724
- sas: SasVerification,725
- active: Arc<Mutex<VerificationSession>>,726
- sender: EventSender,727
-) {728
- // the sdk may already have moved the flow along before we got here, a peer starting729
- // sas is the usual case, so take the stream first and the current state after730
- let changes = sas.changes();731
- active.lock().await.sas = Some(sas.clone());732
- let mut states = futures_util::stream::iter([sas.state()]).chain(changes);733
- while let Some(state) = states.next().await {734
- let update = match state {735
- SasState::Created { .. } | SasState::Accepted { .. } => continue,736
- SasState::Started { .. } => {737
- if let Err(error) = sas.accept().await {738
- tracing::warn!(error = %error, "accepting SAS verification failed");739
- // Claim the session so this terminal outcome is emitted exactly740
- // once; a lost claim means an action path already owns it.741
- report_watcher_terminal(742
- &active,743
- &sender,744
- UiState::Failed(VerificationFailure::SasAcceptance(error.to_string())),745
- "SAS acceptance failure",746
- )747
- .await;748
- return;749
- }750
- continue;751
- }752
- SasState::KeysExchanged { emojis, decimals } => UiState::Comparison {753
- emojis: emojis754
- .map(|short_auth| {755
- short_auth756
- .emojis757
- .into_iter()758
- .map(|emoji| (emoji.symbol.to_owned(), emoji.description.to_owned()))759
- .collect()760
- })761
- .unwrap_or_default(),762
- decimals,763
- },764
- SasState::Confirmed => UiState::Confirmed,765
- SasState::Done { .. } => UiState::Done,766
- SasState::Cancelled(info) => UiState::Cancelled(info.reason().to_owned()),767
- };768
- let terminal = matches!(update, UiState::Done | UiState::Cancelled(_));769
- // Claim the session before the terminal send so a racing Cancel observes770
- // no active session and the SDK no-op cannot regress Done back to771
- // "cancelled by this client". A lost claim means an action path already772
- // owns the terminal outcome, so stay silent instead of duplicating it.773
- if terminal && !take_verification(&active).await {774
- return;775
- }776
- if sender777
- .send(Event::VerificationChanged(update))778
- .await779
- .is_err()780
- {781
- tracing::debug!("SAS watcher stopped because the event receiver closed");782
- return;783
- }784
- if terminal {785
- return;786
- }787
- }788
- tracing::debug!("SAS watcher ended unexpectedly");789
- // The stream may end after the SDK already settled without delivering the790
- // terminal update; report the settled outcome instead of StreamEnded, and791
- // stay silent if an action path already claimed the session.792
- if sas.is_done() {793
- report_watcher_terminal(&active, &sender, UiState::Done, "completion").await;794
- } else if let Some(info) = sas.cancel_info() {795
- let reason = info.reason().to_owned();796
- report_watcher_terminal(&active, &sender, UiState::Cancelled(reason), "cancellation").await;797
- } else {798
- report_watcher_terminal(799
- &active,800
- &sender,801
- UiState::Failed(VerificationFailure::SasStreamEnded),802
- "SAS stream termination",803
- )804
- .await;805
- }806
-}807
-808
-async fn report_watcher_terminal(809
- active: &Arc<Mutex<VerificationSession>>,810
- sender: &EventSender,811
- state: UiState,812
- context: &'static str,813
-) {814
- if take_verification(active).await815
- && sender816
- .send(Event::VerificationChanged(state))817
- .await818
- .is_err()819
- {820
- tracing::debug!(821
- context,822
- "verification watcher could not report terminal outcome"823
- );824
- }825
-}826
-827
-async fn report_claimed_terminal(sender: Option<&EventSender>, state: UiState) {828
- if let Some(sender) = sender {829
- let _ = sender.send(Event::VerificationChanged(state)).await;830
- }831
-}832
-833
-/// Claim the active session for terminal emission, if one is still present.834
-///835
-/// Exactly one party (watcher or action path) wins the claim and may emit the836
-/// terminal event; the loser stays silent so Done is never overwritten by a837
-/// later local Cancelled/Failed.838
-async fn take_verification(active: &Arc<Mutex<VerificationSession>>) -> bool {839
- let mut active = active.lock().await;840
- let had = active.sas.is_some() || active.request.is_some();841
- active.sas.take();842
- active.request.take();843
- had844
-}845
-846
-#[cfg(test)]847
-mod tests {848
- use matrix_sdk::encryption::VerificationState;849
- use matrix_sdk::encryption::backups::BackupState;850
- use ruma::owned_device_id;851
-852
- use super::*;853
-854
- #[test]855
- fn sdk_security_state_is_normalized() {856
- assert_eq!(857
- encryption_state(858
- owned_device_id!("DEVICE"),859
- SdkRecoveryState::Incomplete,860
- VerificationState::Unverified,861
- BackupState::Unknown,862
- ),863
- EncryptionState {864
- device_id: owned_device_id!("DEVICE"),865
- device_verified: false,866
- recovery: RecoveryState::Incomplete,867
- key_backup: KeyBackupState::Unknown,868
- }869
- );870
- assert_eq!(871
- encryption_state(872
- owned_device_id!("DEVICE"),873
- SdkRecoveryState::Enabled,874
- VerificationState::Verified,875
- BackupState::Enabled,876
- ),877
- EncryptionState {878
- device_id: owned_device_id!("DEVICE"),879
- device_verified: true,880
- recovery: RecoveryState::Enabled,881
- key_backup: KeyBackupState::Enabled,882
- }883
- );884
- }885
-886
- #[tokio::test]887
- async fn comparison_actions_require_an_active_sas() {888
- let mut controller = VerificationController::default();889
-890
- assert_eq!(891
- controller.confirm().await.unwrap_err().to_string(),892
- "verification has not reached comparison"893
- );894
- assert_eq!(895
- controller.reject().await.unwrap_err().to_string(),896
- "verification has not reached comparison"897
- );898
- assert_eq!(899
- controller.cancel().await.unwrap_err().to_string(),900
- "no verification is active"901
- );902
- }903
-}@@ -1,329 +0,0 @@
1
-use std::{future::Future, panic::AssertUnwindSafe};2
-3
-use futures_util::FutureExt;4
-use matrix_sdk::encryption::verification::VerificationRequest;5
-use matrix_sdk::{6
- Client,7
- notification_settings::{IsEncrypted, IsOneToOne, RoomNotificationMode as SdkMode},8
- ruma::{9
- OwnedDeviceId, OwnedEventId, RoomId, TransactionId,10
- api::client::receipt::create_receipt::v3::ReceiptType, events::receipt::ReceiptThread,11
- },12
- send_queue::{LocalEchoContent, SendHandle},13
-};14
-use tokio::sync::mpsc;15
-16
-use crate::{17
- EncryptionCommand, Error, ErrorKind, InteractiveAuth, InteractiveAuthChallenge,18
- NotificationCommand, RoomNotificationMode, TimelineId,19
- encryption::VerificationController,20
- transport::{EventSender, Responder},21
-};22
-23
-pub(super) struct Job<T> {24
- pub(super) command: T,25
- pub(super) reply: Responder,26
-}27
-28
-/// a panicking command answers with an error instead of taking its worker down with it29
-pub(super) async fn guarded<T>(30
- label: &'static str,31
- work: impl Future<Output = Result<T, Error>>,32
-) -> Result<T, Error> {33
- AssertUnwindSafe(work)34
- .catch_unwind()35
- .await36
- .unwrap_or_else(|_| Err(Error::new(ErrorKind::Other, format!("{label} panicked"))))37
-}38
-39
-pub(super) async fn run_notifications(40
- client: Client,41
- mut jobs: mpsc::Receiver<Job<NotificationCommand>>,42
-) {43
- while let Some(Job { command, reply }) = jobs.recv().await {44
- reply.done(guarded("notification command", notification(&client, command)).await);45
- }46
-}47
-48
-pub(super) async fn run_encryption(49
- client: Client,50
- events: EventSender,51
- mut jobs: mpsc::Receiver<Job<EncryptionCommand>>,52
- mut incoming: mpsc::UnboundedReceiver<VerificationRequest>,53
-) {54
- let mut verification = VerificationController::default();55
- loop {56
- tokio::select! {57
- job = jobs.recv() => {58
- let Some(Job { command, reply }) = job else { return };59
- let work = encryption(&client, &events, &mut verification, command);60
- reply.done(guarded("encryption command", work).await);61
- }62
- Some(request) = incoming.recv() => {63
- verification.adopt_incoming(events.clone(), request).await;64
- }65
- }66
- }67
-}68
-69
-async fn encryption(70
- client: &Client,71
- events: &EventSender,72
- verification: &mut VerificationController,73
- command: EncryptionCommand,74
-) -> Result<(), Error> {75
- let encryption = client.encryption();76
- match command {77
- EncryptionCommand::Recover(key) => encryption78
- .recovery()79
- .recover(&key)80
- .await81
- .map_err(|source| Error::from_recovery("account recovery failed", source)),82
- EncryptionCommand::RecoverKeyBackup(key) => encryption83
- .recovery()84
- .recover_and_fix_backup(&key)85
- .await86
- .map_err(|source| Error::from_recovery("recovering key backup failed", source)),87
- EncryptionCommand::CreateKeyBackup => encryption88
- .backups()89
- .create()90
- .await91
- .map_err(|source| Error::from_sdk("creating key backup failed", source)),92
- EncryptionCommand::EnableKeyBackup => encryption93
- .recovery()94
- .enable_backup()95
- .await96
- .map_err(|source| Error::from_recovery("enabling key backup failed", source)),97
- EncryptionCommand::DisableKeyBackup { delete_from_server } => {98
- let backups = encryption.backups();99
- if delete_from_server {100
- backups.disable_and_delete().await101
- } else {102
- backups.disable().await103
- }104
- .map_err(|source| Error::from_sdk("disabling key backup failed", source))105
- }106
- EncryptionCommand::StartVerification(target) => {107
- verification.start(client, events.clone(), target).await108
- }109
- EncryptionCommand::Verify(action) => verification.apply(events.clone(), action).await,110
- }111
-}112
-113
-async fn notification(client: &Client, command: NotificationCommand) -> Result<(), Error> {114
- let settings = client.notification_settings().await;115
- match command {116
- NotificationCommand::SetRoomMode { room_id, mode } => {117
- settings118
- .set_room_notification_mode(&room_id, sdk_mode(mode))119
- .await120
- }121
- NotificationCommand::ResetRoomMode(room_id) => {122
- settings.delete_user_defined_room_rules(&room_id).await123
- }124
- NotificationCommand::SetDefaultMode {125
- encrypted,126
- one_to_one,127
- mode,128
- } => {129
- settings130
- .set_default_room_notification_mode(131
- IsEncrypted::from(encrypted),132
- IsOneToOne::from(one_to_one),133
- sdk_mode(mode),134
- )135
- .await136
- }137
- NotificationCommand::AddKeyword(keyword) => settings.add_keyword(keyword).await,138
- NotificationCommand::RemoveKeyword(keyword) => settings.remove_keyword(&keyword).await,139
- }140
- .map_err(|source| {141
- Error::with_source(142
- ErrorKind::Server,143
- "updating notification settings failed",144
- source,145
- )146
- })147
-}148
-149
-const fn sdk_mode(mode: RoomNotificationMode) -> SdkMode {150
- match mode {151
- RoomNotificationMode::AllMessages => SdkMode::AllMessages,152
- RoomNotificationMode::MentionsAndKeywordsOnly => SdkMode::MentionsAndKeywordsOnly,153
- RoomNotificationMode::Mute => SdkMode::Mute,154
- }155
-}156
-157
-fn room(client: &Client, room_id: &RoomId) -> Result<matrix_sdk::Room, Error> {158
- client.get_room(room_id).ok_or_else(Error::room_unknown)159
-}160
-161
-/// the closed-room path. without a timeline nothing stops the marker going backwards162
-pub(super) async fn mark_read(163
- client: &Client,164
- timeline_id: &TimelineId,165
- event_id: OwnedEventId,166
-) -> Result<(), Error> {167
- let room = room(client, timeline_id.room_id())?;168
- let receipt_failed = |source| Error::from_sdk("sending read receipt failed", source);169
- let thread = match timeline_id.thread_root() {170
- None => ReceiptThread::Main,171
- Some(root) => ReceiptThread::Thread(root.clone()),172
- };173
- room.send_single_receipt(ReceiptType::Read, thread, event_id.clone())174
- .await175
- .map_err(receipt_failed)?;176
- if timeline_id.thread_root().is_none() {177
- room.send_single_receipt(ReceiptType::FullyRead, ReceiptThread::Unthreaded, event_id)178
- .await179
- .map_err(receipt_failed)?;180
- }181
- Ok(())182
-}183
-184
-pub(super) async fn set_typing(185
- client: &Client,186
- room_id: &RoomId,187
- typing: bool,188
-) -> Result<(), Error> {189
- room(client, room_id)?190
- .typing_notice(typing)191
- .await192
- .map_err(|source| Error::from_sdk("typing update failed", source))193
-}194
-195
-async fn send_handle(196
- client: &Client,197
- room_id: &RoomId,198
- transaction_id: &TransactionId,199
-) -> Result<SendHandle, Error> {200
- let (echoes, _) = room(client, room_id)?201
- .send_queue()202
- .subscribe()203
- .await204
- .map_err(|source| {205
- Error::with_source(ErrorKind::Store, "reading send queue failed", source)206
- })?;207
- echoes208
- .into_iter()209
- .find_map(|echo| match echo.content {210
- LocalEchoContent::Event { send_handle, .. }211
- if echo.transaction_id.as_str() == transaction_id.as_str() =>212
- {213
- Some(send_handle)214
- }215
- _ => None,216
- })217
- .ok_or_else(|| {218
- Error::new(219
- ErrorKind::Invalid,220
- "send is no longer pending or is not a message",221
- )222
- })223
-}224
-225
-pub(super) async fn cancel_send(226
- client: &Client,227
- room_id: &RoomId,228
- transaction_id: &TransactionId,229
-) -> Result<(), Error> {230
- match send_handle(client, room_id, transaction_id)231
- .await?232
- .abort()233
- .await234
- {235
- Ok(true) => Ok(()),236
- Ok(false) => Err(Error::new(237
- ErrorKind::Invalid,238
- "send could not be cancelled because it has already been sent",239
- )),240
- Err(source) => Err(Error::with_source(241
- ErrorKind::Store,242
- "cancelling send failed",243
- source,244
- )),245
- }246
-}247
-248
-pub(super) async fn retry_send(249
- client: &Client,250
- room_id: &RoomId,251
- transaction_id: &TransactionId,252
-) -> Result<(), Error> {253
- send_handle(client, room_id, transaction_id)254
- .await?255
- .unwedge()256
- .await257
- .map_err(|source| Error::with_source(ErrorKind::Store, "retrying send failed", source))?;258
- room(client, room_id)?.send_queue().set_enabled(true);259
- Ok(())260
-}261
-262
-pub(super) async fn upload_avatar(263
- client: &Client,264
- mimetype: &str,265
- bytes: Vec<u8>,266
- max_bytes: usize,267
-) -> Result<(), Error> {268
- if bytes.len() > max_bytes {269
- return Err(Error::new(270
- ErrorKind::MediaTooLarge,271
- "avatar exceeds the configured byte limit",272
- ));273
- }274
- let mimetype: mime::Mime = mimetype275
- .parse()276
- .map_err(|_| Error::new(ErrorKind::Invalid, "avatar MIME type is invalid"))?;277
- if mimetype.type_() != mime::IMAGE {278
- return Err(Error::new(279
- ErrorKind::Invalid,280
- "an avatar has to be an image",281
- ));282
- }283
- client284
- .account()285
- .upload_avatar(&mimetype, bytes)286
- .await287
- .map(drop)288
- .map_err(|source| Error::from_sdk("uploading avatar failed", source))289
-}290
-291
-pub(super) async fn rename_device(292
- client: &Client,293
- device_id: &OwnedDeviceId,294
- display_name: &str,295
-) -> Result<(), Error> {296
- client297
- .rename_device(device_id, display_name)298
- .await299
- .map(drop)300
- .map_err(|source| Error::from_http("renaming device failed", source))301
-}302
-303
-pub(super) async fn delete_devices(304
- client: &Client,305
- device_ids: &[OwnedDeviceId],306
- auth: Option<InteractiveAuth>,307
-) -> Result<(), Error> {308
- let user_id = client309
- .user_id()310
- .ok_or_else(|| Error::new(ErrorKind::Unauthenticated, "the client is not logged in"))?;311
- let auth = auth312
- .map(|auth| auth.into_sdk(user_id))313
- .transpose()314
- .map_err(|message| {315
- Error::new(316
- ErrorKind::Invalid,317
- format!("interactive authentication data is invalid, {message}"),318
- )319
- })?;320
- match client.delete_devices(device_ids, auth).await {321
- Ok(_) => Ok(()),322
- Err(source) => match source.as_uiaa_response() {323
- Some(info) => Err(Error::interactive_auth(InteractiveAuthChallenge::from(324
- info,325
- ))),326
- None => Err(Error::from_http("deleting devices failed", source)),327
- },328
- }329
-}@@ -1,82 +0,0 @@
1
-use std::future::Future;2
-3
-use futures_util::{StreamExt, stream::FuturesUnordered};4
-use matrix_sdk::SendOutsideWasm;5
-pub(super) use matrix_sdk::executor::{AbortHandle, JoinError, JoinHandle, spawn};6
-7
-#[cfg(not(target_family = "wasm"))]8
-pub(crate) type EngineBoxFuture<'a, T> = futures_util::future::BoxFuture<'a, T>;9
-#[cfg(target_family = "wasm")]10
-pub(crate) type EngineBoxFuture<'a, T> = futures_util::future::LocalBoxFuture<'a, T>;11
-12
-#[cfg(not(target_family = "wasm"))]13
-pub(super) fn is_panic(error: &JoinError) -> bool {14
- error.is_panic()15
-}16
-17
-/// the sdk's wasm join error has no payload, and a real panic traps before it gets here18
-#[cfg(target_family = "wasm")]19
-pub(super) fn is_panic(error: &JoinError) -> bool {20
- !error.is_cancelled()21
-}22
-23
-#[cfg(not(target_family = "wasm"))]24
-pub(super) fn resume_unwind(error: JoinError) -> ! {25
- std::panic::resume_unwind(error.into_panic())26
-}27
-28
-#[cfg(target_family = "wasm")]29
-pub(super) fn resume_unwind(error: JoinError) -> ! {30
- panic!("{error}")31
-}32
-33
-/// a `JoinSet` that runs on wasm too, and like it aborts whatever's left when dropped34
-pub(super) struct TaskSet<T: 'static> {35
- handles: FuturesUnordered<JoinHandle<T>>,36
-}37
-38
-impl<T: 'static> TaskSet<T> {39
- pub(super) fn new() -> Self {40
- Self {41
- handles: FuturesUnordered::new(),42
- }43
- }44
-45
- pub(super) fn spawn(46
- &mut self,47
- task: impl Future<Output = T> + SendOutsideWasm + 'static,48
- ) -> AbortHandle49
- where50
- T: SendOutsideWasm,51
- {52
- let handle = spawn(task);53
- let abort = handle.abort_handle();54
- self.handles.push(handle);55
- abort56
- }57
-58
- pub(super) fn is_empty(&self) -> bool {59
- self.handles.is_empty()60
- }61
-62
- pub(super) async fn join_next(&mut self) -> Option<Result<T, JoinError>> {63
- self.handles.next().await64
- }65
-66
- fn abort_all(&self) {67
- for handle in &self.handles {68
- handle.abort();69
- }70
- }71
-72
- pub(super) async fn shutdown(&mut self) {73
- self.abort_all();74
- while self.handles.next().await.is_some() {}75
- }76
-}77
-78
-impl<T: 'static> Drop for TaskSet<T> {79
- fn drop(&mut self) {80
- self.abort_all();81
- }82
-}@@ -1,743 +0,0 @@
1
-use std::{2
- borrow::Cow,3
- collections::{HashMap, VecDeque},4
- io::Read as _,5
- panic::AssertUnwindSafe,6
- sync::Arc,7
- time::Duration,8
-};9
-10
-use futures_util::{11
- FutureExt, StreamExt,12
- future::{Shared, WeakShared},13
-};14
-use matrix_sdk::{15
- Client, Room, SendOutsideWasm, SyncOutsideWasm,16
- media::{MediaFormat, MediaRequestParameters, MediaThumbnailSettings},17
- room::reply::{EnforceThread, Reply as SdkReply},18
- ruma::{19
- api::{20
- Metadata, OutgoingRequest,21
- auth_scheme::SendAccessToken,22
- client::{authenticated_media, media},23
- },24
- events::room::{25
- EncryptedFileHash, EncryptedFileHashAlgorithm, EncryptedFileInfo, MediaSource,26
- message::{AddMentions, ReplyWithinThread},27
- },28
- },29
-};30
-use matrix_sdk_crypto::{AttachmentDecryptor, MediaEncryptionInfo};31
-use ruma::MxcUri;32
-use tokio::sync::Mutex;33
-34
-use super::{executor::EngineBoxFuture, url_preview::UrlPreviews};35
-use crate::{36
- AttachmentEncryption, AttachmentUpload, Error, ErrorKind, LinkPreview, Media, MediaFile,37
- MediaId, MediaKind, MediaLocation, MediaSize, TimelineId,38
- normalise::{MediaRegistration, MediaScope, MediaSourceInfo},39
- upload::prepare_upload,40
-};41
-42
-/// a stalled download gives up after this long without a byte43
-const DOWNLOAD_IDLE: Duration = Duration::from_secs(60);44
-/// the sdk client gives up on any request after thirty seconds, far too soon for a video45
-const DOWNLOAD_TOTAL: Duration = Duration::from_secs(60 * 60);46
-47
-type FlightFuture = EngineBoxFuture<'static, Result<Media, Error>>;48
-type Flight = Shared<FlightFuture>;49
-50
-/// owns everything needed to turn a media id or an mxc uri into bytes, so the51
-/// handle can answer media queries without a trip through the engine52
-pub(crate) struct MediaService {53
- client: Client,54
- registry: Mutex<MediaRegistry>,55
- flights: Mutex<HashMap<String, WeakShared<FlightFuture>>>,56
- max_bytes: usize,57
- url_previews: UrlPreviews,58
-}59
-60
-impl MediaService {61
- pub(crate) fn new(client: Client, max_bytes: usize, max_sources: usize) -> Self {62
- Self {63
- url_previews: UrlPreviews::new(client.clone()),64
- client,65
- registry: Mutex::new(MediaRegistry::new(max_sources)),66
- flights: Mutex::new(HashMap::new()),67
- max_bytes,68
- }69
- }70
-71
- pub(crate) async fn url_preview(&self, url: &str) -> Result<Option<LinkPreview>, Error> {72
- self.url_previews.preview(url).await73
- }74
-75
- pub(crate) async fn register(&self, media: Vec<MediaRegistration>) {76
- if !media.is_empty() {77
- self.registry.lock().await.insert_all(media);78
- }79
- }80
-81
- pub(crate) async fn release(&self, scope: &MediaScope) {82
- self.registry.lock().await.release(scope);83
- }84
-85
- pub(crate) async fn clear(&self) {86
- self.registry.lock().await.clear();87
- }88
-89
- pub(crate) async fn timeline_media(90
- &self,91
- media_id: &MediaId,92
- size: MediaSize,93
- ) -> Result<Media, Error> {94
- let chosen = choose_source(self.registered(media_id).await?, size)?;95
- if chosen96
- .declared_size97
- .is_some_and(|size| size > self.max_bytes as u64)98
- {99
- return Err(too_large("media exceeds the configured byte limit"));100
- }101
- self.fetch_or_file(chosen.request, chosen.mimetype).await102
- }103
-104
- pub(crate) async fn media_location(105
- &self,106
- media_id: &MediaId,107
- size: MediaSize,108
- ) -> Result<MediaLocation, Error> {109
- let media = self.registered(media_id).await?;110
- let chosen = choose_source(media.clone(), size)?;111
- if is_local(&chosen.request.source) {112
- return Ok(MediaLocation::Local);113
- }114
- if !matches!(chosen.request.format, MediaFormat::Thumbnail(_)) {115
- return Ok(MediaLocation::File(116
- media_file(117
- &self.client,118
- chosen.request,119
- chosen.mimetype,120
- chosen.declared_size,121
- )122
- .await?,123
- ));124
- }125
- let url = media_http_request(&self.client, &chosen.request)126
- .await?127
- .uri()128
- .to_string();129
- let fallback = MediaRequestParameters {130
- source: media.full.source,131
- format: MediaFormat::File,132
- };133
- Ok(MediaLocation::ServerThumbnail {134
- url,135
- fallback: media_file(136
- &self.client,137
- fallback,138
- media.full.mimetype,139
- media.full.declared_size,140
- )141
- .await?,142
- })143
- }144
-145
- async fn registered(&self, media_id: &MediaId) -> Result<MediaRegistration, Error> {146
- if let Some(uri) = media_id.as_plain() {147
- return plain_registration(media_id, uri);148
- }149
- self.registry150
- .lock()151
- .await152
- .get(media_id)153
- .cloned()154
- .ok_or_else(|| {155
- Error::new(156
- ErrorKind::MediaUnknown,157
- "media is not registered in a retained timeline",158
- )159
- })160
- }161
-162
- /// plenty of servers won't thumbnail everything, and the file itself is the next best163
- /// thing. the byte cap still holds either way164
- async fn fetch_or_file(165
- &self,166
- request: MediaRequestParameters,167
- mimetype: Option<String>,168
- ) -> Result<Media, Error> {169
- if !matches!(request.format, MediaFormat::Thumbnail(_)) {170
- return self.fetch(request, mimetype).await;171
- }172
- match self.fetch(request.clone(), mimetype.clone()).await {173
- Err(error) if error.kind == ErrorKind::MediaUnavailable => {174
- let file = MediaRequestParameters {175
- source: request.source,176
- format: MediaFormat::File,177
- };178
- self.fetch(file, mimetype).await179
- }180
- result => result,181
- }182
- }183
-184
- async fn fetch(185
- &self,186
- request: MediaRequestParameters,187
- mimetype: Option<String>,188
- ) -> Result<Media, Error> {189
- let key = flight_key(&request);190
- let flight = {191
- let mut flights = self.flights.lock().await;192
- flights.retain(|_, flight| flight.upgrade().is_some());193
- match flights.get(&key).and_then(WeakShared::upgrade) {194
- Some(flight) => flight,195
- None => {196
- let prepared = AssertUnwindSafe(fetch(197
- self.client.clone(),198
- request,199
- mimetype,200
- self.max_bytes,201
- ))202
- .catch_unwind()203
- .map(|result| {204
- result.unwrap_or_else(|_| {205
- Err(Error::new(ErrorKind::Other, "media fetch panicked"))206
- })207
- });208
- let boxed: FlightFuture = Box::pin(prepared);209
- let flight: Flight = boxed.shared();210
- if let Some(weak) = flight.downgrade() {211
- flights.insert(key, weak);212
- }213
- flight214
- }215
- }216
- };217
- flight.await218
- }219
-}220
-221
-struct ChosenSource {222
- request: MediaRequestParameters,223
- mimetype: Option<String>,224
- declared_size: Option<u64>,225
-}226
-227
-fn plain_registration(media_id: &MediaId, uri: &MxcUri) -> Result<MediaRegistration, Error> {228
- if !uri.is_valid() {229
- return Err(Error::new(ErrorKind::Invalid, "mxc uri is malformed"));230
- }231
- Ok(MediaRegistration {232
- id: media_id.clone(),233
- kind: MediaKind::Image,234
- full: MediaSourceInfo {235
- source: MediaSource::Plain(uri.to_owned()),236
- mimetype: None,237
- declared_size: None,238
- },239
- thumbnail: None,240
- })241
-}242
-243
-fn choose_source(media: MediaRegistration, size: MediaSize) -> Result<ChosenSource, Error> {244
- let (source, format, mimetype, declared_size) = match size {245
- MediaSize::Full => (246
- media.full.source,247
- MediaFormat::File,248
- media.full.mimetype,249
- media.full.declared_size,250
- ),251
- MediaSize::Thumbnail { width, height } => match media.thumbnail {252
- Some(thumbnail) => (253
- thumbnail.source,254
- MediaFormat::File,255
- thumbnail.mimetype,256
- thumbnail.declared_size,257
- ),258
- // the server can only resize what it can read259
- None if matches!(media.kind, MediaKind::Image | MediaKind::Video)260
- && matches!(media.full.source, MediaSource::Plain(_)) =>261
- {262
- // the server picks the thumbnail's type, so the original's would be a lie263
- let format = if is_local(&media.full.source) {264
- MediaFormat::File265
- } else {266
- thumbnail_format(width, height)267
- };268
- (media.full.source, format, None, None)269
- }270
- None => {271
- return Err(Error::new(272
- ErrorKind::MediaUnavailable,273
- "the requested media size is unavailable",274
- ));275
- }276
- },277
- };278
- Ok(ChosenSource {279
- request: MediaRequestParameters { source, format },280
- mimetype,281
- declared_size,282
- })283
-}284
-285
-async fn media_file(286
- client: &Client,287
- request: MediaRequestParameters,288
- mimetype: Option<String>,289
- declared_size: Option<u64>,290
-) -> Result<MediaFile, Error> {291
- let url = media_http_request(client, &request)292
- .await?293
- .uri()294
- .to_string();295
- let encryption = match &request.source {296
- MediaSource::Plain(_) => None,297
- MediaSource::Encrypted(file) => {298
- let (EncryptedFileInfo::V2(info), Some(EncryptedFileHash::Sha256(sha256))) = (299
- &file.info,300
- file.hashes.get(&EncryptedFileHashAlgorithm::Sha256),301
- ) else {302
- return Err(Error::new(303
- ErrorKind::MediaUnavailable,304
- "encrypted media is not v2 with a sha256 hash",305
- ));306
- };307
- Some(AttachmentEncryption {308
- key: info.k.encode(),309
- iv: info.iv.encode(),310
- sha256: sha256.encode(),311
- })312
- }313
- };314
- Ok(MediaFile {315
- url,316
- mimetype,317
- declared_size,318
- encryption,319
- })320
-}321
-322
-fn thumbnail_format(width: u32, height: u32) -> MediaFormat {323
- MediaFormat::Thumbnail(MediaThumbnailSettings::new(width.into(), height.into()))324
-}325
-326
-fn is_local(source: &MediaSource) -> bool {327
- let uri = match source {328
- MediaSource::Plain(uri) => uri,329
- MediaSource::Encrypted(file) => &file.url,330
- };331
- uri.server_name()332
- .is_ok_and(|server| server.as_str() == "send-queue.localhost")333
-}334
-335
-/// plain and encrypted sources share a cache key in the sdk, but must never share a flight336
-fn flight_key(request: &MediaRequestParameters) -> String {337
- let uri = match &request.source {338
- MediaSource::Plain(uri) => format!("plain|{uri}"),339
- MediaSource::Encrypted(file) => format!("encrypted|{}", file.url),340
- };341
- match &request.format {342
- MediaFormat::File => uri.to_owned(),343
- MediaFormat::Thumbnail(settings) => format!(344
- "{uri}|{}|{}x{}|{}",345
- settings.method, settings.width, settings.height, settings.animated346
- ),347
- }348
-}349
-350
-fn too_large(message: &'static str) -> Error {351
- Error::new(ErrorKind::MediaTooLarge, message)352
-}353
-354
-async fn fetch(355
- client: Client,356
- request: MediaRequestParameters,357
- mimetype: Option<String>,358
- max_bytes: usize,359
-) -> Result<Media, Error> {360
- if let Some(cached) = cached_media(&client, &request).await? {361
- if cached.len() > max_bytes {362
- if let Ok(store) = client.media_store().lock().await {363
- let _ = store.remove_media_content(&request).await;364
- }365
- return Err(too_large("cached media exceeds the configured byte limit"));366
- }367
- // plain and encrypted share a cache key, so a plain fetch of an encrypted file's uri368
- // leaves ciphertext here. its hash gives it away369
- if is_ciphertext(&cached, &request.source) {370
- let bytes = maybe_decrypt(cached, &request, max_bytes)?;371
- cache(&client, &request, &bytes).await;372
- return Ok(media(bytes, mimetype));373
- }374
- return Ok(media(cached, mimetype));375
- }376
- let (bytes, served_mimetype) = download_bounded(&client, &request, max_bytes).await?;377
- // the sdk caches plaintext under the encrypted key, so we do too or nobody can read anything378
- let bytes = maybe_decrypt(bytes, &request, max_bytes)?;379
- cache(&client, &request, &bytes).await;380
- Ok(media(bytes, mimetype.or(served_mimetype)))381
-}382
-383
-async fn cache(client: &Client, request: &MediaRequestParameters, bytes: &[u8]) {384
- if let Ok(store) = client.media_store().lock().await {385
- let _ = store386
- .add_media_content(387
- request,388
- bytes.to_vec(),389
- matrix_sdk::media::store::IgnoreMediaRetentionPolicy::No,390
- )391
- .await;392
- }393
-}394
-395
-fn is_ciphertext(bytes: &[u8], source: &MediaSource) -> bool {396
- use sha2::{Digest, Sha256};397
- let MediaSource::Encrypted(file) = source else {398
- return false;399
- };400
- file.hashes401
- .get(&ruma::events::room::EncryptedFileHashAlgorithm::Sha256)402
- .is_some_and(|hash| hash.as_bytes() == Sha256::digest(bytes).as_slice())403
-}404
-405
-fn media(bytes: Vec<u8>, mimetype: Option<String>) -> Media {406
- let mimetype = mimetype407
- .or_else(|| sniff_image(&bytes).map(str::to_owned))408
- .unwrap_or_else(|| "application/octet-stream".to_owned());409
- Media {410
- bytes: Arc::from(bytes),411
- mimetype,412
- }413
-}414
-415
-/// the cache forgets content types, and avatars never declare one. images are the common case416
-fn sniff_image(bytes: &[u8]) -> Option<&'static str> {417
- match bytes {418
- [0x89, b'P', b'N', b'G', ..] => Some("image/png"),419
- [0xff, 0xd8, 0xff, ..] => Some("image/jpeg"),420
- [b'G', b'I', b'F', b'8', ..] => Some("image/gif"),421
- [422
- b'R',423
- b'I',424
- b'F',425
- b'F',426
- _,427
- _,428
- _,429
- _,430
- b'W',431
- b'E',432
- b'B',433
- b'P',434
- ..,435
- ] => Some("image/webp"),436
- _ => None,437
- }438
-}439
-440
-async fn cached_media(441
- client: &Client,442
- request: &MediaRequestParameters,443
-) -> Result<Option<Vec<u8>>, Error> {444
- fn cache_failed(445
- source: impl std::error::Error + SendOutsideWasm + SyncOutsideWasm + 'static,446
- ) -> Error {447
- Error::with_source(ErrorKind::Store, "reading media cache failed", source)448
- }449
- client450
- .media_store()451
- .lock()452
- .await453
- .map_err(cache_failed)?454
- .get_media_content(request)455
- .await456
- .map_err(cache_failed)457
-}458
-459
-fn maybe_decrypt(460
- bytes: Vec<u8>,461
- request: &MediaRequestParameters,462
- max_bytes: usize,463
-) -> Result<Vec<u8>, Error> {464
- let MediaSource::Encrypted(file) = &request.source else {465
- return Ok(bytes);466
- };467
- fn decrypt_failed(468
- source: impl std::error::Error + SendOutsideWasm + SyncOutsideWasm + 'static,469
- ) -> Error {470
- Error::with_source(471
- ErrorKind::MediaUnavailable,472
- "media decryption failed",473
- source,474
- )475
- }476
- let info: MediaEncryptionInfo = (**file).clone().into();477
- let mut cursor = std::io::Cursor::new(bytes);478
- let mut decryptor = AttachmentDecryptor::new(&mut cursor, info).map_err(decrypt_failed)?;479
- let mut decrypted = Vec::new();480
- let mut chunk = [0_u8; 8192];481
- loop {482
- let read = decryptor.read(&mut chunk).map_err(decrypt_failed)?;483
- if read == 0 {484
- return Ok(decrypted);485
- }486
- if read > max_bytes.saturating_sub(decrypted.len()) {487
- return Err(too_large(488
- "decrypted media exceeds the configured byte limit",489
- ));490
- }491
- decrypted.extend_from_slice(&chunk[..read]);492
- }493
-}494
-495
-/// the sdk's own download has no size cap, so we do it by hand496
-async fn download_bounded(497
- client: &Client,498
- request: &MediaRequestParameters,499
- max_bytes: usize,500
-) -> Result<(Vec<u8>, Option<String>), Error> {501
- if is_local(&request.source) {502
- return Err(Error::new(503
- ErrorKind::MediaUnavailable,504
- "local media is not in the media cache",505
- ));506
- }507
- let network_failed =508
- |source| Error::with_source(ErrorKind::Network, "media download failed", source);509
- let mut refreshed = false;510
- let response = loop {511
- let http_request = media_http_request(client, request).await?;512
- let (parts, _) = http_request.into_parts();513
- let mut builder = client514
- .http_client()515
- .get(parts.uri.to_string())516
- .timeout(DOWNLOAD_TOTAL);517
- for (name, value) in &parts.headers {518
- builder = builder.header(name.as_str(), value.as_bytes());519
- }520
- let response = builder.send().await.map_err(network_failed)?;521
- if response.status() != matrix_sdk::reqwest::StatusCode::UNAUTHORIZED {522
- break response;523
- }524
- if refreshed525
- || client526
- .session_tokens()527
- .is_none_or(|tokens| tokens.refresh_token.is_none())528
- {529
- return Err(Error::new(530
- ErrorKind::Unauthenticated,531
- "media download was not authorized",532
- ));533
- }534
- client.refresh_access_token().await.map_err(|source| {535
- Error::from_refresh_token("refreshing media access token failed", source)536
- })?;537
- refreshed = true;538
- };539
- let status = response.status();540
- if !status.is_success() {541
- return Err(Error::new(542
- ErrorKind::MediaUnavailable,543
- format!("media download failed with status {status}"),544
- ));545
- }546
- if response547
- .content_length()548
- .is_some_and(|length| length > max_bytes as u64)549
- {550
- return Err(too_large("media exceeds the configured byte limit"));551
- }552
- let mimetype = response553
- .headers()554
- .get(http::header::CONTENT_TYPE)555
- .and_then(|value| value.to_str().ok())556
- .map(str::to_owned);557
- let mut stream = response.bytes_stream();558
- let mut bytes = Vec::new();559
- loop {560
- let chunk = matrix_sdk::timeout::timeout(stream.next(), DOWNLOAD_IDLE)561
- .await562
- .map_err(|_| Error::new(ErrorKind::Network, "media download stalled"))?;563
- let Some(chunk) = chunk else { break };564
- let chunk = chunk.map_err(network_failed)?;565
- if chunk.len() > max_bytes.saturating_sub(bytes.len()) {566
- return Err(too_large(567
- "downloaded media exceeds the configured byte limit",568
- ));569
- }570
- bytes.extend_from_slice(&chunk);571
- }572
- Ok((bytes, mimetype))573
-}574
-575
-async fn media_http_request(576
- client: &Client,577
- request: &MediaRequestParameters,578
-) -> Result<http::Request<Vec<u8>>, Error> {579
- let versions = client.supported_versions().await.map_err(|source| {580
- Error::with_source(581
- ErrorKind::Network,582
- "reading supported server versions failed",583
- source,584
- )585
- })?;586
- let authenticated =587
- authenticated_media::get_content::v1::Request::PATH_BUILDER.is_supported(&versions);588
- let base = client.homeserver().to_string();589
- let token = client.access_token();590
- let invalid_uri =591
- |source| Error::with_source(ErrorKind::Invalid, "media URI is invalid", source);592
- let (uri, thumbnail) = match (&request.source, &request.format) {593
- (MediaSource::Encrypted(file), _) => (&file.url, None),594
- (MediaSource::Plain(uri), MediaFormat::File) => (uri, None),595
- (MediaSource::Plain(uri), MediaFormat::Thumbnail(settings)) => (uri, Some(settings)),596
- };597
- // each endpoint has its own auth input type, so no generic helper can take them all598
- macro_rules! into_http {599
- ($endpoint:expr, $token:expr) => {600
- $endpoint601
- .try_into_http_request::<Vec<u8>>(&base, $token, Cow::Borrowed(&versions))602
- .map_err(|source| {603
- Error::with_source(ErrorKind::Other, "media request is invalid", source)604
- })?605
- };606
- }607
- let authed = SendAccessToken::IfRequired(token.as_deref().unwrap_or(""));608
- #[allow(deprecated)]609
- let mut http_request = match (thumbnail, authenticated) {610
- (None, true) => into_http!(611
- authenticated_media::get_content::v1::Request::from_uri(uri).map_err(invalid_uri)?,612
- authed613
- ),614
- (None, false) => into_http!(615
- media::get_content::v3::Request::from_url(uri).map_err(invalid_uri)?,616
- SendAccessToken::None617
- ),618
- (Some(settings), true) => {619
- let mut endpoint = authenticated_media::get_content_thumbnail::v1::Request::from_uri(620
- uri,621
- settings.width,622
- settings.height,623
- )624
- .map_err(invalid_uri)?;625
- endpoint.method = Some(settings.method.clone());626
- endpoint.animated = Some(settings.animated);627
- into_http!(endpoint, authed)628
- }629
- (Some(settings), false) => {630
- let mut endpoint = media::get_content_thumbnail::v3::Request::from_url(631
- uri,632
- settings.width,633
- settings.height,634
- )635
- .map_err(invalid_uri)?;636
- endpoint.method = Some(settings.method.clone());637
- endpoint.animated = Some(settings.animated);638
- into_http!(endpoint, SendAccessToken::None)639
- }640
- };641
- // legacy endpoints don't ask for a token but plenty of servers want one anyway642
- if let Some(token) = token.as_deref() {643
- let value = format!("Bearer {token}").parse().map_err(|_| {644
- Error::new(ErrorKind::Unauthenticated, "stored access token is invalid")645
- })?;646
- http_request647
- .headers_mut()648
- .insert(http::header::AUTHORIZATION, value);649
- }650
- Ok(http_request)651
-}652
-653
-pub(crate) async fn queue_upload(654
- room: Room,655
- upload: AttachmentUpload,656
- max_bytes: usize,657
-) -> Result<(), Error> {658
- let (upload, mime, mut config) = prepare_upload(upload, max_bytes)?;659
- if let TimelineId::Thread { root_event_id, .. } = &upload.timeline_id {660
- config.reply = Some(SdkReply {661
- event_id: root_event_id.clone(),662
- enforce_thread: EnforceThread::Threaded(ReplyWithinThread::No),663
- add_mentions: AddMentions::No,664
- });665
- }666
- room.send_queue()667
- .send_attachment(upload.filename, mime, upload.bytes, config)668
- .await669
- .map(drop)670
- .map_err(|source| {671
- use matrix_sdk::send_queue::RoomSendQueueError;672
- let kind = match &source {673
- RoomSendQueueError::RoomNotJoined | RoomSendQueueError::RoomDisappeared => {674
- ErrorKind::RoomUnknown675
- }676
- RoomSendQueueError::StorageError(_) => ErrorKind::Store,677
- _ => ErrorKind::MediaUnavailable,678
- };679
- Error::with_source(kind, "media upload could not be queued", source)680
- })681
-}682
-683
-/// least recently seen goes first. stale order slots are skipped by stamp rather than hunted down684
-struct MediaRegistry {685
- entries: HashMap<MediaId, (MediaRegistration, u64)>,686
- order: VecDeque<(MediaId, u64)>,687
- next_stamp: u64,688
- limit: usize,689
-}690
-691
-impl MediaRegistry {692
- fn new(limit: usize) -> Self {693
- Self {694
- entries: HashMap::new(),695
- order: VecDeque::new(),696
- next_stamp: 0,697
- limit,698
- }699
- }700
-701
- fn get(&self, id: &MediaId) -> Option<&MediaRegistration> {702
- self.entries.get(id).map(|(media, _)| media)703
- }704
-705
- fn insert_all(&mut self, media: Vec<MediaRegistration>) {706
- for media in media {707
- let stamp = self.next_stamp;708
- self.next_stamp += 1;709
- self.order.push_back((media.id.clone(), stamp));710
- self.entries.insert(media.id.clone(), (media, stamp));711
- }712
- while self.entries.len() > self.limit {713
- let Some((id, stamp)) = self.order.pop_front() else {714
- break;715
- };716
- if self.is_current(&id, stamp) {717
- self.entries.remove(&id);718
- }719
- }720
- if self.order.len() > self.limit.saturating_mul(4) {721
- let entries = &self.entries;722
- self.order723
- .retain(|(id, stamp)| entries.get(id).is_some_and(|(_, current)| current == stamp));724
- }725
- }726
-727
- fn is_current(&self, id: &MediaId, stamp: u64) -> bool {728
- self.entries729
- .get(id)730
- .is_some_and(|(_, current)| *current == stamp)731
- }732
-733
- fn release(&mut self, scope: &MediaScope) {734
- self.entries.retain(|id, _| !scope.owns(id));735
- let entries = &self.entries;736
- self.order.retain(|(id, _)| entries.contains_key(id));737
- }738
-739
- fn clear(&mut self) {740
- self.entries.clear();741
- self.order.clear();742
- }743
-}@@ -1,968 +0,0 @@
1
-mod commands;2
-pub(crate) mod executor;3
-mod media;4
-mod mutations;5
-mod prepare;6
-mod room;7
-mod session;8
-mod timeline;9
-mod url_preview;10
-mod watchers;11
-12
-use std::{13
- collections::{HashMap, VecDeque},14
- future::Future,15
- panic::AssertUnwindSafe,16
- sync::Arc,17
-};18
-19
-use futures_util::FutureExt;20
-use matrix_sdk::{Client, SendOutsideWasm, SessionChange};21
-use matrix_sdk_ui::sync_service::SyncService;22
-use ruma::{OwnedDeviceId, OwnedRoomId};23
-use thiserror::Error as ThisError;24
-use tokio::sync::{broadcast, mpsc, watch};25
-26
-pub(crate) use media::MediaService;27
-pub(crate) use session::Persistence;28
-29
-#[cfg(any(feature = "sqlite", feature = "indexeddb"))]30
-pub(crate) use crate::store_lock::StoreLock;31
-#[cfg(not(any(feature = "sqlite", feature = "indexeddb")))]32
-pub(crate) enum StoreLock {}33
-34
-use self::{35
- commands::Job,36
- executor::{EngineBoxFuture, TaskSet},37
- mutations::{Mutation, MutationJob},38
- room::{ActorNote, ActorRequest, RoomActor},39
- timeline::{SessionContext, Supervisor},40
-};41
-use crate::{42
- AccountCommand, ClientHandle, Command, ConnectionState, EncryptionCommand, EngineErrorKind,43
- EngineFailure, Error, ErrorKind, Event, EventStream, LifecycleCommand, MediaCommand,44
- NotificationCommand, RoomCommand, Termination, TimelineCommand,45
- normalise::{ROOM_PACKS, ROOM_PREVIEW_URLS},46
- transport::{DriverPort, Request, Responder, transport_pair},47
-};48
-49
-#[derive(Clone, Debug, Eq, PartialEq)]50
-#[non_exhaustive]51
-pub struct EngineConfig {52
- pub command_capacity: usize,53
- pub event_capacity: usize,54
- pub initial_history_limit: u16,55
- pub max_media_bytes: usize,56
- pub max_media_sources: usize,57
- /// warm a room up for sending when it's opened or someone starts typing in it58
- pub prepare_to_send: bool,59
-}60
-61
-impl EngineConfig {62
- #[must_use]63
- pub const fn with_command_capacity(mut self, capacity: usize) -> Self {64
- self.command_capacity = capacity;65
- self66
- }67
-68
- #[must_use]69
- pub const fn with_event_capacity(mut self, capacity: usize) -> Self {70
- self.event_capacity = capacity;71
- self72
- }73
-74
- #[must_use]75
- pub const fn with_initial_history_limit(mut self, limit: u16) -> Self {76
- self.initial_history_limit = limit;77
- self78
- }79
-80
- #[must_use]81
- pub const fn with_max_media_bytes(mut self, bytes: usize) -> Self {82
- self.max_media_bytes = bytes;83
- self84
- }85
-86
- #[must_use]87
- pub const fn with_max_media_sources(mut self, sources: usize) -> Self {88
- self.max_media_sources = sources;89
- self90
- }91
-92
- #[must_use]93
- pub const fn with_prepare_to_send(mut self, prepare: bool) -> Self {94
- self.prepare_to_send = prepare;95
- self96
- }97
-}98
-99
-impl Default for EngineConfig {100
- fn default() -> Self {101
- Self {102
- command_capacity: 64,103
- event_capacity: 128,104
- initial_history_limit: 30,105
- max_media_bytes: 20 * 1024 * 1024,106
- max_media_sources: 2_048,107
- prepare_to_send: true,108
- }109
- }110
-}111
-112
-#[derive(Debug, ThisError)]113
-pub enum EngineError {114
- #[error("the Matrix client is not authenticated")]115
- Unauthenticated,116
- #[error("command, event, and media source capacities must be nonzero")]117
- ZeroCapacity,118
- #[error("could not build the sync service")]119
- SyncService(#[source] matrix_sdk_ui::sync_service::Error),120
- #[error("the event receiver has closed")]121
- EventReceiverClosed,122
- #[error("engine task {task} panicked")]123
- TaskPanicked { task: String },124
-}125
-126
-impl EngineError {127
- fn failure(&self) -> EngineFailure {128
- EngineFailure {129
- kind: match self {130
- Self::EventReceiverClosed => EngineErrorKind::EventReceiverClosed,131
- Self::TaskPanicked { .. } => EngineErrorKind::Watcher,132
- Self::Unauthenticated | Self::ZeroCapacity | Self::SyncService(_) => {133
- EngineErrorKind::Other134
- }135
- },136
- message: self.to_string(),137
- }138
- }139
-}140
-141
-#[derive(Clone)]142
-enum TaskContext {143
- /// a panic here costs the caller an `Interrupted` answer and nothing else144
- Command(&'static str),145
- /// a panic here takes the engine with it146
- Watcher(&'static str),147
- RoomActor {148
- room_id: OwnedRoomId,149
- generation: u64,150
- },151
-}152
-153
-impl TaskContext {154
- fn label(&self) -> String {155
- match self {156
- Self::Command(name) | Self::Watcher(name) => (*name).to_owned(),157
- Self::RoomActor {158
- room_id,159
- generation,160
- } => format!("room actor {room_id} generation {generation}"),161
- }162
- }163
-}164
-165
-/// tasks catch their own panics so each comes back with its context, since joining166
-/// through the sdk's executor gives no task id to look one up by. wasm has nothing to catch167
-fn panic_message(payload: Box<dyn std::any::Any + Send>) -> String {168
- payload169
- .downcast_ref::<&str>()170
- .map(ToString::to_string)171
- .or_else(|| payload.downcast_ref::<String>().cloned())172
- .unwrap_or_else(|| "task panicked with a non-string payload".to_owned())173
-}174
-175
-struct TaskRegistry {176
- tasks: TaskSet<(TaskContext, Option<String>)>,177
-}178
-179
-impl TaskRegistry {180
- fn spawn(181
- &mut self,182
- context: TaskContext,183
- task: impl Future<Output = ()> + SendOutsideWasm + 'static,184
- ) {185
- self.tasks.spawn(async move {186
- let panic = AssertUnwindSafe(task)187
- .catch_unwind()188
- .await189
- .err()190
- .map(panic_message);191
- (context, panic)192
- });193
- }194
-195
- fn is_empty(&self) -> bool {196
- self.tasks.is_empty()197
- }198
-199
- async fn join_next(&mut self) -> Option<(TaskContext, Option<String>)> {200
- loop {201
- if let Ok(done) = self.tasks.join_next().await? {202
- return Some(done);203
- }204
- }205
- }206
-207
- async fn shutdown(&mut self) {208
- self.tasks.shutdown().await;209
- }210
-}211
-212
-enum RoomSlot {213
- Active {214
- generation: u64,215
- sender: mpsc::Sender<ActorRequest>,216
- },217
- /// opens and closes that arrive while the actor winds down, replayed in order once it's gone218
- Closing {219
- generation: u64,220
- pending: VecDeque<Deferred>,221
- },222
-}223
-224
-impl RoomSlot {225
- const fn generation(&self) -> u64 {226
- match self {227
- Self::Active { generation, .. } | Self::Closing { generation, .. } => *generation,228
- }229
- }230
-}231
-232
-enum Deferred {233
- Open(Responder),234
- Close(Responder),235
-}236
-237
-/// logouts in flight, and a token rejection held back because one of them caused it238
-#[derive(Default)]239
-pub(super) struct Logouts {240
- pub(super) in_flight: usize,241
- pub(super) rejected: Option<Option<OwnedDeviceId>>,242
-}243
-244
-struct Queues {245
- mutations: mpsc::Receiver<MutationJob>,246
- notifications: mpsc::Receiver<Job<NotificationCommand>>,247
- encryption: mpsc::Receiver<Job<EncryptionCommand>>,248
- session_changes: broadcast::Receiver<SessionChange>,249
- persistence: Option<Persistence>,250
-}251
-252
-/// requests other devices send us, handed from the sdk's event handler to the encryption worker253
-type IncomingVerifications =254
- mpsc::UnboundedSender<matrix_sdk::encryption::verification::VerificationRequest>;255
-256
-pub struct Engine {257
- client: Client,258
- sync: Arc<SyncService>,259
- port: DriverPort,260
- config: EngineConfig,261
- media: Arc<MediaService>,262
- tasks: TaskRegistry,263
- rooms: HashMap<OwnedRoomId, RoomSlot>,264
- next_generation: u64,265
- notes: mpsc::UnboundedReceiver<ActorNote>,266
- note_sender: mpsc::UnboundedSender<ActorNote>,267
- /// the rooms sliding sync should follow, applied off the run loop since it touches the store268
- subscriptions: watch::Sender<Vec<OwnedRoomId>>,269
- mutations: mpsc::Sender<MutationJob>,270
- notifications: mpsc::Sender<Job<NotificationCommand>>,271
- encryption: mpsc::Sender<Job<EncryptionCommand>>,272
- queues: Option<Queues>,273
- /// the client can outlive us, so the handler has to come off it when we stop274
- verification_handler: Option<matrix_sdk::event_handler::EventHandlerHandle>,275
- stop: mpsc::UnboundedSender<Termination>,276
- stops: mpsc::UnboundedReceiver<Termination>,277
- logouts: Arc<watch::Sender<Logouts>>,278
- preparations: prepare::Preparations,279
- /// shared with every `ClientHandle`, so the store stays locked while the client is reachable280
- _store_lock: Option<Arc<StoreLock>>,281
-}282
-283
-impl Engine {284
- /// the escape hatch for a client you built yourself. `Session` is the usual way in285
- pub async fn from_client(286
- client: Client,287
- config: EngineConfig,288
- ) -> Result<(Self, ClientHandle, EventStream), EngineError> {289
- let session_changes = client.subscribe_to_session_changes();290
- Self::with_persistence(client, config, session_changes, None, None).await291
- }292
-293
- /// `session_changes` is taken when the client is built, since the sdk can refresh294
- /// before an engine exists295
- pub(crate) async fn with_persistence(296
- client: Client,297
- config: EngineConfig,298
- session_changes: broadcast::Receiver<SessionChange>,299
- persistence: Option<Persistence>,300
- store_lock: Option<StoreLock>,301
- ) -> Result<(Self, ClientHandle, EventStream), EngineError> {302
- if client.user_id().is_none() {303
- return Err(EngineError::Unauthenticated);304
- }305
- if config.command_capacity == 0306
- || config.event_capacity == 0307
- || config.max_media_sources == 0308
- {309
- return Err(EngineError::ZeroCapacity);310
- }311
- let sync = Arc::new(312
- SyncService::builder(client.clone())313
- .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
- ])318
- .build()319
- .await320
- .map_err(EngineError::SyncService)?,321
- );322
- let store_lock = store_lock.map(Arc::new);323
- let media = Arc::new(MediaService::new(324
- client.clone(),325
- config.max_media_bytes,326
- config.max_media_sources,327
- ));328
- let (handle, port, events) = transport_pair(329
- client.clone(),330
- sync.clone(),331
- media.clone(),332
- store_lock.clone(),333
- config.command_capacity,334
- config.event_capacity,335
- );336
- client.send_queue().enable_upload_progress(true);337
- client.send_queue().enable_global_upload_progress(true);338
- let (note_sender, notes) = mpsc::unbounded_channel();339
- let (stop, stops) = mpsc::unbounded_channel();340
- let (mutations, mutation_queue) = mpsc::channel(config.command_capacity);341
- let (notifications, notification_queue) = mpsc::channel(config.command_capacity);342
- let (encryption, encryption_queue) = mpsc::channel(config.command_capacity);343
- let engine = Self {344
- client,345
- sync,346
- port,347
- config,348
- media,349
- tasks: TaskRegistry {350
- tasks: TaskSet::new(),351
- },352
- rooms: HashMap::new(),353
- next_generation: 0,354
- notes,355
- note_sender,356
- subscriptions: watch::Sender::new(Vec::new()),357
- mutations,358
- notifications,359
- encryption,360
- queues: Some(Queues {361
- mutations: mutation_queue,362
- notifications: notification_queue,363
- encryption: encryption_queue,364
- session_changes,365
- persistence,366
- }),367
- verification_handler: None,368
- preparations: prepare::Preparations::default(),369
- stop,370
- stops,371
- logouts: Arc::new(watch::Sender::new(Logouts::default())),372
- _store_lock: store_lock,373
- };374
- Ok((engine, handle, events))375
- }376
-377
- /// boxed because the engine's future is deep enough to blow the compiler's query depth378
- /// in every crate that spawns it, which isn't something a client should have to fix379
- pub fn run(self) -> EngineBoxFuture<'static, Result<Termination, EngineError>> {380
- Box::pin(self.run_inner())381
- }382
-383
- async fn run_inner(mut self) -> Result<Termination, EngineError> {384
- let events = self.port.event_sender();385
- let user_id = self386
- .client387
- .user_id()388
- .expect("authentication was checked during construction")389
- .to_owned();390
- self.port391
- .connection_sender()392
- .send_replace(ConnectionState::Connecting);393
- for event in [394
- Event::Authenticated { user_id },395
- Event::ConnectionChanged(ConnectionState::Connecting),396
- ] {397
- if events.send(event).await.is_err() {398
- self.shutdown().await;399
- return Err(EngineError::EventReceiverClosed);400
- }401
- }402
- self.start_tasks();403
- self.sync.start().await;404
-405
- let mut shutdown_reply = None;406
- let mut handles_dropped = false;407
- let mut logouts = self.logouts.subscribe();408
- let ending = loop {409
- tokio::select! {410
- biased;411
- Some(termination) = self.stops.recv() => break Ok(termination),412
- () = events.closed() => break Err(EngineError::EventReceiverClosed),413
- () = settled(&mut logouts), if handles_dropped => {414
- break Ok(Termination::HandlesDropped);415
- }416
- Some((context, panic)) = self.tasks.join_next(), if !self.tasks.is_empty() => {417
- let Some(message) = panic else { continue };418
- if let TaskContext::Command(label) = context {419
- tracing::error!(label, panic = %message, "command task panicked");420
- continue;421
- }422
- break Err(EngineError::TaskPanicked { task: context.label() });423
- }424
- Some(note) = self.notes.recv() => self.actor_note(note).await,425
- request = self.port.recv(), if !handles_dropped => {426
- // nobody's left to ask, but a logout they asked for still deserves to land427
- let Some(request) = request else {428
- handles_dropped = true;429
- continue;430
- };431
- if let Some(reply) = self.dispatch(request).await {432
- shutdown_reply = Some(reply);433
- break Ok(Termination::ShutDown);434
- }435
- }436
- }437
- };438
-439
- if let Err(error) = &ending {440
- let _ = events.send(Event::Failed(error.failure())).await;441
- }442
- self.shutdown().await;443
- let _ = events.send(Event::Stopped).await;444
- if let Some(reply) = shutdown_reply {445
- reply.done(Ok(()));446
- }447
- // everything else still queued answers `Interrupted` as it drops, but a shutdown did happen448
- while let Some(Request { command, reply }) = self.port.try_recv() {449
- if matches!(command, Command::Lifecycle(LifecycleCommand::Shutdown)) {450
- reply.done(Ok(()));451
- }452
- }453
- ending454
- }455
-456
- fn start_tasks(&mut self) {457
- let queues = self.queues.take().expect("engine tasks start once");458
- let (incoming, incoming_verifications) = mpsc::unbounded_channel();459
- self.verification_handler = Some(self.verification_handler(incoming));460
- let client = &self.client;461
- let events = || self.port.event_sender();462
- let (notices, notice_queue) = mpsc::unbounded_channel();463
- let watchers: [(&'static str, EngineBoxFuture<'static, ()>); 13] = [464
- (465
- "upload tracker",466
- Box::pin(watchers::uploads(467
- client.clone(),468
- client.send_queue().subscribe(),469
- events(),470
- )),471
- ),472
- (473
- "room subscription watcher",474
- Box::pin(watchers::room_subscriptions(475
- self.sync.clone(),476
- self.subscriptions.subscribe(),477
- )),478
- ),479
- (480
- "session change watcher",481
- Box::pin(session::session(482
- client.clone(),483
- queues.session_changes,484
- queues.persistence,485
- notices,486
- client487
- .device_id()488
- .expect("authentication was checked during construction")489
- .to_owned(),490
- self.logouts.clone(),491
- )),492
- ),493
- (494
- "session notice watcher",495
- Box::pin(session::session_notices(496
- notice_queue,497
- events(),498
- self.stop.clone(),499
- )),500
- ),501
- (502
- "connection state watcher",503
- Box::pin(watchers::connection(504
- self.sync.state(),505
- self.sync.clone(),506
- client.clone(),507
- events(),508
- self.port.connection_sender(),509
- )),510
- ),511
- (512
- "send queue watcher",513
- Box::pin(watchers::send_queue_errors(514
- client.clone(),515
- client.send_queue().subscribe_errors(),516
- self.port.connection_sender().subscribe(),517
- )),518
- ),519
- (520
- "encryption state watcher",521
- Box::pin(watchers::encryption(client.clone(), events())),522
- ),523
- (524
- "room list watcher",525
- Box::pin(watchers::rooms(client.clone(), events())),526
- ),527
- (528
- "image pack watcher",529
- Box::pin(watchers::image_packs(client.clone(), events())),530
- ),531
- (532
- "notification settings watcher",533
- Box::pin(watchers::notification_settings(client.clone(), events())),534
- ),535
- (536
- "room command worker",537
- Box::pin(mutations::run_scheduler(538
- client.clone(),539
- queues.mutations,540
- self.config.command_capacity,541
- )),542
- ),543
- (544
- "notification command worker",545
- Box::pin(commands::run_notifications(546
- client.clone(),547
- queues.notifications,548
- )),549
- ),550
- (551
- "encryption command worker",552
- Box::pin(commands::run_encryption(553
- client.clone(),554
- events(),555
- queues.encryption,556
- incoming_verifications,557
- )),558
- ),559
- ];560
- for (label, task) in watchers {561
- self.tasks.spawn(TaskContext::Watcher(label), task);562
- }563
- }564
-565
- fn verification_handler(566
- &self,567
- incoming: IncomingVerifications,568
- ) -> matrix_sdk::event_handler::EventHandlerHandle {569
- use matrix_sdk::ruma::events::key::verification::request::ToDeviceKeyVerificationRequestEvent;570
- self.client.add_event_handler(571
- move |event: ToDeviceKeyVerificationRequestEvent, client: Client| {572
- let incoming = incoming.clone();573
- async move {574
- let request = client575
- .encryption()576
- .get_verification_request(577
- &event.sender,578
- event.content.transaction_id.as_str(),579
- )580
- .await;581
- if let Some(request) = request {582
- let _ = incoming.send(request);583
- }584
- }585
- },586
- )587
- }588
-589
- /// hands back the shutdown request's responder, which is answered once we've stopped590
- async fn dispatch(&mut self, Request { command, reply }: Request) -> Option<Responder> {591
- let client = self.client.clone();592
- match command {593
- Command::Lifecycle(LifecycleCommand::Shutdown) => return Some(reply),594
- Command::Account(AccountCommand::Logout) => {595
- let events = self.port.event_sender();596
- let stop = self.stop.clone();597
- let logouts = self.logouts.clone();598
- logouts.send_modify(|logouts| logouts.in_flight += 1);599
- self.spawn_command("logout", reply, async move {600
- let result = client.logout().await;601
- if result.is_ok() {602
- let _ = events.send(Event::LoggedOut).await;603
- let _ = stop.send(Termination::LoggedOut);604
- }605
- let mut rejected = None;606
- logouts.send_if_modified(|logouts| {607
- if result.is_err() && logouts.in_flight == 1 {608
- rejected = logouts.rejected.take();609
- }610
- false611
- });612
- if let Some(soft_logout) = rejected {613
- session::reject_session(&events, &stop, soft_logout).await;614
- }615
- logouts.send_modify(|logouts| logouts.in_flight -= 1);616
- result.map_err(|source| Error::from_sdk("logout failed", source))617
- });618
- }619
- Command::Account(AccountCommand::SetAccountData {620
- event_type,621
- content,622
- }) => enqueue(623
- &self.mutations,624
- Job {625
- command: Mutation::GlobalAccountData {626
- event_type,627
- content,628
- },629
- reply,630
- },631
- ),632
- Command::Account(AccountCommand::SetDisplayName(name)) => {633
- self.spawn_command("set display name", reply, async move {634
- client635
- .account()636
- .set_display_name(name.as_deref())637
- .await638
- .map_err(|source| Error::from_sdk("setting display name failed", source))639
- });640
- }641
- Command::Account(AccountCommand::SetAvatar(avatar)) => {642
- self.spawn_command("set avatar", reply, async move {643
- client644
- .account()645
- .set_avatar_url(avatar.as_deref())646
- .await647
- .map_err(|source| Error::from_sdk("setting avatar failed", source))648
- });649
- }650
- Command::Account(AccountCommand::UploadAvatar { mimetype, bytes }) => {651
- let max_bytes = self.config.max_media_bytes;652
- self.spawn_command("upload avatar", reply, async move {653
- commands::upload_avatar(&client, &mimetype, bytes, max_bytes).await654
- });655
- }656
- Command::Account(AccountCommand::RenameDevice {657
- device_id,658
- display_name,659
- }) => self.spawn_command("rename device", reply, async move {660
- commands::rename_device(&client, &device_id, &display_name).await661
- }),662
- Command::Account(AccountCommand::DeleteDevices { device_ids, auth }) => {663
- self.spawn_command("delete devices", reply, async move {664
- commands::delete_devices(&client, &device_ids, auth).await665
- });666
- }667
- Command::Room(RoomCommand::Open(room_id)) => {668
- if self.config.prepare_to_send {669
- self.prepare_quietly(room_id.clone());670
- }671
- self.open_room(room_id, reply).await;672
- }673
- Command::Room(RoomCommand::Close(room_id)) => self.close_room(room_id, reply).await,674
- Command::Room(RoomCommand::PrepareToSend(room_id)) => {675
- let flight = self.preparations.join(&client, room_id);676
- self.spawn_command("prepare to send", reply, flight);677
- }678
- Command::Room(RoomCommand::SetTyping { room_id, typing }) => {679
- if typing && self.config.prepare_to_send {680
- self.prepare_quietly(room_id.clone());681
- }682
- self.spawn_command("typing", reply, async move {683
- commands::set_typing(&client, &room_id, typing).await684
- });685
- }686
- Command::Room(RoomCommand::CancelSend {687
- room_id,688
- transaction_id,689
- }) => self.spawn_command("cancel send", reply, async move {690
- commands::cancel_send(&client, &room_id, &transaction_id).await691
- }),692
- Command::Room(RoomCommand::RetrySend {693
- room_id,694
- transaction_id,695
- }) => self.spawn_command("retry send", reply, async move {696
- commands::retry_send(&client, &room_id, &transaction_id).await697
- }),698
- Command::Room(command) => enqueue(699
- &self.mutations,700
- Job {701
- command: Mutation::Room(command),702
- reply,703
- },704
- ),705
- Command::Timeline(command) => self.route_timeline(command, reply),706
- Command::Media(MediaCommand::Upload(upload)) => self.upload(upload, reply),707
- Command::Encryption(command) => enqueue(&self.encryption, Job { command, reply }),708
- Command::Notification(command) => {709
- enqueue(&self.notifications, Job { command, reply });710
- }711
- }712
- None713
- }714
-715
- fn spawn_command(716
- &mut self,717
- label: &'static str,718
- reply: Responder,719
- work: impl Future<Output = Result<(), Error>> + SendOutsideWasm + 'static,720
- ) {721
- self.tasks.spawn(TaskContext::Command(label), async move {722
- reply.done(work.await);723
- });724
- }725
-726
- fn prepare_quietly(&mut self, room_id: OwnedRoomId) {727
- let flight = self.preparations.join(&self.client, room_id);728
- self.tasks729
- .spawn(TaskContext::Command("prepare to send"), async move {730
- let _ = flight.await;731
- });732
- }733
-734
- fn upload(&mut self, upload: crate::AttachmentUpload, reply: Responder) {735
- let Some(room) = self.client.get_room(upload.timeline_id.room_id()) else {736
- return reply.done(Err(Error::room_unknown()));737
- };738
- let max_bytes = self.config.max_media_bytes;739
- self.spawn_command("upload", reply, async move {740
- media::queue_upload(room, upload, max_bytes).await741
- });742
- }743
-744
- fn route_timeline(&mut self, command: TimelineCommand, reply: Responder) {745
- let sender = match self.rooms.get(command.room_id()) {746
- Some(RoomSlot::Active { sender, .. }) => Some(sender.clone()),747
- _ => None,748
- };749
- match (sender, command) {750
- (Some(sender), command) => send_actor(&sender, ActorRequest::Execute(command, reply)),751
- (752
- None,753
- TimelineCommand::MarkRead {754
- timeline_id,755
- event_id,756
- },757
- ) => {758
- let client = self.client.clone();759
- self.spawn_command("mark read", reply, async move {760
- commands::mark_read(&client, &timeline_id, event_id).await761
- });762
- }763
- (None, _) => reply.done(Err(Error::new(ErrorKind::RoomNotOpen, "room is not open"))),764
- }765
- }766
-767
- async fn open_room(&mut self, room_id: OwnedRoomId, reply: Responder) {768
- match self.rooms.get_mut(&room_id) {769
- Some(RoomSlot::Active { sender, .. }) => send_actor(sender, ActorRequest::Open(reply)),770
- Some(RoomSlot::Closing { pending, .. }) => {771
- defer(pending, Deferred::Open(reply), self.config.command_capacity);772
- }773
- None => self.start_actor(room_id, reply).await,774
- }775
- }776
-777
- async fn close_room(&mut self, room_id: OwnedRoomId, reply: Responder) {778
- let (generation, sender) = match self.rooms.get_mut(&room_id) {779
- None => return reply.done(Ok(())),780
- Some(RoomSlot::Closing { pending, .. }) => {781
- return defer(782
- pending,783
- Deferred::Close(reply),784
- self.config.command_capacity,785
- );786
- }787
- Some(RoomSlot::Active { generation, sender }) => (*generation, sender.clone()),788
- };789
- match sender.try_send(ActorRequest::Close(reply)) {790
- Ok(()) => {}791
- // the actor already left on its own, which is closed enough792
- Err(mpsc::error::TrySendError::Closed(request)) => request.into_reply().done(Ok(())),793
- Err(mpsc::error::TrySendError::Full(request)) => {794
- return request.into_reply().done(Err(Error::new(795
- ErrorKind::RoomBusy,796
- "room command queue is full",797
- )));798
- }799
- }800
- self.rooms.insert(801
- room_id,802
- RoomSlot::Closing {803
- generation,804
- pending: VecDeque::new(),805
- },806
- );807
- self.update_room_subscriptions();808
- }809
-810
- async fn start_actor(&mut self, room_id: OwnedRoomId, reply: Responder) {811
- let Some(room) = self.client.get_room(&room_id) else {812
- return reply.done(Err(Error::room_unknown()));813
- };814
- self.next_generation = self.next_generation.wrapping_add(1);815
- let generation = self.next_generation;816
- let (sender, requests) = mpsc::channel(self.config.command_capacity);817
- let (supervisor, panics) = Supervisor::new();818
- let actor = RoomActor {819
- room_id: room_id.clone(),820
- generation,821
- room,822
- requests,823
- notes: self.note_sender.clone(),824
- context: SessionContext {825
- events: self.port.event_sender(),826
- media: self.media.clone(),827
- current_user_id: self.client.user_id().map(ToOwned::to_owned),828
- initial_history_limit: self.config.initial_history_limit,829
- supervisor,830
- },831
- panics,832
- opening: vec![reply],833
- };834
- self.rooms835
- .insert(room_id.clone(), RoomSlot::Active { generation, sender });836
- self.tasks.spawn(837
- TaskContext::RoomActor {838
- room_id,839
- generation,840
- },841
- actor.run(),842
- );843
- self.update_room_subscriptions();844
- }845
-846
- async fn actor_note(&mut self, note: ActorNote) {847
- let (room_id, generation) = match ¬e {848
- ActorNote::Leaving {849
- room_id,850
- generation,851
- }852
- | ActorNote::Exited {853
- room_id,854
- generation,855
- ..856
- } => (room_id.clone(), *generation),857
- };858
- if self859
- .rooms860
- .get(&room_id)861
- .is_none_or(|slot| slot.generation() != generation)862
- {863
- return;864
- }865
- let ActorNote::Exited { leftovers, .. } = note else {866
- if let Some(slot @ RoomSlot::Active { .. }) = self.rooms.get_mut(&room_id) {867
- *slot = RoomSlot::Closing {868
- generation,869
- pending: VecDeque::new(),870
- };871
- self.update_room_subscriptions();872
- }873
- return;874
- };875
- let pending = match self.rooms.remove(&room_id) {876
- Some(RoomSlot::Closing { pending, .. }) => pending,877
- _ => VecDeque::new(),878
- };879
- // leftovers were sent before anything deferred, so they go first880
- for request in leftovers {881
- match request {882
- ActorRequest::Open(reply) => self.open_room(room_id.clone(), reply).await,883
- ActorRequest::Close(reply) => reply.done(Ok(())),884
- ActorRequest::Execute(command, reply) => self.route_timeline(command, reply),885
- }886
- }887
- for deferred in pending {888
- match deferred {889
- Deferred::Open(reply) => self.open_room(room_id.clone(), reply).await,890
- Deferred::Close(reply) => self.close_room(room_id.clone(), reply).await,891
- }892
- }893
- self.update_room_subscriptions();894
- }895
-896
- fn update_room_subscriptions(&self) {897
- let room_ids = self898
- .rooms899
- .iter()900
- .filter(|(_, slot)| matches!(slot, RoomSlot::Active { .. }))901
- .map(|(room_id, _)| room_id.clone())902
- .collect();903
- self.subscriptions.send_replace(room_ids);904
- }905
-906
- async fn shutdown(&mut self) {907
- if let Some(handler) = self.verification_handler.take() {908
- self.client.remove_event_handler(handler);909
- }910
- self.port.close();911
- self.tasks.shutdown().await;912
- self.rooms.clear();913
- self.media.clear().await;914
- self.sync.stop().await;915
- self.port916
- .connection_sender()917
- .send_replace(ConnectionState::Stopped);918
- let _ = self919
- .port920
- .event_sender()921
- .send(Event::ConnectionChanged(ConnectionState::Stopped))922
- .await;923
- }924
-}925
-926
-fn send_actor(sender: &mpsc::Sender<ActorRequest>, request: ActorRequest) {927
- if let Err(error) = sender.try_send(request) {928
- let (request, error) = match error {929
- mpsc::error::TrySendError::Full(request) => (930
- request,931
- Error::new(ErrorKind::RoomBusy, "room command queue is full"),932
- ),933
- mpsc::error::TrySendError::Closed(request) => (934
- request,935
- Error::new(ErrorKind::RoomNotOpen, "room is not open"),936
- ),937
- };938
- request.into_reply().done(Err(error));939
- }940
-}941
-942
-fn defer(pending: &mut VecDeque<Deferred>, deferred: Deferred, capacity: usize) {943
- if pending.len() < capacity {944
- return pending.push_back(deferred);945
- }946
- let (Deferred::Open(reply) | Deferred::Close(reply)) = deferred;947
- reply.done(Err(Error::new(948
- ErrorKind::RoomBusy,949
- "room lifecycle queue is full",950
- )));951
-}952
-953
-async fn settled(logouts: &mut watch::Receiver<Logouts>) {954
- let _ = logouts.wait_for(|logouts| logouts.in_flight == 0).await;955
-}956
-957
-fn enqueue<T>(sender: &mpsc::Sender<Job<T>>, job: Job<T>) {958
- if let Err(error) = sender.try_send(job) {959
- let (job, error) = match error {960
- mpsc::error::TrySendError::Full(job) => (961
- job,962
- Error::new(ErrorKind::QueueFull, "command queue is full"),963
- ),964
- mpsc::error::TrySendError::Closed(job) => (job, Error::interrupted()),965
- };966
- job.reply.done(Err(error));967
- }968
-}@@ -1,379 +0,0 @@
1
-use std::{2
- collections::{HashMap, HashSet, VecDeque},3
- sync::Arc,4
-};5
-6
-use matrix_sdk::{7
- Client, Room,8
- ruma::{9
- Int,10
- api::client::room::{11
- Visibility as SdkVisibility,12
- create_room::v3::{Request as CreateRoomRequest, RoomPreset as SdkPreset},13
- },14
- events::{15
- AnyGlobalAccountDataEventContent, GlobalAccountDataEventType, MessageLikeEventType,16
- StateEventType, room::power_levels::RoomPowerLevelsEventContent,17
- },18
- serde::Raw,19
- },20
-};21
-use ruma::{OwnedRoomId, OwnedUserId};22
-use tokio::sync::mpsc;23
-24
-use super::commands::{Job, guarded};25
-use super::executor::TaskSet;26
-use crate::{27
- CommandOutput, CreateRoomOptions, Error, ErrorKind, PowerLevelChanges, RoomCommand, RoomPreset,28
- RoomVisibility,29
-};30
-31
-/// state changes that must not race each other. same room, same alias, same dm32
-/// partner or same account data type run in order, everything else runs at once33
-pub(super) enum Mutation {34
- Room(RoomCommand),35
- GlobalAccountData {36
- event_type: GlobalAccountDataEventType,37
- content: Raw<AnyGlobalAccountDataEventContent>,38
- },39
-}40
-41
-pub(super) type MutationJob = Job<Mutation>;42
-43
-#[derive(Clone, Debug, Eq, Hash, PartialEq)]44
-enum Key {45
- Room(OwnedRoomId),46
- Alias(String),47
- Direct(OwnedUserId),48
- AccountData(String),49
- Independent(u64),50
-}51
-52
-pub(super) async fn run_scheduler(53
- client: Client,54
- mut jobs: mpsc::Receiver<MutationJob>,55
- queue_limit: usize,56
-) {57
- let fresh_dms = FreshDms::default();58
- let mut waiting = HashMap::<Key, VecDeque<MutationJob>>::new();59
- let mut running = HashSet::<Key>::new();60
- let mut tasks = TaskSet::<Key>::new();61
- let mut queued = 0_usize;62
- let mut independent = 0_u64;63
- let mut receiving = true;64
- while receiving || !tasks.is_empty() {65
- tokio::select! {66
- biased;67
- Some(finished) = tasks.join_next(), if !tasks.is_empty() => {68
- // a job's own panic is caught inside it, so this is only ever a key69
- let Ok(key) = finished else { continue };70
- let next = waiting.get_mut(&key).and_then(VecDeque::pop_front);71
- if waiting.get(&key).is_some_and(VecDeque::is_empty) {72
- waiting.remove(&key);73
- }74
- match next {75
- Some(job) => {76
- queued -= 1;77
- spawn(&mut tasks, &client, &fresh_dms, key, job);78
- }79
- None => {80
- running.remove(&key);81
- }82
- }83
- }84
- job = jobs.recv(), if receiving => {85
- let Some(job) = job else {86
- receiving = false;87
- continue;88
- };89
- let key = key(&job.command, client.user_id().map(ruma::UserId::server_name)).unwrap_or_else(|| {90
- independent += 1;91
- Key::Independent(independent)92
- });93
- if running.insert(key.clone()) {94
- spawn(&mut tasks, &client, &fresh_dms, key, job);95
- } else if queued < queue_limit {96
- waiting.entry(key).or_default().push_back(job);97
- queued += 1;98
- } else {99
- job.reply.done(Err(Error::new(100
- ErrorKind::QueueFull,101
- "room command queue is full",102
- )));103
- }104
- }105
- }106
- }107
-}108
-109
-/// `server` is ours, since a new alias lives there110
-fn key(mutation: &Mutation, server: Option<&ruma::ServerName>) -> Option<Key> {111
- let room_id = match mutation {112
- Mutation::GlobalAccountData { event_type, .. } => {113
- return Some(Key::AccountData(event_type.to_string()));114
- }115
- Mutation::Room(RoomCommand::Join { room, .. }) => {116
- return Some(match OwnedRoomId::try_from(room.clone()) {117
- Ok(room_id) => Key::Room(room_id),118
- Err(alias) => Key::Alias(alias.to_string()),119
- });120
- }121
- Mutation::Room(RoomCommand::CreateDirect(user_id)) => {122
- return Some(Key::Direct(user_id.clone()));123
- }124
- Mutation::Room(RoomCommand::Create(options)) => {125
- return match (&options.alias_localpart, options.invite.as_slice()) {126
- (Some(localpart), _) => Some(Key::Alias(format!("#{localpart}:{}", server?))),127
- (None, [user_id]) if options.direct => Some(Key::Direct(user_id.clone())),128
- _ => None,129
- };130
- }131
- Mutation::Room(132
- RoomCommand::Leave(room_id)133
- | RoomCommand::Forget(room_id)134
- | RoomCommand::AcceptInvite(room_id)135
- | RoomCommand::RejectInvite(room_id)136
- | RoomCommand::InviteUser { room_id, .. }137
- | RoomCommand::KickUser { room_id, .. }138
- | RoomCommand::BanUser { room_id, .. }139
- | RoomCommand::UnbanUser { room_id, .. }140
- | RoomCommand::SetPowerLevels { room_id, .. }141
- | RoomCommand::SetUserPowerLevel { room_id, .. }142
- | RoomCommand::SetAccountData { room_id, .. },143
- ) => room_id,144
- Mutation::Room(_) => return None,145
- };146
- Some(Key::Room(room_id.clone()))147
-}148
-149
-/// dms made here that the next sync hasn't told the sdk about yet, since `get_dm_room`150
-/// only knows `m.direct` once it comes back round151
-type FreshDms = Arc<std::sync::Mutex<HashMap<OwnedUserId, OwnedRoomId>>>;152
-153
-fn spawn(154
- tasks: &mut TaskSet<Key>,155
- client: &Client,156
- fresh_dms: &FreshDms,157
- key: Key,158
- job: MutationJob,159
-) {160
- let client = client.clone();161
- let fresh_dms = fresh_dms.clone();162
- tasks.spawn(async move {163
- let Job { command, reply } = job;164
- reply.send(guarded("room command", execute(&client, &fresh_dms, command)).await);165
- key166
- });167
-}168
-169
-async fn execute(170
- client: &Client,171
- fresh_dms: &FreshDms,172
- mutation: Mutation,173
-) -> Result<CommandOutput, Error> {174
- let command = match mutation {175
- Mutation::GlobalAccountData {176
- event_type,177
- content,178
- } => {179
- client180
- .account()181
- .set_account_data_raw(event_type, content)182
- .await183
- .map_err(|source| Error::from_sdk("writing account data failed", source))?;184
- return Ok(CommandOutput::Done);185
- }186
- Mutation::Room(command) => command,187
- };188
- let room = |room_id: &OwnedRoomId| client.get_room(room_id).ok_or_else(Error::room_unknown);189
- let joined = |room: Room| CommandOutput::Room(room.room_id().to_owned());190
- let sdk = |message: &'static str| move |source| Error::from_sdk(message, source);191
- match command {192
- RoomCommand::Join { room, via } => client193
- .join_room_by_id_or_alias(&room, &via)194
- .await195
- .map(joined)196
- .map_err(sdk("joining room failed")),197
- RoomCommand::Leave(room_id) => room(&room_id)?198
- .leave()199
- .await200
- .map(done)201
- .map_err(sdk("leaving room failed")),202
- RoomCommand::Forget(room_id) => room(&room_id)?203
- .forget()204
- .await205
- .map(done)206
- .map_err(sdk("forgetting room failed")),207
- RoomCommand::AcceptInvite(room_id) => room(&room_id)?208
- .join()209
- .await210
- .map(done)211
- .map_err(sdk("accepting invite failed")),212
- RoomCommand::RejectInvite(room_id) => room(&room_id)?213
- .leave()214
- .await215
- .map(done)216
- .map_err(sdk("rejecting invite failed")),217
- RoomCommand::InviteUser { room_id, user_id } => room(&room_id)?218
- .invite_user_by_id(&user_id)219
- .await220
- .map(done)221
- .map_err(sdk("inviting user failed")),222
- RoomCommand::KickUser {223
- room_id,224
- user_id,225
- reason,226
- } => room(&room_id)?227
- .kick_user(&user_id, reason.as_deref())228
- .await229
- .map(done)230
- .map_err(sdk("kicking user failed")),231
- RoomCommand::BanUser {232
- room_id,233
- user_id,234
- reason,235
- } => room(&room_id)?236
- .ban_user(&user_id, reason.as_deref())237
- .await238
- .map(done)239
- .map_err(sdk("banning user failed")),240
- RoomCommand::UnbanUser {241
- room_id,242
- user_id,243
- reason,244
- } => room(&room_id)?245
- .unban_user(&user_id, reason.as_deref())246
- .await247
- .map(done)248
- .map_err(sdk("unbanning user failed")),249
- RoomCommand::SetPowerLevels { room_id, changes } => {250
- apply_power_level_changes(&room(&room_id)?, changes)251
- .await252
- .map(done)253
- }254
- RoomCommand::SetUserPowerLevel {255
- room_id,256
- user_id,257
- level,258
- } => room(&room_id)?259
- .update_power_levels(vec![(&user_id, power_level(level)?)])260
- .await261
- .map(done)262
- .map_err(sdk("updating user power level failed")),263
- RoomCommand::SetAccountData {264
- room_id,265
- event_type,266
- content,267
- } => room(&room_id)?268
- .set_account_data_raw(event_type, content)269
- .await270
- .map(done)271
- .map_err(sdk("writing room account data failed")),272
- RoomCommand::Create(options) => client273
- .create_room(create_room_request(options))274
- .await275
- .map(joined)276
- .map_err(sdk("creating room failed")),277
- RoomCommand::CreateDirect(user_id) => {278
- if let Some(existing) = client.get_dm_room(&user_id) {279
- return Ok(joined(existing));280
- }281
- let fresh = fresh_dms282
- .lock()283
- .expect("nothing panics holding this")284
- .get(&user_id)285
- .cloned();286
- if let Some(room_id) = fresh.filter(|room_id| client.get_room(room_id).is_some()) {287
- return Ok(CommandOutput::Room(room_id));288
- }289
- let room = client290
- .create_dm(&user_id)291
- .await292
- .map_err(sdk("creating direct room failed"))?;293
- fresh_dms294
- .lock()295
- .expect("nothing panics holding this")296
- .insert(user_id, room.room_id().to_owned());297
- Ok(joined(room))298
- }299
- _ => Err(Error::new(ErrorKind::Other, "room command was misrouted")),300
- }301
-}302
-303
-/// one state event carrying every requested change, since the sdk's own patcher is private304
-async fn apply_power_level_changes(room: &Room, changes: PowerLevelChanges) -> Result<(), Error> {305
- let mut levels = room306
- .power_levels()307
- .await308
- .map_err(|source| Error::from_sdk("reading power levels failed", source.into()))?;309
- for (target, value) in [310
- (&mut levels.ban, changes.ban),311
- (&mut levels.invite, changes.invite),312
- (&mut levels.kick, changes.kick),313
- (&mut levels.redact, changes.redact),314
- (&mut levels.events_default, changes.events_default),315
- (&mut levels.state_default, changes.state_default),316
- (&mut levels.users_default, changes.users_default),317
- (&mut levels.notifications.room, changes.room_notification),318
- ] {319
- if let Some(value) = value {320
- *target = power_level(value)?;321
- }322
- }323
- for (event_type, value) in [324
- (StateEventType::RoomName.into(), changes.room_name),325
- (StateEventType::RoomAvatar.into(), changes.room_avatar),326
- (StateEventType::RoomTopic.into(), changes.room_topic),327
- (StateEventType::SpaceChild.into(), changes.space_child),328
- (MessageLikeEventType::Beacon.into(), changes.beacon),329
- (StateEventType::BeaconInfo.into(), changes.beacon_info),330
- ] {331
- if let Some(value) = value {332
- levels.events.insert(event_type, power_level(value)?);333
- }334
- }335
- let content = RoomPowerLevelsEventContent::try_from(levels).map_err(|source| {336
- Error::with_source(337
- ErrorKind::Invalid,338
- "normalising power levels failed",339
- source,340
- )341
- })?;342
- room.send_state_event(content)343
- .await344
- .map(drop)345
- .map_err(|source| Error::from_sdk("updating power levels failed", source))346
-}347
-348
-fn done<T>(_: T) -> CommandOutput {349
- CommandOutput::Done350
-}351
-352
-fn power_level(value: i64) -> Result<Int, Error> {353
- value.try_into().map_err(|source| {354
- Error::with_source(355
- ErrorKind::Invalid,356
- "power level is outside the Matrix integer range",357
- source,358
- )359
- })360
-}361
-362
-fn create_room_request(options: CreateRoomOptions) -> CreateRoomRequest {363
- let mut request = CreateRoomRequest::new();364
- request.name = options.name;365
- request.topic = options.topic;366
- request.room_alias_name = options.alias_localpart;367
- request.invite = options.invite;368
- request.is_direct = options.direct;369
- request.visibility = match options.visibility {370
- RoomVisibility::Private => SdkVisibility::Private,371
- RoomVisibility::Public => SdkVisibility::Public,372
- };373
- request.preset = options.preset.map(|preset| match preset {374
- RoomPreset::PrivateChat => SdkPreset::PrivateChat,375
- RoomPreset::TrustedPrivateChat => SdkPreset::TrustedPrivateChat,376
- RoomPreset::PublicChat => SdkPreset::PublicChat,377
- });378
- request379
-}@@ -1,43 +0,0 @@
1
-use std::collections::HashMap;2
-3
-use futures_util::{4
- FutureExt,5
- future::{Shared, WeakShared},6
-};7
-use matrix_sdk::Client;8
-use ruma::OwnedRoomId;9
-10
-use super::executor::EngineBoxFuture;11
-use crate::Error;12
-13
-type Flight = Shared<EngineBoxFuture<'static, Result<(), Error>>>;14
-15
-/// one warm-up per room at a time, later asks join the one in flight16
-#[derive(Default)]17
-pub(super) struct Preparations(18
- HashMap<OwnedRoomId, WeakShared<EngineBoxFuture<'static, Result<(), Error>>>>,19
-);20
-21
-impl Preparations {22
- pub(super) fn join(&mut self, client: &Client, room_id: OwnedRoomId) -> Flight {23
- self.0.retain(|_, flight| flight.upgrade().is_some());24
- if let Some(flight) = self.0.get(&room_id).and_then(WeakShared::upgrade) {25
- return flight;26
- }27
- let work: EngineBoxFuture<'static, _> = Box::pin(prepare(client.clone(), room_id.clone()));28
- let flight = work.shared();29
- if let Some(weak) = flight.downgrade() {30
- self.0.insert(room_id, weak);31
- }32
- flight33
- }34
-}35
-36
-/// 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)38
-async fn prepare(client: Client, room_id: OwnedRoomId) -> Result<(), Error> {39
- let room = client.get_room(&room_id).ok_or_else(Error::room_unknown)?;40
- room.prepare_encryption()41
- .await42
- .map_err(|source| Error::from_sdk("preparing room encryption failed", source))43
-}@@ -1,459 +0,0 @@
1
-use std::collections::{HashMap, VecDeque};2
-3
-use matrix_sdk::{Room, RoomState};4
-use matrix_sdk_ui::timeline::{RoomExt, Timeline, TimelineFocus, TimelineReadReceiptTracking};5
-use ruma::{OwnedEventId, OwnedRoomId};6
-use tokio::sync::mpsc;7
-8
-use super::executor::{AbortHandle, JoinError, TaskSet, is_panic, resume_unwind};9
-use super::timeline::{SessionContext, TaskGuard, TimelineSession};10
-use crate::{11
- Error, ErrorKind, Event, TimelineCommand, TimelineId, TypingState, transport::Responder,12
-};13
-14
-pub(super) enum ActorRequest {15
- /// opening an open room answers with a fresh reset16
- Open(Responder),17
- Execute(TimelineCommand, Responder),18
- Close(Responder),19
-}20
-21
-impl ActorRequest {22
- pub(super) fn into_reply(self) -> Responder {23
- match self {24
- Self::Open(reply) | Self::Execute(_, reply) | Self::Close(reply) => reply,25
- }26
- }27
-}28
-29
-pub(super) enum ActorNote {30
- /// stopping on its own, after a leave or a kick. anything sent its way now comes back in `Exited`31
- Leaving {32
- room_id: OwnedRoomId,33
- generation: u64,34
- },35
- Exited {36
- room_id: OwnedRoomId,37
- generation: u64,38
- /// requests that arrived too late to be served, for the engine to answer or replay39
- leftovers: Vec<ActorRequest>,40
- },41
-}42
-43
-pub(super) struct RoomActor {44
- pub(super) room_id: OwnedRoomId,45
- pub(super) generation: u64,46
- pub(super) room: Room,47
- pub(super) requests: mpsc::Receiver<ActorRequest>,48
- pub(super) notes: mpsc::UnboundedSender<ActorNote>,49
- pub(super) context: SessionContext,50
- pub(super) panics: mpsc::UnboundedReceiver<&'static str>,51
- pub(super) opening: Vec<Responder>,52
-}53
-54
-impl RoomActor {55
- pub(super) async fn run(mut self) {56
- self.serve().await;57
- self.requests.close();58
- let mut leftovers = Vec::new();59
- while let Ok(request) = self.requests.try_recv() {60
- leftovers.push(request);61
- }62
- let _ = self.notes.send(ActorNote::Exited {63
- room_id: self.room_id.clone(),64
- generation: self.generation,65
- leftovers,66
- });67
- }68
-69
- async fn serve(&mut self) {70
- let timeline = match build_timeline(71
- &self.room,72
- TimelineFocus::Live {73
- hide_threaded_events: true,74
- },75
- )76
- .await77
- {78
- Ok(timeline) => timeline,79
- Err(error) => return self.refuse_everything(error).await,80
- };81
- let Ok(room_timeline) = TimelineSession::start(82
- TimelineId::Room(self.room_id.clone()),83
- timeline,84
- self.context.clone(),85
- )86
- .await87
- else {88
- return;89
- };90
- for reply in self.opening.drain(..) {91
- reply.done(Ok(()));92
- }93
- let (typing_guard, typing) = self.room.subscribe_to_typing_notifications();94
- let mut typing_watcher = TaskGuard(self.context.supervisor.spawn(95
- "typing watcher",96
- watch_typing(self.room_id.clone(), typing, self.context.events.clone()),97
- ));98
- let mut timelines = Timelines {99
- room: room_timeline,100
- threads: Threads::new(self.requests.max_capacity()),101
- work: TaskSet::new(),102
- };103
- let close = self.serve_open(&mut timelines).await;104
- if close.is_none() {105
- self.requests.close();106
- let _ = self.notes.send(ActorNote::Leaving {107
- room_id: self.room_id.clone(),108
- generation: self.generation,109
- });110
- }111
-112
- // let redactions and receipts that already left finish rather than lie about them113
- while timelines.work.join_next().await.is_some() {}114
- drop(typing_guard);115
- typing_watcher.0.abort();116
- let _ = (&mut typing_watcher.0).await;117
- let _ = self118
- .context119
- .events120
- .send(Event::TypingChanged(TypingState {121
- room_id: self.room_id.clone(),122
- users: Vec::new(),123
- }))124
- .await;125
- timelines.threads.close().await;126
- timelines.room.close().await;127
- if let Some(reply) = close {128
- reply.done(Ok(()));129
- }130
- }131
-132
- /// returns the close request's responder, if a close is why we stopped133
- async fn serve_open(&mut self, timelines: &mut Timelines) -> Option<Responder> {134
- let mut room_info = self.room.subscribe_info();135
- // a leave that landed while the timeline was building won't come round again136
- if matches!(self.room.state(), RoomState::Left | RoomState::Banned) {137
- return None;138
- }139
- loop {140
- tokio::select! {141
- Some(info) = room_info.next() => {142
- if matches!(info.state(), RoomState::Left | RoomState::Banned) {143
- tracing::debug!(room_id = %self.room_id, "room actor stopped because we left the room");144
- return None;145
- }146
- }147
- request = self.requests.recv() => match request {148
- None => return None,149
- Some(ActorRequest::Close(reply)) => return Some(reply),150
- Some(ActorRequest::Open(reply)) => reply.done(timelines.room.resync().await),151
- Some(ActorRequest::Execute(command, reply)) => {152
- timelines.execute(command, reply, &self.room).await;153
- }154
- },155
- Some(built) = timelines.threads.builds.join_next(), if !timelines.threads.builds.is_empty() => {156
- timelines.thread_built(built, &self.room_id, &self.context).await;157
- }158
- Some(label) = self.panics.recv() => panic!("{label} panicked in room {}", self.room_id),159
- Some(done) = timelines.work.join_next(), if !timelines.work.is_empty() => {160
- if let Err(error) = done && is_panic(&error) {161
- tracing::warn!(room_id = %self.room_id, %error, "timeline operation panicked");162
- }163
- }164
- }165
- }166
- }167
-168
- async fn refuse_everything(&mut self, error: matrix_sdk_ui::timeline::Error) {169
- tracing::warn!(%error, room_id = %self.room_id, "room actor could not build its timeline");170
- let error = Error::with_source(ErrorKind::Timeline, "room timeline build failed", error);171
- for reply in self.opening.drain(..) {172
- reply.done(Err(error.clone()));173
- }174
- self.requests.close();175
- while let Some(request) = self.requests.recv().await {176
- request.into_reply().done(Err(error.clone()));177
- }178
- }179
-}180
-181
-async fn build_timeline(182
- room: &Room,183
- focus: TimelineFocus,184
-) -> Result<Timeline, matrix_sdk_ui::timeline::Error> {185
- room.timeline_builder()186
- .with_focus(focus)187
- .track_read_marker_and_receipts(TimelineReadReceiptTracking::AllEvents)188
- .build()189
- .await190
-}191
-192
-async fn watch_typing(193
- room_id: OwnedRoomId,194
- mut typing: tokio::sync::broadcast::Receiver<Vec<ruma::OwnedUserId>>,195
- events: crate::transport::EventSender,196
-) {197
- loop {198
- match typing.recv().await {199
- Ok(users) => {200
- let state = TypingState {201
- room_id: room_id.clone(),202
- users,203
- };204
- if events.send(Event::TypingChanged(state)).await.is_err() {205
- return;206
- }207
- }208
- Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {209
- tracing::warn!(%room_id, skipped, "typing watcher lagged");210
- }211
- Err(tokio::sync::broadcast::error::RecvError::Closed) => return,212
- }213
- }214
-}215
-216
-struct Timelines {217
- room: TimelineSession,218
- threads: Threads,219
- work: TaskSet<()>,220
-}221
-222
-impl Timelines {223
- async fn execute(&mut self, command: TimelineCommand, reply: Responder, room: &Room) {224
- match command {225
- TimelineCommand::OpenThread { root_event_id, .. } => {226
- self.threads.open(root_event_id, reply, room);227
- }228
- TimelineCommand::CloseThread { root_event_id, .. } => {229
- reply.done(self.threads.close_one(&root_event_id).await);230
- }231
- TimelineCommand::Resync {232
- timeline_id: TimelineId::Room(_),233
- } => reply.done(self.room.resync().await),234
- command => {235
- let root = command236
- .timeline_id()237
- .and_then(TimelineId::thread_root)238
- .cloned();239
- let Some(root) = root else {240
- return self.room.execute(command, reply, &mut self.work).await;241
- };242
- if let TimelineCommand::MarkRead {243
- timeline_id,244
- event_id,245
- } = &command246
- && !self.threads.open.contains_key(&root)247
- {248
- let client = room.client();249
- let (timeline_id, event_id) = (timeline_id.clone(), event_id.clone());250
- self.work.spawn(async move {251
- reply.done(252
- super::commands::mark_read(&client, &timeline_id, event_id).await,253
- );254
- });255
- return;256
- }257
- match self.threads.route(root, command, reply) {258
- Routed::Open(session, TimelineCommand::Resync { .. }, reply) => {259
- reply.done(session.resync().await);260
- }261
- Routed::Open(session, command, reply) => {262
- session.execute(command, reply, &mut self.work).await;263
- }264
- Routed::Queued => {}265
- }266
- }267
- }268
- }269
-270
- async fn thread_built(271
- &mut self,272
- built: Result<Built, JoinError>,273
- room_id: &OwnedRoomId,274
- context: &SessionContext,275
- ) {276
- let Some((root, queued, timeline)) = self.threads.built(built) else {277
- return;278
- };279
- let timeline = match timeline {280
- Ok(timeline) => timeline,281
- Err(error) => {282
- tracing::warn!(%error, %room_id, %root, "thread timeline build failed");283
- let error =284
- Error::with_source(ErrorKind::Timeline, "thread timeline build failed", error);285
- for (_, reply) in queued {286
- reply.done(Err(error.clone()));287
- }288
- return;289
- }290
- };291
- let id = TimelineId::thread(room_id.clone(), root.clone());292
- let Ok(session) = TimelineSession::start(id, timeline, context.clone()).await else {293
- return;294
- };295
- let session = self296
- .threads297
- .open298
- .entry(root)299
- .insert_entry(session)300
- .into_mut();301
- for (command, reply) in queued {302
- match command {303
- None => reply.done(Ok(())),304
- Some(TimelineCommand::Resync { .. }) => reply.done(session.resync().await),305
- Some(command) => session.execute(command, reply, &mut self.work).await,306
- }307
- }308
- }309
-}310
-311
-type Built = (312
- OwnedEventId,313
- u64,314
- Result<Timeline, matrix_sdk_ui::timeline::Error>,315
-);316
-317
-/// `None` is the open itself, everything else waits behind it318
-type Queued = VecDeque<(Option<TimelineCommand>, Responder)>;319
-320
-enum Routed<'a> {321
- Open(&'a mut TimelineSession, TimelineCommand, Responder),322
- Queued,323
-}324
-325
-struct OpeningThread {326
- generation: u64,327
- build: AbortHandle,328
- queued: Queued,329
-}330
-331
-struct Threads {332
- open: HashMap<OwnedEventId, TimelineSession>,333
- opening: HashMap<OwnedEventId, OpeningThread>,334
- builds: TaskSet<Built>,335
- next_generation: u64,336
- queued: usize,337
- capacity: usize,338
-}339
-340
-impl Threads {341
- fn new(capacity: usize) -> Self {342
- Self {343
- open: HashMap::new(),344
- opening: HashMap::new(),345
- builds: TaskSet::new(),346
- next_generation: 0,347
- queued: 0,348
- capacity,349
- }350
- }351
-352
- fn enqueue(&mut self, root: &OwnedEventId, command: Option<TimelineCommand>, reply: Responder) {353
- if self.queued >= self.capacity {354
- return reply.done(Err(Error::new(355
- ErrorKind::QueueFull,356
- "thread command queue is full",357
- )));358
- }359
- let Some(opening) = self.opening.get_mut(root) else {360
- return reply.done(Err(Error::new(361
- ErrorKind::TimelineNotOpen,362
- "thread timeline is not open",363
- )));364
- };365
- opening.queued.push_back((command, reply));366
- self.queued += 1;367
- }368
-369
- fn open(&mut self, root: OwnedEventId, reply: Responder, room: &Room) {370
- if self.open.contains_key(&root) {371
- return reply.done(Ok(()));372
- }373
- if !self.opening.contains_key(&root) {374
- let generation = self.next_generation;375
- self.next_generation = self.next_generation.wrapping_add(1);376
- let room = room.clone();377
- let build_root = root.clone();378
- let build = self.builds.spawn(async move {379
- let focus = TimelineFocus::Thread {380
- root_event_id: build_root.clone(),381
- };382
- (build_root, generation, build_timeline(&room, focus).await)383
- });384
- self.opening.insert(385
- root.clone(),386
- OpeningThread {387
- generation,388
- build,389
- queued: VecDeque::new(),390
- },391
- );392
- }393
- self.enqueue(&root, None, reply);394
- }395
-396
- fn route(397
- &mut self,398
- root: OwnedEventId,399
- command: TimelineCommand,400
- reply: Responder,401
- ) -> Routed<'_> {402
- if !self.open.contains_key(&root) {403
- self.enqueue(&root, Some(command), reply);404
- return Routed::Queued;405
- }406
- let session = self.open.get_mut(&root).expect("thread was just checked");407
- Routed::Open(session, command, reply)408
- }409
-410
- /// `None` when the build belongs to a thread that was closed while it ran411
- fn built(412
- &mut self,413
- built: Result<Built, JoinError>,414
- ) -> Option<(415
- OwnedEventId,416
- Queued,417
- Result<Timeline, matrix_sdk_ui::timeline::Error>,418
- )> {419
- let (root, generation, timeline) = match built {420
- Ok(built) => built,421
- Err(error) if error.is_cancelled() => return None,422
- Err(error) => resume_unwind(error),423
- };424
- if self425
- .opening426
- .get(&root)427
- .is_none_or(|opening| opening.generation != generation)428
- {429
- return None;430
- }431
- let opening = self.opening.remove(&root)?;432
- self.queued -= opening.queued.len();433
- Some((root, opening.queued, timeline))434
- }435
-436
- async fn close_one(&mut self, root: &OwnedEventId) -> Result<(), Error> {437
- if let Some(session) = self.open.remove(root) {438
- session.close().await;439
- return Ok(());440
- }441
- let Some(opening) = self.opening.remove(root) else {442
- return Err(Error::new(443
- ErrorKind::TimelineNotOpen,444
- "thread timeline is not open",445
- ));446
- };447
- opening.build.abort();448
- self.queued -= opening.queued.len();449
- Ok(())450
- }451
-452
- async fn close(&mut self) {453
- self.builds.shutdown().await;454
- self.opening.clear();455
- for (_, session) in self.open.drain() {456
- session.close().await;457
- }458
- }459
-}@@ -1,132 +0,0 @@
1
-use std::sync::Arc;2
-3
-use matrix_sdk::{BoxFuture, Client, SessionChange};4
-use ruma::OwnedDeviceId;5
-use tokio::sync::{6
- broadcast::{self, error::RecvError},7
- mpsc, watch,8
-};9
-10
-use super::Logouts;11
-use crate::{Error, Event, Termination, transport::EventSender};12
-13
-#[cfg(not(target_family = "wasm"))]14
-pub(crate) type SaveRecord = dyn Fn(Client) -> BoxFuture<'static, Result<(), Error>> + Send + Sync;15
-#[cfg(target_family = "wasm")]16
-pub(crate) type SaveRecord = dyn Fn(Client) -> BoxFuture<'static, Result<(), Error>>;17
-18
-/// how the engine keeps the saved record current, handed over by `Session::build`19
-pub(crate) struct Persistence {20
- pub(crate) save: Box<SaveRecord>,21
- /// what the sdk's own save callback made of each refresh, when the store could write22
- /// there and then23
- pub(crate) inline: mpsc::UnboundedReceiver<Result<(), Error>>,24
-}25
-26
-impl Persistence {27
- async fn persist(&mut self, client: &Client) -> Result<(), Error> {28
- let mut inline = None;29
- while let Ok(result) = self.inline.try_recv() {30
- inline = Some(result);31
- }32
- if let Some(Ok(())) = inline {33
- return Ok(());34
- }35
- // an inline save for a newer refresh can land while this one writes, so go again36
- // until what's on disk is what the client holds37
- loop {38
- let tokens = client.session_tokens();39
- (self.save)(client.clone()).await?;40
- if client.session_tokens() == tokens {41
- return Ok(());42
- }43
- }44
- }45
-}46
-47
-pub(super) enum Notice {48
- Event(Event),49
- Rejected(Option<OwnedDeviceId>),50
-}51
-52
-/// saves and notices are split, so a full event stream never holds a save back53
-pub(super) async fn session(54
- client: Client,55
- mut changes: broadcast::Receiver<SessionChange>,56
- mut persistence: Option<Persistence>,57
- notices: mpsc::UnboundedSender<Notice>,58
- device_id: OwnedDeviceId,59
- logouts: Arc<watch::Sender<Logouts>>,60
-) {61
- loop {62
- let event = match changes.recv().await {63
- // a missed refresh costs one needless save, a missed rejection comes round again64
- Ok(SessionChange::TokensRefreshed) | Err(RecvError::Lagged(_)) => {65
- match persistence.as_mut() {66
- None => Event::SessionTokensChanged,67
- Some(persistence) => match persistence.persist(&client).await {68
- Ok(()) => Event::SessionTokensChanged,69
- Err(error) => Event::SessionSaveFailed {70
- message: error.message,71
- },72
- },73
- }74
- }75
- Ok(SessionChange::UnknownToken(data)) => {76
- let soft_logout = data.soft_logout.then_some(device_id);77
- let mut held = false;78
- // our own logout revokes the token, so a rejection meanwhile is its doing79
- logouts.send_if_modified(|logouts| {80
- held = logouts.in_flight > 0;81
- if held {82
- logouts.rejected = Some(soft_logout.clone());83
- }84
- held85
- });86
- if !held {87
- let _ = notices.send(Notice::Rejected(soft_logout));88
- }89
- return;90
- }91
- Err(RecvError::Closed) => return,92
- };93
- if notices.send(Notice::Event(event)).is_err() {94
- return;95
- }96
- }97
-}98
-99
-pub(super) async fn session_notices(100
- mut notices: mpsc::UnboundedReceiver<Notice>,101
- events: EventSender,102
- stop: mpsc::UnboundedSender<Termination>,103
-) {104
- while let Some(notice) = notices.recv().await {105
- match notice {106
- Notice::Event(event) => {107
- if events.send(event).await.is_err() {108
- return;109
- }110
- }111
- Notice::Rejected(soft_logout) => {112
- return reject_session(&events, &stop, soft_logout).await;113
- }114
- }115
- }116
-}117
-118
-pub(super) async fn reject_session(119
- events: &EventSender,120
- stop: &mpsc::UnboundedSender<Termination>,121
- soft_logout: Option<OwnedDeviceId>,122
-) {123
- let _ = events124
- .send(Event::SessionAccessRejected {125
- soft_logout: soft_logout.clone(),126
- })127
- .await;128
- let _ = stop.send(match soft_logout {129
- Some(device_id) => Termination::SoftLoggedOut { device_id },130
- None => Termination::AccessRevoked,131
- });132
-}@@ -1,468 +0,0 @@
1
-use std::{collections::HashSet, future::Future, panic::AssertUnwindSafe, sync::Arc};2
-3
-use futures_util::{FutureExt, StreamExt};4
-use matrix_sdk::SendOutsideWasm;5
-use matrix_sdk::{6
- room::edit::EditedContent,7
- room::reply::{EnforceThread, Reply as SdkReply},8
- ruma::{9
- api::client::receipt::create_receipt::v3::ReceiptType,10
- events::{11
- Mentions,12
- room::message::{13
- AddMentions, Relation, ReplyWithinThread, RoomMessageEventContentWithoutRelation,14
- },15
- },16
- },17
-};18
-use matrix_sdk_ui::timeline::{Timeline, TimelineEventItemId};19
-use ruma::{OwnedEventId, OwnedUserId};20
-use tokio::sync::mpsc;21
-22
-use super::executor::{EngineBoxFuture, JoinHandle, TaskSet, spawn};23
-use crate::{24
- Error, ErrorKind, Event, EventRef, HistoryState, MessageContent, MessageEditContent,25
- MessageMentions, RichText, TimelineCommand, TimelineId,26
- engine::MediaService,27
- normalise::{MediaScope, normalise_timeline, normalise_timeline_diffs},28
- transport::{EventSender, Responder, SendEventError},29
-};30
-31
-const PAGINATION_QUEUE: usize = 32;32
-33
-/// long-lived tasks inside a room actor report panics here so the actor can die loudly34
-#[derive(Clone)]35
-pub(super) struct Supervisor(mpsc::UnboundedSender<&'static str>);36
-37
-impl Supervisor {38
- pub(super) fn new() -> (Self, mpsc::UnboundedReceiver<&'static str>) {39
- let (sender, receiver) = mpsc::unbounded_channel();40
- (Self(sender), receiver)41
- }42
-43
- pub(super) fn spawn(44
- &self,45
- label: &'static str,46
- task: impl Future<Output = ()> + SendOutsideWasm + 'static,47
- ) -> JoinHandle<()> {48
- let panics = self.0.clone();49
- spawn(async move {50
- if AssertUnwindSafe(task).catch_unwind().await.is_err() {51
- let _ = panics.send(label);52
- }53
- })54
- }55
-}56
-57
-/// aborts its task when dropped, so an actor that dies mid-flight takes its helpers with it58
-pub(super) struct TaskGuard(pub(super) JoinHandle<()>);59
-60
-impl Drop for TaskGuard {61
- fn drop(&mut self) {62
- self.0.abort();63
- }64
-}65
-66
-/// what a timeline session needs from the room that owns it67
-#[derive(Clone)]68
-pub(super) struct SessionContext {69
- pub(super) events: EventSender,70
- pub(super) media: Arc<MediaService>,71
- pub(super) current_user_id: Option<OwnedUserId>,72
- pub(super) initial_history_limit: u16,73
- pub(super) supervisor: Supervisor,74
-}75
-76
-pub(super) struct TimelineSession {77
- pub(super) id: TimelineId,78
- timeline: Arc<Timeline>,79
- context: SessionContext,80
- scope: MediaScope,81
- watcher: JoinHandle<()>,82
- pagination: mpsc::Sender<PaginationRequest>,83
- pagination_worker: JoinHandle<()>,84
-}85
-86
-struct PaginationRequest {87
- limit: u16,88
- reply: Option<Responder>,89
-}90
-91
-impl TimelineSession {92
- pub(super) async fn start(93
- id: TimelineId,94
- timeline: Timeline,95
- context: SessionContext,96
- ) -> Result<Self, SendEventError> {97
- let timeline = Arc::new(timeline);98
- let scope = MediaScope::new();99
- let watcher = start_watcher(&id, &timeline, &context, &scope).await?;100
- let (pagination, requests) = mpsc::channel(PAGINATION_QUEUE);101
- let pagination_worker = context.supervisor.spawn(102
- "pagination worker",103
- run_pagination(104
- requests,105
- timeline.clone(),106
- context.events.clone(),107
- id.clone(),108
- ),109
- );110
- let session = Self {111
- id,112
- timeline,113
- scope,114
- watcher,115
- pagination,116
- pagination_worker,117
- context,118
- };119
- if session.context.initial_history_limit > 0 {120
- session.paginate(session.context.initial_history_limit, None);121
- }122
- Ok(session)123
- }124
-125
- /// swaps in a fresh watcher. the old one is dead before the reset goes out,126
- /// so no stale diff can land after it127
- pub(super) async fn resync(&mut self) -> Result<(), Error> {128
- self.watcher.abort();129
- let _ = (&mut self.watcher).await;130
- self.watcher = start_watcher(&self.id, &self.timeline, &self.context, &self.scope)131
- .await132
- .map_err(|_| Error::new(ErrorKind::Timeline, "timeline reset could not be sent"))?;133
- Ok(())134
- }135
-136
- pub(super) fn paginate(&self, limit: u16, reply: Option<Responder>) {137
- if let Err(error) = self.pagination.try_send(PaginationRequest { limit, reply }) {138
- let (request, error) = match error {139
- mpsc::error::TrySendError::Full(request) => (140
- request,141
- Error::new(ErrorKind::QueueFull, "pagination queue is full"),142
- ),143
- mpsc::error::TrySendError::Closed(request) => (144
- request,145
- Error::new(ErrorKind::TimelineNotOpen, "timeline is closing"),146
- ),147
- };148
- if let Some(reply) = request.reply {149
- reply.done(Err(error));150
- }151
- }152
- }153
-154
- /// pending paginations answer `Interrupted` as their responders drop155
- pub(super) async fn close(mut self) {156
- self.pagination_worker.abort();157
- let _ = (&mut self.pagination_worker).await;158
- self.watcher.abort();159
- let _ = (&mut self.watcher).await;160
- // after the watcher, or a last diff could register media for a timeline that's gone161
- self.context.media.release(&self.scope).await;162
- let _ = self163
- .context164
- .events165
- .send(Event::TimelineClosed {166
- timeline_id: self.id.clone(),167
- })168
- .await;169
- }170
-171
- pub(super) async fn execute(172
- &self,173
- command: TimelineCommand,174
- reply: Responder,175
- work: &mut TaskSet<()>,176
- ) {177
- let timeline = self.timeline.clone();178
- let thread_root = self.id.thread_root().cloned();179
- let background: EngineBoxFuture<'static, Result<(), Error>> = match command {180
- TimelineCommand::Paginate { limit, .. } => return self.paginate(limit, Some(reply)),181
- // network-bound, so it runs beside the actor instead of queueing the room behind it182
- TimelineCommand::Redact { target, reason, .. } => Box::pin(async move {183
- timeline184
- .redact(&item_id(target), reason.as_deref())185
- .await186
- .map_err(timeline_error)187
- }),188
- TimelineCommand::ToggleReaction {189
- target,190
- key,191
- shortcode,192
- ..193
- } => Box::pin(async move {194
- timeline195
- .toggle_reaction_with_shortcode(&item_id(target), &key, shortcode.as_deref())196
- .await197
- .map(drop)198
- .map_err(timeline_error)199
- }),200
- TimelineCommand::MarkRead { event_id, .. } => Box::pin(async move {201
- let receipt_failed =202
- |source| Error::from_sdk("sending read receipt failed", source);203
- timeline204
- .send_single_receipt(ReceiptType::Read, event_id.clone())205
- .await206
- .map_err(receipt_failed)?;207
- if thread_root.is_none() {208
- timeline209
- .send_single_receipt(ReceiptType::FullyRead, event_id)210
- .await211
- .map_err(receipt_failed)?;212
- }213
- Ok(())214
- }),215
- command => return reply.done(self.execute_serial(command).await),216
- };217
- work.spawn(async move { reply.done(background.await) });218
- }219
-220
- /// sends stay on the actor so they hit the send queue in the order they were asked for221
- async fn execute_serial(&self, command: TimelineCommand) -> Result<(), Error> {222
- let timeline = &self.timeline;223
- match command {224
- TimelineCommand::Send { content, .. } => {225
- let body = match &content {226
- MessageContent::Text { content, .. }227
- | MessageContent::Emote { content, .. }228
- | MessageContent::Reply { content, .. } => &content.body,229
- };230
- if body.trim().is_empty() {231
- return Err(Error::new(ErrorKind::EmptyBody, "message body is empty"));232
- }233
- let content = match content {234
- MessageContent::Text { content, mentions } => {235
- message_content(content, mentions, false).with_relation(None)236
- }237
- MessageContent::Emote { content, mentions } => {238
- message_content(content, mentions, true).with_relation(None)239
- }240
- MessageContent::Reply {241
- event_id,242
- content,243
- mentions,244
- notify_replied_to,245
- } => {246
- self.reply_content(event_id, content, mentions, notify_replied_to)247
- .await?248
- }249
- };250
- timeline251
- .send(content.into())252
- .await253
- .map(drop)254
- .map_err(timeline_error)255
- }256
- TimelineCommand::Edit {257
- target, content, ..258
- } => {259
- let (content, mentions, emote) = match content {260
- MessageEditContent::Text { content, mentions } => (content, mentions, false),261
- MessageEditContent::Emote { content, mentions } => (content, mentions, true),262
- };263
- if content.body.trim().is_empty() {264
- return Err(Error::new(ErrorKind::EmptyBody, "message body is empty"));265
- }266
- timeline267
- .edit(268
- &item_id(target),269
- EditedContent::RoomMessage(message_content(content, mentions, emote)),270
- )271
- .await272
- .map_err(timeline_error)273
- }274
- _ => Err(Error::new(275
- ErrorKind::Other,276
- "timeline command was misrouted",277
- )),278
- }279
- }280
-281
- async fn reply_content(282
- &self,283
- event_id: OwnedEventId,284
- content: RichText,285
- mentions: MessageMentions,286
- notify_replied_to: bool,287
- ) -> Result<matrix_sdk::ruma::events::room::message::RoomMessageEventContent, Error> {288
- let thread_root = self.id.thread_root();289
- let content = self290
- .timeline291
- .room()292
- .make_reply_event(293
- message_content(content, mentions, false),294
- SdkReply {295
- event_id,296
- enforce_thread: if thread_root.is_some() {297
- EnforceThread::Threaded(ReplyWithinThread::Yes)298
- } else {299
- EnforceThread::MaybeThreaded300
- },301
- add_mentions: if notify_replied_to {302
- AddMentions::Yes303
- } else {304
- AddMentions::No305
- },306
- },307
- )308
- .await309
- .map_err(|source| {310
- Error::with_source(ErrorKind::Timeline, "reply operation failed", source)311
- })?;312
- if let Some(thread_root) = thread_root313
- && !matches!(314
- content.relates_to.as_ref(),315
- Some(Relation::Thread(thread)) if &thread.event_id == thread_root316
- )317
- {318
- return Err(Error::new(319
- ErrorKind::Invalid,320
- "reply target does not belong to the selected thread",321
- ));322
- }323
- Ok(content)324
- }325
-}326
-327
-impl Drop for TimelineSession {328
- fn drop(&mut self) {329
- self.watcher.abort();330
- self.pagination_worker.abort();331
- }332
-}333
-334
-fn item_id(target: EventRef) -> TimelineEventItemId {335
- match target {336
- EventRef::Remote(event_id) => TimelineEventItemId::EventId(event_id),337
- EventRef::Local(transaction_id) => TimelineEventItemId::TransactionId(transaction_id),338
- }339
-}340
-341
-/// the sdk leaves a reply's target unloaded until someone asks, so the watcher asks, once per event342
-struct ReplyFetcher {343
- timeline: Arc<Timeline>,344
- asked: HashSet<OwnedEventId>,345
- fetches: TaskSet<()>,346
-}347
-348
-impl ReplyFetcher {349
- fn fetch(&mut self, missing: Vec<OwnedEventId>) {350
- for event_id in missing {351
- if self.asked.insert(event_id.clone()) {352
- let timeline = self.timeline.clone();353
- self.fetches.spawn(async move {354
- if let Err(error) = timeline.fetch_details_for_event(&event_id).await {355
- tracing::debug!(%event_id, %error, "fetching reply details failed");356
- }357
- });358
- }359
- }360
- }361
-}362
-363
-async fn start_watcher(364
- id: &TimelineId,365
- timeline: &Arc<Timeline>,366
- context: &SessionContext,367
- scope: &MediaScope,368
-) -> Result<JoinHandle<()>, SendEventError> {369
- let (items, mut stream) = timeline.subscribe().await;370
- let current_user_id = context.current_user_id.clone();371
- let normalised = normalise_timeline(items.iter(), current_user_id.as_deref(), scope);372
- context.media.register(normalised.media).await;373
- context374
- .events375
- .send(Event::TimelineReset {376
- timeline_id: id.clone(),377
- items: normalised.items,378
- })379
- .await?;380
- let mut replies = ReplyFetcher {381
- timeline: timeline.clone(),382
- asked: HashSet::new(),383
- fetches: TaskSet::new(),384
- };385
- replies.fetch(normalised.missing_replies);386
- let id = id.clone();387
- let events = context.events.clone();388
- let media = context.media.clone();389
- let scope = scope.clone();390
- Ok(context.supervisor.spawn("timeline watcher", async move {391
- loop {392
- let diffs = tokio::select! {393
- diffs = stream.next() => match diffs {394
- Some(diffs) => diffs,395
- None => return,396
- },397
- Some(_) = replies.fetches.join_next(), if !replies.fetches.is_empty() => continue,398
- };399
- let normalised = normalise_timeline_diffs(diffs, current_user_id.as_deref(), &scope);400
- media.register(normalised.media).await;401
- replies.fetch(normalised.missing_replies);402
- let event = Event::TimelineChanged {403
- timeline_id: id.clone(),404
- diffs: normalised.diffs,405
- };406
- if events.send(event).await.is_err() {407
- return;408
- }409
- }410
- }))411
-}412
-413
-async fn run_pagination(414
- mut requests: mpsc::Receiver<PaginationRequest>,415
- timeline: Arc<Timeline>,416
- events: EventSender,417
- timeline_id: TimelineId,418
-) {419
- while let Some(PaginationRequest { limit, reply }) = requests.recv().await {420
- let history = |state| Event::HistoryChanged {421
- timeline_id: timeline_id.clone(),422
- state,423
- };424
- if events.send(history(HistoryState::Loading)).await.is_err() {425
- return;426
- }427
- let result = timeline428
- .paginate_backwards(limit.max(1))429
- .await430
- .map_err(timeline_error);431
- let state = match &result {432
- Ok(true) => HistoryState::StartReached,433
- Ok(false) => HistoryState::Idle,434
- Err(error) => HistoryState::Failed(error.to_string()),435
- };436
- let _ = events.send(history(state)).await;437
- if let Some(reply) = reply {438
- reply.done(result.map(drop));439
- }440
- }441
-}442
-443
-fn message_content(444
- content: RichText,445
- mentions: MessageMentions,446
- emote: bool,447
-) -> RoomMessageEventContentWithoutRelation {448
- let content = match content.formatted_body {449
- Some(formatted_body) if emote => {450
- RoomMessageEventContentWithoutRelation::emote_html(content.body, formatted_body)451
- }452
- Some(formatted_body) => {453
- RoomMessageEventContentWithoutRelation::text_html(content.body, formatted_body)454
- }455
- None if emote => RoomMessageEventContentWithoutRelation::emote_plain(content.body),456
- None => RoomMessageEventContentWithoutRelation::text_plain(content.body),457
- };458
- let content = content.add_mentions(Mentions::with_user_ids(mentions.user_ids));459
- if mentions.room {460
- content.add_mentions(Mentions::with_room_mention())461
- } else {462
- content463
- }464
-}465
-466
-pub(super) fn timeline_error(source: matrix_sdk_ui::timeline::Error) -> Error {467
- Error::with_source(ErrorKind::Timeline, "timeline operation failed", source)468
-}@@ -1,166 +0,0 @@
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,565 +0,0 @@
1
-use std::{2
- collections::{HashSet, VecDeque},3
- sync::Arc,4
- time::Duration,5
-};6
-7
-use futures_util::StreamExt;8
-use matrix_sdk::{9
- Client,10
- ruma::events::{AnyMessageLikeEventContent, room::message::MessageType},11
- send_queue::{LocalEchoContent, RoomSendQueueUpdate, SendQueueUpdate},12
-};13
-use matrix_sdk_base::RoomInfoNotableUpdateReasons;14
-use matrix_sdk_ui::sync_service::{State as SyncState, SyncService};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};22
-23
-use super::executor::TaskSet;24
-use crate::{25
- ConnectionState, Event, UploadOutcome,26
- encryption::encryption_state,27
- error::classify_sdk_error,28
- normalise::{ENABLED_ROOMS, ROOM_PACKS, USER_PACK},29
- transport::EventSender,30
-};31
-32
-const SYNC_RESTART_MIN: Duration = Duration::from_secs(1);33
-const SYNC_RESTART_MAX: Duration = Duration::from_secs(60);34
-const SEND_QUEUE_RETRY: Duration = Duration::from_secs(5);35
-const SYNC_STABLE: Duration = Duration::from_secs(30);36
-37
-pub(super) async fn room_subscriptions(38
- sync: Arc<SyncService>,39
- mut rooms: watch::Receiver<Vec<ruma::OwnedRoomId>>,40
-) {41
- while rooms.changed().await.is_ok() {42
- let room_ids = rooms.borrow_and_update().clone();43
- let room_ids = room_ids.iter().map(AsRef::as_ref).collect::<Vec<_>>();44
- sync.room_list_service().subscribe_to_rooms(&room_ids).await;45
- }46
-}47
-48
-/// `states` must be taken before sync starts, a late subscriber never hears the transition it missed49
-pub(super) async fn connection(50
- mut states: impl futures_util::Stream<Item = SyncState> + Unpin,51
- sync: Arc<SyncService>,52
- client: Client,53
- events: EventSender,54
- connection: watch::Sender<ConnectionState>,55
-) {56
- let mut backoff = SYNC_RESTART_MIN;57
- let mut online_since: Option<Instant> = None;58
- while let Some(state) = states.next().await {59
- // only a run that lasted counts as recovered, or a server that answers /versions60
- // but refuses sync would have us bounce between offline and online forever61
- let stable = online_since62
- .take()63
- .map(|since| since.elapsed() >= SYNC_STABLE);64
- if stable == Some(true) {65
- backoff = SYNC_RESTART_MIN;66
- }67
- let (state, restart) = match state {68
- // only ever us stopping it for a restart, nothing a client needs to hear69
- SyncState::Idle => continue,70
- SyncState::Running => {71
- online_since = Some(Instant::now());72
- // the sdk parks a room queue on any send error and never unparks it73
- client.send_queue().set_enabled(true).await;74
- (ConnectionState::Online, false)75
- }76
- SyncState::Offline => (ConnectionState::Offline, stable == Some(false)),77
- SyncState::Terminated => (ConnectionState::Offline, true),78
- SyncState::Error(error) => {79
- tracing::warn!(%error, "sync service reported an error");80
- (ConnectionState::Offline, true)81
- }82
- };83
- if *connection.borrow() != state {84
- connection.send_replace(state);85
- if events.send(Event::ConnectionChanged(state)).await.is_err() {86
- return;87
- }88
- }89
- if restart {90
- sync.stop().await;91
- matrix_sdk::sleep::sleep(backoff).await;92
- backoff = (backoff * 2).min(SYNC_RESTART_MAX);93
- sync.start().await;94
- }95
- }96
-}97
-98
-/// a recoverable failure while we're online is a hiccup, so give the room another go99
-pub(super) async fn send_queue_errors(100
- client: Client,101
- mut errors: tokio::sync::broadcast::Receiver<matrix_sdk::send_queue::SendQueueRoomError>,102
- online: watch::Receiver<ConnectionState>,103
-) {104
- let mut retries = TaskSet::new();105
- loop {106
- let error = tokio::select! {107
- error = errors.recv() => error,108
- Some(_) = retries.join_next(), if !retries.is_empty() => continue,109
- };110
- match error {111
- Ok(error) if error.is_recoverable => {112
- let client = client.clone();113
- let online = online.clone();114
- retries.spawn(async move {115
- matrix_sdk::sleep::sleep(SEND_QUEUE_RETRY).await;116
- if *online.borrow() == ConnectionState::Online117
- && let Some(room) = client.get_room(&error.room_id)118
- {119
- room.send_queue().set_enabled(true);120
- }121
- });122
- }123
- Ok(_) | Err(RecvError::Lagged(_)) => {}124
- Err(RecvError::Closed) => return,125
- }126
- }127
-}128
-129
-/// the one place upload events leave from, so an upload is never reported after it130
-/// 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 full132
-pub(super) async fn uploads(133
- client: Client,134
- mut updates: tokio::sync::broadcast::Receiver<SendQueueUpdate>,135
- events: EventSender,136
-) {137
- let (_, mut rooms) = client.rooms_stream();138
- let mut tracker = UploadTracker::new(attachment_echoes(&client).await);139
- let mut pending = VecDeque::new();140
- loop {141
- let reports: Vec<Event> = tokio::select! {142
- biased;143
- update = updates.recv() => match update {144
- Ok(update) => tracker.update(update).into_iter().collect(),145
- Err(RecvError::Lagged(skipped)) => {146
- tracing::warn!(skipped, "upload tracker lagged");147
- tracker.resnapshot(attachment_echoes(&client).await)148
- }149
- Err(RecvError::Closed) => return,150
- },151
- update = rooms.next() => {152
- if update.is_none() {153
- return;154
- }155
- tracker.finish_where(156
- |(room_id, _)| client.get_room(room_id).is_none(),157
- &UploadOutcome::RoomForgotten,158
- )159
- }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
- }165
- };166
- for event in reports {167
- queue_upload_event(&mut pending, event);168
- }169
- }170
-}171
-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
-209
-type Upload = (OwnedRoomId, OwnedTransactionId);210
-211
-/// 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 it216
-struct UploadTracker {217
- live: HashSet<Upload>,218
- ended: HashSet<Upload>,219
- ended_order: VecDeque<Upload>,220
-}221
-222
-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
-231
- fn update(&mut self, update: SendQueueUpdate) -> Option<Event> {232
- let room_id = update.room_id;233
- let (transaction_id, outcome) = match update.update {234
- RoomSendQueueUpdate::NewLocalEvent(echo) => {235
- let upload = (room_id, echo.transaction_id);236
- if is_attachment_echo(&echo.content) && !self.ended.contains(&upload) {237
- self.live.insert(upload);238
- }239
- return None;240
- }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
- }261
- RoomSendQueueUpdate::SendError {262
- transaction_id,263
- error,264
- is_recoverable,265
- } => {266
- let upload = (room_id, transaction_id);267
- if !self.live.contains(&upload) {268
- return None;269
- }270
- let (room_id, transaction_id) = upload;271
- return Some(Event::MediaUploadFailed {272
- room_id,273
- transaction_id,274
- kind: classify_sdk_error(&error),275
- will_retry: is_recoverable,276
- });277
- }278
- RoomSendQueueUpdate::SentEvent {279
- transaction_id,280
- event_id,281
- } => (transaction_id, UploadOutcome::Sent { event_id }),282
- RoomSendQueueUpdate::CancelledLocalEvent { transaction_id } => {283
- (transaction_id, UploadOutcome::Cancelled)284
- }285
- _ => return None,286
- };287
- self.finish((room_id, transaction_id), outcome)288
- }289
-290
- fn resnapshot(&mut self, current: HashSet<Upload>) -> Vec<Event> {291
- let reports = self.finish_where(|upload| !current.contains(upload), &UploadOutcome::Missed);292
- let ended = &self.ended;293
- self.live294
- .extend(current.into_iter().filter(|upload| !ended.contains(upload)));295
- reports296
- }297
-298
- fn finish_where(299
- &mut self,300
- ends: impl Fn(&Upload) -> bool,301
- outcome: &UploadOutcome,302
- ) -> Vec<Event> {303
- let ending = self304
- .live305
- .iter()306
- .filter(|upload| ends(upload))307
- .cloned()308
- .collect::<Vec<_>>();309
- ending310
- .into_iter()311
- .filter_map(|upload| self.finish(upload, outcome.clone()))312
- .collect()313
- }314
-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
- }325
- if !self.live.remove(&upload) {326
- return None;327
- }328
- let (room_id, transaction_id) = upload;329
- Some(Event::MediaUploadFinished {330
- room_id,331
- transaction_id,332
- outcome,333
- })334
- }335
-}336
-337
-async fn attachment_echoes(client: &Client) -> HashSet<Upload> {338
- let Ok(echoes) = client.send_queue().local_echoes().await else {339
- return HashSet::new();340
- };341
- echoes342
- .into_iter()343
- .flat_map(|(room_id, echoes)| {344
- echoes345
- .into_iter()346
- .filter(|echo| is_attachment_echo(&echo.content))347
- .map(move |echo| (room_id.clone(), echo.transaction_id))348
- })349
- .collect()350
-}351
-352
-fn is_attachment_echo(content: &LocalEchoContent) -> bool {353
- let LocalEchoContent::Event {354
- serialized_event, ..355
- } = content356
- else {357
- return false;358
- };359
- matches!(360
- serialized_event.deserialize(),361
- Ok(AnyMessageLikeEventContent::RoomMessage(content))362
- if matches!(363
- content.msgtype,364
- MessageType::Audio(_) | MessageType::File(_) | MessageType::Image(_) | MessageType::Video(_)365
- )366
- )367
-}368
-369
-pub(super) async fn encryption(client: Client, events: EventSender) {370
- let Some(device_id) = client.device_id().map(ToOwned::to_owned) else {371
- tracing::warn!("encryption watcher stopped because the client has no device id");372
- return;373
- };374
- let recovery = client.encryption().recovery();375
- let backups = client.encryption().backups();376
- let mut recovery_stream = recovery.state_stream();377
- let mut backup_stream = backups.state_stream();378
- let mut verification_stream = client.encryption().verification_state();379
- let mut recovery_state = recovery.state();380
- let mut backup_state = backups.state();381
- let mut verification_state = verification_stream.get();382
- loop {383
- let state = encryption_state(384
- device_id.clone(),385
- recovery_state,386
- verification_state,387
- backup_state,388
- );389
- if events.send(Event::EncryptionChanged(state)).await.is_err() {390
- return;391
- }392
- tokio::select! {393
- Some(update) = recovery_stream.next() => recovery_state = update,394
- Some(update) = verification_stream.next() => verification_state = update,395
- Some(update) = backup_stream.next() => {396
- backup_state = update.unwrap_or_else(|_| backups.state());397
- }398
- else => return,399
- }400
- }401
-}402
-403
-pub(super) async fn rooms(client: Client, events: EventSender) {404
- let (_, mut rooms) = client.rooms_stream();405
- let mut info = client.room_info_notable_update_receiver();406
- loop {407
- if events.send(Event::RoomsChanged).await.is_err() {408
- return;409
- }410
- tokio::select! {411
- update = rooms.next() => if update.is_none() { return },412
- update = info.recv() => if matches!(update, Err(RecvError::Closed)) { return },413
- }414
- // a sync touching fifty rooms is one invalidation, not fifty415
- while let Ok(_) | Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) =416
- info.try_recv()417
- {}418
- }419
-}420
-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
-476
-pub(super) async fn notification_settings(client: Client, events: EventSender) {477
- let settings = client.notification_settings().await;478
- let mut changes = settings.subscribe_to_changes();479
- while let Ok(()) | Err(RecvError::Lagged(_)) = changes.recv().await {480
- // room summaries carry the effective mode, and push rule changes never touch room info481
- for event in [Event::NotificationSettingsChanged, Event::RoomsChanged] {482
- if events.send(event).await.is_err() {483
- return;484
- }485
- }486
- }487
-}488
-489
-#[cfg(test)]490
-mod tests {491
- use matrix_sdk::send_queue::{AbstractProgress, RoomSendQueueUpdate, SendQueueUpdate};492
- use ruma::{OwnedTransactionId, event_id, owned_room_id};493
-494
- use super::{HashSet, Upload, UploadTracker};495
- use crate::{Event, UploadOutcome};496
-497
- fn upload() -> Upload {498
- (499
- owned_room_id!("!room:localhost"),500
- OwnedTransactionId::from("upload"),501
- )502
- }503
-504
- fn update(update: RoomSendQueueUpdate) -> SendQueueUpdate {505
- SendQueueUpdate {506
- room_id: upload().0,507
- update,508
- }509
- }510
-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
- })521
- }522
-523
- fn sent() -> SendQueueUpdate {524
- update(RoomSendQueueUpdate::SentEvent {525
- transaction_id: upload().1,526
- event_id: event_id!("$sent:localhost").to_owned(),527
- })528
- }529
-530
- fn finished(event: Option<Event>) -> Option<UploadOutcome> {531
- match event? {532
- Event::MediaUploadFinished { outcome, .. } => Some(outcome),533
- _ => None,534
- }535
- }536
-537
- #[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]550
- fn a_stale_tick_after_a_missed_end_changes_nothing() {551
- let mut tracker = UploadTracker::new(HashSet::new());552
- assert!(tracker.update(tick()).is_some());553
- let missed = tracker.resnapshot(HashSet::new());554
- assert!(matches!(555
- missed.as_slice(),556
- [Event::MediaUploadFinished {557
- outcome: UploadOutcome::Missed,558
- ..559
- }]560
- ));561
- assert!(tracker.update(tick()).is_none());562
- assert!(tracker.update(sent()).is_none());563
- assert!(tracker.live.is_empty());564
- }565
-}@@ -1,322 +0,0 @@
1
-use std::sync::Arc;2
-3
-use matrix_sdk::{4
- HttpError, RefreshTokenError, SendOutsideWasm, SyncOutsideWasm,5
- authentication::oauth::error::{6
- OAuthAuthorizationCodeError, OAuthDiscoveryError, OAuthError, OAuthTokenRevocationError,7
- RequestTokenError,8
- },9
- encryption::{10
- identities::{ManualVerifyError, RequestVerificationError},11
- recovery::RecoveryError,12
- secret_storage::{ImportError, SecretStorageError},13
- },14
-};15
-#[cfg(feature = "serde")]16
-use serde::{Deserialize, Serialize};17
-18
-use crate::InteractiveAuthChallenge;19
-20
-/// the sdk's `SendOutsideWasm` isn't an auto trait, so it can't go in a trait object21
-#[cfg(not(target_family = "wasm"))]22
-pub(crate) type SourceError = dyn std::error::Error + Send + Sync;23
-#[cfg(target_family = "wasm")]24
-pub(crate) type SourceError = dyn std::error::Error;25
-26
-/// the one thing that goes wrong, for commands and queries alike. match on `kind`, show `message`27
-#[derive(Clone, Debug, thiserror::Error)]28
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]29
-#[error("{message}")]30
-#[non_exhaustive]31
-pub struct Error {32
- pub kind: ErrorKind,33
- pub message: String,34
- /// set when the server wants interactive auth before it'll do the thing35
- pub challenge: Option<Box<InteractiveAuthChallenge>>,36
- /// set when the homeserver refused with a matrix error37
- pub server: Option<ServerError>,38
- #[source]39
- #[cfg_attr(feature = "serde", serde(skip))]40
- source: Option<Arc<SourceError>>,41
-}42
-43
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]44
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]45
-#[non_exhaustive]46
-pub enum ErrorKind {47
- /// the engine stopped, or the task doing the work died, before answering48
- Interrupted,49
- Unauthenticated,50
- InteractiveAuthRequired,51
- Invalid,52
- RoomUnknown,53
- RoomNotOpen,54
- TimelineNotOpen,55
- RoomBusy,56
- QueueFull,57
- EmptyBody,58
- MediaUnknown,59
- MediaTooLarge,60
- MediaUnavailable,61
- VerificationInactive,62
- VerificationUnsupported,63
- NoCrossSigningIdentity,64
- RecoveryRejected,65
- Network,66
- /// the homeserver wants us to slow down. try again later67
- RateLimited,68
- Server,69
- Store,70
- Timeline,71
- Encryption,72
- Other,73
-}74
-75
-/// the homeserver's own answer, worth showing a person as it stands76
-#[derive(Clone, Debug, Eq, PartialEq)]77
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]78
-#[non_exhaustive]79
-pub struct ServerError {80
- pub status: u16,81
- pub errcode: String,82
- pub message: String,83
-}84
-85
-impl ServerError {86
- pub(crate) fn find(source: &(dyn std::error::Error + 'static)) -> Option<Self> {87
- let mut current = Some(source);88
- while let Some(error) = current {89
- let api = if let Some(error) = error.downcast_ref::<matrix_sdk::Error>() {90
- error.as_client_api_error()91
- } else if let Some(error) = error.downcast_ref::<HttpError>() {92
- error.as_client_api_error()93
- } else {94
- error.downcast_ref::<ruma::api::error::Error>()95
- };96
- if let Some(api) = api97
- && let ruma::api::error::ErrorBody::Standard(body) = &api.body98
- {99
- return Some(Self {100
- status: api.status_code.as_u16(),101
- errcode: body.kind.errcode().to_string(),102
- message: body.message.clone(),103
- });104
- }105
- current = error.source();106
- }107
- None108
- }109
-}110
-111
-impl std::fmt::Display for ServerError {112
- fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {113
- write!(formatter, "{} ({})", self.message, self.errcode)114
- }115
-}116
-117
-#[cfg(any(feature = "sqlite", feature = "indexeddb"))]118
-pub(crate) fn classify(source: &(dyn std::error::Error + 'static)) -> ErrorKind {119
- let mut current = Some(source);120
- while let Some(error) = current {121
- if let Some(error) = error.downcast_ref::<matrix_sdk::Error>() {122
- return classify_sdk_error(error);123
- }124
- if let Some(error) = error.downcast_ref::<HttpError>() {125
- return classify_http_error(error);126
- }127
- if let Some(error) = error.downcast_ref::<OAuthError>() {128
- return classify_oauth_error(error);129
- }130
- if let Some(error) = error.downcast_ref::<RefreshTokenError>() {131
- return classify_refresh_token_error(error);132
- }133
- current = error.source();134
- }135
- ErrorKind::Other136
-}137
-138
-impl Error {139
- pub(crate) fn new(kind: ErrorKind, message: impl Into<String>) -> Self {140
- Self {141
- kind,142
- message: message.into(),143
- challenge: None,144
- server: None,145
- source: None,146
- }147
- }148
-149
- pub(crate) fn with_source(150
- kind: ErrorKind,151
- message: impl Into<String>,152
- source: impl std::error::Error + SendOutsideWasm + SyncOutsideWasm + 'static,153
- ) -> Self {154
- Self {155
- server: ServerError::find(&source),156
- source: Some(Arc::new(source)),157
- ..Self::new(kind, message)158
- }159
- }160
-161
- pub(crate) fn interrupted() -> Self {162
- Self::new(163
- ErrorKind::Interrupted,164
- "the engine stopped before the operation finished",165
- )166
- }167
-168
- pub(crate) fn room_unknown() -> Self {169
- Self::new(170
- ErrorKind::RoomUnknown,171
- "room is not available in the local store",172
- )173
- }174
-175
- pub(crate) fn interactive_auth(challenge: InteractiveAuthChallenge) -> Self {176
- Self {177
- challenge: Some(Box::new(challenge)),178
- ..Self::new(179
- ErrorKind::InteractiveAuthRequired,180
- "interactive authentication is required",181
- )182
- }183
- }184
-185
- pub(crate) fn from_sdk(message: impl Into<String>, source: matrix_sdk::Error) -> Self {186
- Self::with_source(classify_sdk_error(&source), message, source)187
- }188
-189
- pub(crate) fn from_http(message: impl Into<String>, source: HttpError) -> Self {190
- Self::with_source(classify_http_error(&source), message, source)191
- }192
-193
- pub(crate) fn from_refresh_token(194
- message: impl Into<String>,195
- source: RefreshTokenError,196
- ) -> Self {197
- Self::with_source(classify_refresh_token_error(&source), message, source)198
- }199
-200
- pub(crate) fn from_recovery(message: impl Into<String>, source: RecoveryError) -> Self {201
- let kind = match &source {202
- RecoveryError::Sdk(source) => classify_sdk_error(source),203
- RecoveryError::SecretStorage(SecretStorageError::Sdk(source)) => {204
- classify_sdk_error(source)205
- }206
- RecoveryError::SecretStorage(SecretStorageError::ImportError { error, .. }) => {207
- match error {208
- ImportError::Sdk(source) => classify_sdk_error(source),209
- _ => ErrorKind::RecoveryRejected,210
- }211
- }212
- RecoveryError::SecretStorage(SecretStorageError::Verification(source)) => {213
- match source {214
- ManualVerifyError::Http(source) => classify_http_error(source),215
- _ => ErrorKind::RecoveryRejected,216
- }217
- }218
- RecoveryError::SecretStorage(SecretStorageError::SecretStorageKey(_)) => {219
- ErrorKind::RecoveryRejected220
- }221
- RecoveryError::SecretStorage(SecretStorageError::Storage(_)) => ErrorKind::Store,222
- RecoveryError::BackupExistsOnServer | RecoveryError::SecretStorage(_) => {223
- ErrorKind::RecoveryRejected224
- }225
- };226
- Self::with_source(kind, message, source)227
- }228
-229
- pub(crate) fn from_verification_request(230
- message: impl Into<String>,231
- source: RequestVerificationError,232
- ) -> Self {233
- let kind = match &source {234
- RequestVerificationError::Sdk(source) => classify_sdk_error(source),235
- RequestVerificationError::RoomCreation(_) => ErrorKind::Encryption,236
- };237
- Self::with_source(kind, message, source)238
- }239
-}240
-241
-impl PartialEq for Error {242
- fn eq(&self, other: &Self) -> bool {243
- self.kind == other.kind244
- && self.message == other.message245
- && self.challenge == other.challenge246
- && self.server == other.server247
- }248
-}249
-250
-pub(crate) fn classify_sdk_error(source: &matrix_sdk::Error) -> ErrorKind {251
- match source {252
- matrix_sdk::Error::Http(source) => classify_http_error(source),253
- matrix_sdk::Error::OAuth(source) => classify_oauth_error(source),254
- matrix_sdk::Error::AuthenticationRequired => ErrorKind::Unauthenticated,255
- matrix_sdk::Error::BadCryptoStoreState256
- | matrix_sdk::Error::CryptoStoreError(_)257
- | matrix_sdk::Error::CrossProcessLockError(_)258
- | matrix_sdk::Error::StateStore(_)259
- | matrix_sdk::Error::EventCacheStore(_)260
- | matrix_sdk::Error::MediaStore(_) => ErrorKind::Store,261
- matrix_sdk::Error::NoOlmMachine262
- | matrix_sdk::Error::OlmError(_)263
- | matrix_sdk::Error::MegolmError(_)264
- | matrix_sdk::Error::DecryptorError(_) => ErrorKind::Encryption,265
- matrix_sdk::Error::Media(_) => ErrorKind::MediaUnavailable,266
- matrix_sdk::Error::Timeout => ErrorKind::Network,267
- _ => ErrorKind::Other,268
- }269
-}270
-271
-fn classify_http_error(source: &HttpError) -> ErrorKind {272
- match source {273
- 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
- }281
- HttpError::Api(_) => ErrorKind::Server,282
- HttpError::RefreshToken(source) => classify_refresh_token_error(source),283
- HttpError::Cached(source) => classify_http_error(source),284
- _ => ErrorKind::Other,285
- }286
-}287
-288
-fn classify_refresh_token_error(source: &RefreshTokenError) -> ErrorKind {289
- match source {290
- RefreshTokenError::RefreshTokenRequired => ErrorKind::Unauthenticated,291
- RefreshTokenError::MatrixAuth(source) => classify_http_error(source),292
- RefreshTokenError::OAuth(source) => classify_oauth_error(source),293
- }294
-}295
-296
-fn classify_oauth_error(source: &OAuthError) -> ErrorKind {297
- let classify_token_request_error = |source: &_| match source {298
- RequestTokenError::Request(_) => ErrorKind::Network,299
- RequestTokenError::ServerResponse(_) => ErrorKind::Unauthenticated,300
- _ => ErrorKind::Other,301
- };302
- match source {303
- OAuthError::Discovery(source) => match source {304
- OAuthDiscoveryError::Http(source) => classify_http_error(source),305
- OAuthDiscoveryError::Oidc(source) => classify_token_request_error(source),306
- _ => ErrorKind::Unauthenticated,307
- },308
- OAuthError::RefreshToken(source) => classify_token_request_error(source),309
- OAuthError::AuthorizationCode(OAuthAuthorizationCodeError::RequestToken(source)) => {310
- classify_token_request_error(source)311
- }312
- OAuthError::AuthorizationCode(_) => ErrorKind::Unauthenticated,313
- OAuthError::Logout(source) => match source {314
- OAuthTokenRevocationError::Revoke(RequestTokenError::Request(_)) => ErrorKind::Network,315
- OAuthTokenRevocationError::Revoke(RequestTokenError::ServerResponse(_)) => {316
- ErrorKind::Unauthenticated317
- }318
- _ => ErrorKind::Other,319
- },320
- _ => ErrorKind::Unauthenticated,321
- }322
-}@@ -1,73 +0,0 @@
1
-//! A compact application backend contract on top of the Matrix Rust SDK.2
-3
-mod encryption;4
-mod engine;5
-mod error;6
-#[cfg(feature = "mirror")]7
-mod mirror;8
-mod model;9
-mod normalise;10
-mod transport;11
-mod uiaa;12
-mod upload;13
-#[cfg(target_family = "wasm")]14
-mod wasm;15
-16
-#[cfg(all(feature = "sqlite", target_family = "wasm"))]17
-compile_error!("sqlite has no filesystem to live on in wasm, use indexeddb");18
-#[cfg(all(feature = "indexeddb", not(target_family = "wasm")))]19
-compile_error!("indexeddb only exists in a browser, use sqlite");20
-21
-#[cfg(any(feature = "sqlite", feature = "indexeddb"))]22
-mod account;23
-#[cfg(any(feature = "sqlite", feature = "indexeddb"))]24
-mod accounts;25
-#[cfg(any(feature = "sqlite", feature = "indexeddb"))]26
-mod session_store;27
-#[cfg(any(feature = "sqlite", feature = "indexeddb"))]28
-mod store_lock;29
-30
-#[cfg(any(feature = "sqlite", feature = "indexeddb"))]31
-pub use account::{32
- AccountError, OAuthApplicationType, OAuthLogin, PasswordLogin, PendingOAuthLogin, Restore,33
- Session, SessionRecord, SoftLogout, StoreConfig, UnsavedSession,34
-};35
-#[cfg(any(feature = "sqlite", feature = "indexeddb"))]36
-pub use accounts::{Account, Accounts};37
-#[cfg(any(feature = "sqlite", feature = "indexeddb"))]38
-pub use session_store::{EncryptedSessionStore, SessionStore};39
-40
-pub use engine::{Engine, EngineConfig, EngineError};41
-pub use error::{Error, ErrorKind, ServerError};42
-pub use imbl;43
-#[cfg(feature = "mirror")]44
-pub use mirror::{45
- Mirror, MirrorHandle, SessionStatus, State, StateChange, Subscription, TimelineChange,46
- TimelineState, Update, UploadFailure, UploadState,47
-};48
-pub use model::{49
- AccountCommand, AttachmentEncryption, AttachmentUpload, Change, Command, CommandOutput,50
- ConnectionState, CreateRoomOptions, EncryptionCommand, EncryptionState, EngineErrorKind,51
- EngineFailure, Event, EventRef, HistoryState, ImagePack, ImagePackSource, ImagePacks,52
- KeyBackupState, LatestEvent, LifecycleCommand, LinkPreview, LinkPreviewImage, Media,53
- MediaCommand, MediaDescriptor, MediaFile, MediaId, MediaKind, MediaLocation, MediaSize,54
- MemberFilter, MemberMembership, MemberSummary, MembershipChange, Message, MessageContent,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,62
- TimelineEvent, TimelineId, TimelineItem, TimelineItemId, TypingState, UploadDetails,63
- UploadOutcome, UploadThumbnail, UtdCause, VerificationAction, VerificationFailure,64
- VerificationState, VerificationTarget,65
-};66
-pub use ruma;67
-pub use transport::{ClientHandle, EventStream, Pending, SpaceHierarchy};68
-pub use uiaa::{69
- InteractiveAuth, InteractiveAuthChallenge, InteractiveAuthFailure, InteractiveAuthFlow,70
- InteractiveAuthOutcome,71
-};72
-#[cfg(target_family = "wasm")]73
-pub use wasm::on_panic;@@ -1,707 +0,0 @@
1
-use std::{2
- collections::HashMap,3
- fmt,4
- pin::Pin,5
- sync::{Arc, Mutex, MutexGuard, PoisonError},6
- task::{Context, Poll},7
- time::Duration,8
-};9
-10
-use futures_util::{Stream, StreamExt};11
-use imbl::Vector;12
-use matrix_sdk::{13
- SendOutsideWasm,14
- executor::{AbortHandle, spawn},15
-};16
-use ruma::{OwnedDeviceId, OwnedRoomId, OwnedTransactionId, OwnedUserId};17
-#[cfg(feature = "serde")]18
-use serde::{Deserialize, Serialize};19
-use tokio::sync::broadcast;20
-21
-use crate::{22
- ClientHandle, Command, ConnectionState, EncryptionState, EngineFailure, ErrorKind, Event,23
- HistoryState, ImagePacks, RoomSummary, TimelineCommand, TimelineDiff, TimelineId, TimelineItem,24
- TypingState, UploadOutcome, VerificationState, VerificationTarget,25
- engine::executor::EngineBoxFuture,26
-};27
-28
-#[cfg(not(target_family = "wasm"))]29
-type EngineBoxStream<T> = futures_util::stream::BoxStream<'static, T>;30
-#[cfg(target_family = "wasm")]31
-type EngineBoxStream<T> = futures_util::stream::LocalBoxStream<'static, T>;32
-33
-const DEFAULT_CAPACITY: usize = 256;34
-const RESYNC_BACKOFF: Duration = Duration::from_millis(100);35
-const RESYNC_BACKOFF_CAP: Duration = Duration::from_secs(10);36
-37
-/// everything the engine has said so far, folded together38
-#[derive(Clone, Debug, PartialEq)]39
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]40
-#[non_exhaustive]41
-pub struct State {42
- pub user_id: Option<OwnedUserId>,43
- pub session: SessionStatus,44
- pub connection: ConnectionState,45
- pub encryption: Option<EncryptionState>,46
- pub verification: Option<VerificationState>,47
- pub verification_target: Option<VerificationTarget>,48
- pub rooms: Vector<RoomSummary>,49
- pub image_packs: ImagePacks,50
- pub timelines: imbl::HashMap<TimelineId, TimelineState>,51
- pub typing: imbl::HashMap<OwnedRoomId, Vec<OwnedUserId>>,52
- pub uploads: imbl::HashMap<OwnedTransactionId, UploadState>,53
- /// what stopped the engine, if something went wrong54
- pub engine_failure: Option<EngineFailure>,55
- /// the last save of the session record that failed, until one succeeds56
- pub session_save_failure: Option<String>,57
-}58
-59
-#[derive(Clone, Debug, PartialEq)]60
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]61
-#[non_exhaustive]62
-pub struct TimelineState {63
- pub items: Vector<TimelineItem>,64
- pub history: HistoryState,65
- /// a batch didn't fit, so `items` is the last good copy until the next reset66
- pub diverged: bool,67
-}68
-69
-#[derive(Clone, Debug, Eq, PartialEq)]70
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]71
-#[non_exhaustive]72
-pub struct UploadState {73
- pub room_id: OwnedRoomId,74
- pub uploaded_bytes: u64,75
- pub total_bytes: u64,76
- pub complete: bool,77
- pub failure: Option<UploadFailure>,78
-}79
-80
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]81
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]82
-#[non_exhaustive]83
-pub struct UploadFailure {84
- pub kind: ErrorKind,85
- pub will_retry: bool,86
-}87
-88
-/// the `Termination` it'll end in, as far as the events tell89
-#[derive(Clone, Debug, Eq, PartialEq)]90
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]91
-#[non_exhaustive]92
-pub enum SessionStatus {93
- Active,94
- SoftLoggedOut {95
- device_id: OwnedDeviceId,96
- },97
- AccessRevoked,98
- LoggedOut,99
- /// stopped with the session still good100
- Stopped,101
-}102
-103
-#[derive(Clone, Debug)]104
-#[non_exhaustive]105
-pub struct Update {106
- /// one more than the update before it, and a `State` carries the one it's current as of107
- pub sequence: u64,108
- pub change: StateChange,109
-}110
-111
-#[derive(Clone, Debug)]112
-#[non_exhaustive]113
-pub enum StateChange {114
- /// start over from this. a subscription opens with one and gets another after lagging115
- State(Arc<State>),116
- Authenticated(OwnedUserId),117
- Session(SessionStatus),118
- /// the record on disk may now hold a spent refresh token119
- SessionSaveFailed(String),120
- /// saved again after `SessionSaveFailed`121
- SessionSaved,122
- Connection(ConnectionState),123
- Encryption(EncryptionState),124
- Verification(VerificationState),125
- VerificationTarget(VerificationTarget),126
- /// room modes arrive with the `Rooms` that follows127
- NotificationSettings,128
- Rooms(Vector<RoomSummary>),129
- ImagePacks(ImagePacks),130
- Timeline {131
- timeline_id: TimelineId,132
- change: TimelineChange,133
- },134
- History {135
- timeline_id: TimelineId,136
- state: HistoryState,137
- },138
- Typing(TypingState),139
- Upload {140
- transaction_id: OwnedTransactionId,141
- upload: UploadState,142
- },143
- UploadFinished {144
- room_id: OwnedRoomId,145
- transaction_id: OwnedTransactionId,146
- outcome: UploadOutcome,147
- },148
- Failed(EngineFailure),149
-}150
-151
-#[derive(Clone, Debug)]152
-#[non_exhaustive]153
-pub enum TimelineChange {154
- Reset {155
- items: Vector<TimelineItem>,156
- },157
- /// `items` is what applying `diffs` left158
- Changed {159
- diffs: Arc<[TimelineDiff]>,160
- items: Vector<TimelineItem>,161
- },162
- /// a batch didn't fit. a resync is on its way and nothing changes until its reset163
- Diverged,164
- Closed,165
-}166
-167
-/// drains the event stream into a `State` that any number of `MirrorHandle`s watch168
-pub struct Mirror {169
- handle: ClientHandle,170
- events: EngineBoxStream<Event>,171
- shared: Arc<Shared>,172
- resyncs: HashMap<TimelineId, AbortHandle>,173
- rooms_fetch: Option<AbortHandle>,174
- packs_fetch: Option<AbortHandle>,175
-}176
-177
-impl fmt::Debug for Mirror {178
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {179
- formatter.debug_struct("Mirror").finish_non_exhaustive()180
- }181
-}182
-183
-impl Mirror {184
- /// takes any stream of the engine's events, so a host can still look at them on the way past185
- pub fn new(186
- handle: ClientHandle,187
- events: impl Stream<Item = Event> + SendOutsideWasm + 'static,188
- ) -> (Self, MirrorHandle) {189
- Self::with_capacity(handle, events, DEFAULT_CAPACITY)190
- }191
-192
- /// `capacity` updates can queue for a subscriber before it lags and starts over. a zero193
- /// capacity counts as one194
- pub fn with_capacity(195
- handle: ClientHandle,196
- events: impl Stream<Item = Event> + SendOutsideWasm + 'static,197
- capacity: usize,198
- ) -> (Self, MirrorHandle) {199
- let state = State {200
- user_id: None,201
- session: SessionStatus::Active,202
- connection: *handle.connection_state().borrow(),203
- encryption: handle.encryption_state(),204
- verification: None,205
- verification_target: None,206
- rooms: Vector::new(),207
- image_packs: ImagePacks::default(),208
- timelines: imbl::HashMap::new(),209
- typing: imbl::HashMap::new(),210
- uploads: imbl::HashMap::new(),211
- engine_failure: None,212
- session_save_failure: None,213
- };214
- let shared = Arc::new(Shared {215
- published: Mutex::new(Published {216
- state: Arc::new(state),217
- sequence: 0,218
- updates: Some(broadcast::channel(capacity.max(1)).0),219
- rooms_fetch: Fetch::Idle,220
- packs_fetch: Fetch::Idle,221
- }),222
- });223
- #[cfg(not(target_family = "wasm"))]224
- let events = events.boxed();225
- #[cfg(target_family = "wasm")]226
- let events = events.boxed_local();227
- let mirror = Self {228
- handle,229
- events,230
- shared: shared.clone(),231
- resyncs: HashMap::new(),232
- rooms_fetch: None,233
- packs_fetch: None,234
- };235
- (mirror, MirrorHandle { shared })236
- }237
-238
- /// resolves once the event stream ends, which is just after `Stopped`239
- pub fn run(mut self) -> EngineBoxFuture<'static, ()> {240
- Box::pin(async move {241
- self.refetch_rooms();242
- self.refetch_image_packs();243
- while let Some(event) = self.events.next().await {244
- self.apply(event);245
- }246
- })247
- }248
-249
- fn apply(&mut self, event: Event) {250
- match event {251
- Event::ConnectionChanged(connection) => self.publish(|state| {252
- state.connection = connection;253
- Some(StateChange::Connection(connection))254
- }),255
- Event::Authenticated { user_id } => self.publish(|state| {256
- state.user_id = Some(user_id.clone());257
- Some(StateChange::Authenticated(user_id))258
- }),259
- Event::SessionSaveFailed { message } => self.publish(|state| {260
- state.session_save_failure = Some(message.clone());261
- Some(StateChange::SessionSaveFailed(message))262
- }),263
- Event::SessionTokensChanged => self.publish(|state| {264
- state.session_save_failure.take()?;265
- Some(StateChange::SessionSaved)266
- }),267
- Event::SessionAccessRejected { soft_logout } => self.publish(|state| {268
- state.session = match soft_logout {269
- Some(device_id) => SessionStatus::SoftLoggedOut { device_id },270
- None => SessionStatus::AccessRevoked,271
- };272
- Some(StateChange::Session(state.session.clone()))273
- }),274
- Event::LoggedOut => self.publish(|state| {275
- state.session = SessionStatus::LoggedOut;276
- Some(StateChange::Session(SessionStatus::LoggedOut))277
- }),278
- Event::Stopped => {279
- self.cancel_resyncs();280
- self.publish(|state| {281
- if state.session != SessionStatus::Active {282
- return None;283
- }284
- state.session = SessionStatus::Stopped;285
- Some(StateChange::Session(SessionStatus::Stopped))286
- });287
- }288
- Event::Failed(failure) => self.publish(|state| {289
- state.engine_failure = Some(failure.clone());290
- Some(StateChange::Failed(failure))291
- }),292
- Event::NotificationSettingsChanged => {293
- self.publish(|_| Some(StateChange::NotificationSettings));294
- }295
- Event::RoomsChanged => self.refetch_rooms(),296
- Event::ImagePacksChanged => self.refetch_image_packs(),297
- Event::EncryptionChanged(encryption) => self.publish(|state| {298
- state.encryption = Some(encryption.clone());299
- Some(StateChange::Encryption(encryption))300
- }),301
- Event::VerificationChanged(verification) => self.publish(|state| {302
- state.verification = Some(verification.clone());303
- Some(StateChange::Verification(verification))304
- }),305
- Event::VerificationTargetChanged(target) => self.publish(|state| {306
- state.verification_target = Some(target.clone());307
- Some(StateChange::VerificationTarget(target))308
- }),309
- Event::TimelineReset { timeline_id, items } => {310
- self.cancel_resync(&timeline_id);311
- self.publish(|state| {312
- let history = state313
- .timelines314
- .get(&timeline_id)315
- .map_or(HistoryState::Idle, |timeline| timeline.history.clone());316
- let timeline = TimelineState {317
- items: items.clone(),318
- history,319
- diverged: false,320
- };321
- state.timelines.insert(timeline_id.clone(), timeline);322
- Some(StateChange::Timeline {323
- timeline_id,324
- change: TimelineChange::Reset { items },325
- })326
- });327
- }328
- Event::TimelineChanged { timeline_id, diffs } => self.apply_diffs(timeline_id, diffs),329
- Event::HistoryChanged {330
- timeline_id,331
- state: history,332
- } => self.publish(|state| {333
- state.timelines.get_mut(&timeline_id)?.history = history.clone();334
- Some(StateChange::History {335
- timeline_id,336
- state: history,337
- })338
- }),339
- Event::TimelineClosed { timeline_id } => {340
- self.cancel_resync(&timeline_id);341
- self.publish(|state| {342
- state.timelines.remove(&timeline_id)?;343
- Some(StateChange::Timeline {344
- timeline_id,345
- change: TimelineChange::Closed,346
- })347
- });348
- }349
- Event::TypingChanged(typing) => self.publish(|state| {350
- if typing.users.is_empty() {351
- state.typing.remove(&typing.room_id);352
- } else {353
- state354
- .typing355
- .insert(typing.room_id.clone(), typing.users.clone());356
- }357
- Some(StateChange::Typing(typing))358
- }),359
- Event::MediaUploadProgress {360
- room_id,361
- transaction_id,362
- uploaded_bytes,363
- total_bytes,364
- complete,365
- } => self.publish(|state| {366
- let upload = UploadState {367
- room_id,368
- uploaded_bytes,369
- total_bytes,370
- complete,371
- failure: None,372
- };373
- state.uploads.insert(transaction_id.clone(), upload.clone());374
- Some(StateChange::Upload {375
- transaction_id,376
- upload,377
- })378
- }),379
- Event::MediaUploadFailed {380
- room_id,381
- transaction_id,382
- kind,383
- will_retry,384
- } => self.publish(|state| {385
- let upload = state386
- .uploads387
- .entry(transaction_id.clone())388
- .or_insert_with(|| UploadState {389
- room_id,390
- uploaded_bytes: 0,391
- total_bytes: 0,392
- complete: false,393
- failure: None,394
- });395
- upload.failure = Some(UploadFailure { kind, will_retry });396
- Some(StateChange::Upload {397
- transaction_id,398
- upload: upload.clone(),399
- })400
- }),401
- Event::MediaUploadFinished {402
- room_id,403
- transaction_id,404
- outcome,405
- } => self.publish(|state| {406
- state.uploads.remove(&transaction_id);407
- Some(StateChange::UploadFinished {408
- room_id,409
- transaction_id,410
- outcome,411
- })412
- }),413
- }414
- }415
-416
- fn apply_diffs(&mut self, timeline_id: TimelineId, diffs: Vec<TimelineDiff>) {417
- let mut diverged = false;418
- self.publish(|state| {419
- let Some(timeline) = state.timelines.get_mut(&timeline_id) else {420
- diverged = true;421
- state.timelines.insert(422
- timeline_id.clone(),423
- TimelineState {424
- items: Vector::new(),425
- history: HistoryState::Idle,426
- diverged: true,427
- },428
- );429
- return Some(StateChange::Timeline {430
- timeline_id: timeline_id.clone(),431
- change: TimelineChange::Diverged,432
- });433
- };434
- if timeline.diverged {435
- return None;436
- }437
- let diffs: Arc<[TimelineDiff]> = diffs.into();438
- let change = match TimelineDiff::try_apply_all(diffs.to_vec(), &mut timeline.items) {439
- Ok(()) => TimelineChange::Changed {440
- diffs,441
- items: timeline.items.clone(),442
- },443
- Err(error) => {444
- tracing::debug!(?timeline_id, length = error.length, "timeline diverged");445
- timeline.diverged = true;446
- diverged = true;447
- TimelineChange::Diverged448
- }449
- };450
- Some(StateChange::Timeline {451
- timeline_id: timeline_id.clone(),452
- change,453
- })454
- });455
- if diverged {456
- let task = spawn(resync(self.handle.clone(), timeline_id.clone()));457
- self.resyncs.insert(timeline_id, task.abort_handle());458
- }459
- }460
-461
- fn refetch_rooms(&mut self) {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);478
- }479
- }480
-481
- fn cancel_resync(&mut self, timeline_id: &TimelineId) {482
- if let Some(task) = self.resyncs.remove(timeline_id) {483
- task.abort();484
- }485
- }486
-487
- fn cancel_resyncs(&mut self) {488
- for (_, task) in self.resyncs.drain() {489
- task.abort();490
- }491
- }492
-493
- fn publish(&self, change: impl FnOnce(&mut State) -> Option<StateChange>) {494
- self.shared.publish(change);495
- }496
-}497
-498
-impl Drop for Mirror {499
- fn drop(&mut self) {500
- self.cancel_resyncs();501
- for task in [self.rooms_fetch.take(), self.packs_fetch.take()]502
- .into_iter()503
- .flatten()504
- {505
- task.abort();506
- }507
- self.shared.lock().updates = None;508
- }509
-}510
-511
-/// asks until the engine answers, since a busy room or a full queue is only ever for now.512
-/// a reset or a close for the timeline aborts it, and the engine sends a reset before it answers513
-async fn resync(handle: ClientHandle, timeline_id: TimelineId) {514
- let mut backoff = RESYNC_BACKOFF;515
- loop {516
- let command = Command::Timeline(TimelineCommand::Resync {517
- timeline_id: timeline_id.clone(),518
- });519
- match handle.execute(command).await {520
- Ok(_) => return,521
- Err(error) if error.kind == ErrorKind::Interrupted => return,522
- Err(error) => {523
- tracing::debug!(%error, ?timeline_id, "resync refused, trying again");524
- matrix_sdk::sleep::sleep(backoff).await;525
- backoff = (backoff * 2).min(RESYNC_BACKOFF_CAP);526
- }527
- }528
- }529
-}530
-531
-async fn fetch_rooms(handle: ClientHandle, shared: Arc<Shared>) {532
- loop {533
- let rooms: Vector<RoomSummary> = handle.rooms().await.into();534
- shared.publish(|state| {535
- state.rooms = rooms.clone();536
- Some(StateChange::Rooms(rooms))537
- });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) {558
- return;559
- }560
- }561
-}562
-563
-/// cheap to clone, one per frontend564
-#[derive(Clone, Debug)]565
-pub struct MirrorHandle {566
- shared: Arc<Shared>,567
-}568
-569
-impl MirrorHandle {570
- #[must_use]571
- pub fn snapshot(&self) -> Arc<State> {572
- self.shared.lock().state.clone()573
- }574
-575
- /// opens with the current state, then every update after it. ends once the mirror stops576
- #[must_use]577
- pub fn subscribe(&self) -> Subscription {578
- let (first, receiving) = self.shared.clone().subscribe();579
- let rest = futures_util::stream::unfold(receiving, |(shared, receiver)| async move {580
- let mut receiver = receiver?;581
- match receiver.recv().await {582
- Ok(update) => Some((update, (shared, Some(receiver)))),583
- Err(broadcast::error::RecvError::Lagged(missed)) => {584
- tracing::debug!(missed, "mirror subscriber lagged, starting it over");585
- Some(shared.subscribe())586
- }587
- Err(broadcast::error::RecvError::Closed) => None,588
- }589
- });590
- let updates = futures_util::stream::once(std::future::ready(first)).chain(rest);591
- #[cfg(not(target_family = "wasm"))]592
- let updates = updates.boxed();593
- #[cfg(target_family = "wasm")]594
- let updates = updates.boxed_local();595
- Subscription(updates)596
- }597
-}598
-599
-pub struct Subscription(EngineBoxStream<Update>);600
-601
-impl fmt::Debug for Subscription {602
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {603
- formatter604
- .debug_struct("Subscription")605
- .finish_non_exhaustive()606
- }607
-}608
-609
-impl Stream for Subscription {610
- type Item = Update;611
-612
- fn poll_next(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Option<Update>> {613
- self.0.poll_next_unpin(context)614
- }615
-}616
-617
-#[derive(Debug)]618
-struct Shared {619
- published: Mutex<Published>,620
-}621
-622
-#[derive(Debug)]623
-struct Published {624
- state: Arc<State>,625
- sequence: u64,626
- /// gone once the mirror stops, which ends every subscription627
- updates: Option<broadcast::Sender<Update>>,628
- rooms_fetch: Fetch,629
- packs_fetch: Fetch,630
-}631
-632
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]633
-enum Fetch {634
- Idle,635
- Running,636
- Again,637
-}638
-639
-impl Shared {640
- fn lock(&self) -> MutexGuard<'_, Published> {641
- self.published642
- .lock()643
- .unwrap_or_else(PoisonError::into_inner)644
- }645
-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
-680
- /// state, sequence and send move together under the one lock, which is what lets681
- /// `subscribe` take a snapshot and a receiver with nothing between them682
- fn publish(&self, change: impl FnOnce(&mut State) -> Option<StateChange>) {683
- let mut published = self.lock();684
- let Some(change) = change(Arc::make_mut(&mut published.state)) else {685
- return;686
- };687
- published.sequence += 1;688
- let update = Update {689
- sequence: published.sequence,690
- change,691
- };692
- if let Some(updates) = &published.updates {693
- let _ = updates.send(update);694
- }695
- }696
-697
- fn subscribe(self: Arc<Self>) -> (Update, (Arc<Self>, Option<broadcast::Receiver<Update>>)) {698
- let published = self.lock();699
- let update = Update {700
- sequence: published.sequence,701
- change: StateChange::State(published.state.clone()),702
- };703
- let receiver = published.updates.as_ref().map(broadcast::Sender::subscribe);704
- drop(published);705
- (update, (self, receiver))706
- }707
-}@@ -1,1979 +0,0 @@
1
-use std::{fmt, sync::Arc};2
-3
-use imbl::Vector;4
-use ruma::{5
- MxcUri, OwnedDeviceId, OwnedEventId, OwnedMxcUri, OwnedRoomAliasId, OwnedRoomId,6
- OwnedRoomOrAliasId, OwnedServerName, OwnedTransactionId, OwnedUserId, RoomId,7
- events::{8
- AnyGlobalAccountDataEventContent, AnyRoomAccountDataEventContent, AnySyncTimelineEvent,9
- GlobalAccountDataEventType, RoomAccountDataEventType,10
- room::member::StrippedRoomMemberEvent,11
- },12
- serde::Raw,13
-};14
-#[cfg(feature = "serde")]15
-use serde::{Deserialize, Serialize};16
-17
-/// every command answers exactly once through `ClientHandle::send` or `execute`.18
-/// the event stream only ever carries state19
-#[derive(Debug)]20
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]21
-#[non_exhaustive]22
-pub enum Command {23
- Account(AccountCommand),24
- Room(RoomCommand),25
- Timeline(TimelineCommand),26
- Media(MediaCommand),27
- Encryption(EncryptionCommand),28
- Notification(NotificationCommand),29
- Lifecycle(LifecycleCommand),30
-}31
-32
-#[derive(Debug)]33
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]34
-#[non_exhaustive]35
-pub enum AccountCommand {36
- Logout,37
- SetAccountData {38
- event_type: GlobalAccountDataEventType,39
- content: Raw<AnyGlobalAccountDataEventContent>,40
- },41
- SetDisplayName(Option<String>),42
- SetAvatar(Option<OwnedMxcUri>),43
- /// uploads the image and points the profile at it, same byte cap as any upload44
- UploadAvatar {45
- mimetype: String,46
- bytes: Vec<u8>,47
- },48
- RenameDevice {49
- device_id: OwnedDeviceId,50
- display_name: String,51
- },52
- DeleteDevices {53
- device_ids: Vec<OwnedDeviceId>,54
- auth: Option<crate::InteractiveAuth>,55
- },56
-}57
-58
-#[derive(Clone, Debug)]59
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]60
-#[non_exhaustive]61
-pub enum RoomCommand {62
- /// the snapshot turns up as `Event::TimelineReset`. opening an open room sends a fresh one63
- Open(OwnedRoomId),64
- Close(OwnedRoomId),65
- Join {66
- room: OwnedRoomOrAliasId,67
- via: Vec<OwnedServerName>,68
- },69
- Leave(OwnedRoomId),70
- Forget(OwnedRoomId),71
- AcceptInvite(OwnedRoomId),72
- RejectInvite(OwnedRoomId),73
- InviteUser {74
- room_id: OwnedRoomId,75
- user_id: OwnedUserId,76
- },77
- KickUser {78
- room_id: OwnedRoomId,79
- user_id: OwnedUserId,80
- reason: Option<String>,81
- },82
- BanUser {83
- room_id: OwnedRoomId,84
- user_id: OwnedUserId,85
- reason: Option<String>,86
- },87
- UnbanUser {88
- room_id: OwnedRoomId,89
- user_id: OwnedUserId,90
- reason: Option<String>,91
- },92
- SetPowerLevels {93
- room_id: OwnedRoomId,94
- changes: PowerLevelChanges,95
- },96
- SetUserPowerLevel {97
- room_id: OwnedRoomId,98
- user_id: OwnedUserId,99
- level: i64,100
- },101
- Create(CreateRoomOptions),102
- /// hands back the existing dm if there is one103
- CreateDirect(OwnedUserId),104
- CancelSend {105
- room_id: OwnedRoomId,106
- transaction_id: OwnedTransactionId,107
- },108
- RetrySend {109
- room_id: OwnedRoomId,110
- transaction_id: OwnedTransactionId,111
- },112
- SetTyping {113
- room_id: OwnedRoomId,114
- typing: bool,115
- },116
- /// the key work a send into an encrypted room would otherwise start with, done now.117
- /// nothing in a plain room, and a second ask joins the first118
- PrepareToSend(OwnedRoomId),119
- SetAccountData {120
- room_id: OwnedRoomId,121
- event_type: RoomAccountDataEventType,122
- content: Raw<AnyRoomAccountDataEventContent>,123
- },124
-}125
-126
-#[derive(Clone, Debug, Default, Eq, PartialEq)]127
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]128
-#[non_exhaustive]129
-pub struct CreateRoomOptions {130
- pub name: Option<String>,131
- pub topic: Option<String>,132
- pub alias_localpart: Option<String>,133
- pub invite: Vec<OwnedUserId>,134
- pub direct: bool,135
- pub visibility: RoomVisibility,136
- pub preset: Option<RoomPreset>,137
-}138
-139
-impl CreateRoomOptions {140
- #[must_use]141
- pub fn with_name(mut self, name: impl Into<String>) -> Self {142
- self.name = Some(name.into());143
- self144
- }145
-146
- #[must_use]147
- pub fn with_topic(mut self, topic: impl Into<String>) -> Self {148
- self.topic = Some(topic.into());149
- self150
- }151
-152
- #[must_use]153
- pub fn with_alias_localpart(mut self, localpart: impl Into<String>) -> Self {154
- self.alias_localpart = Some(localpart.into());155
- self156
- }157
-158
- #[must_use]159
- pub fn with_invite(mut self, invite: impl IntoIterator<Item = OwnedUserId>) -> Self {160
- self.invite = invite.into_iter().collect();161
- self162
- }163
-164
- #[must_use]165
- pub const fn with_direct(mut self, direct: bool) -> Self {166
- self.direct = direct;167
- self168
- }169
-170
- #[must_use]171
- pub const fn with_visibility(mut self, visibility: RoomVisibility) -> Self {172
- self.visibility = visibility;173
- self174
- }175
-176
- #[must_use]177
- pub const fn with_preset(mut self, preset: RoomPreset) -> Self {178
- self.preset = Some(preset);179
- self180
- }181
-}182
-183
-#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]184
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]185
-pub enum RoomVisibility {186
- #[default]187
- Private,188
- Public,189
-}190
-191
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]192
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]193
-pub enum RoomPreset {194
- PrivateChat,195
- TrustedPrivateChat,196
- PublicChat,197
-}198
-199
-#[derive(Clone, Debug, Eq, PartialEq)]200
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]201
-#[non_exhaustive]202
-pub enum TimelineCommand {203
- OpenThread {204
- room_id: OwnedRoomId,205
- root_event_id: OwnedEventId,206
- },207
- CloseThread {208
- room_id: OwnedRoomId,209
- root_event_id: OwnedEventId,210
- },211
- /// Re-emit an authoritative reset for an open room or thread without212
- /// closing it. The reset arrives before this command's terminal result.213
- Resync {214
- timeline_id: TimelineId,215
- },216
- Paginate {217
- timeline_id: TimelineId,218
- limit: u16,219
- },220
- Send {221
- timeline_id: TimelineId,222
- content: MessageContent,223
- },224
- Edit {225
- timeline_id: TimelineId,226
- target: EventRef,227
- content: MessageEditContent,228
- },229
- /// redacting your own unsent message just cancels it230
- Redact {231
- timeline_id: TimelineId,232
- target: EventRef,233
- reason: Option<String>,234
- },235
- ToggleReaction {236
- timeline_id: TimelineId,237
- target: EventRef,238
- key: String,239
- /// a custom emote's shortcode, sent along so clients without the pack can label it240
- shortcode: Option<String>,241
- },242
- /// works on a closed room too, it just can't refuse to move the marker backwards243
- MarkRead {244
- timeline_id: TimelineId,245
- event_id: OwnedEventId,246
- },247
-}248
-249
-impl TimelineCommand {250
- #[must_use]251
- pub const fn room_id(&self) -> &OwnedRoomId {252
- match self {253
- Self::OpenThread { room_id, .. } | Self::CloseThread { room_id, .. } => room_id,254
- Self::Resync { timeline_id }255
- | Self::Paginate { timeline_id, .. }256
- | Self::Send { timeline_id, .. }257
- | Self::Edit { timeline_id, .. }258
- | Self::Redact { timeline_id, .. }259
- | Self::ToggleReaction { timeline_id, .. }260
- | Self::MarkRead { timeline_id, .. } => timeline_id.room_id(),261
- }262
- }263
-264
- #[must_use]265
- pub const fn timeline_id(&self) -> Option<&TimelineId> {266
- match self {267
- Self::OpenThread { .. } | Self::CloseThread { .. } => None,268
- Self::Resync { timeline_id }269
- | Self::Paginate { timeline_id, .. }270
- | Self::Send { timeline_id, .. }271
- | Self::Edit { timeline_id, .. }272
- | Self::Redact { timeline_id, .. }273
- | Self::ToggleReaction { timeline_id, .. }274
- | Self::MarkRead { timeline_id, .. } => Some(timeline_id),275
- }276
- }277
-}278
-279
-#[derive(Clone, Debug, Eq, Hash, PartialEq)]280
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]281
-#[non_exhaustive]282
-pub enum TimelineId {283
- Room(OwnedRoomId),284
- Thread {285
- room_id: OwnedRoomId,286
- root_event_id: OwnedEventId,287
- },288
-}289
-290
-impl TimelineId {291
- #[must_use]292
- pub const fn room_id(&self) -> &OwnedRoomId {293
- match self {294
- Self::Room(room_id) | Self::Thread { room_id, .. } => room_id,295
- }296
- }297
-298
- #[must_use]299
- pub fn thread(room_id: OwnedRoomId, root_event_id: OwnedEventId) -> Self {300
- Self::Thread {301
- room_id,302
- root_event_id,303
- }304
- }305
-306
- #[must_use]307
- pub fn thread_root(&self) -> Option<&OwnedEventId> {308
- match self {309
- Self::Room(_) => None,310
- Self::Thread { root_event_id, .. } => Some(root_event_id),311
- }312
- }313
-}314
-315
-impl From<OwnedRoomId> for TimelineId {316
- fn from(room_id: OwnedRoomId) -> Self {317
- Self::Room(room_id)318
- }319
-}320
-321
-#[derive(Clone, Debug, Eq, PartialEq)]322
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]323
-#[non_exhaustive]324
-pub enum MediaCommand {325
- /// succeeds once the send queue has it. progress and failure arrive as events326
- Upload(AttachmentUpload),327
-}328
-329
-#[derive(Clone, Debug, Eq, PartialEq)]330
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]331
-#[non_exhaustive]332
-pub struct AttachmentUpload {333
- pub timeline_id: TimelineId,334
- pub filename: String,335
- pub mimetype: String,336
- pub bytes: Vec<u8>,337
- pub caption: Option<String>,338
- pub details: Option<UploadDetails>,339
- pub thumbnail: Option<UploadThumbnail>,340
-}341
-342
-impl AttachmentUpload {343
- #[must_use]344
- pub fn new(345
- timeline_id: impl Into<TimelineId>,346
- filename: impl Into<String>,347
- mimetype: impl Into<String>,348
- bytes: Vec<u8>,349
- ) -> Self {350
- Self {351
- timeline_id: timeline_id.into(),352
- filename: filename.into(),353
- mimetype: mimetype.into(),354
- bytes,355
- caption: None,356
- details: None,357
- thumbnail: None,358
- }359
- }360
-}361
-362
-#[derive(Clone, Debug, Eq, PartialEq)]363
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]364
-#[non_exhaustive]365
-pub enum UploadDetails {366
- Image {367
- width: Option<u32>,368
- height: Option<u32>,369
- blurhash: Option<String>,370
- },371
- Video {372
- width: Option<u32>,373
- height: Option<u32>,374
- duration_ms: Option<u64>,375
- blurhash: Option<String>,376
- },377
- Audio {378
- duration_ms: Option<u64>,379
- },380
- Voice {381
- duration_ms: u64,382
- waveform: Vec<u16>,383
- },384
- File,385
-}386
-387
-#[derive(Clone, Debug, Eq, PartialEq)]388
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]389
-#[non_exhaustive]390
-pub struct UploadThumbnail {391
- pub bytes: Vec<u8>,392
- pub mimetype: String,393
- pub width: u32,394
- pub height: u32,395
-}396
-397
-impl UploadThumbnail {398
- #[must_use]399
- pub fn new(bytes: Vec<u8>, mimetype: impl Into<String>, width: u32, height: u32) -> Self {400
- Self {401
- bytes,402
- mimetype: mimetype.into(),403
- width,404
- height,405
- }406
- }407
-}408
-409
-#[derive(Eq, PartialEq)]410
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]411
-#[non_exhaustive]412
-pub enum EncryptionCommand {413
- Recover(String),414
- CreateKeyBackup,415
- EnableKeyBackup,416
- DisableKeyBackup { delete_from_server: bool },417
- RecoverKeyBackup(String),418
- StartVerification(VerificationTarget),419
- Verify(VerificationAction),420
-}421
-422
-impl fmt::Debug for EncryptionCommand {423
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {424
- match self {425
- Self::Recover(_) => formatter.debug_tuple("Recover").field(&"...").finish(),426
- Self::CreateKeyBackup => formatter.write_str("CreateKeyBackup"),427
- Self::EnableKeyBackup => formatter.write_str("EnableKeyBackup"),428
- Self::DisableKeyBackup { delete_from_server } => formatter429
- .debug_struct("DisableKeyBackup")430
- .field("delete_from_server", delete_from_server)431
- .finish(),432
- Self::RecoverKeyBackup(_) => formatter433
- .debug_tuple("RecoverKeyBackup")434
- .field(&"...")435
- .finish(),436
- Self::StartVerification(target) => formatter437
- .debug_tuple("StartVerification")438
- .field(target)439
- .finish(),440
- Self::Verify(action) => formatter.debug_tuple("Verify").field(action).finish(),441
- }442
- }443
-}444
-445
-#[derive(Clone, Debug, Eq, PartialEq)]446
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]447
-#[non_exhaustive]448
-pub enum NotificationCommand {449
- SetRoomMode {450
- room_id: OwnedRoomId,451
- mode: RoomNotificationMode,452
- },453
- ResetRoomMode(OwnedRoomId),454
- SetDefaultMode {455
- encrypted: bool,456
- one_to_one: bool,457
- mode: RoomNotificationMode,458
- },459
- AddKeyword(String),460
- RemoveKeyword(String),461
-}462
-463
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]464
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]465
-#[non_exhaustive]466
-pub enum LifecycleCommand {467
- Shutdown,468
-}469
-470
-#[derive(Clone, Debug, Eq, PartialEq)]471
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]472
-#[non_exhaustive]473
-pub enum CommandOutput {474
- Done,475
- Room(OwnedRoomId),476
-}477
-478
-#[derive(Clone, Debug, Eq, PartialEq)]479
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]480
-#[non_exhaustive]481
-pub enum MessageContent {482
- Text {483
- content: RichText,484
- mentions: MessageMentions,485
- },486
- Emote {487
- content: RichText,488
- mentions: MessageMentions,489
- },490
- Reply {491
- event_id: OwnedEventId,492
- content: RichText,493
- mentions: MessageMentions,494
- notify_replied_to: bool,495
- },496
-}497
-498
-impl MessageContent {499
- #[must_use]500
- pub fn text(body: impl Into<String>) -> Self {501
- Self::Text {502
- content: RichText::plain(body),503
- mentions: MessageMentions::default(),504
- }505
- }506
-507
- #[must_use]508
- pub fn html(body: impl Into<String>, formatted_body: impl Into<String>) -> Self {509
- Self::Text {510
- content: RichText::html(body, formatted_body),511
- mentions: MessageMentions::default(),512
- }513
- }514
-515
- #[must_use]516
- pub fn emote(content: RichText, mentions: MessageMentions) -> Self {517
- Self::Emote { content, mentions }518
- }519
-520
- #[must_use]521
- pub fn reply(event_id: OwnedEventId, content: RichText, mentions: MessageMentions) -> Self {522
- Self::Reply {523
- event_id,524
- content,525
- mentions,526
- notify_replied_to: true,527
- }528
- }529
-530
- #[must_use]531
- pub fn reply_without_notification(532
- event_id: OwnedEventId,533
- content: RichText,534
- mentions: MessageMentions,535
- ) -> Self {536
- Self::Reply {537
- event_id,538
- content,539
- mentions,540
- notify_replied_to: false,541
- }542
- }543
-}544
-545
-#[derive(Clone, Debug, Eq, PartialEq)]546
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]547
-#[non_exhaustive]548
-pub enum MessageEditContent {549
- Text {550
- content: RichText,551
- mentions: MessageMentions,552
- },553
- Emote {554
- content: RichText,555
- mentions: MessageMentions,556
- },557
-}558
-559
-impl MessageEditContent {560
- #[must_use]561
- pub fn text(body: impl Into<String>) -> Self {562
- Self::Text {563
- content: RichText::plain(body),564
- mentions: MessageMentions::default(),565
- }566
- }567
-568
- #[must_use]569
- pub fn html(body: impl Into<String>, formatted_body: impl Into<String>) -> Self {570
- Self::Text {571
- content: RichText::html(body, formatted_body),572
- mentions: MessageMentions::default(),573
- }574
- }575
-576
- #[must_use]577
- pub fn emote(content: RichText, mentions: MessageMentions) -> Self {578
- Self::Emote { content, mentions }579
- }580
-}581
-582
-#[derive(Clone, Debug, Default, Eq, PartialEq)]583
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]584
-#[non_exhaustive]585
-pub struct RichText {586
- pub body: String,587
- pub formatted_body: Option<String>,588
-}589
-590
-impl RichText {591
- #[must_use]592
- pub fn plain(body: impl Into<String>) -> Self {593
- Self {594
- body: body.into(),595
- formatted_body: None,596
- }597
- }598
-599
- #[must_use]600
- pub fn html(body: impl Into<String>, formatted_body: impl Into<String>) -> Self {601
- Self {602
- body: body.into(),603
- formatted_body: Some(formatted_body.into()),604
- }605
- }606
-}607
-608
-#[derive(Clone, Debug, Default, Eq, PartialEq)]609
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]610
-#[non_exhaustive]611
-pub struct MessageMentions {612
- pub user_ids: Vec<OwnedUserId>,613
- pub room: bool,614
-}615
-616
-impl MessageMentions {617
- #[must_use]618
- pub fn with_user_ids(user_ids: impl IntoIterator<Item = OwnedUserId>) -> Self {619
- Self {620
- user_ids: user_ids.into_iter().collect(),621
- room: false,622
- }623
- }624
-625
- #[must_use]626
- pub fn room() -> Self {627
- Self {628
- user_ids: Vec::new(),629
- room: true,630
- }631
- }632
-}633
-634
-#[derive(Clone, Debug, Eq, PartialEq)]635
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]636
-#[non_exhaustive]637
-pub enum Event {638
- ConnectionChanged(ConnectionState),639
- Authenticated {640
- user_id: OwnedUserId,641
- },642
- /// the tokens rotated. with a `SessionStore` the record is already saved by now,643
- /// without one persist a fresh `ClientHandle::session_record()` atomically644
- SessionTokensChanged,645
- /// the rotated tokens didn't reach the `SessionStore`, so the saved record may not646
- /// restore. the engine tries again on the next rotation647
- SessionSaveFailed {648
- message: String,649
- },650
- /// the server stopped honouring our token and the engine stops. a soft logout651
- /// names the device to log in again as, keeping the store. otherwise the record652
- /// and the store are both dead653
- SessionAccessRejected {654
- soft_logout: Option<OwnedDeviceId>,655
- },656
- NotificationSettingsChanged,657
- VerificationTargetChanged(VerificationTarget),658
- RoomsChanged,659
- /// an image pack came, went or changed somewhere. ask `image_packs` again660
- ImagePacksChanged,661
- /// replaces whatever you had for this timeline. every diff after it applies to it662
- TimelineReset {663
- timeline_id: TimelineId,664
- items: Vector<TimelineItem>,665
- },666
- TimelineChanged {667
- timeline_id: TimelineId,668
- diffs: Vec<TimelineDiff>,669
- },670
- HistoryChanged {671
- timeline_id: TimelineId,672
- state: HistoryState,673
- },674
- /// the timeline was closed, or its room was left, and nothing more comes for it675
- /// until another `TimelineReset`676
- TimelineClosed {677
- timeline_id: TimelineId,678
- },679
- /// a room that stops being watched says so with an empty `users`680
- TypingChanged(TypingState),681
- MediaUploadProgress {682
- room_id: OwnedRoomId,683
- transaction_id: OwnedTransactionId,684
- uploaded_bytes: u64,685
- total_bytes: u64,686
- complete: bool,687
- },688
- /// not the end of it. `will_retry` means the send queue has another go by itself,689
- /// otherwise it waits for `retry_send` or `cancel_send`690
- MediaUploadFailed {691
- room_id: OwnedRoomId,692
- transaction_id: OwnedTransactionId,693
- kind: crate::ErrorKind,694
- will_retry: bool,695
- },696
- /// an upload that turned up in a progress or failure event gets exactly one of697
- /// these while the engine runs, and nothing about it after. stopping ends698
- /// nothing, `Stopped` covers whatever is still going and the sdk resumes it next run699
- MediaUploadFinished {700
- room_id: OwnedRoomId,701
- transaction_id: OwnedTransactionId,702
- outcome: UploadOutcome,703
- },704
- EncryptionChanged(EncryptionState),705
- VerificationChanged(VerificationState),706
- /// logout went through and the engine stops. delete the record and the store707
- LoggedOut,708
- Failed(EngineFailure),709
- /// always the last event. keep the saved record unless `LoggedOut` or a710
- /// hard `SessionAccessRejected` came first. commands still in flight answer `Interrupted`711
- Stopped,712
-}713
-714
-#[derive(Clone, Debug, Eq, PartialEq)]715
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]716
-#[non_exhaustive]717
-pub enum UploadOutcome {718
- Sent {719
- event_id: OwnedEventId,720
- },721
- Cancelled,722
- /// its room was forgotten, and the queue with it723
- RoomForgotten,724
- /// the sdk's update channel overflowed while it ended, so whether it was sent is unknown725
- Missed,726
-}727
-728
-/// why `Engine::run` came back, when nothing went wrong729
-#[derive(Clone, Debug, Eq, PartialEq)]730
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]731
-#[non_exhaustive]732
-pub enum Termination {733
- ShutDown,734
- /// every `ClientHandle` was dropped, so nobody could have asked for anything else735
- HandlesDropped,736
- LoggedOut,737
- /// the server rejected our token for good738
- AccessRevoked,739
- /// the server wants this device to log in again, and its e2ee keys live in the store740
- SoftLoggedOut {741
- device_id: OwnedDeviceId,742
- },743
-}744
-745
-impl Termination {746
- #[must_use]747
- pub const fn retain(&self) -> Retain {748
- match self {749
- Self::ShutDown | Self::HandlesDropped => Retain::RecordAndStore,750
- Self::SoftLoggedOut { .. } => Retain::Store,751
- Self::LoggedOut | Self::AccessRevoked => Retain::Nothing,752
- }753
- }754
-}755
-756
-/// what's still worth keeping of the saved record and the store757
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]758
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]759
-pub enum Retain {760
- RecordAndStore,761
- /// keep the store and a record naming the device to log in again as762
- Store,763
- Nothing,764
-}765
-766
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]767
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]768
-#[non_exhaustive]769
-pub enum ConnectionState {770
- Idle,771
- Connecting,772
- Online,773
- Offline,774
- Stopped,775
-}776
-777
-#[derive(Clone, Debug, Eq, PartialEq)]778
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]779
-#[non_exhaustive]780
-pub struct RoomSummary {781
- pub room_id: OwnedRoomId,782
- pub name: String,783
- pub membership: RoomMembership,784
- pub unread_messages: u64,785
- pub unread_mentions: u64,786
- /// unread events the push rules said to notify about, which is neither of the above787
- pub unread_notifications: u64,788
- pub encrypted: bool,789
- pub space: bool,790
- pub direct: bool,791
- pub favourite: bool,792
- pub low_priority: bool,793
- pub avatar: Option<MediaId>,794
- pub latest_event_timestamp_ms: Option<u64>,795
- pub latest_event: Option<LatestEvent>,796
- pub notification_mode: Option<RoomNotificationMode>,797
- pub notification_mode_custom: bool,798
- pub topic: Option<String>,799
- pub canonical_alias: Option<OwnedRoomAliasId>,800
- pub joined_members: u64,801
- pub invited_members: u64,802
- /// who a nameless room gets named after803
- pub heroes: Vec<RoomHero>,804
- /// set once the room has been upgraded, this is where everyone went805
- pub successor: Option<OwnedRoomId>,806
- pub inviter: Option<OwnedUserId>,807
- /// joined spaces that list this room as a child808
- pub parent_spaces: Vec<OwnedRoomId>,809
- /// for a space, the children it lists, joined or not810
- pub children: Vec<OwnedRoomId>,811
-}812
-813
-#[derive(Clone, Debug, Eq, PartialEq)]814
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]815
-#[non_exhaustive]816
-pub struct RoomHero {817
- pub user_id: OwnedUserId,818
- pub display_name: Option<String>,819
- pub avatar: Option<MediaId>,820
-}821
-822
-/// enough for a room list preview line. `kind` and `body` are only set for messages,823
-/// and an edit shows its new text824
-#[derive(Clone, Debug, Eq, PartialEq)]825
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]826
-#[non_exhaustive]827
-pub struct LatestEvent {828
- pub sender: Option<OwnedUserId>,829
- pub kind: Option<MessageKind>,830
- pub body: Option<String>,831
- pub timestamp_ms: Option<u64>,832
- pub send_state: SendState,833
-}834
-835
-#[derive(Clone, Debug, Eq, PartialEq)]836
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]837
-#[non_exhaustive]838
-pub struct Profile {839
- pub user_id: OwnedUserId,840
- pub display_name: Option<String>,841
- pub avatar: Option<MediaId>,842
-}843
-844
-/// one room in a space's hierarchy, as the server describes it, which may be a room you're not in845
-#[derive(Clone, Debug, Eq, PartialEq)]846
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]847
-#[non_exhaustive]848
-pub struct SpaceChild {849
- pub room_id: OwnedRoomId,850
- pub name: String,851
- pub topic: Option<String>,852
- pub avatar: Option<MediaId>,853
- pub canonical_alias: Option<OwnedRoomAliasId>,854
- pub joined_members: u64,855
- pub space: bool,856
- pub children_count: u64,857
- pub membership: Option<RoomMembership>,858
- pub via: Vec<OwnedServerName>,859
- pub suggested: bool,860
- pub world_readable: Option<bool>,861
- pub guest_can_join: bool,862
-}863
-864
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]865
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]866
-#[non_exhaustive]867
-pub enum RoomNotificationMode {868
- AllMessages,869
- MentionsAndKeywordsOnly,870
- Mute,871
-}872
-873
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]874
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]875
-#[non_exhaustive]876
-pub struct RoomNotificationSettings {877
- pub mode: RoomNotificationMode,878
- pub custom: bool,879
-}880
-881
-#[derive(Clone, Debug)]882
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]883
-#[non_exhaustive]884
-pub enum PushNotificationStatus {885
- Event(Box<PushNotification>),886
- NotFound,887
- FilteredOut,888
- Redacted,889
-}890
-891
-#[derive(Clone, Debug)]892
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]893
-#[non_exhaustive]894
-pub struct PushNotification {895
- pub event_id: OwnedEventId,896
- pub event: PushNotificationEvent,897
- pub sender: PushNotificationSender,898
- pub room: PushNotificationRoom,899
- pub noisy: Option<bool>,900
- pub has_mention: Option<bool>,901
- pub thread_id: Option<OwnedEventId>,902
-}903
-904
-#[derive(Clone, Debug, Eq, PartialEq)]905
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]906
-#[non_exhaustive]907
-pub struct PushNotificationSender {908
- pub user_id: OwnedUserId,909
- pub display_name: Option<String>,910
- pub avatar_url: Option<MediaId>,911
- pub name_ambiguous: bool,912
-}913
-914
-#[derive(Clone, Debug, Eq, PartialEq)]915
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]916
-#[non_exhaustive]917
-pub struct PushNotificationRoom {918
- pub room_id: OwnedRoomId,919
- pub name: String,920
- pub avatar_url: Option<MediaId>,921
- pub canonical_alias: Option<OwnedRoomAliasId>,922
- pub topic: Option<String>,923
- pub encrypted: Option<bool>,924
- pub direct: bool,925
- pub joined_members_count: u64,926
- pub service_members: Vec<OwnedUserId>,927
- pub active_service_members_count: u64,928
- pub space: bool,929
-}930
-931
-#[derive(Clone, Debug)]932
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]933
-#[non_exhaustive]934
-pub enum PushNotificationEvent {935
- Timeline(Raw<AnySyncTimelineEvent>),936
- Invite(Raw<StrippedRoomMemberEvent>),937
-}938
-939
-#[derive(Clone, Debug, Eq, PartialEq)]940
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]941
-#[non_exhaustive]942
-pub struct RoomPowerLevels {943
- pub ban: i64,944
- pub invite: i64,945
- pub kick: i64,946
- pub redact: i64,947
- pub events_default: i64,948
- pub state_default: i64,949
- pub users_default: i64,950
- pub room_notification: i64,951
- pub users: Vec<(OwnedUserId, i64)>,952
- pub events: Vec<(String, i64)>,953
- /// in rooms from version 12 the creators outrank everyone and aren't listed in `users`954
- pub creators: Vec<OwnedUserId>,955
-}956
-957
-#[derive(Clone, Debug, Default, Eq, PartialEq)]958
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]959
-#[non_exhaustive]960
-pub struct PowerLevelChanges {961
- pub ban: Option<i64>,962
- pub invite: Option<i64>,963
- pub kick: Option<i64>,964
- pub redact: Option<i64>,965
- pub events_default: Option<i64>,966
- pub state_default: Option<i64>,967
- pub users_default: Option<i64>,968
- pub room_notification: Option<i64>,969
- pub room_name: Option<i64>,970
- pub room_avatar: Option<i64>,971
- pub room_topic: Option<i64>,972
- pub space_child: Option<i64>,973
- pub beacon: Option<i64>,974
- pub beacon_info: Option<i64>,975
-}976
-977
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]978
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]979
-#[non_exhaustive]980
-pub enum RoomMembership {981
- Joined,982
- Invited,983
- Left,984
- Knocked,985
- Banned,986
-}987
-988
-#[derive(Clone, Debug, Default, Eq, PartialEq)]989
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]990
-#[non_exhaustive]991
-pub struct RoomListFilter {992
- pub membership: Option<RoomMembership>,993
- pub category: Option<RoomCategory>,994
- pub favourite: Option<bool>,995
- pub unread_only: bool,996
- pub search: Option<String>,997
-}998
-999
-impl RoomListFilter {1000
- #[must_use]1001
- pub fn with_membership(mut self, membership: RoomMembership) -> Self {1002
- self.membership = Some(membership);1003
- self1004
- }1005
-1006
- #[must_use]1007
- pub fn with_category(mut self, category: RoomCategory) -> Self {1008
- self.category = Some(category);1009
- self1010
- }1011
-1012
- #[must_use]1013
- pub fn with_favourite(mut self, favourite: bool) -> Self {1014
- self.favourite = Some(favourite);1015
- self1016
- }1017
-1018
- #[must_use]1019
- pub const fn with_unread_only(mut self, unread_only: bool) -> Self {1020
- self.unread_only = unread_only;1021
- self1022
- }1023
-1024
- #[must_use]1025
- pub fn with_search(mut self, search: impl Into<String>) -> Self {1026
- self.search = Some(search.into());1027
- self1028
- }1029
-1030
- #[must_use]1031
- pub fn matches(&self, room: &RoomSummary) -> bool {1032
- self.matcher().matches(room)1033
- }1034
-1035
- /// lowercases the search once, for filtering a whole list1036
- #[must_use]1037
- pub fn matcher(&self) -> RoomMatcher<'_> {1038
- RoomMatcher {1039
- filter: self,1040
- search: self.search.as_deref().map(str::to_lowercase),1041
- }1042
- }1043
-}1044
-1045
-#[derive(Debug)]1046
-pub struct RoomMatcher<'a> {1047
- filter: &'a RoomListFilter,1048
- search: Option<String>,1049
-}1050
-1051
-impl RoomMatcher<'_> {1052
- #[must_use]1053
- pub fn matches(&self, room: &RoomSummary) -> bool {1054
- let filter = self.filter;1055
- filter1056
- .membership1057
- .is_none_or(|membership| room.membership == membership)1058
- && filter.category.is_none_or(|category| match category {1059
- RoomCategory::People => room.direct && !room.space,1060
- RoomCategory::Rooms => !room.direct && !room.space,1061
- RoomCategory::Spaces => room.space,1062
- })1063
- && filter1064
- .favourite1065
- .is_none_or(|favourite| room.favourite == favourite)1066
- && (!filter.unread_only || room.unread_messages > 0 || room.unread_mentions > 0)1067
- && self.search.as_ref().is_none_or(|search| {1068
- room.name.to_lowercase().contains(search)1069
- || room.room_id.as_str().to_lowercase().contains(search)1070
- })1071
- }1072
-}1073
-1074
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]1075
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1076
-#[non_exhaustive]1077
-pub enum RoomCategory {1078
- People,1079
- Rooms,1080
- Spaces,1081
-}1082
-1083
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]1084
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1085
-#[non_exhaustive]1086
-pub enum MemberFilter {1087
- Active,1088
- Joined,1089
- Invited,1090
- Banned,1091
- Left,1092
- All,1093
-}1094
-1095
-#[derive(Clone, Debug, Eq, PartialEq)]1096
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1097
-#[non_exhaustive]1098
-pub enum MemberMembership {1099
- Joined,1100
- Invited,1101
- Left,1102
- Knocked,1103
- Banned,1104
- Custom(String),1105
-}1106
-1107
-#[derive(Clone, Debug, Eq, PartialEq)]1108
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1109
-#[non_exhaustive]1110
-pub struct MemberSummary {1111
- pub user_id: OwnedUserId,1112
- pub name: String,1113
- pub display_name: Option<String>,1114
- pub avatar: Option<MediaId>,1115
- pub membership: MemberMembership,1116
- pub own: bool,1117
- pub name_ambiguous: bool,1118
- pub ignored: bool,1119
- pub service: bool,1120
-}1121
-1122
-#[derive(Clone, Debug, Eq, PartialEq)]1123
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1124
-#[non_exhaustive]1125
-pub struct OwnDevice {1126
- pub device_id: OwnedDeviceId,1127
- pub display_name: Option<String>,1128
- pub last_seen_ip: Option<String>,1129
- pub last_seen_timestamp_ms: Option<u64>,1130
- pub current: bool,1131
-}1132
-1133
-#[derive(Clone, Debug, Eq, PartialEq)]1134
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1135
-#[non_exhaustive]1136
-pub enum TimelineItem {1137
- Event(Box<TimelineEvent>),1138
- DayDivider { timestamp_ms: u64 },1139
- ReadMarker,1140
- TimelineStart,1141
-}1142
-1143
-#[derive(Clone, Debug, Eq, PartialEq)]1144
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1145
-#[non_exhaustive]1146
-pub enum TimelineDiff {1147
- Append { values: Vec<TimelineItem> },1148
- Clear,1149
- PushFront { value: TimelineItem },1150
- PushBack { value: TimelineItem },1151
- PopFront,1152
- PopBack,1153
- Insert { index: usize, value: TimelineItem },1154
- Set { index: usize, value: TimelineItem },1155
- Remove { index: usize },1156
- Truncate { length: usize },1157
- Reset { values: Vec<TimelineItem> },1158
-}1159
-1160
-#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]1161
-#[error("timeline diff is incompatible with the current {length}-item snapshot")]1162
-pub struct TimelineDiffError {1163
- pub length: usize,1164
-}1165
-1166
-impl TimelineDiff {1167
- /// Apply a diff only when its indices match the current snapshot.1168
- /// On error the snapshot is unchanged; request `TimelineCommand::Resync`1169
- /// and ignore further diffs until the corresponding `TimelineReset`.1170
- pub fn try_apply(self, items: &mut Vector<TimelineItem>) -> Result<(), TimelineDiffError> {1171
- let length = items.len();1172
- if self.length_after(length).is_none() {1173
- return Err(TimelineDiffError { length });1174
- }1175
- self.apply(items);1176
- Ok(())1177
- }1178
-1179
- /// a whole `TimelineChanged` or none of it. whether a diff fits depends only on the1180
- /// length before it, so the batch is checked against lengths alone before anything1181
- /// moves. the error carries the length the snapshot would have had at the misfit1182
- pub fn try_apply_all(1183
- diffs: Vec<Self>,1184
- items: &mut Vector<TimelineItem>,1185
- ) -> Result<(), TimelineDiffError> {1186
- diffs.iter().try_fold(items.len(), |length, diff| {1187
- diff.length_after(length)1188
- .ok_or(TimelineDiffError { length })1189
- })?;1190
- for diff in diffs {1191
- diff.apply(items);1192
- }1193
- Ok(())1194
- }1195
-1196
- fn length_after(&self, length: usize) -> Option<usize> {1197
- match self {1198
- Self::Append { values } => Some(length + values.len()),1199
- Self::Clear => Some(0),1200
- Self::PushFront { .. } | Self::PushBack { .. } => Some(length + 1),1201
- Self::PopFront | Self::PopBack => length.checked_sub(1),1202
- Self::Insert { index, .. } => (*index <= length).then_some(length + 1),1203
- Self::Set { index, .. } => (*index < length).then_some(length),1204
- Self::Remove { index } => (*index < length).then(|| length - 1),1205
- Self::Truncate { length: target } => (*target <= length).then_some(*target),1206
- Self::Reset { values } => Some(values.len()),1207
- }1208
- }1209
-1210
- fn apply(self, items: &mut Vector<TimelineItem>) {1211
- match self {1212
- Self::Append { values } => items.extend(values),1213
- Self::Clear => items.clear(),1214
- Self::PushFront { value } => items.push_front(value),1215
- Self::PushBack { value } => items.push_back(value),1216
- Self::PopFront => {1217
- items.pop_front();1218
- }1219
- Self::PopBack => {1220
- items.pop_back();1221
- }1222
- Self::Insert { index, value } => items.insert(index, value),1223
- Self::Set { index, value } => {1224
- items.set(index, value);1225
- }1226
- Self::Remove { index } => {1227
- items.remove(index);1228
- }1229
- Self::Truncate { length } => items.truncate(length),1230
- Self::Reset { values } => *items = values.into_iter().collect(),1231
- }1232
- }1233
-}1234
-1235
-#[cfg(test)]1236
-mod tests {1237
- use imbl::{Vector, vector};1238
-1239
- use super::{TimelineDiff, TimelineItem};1240
-1241
- fn item(timestamp_ms: u64) -> TimelineItem {1242
- TimelineItem::DayDivider { timestamp_ms }1243
- }1244
-1245
- #[test]1246
- fn timeline_diff_try_apply_handles_every_variant() {1247
- let mut items = vector![item(1), item(2)];1248
-1249
- TimelineDiff::Append {1250
- values: vec![item(3)],1251
- }1252
- .try_apply(&mut items)1253
- .unwrap();1254
- assert_eq!(items, vector![item(1), item(2), item(3)]);1255
-1256
- TimelineDiff::Clear.try_apply(&mut items).unwrap();1257
- assert!(items.is_empty());1258
-1259
- TimelineDiff::PushFront { value: item(2) }1260
- .try_apply(&mut items)1261
- .unwrap();1262
- TimelineDiff::PushBack { value: item(3) }1263
- .try_apply(&mut items)1264
- .unwrap();1265
- assert_eq!(items, vector![item(2), item(3)]);1266
-1267
- TimelineDiff::PopFront.try_apply(&mut items).unwrap();1268
- TimelineDiff::PopBack.try_apply(&mut items).unwrap();1269
- assert!(items.is_empty());1270
-1271
- TimelineDiff::Reset {1272
- values: vec![item(1), item(3)],1273
- }1274
- .try_apply(&mut items)1275
- .unwrap();1276
- TimelineDiff::Insert {1277
- index: 1,1278
- value: item(2),1279
- }1280
- .try_apply(&mut items)1281
- .unwrap();1282
- assert_eq!(items, vector![item(1), item(2), item(3)]);1283
-1284
- TimelineDiff::Set {1285
- index: 1,1286
- value: item(4),1287
- }1288
- .try_apply(&mut items)1289
- .unwrap();1290
- assert_eq!(items, vector![item(1), item(4), item(3)]);1291
-1292
- TimelineDiff::Remove { index: 1 }1293
- .try_apply(&mut items)1294
- .unwrap();1295
- assert_eq!(items, vector![item(1), item(3)]);1296
-1297
- TimelineDiff::Truncate { length: 1 }1298
- .try_apply(&mut items)1299
- .unwrap();1300
- assert_eq!(items, vector![item(1)]);1301
- }1302
-1303
- #[test]1304
- fn invalid_timeline_diff_leaves_snapshot_unchanged() {1305
- let invalid = [1306
- TimelineDiff::Insert {1307
- index: 3,1308
- value: item(3),1309
- },1310
- TimelineDiff::Set {1311
- index: 2,1312
- value: item(3),1313
- },1314
- TimelineDiff::Remove { index: 2 },1315
- TimelineDiff::Truncate { length: 3 },1316
- ];1317
- for diff in invalid {1318
- let mut items = vector![item(1), item(2)];1319
- assert_eq!(diff.try_apply(&mut items).unwrap_err().length, 2);1320
- assert_eq!(items, vector![item(1), item(2)]);1321
- }1322
- for diff in [TimelineDiff::PopFront, TimelineDiff::PopBack] {1323
- let mut items = Vector::new();1324
- assert_eq!(diff.try_apply(&mut items).unwrap_err().length, 0);1325
- assert!(items.is_empty());1326
- }1327
- }1328
-1329
- #[test]1330
- fn a_batch_that_goes_wrong_halfway_changes_nothing() {1331
- let mut items = vector![item(1), item(2)];1332
- let batch = vec![1333
- TimelineDiff::PushBack { value: item(3) },1334
- TimelineDiff::PushBack { value: item(4) },1335
- TimelineDiff::Remove { index: 0 },1336
- TimelineDiff::Set {1337
- index: 3,1338
- value: item(5),1339
- },1340
- ];1341
- assert_eq!(1342
- TimelineDiff::try_apply_all(batch, &mut items)1343
- .unwrap_err()1344
- .length,1345
- 31346
- );1347
- assert_eq!(items, vector![item(1), item(2)]);1348
-1349
- let batch = vec![1350
- TimelineDiff::PushBack { value: item(3) },1351
- TimelineDiff::Remove { index: 0 },1352
- TimelineDiff::Set {1353
- index: 1,1354
- value: item(4),1355
- },1356
- ];1357
- TimelineDiff::try_apply_all(batch, &mut items).unwrap();1358
- assert_eq!(items, vector![item(2), item(4)]);1359
- }1360
-}1361
-1362
-#[derive(Clone, Debug, Eq, Hash, PartialEq)]1363
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1364
-pub struct TimelineItemId(String);1365
-1366
-impl TimelineItemId {1367
- pub(crate) fn new(value: String) -> Self {1368
- Self(value)1369
- }1370
-1371
- #[must_use]1372
- pub fn as_str(&self) -> &str {1373
- &self.01374
- }1375
-}1376
-1377
-#[derive(Clone, Debug, Eq, PartialEq)]1378
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1379
-#[non_exhaustive]1380
-pub struct TimelineEvent {1381
- pub item_id: TimelineItemId,1382
- pub event_id: Option<OwnedEventId>,1383
- pub transaction_id: Option<OwnedTransactionId>,1384
- pub sender: Sender,1385
- pub timestamp_ms: u64,1386
- pub own: bool,1387
- pub send_state: SendState,1388
- pub content: TimelineContent,1389
- pub reply_to: Option<ReplyReference>,1390
- pub thread_root: Option<OwnedEventId>,1391
- pub thread_summary: Option<ThreadSummary>,1392
- pub reactions: Vec<Reaction>,1393
- pub read_by: Vec<OwnedUserId>,1394
- pub editable: bool,1395
-}1396
-1397
-impl TimelineEvent {1398
- /// what to hand edit, redact and react. works before the server has echoed it back1399
- #[must_use]1400
- pub fn target(&self) -> Option<EventRef> {1401
- self.event_id1402
- .clone()1403
- .map(EventRef::Remote)1404
- .or_else(|| self.transaction_id.clone().map(EventRef::Local))1405
- }1406
-}1407
-1408
-#[derive(Clone, Debug, Eq, Hash, PartialEq)]1409
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1410
-#[non_exhaustive]1411
-pub enum EventRef {1412
- Remote(OwnedEventId),1413
- /// a local echo, by the transaction id it was queued under1414
- Local(OwnedTransactionId),1415
-}1416
-1417
-impl From<OwnedEventId> for EventRef {1418
- fn from(event_id: OwnedEventId) -> Self {1419
- Self::Remote(event_id)1420
- }1421
-}1422
-1423
-#[derive(Clone, Debug, Eq, PartialEq)]1424
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1425
-#[non_exhaustive]1426
-pub struct Sender {1427
- pub user_id: OwnedUserId,1428
- pub display_name: Option<String>,1429
- pub avatar: Option<MediaId>,1430
- /// someone else in the room goes by the same name, so show the user id too1431
- pub name_ambiguous: bool,1432
-}1433
-1434
-impl Sender {1435
- #[must_use]1436
- pub fn name(&self) -> &str {1437
- self.display_name1438
- .as_deref()1439
- .unwrap_or_else(|| self.user_id.as_str())1440
- }1441
-}1442
-1443
-#[derive(Clone, Debug, Eq, PartialEq)]1444
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1445
-#[non_exhaustive]1446
-pub enum TimelineContent {1447
- Message(Message),1448
- Sticker {1449
- body: String,1450
- media: MediaDescriptor,1451
- },1452
- Poll {1453
- question: String,1454
- },1455
- Redacted,1456
- UnableToDecrypt(UtdCause),1457
- Membership {1458
- user_id: OwnedUserId,1459
- display_name: Option<String>,1460
- change: MembershipChange,1461
- },1462
- Profile {1463
- user_id: OwnedUserId,1464
- display_name: Option<Change<Option<String>>>,1465
- avatar: Option<Change<Option<MediaId>>>,1466
- },1467
- /// `None` means it was cleared, or the event was redacted1468
- RoomName(Option<String>),1469
- RoomTopic(Option<String>),1470
- RoomAvatar(Option<MediaId>),1471
- EncryptionEnabled,1472
- RoomCreated,1473
- RoomUpgraded {1474
- successor: OwnedRoomId,1475
- },1476
- OtherState {1477
- event_type: String,1478
- state_key: String,1479
- },1480
- Other {1481
- event_type: String,1482
- },1483
-}1484
-1485
-#[derive(Clone, Debug, Eq, PartialEq)]1486
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1487
-#[non_exhaustive]1488
-pub struct Message {1489
- pub kind: MessageKind,1490
- pub body: String,1491
- /// only for `MessageKind::Location`1492
- pub geo_uri: Option<String>,1493
- pub formatted_body: Option<String>,1494
- pub mentions: Option<MessageMentions>,1495
- pub edited: bool,1496
- 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>,1527
-}1528
-1529
-#[derive(Clone, Debug, Eq, PartialEq)]1530
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1531
-#[non_exhaustive]1532
-pub enum MessageKind {1533
- Text,1534
- Emote,1535
- Notice,1536
- ServerNotice,1537
- Image,1538
- Video,1539
- Audio,1540
- File,1541
- Location,1542
- VerificationRequest,1543
- Other(String),1544
-}1545
-1546
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]1547
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1548
-#[non_exhaustive]1549
-pub enum MembershipChange {1550
- Joined,1551
- Left,1552
- Banned,1553
- Unbanned,1554
- Kicked,1555
- KickedAndBanned,1556
- Invited,1557
- InvitationAccepted,1558
- InvitationRejected,1559
- InvitationRevoked,1560
- Knocked,1561
- KnockAccepted,1562
- KnockRetracted,1563
- KnockDenied,1564
- /// nothing a person would call a change, or something the sdk couldn't make sense of1565
- Other,1566
-}1567
-1568
-#[derive(Clone, Debug, Eq, PartialEq)]1569
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1570
-pub struct Change<T> {1571
- pub old: T,1572
- pub new: T,1573
-}1574
-1575
-/// why a message couldn't be read, worth telling the user since some of these they can fix1576
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]1577
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1578
-#[non_exhaustive]1579
-pub enum UtdCause {1580
- Unknown,1581
- SentBeforeWeJoined,1582
- VerificationViolation,1583
- UnsignedDevice,1584
- UnknownDevice,1585
- HistoricalMessageAndBackupIsDisabled,1586
- WithheldForUnverifiedOrInsecureDevice,1587
- WithheldBySender,1588
- HistoricalMessageAndDeviceIsUnverified,1589
-}1590
-1591
-#[derive(Clone, Debug, Eq, PartialEq)]1592
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1593
-#[non_exhaustive]1594
-pub struct ThreadSummary {1595
- pub reply_count: u32,1596
- pub latest_event_id: Option<OwnedEventId>,1597
- pub latest_event_timestamp_ms: Option<u64>,1598
-}1599
-1600
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]1601
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1602
-#[non_exhaustive]1603
-pub enum SendState {1604
- Sent,1605
- Sending,1606
- /// `will_retry` means macltok has it in hand. otherwise it's wedged until you1607
- /// `retry_send` or `cancel_send`, so that's when to show a retry button1608
- Failed {1609
- will_retry: bool,1610
- },1611
-}1612
-1613
-#[derive(Clone, Debug, Eq, PartialEq)]1614
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1615
-#[non_exhaustive]1616
-pub struct ReplyReference {1617
- pub event_id: OwnedEventId,1618
- pub details: ReplyDetails,1619
-}1620
-1621
-/// the replied-to event. macltok asks for it when it isn't loaded, `Pending` then turns into one of the others1622
-#[derive(Clone, Debug, Eq, PartialEq)]1623
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1624
-#[non_exhaustive]1625
-pub enum ReplyDetails {1626
- Pending,1627
- Ready {1628
- sender: Sender,1629
- content: Box<TimelineContent>,1630
- timestamp_ms: u64,1631
- },1632
- Unavailable,1633
-}1634
-1635
-#[derive(Clone, Debug, Eq, PartialEq)]1636
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1637
-#[non_exhaustive]1638
-pub struct Reaction {1639
- 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>,1644
- pub count: usize,1645
- pub own: bool,1646
- pub senders: Vec<OwnedUserId>,1647
-}1648
-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
-1727
-#[derive(Clone, Debug, Eq, PartialEq)]1728
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1729
-#[non_exhaustive]1730
-pub struct MediaDescriptor {1731
- pub id: MediaId,1732
- pub kind: MediaKind,1733
- pub filename: String,1734
- pub caption: Option<String>,1735
- pub mimetype: Option<String>,1736
- pub size_bytes: Option<u64>,1737
- pub width: Option<u64>,1738
- pub height: Option<u64>,1739
- pub duration_ms: Option<u64>,1740
- pub blurhash: Option<String>,1741
- /// the uploader sent a thumbnail, so `MediaSize::Thumbnail` won't need the server to resize1742
- pub has_thumbnail: bool,1743
-}1744
-1745
-#[derive(Clone, Debug, Eq, Hash, PartialEq)]1746
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1747
-pub struct MediaId(String);1748
-1749
-impl MediaId {1750
- pub(crate) fn new(value: String) -> Self {1751
- Self(value)1752
- }1753
-1754
- /// plain media carries no keys, so it needs no registering and never expires1755
- pub(crate) fn plain<U: AsRef<str> + ?Sized>(uri: &U) -> Self {1756
- Self(uri.as_ref().to_owned())1757
- }1758
-1759
- pub(crate) fn as_plain(&self) -> Option<&MxcUri> {1760
- self.0.starts_with("mxc://").then(|| self.0.as_str().into())1761
- }1762
-1763
- #[must_use]1764
- pub fn as_str(&self) -> &str {1765
- &self.01766
- }1767
-}1768
-1769
-/// media ids are opaque but they do have to survive a trip through a browser. a made up one just fails `MediaUnknown`1770
-impl std::str::FromStr for MediaId {1771
- type Err = std::convert::Infallible;1772
-1773
- fn from_str(value: &str) -> Result<Self, Self::Err> {1774
- Ok(Self(value.to_owned()))1775
- }1776
-}1777
-1778
-impl From<String> for MediaId {1779
- fn from(value: String) -> Self {1780
- Self(value)1781
- }1782
-}1783
-1784
-impl std::fmt::Display for MediaId {1785
- fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {1786
- self.0.fmt(formatter)1787
- }1788
-}1789
-1790
-#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]1791
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1792
-#[non_exhaustive]1793
-pub enum MediaSize {1794
- Full,1795
- /// a hint. an uploader's own thumbnail wins over the server resizing anything1796
- Thumbnail {1797
- width: u32,1798
- height: u32,1799
- },1800
-}1801
-1802
-#[derive(Clone, Debug, Eq, PartialEq)]1803
-#[non_exhaustive]1804
-pub struct Media {1805
- pub bytes: Arc<[u8]>,1806
- pub mimetype: String,1807
-}1808
-1809
-/// where media lives, for a host that fetches and decrypts it itself1810
-#[derive(Clone, Debug, Eq, PartialEq)]1811
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1812
-#[non_exhaustive]1813
-pub enum MediaLocation {1814
- File(MediaFile),1815
- /// a resize of plaintext, so never encrypted. plenty of servers refuse some, and `media`1816
- /// then fetches `fallback` instead, so should you1817
- ServerThumbnail {1818
- url: String,1819
- fallback: MediaFile,1820
- },1821
- /// still in the send queue with no url yet. `media` has the bytes1822
- Local,1823
-}1824
-1825
-#[derive(Clone, Debug, Eq, PartialEq)]1826
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1827
-#[non_exhaustive]1828
-pub struct MediaFile {1829
- pub url: String,1830
- pub mimetype: Option<String>,1831
- pub declared_size: Option<u64>,1832
- pub encryption: Option<AttachmentEncryption>,1833
-}1834
-1835
-/// AES-256-CTR, the only attachment cipher matrix has1836
-#[derive(Clone, Debug, Eq, PartialEq)]1837
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1838
-#[non_exhaustive]1839
-pub struct AttachmentEncryption {1840
- /// the JWK `k`, unpadded url-safe base641841
- pub key: String,1842
- /// unpadded base64, the initial counter block1843
- pub iv: String,1844
- /// unpadded base64 SHA-256 of the whole ciphertext, so a partial read goes unchecked1845
- pub sha256: String,1846
-}1847
-1848
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]1849
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1850
-#[non_exhaustive]1851
-pub enum MediaKind {1852
- Image,1853
- Video,1854
- Audio,1855
- File,1856
- Sticker,1857
-}1858
-1859
-#[derive(Clone, Debug, Eq, PartialEq)]1860
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1861
-#[non_exhaustive]1862
-pub struct TypingState {1863
- pub room_id: OwnedRoomId,1864
- pub users: Vec<OwnedUserId>,1865
-}1866
-1867
-#[derive(Clone, Debug, Eq, PartialEq)]1868
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1869
-#[non_exhaustive]1870
-pub enum HistoryState {1871
- Idle,1872
- Loading,1873
- StartReached,1874
- Failed(String),1875
-}1876
-1877
-#[derive(Clone, Debug, Eq, PartialEq)]1878
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1879
-#[non_exhaustive]1880
-pub struct EncryptionState {1881
- pub device_id: OwnedDeviceId,1882
- pub device_verified: bool,1883
- pub recovery: RecoveryState,1884
- pub key_backup: KeyBackupState,1885
-}1886
-1887
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]1888
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1889
-#[non_exhaustive]1890
-pub enum KeyBackupState {1891
- Unknown,1892
- Creating,1893
- Enabling,1894
- Resuming,1895
- Downloading,1896
- Enabled,1897
- Disabling,1898
-}1899
-1900
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]1901
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1902
-#[non_exhaustive]1903
-pub enum RecoveryState {1904
- Unknown,1905
- Disabled,1906
- Incomplete,1907
- Enabled,1908
-}1909
-1910
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]1911
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1912
-#[non_exhaustive]1913
-pub enum VerificationAction {1914
- /// take up a request another device sent, which arrives as `VerificationState::Incoming`1915
- Accept,1916
- Confirm,1917
- Reject,1918
- Cancel,1919
-}1920
-1921
-#[derive(Clone, Debug, Eq, PartialEq)]1922
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1923
-#[non_exhaustive]1924
-pub enum VerificationTarget {1925
- OwnIdentity,1926
- User(OwnedUserId),1927
- Device {1928
- user_id: OwnedUserId,1929
- device_id: OwnedDeviceId,1930
- },1931
-}1932
-1933
-#[derive(Clone, Debug, Eq, PartialEq)]1934
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1935
-#[non_exhaustive]1936
-pub enum VerificationState {1937
- Waiting,1938
- /// someone else asked. `Accept` to go ahead, `Cancel` to turn them down1939
- Incoming {1940
- user_id: OwnedUserId,1941
- device_id: Option<OwnedDeviceId>,1942
- },1943
- Comparison {1944
- emojis: Vec<(String, String)>,1945
- decimals: (u16, u16, u16),1946
- },1947
- Confirmed,1948
- Done,1949
- Cancelled(String),1950
- Failed(VerificationFailure),1951
-}1952
-1953
-#[derive(Clone, Debug, Eq, PartialEq)]1954
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1955
-#[non_exhaustive]1956
-pub enum VerificationFailure {1957
- RequestAcceptance(String),1958
- SasStartup(String),1959
- SasAcceptance(String),1960
- RequestStreamEnded,1961
- SasStreamEnded,1962
-}1963
-1964
-#[derive(Clone, Debug, Eq, PartialEq)]1965
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1966
-#[non_exhaustive]1967
-pub struct EngineFailure {1968
- pub kind: EngineErrorKind,1969
- pub message: String,1970
-}1971
-1972
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]1973
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1974
-#[non_exhaustive]1975
-pub enum EngineErrorKind {1976
- EventReceiverClosed,1977
- Watcher,1978
- Other,1979
-}@@ -1,152 +0,0 @@
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
-}@@ -1,217 +0,0 @@
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
-}@@ -1,339 +0,0 @@
1
-use std::sync::atomic::{AtomicU64, Ordering};2
-3
-use matrix_sdk::{media::MediaEventContent, ruma::events::room::MediaSource};4
-use matrix_sdk_ui::timeline::{MsgLikeKind, TimelineItem as SdkTimelineItem, TimelineItemContent};5
-use ruma::events::{6
- room::{7
- ImageInfo, ThumbnailInfo,8
- message::{FileInfo, MessageType, VideoInfo},9
- },10
- sticker::StickerEventContent,11
-};12
-13
-use super::link_preview::sender_previews;14
-use crate::{MediaDescriptor, MediaId, MediaKind};15
-16
-static NEXT_MEDIA_SCOPE: AtomicU64 = AtomicU64::new(1);17
-18
-/// Per-timeline-session nonce namespacing recycled SDK unique IDs.19
-///20
-/// SDK 0.18 recycles [`SdkTimelineItem::unique_id`] across the local-to-remote21
-/// echo replacement, while transaction IDs disappear. Scoping the recycled ID22
-/// by a process-wide nonce keeps media IDs stable across replacement without23
-/// colliding across rooms, threads, or reopened sessions.24
-#[derive(Clone, Debug)]25
-pub(crate) struct MediaScope {26
- nonce: u64,27
-}28
-29
-impl MediaScope {30
- pub(crate) fn new() -> Self {31
- Self {32
- nonce: NEXT_MEDIA_SCOPE.fetch_add(1, Ordering::Relaxed),33
- }34
- }35
-36
- pub(crate) fn owns(&self, id: &MediaId) -> bool {37
- id.as_str()38
- .split_once('#')39
- .is_some_and(|(nonce, _)| nonce.parse() == Ok(self.nonce))40
- }41
-}42
-43
-#[derive(Clone)]44
-pub(crate) struct MediaRegistration {45
- pub(crate) id: MediaId,46
- pub(crate) kind: MediaKind,47
- pub(crate) full: MediaSourceInfo,48
- pub(crate) thumbnail: Option<MediaSourceInfo>,49
-}50
-51
-#[derive(Clone)]52
-pub(crate) struct MediaSourceInfo {53
- pub(crate) source: MediaSource,54
- pub(crate) mimetype: Option<String>,55
- pub(crate) declared_size: Option<u64>,56
-}57
-58
-pub(super) fn media_registrations_for_item(59
- item: &SdkTimelineItem,60
- scope: &MediaScope,61
-) -> Vec<MediaRegistration> {62
- let Some(event) = item.as_event() else {63
- return Vec::new();64
- };65
- let identity = media_identity(item, scope);66
- let TimelineItemContent::MsgLike(content) = event.content() else {67
- return Vec::new();68
- };69
- match &content.kind {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(),82
- }83
-}84
-85
-pub(super) fn media_identity(item: &SdkTimelineItem, scope: &MediaScope) -> String {86
- format!("{}#{}", scope.nonce, item.unique_id().0)87
-}88
-89
-pub(super) fn media_from_message(identity: &str, message: &MessageType) -> Option<MediaDescriptor> {90
- let descriptor = |kind, filename: &str, caption: Option<&str>| MediaDescriptor {91
- id: media_id(identity, "media"),92
- kind,93
- filename: filename.to_owned(),94
- caption: caption.map(str::to_owned),95
- mimetype: None,96
- size_bytes: None,97
- width: None,98
- height: None,99
- duration_ms: None,100
- blurhash: None,101
- has_thumbnail: false,102
- };103
- let millis = |duration: std::time::Duration| u64::try_from(duration.as_millis()).ok();104
- Some(match message {105
- MessageType::Image(content) => {106
- let info = content.info.as_deref();107
- MediaDescriptor {108
- mimetype: info.and_then(|info| info.mimetype.clone()),109
- size_bytes: info.and_then(|info| info.size).map(u64::from),110
- width: info.and_then(|info| info.width).map(u64::from),111
- height: info.and_then(|info| info.height).map(u64::from),112
- blurhash: info.and_then(|info| info.blurhash.clone()),113
- has_thumbnail: info.is_some_and(|info| info.thumbnail_source.is_some()),114
- ..descriptor(MediaKind::Image, content.filename(), content.caption())115
- }116
- }117
- MessageType::Video(content) => {118
- let info = content.info.as_deref();119
- MediaDescriptor {120
- mimetype: info.and_then(|info| info.mimetype.clone()),121
- size_bytes: info.and_then(|info| info.size).map(u64::from),122
- width: info.and_then(|info| info.width).map(u64::from),123
- height: info.and_then(|info| info.height).map(u64::from),124
- duration_ms: info.and_then(|info| info.duration).and_then(millis),125
- blurhash: info.and_then(|info| info.blurhash.clone()),126
- has_thumbnail: info.is_some_and(|info| info.thumbnail_source.is_some()),127
- ..descriptor(MediaKind::Video, content.filename(), content.caption())128
- }129
- }130
- MessageType::Audio(content) => {131
- let info = content.info.as_deref();132
- MediaDescriptor {133
- mimetype: info.and_then(|info| info.mimetype.clone()),134
- size_bytes: info.and_then(|info| info.size).map(u64::from),135
- duration_ms: info.and_then(|info| info.duration).and_then(millis),136
- ..descriptor(MediaKind::Audio, content.filename(), content.caption())137
- }138
- }139
- MessageType::File(content) => {140
- let info = content.info.as_deref();141
- MediaDescriptor {142
- mimetype: info.and_then(|info| info.mimetype.clone()),143
- size_bytes: info.and_then(|info| info.size).map(u64::from),144
- has_thumbnail: info.is_some_and(|info| info.thumbnail_source.is_some()),145
- ..descriptor(MediaKind::File, content.filename(), content.caption())146
- }147
- }148
- _ => return None,149
- })150
-}151
-152
-pub(super) fn media_descriptor_for_sticker(153
- identity: &str,154
- content: &StickerEventContent,155
-) -> MediaDescriptor {156
- MediaDescriptor {157
- id: media_id(identity, "sticker"),158
- kind: MediaKind::Sticker,159
- filename: content.body.clone(),160
- caption: None,161
- mimetype: content.info.mimetype.clone(),162
- size_bytes: content.info.size.map(u64::from),163
- width: content.info.width.map(u64::from),164
- height: content.info.height.map(u64::from),165
- duration_ms: None,166
- blurhash: content.info.blurhash.clone(),167
- has_thumbnail: content.info.thumbnail_source.is_some(),168
- }169
-}170
-171
-pub(super) fn media_id(identity: &str, suffix: &str) -> MediaId {172
- MediaId::new(format!("{identity}#{suffix}"))173
-}174
-175
-fn media_sticker_registration(176
- identity: &str,177
- content: &StickerEventContent,178
-) -> Option<MediaRegistration> {179
- Some(MediaRegistration {180
- id: media_id(identity, "sticker"),181
- kind: MediaKind::Sticker,182
- full: MediaSourceInfo {183
- source: content.source()?,184
- mimetype: content.info.mimetype.clone(),185
- declared_size: content.info.size.map(u64::from),186
- },187
- thumbnail: thumbnail_media_source(188
- content.info.thumbnail_source.clone(),189
- content.info.thumbnail_info.as_deref(),190
- ),191
- })192
-}193
-194
-fn media_registration(identity: &str, message: &MessageType) -> Option<MediaRegistration> {195
- match message {196
- MessageType::Image(content) => media_registration_with_info(197
- identity,198
- MediaKind::Image,199
- content.source(),200
- content.info.as_deref(),201
- ),202
- MessageType::Video(content) => media_registration_with_info(203
- identity,204
- MediaKind::Video,205
- content.source(),206
- content.info.as_deref(),207
- ),208
- MessageType::File(content) => media_registration_with_info(209
- identity,210
- MediaKind::File,211
- content.source(),212
- content.info.as_deref(),213
- ),214
- MessageType::Audio(content) => media_registration_with_parts(215
- identity,216
- MediaKind::Audio,217
- content.source(),218
- content.info.as_ref().and_then(|info| info.mimetype.clone()),219
- content220
- .info221
- .as_ref()222
- .and_then(|info| info.size)223
- .map(u64::from),224
- None,225
- ),226
- _ => None,227
- }228
-}229
-230
-fn media_registration_with_info<T: MediaInfo>(231
- identity: &str,232
- kind: MediaKind,233
- source: Option<MediaSource>,234
- info: Option<&T>,235
-) -> Option<MediaRegistration> {236
- media_registration_with_parts(237
- identity,238
- kind,239
- source,240
- info.and_then(MediaInfo::mimetype),241
- info.and_then(MediaInfo::declared_size),242
- info.and_then(MediaInfo::thumbnail),243
- )244
-}245
-246
-fn media_registration_with_parts(247
- identity: &str,248
- kind: MediaKind,249
- source: Option<MediaSource>,250
- mimetype: Option<String>,251
- declared_size: Option<u64>,252
- thumbnail: Option<MediaSourceInfo>,253
-) -> Option<MediaRegistration> {254
- Some(MediaRegistration {255
- id: media_id(identity, "media"),256
- kind,257
- full: MediaSourceInfo {258
- source: source?,259
- mimetype,260
- declared_size,261
- },262
- thumbnail,263
- })264
-}265
-266
-trait MediaInfo {267
- fn mimetype(&self) -> Option<String>;268
- fn declared_size(&self) -> Option<u64>;269
- fn thumbnail(&self) -> Option<MediaSourceInfo>;270
-}271
-272
-macro_rules! impl_media_info {273
- ($type:ty) => {274
- impl MediaInfo for $type {275
- fn mimetype(&self) -> Option<String> {276
- self.mimetype.clone()277
- }278
-279
- fn declared_size(&self) -> Option<u64> {280
- self.size.map(u64::from)281
- }282
-283
- fn thumbnail(&self) -> Option<MediaSourceInfo> {284
- thumbnail_media_source(285
- self.thumbnail_source.clone(),286
- self.thumbnail_info.as_deref(),287
- )288
- }289
- }290
- };291
-}292
-293
-impl_media_info!(ImageInfo);294
-impl_media_info!(VideoInfo);295
-impl_media_info!(FileInfo);296
-297
-fn thumbnail_media_source(298
- source: Option<MediaSource>,299
- info: Option<&ThumbnailInfo>,300
-) -> Option<MediaSourceInfo> {301
- source.map(|source| MediaSourceInfo {302
- source,303
- mimetype: info.and_then(|info| info.mimetype.clone()),304
- declared_size: info.and_then(|info| info.size).map(u64::from),305
- })306
-}307
-308
-#[cfg(test)]309
-mod tests {310
- use matrix_sdk::ruma::{311
- events::room::{ImageInfo, message::ImageMessageEventContent},312
- mxc_uri,313
- };314
-315
- use super::*;316
-317
- #[test]318
- fn image_messages_keep_media_metadata() {319
- let mut info = ImageInfo::new();320
- info.mimetype = Some("image/png".to_owned());321
- info.size = Some(65_536_u32.into());322
- info.width = Some(640_u32.into());323
- info.height = Some(480_u32.into());324
- let content = ImageMessageEventContent::plain(325
- "screen.png".to_owned(),326
- mxc_uri!("mxc://example.org/screen").to_owned(),327
- )328
- .info(Box::new(info));329
-330
- let media = media_from_message("$event:example.org", &MessageType::Image(content)).unwrap();331
- assert_eq!(media.id.as_str(), "$event:example.org#media");332
- assert_eq!(media.kind, MediaKind::Image);333
- assert_eq!(media.filename, "screen.png");334
- assert_eq!(media.caption, None);335
- assert_eq!(media.mimetype.as_deref(), Some("image/png"));336
- assert_eq!(media.size_bytes, Some(65_536));337
- assert_eq!((media.width, media.height), (Some(640), Some(480)));338
- }339
-}@@ -1,25 +0,0 @@
1
-use matrix_sdk::room::RoomMember;2
-use ruma::events::room::member::MembershipState;3
-4
-use crate::{MediaId, MemberMembership, MemberSummary};5
-6
-pub(crate) fn member_summary(member: RoomMember) -> MemberSummary {7
- MemberSummary {8
- user_id: member.user_id().to_owned(),9
- name: member.name().to_owned(),10
- display_name: member.display_name().map(ToOwned::to_owned),11
- avatar: member.avatar_url().map(MediaId::plain),12
- membership: match member.membership() {13
- MembershipState::Join => MemberMembership::Joined,14
- MembershipState::Invite => MemberMembership::Invited,15
- MembershipState::Leave => MemberMembership::Left,16
- MembershipState::Knock => MemberMembership::Knocked,17
- MembershipState::Ban => MemberMembership::Banned,18
- value => MemberMembership::Custom(value.to_string()),19
- },20
- own: member.is_account_user(),21
- name_ambiguous: member.name_ambiguous(),22
- ignored: member.is_ignored(),23
- service: member.is_service_member(),24
- }25
-}@@ -1,14 +0,0 @@
1
-mod image_pack;2
-mod link_preview;3
-mod media;4
-mod member;5
-mod room;6
-mod timeline;7
-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};10
-pub(crate) use media::{MediaRegistration, MediaScope, MediaSourceInfo};11
-pub(crate) use member::member_summary;12
-pub(crate) use room::notification_mode;13
-pub(crate) use room::{room_summaries, space_child};14
-pub(crate) use timeline::{message_kind, normalise_timeline, normalise_timeline_diffs};@@ -1,225 +0,0 @@
1
-use futures_util::{StreamExt, stream};2
-use matrix_sdk::{3
- Client, Room, RoomState,4
- deserialized_responses::SyncOrStrippedState,5
- latest_events::LatestEventValue,6
- ruma::events::{7
- AnyMessageLikeEventContent, AnySyncMessageLikeEvent, AnySyncTimelineEvent, SyncStateEvent,8
- room::message::{Relation, RoomMessageEventContent},9
- space::child::SpaceChildEventContent,10
- },11
-};12
-use matrix_sdk_ui::spaces::{SpaceRoom, SpaceService};13
-use ruma::OwnedRoomId;14
-15
-use super::message_kind;16
-use crate::{17
- LatestEvent, MediaId, MessageKind, RoomHero, RoomMembership, RoomNotificationMode, RoomSummary,18
- SendState, SpaceChild,19
-};20
-21
-pub(crate) async fn room_summaries(client: &Client, spaces: &SpaceService) -> Vec<RoomSummary> {22
- let mut summaries = stream::iter(client.rooms())23
- .map(|room| async move { room_summary(&room, spaces).await })24
- .buffer_unordered(16)25
- .collect::<Vec<_>>()26
- .await;27
- summaries.sort_by(|left, right| {28
- right29
- .latest_event_timestamp_ms30
- .cmp(&left.latest_event_timestamp_ms)31
- .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase()))32
- .then_with(|| left.room_id.cmp(&right.room_id))33
- });34
- summaries35
-}36
-37
-async fn room_summary(room: &Room, spaces: &SpaceService) -> RoomSummary {38
- let name = room39
- .display_name()40
- .await41
- .map(|name| name.to_string())42
- .unwrap_or_else(|_| room.room_id().to_string());43
- let custom_notification_mode = room.user_defined_notification_mode().await;44
- let effective_notification_mode = match custom_notification_mode {45
- Some(mode) => Some(mode),46
- None => room.notification_mode().await,47
- };48
- let inviter = match room.state() {49
- RoomState::Invited => room50
- .invite_details()51
- .await52
- .ok()53
- .map(|invite| invite.inviter_id),54
- _ => None,55
- };56
- let children = if room.is_space() {57
- space_children(room).await58
- } else {59
- Vec::new()60
- };61
- RoomSummary {62
- room_id: room.room_id().to_owned(),63
- name,64
- membership: membership(room.state()),65
- unread_messages: room.num_unread_messages(),66
- unread_mentions: room.num_unread_mentions(),67
- unread_notifications: room.num_unread_notifications(),68
- encrypted: room.encryption_state().is_encrypted(),69
- space: room.is_space(),70
- direct: room.is_direct().await.unwrap_or(false),71
- favourite: room.is_favourite(),72
- low_priority: room.is_low_priority(),73
- avatar: room.avatar_url().as_deref().map(MediaId::plain),74
- latest_event_timestamp_ms: room75
- .latest_event_timestamp()76
- .map(|value| value.get().into()),77
- latest_event: latest_event(room),78
- notification_mode: effective_notification_mode.map(notification_mode),79
- notification_mode_custom: custom_notification_mode.is_some(),80
- topic: room.topic(),81
- canonical_alias: room.canonical_alias(),82
- joined_members: room.joined_members_count(),83
- invited_members: room.invited_members_count(),84
- heroes: room85
- .heroes()86
- .into_iter()87
- .map(|hero| RoomHero {88
- user_id: hero.user_id,89
- display_name: hero.display_name,90
- avatar: hero.avatar_url.as_deref().map(MediaId::plain),91
- })92
- .collect(),93
- successor: room.successor_room().map(|successor| successor.room_id),94
- inviter,95
- parent_spaces: spaces96
- .joined_parents_of_child(room.room_id())97
- .await98
- .into_iter()99
- .map(|parent| parent.room_id)100
- .collect(),101
- children,102
- }103
-}104
-105
-const fn membership(state: RoomState) -> RoomMembership {106
- match state {107
- RoomState::Joined => RoomMembership::Joined,108
- RoomState::Invited => RoomMembership::Invited,109
- RoomState::Left => RoomMembership::Left,110
- RoomState::Knocked => RoomMembership::Knocked,111
- RoomState::Banned => RoomMembership::Banned,112
- }113
-}114
-115
-/// what the space itself lists, straight from local state. redacted children don't count116
-async fn space_children(room: &Room) -> Vec<OwnedRoomId> {117
- let Ok(events) = room118
- .get_state_events_static::<SpaceChildEventContent>()119
- .await120
- else {121
- return Vec::new();122
- };123
- events124
- .into_iter()125
- .filter_map(|event| match event.deserialize().ok()? {126
- SyncOrStrippedState::Sync(SyncStateEvent::Original(event)) => Some(event.state_key),127
- SyncOrStrippedState::Stripped(event) => Some(event.state_key),128
- SyncOrStrippedState::Sync(SyncStateEvent::Redacted(_)) => None,129
- })130
- .collect()131
-}132
-133
-fn latest_event(room: &Room) -> Option<LatestEvent> {134
- let own = || Some(room.own_user_id().to_owned());135
- let local = |local: &matrix_sdk::latest_events::LocalLatestEventValue| {136
- let preview = match local.content.deserialize() {137
- Ok(AnyMessageLikeEventContent::RoomMessage(content)) => preview(&content),138
- _ => (None, None),139
- };140
- (preview, Some(local.timestamp))141
- };142
- let (sender, (kind, body), timestamp, send_state) = match room.latest_event() {143
- LatestEventValue::None => return None,144
- LatestEventValue::Remote(event) => {145
- let preview = match event.raw().deserialize() {146
- Ok(AnySyncTimelineEvent::MessageLike(AnySyncMessageLikeEvent::RoomMessage(147
- message,148
- ))) => message149
- .as_original()150
- .map_or((None, None), |message| preview(&message.content)),151
- _ => (None, None),152
- };153
- (event.sender(), preview, event.timestamp(), SendState::Sent)154
- }155
- LatestEventValue::RemoteInvite {156
- timestamp, inviter, ..157
- } => (inviter, (None, None), Some(timestamp), SendState::Sent),158
- LatestEventValue::LocalIsSending(value) => {159
- let (preview, timestamp) = local(&value);160
- (own(), preview, timestamp, SendState::Sending)161
- }162
- LatestEventValue::LocalHasBeenSent { value, .. } => {163
- let (preview, timestamp) = local(&value);164
- (own(), preview, timestamp, SendState::Sent)165
- }166
- // the sdk won't say whether it'll retry, so assume the user has to167
- LatestEventValue::LocalCannotBeSent(value) => {168
- let (preview, timestamp) = local(&value);169
- (170
- own(),171
- preview,172
- timestamp,173
- SendState::Failed { will_retry: false },174
- )175
- }176
- };177
- Some(LatestEvent {178
- sender,179
- kind,180
- body,181
- timestamp_ms: timestamp.map(|timestamp| timestamp.get().into()),182
- send_state,183
- })184
-}185
-186
-/// the latest event of an edited message is the edit, whose own body is the `* fallback`187
-fn preview(content: &RoomMessageEventContent) -> (Option<MessageKind>, Option<String>) {188
- let msgtype = match &content.relates_to {189
- Some(Relation::Replacement(replacement)) => &replacement.new_content.msgtype,190
- _ => &content.msgtype,191
- };192
- (Some(message_kind(msgtype)), Some(msgtype.body().to_owned()))193
-}194
-195
-pub(crate) fn space_child(room: SpaceRoom) -> SpaceChild {196
- SpaceChild {197
- name: room.display_name,198
- room_id: room.room_id,199
- topic: room.topic,200
- avatar: room.avatar_url.as_deref().map(MediaId::plain),201
- canonical_alias: room.canonical_alias,202
- joined_members: room.num_joined_members,203
- space: room.room_type == Some(ruma::room::RoomType::Space),204
- children_count: room.children_count,205
- membership: room.state.map(membership),206
- via: room.via,207
- suggested: room.suggested,208
- world_readable: room.world_readable,209
- guest_can_join: room.guest_can_join,210
- }211
-}212
-213
-pub(crate) const fn notification_mode(214
- mode: matrix_sdk::notification_settings::RoomNotificationMode,215
-) -> RoomNotificationMode {216
- match mode {217
- matrix_sdk::notification_settings::RoomNotificationMode::AllMessages => {218
- RoomNotificationMode::AllMessages219
- }220
- matrix_sdk::notification_settings::RoomNotificationMode::MentionsAndKeywordsOnly => {221
- RoomNotificationMode::MentionsAndKeywordsOnly222
- }223
- matrix_sdk::notification_settings::RoomNotificationMode::Mute => RoomNotificationMode::Mute,224
- }225
-}@@ -1,451 +0,0 @@
1
-use std::sync::Arc;2
-3
-use matrix_sdk_crypto::types::events::UtdCause as SdkUtdCause;4
-use matrix_sdk_ui::{5
- eyeball_im::VectorDiff,6
- timeline::{7
- AnyOtherStateEventContentChange, EncryptedMessage, EventSendState, EventTimelineItem,8
- MembershipChange as SdkMembershipChange, MsgLikeContent, MsgLikeKind, Profile,9
- TimelineDetails, TimelineEventItemId, TimelineItem as SdkTimelineItem, TimelineItemContent,10
- TimelineItemKind, VirtualTimelineItem,11
- },12
-};13
-use ruma::{14
- MxcUri, OwnedEventId, UserId,15
- events::{16
- StateEventContentChange,17
- room::message::{MessageFormat, MessageType},18
- },19
-};20
-21
-use crate::{22
- Change, MediaId, MembershipChange, Message, MessageKind, MessageMentions, Reaction,23
- ReplyDetails, ReplyReference, SendState, Sender, ThreadSummary, TimelineContent, TimelineDiff,24
- TimelineEvent, TimelineItem, TimelineItemId, UtdCause,25
-};26
-27
-use super::link_preview::sender_previews;28
-use super::media::{29
- MediaRegistration, MediaScope, media_descriptor_for_sticker, media_from_message,30
- media_identity, media_registrations_for_item,31
-};32
-33
-#[derive(Default)]34
-pub(crate) struct NormalisedTimeline {35
- pub(crate) items: imbl::Vector<TimelineItem>,36
- pub(crate) media: Vec<MediaRegistration>,37
- /// replies whose target isn't loaded, so somebody should go and ask for it38
- pub(crate) missing_replies: Vec<OwnedEventId>,39
-}40
-41
-pub(crate) struct NormalisedTimelineDiffs {42
- pub(crate) diffs: Vec<TimelineDiff>,43
- pub(crate) media: Vec<MediaRegistration>,44
- pub(crate) missing_replies: Vec<OwnedEventId>,45
-}46
-47
-struct Normaliser<'a> {48
- current_user_id: Option<&'a UserId>,49
- scope: &'a MediaScope,50
- media: Vec<MediaRegistration>,51
- missing_replies: Vec<OwnedEventId>,52
-}53
-54
-impl Normaliser<'_> {55
- fn item(&mut self, item: &SdkTimelineItem) -> TimelineItem {56
- self.media57
- .extend(media_registrations_for_item(item, self.scope));58
- match item.kind() {59
- TimelineItemKind::Event(event) => {60
- TimelineItem::Event(Box::new(self.event(item, event)))61
- }62
- TimelineItemKind::Virtual(VirtualTimelineItem::DateDivider(timestamp)) => {63
- TimelineItem::DayDivider {64
- timestamp_ms: timestamp.get().into(),65
- }66
- }67
- TimelineItemKind::Virtual(VirtualTimelineItem::ReadMarker) => TimelineItem::ReadMarker,68
- TimelineItemKind::Virtual(VirtualTimelineItem::TimelineStart) => {69
- TimelineItem::TimelineStart70
- }71
- }72
- }73
-74
- fn event(&mut self, item: &SdkTimelineItem, event: &EventTimelineItem) -> TimelineEvent {75
- let msg_like = match event.content() {76
- TimelineItemContent::MsgLike(content) => Some(content),77
- _ => None,78
- };79
- let reply_to = msg_like80
- .and_then(|content| content.in_reply_to.as_ref())81
- .map(|reply| {82
- let details = match &reply.event {83
- TimelineDetails::Ready(embedded) => ReplyDetails::Ready {84
- sender: sender(&embedded.sender, &embedded.sender_profile),85
- content: Box::new(content(&embedded.content, None)),86
- timestamp_ms: embedded.timestamp.get().into(),87
- },88
- TimelineDetails::Unavailable => {89
- self.missing_replies.push(reply.event_id.clone());90
- ReplyDetails::Pending91
- }92
- TimelineDetails::Pending => ReplyDetails::Pending,93
- TimelineDetails::Error(_) => ReplyDetails::Unavailable,94
- };95
- ReplyReference {96
- event_id: reply.event_id.clone(),97
- details,98
- }99
- });100
- let mut read_by: Vec<_> = event101
- .read_receipts()102
- .keys()103
- .filter(|user_id| *user_id != event.sender())104
- .cloned()105
- .collect();106
- read_by.sort();107
- TimelineEvent {108
- item_id: TimelineItemId::new(item.unique_id().0.clone()),109
- event_id: event.event_id().map(ToOwned::to_owned),110
- transaction_id: event.transaction_id().map(ToOwned::to_owned),111
- sender: sender(event.sender(), event.sender_profile()),112
- timestamp_ms: event.timestamp().get().into(),113
- own: event.is_own(),114
- send_state: send_state(event.send_state()),115
- content: content(event.content(), Some(&media_identity(item, self.scope))),116
- reply_to,117
- thread_root: msg_like.and_then(|content| content.thread_root.clone()),118
- thread_summary: msg_like.and_then(thread_summary),119
- reactions: reactions(event, self.current_user_id),120
- read_by,121
- editable: event.is_editable(),122
- }123
- }124
-}125
-126
-pub(crate) fn normalise_timeline<'a>(127
- items: impl Iterator<Item = &'a Arc<SdkTimelineItem>>,128
- current_user_id: Option<&UserId>,129
- scope: &MediaScope,130
-) -> NormalisedTimeline {131
- let mut normaliser = Normaliser {132
- current_user_id,133
- scope,134
- media: Vec::new(),135
- missing_replies: Vec::new(),136
- };137
- let items = items.map(|item| normaliser.item(item)).collect();138
- NormalisedTimeline {139
- items,140
- media: normaliser.media,141
- missing_replies: normaliser.missing_replies,142
- }143
-}144
-145
-pub(crate) fn normalise_timeline_diffs(146
- diffs: Vec<VectorDiff<Arc<SdkTimelineItem>>>,147
- current_user_id: Option<&UserId>,148
- scope: &MediaScope,149
-) -> NormalisedTimelineDiffs {150
- let mut normaliser = Normaliser {151
- current_user_id,152
- scope,153
- media: Vec::new(),154
- missing_replies: Vec::new(),155
- };156
- let diffs = diffs157
- .into_iter()158
- .map(|diff| match diff.map(|item| normaliser.item(&item)) {159
- VectorDiff::Append { values } => TimelineDiff::Append {160
- values: values.into_iter().collect(),161
- },162
- VectorDiff::Clear => TimelineDiff::Clear,163
- VectorDiff::PushFront { value } => TimelineDiff::PushFront { value },164
- VectorDiff::PushBack { value } => TimelineDiff::PushBack { value },165
- VectorDiff::PopFront => TimelineDiff::PopFront,166
- VectorDiff::PopBack => TimelineDiff::PopBack,167
- VectorDiff::Insert { index, value } => TimelineDiff::Insert { index, value },168
- VectorDiff::Set { index, value } => TimelineDiff::Set { index, value },169
- VectorDiff::Remove { index } => TimelineDiff::Remove { index },170
- VectorDiff::Truncate { length } => TimelineDiff::Truncate { length },171
- VectorDiff::Reset { values } => TimelineDiff::Reset {172
- values: values.into_iter().collect(),173
- },174
- })175
- .collect();176
- NormalisedTimelineDiffs {177
- diffs,178
- media: normaliser.media,179
- missing_replies: normaliser.missing_replies,180
- }181
-}182
-183
-/// `media_identity` is `None` for embedded reply content, which never gets fetchable media of its own184
-fn content(content: &TimelineItemContent, media_identity: Option<&str>) -> TimelineContent {185
- match content {186
- TimelineItemContent::MsgLike(content) => msg_like(content, media_identity),187
- TimelineItemContent::MembershipChange(change) => TimelineContent::Membership {188
- user_id: change.user_id().to_owned(),189
- display_name: change.display_name(),190
- change: change191
- .change()192
- .map_or(MembershipChange::Other, membership_change),193
- },194
- TimelineItemContent::ProfileChange(change) => TimelineContent::Profile {195
- user_id: change.user_id().to_owned(),196
- display_name: change.displayname_change().map(|change| Change {197
- old: change.old.clone(),198
- new: change.new.clone(),199
- }),200
- avatar: change.avatar_url_change().map(|change| Change {201
- old: change.old.as_deref().map(MediaId::plain),202
- new: change.new.as_deref().map(MediaId::plain),203
- }),204
- },205
- TimelineItemContent::OtherState(state) => match state.content() {206
- AnyOtherStateEventContentChange::RoomName(change) => TimelineContent::RoomName(207
- original(change).and_then(|content| non_empty(&content.name)),208
- ),209
- AnyOtherStateEventContentChange::RoomTopic(change) => TimelineContent::RoomTopic(210
- original(change).and_then(|content| non_empty(&content.topic)),211
- ),212
- AnyOtherStateEventContentChange::RoomAvatar(change) => TimelineContent::RoomAvatar(213
- original(change).and_then(|content| content.url.as_deref().map(MediaId::plain)),214
- ),215
- AnyOtherStateEventContentChange::RoomEncryption(_) => {216
- TimelineContent::EncryptionEnabled217
- }218
- AnyOtherStateEventContentChange::RoomCreate(_) => TimelineContent::RoomCreated,219
- AnyOtherStateEventContentChange::RoomTombstone(change) => match original(change) {220
- Some(content) => TimelineContent::RoomUpgraded {221
- successor: content.replacement_room.clone(),222
- },223
- None => other_state(state.content().event_type().to_string(), state.state_key()),224
- },225
- other => other_state(other.event_type().to_string(), state.state_key()),226
- },227
- TimelineItemContent::FailedToParseMessageLike { event_type, .. } => {228
- TimelineContent::Other {229
- event_type: event_type.to_string(),230
- }231
- }232
- TimelineItemContent::FailedToParseState {233
- event_type,234
- state_key,235
- ..236
- } => other_state(event_type.to_string(), state_key),237
- TimelineItemContent::CallInvite => TimelineContent::Other {238
- event_type: "m.call.invite".to_owned(),239
- },240
- TimelineItemContent::RtcNotification { .. } => TimelineContent::Other {241
- event_type: "org.matrix.msc4075.rtc.notification".to_owned(),242
- },243
- }244
-}245
-246
-/// a cleared name or topic arrives as an empty string247
-fn non_empty(value: &str) -> Option<String> {248
- (!value.is_empty()).then(|| value.to_owned())249
-}250
-251
-fn other_state(event_type: String, state_key: &str) -> TimelineContent {252
- TimelineContent::OtherState {253
- event_type,254
- state_key: state_key.to_owned(),255
- }256
-}257
-258
-fn original<C>(change: &StateEventContentChange<C>) -> Option<&C>259
-where260
- C: ruma::events::StaticStateEventContent + ruma::events::RedactContent,261
-{262
- match change {263
- StateEventContentChange::Original { content, .. } => Some(content),264
- StateEventContentChange::Redacted(_) => None,265
- }266
-}267
-268
-fn msg_like(content: &MsgLikeContent, media_identity: Option<&str>) -> TimelineContent {269
- match &content.kind {270
- MsgLikeKind::Message(message) => TimelineContent::Message(Message {271
- kind: message_kind(message.msgtype()),272
- body: message.body().to_owned(),273
- geo_uri: match message.msgtype() {274
- MessageType::Location(location) => Some(location.geo_uri.clone()),275
- _ => None,276
- },277
- formatted_body: formatted_body(message.msgtype()),278
- mentions: message.mentions().map(|mentions| MessageMentions {279
- user_ids: mentions.user_ids.iter().cloned().collect(),280
- room: mentions.room,281
- }),282
- edited: message.is_edited(),283
- media: media_identity284
- .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(),289
- }),290
- MsgLikeKind::Sticker(sticker) => {291
- let sticker = sticker.content();292
- match media_identity {293
- Some(identity) => TimelineContent::Sticker {294
- body: sticker.body.clone(),295
- media: media_descriptor_for_sticker(identity, sticker),296
- },297
- None => TimelineContent::Other {298
- event_type: "m.sticker".to_owned(),299
- },300
- }301
- }302
- MsgLikeKind::Poll(poll) => TimelineContent::Poll {303
- question: poll.results().question,304
- },305
- MsgLikeKind::Redacted => TimelineContent::Redacted,306
- MsgLikeKind::UnableToDecrypt(encrypted) => {307
- TimelineContent::UnableToDecrypt(match encrypted {308
- EncryptedMessage::MegolmV1AesSha2 { cause, .. } => utd_cause(*cause),309
- _ => UtdCause::Unknown,310
- })311
- }312
- MsgLikeKind::Other(other) => TimelineContent::Other {313
- event_type: other.event_type().to_string(),314
- },315
- MsgLikeKind::LiveLocation(_) => TimelineContent::Other {316
- event_type: "m.beacon_info".to_owned(),317
- },318
- }319
-}320
-321
-pub(crate) fn message_kind(message: &MessageType) -> MessageKind {322
- match message {323
- MessageType::Text(_) => MessageKind::Text,324
- MessageType::Emote(_) => MessageKind::Emote,325
- MessageType::Notice(_) => MessageKind::Notice,326
- MessageType::ServerNotice(_) => MessageKind::ServerNotice,327
- MessageType::Image(_) => MessageKind::Image,328
- MessageType::Video(_) => MessageKind::Video,329
- MessageType::Audio(_) => MessageKind::Audio,330
- MessageType::File(_) => MessageKind::File,331
- MessageType::Location(_) => MessageKind::Location,332
- MessageType::VerificationRequest(_) => MessageKind::VerificationRequest,333
- other => MessageKind::Other(other.msgtype().to_owned()),334
- }335
-}336
-337
-const fn membership_change(change: SdkMembershipChange) -> MembershipChange {338
- match change {339
- SdkMembershipChange::Joined => MembershipChange::Joined,340
- SdkMembershipChange::Left => MembershipChange::Left,341
- SdkMembershipChange::Banned => MembershipChange::Banned,342
- SdkMembershipChange::Unbanned => MembershipChange::Unbanned,343
- SdkMembershipChange::Kicked => MembershipChange::Kicked,344
- SdkMembershipChange::KickedAndBanned => MembershipChange::KickedAndBanned,345
- SdkMembershipChange::Invited => MembershipChange::Invited,346
- SdkMembershipChange::InvitationAccepted => MembershipChange::InvitationAccepted,347
- SdkMembershipChange::InvitationRejected => MembershipChange::InvitationRejected,348
- SdkMembershipChange::InvitationRevoked => MembershipChange::InvitationRevoked,349
- SdkMembershipChange::Knocked => MembershipChange::Knocked,350
- SdkMembershipChange::KnockAccepted => MembershipChange::KnockAccepted,351
- SdkMembershipChange::KnockRetracted => MembershipChange::KnockRetracted,352
- SdkMembershipChange::KnockDenied => MembershipChange::KnockDenied,353
- SdkMembershipChange::None354
- | SdkMembershipChange::Error355
- | SdkMembershipChange::NotImplemented => MembershipChange::Other,356
- }357
-}358
-359
-const fn utd_cause(cause: SdkUtdCause) -> UtdCause {360
- match cause {361
- SdkUtdCause::Unknown => UtdCause::Unknown,362
- SdkUtdCause::SentBeforeWeJoined => UtdCause::SentBeforeWeJoined,363
- SdkUtdCause::VerificationViolation => UtdCause::VerificationViolation,364
- SdkUtdCause::UnsignedDevice => UtdCause::UnsignedDevice,365
- SdkUtdCause::UnknownDevice => UtdCause::UnknownDevice,366
- SdkUtdCause::HistoricalMessageAndBackupIsDisabled => {367
- UtdCause::HistoricalMessageAndBackupIsDisabled368
- }369
- SdkUtdCause::WithheldForUnverifiedOrInsecureDevice => {370
- UtdCause::WithheldForUnverifiedOrInsecureDevice371
- }372
- SdkUtdCause::WithheldBySender => UtdCause::WithheldBySender,373
- SdkUtdCause::HistoricalMessageAndDeviceIsUnverified => {374
- UtdCause::HistoricalMessageAndDeviceIsUnverified375
- }376
- }377
-}378
-379
-fn sender(user_id: &UserId, profile: &TimelineDetails<Profile>) -> Sender {380
- let profile = match profile {381
- TimelineDetails::Ready(profile) => Some(profile),382
- _ => None,383
- };384
- Sender {385
- user_id: user_id.to_owned(),386
- display_name: profile.and_then(|profile| profile.display_name.clone()),387
- avatar: profile.and_then(|profile| profile.avatar_url.as_deref().map(MediaId::plain)),388
- name_ambiguous: profile.is_some_and(|profile| profile.display_name_ambiguous),389
- }390
-}391
-392
-fn send_state(state: Option<&EventSendState>) -> SendState {393
- match state {394
- None | Some(EventSendState::Sent { .. }) => SendState::Sent,395
- Some(EventSendState::NotSentYet { .. }) => SendState::Sending,396
- // recoverable means the sdk parks the queue and we unpark it, nothing for the user to do397
- Some(EventSendState::SendingFailed { is_recoverable, .. }) => SendState::Failed {398
- will_retry: *is_recoverable,399
- },400
- }401
-}402
-403
-fn thread_summary(content: &MsgLikeContent) -> Option<ThreadSummary> {404
- content.thread_summary.as_ref().map(|summary| {405
- let latest_event = match &summary.latest_event {406
- TimelineDetails::Ready(event) => Some(event.as_ref()),407
- _ => None,408
- };409
- ThreadSummary {410
- reply_count: summary.num_replies,411
- latest_event_id: latest_event.and_then(|event| match &event.identifier {412
- TimelineEventItemId::EventId(event_id) => Some(event_id.clone()),413
- TimelineEventItemId::TransactionId(_) => None,414
- }),415
- latest_event_timestamp_ms: latest_event.map(|event| event.timestamp.get().into()),416
- }417
- })418
-}419
-420
-fn reactions(event: &EventTimelineItem, current_user_id: Option<&UserId>) -> Vec<Reaction> {421
- event422
- .content()423
- .reactions()424
- .into_iter()425
- .flat_map(|reactions| reactions.iter())426
- .map(|(key, senders)| Reaction {427
- 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)),432
- count: senders.len(),433
- own: current_user_id.is_some_and(|user_id| senders.contains_key(user_id)),434
- senders: senders.keys().cloned().collect(),435
- })436
- .collect()437
-}438
-439
-fn formatted_body(message: &MessageType) -> Option<String> {440
- let formatted = match message {441
- MessageType::Text(content) => content.formatted.as_ref(),442
- MessageType::Emote(content) => content.formatted.as_ref(),443
- MessageType::Notice(content) => content.formatted.as_ref(),444
- MessageType::Audio(content) => content.formatted_caption(),445
- MessageType::File(content) => content.formatted_caption(),446
- MessageType::Image(content) => content.formatted_caption(),447
- MessageType::Video(content) => content.formatted_caption(),448
- _ => None,449
- }?;450
- (formatted.format == MessageFormat::Html).then(|| formatted.body.clone())451
-}@@ -1,566 +0,0 @@
1
-#[cfg(feature = "sqlite")]2
-use std::path::{Path, PathBuf};3
-4
-use matrix_sdk::{BoxFuture, SendOutsideWasm, SyncOutsideWasm};5
-use matrix_sdk_store_encryption::StoreCipher;6
-7
-use crate::{AccountError, SessionRecord, StoreConfig, account::account_error};8
-9
-#[cfg(feature = "sqlite")]10
-type RecordLocation = PathBuf;11
-#[cfg(feature = "indexeddb")]12
-type RecordLocation = String;13
-14
-#[cfg(not(target_family = "wasm"))]15
-type Lock<T> = std::sync::Mutex<T>;16
-#[cfg(target_family = "wasm")]17
-type Lock<T> = tokio::sync::Mutex<T>;18
-19
-/// one login's record and the matrix store it belongs to. the engine rotates refresh tokens20
-/// and each works once, so a `save` has to land before the next refresh or the login is gone21
-pub trait SessionStore: SendOutsideWasm + SyncOutsideWasm {22
- fn store(&self) -> &StoreConfig;23
-24
- fn load(&self) -> BoxFuture<'_, Result<Option<SessionRecord>, AccountError>>;25
-26
- /// replace the saved record, all or nothing27
- fn save<'a>(&'a self, record: &'a SessionRecord) -> BoxFuture<'a, Result<(), AccountError>>;28
-29
- /// `save`, but without awaiting. the sdk calls it from inside a refresh, before the new30
- /// refresh token is ever used, so a store that can write synchronously closes the gap31
- /// a crash would otherwise lose the login through. `None` leaves the refresh to `save`32
- #[cfg(not(target_family = "wasm"))]33
- fn save_blocking(&self, _record: &SessionRecord) -> Option<Result<(), AccountError>> {34
- None35
- }36
-37
- /// swap the record for one that only says who to log in again as, keeping the store.38
- /// `Session::restore` then answers `Restore::SoftLoggedOut`, even after a restart39
- fn soft_log_out(&self) -> BoxFuture<'_, Result<(), AccountError>>;40
-41
- /// drop the record and delete the store, once nothing has the store open42
- fn discard(&self) -> BoxFuture<'_, Result<(), AccountError>>;43
-}44
-45
-/// keeps the record sealed with the store's passphrase, in a file natively and in an46
-/// IndexedDB database of its own in a browser47
-pub struct EncryptedSessionStore {48
- record: RecordLocation,49
- store: StoreConfig,50
- sealer: Lock<Option<Sealer>>,51
-}52
-53
-struct Sealer {54
- cipher: StoreCipher,55
- exported: Vec<u8>,56
-}57
-58
-impl EncryptedSessionStore {59
- #[cfg(feature = "sqlite")]60
- #[must_use]61
- pub fn file(record: impl Into<PathBuf>, store: StoreConfig) -> Self {62
- Self::new(record.into(), store)63
- }64
-65
- /// the record lives beside the store, in `<name>::macltok-session`66
- #[cfg(feature = "indexeddb")]67
- #[must_use]68
- pub fn indexeddb(store: StoreConfig) -> Self {69
- let record = format!("{}::macltok-session", store.indexeddb_name());70
- Self::new(record, store)71
- }72
-73
- fn new(record: RecordLocation, store: StoreConfig) -> Self {74
- Self {75
- record,76
- store,77
- sealer: Lock::new(None),78
- }79
- }80
-81
- fn open(82
- &self,83
- sealer: &mut Option<Sealer>,84
- sealed: Option<Vec<u8>>,85
- ) -> Result<Option<SessionRecord>, AccountError> {86
- let Some(sealed) = sealed else {87
- return Ok(None);88
- };89
- let (opened, record) = unseal(self.store.passphrase(), &sealed)?;90
- *sealer = Some(opened);91
- Ok(Some(record))92
- }93
-94
- fn seal(95
- &self,96
- sealer: &mut Option<Sealer>,97
- record: &SessionRecord,98
- ) -> Result<Vec<u8>, AccountError> {99
- let sealer = match sealer {100
- Some(sealer) => sealer,101
- empty => empty.insert(Sealer::new(self.store.passphrase())?),102
- };103
- sealer.seal(record)104
- }105
-}106
-107
-#[cfg(not(target_family = "wasm"))]108
-impl EncryptedSessionStore {109
- fn lock(&self) -> std::sync::MutexGuard<'_, Option<Sealer>> {110
- self.sealer111
- .lock()112
- .unwrap_or_else(std::sync::PoisonError::into_inner)113
- }114
-115
- fn read(&self, sealer: &mut Option<Sealer>) -> Result<Option<SessionRecord>, AccountError> {116
- let sealed = backend::read(&self.record)117
- .map_err(|error| account_error("reading the session record", error))?;118
- self.open(sealer, sealed)119
- }120
-121
- fn write(122
- &self,123
- sealer: &mut Option<Sealer>,124
- record: &SessionRecord,125
- ) -> Result<(), AccountError> {126
- let sealed = self.seal(sealer, record)?;127
- backend::write(&self.record, &sealed)128
- .map_err(|error| account_error("writing the session record", error))129
- }130
-131
- fn load_record(&self) -> Result<Option<SessionRecord>, AccountError> {132
- self.read(&mut self.lock())133
- }134
-135
- fn save_record(&self, record: &SessionRecord) -> Result<(), AccountError> {136
- self.write(&mut self.lock(), record)137
- }138
-139
- fn soft_log_out_record(&self) -> Result<(), AccountError> {140
- let mut sealer = self.lock();141
- match self.read(&mut sealer)? {142
- Some(record) => self.write(&mut sealer, &record.soft_logged_out()),143
- None => Ok(()),144
- }145
- }146
-147
- fn forget_record(&self) -> Result<(), AccountError> {148
- let mut sealer = self.lock();149
- backend::remove(&self.record)150
- .map_err(|error| account_error("removing the session record", error))?;151
- *sealer = None;152
- Ok(())153
- }154
-}155
-156
-#[cfg(not(target_family = "wasm"))]157
-impl SessionStore for EncryptedSessionStore {158
- fn store(&self) -> &StoreConfig {159
- &self.store160
- }161
-162
- fn load(&self) -> BoxFuture<'_, Result<Option<SessionRecord>, AccountError>> {163
- Box::pin(async move { self.load_record() })164
- }165
-166
- fn save<'a>(&'a self, record: &'a SessionRecord) -> BoxFuture<'a, Result<(), AccountError>> {167
- Box::pin(async move { self.save_record(record) })168
- }169
-170
- /// blocks the calling worker for the write and its fsyncs, about a millisecond171
- fn save_blocking(&self, record: &SessionRecord) -> Option<Result<(), AccountError>> {172
- Some(self.save_record(record))173
- }174
-175
- fn soft_log_out(&self) -> BoxFuture<'_, Result<(), AccountError>> {176
- Box::pin(async move { self.soft_log_out_record() })177
- }178
-179
- fn discard(&self) -> BoxFuture<'_, Result<(), AccountError>> {180
- Box::pin(async move {181
- self.forget_record()?;182
- self.store.delete().await183
- })184
- }185
-}186
-187
-#[cfg(target_family = "wasm")]188
-impl EncryptedSessionStore {189
- async fn read(190
- &self,191
- sealer: &mut Option<Sealer>,192
- ) -> Result<Option<SessionRecord>, AccountError> {193
- let sealed = backend::read(&self.record)194
- .await195
- .map_err(|error| account_error("reading the session record", error))?;196
- self.open(sealer, sealed)197
- }198
-199
- async fn write(200
- &self,201
- sealer: &mut Option<Sealer>,202
- record: &SessionRecord,203
- ) -> Result<(), AccountError> {204
- let sealed = self.seal(sealer, record)?;205
- backend::write(&self.record, &sealed)206
- .await207
- .map_err(|error| account_error("writing the session record", error))208
- }209
-210
- async fn load_record(&self) -> Result<Option<SessionRecord>, AccountError> {211
- self.read(&mut *self.sealer.lock().await).await212
- }213
-214
- async fn save_record(&self, record: &SessionRecord) -> Result<(), AccountError> {215
- self.write(&mut *self.sealer.lock().await, record).await216
- }217
-218
- async fn soft_log_out_record(&self) -> Result<(), AccountError> {219
- let mut sealer = self.sealer.lock().await;220
- match self.read(&mut sealer).await? {221
- Some(record) => self.write(&mut sealer, &record.soft_logged_out()).await,222
- None => Ok(()),223
- }224
- }225
-226
- async fn discard_all(&self) -> Result<(), AccountError> {227
- let mut sealer = self.sealer.lock().await;228
- backend::remove(&self.record)229
- .await230
- .map_err(|error| error.into_account_error("removing the session record"))?;231
- *sealer = None;232
- self.store.delete().await233
- }234
-}235
-236
-#[cfg(target_family = "wasm")]237
-impl SessionStore for EncryptedSessionStore {238
- fn store(&self) -> &StoreConfig {239
- &self.store240
- }241
-242
- fn load(&self) -> BoxFuture<'_, Result<Option<SessionRecord>, AccountError>> {243
- Box::pin(self.load_record())244
- }245
-246
- fn save<'a>(&'a self, record: &'a SessionRecord) -> BoxFuture<'a, Result<(), AccountError>> {247
- Box::pin(self.save_record(record))248
- }249
-250
- fn soft_log_out(&self) -> BoxFuture<'_, Result<(), AccountError>> {251
- Box::pin(self.soft_log_out_record())252
- }253
-254
- fn discard(&self) -> BoxFuture<'_, Result<(), AccountError>> {255
- Box::pin(self.discard_all())256
- }257
-}258
-259
-impl Sealer {260
- fn new(passphrase: &str) -> Result<Self, AccountError> {261
- let cipher = StoreCipher::new()262
- .map_err(|error| account_error("creating the session record cipher", error))?;263
- let exported = cipher264
- .export(passphrase)265
- .map_err(|error| account_error("sealing the session record cipher", error))?;266
- Ok(Self { cipher, exported })267
- }268
-269
- fn seal(&self, record: &SessionRecord) -> Result<Vec<u8>, AccountError> {270
- let value = self271
- .cipher272
- .encrypt_value(record)273
- .map_err(|error| account_error("sealing the session record", error))?;274
- let length = u32::try_from(self.exported.len())275
- .expect("an exported store cipher is a few hundred bytes");276
- let mut sealed = Vec::with_capacity(4 + self.exported.len() + value.len());277
- sealed.extend_from_slice(&length.to_be_bytes());278
- sealed.extend_from_slice(&self.exported);279
- sealed.extend_from_slice(&value);280
- Ok(sealed)281
- }282
-}283
-284
-fn unseal(passphrase: &str, sealed: &[u8]) -> Result<(Sealer, SessionRecord), AccountError> {285
- let truncated = || {286
- account_error(287
- "opening the session record",288
- std::io::Error::from(std::io::ErrorKind::UnexpectedEof),289
- )290
- };291
- let (length, rest) = sealed.split_first_chunk::<4>().ok_or_else(truncated)?;292
- let (exported, value) = rest293
- .split_at_checked(u32::from_be_bytes(*length) as usize)294
- .ok_or_else(truncated)?;295
- let cipher = StoreCipher::import(passphrase, exported)296
- .map_err(|error| account_error("opening the session record cipher", error))?;297
- let record = cipher298
- .decrypt_value(value)299
- .map_err(|error| account_error("opening the session record", error))?;300
- let sealer = Sealer {301
- cipher,302
- exported: exported.to_vec(),303
- };304
- Ok((sealer, record))305
-}306
-307
-#[cfg(feature = "sqlite")]308
-mod backend {309
- use std::{310
- ffi::OsString,311
- fs::{self, File, OpenOptions},312
- io::{self, ErrorKind, Write},313
- path::{Path, PathBuf},314
- sync::atomic::{AtomicU64, Ordering},315
- };316
-317
- static WRITES: AtomicU64 = AtomicU64::new(0);318
-319
- pub(super) fn read(path: &Path) -> io::Result<Option<Vec<u8>>> {320
- match fs::read(path) {321
- Ok(sealed) => Ok(Some(sealed)),322
- Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),323
- Err(error) => Err(error),324
- }325
- }326
-327
- pub(super) fn write(path: &Path, sealed: &[u8]) -> io::Result<()> {328
- let parent = match path.parent() {329
- Some(parent) if !parent.as_os_str().is_empty() => parent,330
- _ => Path::new("."),331
- };332
- fs::create_dir_all(parent)?;333
- let (temporary, mut file) = temporary_file(path)?;334
- let written = replace(path, &temporary, &mut file, parent, sealed);335
- if written.is_err() {336
- let _ = fs::remove_file(&temporary);337
- }338
- written339
- }340
-341
- fn temporary_file(path: &Path) -> io::Result<(PathBuf, File)> {342
- loop {343
- let mut name = OsString::from(path.as_os_str());344
- let write = WRITES.fetch_add(1, Ordering::Relaxed);345
- name.push(format!(".{}.{write}.tmp", std::process::id()));346
- let temporary = PathBuf::from(name);347
- match private_file(&temporary) {348
- Ok(file) => return Ok((temporary, file)),349
- Err(error) if error.kind() == ErrorKind::AlreadyExists => {}350
- Err(error) => return Err(error),351
- }352
- }353
- }354
-355
- fn replace(356
- path: &Path,357
- temporary: &Path,358
- file: &mut File,359
- parent: &Path,360
- sealed: &[u8],361
- ) -> io::Result<()> {362
- file.write_all(sealed)?;363
- file.sync_all()?;364
- fs::rename(temporary, path)?;365
- sync_directory(parent)366
- }367
-368
- pub(super) fn remove(path: &Path) -> io::Result<()> {369
- match fs::remove_file(path) {370
- Err(error) if error.kind() != ErrorKind::NotFound => Err(error),371
- _ => Ok(()),372
- }373
- }374
-375
- /// `create_new` is `O_EXCL`, which posix says never follows a symlink, even a dangling one376
- #[cfg(unix)]377
- fn private_file(path: &Path) -> io::Result<File> {378
- use std::os::unix::fs::OpenOptionsExt;379
- OpenOptions::new()380
- .write(true)381
- .create_new(true)382
- .mode(0o600)383
- .open(path)384
- }385
-386
- #[cfg(not(unix))]387
- fn private_file(path: &Path) -> io::Result<File> {388
- OpenOptions::new().write(true).create_new(true).open(path)389
- }390
-391
- /// a rename isn't durable until its directory is synced392
- #[cfg(unix)]393
- fn sync_directory(path: &Path) -> io::Result<()> {394
- File::open(path)?.sync_all()395
- }396
-397
- #[cfg(not(unix))]398
- fn sync_directory(_: &Path) -> io::Result<()> {399
- Ok(())400
- }401
-}402
-403
-#[cfg(feature = "indexeddb")]404
-mod backend {405
- use indexed_db_futures::{406
- Build,407
- database::Database,408
- error::{Error, OpenDbError},409
- prelude::*,410
- transaction::{Transaction, TransactionMode},411
- typed_array::{Uint8Array, Uint8ArraySlice},412
- };413
-414
- const RECORDS: &str = "records";415
- const KEY: &str = "record";416
-417
- async fn open(name: &str) -> Result<Database, OpenDbError> {418
- Database::open(name)419
- .with_version(1u32)420
- .with_on_upgrade_needed(|_, transaction: &Transaction<'_>| -> Result<(), Error> {421
- transaction.db().create_object_store(RECORDS).build()?;422
- Ok(())423
- })424
- .build()?425
- .await426
- }427
-428
- /// the crate's `Database` never closes itself on drop, and an open one blocks deleting it429
- pub(super) async fn read(name: &str) -> Result<Option<Vec<u8>>, OpenDbError> {430
- let database = open(name).await?;431
- let sealed = get(&database).await;432
- database.close();433
- Ok(sealed?.map(Into::into))434
- }435
-436
- pub(super) async fn write(name: &str, sealed: &[u8]) -> Result<(), OpenDbError> {437
- let database = open(name).await?;438
- let written = put(&database, sealed).await;439
- database.close();440
- Ok(written?)441
- }442
-443
- pub(super) async fn remove(name: &str) -> Result<(), super::DeleteError> {444
- super::delete_database(name).await445
- }446
-447
- async fn get(database: &Database) -> Result<Option<Uint8Array>, Error> {448
- database449
- .transaction(RECORDS)450
- .with_mode(TransactionMode::Readonly)451
- .build()?452
- .object_store(RECORDS)?453
- .get(KEY)454
- .await455
- }456
-457
- async fn put(database: &Database, sealed: &[u8]) -> Result<(), Error> {458
- let transaction = database459
- .transaction(RECORDS)460
- .with_mode(TransactionMode::Readwrite)461
- .build()?;462
- transaction463
- .object_store(RECORDS)?464
- .put(Uint8ArraySlice::new(sealed))465
- .with_key(KEY)466
- .build()?;467
- transaction.commit().await468
- }469
-}470
-471
-#[cfg(feature = "sqlite")]472
-pub(crate) fn delete_sqlite_store(path: &Path) -> std::io::Result<()> {473
- use std::{fs, io::ErrorKind};474
-475
- // matrix-sdk-sqlite 0.18's database names, which it keeps private476
- const DATABASES: [&str; 4] = [477
- "matrix-sdk-state.sqlite3",478
- "matrix-sdk-crypto.sqlite3",479
- "matrix-sdk-event-cache.sqlite3",480
- "matrix-sdk-media.sqlite3",481
- ];482
- for database in DATABASES {483
- for suffix in ["", "-wal", "-shm", "-journal"] {484
- match fs::remove_file(path.join(format!("{database}{suffix}"))) {485
- Err(error) if error.kind() != ErrorKind::NotFound => return Err(error),486
- _ => {}487
- }488
- }489
- }490
- match fs::remove_file(path.join(crate::store_lock::LOCK_FILE)) {491
- Err(error) if error.kind() != ErrorKind::NotFound => return Err(error),492
- _ => {}493
- }494
- match fs::remove_dir(path) {495
- Err(error) if error.kind() != ErrorKind::NotFound => Err(error),496
- _ => Ok(()),497
- }498
-}499
-500
-#[cfg(feature = "indexeddb")]501
-pub(crate) async fn delete_indexeddb_store(name: &str) -> Result<(), DeleteError> {502
- // the databases matrix-sdk-indexeddb 0.18 opens under one store name503
- for suffix in [504
- "",505
- "::matrix-sdk-state",506
- "::matrix-sdk-crypto",507
- "::matrix-sdk-crypto-meta",508
- "::event_cache",509
- "::media",510
- ] {511
- delete_database(&format!("{name}{suffix}")).await?;512
- }513
- Ok(())514
-}515
-516
-#[cfg(feature = "indexeddb")]517
-#[derive(Debug, thiserror::Error)]518
-pub(crate) enum DeleteError {519
- #[error("the database is still open somewhere, it goes once that closes")]520
- Blocked,521
- #[error("this global scope has no IndexedDB")]522
- Unavailable,523
- #[error("deleting the database failed, {0}")]524
- Failed(String),525
-}526
-527
-#[cfg(feature = "indexeddb")]528
-impl DeleteError {529
- pub(crate) fn into_account_error(self, context: &'static str) -> AccountError {530
- match self {531
- Self::Blocked => AccountError::StoreDeletePending,532
- error => account_error(context, error),533
- }534
- }535
-}536
-537
-/// matrix_indexed_db_futures 0.7 ignores `blocked` on a delete, which then waits forever538
-#[cfg(feature = "indexeddb")]539
-async fn delete_database(name: &str) -> Result<(), DeleteError> {540
- use wasm_bindgen::{JsCast, JsValue};541
-542
- const BLOCKED: &str = "blocked";543
- let global = js_sys::global();544
- let factory = if let Some(window) = global.dyn_ref::<web_sys::Window>() {545
- window.indexed_db()546
- } else if let Some(worker) = global.dyn_ref::<web_sys::WorkerGlobalScope>() {547
- worker.indexed_db()548
- } else {549
- return Err(DeleteError::Unavailable);550
- };551
- let factory = factory.ok().flatten().ok_or(DeleteError::Unavailable)?;552
- let request = factory553
- .delete_database(name)554
- .map_err(|error| DeleteError::Failed(format!("{error:?}")))?;555
- let settled = js_sys::Promise::new(&mut |resolve, reject| {556
- request.set_onsuccess(Some(&resolve));557
- request.set_onerror(Some(&reject));558
- let blocked = reject.bind1(&JsValue::NULL, &JsValue::from_str(BLOCKED));559
- request.set_onblocked(Some(blocked.unchecked_ref()));560
- });561
- match wasm_bindgen_futures::JsFuture::from(settled).await {562
- Ok(_) => Ok(()),563
- Err(reason) if reason.as_string().as_deref() == Some(BLOCKED) => Err(DeleteError::Blocked),564
- Err(_) => Err(DeleteError::Failed(format!("{:?}", request.error()))),565
- }566
-}@@ -1,117 +0,0 @@
1
-use crate::{AccountError, account::account_error};2
-3
-/// the platform's own exclusive lock on a store. matrix-sdk 0.18's cross process lease drops4
-/// itself silently after one failed renewal or a stall past 500ms, so it can't be what keeps a5
-/// second engine out6
-pub(crate) struct StoreLock {7
- #[cfg(feature = "sqlite")]8
- _file: std::fs::File,9
- #[cfg(feature = "indexeddb")]10
- release: js_sys::Function,11
-}12
-13
-#[cfg(feature = "sqlite")]14
-pub(crate) const LOCK_FILE: &str = "macltok.lock";15
-16
-#[cfg(feature = "sqlite")]17
-impl StoreLock {18
- pub(crate) fn file(directory: &std::path::Path) -> Result<Self, AccountError> {19
- use std::fs::{self, File, TryLockError};20
-21
- fs::create_dir_all(directory)22
- .map_err(|error| account_error("creating the Matrix store directory", error))?;23
- let file = File::options()24
- .create(true)25
- .truncate(false)26
- .write(true)27
- .open(directory.join(LOCK_FILE))28
- .map_err(|error| account_error("opening the Matrix store's lock file", error))?;29
- match file.try_lock() {30
- Ok(()) => Ok(Self { _file: file }),31
- Err(TryLockError::WouldBlock) => Err(AccountError::StoreInUse),32
- Err(TryLockError::Error(error)) => {33
- Err(account_error("locking the Matrix store", error))34
- }35
- }36
- }37
-}38
-39
-#[cfg(feature = "indexeddb")]40
-#[derive(Debug, thiserror::Error)]41
-enum WebLockError {42
- #[error("this global scope has no Web Locks, they need a secure context")]43
- Unavailable,44
- #[error("requesting the Web Lock failed, {0}")]45
- Failed(String),46
-}47
-48
-#[cfg(feature = "indexeddb")]49
-impl StoreLock {50
- /// `ifAvailable` answers at once. the lock is ours until the promise we hand back settles51
- pub(crate) async fn web(name: &str) -> Result<Self, AccountError> {52
- use futures_util::future::{Either, select};53
- use js_sys::{Function, Object, Promise, Reflect};54
- use tokio::sync::oneshot;55
- use wasm_bindgen::{JsCast, JsValue, closure::Closure};56
-57
- let failed = |error: JsValue| {58
- account_error(59
- "locking the Matrix store",60
- WebLockError::Failed(format!("{error:?}")),61
- )62
- };63
- let locks = Reflect::get(&js_sys::global(), &JsValue::from_str("navigator"))64
- .and_then(|navigator| Reflect::get(&navigator, &JsValue::from_str("locks")))65
- .map_err(failed)?;66
- if locks.is_undefined() {67
- return Err(account_error(68
- "locking the Matrix store",69
- WebLockError::Unavailable,70
- ));71
- }72
- let request: Function = Reflect::get(&locks, &JsValue::from_str("request"))73
- .map_err(failed)?74
- .dyn_into()75
- .map_err(failed)?;76
- let options = Object::new();77
- Reflect::set(&options, &JsValue::from_str("ifAvailable"), &JsValue::TRUE)78
- .map_err(failed)?;79
-80
- let (granted, grant) = oneshot::channel();81
- let callback = Closure::once(move |lock: JsValue| -> JsValue {82
- if lock.is_null() {83
- let _ = granted.send(None);84
- return JsValue::UNDEFINED;85
- }86
- let mut release = None;87
- let held = Promise::new(&mut |resolve, _| release = Some(resolve));88
- let _ = granted.send(release);89
- held.into()90
- });91
- let outcome: Promise = request92
- .call3(93
- &locks,94
- &JsValue::from_str(&format!("macltok:{name}")),95
- &options,96
- callback.as_ref(),97
- )98
- .map_err(failed)?99
- .dyn_into()100
- .map_err(failed)?;101
- match select(grant, wasm_bindgen_futures::JsFuture::from(outcome)).await {102
- Either::Left((Ok(Some(release)), _)) => Ok(Self { release }),103
- Either::Left((Ok(None), _)) => Err(AccountError::StoreInUse),104
- Either::Left((Err(_), _)) | Either::Right((Ok(_), _)) => {105
- Err(failed(JsValue::from_str("the lock callback never ran")))106
- }107
- Either::Right((Err(error), _)) => Err(failed(error)),108
- }109
- }110
-}111
-112
-#[cfg(feature = "indexeddb")]113
-impl Drop for StoreLock {114
- fn drop(&mut self) {115
- let _ = self.release.call0(&wasm_bindgen::JsValue::UNDEFINED);116
- }117
-}@@ -1,1125 +0,0 @@
1
-use std::{2
- fmt,3
- future::Future,4
- pin::Pin,5
- sync::Arc,6
- task::{Context, Poll},7
-};8
-9
-use matrix_sdk::{Client, RoomMemberships};10
-use matrix_sdk_ui::{11
- notification_client::{12
- NotificationClient, NotificationProcessSetup, NotificationStatus, RawNotificationEvent,13
- },14
- spaces::{SpaceRoomList, SpaceService, room_list::SpaceRoomListPaginationState},15
- sync_service::SyncService,16
-};17
-use ruma::{18
- EventId, OwnedDeviceId, OwnedEventId, OwnedMxcUri, OwnedRoomId, OwnedRoomOrAliasId,19
- OwnedServerName, OwnedTransactionId, OwnedUserId, RoomId, UserId,20
- events::{21
- AnyGlobalAccountDataEventContent, AnyRoomAccountDataEvent, AnyRoomAccountDataEventContent,22
- GlobalAccountDataEventType, RoomAccountDataEventType,23
- },24
- serde::Raw,25
-};26
-use tokio::sync::{OnceCell, mpsc, oneshot, watch};27
-28
-use crate::{29
- AccountCommand, AttachmentUpload, Command, CommandOutput, ConnectionState, CreateRoomOptions,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,35
- RoomNotificationMode, RoomNotificationSettings, RoomPowerLevels, RoomSummary, SpaceChild,36
- TimelineCommand, TimelineId, VerificationAction, VerificationTarget,37
- encryption::encryption_state,38
- engine::{MediaService, StoreLock},39
- model::{PushNotificationRoom, PushNotificationSender},40
- normalise::{image_packs, member_summary, room_summaries, space_child, url_previews_allowed},41
-};42
-43
-#[derive(Clone)]44
-pub struct ClientHandle {45
- requests: mpsc::Sender<Request>,46
- client: Client,47
- connection: watch::Receiver<ConnectionState>,48
- notification_client: Arc<OnceCell<NotificationClient>>,49
- sync_service: Arc<SyncService>,50
- media: Arc<MediaService>,51
- spaces: Arc<OnceCell<SpaceService>>,52
- _store_lock: Option<Arc<StoreLock>>,53
-}54
-55
-impl fmt::Debug for ClientHandle {56
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {57
- formatter58
- .debug_struct("ClientHandle")59
- .finish_non_exhaustive()60
- }61
-}62
-63
-/// a command's answer. drop it if you don't care, the command still runs64
-#[derive(Debug)]65
-#[must_use = "dropping it is fine but then you'll never know"]66
-pub struct Pending(oneshot::Receiver<Result<CommandOutput, Error>>);67
-68
-impl Future for Pending {69
- type Output = Result<CommandOutput, Error>;70
-71
- fn poll(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Self::Output> {72
- Pin::new(&mut self.0)73
- .poll(context)74
- .map(|result| result.unwrap_or_else(|_| Err(Error::interrupted())))75
- }76
-}77
-78
-impl ClientHandle {79
- /// current credentials, it holds secrets. the engine saves it for you unless the80
- /// session was built `without_persistence`81
- #[cfg(any(feature = "sqlite", feature = "indexeddb"))]82
- pub fn session_record(&self) -> Result<crate::SessionRecord, crate::AccountError> {83
- crate::account::session_record(&self.client)84
- }85
-86
- /// the bearer token. it rotates on every refresh so ask again after a 401 rather than87
- /// keeping it, and keep it in memory and away from all but the homeserver's media endpoints88
- pub fn access_token(&self) -> Option<String> {89
- self.client.access_token()90
- }91
-92
- /// queue a command, waiting for room in the queue if there isn't any93
- pub async fn send(&self, command: Command) -> Result<Pending, Error> {94
- let (request, pending) = Request::new(command);95
- self.requests96
- .send(request)97
- .await98
- .map_err(|_| Error::interrupted())?;99
- Ok(pending)100
- }101
-102
- /// queue a command or fail with `QueueFull` right now103
- pub fn try_send(&self, command: Command) -> Result<Pending, Error> {104
- let (request, pending) = Request::new(command);105
- self.requests106
- .try_send(request)107
- .map_err(|error| match error {108
- mpsc::error::TrySendError::Full(_) => {109
- Error::new(ErrorKind::QueueFull, "the command queue is full")110
- }111
- mpsc::error::TrySendError::Closed(_) => Error::interrupted(),112
- })?;113
- Ok(pending)114
- }115
-116
- /// queue a command and wait for its answer. timeline sends answer once the117
- /// sdk send queue has them, delivery shows up later as the item's send state118
- pub async fn execute(&self, command: Command) -> Result<CommandOutput, Error> {119
- self.send(command).await?.await120
- }121
-122
- async fn run(&self, command: Command) -> Result<(), Error> {123
- self.execute(command).await.map(drop)124
- }125
-126
- async fn run_for_room(&self, command: RoomCommand) -> Result<OwnedRoomId, Error> {127
- match self.execute(Command::Room(command)).await? {128
- CommandOutput::Room(room_id) => Ok(room_id),129
- output => Err(Error::new(130
- ErrorKind::Other,131
- format!("expected a room but got {output:?}"),132
- )),133
- }134
- }135
-136
- pub async fn shutdown(&self) -> Result<(), Error> {137
- self.run(Command::Lifecycle(LifecycleCommand::Shutdown))138
- .await139
- }140
-141
- pub async fn logout(&self) -> Result<(), Error> {142
- self.run(Command::Account(AccountCommand::Logout)).await143
- }144
-145
- pub async fn delete_devices(146
- &self,147
- device_ids: Vec<OwnedDeviceId>,148
- auth: Option<InteractiveAuth>,149
- ) -> Result<InteractiveAuthOutcome, Error> {150
- match self151
- .run(Command::Account(AccountCommand::DeleteDevices {152
- device_ids,153
- auth,154
- }))155
- .await156
- {157
- Ok(()) => Ok(InteractiveAuthOutcome::Completed),158
- Err(Error {159
- challenge: Some(challenge),160
- ..161
- }) => Ok(InteractiveAuthOutcome::Challenge(*challenge)),162
- Err(error) => Err(error),163
- }164
- }165
-166
- pub async fn rename_device(167
- &self,168
- device_id: OwnedDeviceId,169
- display_name: impl Into<String>,170
- ) -> Result<(), Error> {171
- self.run(Command::Account(AccountCommand::RenameDevice {172
- device_id,173
- display_name: display_name.into(),174
- }))175
- .await176
- }177
-178
- pub async fn set_global_account_data(179
- &self,180
- event_type: GlobalAccountDataEventType,181
- content: Raw<AnyGlobalAccountDataEventContent>,182
- ) -> Result<(), Error> {183
- self.run(Command::Account(AccountCommand::SetAccountData {184
- event_type,185
- content,186
- }))187
- .await188
- }189
-190
- pub async fn set_room_account_data(191
- &self,192
- room_id: OwnedRoomId,193
- event_type: RoomAccountDataEventType,194
- content: Raw<AnyRoomAccountDataEventContent>,195
- ) -> Result<(), Error> {196
- self.run(Command::Room(RoomCommand::SetAccountData {197
- room_id,198
- event_type,199
- content,200
- }))201
- .await202
- }203
-204
- pub async fn set_room_notification_mode(205
- &self,206
- room_id: OwnedRoomId,207
- mode: RoomNotificationMode,208
- ) -> Result<(), Error> {209
- self.run(Command::Notification(NotificationCommand::SetRoomMode {210
- room_id,211
- mode,212
- }))213
- .await214
- }215
-216
- pub async fn reset_room_notification_mode(&self, room_id: OwnedRoomId) -> Result<(), Error> {217
- self.run(Command::Notification(NotificationCommand::ResetRoomMode(218
- room_id,219
- )))220
- .await221
- }222
-223
- pub async fn set_default_notification_mode(224
- &self,225
- encrypted: bool,226
- one_to_one: bool,227
- mode: RoomNotificationMode,228
- ) -> Result<(), Error> {229
- self.run(Command::Notification(NotificationCommand::SetDefaultMode {230
- encrypted,231
- one_to_one,232
- mode,233
- }))234
- .await235
- }236
-237
- pub async fn add_notification_keyword(&self, keyword: impl Into<String>) -> Result<(), Error> {238
- self.run(Command::Notification(NotificationCommand::AddKeyword(239
- keyword.into(),240
- )))241
- .await242
- }243
-244
- pub async fn remove_notification_keyword(245
- &self,246
- keyword: impl Into<String>,247
- ) -> Result<(), Error> {248
- self.run(Command::Notification(NotificationCommand::RemoveKeyword(249
- keyword.into(),250
- )))251
- .await252
- }253
-254
- pub async fn open_room(&self, room_id: OwnedRoomId) -> Result<(), Error> {255
- self.run(Command::Room(RoomCommand::Open(room_id))).await256
- }257
-258
- pub async fn close_room(&self, room_id: OwnedRoomId) -> Result<(), Error> {259
- self.run(Command::Room(RoomCommand::Close(room_id))).await260
- }261
-262
- pub async fn open_thread(263
- &self,264
- room_id: OwnedRoomId,265
- root_event_id: OwnedEventId,266
- ) -> Result<(), Error> {267
- self.run(Command::Timeline(TimelineCommand::OpenThread {268
- room_id,269
- root_event_id,270
- }))271
- .await272
- }273
-274
- pub async fn close_thread(275
- &self,276
- room_id: OwnedRoomId,277
- root_event_id: OwnedEventId,278
- ) -> Result<(), Error> {279
- self.run(Command::Timeline(TimelineCommand::CloseThread {280
- room_id,281
- root_event_id,282
- }))283
- .await284
- }285
-286
- pub async fn resync(&self, timeline_id: TimelineId) -> Result<(), Error> {287
- self.run(Command::Timeline(TimelineCommand::Resync { timeline_id }))288
- .await289
- }290
-291
- pub async fn paginate(&self, timeline_id: TimelineId, limit: u16) -> Result<(), Error> {292
- self.run(Command::Timeline(TimelineCommand::Paginate {293
- timeline_id,294
- limit,295
- }))296
- .await297
- }298
-299
- pub async fn send_message(300
- &self,301
- timeline_id: TimelineId,302
- content: MessageContent,303
- ) -> Result<(), Error> {304
- self.run(Command::Timeline(TimelineCommand::Send {305
- timeline_id,306
- content,307
- }))308
- .await309
- }310
-311
- pub async fn edit_message(312
- &self,313
- timeline_id: TimelineId,314
- target: impl Into<EventRef>,315
- content: MessageEditContent,316
- ) -> Result<(), Error> {317
- self.run(Command::Timeline(TimelineCommand::Edit {318
- timeline_id,319
- target: target.into(),320
- content,321
- }))322
- .await323
- }324
-325
- pub async fn redact(326
- &self,327
- timeline_id: TimelineId,328
- target: impl Into<EventRef>,329
- reason: Option<String>,330
- ) -> Result<(), Error> {331
- self.run(Command::Timeline(TimelineCommand::Redact {332
- timeline_id,333
- target: target.into(),334
- reason,335
- }))336
- .await337
- }338
-339
- pub async fn toggle_reaction(340
- &self,341
- timeline_id: TimelineId,342
- target: impl Into<EventRef>,343
- key: impl Into<String>,344
- shortcode: Option<String>,345
- ) -> Result<(), Error> {346
- self.run(Command::Timeline(TimelineCommand::ToggleReaction {347
- timeline_id,348
- target: target.into(),349
- key: key.into(),350
- shortcode,351
- }))352
- .await353
- }354
-355
- pub async fn mark_read(356
- &self,357
- timeline_id: TimelineId,358
- event_id: OwnedEventId,359
- ) -> Result<(), Error> {360
- self.run(Command::Timeline(TimelineCommand::MarkRead {361
- timeline_id,362
- event_id,363
- }))364
- .await365
- }366
-367
- pub async fn set_typing(&self, room_id: OwnedRoomId, typing: bool) -> Result<(), Error> {368
- self.run(Command::Room(RoomCommand::SetTyping { room_id, typing }))369
- .await370
- }371
-372
- pub async fn prepare_to_send(&self, room_id: OwnedRoomId) -> Result<(), Error> {373
- self.run(Command::Room(RoomCommand::PrepareToSend(room_id)))374
- .await375
- }376
-377
- pub async fn join_room(378
- &self,379
- room: OwnedRoomOrAliasId,380
- via: Vec<OwnedServerName>,381
- ) -> Result<OwnedRoomId, Error> {382
- self.run_for_room(RoomCommand::Join { room, via }).await383
- }384
-385
- pub async fn leave_room(&self, room_id: OwnedRoomId) -> Result<(), Error> {386
- self.run(Command::Room(RoomCommand::Leave(room_id))).await387
- }388
-389
- pub async fn forget_room(&self, room_id: OwnedRoomId) -> Result<(), Error> {390
- self.run(Command::Room(RoomCommand::Forget(room_id))).await391
- }392
-393
- pub async fn accept_invite(&self, room_id: OwnedRoomId) -> Result<(), Error> {394
- self.run(Command::Room(RoomCommand::AcceptInvite(room_id)))395
- .await396
- }397
-398
- pub async fn reject_invite(&self, room_id: OwnedRoomId) -> Result<(), Error> {399
- self.run(Command::Room(RoomCommand::RejectInvite(room_id)))400
- .await401
- }402
-403
- pub async fn invite_user(404
- &self,405
- room_id: OwnedRoomId,406
- user_id: OwnedUserId,407
- ) -> Result<(), Error> {408
- self.run(Command::Room(RoomCommand::InviteUser { room_id, user_id }))409
- .await410
- }411
-412
- pub async fn kick_user(413
- &self,414
- room_id: OwnedRoomId,415
- user_id: OwnedUserId,416
- reason: Option<String>,417
- ) -> Result<(), Error> {418
- self.run(Command::Room(RoomCommand::KickUser {419
- room_id,420
- user_id,421
- reason,422
- }))423
- .await424
- }425
-426
- pub async fn ban_user(427
- &self,428
- room_id: OwnedRoomId,429
- user_id: OwnedUserId,430
- reason: Option<String>,431
- ) -> Result<(), Error> {432
- self.run(Command::Room(RoomCommand::BanUser {433
- room_id,434
- user_id,435
- reason,436
- }))437
- .await438
- }439
-440
- pub async fn unban_user(441
- &self,442
- room_id: OwnedRoomId,443
- user_id: OwnedUserId,444
- reason: Option<String>,445
- ) -> Result<(), Error> {446
- self.run(Command::Room(RoomCommand::UnbanUser {447
- room_id,448
- user_id,449
- reason,450
- }))451
- .await452
- }453
-454
- pub async fn set_power_levels(455
- &self,456
- room_id: OwnedRoomId,457
- changes: PowerLevelChanges,458
- ) -> Result<(), Error> {459
- self.run(Command::Room(RoomCommand::SetPowerLevels {460
- room_id,461
- changes,462
- }))463
- .await464
- }465
-466
- pub async fn set_user_power_level(467
- &self,468
- room_id: OwnedRoomId,469
- user_id: OwnedUserId,470
- level: i64,471
- ) -> Result<(), Error> {472
- self.run(Command::Room(RoomCommand::SetUserPowerLevel {473
- room_id,474
- user_id,475
- level,476
- }))477
- .await478
- }479
-480
- pub async fn create_room(&self, options: CreateRoomOptions) -> Result<OwnedRoomId, Error> {481
- self.run_for_room(RoomCommand::Create(options)).await482
- }483
-484
- /// hands back the existing dm with them if there is one485
- pub async fn create_direct(&self, user_id: OwnedUserId) -> Result<OwnedRoomId, Error> {486
- self.run_for_room(RoomCommand::CreateDirect(user_id)).await487
- }488
-489
- pub async fn cancel_send(490
- &self,491
- room_id: OwnedRoomId,492
- transaction_id: OwnedTransactionId,493
- ) -> Result<(), Error> {494
- self.run(Command::Room(RoomCommand::CancelSend {495
- room_id,496
- transaction_id,497
- }))498
- .await499
- }500
-501
- pub async fn retry_send(502
- &self,503
- room_id: OwnedRoomId,504
- transaction_id: OwnedTransactionId,505
- ) -> Result<(), Error> {506
- self.run(Command::Room(RoomCommand::RetrySend {507
- room_id,508
- transaction_id,509
- }))510
- .await511
- }512
-513
- pub async fn upload(&self, upload: AttachmentUpload) -> Result<(), Error> {514
- self.run(Command::Media(MediaCommand::Upload(upload))).await515
- }516
-517
- pub async fn recover(&self, recovery_key: impl Into<String>) -> Result<(), Error> {518
- self.run(Command::Encryption(EncryptionCommand::Recover(519
- recovery_key.into(),520
- )))521
- .await522
- }523
-524
- pub async fn create_key_backup(&self) -> Result<(), Error> {525
- self.run(Command::Encryption(EncryptionCommand::CreateKeyBackup))526
- .await527
- }528
-529
- pub async fn enable_key_backup(&self) -> Result<(), Error> {530
- self.run(Command::Encryption(EncryptionCommand::EnableKeyBackup))531
- .await532
- }533
-534
- pub async fn disable_key_backup(&self, delete_from_server: bool) -> Result<(), Error> {535
- self.run(Command::Encryption(EncryptionCommand::DisableKeyBackup {536
- delete_from_server,537
- }))538
- .await539
- }540
-541
- pub async fn recover_key_backup(&self, recovery_key: impl Into<String>) -> Result<(), Error> {542
- self.run(Command::Encryption(EncryptionCommand::RecoverKeyBackup(543
- recovery_key.into(),544
- )))545
- .await546
- }547
-548
- pub async fn start_verification(&self, target: VerificationTarget) -> Result<(), Error> {549
- self.run(Command::Encryption(EncryptionCommand::StartVerification(550
- target,551
- )))552
- .await553
- }554
-555
- pub async fn verify(&self, action: VerificationAction) -> Result<(), Error> {556
- self.run(Command::Encryption(EncryptionCommand::Verify(action)))557
- .await558
- }559
-560
- /// bytes for an avatar, or for media a retained timeline showed you. concurrent asks share561
- /// one download562
- pub async fn media(&self, media_id: &MediaId, size: MediaSize) -> Result<Media, Error> {563
- self.media.timeline_media(media_id, size).await564
- }565
-566
- /// where `media` would fetch from, for a host streaming big files itself. the byte cap567
- /// doesn't apply since nothing is buffered568
- pub async fn media_source(569
- &self,570
- media_id: &MediaId,571
- size: MediaSize,572
- ) -> Result<MediaLocation, Error> {573
- self.media.media_location(media_id, size).await574
- }575
-576
- /// the space graph builds on first use and then keeps itself current577
- async fn spaces(&self) -> &SpaceService {578
- self.spaces579
- .get_or_init(|| SpaceService::new(self.client.clone()))580
- .await581
- }582
-583
- pub async fn rooms(&self) -> Vec<RoomSummary> {584
- room_summaries(&self.client, self.spaces().await).await585
- }586
-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
-602
- /// browse a space the way the server sees it, rooms you haven't joined included603
- pub async fn space_hierarchy(&self, space_id: OwnedRoomId) -> SpaceHierarchy {604
- SpaceHierarchy(SpaceRoomList::new(self.client.clone(), space_id).await)605
- }606
-607
- pub async fn own_profile(&self) -> Result<Profile, Error> {608
- let user_id = self609
- .client610
- .user_id()611
- .ok_or_else(|| Error::new(ErrorKind::Unauthenticated, "the client is not logged in"))?612
- .to_owned();613
- let response = self614
- .client615
- .account()616
- .fetch_user_profile()617
- .await618
- .map_err(|source| Error::from_sdk("fetching the profile failed", source))?;619
- profile(user_id, &response)620
- }621
-622
- pub async fn user_profile(&self, user_id: &UserId) -> Result<Profile, Error> {623
- let response = self624
- .client625
- .account()626
- .fetch_user_profile_of(user_id)627
- .await628
- .map_err(|source| Error::from_sdk("fetching the profile failed", source))?;629
- profile(user_id.to_owned(), &response)630
- }631
-632
- pub async fn set_display_name(&self, name: Option<String>) -> Result<(), Error> {633
- self.run(Command::Account(AccountCommand::SetDisplayName(name)))634
- .await635
- }636
-637
- pub async fn set_avatar(&self, avatar: Option<OwnedMxcUri>) -> Result<(), Error> {638
- self.run(Command::Account(AccountCommand::SetAvatar(avatar)))639
- .await640
- }641
-642
- pub async fn upload_avatar(643
- &self,644
- mimetype: impl Into<String>,645
- bytes: Vec<u8>,646
- ) -> Result<(), Error> {647
- self.run(Command::Account(AccountCommand::UploadAvatar {648
- mimetype: mimetype.into(),649
- bytes,650
- }))651
- .await652
- }653
-654
- pub async fn rooms_filtered(&self, filter: &RoomListFilter) -> Vec<RoomSummary> {655
- let matcher = filter.matcher();656
- self.rooms()657
- .await658
- .into_iter()659
- .filter(|room| matcher.matches(room))660
- .collect()661
- }662
-663
- pub async fn devices(&self) -> Result<Vec<OwnDevice>, Error> {664
- let current_device_id = self.client.device_id();665
- Ok(self666
- .client667
- .devices()668
- .await669
- .map_err(|source| Error::from_http("listing devices failed", source))?670
- .devices671
- .into_iter()672
- .map(|device| OwnDevice {673
- current: current_device_id == Some(device.device_id.as_ref()),674
- device_id: device.device_id,675
- display_name: device.display_name,676
- last_seen_ip: device.last_seen_ip,677
- last_seen_timestamp_ms: device.last_seen_ts.map(|timestamp| timestamp.get().into()),678
- })679
- .collect())680
- }681
-682
- fn room(&self, room_id: &RoomId) -> Result<matrix_sdk::Room, Error> {683
- self.client684
- .get_room(room_id)685
- .ok_or_else(Error::room_unknown)686
- }687
-688
- pub async fn room_notification_settings(689
- &self,690
- room_id: &RoomId,691
- ) -> Result<Option<RoomNotificationSettings>, Error> {692
- let room = self.room(room_id)?;693
- let custom = room.user_defined_notification_mode().await;694
- let mode = match custom {695
- Some(mode) => mode,696
- None => match room.notification_mode().await {697
- Some(mode) => mode,698
- None => return Ok(None),699
- },700
- };701
- Ok(Some(RoomNotificationSettings {702
- mode: crate::normalise::notification_mode(mode),703
- custom: custom.is_some(),704
- }))705
- }706
-707
- pub async fn notification_keywords(&self) -> Vec<String> {708
- self.client709
- .notification_settings()710
- .await711
- .enabled_keywords()712
- .await713
- .into_iter()714
- .collect()715
- }716
-717
- pub async fn resolve_push_notification(718
- &self,719
- room_id: &RoomId,720
- event_id: &EventId,721
- ) -> Result<PushNotificationStatus, Error> {722
- let notification_failed = |source| {723
- Error::with_source(724
- ErrorKind::Other,725
- "resolving the push notification failed",726
- source,727
- )728
- };729
- let client = self730
- .notification_client731
- .get_or_try_init(|| async {732
- NotificationClient::new(733
- self.client.clone(),734
- NotificationProcessSetup::SingleProcess {735
- sync_service: self.sync_service.clone(),736
- },737
- )738
- .await739
- })740
- .await741
- .map_err(notification_failed)?;742
- let status = client743
- .get_notification(room_id, event_id)744
- .await745
- .map_err(notification_failed)?;746
- Ok(normalise_push_notification(room_id, event_id, status))747
- }748
-749
- pub async fn members(750
- &self,751
- room_id: &RoomId,752
- filter: MemberFilter,753
- ) -> Result<Vec<MemberSummary>, Error> {754
- let memberships = match filter {755
- MemberFilter::Active => RoomMemberships::ACTIVE,756
- MemberFilter::Joined => RoomMemberships::JOIN,757
- MemberFilter::Invited => RoomMemberships::INVITE,758
- MemberFilter::Banned => RoomMemberships::BAN,759
- MemberFilter::Left => RoomMemberships::LEAVE,760
- // An empty mask is the SDK's unfiltered wildcard: it matches every761
- // membership, including custom states that `all()` would exclude.762
- MemberFilter::All => RoomMemberships::empty(),763
- };764
- Ok(self765
- .room(room_id)?766
- .members(memberships)767
- .await768
- .map_err(|source| Error::from_sdk("listing members failed", source))?769
- .into_iter()770
- .map(member_summary)771
- .collect())772
- }773
-774
- pub async fn room_power_levels(&self, room_id: &RoomId) -> Result<RoomPowerLevels, Error> {775
- let levels = self776
- .room(room_id)?777
- .power_levels()778
- .await779
- .map_err(|source| Error::from_sdk("reading power levels failed", source.into()))?;780
- Ok(RoomPowerLevels {781
- ban: levels.ban.into(),782
- invite: levels.invite.into(),783
- kick: levels.kick.into(),784
- redact: levels.redact.into(),785
- events_default: levels.events_default.into(),786
- state_default: levels.state_default.into(),787
- users_default: levels.users_default.into(),788
- room_notification: levels.notifications.room.into(),789
- users: levels790
- .users791
- .into_iter()792
- .map(|(user_id, level)| (user_id, level.into()))793
- .collect(),794
- events: levels795
- .events796
- .into_iter()797
- .map(|(event_type, level)| (event_type.to_string(), level.into()))798
- .collect(),799
- creators: levels800
- .rules801
- .privileged_creators802
- .into_iter()803
- .flatten()804
- .collect(),805
- })806
- }807
-808
- pub async fn pending_send_ids(809
- &self,810
- room_id: &RoomId,811
- ) -> Result<Vec<OwnedTransactionId>, Error> {812
- let (echoes, _) = self813
- .room(room_id)?814
- .send_queue()815
- .subscribe()816
- .await817
- .map_err(|source| {818
- Error::with_source(ErrorKind::Store, "reading the send queue failed", source)819
- })?;820
- Ok(echoes821
- .into_iter()822
- .filter(|echo| {823
- matches!(824
- echo.content,825
- matrix_sdk::send_queue::LocalEchoContent::Event { .. }826
- )827
- })828
- .map(|echo| echo.transaction_id)829
- .collect())830
- }831
-832
- pub async fn global_account_data(833
- &self,834
- event_type: GlobalAccountDataEventType,835
- ) -> Result<Option<Raw<AnyGlobalAccountDataEventContent>>, Error> {836
- self.client837
- .account()838
- .account_data_raw(event_type)839
- .await840
- .map_err(|source| Error::from_sdk("reading account data failed", source))841
- }842
-843
- pub async fn room_account_data(844
- &self,845
- room_id: &RoomId,846
- event_type: RoomAccountDataEventType,847
- ) -> Result<Option<Raw<AnyRoomAccountDataEvent>>, Error> {848
- self.room(room_id)?849
- .account_data(event_type)850
- .await851
- .map_err(|source| Error::from_sdk("reading room account data failed", source))852
- }853
-854
- #[must_use]855
- pub fn connection_state(&self) -> watch::Receiver<ConnectionState> {856
- self.connection.clone()857
- }858
-859
- #[must_use]860
- pub fn encryption_state(&self) -> Option<EncryptionState> {861
- let device_id = self.client.device_id()?.to_owned();862
- let encryption = self.client.encryption();863
- Some(encryption_state(864
- device_id,865
- encryption.recovery().state(),866
- encryption.verification_state().get(),867
- encryption.backups().state(),868
- ))869
- }870
-}871
-872
-/// one space's hierarchy, a page at a time. hold on to it while you browse, it remembers where it got to873
-pub struct SpaceHierarchy(SpaceRoomList);874
-875
-impl fmt::Debug for SpaceHierarchy {876
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {877
- formatter878
- .debug_struct("SpaceHierarchy")879
- .finish_non_exhaustive()880
- }881
-}882
-883
-impl SpaceHierarchy {884
- /// fetches the next page. a no-op once the end is reached or while a page is in flight885
- pub async fn paginate(&self) -> Result<(), Error> {886
- self.0.paginate().await.map_err(|source| {887
- Error::with_source(888
- ErrorKind::Server,889
- "fetching the space hierarchy failed",890
- source,891
- )892
- })893
- }894
-895
- /// everything fetched so far, the space itself excluded896
- pub async fn rooms(&self) -> Vec<SpaceChild> {897
- self.0.rooms().await.into_iter().map(space_child).collect()898
- }899
-900
- #[must_use]901
- pub fn end_reached(&self) -> bool {902
- matches!(903
- self.0.pagination_state(),904
- SpaceRoomListPaginationState::Idle { end_reached: true }905
- )906
- }907
-}908
-909
-fn profile(910
- user_id: OwnedUserId,911
- response: &ruma::api::client::profile::get_profile::v3::Response,912
-) -> Result<Profile, Error> {913
- use ruma::api::client::profile::{AvatarUrl, DisplayName};914
- let malformed = |source| {915
- Error::with_source(916
- ErrorKind::Server,917
- "the server sent a malformed profile",918
- source,919
- )920
- };921
- Ok(Profile {922
- user_id,923
- display_name: response.get_static::<DisplayName>().map_err(malformed)?,924
- avatar: response925
- .get_static::<AvatarUrl>()926
- .map_err(malformed)?927
- .as_deref()928
- .map(MediaId::plain),929
- })930
-}931
-932
-#[derive(Debug)]933
-pub(crate) struct Request {934
- pub(crate) command: Command,935
- pub(crate) reply: Responder,936
-}937
-938
-impl Request {939
- fn new(command: Command) -> (Self, Pending) {940
- let (reply, pending) = oneshot::channel();941
- (942
- Self {943
- command,944
- reply: Responder(reply),945
- },946
- Pending(pending),947
- )948
- }949
-}950
-951
-/// whoever holds this owes the caller an answer. dropping it answers `Interrupted`952
-#[derive(Debug)]953
-pub(crate) struct Responder(oneshot::Sender<Result<CommandOutput, Error>>);954
-955
-impl Responder {956
- pub(crate) fn send(self, result: Result<CommandOutput, Error>) {957
- if let Err(error) = &result {958
- tracing::debug!(%error, kind = ?error.kind, "command failed");959
- }960
- let _ = self.0.send(result);961
- }962
-963
- pub(crate) fn done(self, result: Result<(), Error>) {964
- self.send(result.map(|()| CommandOutput::Done));965
- }966
-}967
-968
-#[derive(Debug)]969
-pub struct EventStream {970
- events: mpsc::Receiver<Event>,971
-}972
-973
-impl EventStream {974
- pub async fn recv(&mut self) -> Option<Event> {975
- self.events.recv().await976
- }977
-978
- pub fn try_recv(&mut self) -> Result<Event, mpsc::error::TryRecvError> {979
- self.events.try_recv()980
- }981
-}982
-983
-impl futures_util::Stream for EventStream {984
- type Item = Event;985
-986
- fn poll_next(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Option<Self::Item>> {987
- self.events.poll_recv(context)988
- }989
-}990
-991
-#[derive(Debug)]992
-pub(crate) struct DriverPort {993
- requests: mpsc::Receiver<Request>,994
- events: mpsc::Sender<Event>,995
- connection: watch::Sender<ConnectionState>,996
-}997
-998
-#[derive(Clone, Debug)]999
-pub(crate) struct EventSender(mpsc::Sender<Event>);1000
-1001
-impl EventSender {1002
- pub(crate) async fn send(&self, event: Event) -> Result<(), SendEventError> {1003
- self.0.send(event).await.map_err(|_| SendEventError)1004
- }1005
-1006
- pub(crate) async fn reserve(&self) -> Result<mpsc::Permit<'_, Event>, SendEventError> {1007
- self.0.reserve().await.map_err(|_| SendEventError)1008
- }1009
-1010
- /// resolves once every `EventStream` is gone1011
- pub(crate) async fn closed(&self) {1012
- self.0.closed().await;1013
- }1014
-}1015
-1016
-impl DriverPort {1017
- pub(crate) fn event_sender(&self) -> EventSender {1018
- EventSender(self.events.clone())1019
- }1020
-1021
- pub(crate) fn connection_sender(&self) -> watch::Sender<ConnectionState> {1022
- self.connection.clone()1023
- }1024
-1025
- pub(crate) async fn recv(&mut self) -> Option<Request> {1026
- self.requests.recv().await1027
- }1028
-1029
- pub(crate) fn try_recv(&mut self) -> Option<Request> {1030
- self.requests.try_recv().ok()1031
- }1032
-1033
- pub(crate) fn close(&mut self) {1034
- self.requests.close();1035
- }1036
-}1037
-1038
-#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]1039
-#[error("the event receiver has closed")]1040
-pub(crate) struct SendEventError;1041
-1042
-#[must_use]1043
-pub(crate) fn transport_pair(1044
- client: Client,1045
- sync_service: Arc<SyncService>,1046
- media: Arc<MediaService>,1047
- store_lock: Option<Arc<StoreLock>>,1048
- command_capacity: usize,1049
- event_capacity: usize,1050
-) -> (ClientHandle, DriverPort, EventStream) {1051
- let (request_tx, request_rx) = mpsc::channel(command_capacity);1052
- let (event_tx, event_rx) = mpsc::channel(event_capacity);1053
- let (connection_tx, connection_rx) = watch::channel(ConnectionState::Idle);1054
- (1055
- ClientHandle {1056
- requests: request_tx,1057
- client,1058
- connection: connection_rx,1059
- notification_client: Arc::new(OnceCell::new()),1060
- sync_service,1061
- media,1062
- spaces: Arc::new(OnceCell::new()),1063
- _store_lock: store_lock,1064
- },1065
- DriverPort {1066
- requests: request_rx,1067
- events: event_tx,1068
- connection: connection_tx,1069
- },1070
- EventStream { events: event_rx },1071
- )1072
-}1073
-1074
-fn normalise_push_notification(1075
- room_id: &RoomId,1076
- event_id: &EventId,1077
- status: NotificationStatus,1078
-) -> PushNotificationStatus {1079
- match status {1080
- NotificationStatus::Event(item) => {1081
- let event = match &item.raw_event {1082
- RawNotificationEvent::Timeline(event) => {1083
- PushNotificationEvent::Timeline(event.clone())1084
- }1085
- RawNotificationEvent::Invite(event) => PushNotificationEvent::Invite(event.clone()),1086
- };1087
- PushNotificationStatus::Event(Box::new(PushNotification {1088
- event_id: event_id.to_owned(),1089
- event,1090
- sender: PushNotificationSender {1091
- user_id: item.event.sender().to_owned(),1092
- display_name: item.sender_display_name,1093
- avatar_url: item.sender_avatar_url.as_deref().map(MediaId::plain),1094
- name_ambiguous: item.is_sender_name_ambiguous,1095
- },1096
- room: PushNotificationRoom {1097
- room_id: room_id.to_owned(),1098
- name: item.room_computed_display_name,1099
- avatar_url: item.room_avatar_url.as_deref().map(MediaId::plain),1100
- canonical_alias: item1101
- .room_canonical_alias1102
- .as_deref()1103
- .and_then(|alias| alias.parse().ok()),1104
- topic: item.room_topic,1105
- encrypted: item.is_room_encrypted,1106
- direct: item.room_is_dm,1107
- joined_members_count: item.joined_members_count,1108
- service_members: item1109
- .service_members1110
- .into_iter()1111
- .filter_map(|user_id| user_id.parse().ok())1112
- .collect(),1113
- active_service_members_count: item.active_service_members_count,1114
- space: item.is_space,1115
- },1116
- noisy: item.is_noisy,1117
- has_mention: item.has_mention,1118
- thread_id: item.thread_id,1119
- }))1120
- }1121
- NotificationStatus::EventNotFound => PushNotificationStatus::NotFound,1122
- NotificationStatus::EventFilteredOut => PushNotificationStatus::FilteredOut,1123
- NotificationStatus::EventRedacted => PushNotificationStatus::Redacted,1124
- }1125
-}@@ -1,234 +0,0 @@
1
-use std::fmt;2
-3
-use ruma::{UserId, api::client::uiaa, serde::JsonObject};4
-#[cfg(feature = "serde")]5
-use serde::{Deserialize, Serialize};6
-7
-#[derive(Eq, PartialEq)]8
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]9
-#[non_exhaustive]10
-pub enum InteractiveAuth {11
- Password {12
- password: String,13
- session: Option<String>,14
- },15
- ReCaptcha {16
- response: String,17
- session: Option<String>,18
- },19
- Dummy {20
- session: Option<String>,21
- },22
- FallbackAcknowledgement {23
- session: String,24
- },25
- OAuth {26
- session: Option<String>,27
- },28
- Stage {29
- auth_type: String,30
- session: Option<String>,31
- data: JsonObject,32
- },33
-}34
-35
-impl InteractiveAuth {36
- #[must_use]37
- pub fn password(password: impl Into<String>, session: Option<String>) -> Self {38
- Self::Password {39
- password: password.into(),40
- session,41
- }42
- }43
-44
- #[must_use]45
- pub fn recaptcha(response: impl Into<String>, session: Option<String>) -> Self {46
- Self::ReCaptcha {47
- response: response.into(),48
- session,49
- }50
- }51
-52
- #[must_use]53
- pub fn dummy(session: Option<String>) -> Self {54
- Self::Dummy { session }55
- }56
-57
- #[must_use]58
- pub fn fallback_acknowledgement(session: impl Into<String>) -> Self {59
- Self::FallbackAcknowledgement {60
- session: session.into(),61
- }62
- }63
-64
- #[must_use]65
- pub fn oauth(session: Option<String>) -> Self {66
- Self::OAuth { session }67
- }68
-69
- #[must_use]70
- pub fn stage(auth_type: impl Into<String>, session: Option<String>, data: JsonObject) -> Self {71
- Self::Stage {72
- auth_type: auth_type.into(),73
- session,74
- data,75
- }76
- }77
-78
- pub(crate) fn into_sdk(self, user_id: &UserId) -> Result<uiaa::AuthData, String> {79
- match self {80
- Self::Password { password, session } => {81
- let mut password = uiaa::Password::new(user_id.to_owned().into(), password);82
- password.session = session;83
- Ok(uiaa::AuthData::Password(password))84
- }85
- Self::ReCaptcha { response, session } => {86
- let mut recaptcha = uiaa::ReCaptcha::new(response);87
- recaptcha.session = session;88
- Ok(uiaa::AuthData::ReCaptcha(recaptcha))89
- }90
- Self::Dummy { session } => {91
- let mut dummy = uiaa::Dummy::new();92
- dummy.session = session;93
- Ok(uiaa::AuthData::Dummy(dummy))94
- }95
- Self::FallbackAcknowledgement { session } => {96
- Ok(uiaa::AuthData::fallback_acknowledgement(session))97
- }98
- Self::OAuth { session } => {99
- let mut oauth = uiaa::OAuth::new();100
- oauth.session = session;101
- Ok(uiaa::AuthData::OAuth(oauth))102
- }103
- Self::Stage {104
- auth_type,105
- session,106
- data,107
- } => uiaa::AuthData::new(&auth_type, session, data).map_err(|error| error.to_string()),108
- }109
- }110
-}111
-112
-impl fmt::Debug for InteractiveAuth {113
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {114
- match self {115
- Self::Password {116
- password: _,117
- session,118
- } => formatter119
- .debug_struct("Password")120
- .field("session", &session.as_ref().map(|_| "..."))121
- .finish_non_exhaustive(),122
- Self::ReCaptcha {123
- response: _,124
- session,125
- } => formatter126
- .debug_struct("ReCaptcha")127
- .field("session", &session.as_ref().map(|_| "..."))128
- .finish_non_exhaustive(),129
- Self::Dummy { session } => formatter130
- .debug_struct("Dummy")131
- .field("session", &session.as_ref().map(|_| "..."))132
- .finish(),133
- Self::FallbackAcknowledgement { session: _ } => formatter134
- .debug_struct("FallbackAcknowledgement")135
- .field("session", &"...")136
- .finish(),137
- Self::OAuth { session } => formatter138
- .debug_struct("OAuth")139
- .field("session", &session.as_ref().map(|_| "..."))140
- .finish(),141
- Self::Stage {142
- auth_type,143
- session,144
- data: _,145
- } => formatter146
- .debug_struct("Stage")147
- .field("auth_type", auth_type)148
- .field("session", &session.as_ref().map(|_| "..."))149
- .finish_non_exhaustive(),150
- }151
- }152
-}153
-154
-#[derive(Clone, Eq, PartialEq)]155
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]156
-#[non_exhaustive]157
-pub struct InteractiveAuthChallenge {158
- pub flows: Vec<InteractiveAuthFlow>,159
- pub completed: Vec<String>,160
- pub parameters_json: Option<String>,161
- pub session: Option<String>,162
- pub error: Option<InteractiveAuthFailure>,163
-}164
-165
-impl fmt::Debug for InteractiveAuthChallenge {166
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {167
- formatter168
- .debug_struct("InteractiveAuthChallenge")169
- .field("flows", &self.flows)170
- .field("completed", &self.completed)171
- .field(172
- "parameters_json",173
- &self.parameters_json.as_ref().map(|_| "..."),174
- )175
- .field("session", &self.session.as_ref().map(|_| "..."))176
- .field("error", &self.error)177
- .finish()178
- }179
-}180
-181
-impl From<&uiaa::UiaaInfo> for InteractiveAuthChallenge {182
- fn from(info: &uiaa::UiaaInfo) -> Self {183
- Self {184
- flows: info185
- .flows186
- .iter()187
- .map(|flow| InteractiveAuthFlow {188
- stages: flow189
- .stages190
- .iter()191
- .map(|stage| stage.as_str().to_owned())192
- .collect(),193
- })194
- .collect(),195
- completed: info196
- .completed197
- .iter()198
- .map(|stage| stage.as_str().to_owned())199
- .collect(),200
- parameters_json: info.params.as_ref().map(|params| params.get().to_owned()),201
- session: info.session.clone(),202
- error: info203
- .auth_error204
- .as_ref()205
- .map(|error| InteractiveAuthFailure {206
- code: error.kind.errcode().as_str().to_owned(),207
- message: error.message.clone(),208
- }),209
- }210
- }211
-}212
-213
-#[derive(Clone, Debug, Eq, PartialEq)]214
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]215
-#[non_exhaustive]216
-pub struct InteractiveAuthFlow {217
- pub stages: Vec<String>,218
-}219
-220
-#[derive(Clone, Debug, Eq, PartialEq)]221
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]222
-#[non_exhaustive]223
-pub struct InteractiveAuthFailure {224
- pub code: String,225
- pub message: String,226
-}227
-228
-#[derive(Clone, Debug, Eq, PartialEq)]229
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]230
-#[non_exhaustive]231
-pub enum InteractiveAuthOutcome {232
- Completed,233
- Challenge(InteractiveAuthChallenge),234
-}@@ -1,168 +0,0 @@
1
-use std::time::Duration;2
-3
-use matrix_sdk::{4
- attachment::{5
- AttachmentConfig, AttachmentInfo, BaseAudioInfo, BaseFileInfo, BaseImageInfo,6
- BaseVideoInfo, Thumbnail,7
- },8
- ruma::events::room::message::TextMessageEventContent,9
-};10
-11
-use crate::{AttachmentUpload, Error, ErrorKind, UploadDetails};12
-13
-pub(crate) fn prepare_upload(14
- mut upload: AttachmentUpload,15
- max_media_bytes: usize,16
-) -> Result<(AttachmentUpload, mime::Mime, AttachmentConfig), Error> {17
- if upload.bytes.len() > max_media_bytes {18
- return Err(Error::new(19
- ErrorKind::MediaTooLarge,20
- "attachment exceeds the configured byte limit",21
- ));22
- }23
- let mimetype: mime::Mime = upload24
- .mimetype25
- .parse()26
- .map_err(|_| Error::new(ErrorKind::Invalid, "media MIME type is invalid"))?;27
-28
- let size = ruma::UInt::try_from(upload.bytes.len()).map_err(|_| {29
- Error::new(30
- ErrorKind::MediaTooLarge,31
- "attachment size is not representable",32
- )33
- })?;34
- let info = match upload.details.as_ref() {35
- Some(UploadDetails::Image {36
- width,37
- height,38
- blurhash,39
- }) if mimetype.type_() == mime::IMAGE => AttachmentInfo::Image(BaseImageInfo {40
- width: width.map(Into::into),41
- height: height.map(Into::into),42
- size: Some(size),43
- blurhash: blurhash.clone(),44
- ..Default::default()45
- }),46
- Some(UploadDetails::Video {47
- width,48
- height,49
- duration_ms,50
- blurhash,51
- }) if mimetype.type_() == mime::VIDEO => AttachmentInfo::Video(BaseVideoInfo {52
- width: width.map(Into::into),53
- height: height.map(Into::into),54
- duration: duration_ms.map(Duration::from_millis),55
- size: Some(size),56
- blurhash: blurhash.clone(),57
- }),58
- Some(UploadDetails::Audio { duration_ms }) if mimetype.type_() == mime::AUDIO => {59
- AttachmentInfo::Audio(BaseAudioInfo {60
- duration: duration_ms.map(Duration::from_millis),61
- size: Some(size),62
- ..Default::default()63
- })64
- }65
- Some(UploadDetails::Voice {66
- duration_ms,67
- waveform,68
- }) if mimetype.type_() == mime::AUDIO => {69
- if waveform.iter().any(|sample| *sample > 1024) {70
- return Err(Error::new(71
- ErrorKind::Invalid,72
- "voice waveform samples must be at most 1024",73
- ));74
- }75
- AttachmentInfo::Voice(BaseAudioInfo {76
- duration: Some(Duration::from_millis(*duration_ms)),77
- size: Some(size),78
- waveform: Some(79
- waveform80
- .iter()81
- .map(|sample| f32::from(*sample) / 1024.0)82
- .collect(),83
- ),84
- })85
- }86
- Some(UploadDetails::File)87
- if mimetype.type_() != mime::IMAGE88
- && mimetype.type_() != mime::VIDEO89
- && mimetype.type_() != mime::AUDIO =>90
- {91
- AttachmentInfo::File(BaseFileInfo { size: Some(size) })92
- }93
- None => match mimetype.type_() {94
- mime::IMAGE => AttachmentInfo::Image(BaseImageInfo {95
- size: Some(size),96
- ..Default::default()97
- }),98
- mime::VIDEO => AttachmentInfo::Video(BaseVideoInfo {99
- size: Some(size),100
- ..Default::default()101
- }),102
- mime::AUDIO => AttachmentInfo::Audio(BaseAudioInfo {103
- size: Some(size),104
- ..Default::default()105
- }),106
- _ => AttachmentInfo::File(BaseFileInfo { size: Some(size) }),107
- },108
- _ => {109
- return Err(Error::new(110
- ErrorKind::Invalid,111
- "attachment metadata does not match its MIME type",112
- ));113
- }114
- };115
- if upload.thumbnail.is_some() && mimetype.type_() == mime::AUDIO {116
- return Err(Error::new(117
- ErrorKind::Invalid,118
- "audio attachments do not support thumbnails",119
- ));120
- }121
- let thumbnail = upload122
- .thumbnail123
- .take()124
- .map(|thumbnail| {125
- if thumbnail.bytes.len() > max_media_bytes126
- || thumbnail.width == 0127
- || thumbnail.height == 0128
- {129
- return Err(Error::new(130
- if thumbnail.bytes.len() > max_media_bytes {131
- ErrorKind::MediaTooLarge132
- } else {133
- ErrorKind::Invalid134
- },135
- "thumbnail size or dimensions are invalid",136
- ));137
- }138
- let content_type: mime::Mime = thumbnail139
- .mimetype140
- .parse()141
- .map_err(|_| Error::new(ErrorKind::Invalid, "thumbnail MIME type is invalid"))?;142
- if content_type.type_() != mime::IMAGE {143
- return Err(Error::new(144
- ErrorKind::Invalid,145
- "thumbnail MIME type must be an image",146
- ));147
- }148
- let size = ruma::UInt::try_from(thumbnail.bytes.len()).map_err(|_| {149
- Error::new(150
- ErrorKind::MediaTooLarge,151
- "thumbnail size is not representable",152
- )153
- })?;154
- Ok(Thumbnail {155
- data: thumbnail.bytes,156
- content_type,157
- width: thumbnail.width.into(),158
- height: thumbnail.height.into(),159
- size,160
- })161
- })162
- .transpose()?;163
- let config = AttachmentConfig::new()164
- .info(info)165
- .thumbnail(thumbnail)166
- .caption(upload.caption.clone().map(TextMessageEventContent::plain));167
- Ok((upload, mimetype, config))168
-}@@ -1,29 +0,0 @@
1
-use std::cell::RefCell;2
-3
-type Handler = Box<dyn Fn(&str)>;4
-5
-thread_local! {6
- static HANDLER: RefCell<Option<Handler>> = const { RefCell::new(None) };7
-}8
-9
-/// a panic on wasm32 traps the task that raised it and nothing unwinds, so the engine10
-/// catches nothing. the rest of it keeps running in an undefined state and goes silent,11
-/// no `Failed` and no `Stopped`. `handler` runs from the panic hook with the message,12
-/// after whatever hook was there before, and is the host's one chance to react, say by13
-/// terminating the worker and starting a fresh one. a second call replaces the handler14
-pub fn on_panic(handler: impl Fn(&str) + 'static) {15
- let installed = HANDLER.with(|slot| slot.replace(Some(Box::new(handler))).is_some());16
- if installed {17
- return;18
- }19
- let previous = std::panic::take_hook();20
- std::panic::set_hook(Box::new(move |info| {21
- previous(info);22
- let message = info.to_string();23
- HANDLER.with(|slot| {24
- if let Some(handler) = &*slot.borrow() {25
- handler(&message);26
- }27
- });28
- }));29
-}@@ -16,9 +16,7 @@ function precacheManifestPlugin(): import('vite').Plugin {
1616
name: 'malice-sw-precache-manifest',1717
generateBundle(_options, bundle) {1818
const urls = Object.keys(bundle)19
- .filter(20
- (fileName) => fileName !== 'sw.js' && fileName !== '.vite/manifest.json' && !fileName.startsWith('twemoji/')21
- )19
+ .filter((fileName) => fileName !== 'sw.js' && !fileName.startsWith('twemoji/'))2220
.map((fileName) => `/${fileName}`);2321
const swChunk = bundle['sw.js'];2422
if (swChunk && swChunk.type === 'chunk') {@@ -82,7 +80,6 @@ export default defineConfig(({ mode }) => {
8280
? {8381
outDir: 'dist-device',8482
emptyOutDir: true,85
- manifest: true,8683
rollupOptions: {8784
input: {8885
main: 'index.html',