Commit 5a52012c9b1d
tarball21 files changed+908-342
@@ -1830,6 +1830,7 @@ dependencies = [
18301830
"ruma",18311831
"serde",18321832
"serde_json",1833
+ "sha2",18331834
"thiserror 2.0.20",18341835
"tokio",18351836
"tracing",@@ -20,6 +20,7 @@ ruma = { version = "0.16.0", default-features = false }
2020
serde = { version = "1.0.219", features = ["derive", "rc"], optional = true }2121
thiserror = "2.0.16"2222
mime = "0.3.17"23
+sha2 = "0.10.9"2324
tokio = { version = "1.47.1", features = ["rt", "sync", "time"] }2425
tracing = "0.1.41"2526
url = { version = "2.5.7", optional = true }@@ -33,20 +33,27 @@ and gets logged. a watcher or a room actor that panics takes the engine down,
3333
emitting `Failed` then `Stopped`3434
3535
sync restarts itself with backoff after an error or an unexpected stop, and36
-`ConnectionChanged` shows `Offline` meanwhile. every return to `Online` re-arms36
+`ConnectionChanged` shows `Offline` meanwhile. a run that drops again inside37
+thirty seconds counts as a failure too, or a server that answers `/versions`38
+but refuses sync would bounce us between online and offline forever. every return to `Online` re-arms3739
the send queues, since the sdk parks a room's queue on any send failure and3840
never unparks it. a recoverable failure while online gets the room another go3941
after a few seconds4042
4143
logout and a rejected token both stop the engine. `Shutdown` answers once42
-`Stopped` has gone out44
+`Stopped` has gone out, every one of them if several were queued45
+46
+`Engine::run` hands back a boxed future. the real one nests deep enough that47
+every crate spawning it would trip the compiler's query depth4348
4449
## rooms4550
4651
one actor per open room. the engine holds a slot per room, `Active` with a4752
sender or `Closing` with a fifo of opens and closes that arrived while it wound4853
down. those replay in order once the actor exits. a room you leave, or get49
-kicked or banned from, closes itself54
+kicked or banned from, closes itself. it tells the engine first, and anything55
+already queued to it comes back to be answered or replayed, so an open racing a56
+leave still gets a fresh actor5057
5158
opening sends `TimelineReset` on the stream before it answers. opening an5259
already open room sends a fresh reset, so a second frontend gets a snapshot@@ -63,8 +70,9 @@ pagination runs one request at a time per timeline and emits `HistoryChanged`
6370
around each. the sdk remembers where history starts, we don't, because a gappy6471
sync can unload it again6572
66
-every task inside an actor is supervised, and a panic in any of them panics the67
-actor73
+the actor's watchers are supervised, and a panic in any of them panics the74
+actor. its command work isn't, a panicking redaction just answers `Interrupted`75
+and gets logged6876
6977
## timelines7078
@@ -82,6 +90,9 @@ changes and the common room state changes get their own variants, and anything
8290
else is `OtherState` or `Other` with its type. nothing is flattened into english8391
in a body field8492
93
+thread receipts for a thread that isn't open go straight to the room, so94
+marking a thread read works the same whether its room is on screen or not95
+8596
replies carry the replied-to event once it's loaded. the watcher asks the sdk8697
for any it hasn't got, once per event, since the sdk won't on its own8798
@@ -106,17 +117,26 @@ sdk's own patcher is private
106117
107118
media is a read. `ClientHandle::media` takes a `MediaId` a retained timeline108119
handed you, `mxc_media` takes any plain mxc uri. both share one bounded109
-download path with the byte cap the sdk's own download lacks, and110
-concurrent asks for the same bytes share one flight120
+download path with the byte cap the sdk's own download lacks, an hour's total121
+deadline instead of the sdk client's thirty seconds, and a minute's patience122
+for a stalled stream. concurrent asks for the same bytes share one flight.123
+a thumbnail the server won't make falls back to the file itself.124
+`MediaId` parses from a string, since a web client has to round trip it111125
112126
the cache holds plaintext, keyed like the sdk keys it, so the sdk and macltok113
-can read each other's entries. that's how your own encrypted uploads come back127
+can read each other's entries. that's how your own encrypted uploads come back.128
+the sdk keys plain and encrypted sources alike, so a plain event pointing at129
+someone's encrypted file can leave ciphertext in the cache. an encrypted read130
+checks the cached bytes against the file's hash and decrypts them if they match114131
115132
media ids are registered as timelines render and forgotten when their timeline116133
closes, least recently seen first once the cap is hit117134
118
-uploads are commands that answer once the send queue has them. progress and119
-failure arrive as events from one watcher on the sdk's global send queue135
+uploads are commands that answer once the send queue has them. progress only136
+goes out on a room's own send queue channel, so each room that uploads gets a137
+forwarder, subscribed before the upload is queued and restarted for uploads the138
+sdk resumes from last time. failures do reach the client-wide channel, so one139
+watcher there reports them120140
121141
## sessions and secrets122142
@@ -156,8 +176,12 @@ touching fifty rooms is one event
156176
157177
## verification158178
159
-`Confirm`, `Reject` and `Cancel`. reject tells the other side the emoji didn't160
-match. the watcher and the action paths claim the terminal event, so a racing179
+`Accept`, `Confirm`, `Reject` and `Cancel`. a request another device sends180
+shows up as `Incoming` and waits for `Accept`, or `Cancel` to turn it down. one181
+is only taken up while nothing else is in flight. reject tells the other side182
+the emoji didn't match. the watchers subscribe before they look at the current183
+state, since the sdk only reports what changes after you ask. the watcher and184
+the action paths claim the terminal event, so a racing161185
cancel can't turn `Done` into `Cancelled`162186
163187
## public model@@ -249,7 +249,7 @@ Missing fields, all cheap SDK reads. Topic, canonical alias, joined and invited
249249
member counts, heroes, tombstone and successor, inviter for invites, a latest250250
event preview, and space parents and children (Malice gap 3).251251
252
-### A6. No incoming verification. open252
+### A6. No incoming verification. done, ported from malice's vendored copy253253
254254
Only outgoing `StartVerification` exists. A request started from another255255
device, which is the normal "verify this new login" flow, is never observed.@@ -393,3 +393,30 @@ avatar and encryption changes, which cover what Element shows inline, plus
393393
394394
- `cargo fmt -- --check` clean.395395
- `cargo test --all-targets` 49 passed, none failed.396
+397
+## follow-up review, 2026-09-25398
+399
+three parallel reviews of the reworked engine, each finding checked against the400
+code before fixing. all fixed in the same change401
+402
+- close answered `Interrupted` when the actor had already gone, and anything403
+ sent to an actor stopping after a leave was dropped. actors now say they're404
+ leaving and hand back what they couldn't serve405
+- closing a timeline released its media before stopping the watcher, so late406
+ diffs re-registered it407
+- thread receipts failed while the room was open and the thread wasn't408
+- verification watchers subscribed after the state they needed, so a peer409
+ starting sas left the flow waiting forever410
+- a plain event could leave an encrypted file's ciphertext in the cache411
+- downloads died at thirty seconds on the sdk client's timeout412
+- `create_direct` made a second dm when asked twice before sync caught up413
+- offline mode plus a server refusing sync bounced forever with no backoff414
+- upload progress missed resumed uploads, failures could be lost to lag415
+- `SendState::Failed { retriable }` meant the opposite of what a ui wants,416
+ now `will_retry`, and `Queued` folded into `Sending`417
+- room previews showed the `* edit` fallback, thumbnails claimed the original's418
+ mimetype and ignored the asked size, v12 creators' power was dropped,419
+ cleared names came back as empty strings420
+- the example logged in afresh every run and broke its own store421
+- adding the fixes tipped `Engine::run` past the compiler's query depth in every422
+ consumer, so it now returns a boxed future@@ -5,19 +5,28 @@ use macltok::{
55
66
#[tokio::main]77
async fn main() -> Result<(), Box<dyn std::error::Error>> {8
- let session = Session::login_password(9
- PasswordLogin::new(10
- required("MATRIX_HOMESERVER")?,11
- required("MATRIX_USERNAME")?,12
- required("MATRIX_PASSWORD")?,13
- )14
- .with_device_display_name("macltok headless example"),15
- StoreConfig::new(16
- required("MATRIX_STORE_PATH")?,17
- required("MATRIX_STORE_PASSPHRASE")?,18
- ),19
- )20
- .await?;8
+ let store_path = std::path::PathBuf::from(required("MATRIX_STORE_PATH")?);9
+ let passphrase = required("MATRIX_STORE_PASSPHRASE")?;10
+ // a real client keeps this somewhere safer, it holds the access token11
+ let record_path = store_path.join("session.json");12
+ let store = StoreConfig::new(&store_path, &passphrase);13
+ let session = match std::fs::read(&record_path) {14
+ Ok(saved) => Session::restore(serde_json::from_slice(&saved)?, store).await?,15
+ Err(_) => {16
+ let session = Session::login_password(17
+ PasswordLogin::new(18
+ required("MATRIX_HOMESERVER")?,19
+ required("MATRIX_USERNAME")?,20
+ required("MATRIX_PASSWORD")?,21
+ )22
+ .with_device_display_name("macltok headless example"),23
+ store,24
+ )25
+ .await?;26
+ std::fs::write(&record_path, serde_json::to_vec(&session.record())?)?;27
+ session28
+ }29
+ };2130
let (engine, handle, mut events) = session.build(EngineConfig::default()).await?;2231
let running = tokio::spawn(engine.run());2332
let mut opened: Option<TimelineId> = None;@@ -65,6 +74,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
6574
}6675
print_messages(&items);6776
}77
+ Event::SessionTokensChanged => {78
+ std::fs::write(&record_path, serde_json::to_vec(&handle.session_record()?)?)?;79
+ }80
+ Event::LoggedOut | Event::SessionAccessRejected { soft_logout: false } => {81
+ std::fs::remove_dir_all(&store_path)?;82
+ }6883
Event::Stopped => break,6984
_ => {}7085
}@@ -68,6 +68,7 @@ impl VerificationController {
6868
action: VerificationAction,6969
) -> Result<(), Error> {7070
match action {71
+ VerificationAction::Accept => self.accept().await,7172
VerificationAction::Confirm => {7273
// Retain a handle across the operation: the watcher clears7374
// `active` when it publishes a terminal event, but this clone@@ -248,6 +249,9 @@ impl VerificationController {
248249
})?249250
}250251
};252
+ // subscribed before anything can wait on the event channel, or a quick peer's253
+ // answer lands while we're blocked and nobody ever hears it254
+ let changes = request.changes();251255
self.active.lock().await.request = Some(request.clone());252256
sender253257
.send(Event::VerificationTargetChanged(target))@@ -263,11 +267,80 @@ impl VerificationController {
263267
})?;264268
let active = self.active.clone();265269
self.task = Some(tokio::spawn(async move {266
- follow_request(request, active, sender).await;270
+ follow_request(request, changes, active, sender).await;267271
}));268272
Ok(())269273
}270274
275
+ /// another device asked us. only taken when nothing else is in flight, and not accepted276
+ /// until the user says so with `VerificationAction::Accept`277
+ pub(crate) async fn adopt_incoming(278
+ &mut self,279
+ sender: EventSender,280
+ request: VerificationRequest,281
+ ) {282
+ if request.we_started() || request.is_done() || request.cancel_info().is_some() {283
+ return;284
+ }285
+ if self.has_active_verification().await {286
+ tracing::debug!("ignoring a verification request while another is in flight");287
+ return;288
+ }289
+ self.stop_task().await;290
+ let changes = request.changes();291
+ self.active.lock().await.request = Some(request.clone());292
+ let incoming = UiState::Incoming {293
+ user_id: request.other_user_id().to_owned(),294
+ device_id: match request.state() {295
+ VerificationRequestState::Requested {296
+ other_device_data, ..297
+ }298
+ | VerificationRequestState::Ready {299
+ other_device_data, ..300
+ } => Some(other_device_data.device_id().to_owned()),301
+ _ => None,302
+ },303
+ };304
+ let target = VerificationTarget::User(request.other_user_id().to_owned());305
+ for event in [306
+ Event::VerificationTargetChanged(target),307
+ Event::VerificationChanged(incoming),308
+ ] {309
+ if sender.send(event).await.is_err() {310
+ return;311
+ }312
+ }313
+ let active = self.active.clone();314
+ self.task = Some(tokio::spawn(async move {315
+ follow_request(request, changes, active, sender).await;316
+ }));317
+ }318
+319
+ async fn accept(&self) -> Result<(), Error> {320
+ let inactive = |message: &str| Error::new(ErrorKind::VerificationInactive, message);321
+ let (request, sas) = {322
+ let active = self.active.lock().await;323
+ (active.request.clone(), active.sas.is_some())324
+ };325
+ let request = request.ok_or_else(|| inactive("no verification request is pending"))?;326
+ if request.we_started() {327
+ return Err(inactive("nobody asked us, we asked them"));328
+ }329
+ if request.is_done() {330
+ return Err(inactive("verification is already complete"));331
+ }332
+ if let Some(info) = request.cancel_info() {333
+ return Err(inactive(info.reason()));334
+ }335
+ if sas {336
+ return Err(inactive("verification has already started"));337
+ }338
+ request339
+ .accept_with_methods(vec![VerificationMethod::SasV1])340
+ .await341
+ .map_err(|source| Error::from_sdk("accepting verification request failed", source))342
+ }343
+271344
async fn confirm(&self) -> Result<(), Error> {272345
let sas = self.active.lock().await.sas.clone().ok_or_else(|| {273346
Error::new(@@ -560,33 +633,20 @@ async fn request_identity_verification(
560633
.map_err(|source| Error::from_verification_request("starting verification failed", source))561634
}562635
636
+/// `changes` is subscribed by the caller before the request could move, and the current state637
+/// is handled first, since the stream only reports what happens after it was taken563638
async fn follow_request(564639
request: VerificationRequest,640
+ changes: impl futures_util::Stream<Item = VerificationRequestState> + Unpin,565641
active: Arc<Mutex<VerificationSession>>,566642
sender: EventSender,567643
) {568
- let mut changes = request.changes();569
- while let Some(state) = changes.next().await {644
+ let mut states = futures_util::stream::iter([request.state()]).chain(changes);645
+ while let Some(state) = states.next().await {570646
match state {571
- VerificationRequestState::Created { .. } => {}572
- VerificationRequestState::Requested { .. } => {573
- if let Err(error) = request574
- .accept_with_methods(vec![VerificationMethod::SasV1])575
- .await576
- {577
- tracing::warn!(error = %error, "accepting verification request failed");578
- // Claim the session so this terminal outcome is emitted exactly579
- // once; a lost claim means an action path already owns it.580
- report_watcher_terminal(581
- &active,582
- &sender,583
- UiState::Failed(VerificationFailure::RequestAcceptance(error.to_string())),584
- "request acceptance failure",585
- )586
- .await;587
- return;588
- }589
- }647
+ // created is ours waiting on them, requested is theirs waiting on `Accept`648
+ VerificationRequestState::Created { .. }649
+ | VerificationRequestState::Requested { .. } => {}590650
VerificationRequestState::Ready { .. } => match request.start_sas().await {591651
Ok(Some(sas)) => {592652
follow_sas(sas, active.clone(), sender.clone()).await;@@ -664,9 +724,12 @@ async fn follow_sas(
664724
active: Arc<Mutex<VerificationSession>>,665725
sender: EventSender,666726
) {727
+ // the sdk may already have moved the flow along before we got here, a peer starting728
+ // sas is the usual case, so take the stream first and the current state after729
+ let changes = sas.changes();667730
active.lock().await.sas = Some(sas.clone());668
- let mut changes = sas.changes();669
- while let Some(state) = changes.next().await {731
+ let mut states = futures_util::stream::iter([sas.state()]).chain(changes);732
+ while let Some(state) = states.next().await {670733
let update = match state {671734
SasState::Created { .. } | SasState::Accepted { .. } => continue,672735
SasState::Started { .. } => {@@ -1,6 +1,7 @@
11
use std::{future::Future, panic::AssertUnwindSafe};22
33
use futures_util::FutureExt;4
+use matrix_sdk::encryption::verification::VerificationRequest;45
use matrix_sdk::{56
Client,67
notification_settings::{IsEncrypted, IsOneToOne, RoomNotificationMode as SdkMode},@@ -48,11 +49,20 @@ pub(super) async fn run_encryption(
4849
client: Client,4950
events: EventSender,5051
mut jobs: mpsc::Receiver<Job<EncryptionCommand>>,52
+ mut incoming: mpsc::UnboundedReceiver<VerificationRequest>,5153
) {5254
let mut verification = VerificationController::default();53
- while let Some(Job { command, reply }) = jobs.recv().await {54
- let work = encryption(&client, &events, &mut verification, command);55
- reply.done(guarded("encryption command", work).await);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
+ }5666
}5767
}5868
@@ -4,6 +4,7 @@ use std::{
44
io::Read as _,55
panic::AssertUnwindSafe,66
sync::Arc,7
+ time::Duration,78
};89
910
use futures_util::{@@ -36,8 +37,10 @@ use crate::{
3637
upload::prepare_upload,3738
};3839
39
-/// the uploader's thumbnail is used when there is one, this is only what we ask the server for40
-const TIMELINE_THUMBNAIL: u32 = 800;40
+/// a stalled download gives up after this long without a byte41
+const DOWNLOAD_IDLE: Duration = Duration::from_secs(60);42
+/// the sdk client gives up on any request after thirty seconds, far too soon for a video43
+const DOWNLOAD_TOTAL: Duration = Duration::from_secs(60 * 60);4144
4245
type FlightFuture = BoxFuture<'static, Result<Media, Error>>;4346
type Flight = Shared<FlightFuture>;@@ -99,7 +102,7 @@ impl MediaService {
99102
media.full.mimetype,100103
media.full.declared_size,101104
),102
- MediaSize::Thumbnail { .. } => match media.thumbnail {105
+ MediaSize::Thumbnail { width, height } => match media.thumbnail {103106
Some(thumbnail) => (104107
thumbnail.source,105108
MediaFormat::File,@@ -110,12 +113,13 @@ impl MediaService {
110113
None if matches!(media.kind, MediaKind::Image | MediaKind::Video)111114
&& matches!(media.full.source, MediaSource::Plain(_)) =>112115
{113
- (114
- media.full.source,115
- thumbnail_format(TIMELINE_THUMBNAIL, TIMELINE_THUMBNAIL),116
- media.full.mimetype,117
- None,118
- )116
+ // the server picks the thumbnail's type, so the original's would be a lie117
+ let format = if is_local(&media.full.source) {118
+ MediaFormat::File119
+ } else {120
+ thumbnail_format(width, height)121
+ };122
+ (media.full.source, format, None, None)119123
}120124
None => {121125
return Err(Error::new(@@ -128,7 +132,7 @@ impl MediaService {
128132
if declared_size.is_some_and(|size| size > self.max_bytes as u64) {129133
return Err(too_large("media exceeds the configured byte limit"));130134
}131
- self.fetch(MediaRequestParameters { source, format }, mimetype)135
+ self.fetch_or_file(MediaRequestParameters { source, format }, mimetype)132136
.await133137
}134138
@@ -140,7 +144,7 @@ impl MediaService {
140144
MediaSize::Full => MediaFormat::File,141145
MediaSize::Thumbnail { width, height } => thumbnail_format(width, height),142146
};143
- self.fetch(147
+ self.fetch_or_file(144148
MediaRequestParameters {145149
source: MediaSource::Plain(uri.to_owned()),146150
format,@@ -150,6 +154,28 @@ impl MediaService {
150154
.await151155
}152156
157
+ /// plenty of servers won't thumbnail everything, and the file itself is the next best158
+ /// thing. the byte cap still holds either way159
+ async fn fetch_or_file(160
+ &self,161
+ request: MediaRequestParameters,162
+ mimetype: Option<String>,163
+ ) -> Result<Media, Error> {164
+ if !matches!(request.format, MediaFormat::Thumbnail(_)) {165
+ return self.fetch(request, mimetype).await;166
+ }167
+ match self.fetch(request.clone(), mimetype.clone()).await {168
+ Err(error) if error.kind == ErrorKind::MediaUnavailable => {169
+ let file = MediaRequestParameters {170
+ source: request.source,171
+ format: MediaFormat::File,172
+ };173
+ self.fetch(file, mimetype).await174
+ }175
+ result => result,176
+ }177
+ }178
+153179
async fn fetch(154180
&self,155181
request: MediaRequestParameters,@@ -191,10 +217,20 @@ fn thumbnail_format(width: u32, height: u32) -> MediaFormat {
191217
MediaFormat::Thumbnail(MediaThumbnailSettings::new(width.into(), height.into()))192218
}193219
220
+fn is_local(source: &MediaSource) -> bool {221
+ let uri = match source {222
+ MediaSource::Plain(uri) => uri,223
+ MediaSource::Encrypted(file) => &file.url,224
+ };225
+ uri.server_name()226
+ .is_ok_and(|server| server.as_str() == "send-queue.localhost")227
+}228
+229
+/// plain and encrypted sources share a cache key in the sdk, but must never share a flight194230
fn flight_key(request: &MediaRequestParameters) -> String {195231
let uri = match &request.source {196
- MediaSource::Plain(uri) => uri.as_str(),197
- MediaSource::Encrypted(file) => file.url.as_str(),232
+ MediaSource::Plain(uri) => format!("plain|{uri}"),233
+ MediaSource::Encrypted(file) => format!("encrypted|{}", file.url),198234
};199235
match &request.format {200236
MediaFormat::File => uri.to_owned(),@@ -222,21 +258,42 @@ async fn fetch(
222258
}223259
return Err(too_large("cached media exceeds the configured byte limit"));224260
}261
+ // plain and encrypted share a cache key, so a plain fetch of an encrypted file's uri262
+ // leaves ciphertext here. its hash gives it away263
+ if is_ciphertext(&cached, &request.source) {264
+ let bytes = maybe_decrypt(cached, &request, max_bytes)?;265
+ cache(&client, &request, &bytes).await;266
+ return Ok(media(bytes, mimetype));267
+ }225268
return Ok(media(cached, mimetype));226269
}227270
let (bytes, served_mimetype) = download_bounded(&client, &request, max_bytes).await?;228271
// the sdk caches plaintext under the encrypted key, so we do too or nobody can read anything229272
let bytes = maybe_decrypt(bytes, &request, max_bytes)?;273
+ cache(&client, &request, &bytes).await;274
+ Ok(media(bytes, mimetype.or(served_mimetype)))275
+}276
+277
+async fn cache(client: &Client, request: &MediaRequestParameters, bytes: &[u8]) {230278
if let Ok(store) = client.media_store().lock().await {231279
let _ = store232280
.add_media_content(233
- &request,234
- bytes.clone(),281
+ request,282
+ bytes.to_vec(),235283
matrix_sdk::media::store::IgnoreMediaRetentionPolicy::No,236284
)237285
.await;238286
}239
- Ok(media(bytes, mimetype.or(served_mimetype)))287
+}288
+289
+fn is_ciphertext(bytes: &[u8], source: &MediaSource) -> bool {290
+ use sha2::{Digest, Sha256};291
+ let MediaSource::Encrypted(file) = source else {292
+ return false;293
+ };294
+ file.hashes295
+ .get(&ruma::events::room::EncryptedFileHashAlgorithm::Sha256)296
+ .is_some_and(|hash| hash.as_bytes() == Sha256::digest(bytes).as_slice())240297
}241298
242299
fn media(bytes: Vec<u8>, mimetype: Option<String>) -> Media {@@ -325,20 +382,13 @@ fn maybe_decrypt(
325382
}326383
}327384
328
-/// the sdk's own download has no size cap and no timeout, so we do it by hand385
+/// the sdk's own download has no size cap, so we do it by hand329386
async fn download_bounded(330387
client: &Client,331388
request: &MediaRequestParameters,332389
max_bytes: usize,333390
) -> Result<(Vec<u8>, Option<String>), Error> {334
- let uri = match &request.source {335
- MediaSource::Plain(uri) => uri,336
- MediaSource::Encrypted(file) => &file.url,337
- };338
- if uri339
- .server_name()340
- .is_ok_and(|server| server.as_str() == "send-queue.localhost")341
- {391
+ if is_local(&request.source) {342392
return Err(Error::new(343393
ErrorKind::MediaUnavailable,344394
"local media is not in the media cache",@@ -350,7 +400,10 @@ async fn download_bounded(
350400
let response = loop {351401
let http_request = media_http_request(client, request).await?;352402
let (parts, _) = http_request.into_parts();353
- let mut builder = client.http_client().get(parts.uri.to_string());403
+ let mut builder = client404
+ .http_client()405
+ .get(parts.uri.to_string())406
+ .timeout(DOWNLOAD_TOTAL);354407
for (name, value) in &parts.headers {355408
builder = builder.header(name.as_str(), value.as_bytes());356409
}@@ -393,7 +446,11 @@ async fn download_bounded(
393446
.map(str::to_owned);394447
let mut stream = response.bytes_stream();395448
let mut bytes = Vec::new();396
- while let Some(chunk) = stream.next().await {449
+ loop {450
+ let chunk = tokio::time::timeout(DOWNLOAD_IDLE, stream.next())451
+ .await452
+ .map_err(|_| Error::new(ErrorKind::Network, "media download stalled"))?;453
+ let Some(chunk) = chunk else { break };397454
let chunk = chunk.map_err(network_failed)?;398455
if chunk.len() > max_bytes.saturating_sub(bytes.len()) {399456
return Err(too_large(@@ -501,11 +558,15 @@ pub(crate) async fn queue_upload(
501558
.await502559
.map(drop)503560
.map_err(|source| {504
- Error::with_source(505
- ErrorKind::MediaUnavailable,506
- "media upload could not be queued",507
- source,508
- )561
+ use matrix_sdk::send_queue::RoomSendQueueError;562
+ let kind = match &source {563
+ RoomSendQueueError::RoomNotJoined | RoomSendQueueError::RoomDisappeared => {564
+ ErrorKind::RoomUnknown565
+ }566
+ RoomSendQueueError::StorageError(_) => ErrorKind::Store,567
+ _ => ErrorKind::MediaUnavailable,568
+ };569
+ Error::with_source(kind, "media upload could not be queued", source)509570
})510571
}511572
@@ -14,7 +14,7 @@ use std::{
1414
use futures_util::future::BoxFuture;1515
use matrix_sdk::{Client, SessionChange};1616
use matrix_sdk_ui::sync_service::SyncService;17
-use ruma::{OwnedRoomId, RoomId};17
+use ruma::OwnedRoomId;1818
use thiserror::Error as ThisError;1919
use tokio::{2020
sync::{Notify, broadcast, mpsc, watch},@@ -26,7 +26,7 @@ pub(crate) use media::MediaService;
2626
use self::{2727
commands::Job,2828
mutations::{Mutation, MutationJob},29
- room::{ActorRequest, Exited, RoomActor},29
+ room::{ActorNote, ActorRequest, RoomActor},3030
timeline::{SessionContext, Supervisor},3131
};3232
use crate::{@@ -208,6 +208,10 @@ struct Queues {
208208
session_changes: broadcast::Receiver<SessionChange>,209209
}210210
211
+/// requests other devices send us, handed from the sdk's event handler to the encryption worker212
+type IncomingVerifications =213
+ mpsc::UnboundedSender<matrix_sdk::encryption::verification::VerificationRequest>;214
+211215
pub struct Engine {212216
client: Client,213217
sync: Arc<SyncService>,@@ -217,14 +221,18 @@ pub struct Engine {
217221
tasks: TaskRegistry,218222
rooms: HashMap<OwnedRoomId, RoomSlot>,219223
next_generation: u64,220
- exits: mpsc::UnboundedReceiver<Exited>,221
- exit_sender: mpsc::UnboundedSender<Exited>,224
+ notes: mpsc::UnboundedReceiver<ActorNote>,225
+ note_sender: mpsc::UnboundedSender<ActorNote>,226
+ /// the rooms sliding sync should follow, applied off the run loop since it touches the store227
+ subscriptions: watch::Sender<Vec<OwnedRoomId>>,222228
mutations: mpsc::Sender<MutationJob>,223229
notifications: mpsc::Sender<Job<NotificationCommand>>,224230
encryption: mpsc::Sender<Job<EncryptionCommand>>,225231
queues: Option<Queues>,226232
/// one progress forwarder per room that has uploaded, and whether it's subscribed yet227233
uploads: HashMap<OwnedRoomId, (AbortHandle, watch::Receiver<bool>)>,234
+ /// the client can outlive us, so the handler has to come off it when we stop235
+ verification_handler: Option<matrix_sdk::event_handler::EventHandlerHandle>,228236
stop: Arc<Notify>,229237
}230238
@@ -264,7 +272,7 @@ impl Engine {
264272
config.event_capacity,265273
);266274
client.send_queue().enable_upload_progress(true);267
- let (exit_sender, exits) = mpsc::unbounded_channel();275
+ let (note_sender, notes) = mpsc::unbounded_channel();268276
let (mutations, mutation_queue) = mpsc::channel(config.command_capacity);269277
let (notifications, notification_queue) = mpsc::channel(config.command_capacity);270278
let (encryption, encryption_queue) = mpsc::channel(config.command_capacity);@@ -280,8 +288,9 @@ impl Engine {
280288
},281289
rooms: HashMap::new(),282290
next_generation: 0,283
- exits,284
- exit_sender,291
+ notes,292
+ note_sender,293
+ subscriptions: watch::Sender::new(Vec::new()),285294
mutations,286295
notifications,287296
encryption,@@ -292,12 +301,19 @@ impl Engine {
292301
session_changes,293302
}),294303
uploads: HashMap::new(),304
+ verification_handler: None,295305
stop: Arc::new(Notify::new()),296306
};297307
Ok((engine, handle, events))298308
}299309
300
- pub async fn run(mut self) -> Result<(), EngineError> {310
+ /// boxed because the engine's future is deep enough to blow the compiler's query depth311
+ /// in every crate that spawns it, which isn't something a client should have to fix312
+ pub fn run(self) -> BoxFuture<'static, Result<(), EngineError>> {313
+ Box::pin(self.run_inner())314
+ }315
+316
+ async fn run_inner(mut self) -> Result<(), EngineError> {301317
let events = self.port.event_sender();302318
let user_id = self303319
.client@@ -317,6 +333,7 @@ impl Engine {
317333
}318334
}319335
self.start_tasks();336
+ self.resume_upload_forwarders().await;320337
self.sync.start().await;321338
322339
let mut failure = None;@@ -329,13 +346,6 @@ impl Engine {
329346
break;330347
}331348
() = self.stop.notified() => break,332
- request = self.port.recv() => {333
- let Some(request) = request else { break };334
- if let Some(reply) = self.dispatch(request).await {335
- shutdown_reply = Some(reply);336
- break;337
- }338
- }339349
Some((context, result)) = self.tasks.join_next(), if !self.tasks.tasks.is_empty() => {340350
let Err(error) = result else { continue };341351
if !error.is_panic() {@@ -348,7 +358,14 @@ impl Engine {
348358
failure = Some(EngineError::TaskPanicked { task: context.label() });349359
break;350360
}351
- Some(exited) = self.exits.recv() => self.room_exited(exited).await,361
+ Some(note) = self.notes.recv() => self.actor_note(note).await,362
+ request = self.port.recv() => {363
+ let Some(request) = request else { break };364
+ if let Some(reply) = self.dispatch(request).await {365
+ shutdown_reply = Some(reply);366
+ break;367
+ }368
+ }352369
}353370
}354371
@@ -360,14 +377,37 @@ impl Engine {
360377
if let Some(reply) = shutdown_reply {361378
reply.done(Ok(()));362379
}380
+ // everything else still queued answers `Interrupted` as it drops, but a shutdown did happen381
+ while let Some(Request { command, reply }) = self.port.try_recv() {382
+ if matches!(command, Command::Lifecycle(LifecycleCommand::Shutdown)) {383
+ reply.done(Ok(()));384
+ }385
+ }363386
failure.map_or(Ok(()), Err)364387
}365388
366389
fn start_tasks(&mut self) {367390
let queues = self.queues.take().expect("engine tasks start once");391
+ let (incoming, incoming_verifications) = mpsc::unbounded_channel();392
+ self.verification_handler = Some(self.verification_handler(incoming));368393
let client = &self.client;369394
let events = || self.port.event_sender();370
- let watchers: [(&'static str, BoxFuture<'static, ()>); 9] = [395
+ let watchers: [(&'static str, BoxFuture<'static, ()>); 11] = [396
+ (397
+ "upload failure watcher",398
+ Box::pin(watchers::upload_failures(399
+ client.clone(),400
+ client.send_queue().subscribe(),401
+ events(),402
+ )),403
+ ),404
+ (405
+ "room subscription watcher",406
+ Box::pin(watchers::room_subscriptions(407
+ self.sync.clone(),408
+ self.subscriptions.subscribe(),409
+ )),410
+ ),371411
(372412
"session change watcher",373413
Box::pin(watchers::session(@@ -427,6 +467,7 @@ impl Engine {
427467
client.clone(),428468
events(),429469
queues.encryption,470
+ incoming_verifications,430471
)),431472
),432473
];@@ -435,6 +476,30 @@ impl Engine {
435476
}436477
}437478
479
+ fn verification_handler(480
+ &self,481
+ incoming: IncomingVerifications,482
+ ) -> matrix_sdk::event_handler::EventHandlerHandle {483
+ use matrix_sdk::ruma::events::key::verification::request::ToDeviceKeyVerificationRequestEvent;484
+ self.client.add_event_handler(485
+ move |event: ToDeviceKeyVerificationRequestEvent, client: Client| {486
+ let incoming = incoming.clone();487
+ async move {488
+ let request = client489
+ .encryption()490
+ .get_verification_request(491
+ &event.sender,492
+ event.content.transaction_id.as_str(),493
+ )494
+ .await;495
+ if let Some(request) = request {496
+ let _ = incoming.send(request);497
+ }498
+ }499
+ },500
+ )501
+ }502
+438503
/// hands back the shutdown request's responder, which is answered once we've stopped439504
async fn dispatch(&mut self, Request { command, reply }: Request) -> Option<Responder> {440505
let client = self.client.clone();@@ -552,21 +617,7 @@ impl Engine {
552617
let Some(room) = self.client.get_room(upload.timeline_id.room_id()) else {553618
return reply.done(Err(Error::room_unknown()));554619
};555
- let room_id = room.room_id().to_owned();556
- self.uploads557
- .retain(|_, (forwarder, _)| !forwarder.is_finished());558
- let mut ready = match self.uploads.get(&room_id) {559
- Some((_, ready)) => ready.clone(),560
- None => {561
- let (subscribed, ready) = watch::channel(false);562
- let forwarder = self.tasks.spawn(563
- TaskContext::Command("upload watcher"),564
- watchers::room_uploads(room.clone(), self.port.event_sender(), subscribed),565
- );566
- self.uploads.insert(room_id, (forwarder, ready.clone()));567
- ready568
- }569
- };620
+ let mut ready = self.upload_forwarder(&room);570621
let max_bytes = self.config.max_media_bytes;571622
// queueing before the forwarder listens would lose the first progress ticks572623
self.spawn_command("upload", reply, async move {@@ -578,6 +629,34 @@ impl Engine {
578629
});579630
}580631
632
+ fn upload_forwarder(&mut self, room: &matrix_sdk::Room) -> watch::Receiver<bool> {633
+ self.uploads634
+ .retain(|_, (forwarder, _)| !forwarder.is_finished());635
+ if let Some((_, ready)) = self.uploads.get(room.room_id()) {636
+ return ready.clone();637
+ }638
+ let (subscribed, ready) = watch::channel(false);639
+ let forwarder = self.tasks.spawn(640
+ TaskContext::Command("upload watcher"),641
+ watchers::room_uploads(room.clone(), self.port.event_sender(), subscribed),642
+ );643
+ self.uploads644
+ .insert(room.room_id().to_owned(), (forwarder, ready.clone()));645
+ ready646
+ }647
+648
+ /// the sdk resumes uploads left over from last time, and they deserve progress too649
+ async fn resume_upload_forwarders(&mut self) {650
+ let Ok(echoes) = self.client.send_queue().local_echoes().await else {651
+ return;652
+ };653
+ for room_id in echoes.keys() {654
+ if let Some(room) = self.client.get_room(room_id) {655
+ self.upload_forwarder(&room);656
+ }657
+ }658
+ }659
+581660
fn route_timeline(&mut self, command: TimelineCommand, reply: Responder) {582661
let sender = match self.rooms.get(command.room_id()) {583662
Some(RoomSlot::Active { sender, .. }) => Some(sender.clone()),@@ -624,8 +703,9 @@ impl Engine {
624703
Some(RoomSlot::Active { generation, sender }) => (*generation, sender.clone()),625704
};626705
match sender.try_send(ActorRequest::Close(reply)) {627
- // a closed channel means the actor already left on its own, which is closed enough628
- Ok(()) | Err(mpsc::error::TrySendError::Closed(_)) => {}706
+ Ok(()) => {}707
+ // the actor already left on its own, which is closed enough708
+ Err(mpsc::error::TrySendError::Closed(request)) => request.into_reply().done(Ok(())),629709
Err(mpsc::error::TrySendError::Full(request)) => {630710
return request.into_reply().done(Err(Error::new(631711
ErrorKind::RoomBusy,@@ -640,7 +720,7 @@ impl Engine {
640720
pending: VecDeque::new(),641721
},642722
);643
- self.update_room_subscriptions().await;723
+ self.update_room_subscriptions();644724
}645725
646726
async fn start_actor(&mut self, room_id: OwnedRoomId, reply: Responder) {@@ -656,7 +736,7 @@ impl Engine {
656736
generation,657737
room,658738
requests,659
- exits: self.exit_sender.clone(),739
+ notes: self.note_sender.clone(),660740
context: SessionContext {661741
events: self.port.event_sender(),662742
media: self.media.clone(),@@ -676,16 +756,21 @@ impl Engine {
676756
},677757
actor.run(),678758
);679
- self.update_room_subscriptions().await;759
+ self.update_room_subscriptions();680760
}681761
682
- async fn room_exited(683
- &mut self,684
- Exited {685
- room_id,686
- generation,687
- }: Exited,688
- ) {762
+ async fn actor_note(&mut self, note: ActorNote) {763
+ let (room_id, generation) = match ¬e {764
+ ActorNote::Leaving {765
+ room_id,766
+ generation,767
+ }768
+ | ActorNote::Exited {769
+ room_id,770
+ generation,771
+ ..772
+ } => (room_id.clone(), *generation),773
+ };689774
if self690775
.rooms691776
.get(&room_id)@@ -693,31 +778,51 @@ impl Engine {
693778
{694779
return;695780
}696
- if let Some(RoomSlot::Closing { pending, .. }) = self.rooms.remove(&room_id) {697
- for deferred in pending {698
- match deferred {699
- Deferred::Open(reply) => self.open_room(room_id.clone(), reply).await,700
- Deferred::Close(reply) => self.close_room(room_id.clone(), reply).await,701
- }781
+ let ActorNote::Exited { leftovers, .. } = note else {782
+ if let Some(slot @ RoomSlot::Active { .. }) = self.rooms.get_mut(&room_id) {783
+ *slot = RoomSlot::Closing {784
+ generation,785
+ pending: VecDeque::new(),786
+ };787
+ self.update_room_subscriptions();788
+ }789
+ return;790
+ };791
+ let pending = match self.rooms.remove(&room_id) {792
+ Some(RoomSlot::Closing { pending, .. }) => pending,793
+ _ => VecDeque::new(),794
+ };795
+ // leftovers were sent before anything deferred, so they go first796
+ for request in leftovers {797
+ match request {798
+ ActorRequest::Open(reply) => self.open_room(room_id.clone(), reply).await,799
+ ActorRequest::Close(reply) => reply.done(Ok(())),800
+ ActorRequest::Execute(command, reply) => self.route_timeline(command, reply),702801
}703802
}704
- self.update_room_subscriptions().await;803
+ for deferred in pending {804
+ match deferred {805
+ Deferred::Open(reply) => self.open_room(room_id.clone(), reply).await,806
+ Deferred::Close(reply) => self.close_room(room_id.clone(), reply).await,807
+ }808
+ }809
+ self.update_room_subscriptions();705810
}706811
707
- async fn update_room_subscriptions(&self) {812
+ fn update_room_subscriptions(&self) {708813
let room_ids = self709814
.rooms710815
.iter()711816
.filter(|(_, slot)| matches!(slot, RoomSlot::Active { .. }))712
- .map(|(room_id, _)| room_id.as_ref())713
- .collect::<Vec<&RoomId>>();714
- self.sync715
- .room_list_service()716
- .subscribe_to_rooms(&room_ids)717
- .await;817
+ .map(|(room_id, _)| room_id.clone())818
+ .collect();819
+ self.subscriptions.send_replace(room_ids);718820
}719821
720822
async fn shutdown(&mut self) {823
+ if let Some(handler) = self.verification_handler.take() {824
+ self.client.remove_event_handler(handler);825
+ }721826
self.port.close();722827
self.tasks.shutdown().await;723828
self.rooms.clear();@@ -1,4 +1,7 @@
1
-use std::collections::{HashMap, HashSet, VecDeque};1
+use std::{2
+ collections::{HashMap, HashSet, VecDeque},3
+ sync::Arc,4
+};25
36
use matrix_sdk::{47
Client, Room,@@ -50,6 +53,7 @@ pub(super) async fn run_scheduler(
5053
mut jobs: mpsc::Receiver<MutationJob>,5154
queue_limit: usize,5255
) {56
+ let fresh_dms = FreshDms::default();5357
let mut waiting = HashMap::<Key, VecDeque<MutationJob>>::new();5458
let mut running = HashSet::<Key>::new();5559
let mut tasks = JoinSet::<Key>::new();@@ -69,7 +73,7 @@ pub(super) async fn run_scheduler(
6973
match next {7074
Some(job) => {7175
queued -= 1;72
- spawn(&mut tasks, &client, key, job);76
+ spawn(&mut tasks, &client, &fresh_dms, key, job);7377
}7478
None => {7579
running.remove(&key);@@ -81,12 +85,12 @@ pub(super) async fn run_scheduler(
8185
receiving = false;8286
continue;8387
};84
- let key = key(&job.command).unwrap_or_else(|| {88
+ let key = key(&job.command, client.user_id().map(ruma::UserId::server_name)).unwrap_or_else(|| {8589
independent += 1;8690
Key::Independent(independent)8791
});8892
if running.insert(key.clone()) {89
- spawn(&mut tasks, &client, key, job);93
+ spawn(&mut tasks, &client, &fresh_dms, key, job);9094
} else if queued < queue_limit {9195
waiting.entry(key).or_default().push_back(job);9296
queued += 1;@@ -101,7 +105,8 @@ pub(super) async fn run_scheduler(
101105
}102106
}103107
104
-fn key(mutation: &Mutation) -> Option<Key> {108
+/// `server` is ours, since a new alias lives there109
+fn key(mutation: &Mutation, server: Option<&ruma::ServerName>) -> Option<Key> {105110
let room_id = match mutation {106111
Mutation::GlobalAccountData { event_type, .. } => {107112
return Some(Key::AccountData(event_type.to_string()));@@ -115,6 +120,13 @@ fn key(mutation: &Mutation) -> Option<Key> {
115120
Mutation::Room(RoomCommand::CreateDirect(user_id)) => {116121
return Some(Key::Direct(user_id.clone()));117122
}123
+ Mutation::Room(RoomCommand::Create(options)) => {124
+ return match (&options.alias_localpart, options.invite.as_slice()) {125
+ (Some(localpart), _) => Some(Key::Alias(format!("#{localpart}:{}", server?))),126
+ (None, [user_id]) if options.direct => Some(Key::Direct(user_id.clone())),127
+ _ => None,128
+ };129
+ }118130
Mutation::Room(119131
RoomCommand::Leave(room_id)120132
| RoomCommand::Forget(room_id)@@ -133,16 +145,31 @@ fn key(mutation: &Mutation) -> Option<Key> {
133145
Some(Key::Room(room_id.clone()))134146
}135147
136
-fn spawn(tasks: &mut JoinSet<Key>, client: &Client, key: Key, job: MutationJob) {148
+/// dms made here that the next sync hasn't told the sdk about yet, since `get_dm_room`149
+/// only knows `m.direct` once it comes back round150
+type FreshDms = Arc<std::sync::Mutex<HashMap<OwnedUserId, OwnedRoomId>>>;151
+152
+fn spawn(153
+ tasks: &mut JoinSet<Key>,154
+ client: &Client,155
+ fresh_dms: &FreshDms,156
+ key: Key,157
+ job: MutationJob,158
+) {137159
let client = client.clone();160
+ let fresh_dms = fresh_dms.clone();138161
tasks.spawn(async move {139162
let Job { command, reply } = job;140
- reply.send(guarded("room command", execute(&client, command)).await);163
+ reply.send(guarded("room command", execute(&client, &fresh_dms, command)).await);141164
key142165
});143166
}144167
145
-async fn execute(client: &Client, mutation: Mutation) -> Result<CommandOutput, Error> {168
+async fn execute(169
+ client: &Client,170
+ fresh_dms: &FreshDms,171
+ mutation: Mutation,172
+) -> Result<CommandOutput, Error> {146173
let command = match mutation {147174
Mutation::GlobalAccountData {148175
event_type,@@ -246,14 +273,28 @@ async fn execute(client: &Client, mutation: Mutation) -> Result<CommandOutput, E
246273
.await247274
.map(joined)248275
.map_err(sdk("creating room failed")),249
- RoomCommand::CreateDirect(user_id) => match client.get_dm_room(&user_id) {250
- Some(existing) => Ok(joined(existing)),251
- None => client276
+ RoomCommand::CreateDirect(user_id) => {277
+ if let Some(existing) = client.get_dm_room(&user_id) {278
+ return Ok(joined(existing));279
+ }280
+ let fresh = fresh_dms281
+ .lock()282
+ .expect("nothing panics holding this")283
+ .get(&user_id)284
+ .cloned();285
+ if let Some(room_id) = fresh.filter(|room_id| client.get_room(room_id).is_some()) {286
+ return Ok(CommandOutput::Room(room_id));287
+ }288
+ let room = client252289
.create_dm(&user_id)253290
.await254
- .map(joined)255
- .map_err(sdk("creating direct room failed")),256
- },291
+ .map_err(sdk("creating direct room failed"))?;292
+ fresh_dms293
+ .lock()294
+ .expect("nothing panics holding this")295
+ .insert(user_id, room.room_id().to_owned());296
+ Ok(joined(room))297
+ }257298
_ => Err(Error::new(ErrorKind::Other, "room command was misrouted")),258299
}259300
}@@ -8,7 +8,7 @@ use tokio::{
88
task::{AbortHandle, JoinSet},99
};1010
11
-use super::timeline::{SessionContext, TimelineSession};11
+use super::timeline::{SessionContext, TaskGuard, TimelineSession};1212
use crate::{1313
Error, ErrorKind, Event, TimelineCommand, TimelineId, TypingState, transport::Responder,1414
};@@ -28,9 +28,18 @@ impl ActorRequest {
2828
}2929
}3030
31
-pub(super) struct Exited {32
- pub(super) room_id: OwnedRoomId,33
- pub(super) generation: u64,31
+pub(super) enum ActorNote {32
+ /// stopping on its own, after a leave or a kick. anything sent its way now comes back in `Exited`33
+ Leaving {34
+ room_id: OwnedRoomId,35
+ generation: u64,36
+ },37
+ Exited {38
+ room_id: OwnedRoomId,39
+ generation: u64,40
+ /// requests that arrived too late to be served, for the engine to answer or replay41
+ leftovers: Vec<ActorRequest>,42
+ },3443
}3544
3645
pub(super) struct RoomActor {@@ -38,7 +47,7 @@ pub(super) struct RoomActor {
3847
pub(super) generation: u64,3948
pub(super) room: Room,4049
pub(super) requests: mpsc::Receiver<ActorRequest>,41
- pub(super) exits: mpsc::UnboundedSender<Exited>,50
+ pub(super) notes: mpsc::UnboundedSender<ActorNote>,4251
pub(super) context: SessionContext,4352
pub(super) panics: mpsc::UnboundedReceiver<&'static str>,4453
pub(super) opening: Vec<Responder>,@@ -46,13 +55,17 @@ pub(super) struct RoomActor {
4655
4756
impl RoomActor {4857
pub(super) async fn run(mut self) {49
- let exits = self.exits.clone();50
- let exited = Exited {58
+ self.serve().await;59
+ self.requests.close();60
+ let mut leftovers = Vec::new();61
+ while let Ok(request) = self.requests.try_recv() {62
+ leftovers.push(request);63
+ }64
+ let _ = self.notes.send(ActorNote::Exited {5165
room_id: self.room_id.clone(),5266
generation: self.generation,53
- };54
- self.serve().await;55
- let _ = exits.send(exited);67
+ leftovers,68
+ });5669
}5770
5871
async fn serve(&mut self) {@@ -80,21 +93,28 @@ impl RoomActor {
8093
reply.done(Ok(()));8194
}8295
let (typing_guard, typing) = self.room.subscribe_to_typing_notifications();83
- let typing_watcher = self.context.supervisor.spawn(96
+ let typing_watcher = TaskGuard(self.context.supervisor.spawn(8497
"typing watcher",8598
watch_typing(self.room_id.clone(), typing, self.context.events.clone()),86
- );99
+ ));87100
let mut timelines = Timelines {88101
room: room_timeline,89102
threads: Threads::new(self.requests.max_capacity()),90103
work: JoinSet::new(),91104
};92105
let close = self.serve_open(&mut timelines).await;106
+ if close.is_none() {107
+ self.requests.close();108
+ let _ = self.notes.send(ActorNote::Leaving {109
+ room_id: self.room_id.clone(),110
+ generation: self.generation,111
+ });112
+ }93113
94114
// let redactions and receipts that already left finish rather than lie about them95115
while timelines.work.join_next().await.is_some() {}96116
drop(typing_guard);97
- typing_watcher.abort();117
+ drop(typing_watcher);98118
timelines.threads.close().await;99119
timelines.room.close().await;100120
if let Some(reply) = close {@@ -105,6 +125,10 @@ impl RoomActor {
105125
/// returns the close request's responder, if a close is why we stopped106126
async fn serve_open(&mut self, timelines: &mut Timelines) -> Option<Responder> {107127
let mut room_info = self.room.subscribe_info();128
+ // a leave that landed while the timeline was building won't come round again129
+ if matches!(self.room.state(), RoomState::Left | RoomState::Banned) {130
+ return None;131
+ }108132
loop {109133
tokio::select! {110134
Some(info) = room_info.next() => {@@ -208,6 +232,21 @@ impl Timelines {
208232
let Some(root) = root else {209233
return self.room.execute(command, reply, &mut self.work).await;210234
};235
+ if let TimelineCommand::MarkRead {236
+ timeline_id,237
+ event_id,238
+ } = &command239
+ && !self.threads.open.contains_key(&root)240
+ {241
+ let client = room.client();242
+ let (timeline_id, event_id) = (timeline_id.clone(), event_id.clone());243
+ self.work.spawn(async move {244
+ reply.done(245
+ super::commands::mark_read(&client, &timeline_id, event_id).await,246
+ );247
+ });248
+ return;249
+ }211250
match self.threads.route(root, command, reply) {212251
Routed::Open(session, TimelineCommand::Resync { .. }, reply) => {213252
reply.done(session.resync().await);@@ -55,6 +55,15 @@ impl Supervisor {
5555
}5656
}5757
58
+/// aborts its task when dropped, so an actor that dies mid-flight takes its helpers with it59
+pub(super) struct TaskGuard(pub(super) JoinHandle<()>);60
+61
+impl Drop for TaskGuard {62
+ fn drop(&mut self) {63
+ self.0.abort();64
+ }65
+}66
+5867
/// what a timeline session needs from the room that owns it5968
#[derive(Clone)]6069
pub(super) struct SessionContext {@@ -145,11 +154,12 @@ impl TimelineSession {
145154
146155
/// pending paginations answer `Interrupted` as their responders drop147156
pub(super) async fn close(mut self) {148
- self.context.media.release(&self.scope).await;149157
self.pagination_worker.abort();150158
let _ = (&mut self.pagination_worker).await;151159
self.watcher.abort();152160
let _ = (&mut self.watcher).await;161
+ // after the watcher, or a last diff could register media for a timeline that's gone162
+ self.context.media.release(&self.scope).await;153163
}154164
155165
pub(super) async fn execute(@@ -1,4 +1,7 @@
1
-use std::{sync::Arc, time::Duration};1
+use std::{2
+ sync::Arc,3
+ time::{Duration, Instant},4
+};25
36
use futures_util::StreamExt;47
use matrix_sdk::{@@ -20,6 +23,18 @@ use crate::{
2023
const SYNC_RESTART_MIN: Duration = Duration::from_secs(1);2124
const SYNC_RESTART_MAX: Duration = Duration::from_secs(60);2225
const SEND_QUEUE_RETRY: Duration = Duration::from_secs(5);26
+const SYNC_STABLE: Duration = Duration::from_secs(30);27
+28
+pub(super) async fn room_subscriptions(29
+ sync: Arc<SyncService>,30
+ mut rooms: watch::Receiver<Vec<ruma::OwnedRoomId>>,31
+) {32
+ while rooms.changed().await.is_ok() {33
+ let room_ids = rooms.borrow_and_update().clone();34
+ let room_ids = room_ids.iter().map(AsRef::as_ref).collect::<Vec<_>>();35
+ sync.room_list_service().subscribe_to_rooms(&room_ids).await;36
+ }37
+}2338
2439
pub(super) async fn session(2540
mut changes: tokio::sync::broadcast::Receiver<SessionChange>,@@ -57,16 +72,26 @@ pub(super) async fn connection(
5772
connection: watch::Sender<ConnectionState>,5873
) {5974
let mut backoff = SYNC_RESTART_MIN;75
+ let mut online_since: Option<Instant> = None;6076
while let Some(state) = states.next().await {77
+ // only a run that lasted counts as recovered, or a server that answers /versions78
+ // but refuses sync would have us bounce between offline and online forever79
+ let stable = online_since80
+ .take()81
+ .map(|since| since.elapsed() >= SYNC_STABLE);82
+ if stable == Some(true) {83
+ backoff = SYNC_RESTART_MIN;84
+ }6185
let (state, restart) = match state {62
- SyncState::Idle => (ConnectionState::Idle, false),86
+ // only ever us stopping it for a restart, nothing a client needs to hear87
+ SyncState::Idle => continue,6388
SyncState::Running => {64
- backoff = SYNC_RESTART_MIN;89
+ online_since = Some(Instant::now());6590
// the sdk parks a room queue on any send error and never unparks it6691
client.send_queue().set_enabled(true).await;6792
(ConnectionState::Online, false)6893
}69
- SyncState::Offline => (ConnectionState::Offline, false),94
+ SyncState::Offline => (ConnectionState::Offline, stable == Some(false)),7095
SyncState::Terminated => (ConnectionState::Offline, true),7196
SyncState::Error(error) => {7297
tracing::warn!(%error, "sync service reported an error");@@ -80,6 +105,7 @@ pub(super) async fn connection(
80105
}81106
}82107
if restart {108
+ sync.stop().await;83109
tokio::time::sleep(backoff).await;84110
backoff = (backoff * 2).min(SYNC_RESTART_MAX);85111
sync.start().await;@@ -132,51 +158,75 @@ pub(super) async fn room_uploads(room: Room, events: EventSender, ready: watch::
132158
loop {133159
let update = match updates.recv().await {134160
Ok(update) => update,161
+ // a missed tick is fine, the next one says where it's got to162
+ Err(RecvError::Lagged(_)) => continue,163
+ Err(RecvError::Closed) => return,164
+ };165
+ let RoomSendQueueUpdate::MediaUpload {166
+ related_to,167
+ file,168
+ progress,169
+ ..170
+ } = update171
+ else {172
+ continue;173
+ };174
+ let event = Event::MediaUploadProgress {175
+ room_id: room_id.clone(),176
+ transaction_id: related_to,177
+ uploaded_bytes: progress.current as u64,178
+ total_bytes: progress.total as u64,179
+ complete: file.is_some() && progress.current == progress.total,180
+ };181
+ if events.send(event).await.is_err() {182
+ return;183
+ }184
+ }185
+}186
+187
+/// failures do reach the client-wide channel, and this one exists before any upload does188
+pub(super) async fn upload_failures(189
+ client: Client,190
+ mut updates: tokio::sync::broadcast::Receiver<matrix_sdk::send_queue::SendQueueUpdate>,191
+ events: EventSender,192
+) {193
+ loop {194
+ let update = match updates.recv().await {195
+ Ok(update) => update,135196
Err(RecvError::Lagged(skipped)) => {136
- tracing::warn!(%room_id, skipped, "upload watcher lagged");197
+ tracing::warn!(skipped, "upload failure watcher lagged");137198
continue;138199
}139200
Err(RecvError::Closed) => return,140201
};141
- let event = match update {142
- RoomSendQueueUpdate::MediaUpload {143
- related_to,144
- file,145
- progress,146
- ..147
- } => Event::MediaUploadProgress {148
- room_id: room_id.clone(),149
- transaction_id: related_to,150
- uploaded_bytes: progress.current as u64,151
- total_bytes: progress.total as u64,152
- complete: file.is_some() && progress.current == progress.total,153
- },154
- RoomSendQueueUpdate::SendError {155
- transaction_id,156
- error,157
- is_recoverable,158
- } => {159
- let is_attachment = room160
- .send_queue()161
- .subscribe()162
- .await163
- .is_ok_and(|(echoes, _)| {164
- echoes.iter().any(|echo| {165
- echo.transaction_id == transaction_id166
- && is_attachment_echo(&echo.content)167
- })168
- });169
- if !is_attachment {170
- continue;171
- }172
- Event::MediaUploadFailed {173
- room_id: room_id.clone(),174
- transaction_id,175
- kind: classify_sdk_error(&error),176
- recoverable: is_recoverable,177
- }178
- }179
- _ => continue,202
+ let RoomSendQueueUpdate::SendError {203
+ transaction_id,204
+ error,205
+ is_recoverable,206
+ } = update.update207
+ else {208
+ continue;209
+ };210
+ let Some(room) = client.get_room(&update.room_id) else {211
+ continue;212
+ };213
+ let is_attachment = room214
+ .send_queue()215
+ .subscribe()216
+ .await217
+ .is_ok_and(|(echoes, _)| {218
+ echoes.iter().any(|echo| {219
+ echo.transaction_id == transaction_id && is_attachment_echo(&echo.content)220
+ })221
+ });222
+ if !is_attachment {223
+ continue;224
+ }225
+ let event = Event::MediaUploadFailed {226
+ room_id: update.room_id,227
+ transaction_id,228
+ kind: classify_sdk_error(&error),229
+ recoverable: is_recoverable,180230
};181231
if events.send(event).await.is_err() {182232
return;@@ -322,6 +322,7 @@ pub enum MediaCommand {
322322
323323
#[derive(Clone, Debug, Eq, PartialEq)]324324
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]325
+#[non_exhaustive]325326
pub struct AttachmentUpload {326327
pub timeline_id: TimelineId,327328
pub filename: String,@@ -677,7 +678,7 @@ pub enum Event {
677678
LoggedOut,678679
Failed(EngineFailure),679680
/// always the last event. keep the saved record unless `LoggedOut` or a680
- /// hard `SessionAccessRejected` came first. commands still in flight get no result681
+ /// hard `SessionAccessRejected` came first. commands still in flight answer `Interrupted`681682
Stopped,682683
}683684
@@ -701,6 +702,8 @@ pub struct RoomSummary {
701702
pub membership: RoomMembership,702703
pub unread_messages: u64,703704
pub unread_mentions: u64,705
+ /// unread events the push rules said to notify about, which is neither of the above706
+ pub unread_notifications: u64,704707
pub encrypted: bool,705708
pub space: bool,706709
pub direct: bool,@@ -735,12 +738,14 @@ pub struct RoomHero {
735738
pub avatar: Option<OwnedMxcUri>,736739
}737740
738
-/// enough for a room list preview line. `body` is empty for anything that isn't a plain message741
+/// enough for a room list preview line. `kind` and `body` are only set for messages,742
+/// and an edit shows its new text739743
#[derive(Clone, Debug, Eq, PartialEq)]740744
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]741745
#[non_exhaustive]742746
pub struct LatestEvent {743747
pub sender: Option<OwnedUserId>,748
+ pub kind: Option<MessageKind>,744749
pub body: Option<String>,745750
pub timestamp_ms: Option<u64>,746751
pub send_state: SendState,@@ -864,6 +869,8 @@ pub struct RoomPowerLevels {
864869
pub room_notification: i64,865870
pub users: Vec<(OwnedUserId, i64)>,866871
pub events: Vec<(String, i64)>,872
+ /// in rooms from version 12 the creators outrank everyone and aren't listed in `users`873
+ pub creators: Vec<OwnedUserId>,867874
}868875
869876
#[derive(Clone, Debug, Default, Eq, PartialEq)]@@ -985,6 +992,7 @@ impl RoomMatcher<'_> {
985992
986993
#[derive(Clone, Copy, Debug, Eq, PartialEq)]987994
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]995
+#[non_exhaustive]988996
pub enum RoomCategory {989997
People,990998
Rooms,@@ -993,10 +1001,13 @@ pub enum RoomCategory {
9931001
9941002
#[derive(Clone, Copy, Debug, Eq, PartialEq)]9951003
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1004
+#[non_exhaustive]9961005
pub enum MemberFilter {9971006
Active,9981007
Joined,9991008
Invited,1009
+ Banned,1010
+ Left,10001011
All,10011012
}10021013
@@ -1308,7 +1319,7 @@ pub enum TimelineContent {
13081319
display_name: Option<Change<Option<String>>>,13091320
avatar: Option<Change<Option<OwnedMxcUri>>>,13101321
},1311
- /// `None` means it was cleared1322
+ /// `None` means it was cleared, or the event was redacted13121323
RoomName(Option<String>),13131324
RoomTopic(Option<String>),13141325
RoomAvatar(Option<OwnedMxcUri>),@@ -1332,6 +1343,8 @@ pub enum TimelineContent {
13321343
pub struct Message {13331344
pub kind: MessageKind,13341345
pub body: String,1346
+ /// only for `MessageKind::Location`1347
+ pub geo_uri: Option<String>,13351348
pub formatted_body: Option<String>,13361349
pub mentions: Option<MessageMentions>,13371350
pub edited: bool,@@ -1414,9 +1427,12 @@ pub struct ThreadSummary {
14141427
#[non_exhaustive]14151428
pub enum SendState {14161429
Sent,1417
- Queued,14181430
Sending,1419
- Failed { retriable: bool },1431
+ /// `will_retry` means macltok has it in hand. otherwise it's wedged until you1432
+ /// `retry_send` or `cancel_send`, so that's when to show a retry button1433
+ Failed {1434
+ will_retry: bool,1435
+ },14201436
}14211437
14221438
#[derive(Clone, Debug, Eq, PartialEq)]@@ -1463,6 +1479,10 @@ pub struct MediaDescriptor {
14631479
pub size_bytes: Option<u64>,14641480
pub width: Option<u64>,14651481
pub height: Option<u64>,1482
+ pub duration_ms: Option<u64>,1483
+ pub blurhash: Option<String>,1484
+ /// the uploader sent a thumbnail, so `MediaSize::Thumbnail` won't need the server to resize1485
+ pub has_thumbnail: bool,14661486
}14671487
14681488
#[derive(Clone, Debug, Eq, Hash, PartialEq)]@@ -1480,6 +1500,21 @@ impl MediaId {
14801500
}14811501
}14821502
1503
+/// media ids are opaque but they do have to survive a trip through a browser. a made up one just fails `MediaUnknown`1504
+impl std::str::FromStr for MediaId {1505
+ type Err = std::convert::Infallible;1506
+1507
+ fn from_str(value: &str) -> Result<Self, Self::Err> {1508
+ Ok(Self(value.to_owned()))1509
+ }1510
+}1511
+1512
+impl From<String> for MediaId {1513
+ fn from(value: String) -> Self {1514
+ Self(value)1515
+ }1516
+}1517
+14831518
impl std::fmt::Display for MediaId {14841519
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {14851520
self.0.fmt(formatter)@@ -1488,6 +1523,7 @@ impl std::fmt::Display for MediaId {
14881523
14891524
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]14901525
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1526
+#[non_exhaustive]14911527
pub enum MediaSize {14921528
Full,14931529
/// a hint. an uploader's own thumbnail wins over the server resizing anything@@ -1568,7 +1604,10 @@ pub enum RecoveryState {
15681604
15691605
#[derive(Clone, Copy, Debug, Eq, PartialEq)]15701606
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1607
+#[non_exhaustive]15711608
pub enum VerificationAction {1609
+ /// take up a request another device sent, which arrives as `VerificationState::Incoming`1610
+ Accept,15721611
Confirm,15731612
Reject,15741613
Cancel,@@ -1591,6 +1630,11 @@ pub enum VerificationTarget {
15911630
#[non_exhaustive]15921631
pub enum VerificationState {15931632
Waiting,1633
+ /// someone else asked. `Accept` to go ahead, `Cancel` to turn them down1634
+ Incoming {1635
+ user_id: OwnedUserId,1636
+ device_id: Option<OwnedDeviceId>,1637
+ },15941638
Comparison {15951639
emojis: Vec<(String, String)>,15961640
decimals: (u16, u16, u16),@@ -76,86 +76,65 @@ pub(super) fn media_identity(item: &SdkTimelineItem, scope: &MediaScope) -> Stri
7676
}7777
7878
pub(super) fn media_from_message(identity: &str, message: &MessageType) -> Option<MediaDescriptor> {79
- let (kind, filename, caption, mimetype, size_bytes, width, height) = match message {80
- MessageType::Image(content) => (81
- MediaKind::Image,82
- content.filename().to_owned(),83
- content.caption().map(str::to_owned),84
- content.info.as_ref().and_then(|info| info.mimetype.clone()),85
- content86
- .info87
- .as_ref()88
- .and_then(|info| info.size)89
- .map(u64::from),90
- content91
- .info92
- .as_ref()93
- .and_then(|info| info.width)94
- .map(u64::from),95
- content96
- .info97
- .as_ref()98
- .and_then(|info| info.height)99
- .map(u64::from),100
- ),101
- MessageType::Video(content) => (102
- MediaKind::Video,103
- content.filename().to_owned(),104
- content.caption().map(str::to_owned),105
- content.info.as_ref().and_then(|info| info.mimetype.clone()),106
- content107
- .info108
- .as_ref()109
- .and_then(|info| info.size)110
- .map(u64::from),111
- content112
- .info113
- .as_ref()114
- .and_then(|info| info.width)115
- .map(u64::from),116
- content117
- .info118
- .as_ref()119
- .and_then(|info| info.height)120
- .map(u64::from),121
- ),122
- MessageType::Audio(content) => (123
- MediaKind::Audio,124
- content.filename().to_owned(),125
- content.caption().map(str::to_owned),126
- content.info.as_ref().and_then(|info| info.mimetype.clone()),127
- content128
- .info129
- .as_ref()130
- .and_then(|info| info.size)131
- .map(u64::from),132
- None,133
- None,134
- ),135
- MessageType::File(content) => (136
- MediaKind::File,137
- content.filename().to_owned(),138
- content.caption().map(str::to_owned),139
- content.info.as_ref().and_then(|info| info.mimetype.clone()),140
- content141
- .info142
- .as_ref()143
- .and_then(|info| info.size)144
- .map(u64::from),145
- None,146
- None,147
- ),148
- _ => return None,149
- };150
- Some(MediaDescriptor {79
+ let descriptor = |kind, filename: &str, caption: Option<&str>| MediaDescriptor {15180
id: media_id(identity, "media"),15281
kind,153
- filename,154
- caption,155
- mimetype,156
- size_bytes,157
- width,158
- height,82
+ filename: filename.to_owned(),83
+ caption: caption.map(str::to_owned),84
+ mimetype: None,85
+ size_bytes: None,86
+ width: None,87
+ height: None,88
+ duration_ms: None,89
+ blurhash: None,90
+ has_thumbnail: false,91
+ };92
+ let millis = |duration: std::time::Duration| u64::try_from(duration.as_millis()).ok();93
+ Some(match message {94
+ MessageType::Image(content) => {95
+ let info = content.info.as_deref();96
+ MediaDescriptor {97
+ mimetype: info.and_then(|info| info.mimetype.clone()),98
+ size_bytes: info.and_then(|info| info.size).map(u64::from),99
+ width: info.and_then(|info| info.width).map(u64::from),100
+ height: info.and_then(|info| info.height).map(u64::from),101
+ blurhash: info.and_then(|info| info.blurhash.clone()),102
+ has_thumbnail: info.is_some_and(|info| info.thumbnail_source.is_some()),103
+ ..descriptor(MediaKind::Image, content.filename(), content.caption())104
+ }105
+ }106
+ MessageType::Video(content) => {107
+ let info = content.info.as_deref();108
+ MediaDescriptor {109
+ mimetype: info.and_then(|info| info.mimetype.clone()),110
+ size_bytes: info.and_then(|info| info.size).map(u64::from),111
+ width: info.and_then(|info| info.width).map(u64::from),112
+ height: info.and_then(|info| info.height).map(u64::from),113
+ duration_ms: info.and_then(|info| info.duration).and_then(millis),114
+ blurhash: info.and_then(|info| info.blurhash.clone()),115
+ has_thumbnail: info.is_some_and(|info| info.thumbnail_source.is_some()),116
+ ..descriptor(MediaKind::Video, content.filename(), content.caption())117
+ }118
+ }119
+ MessageType::Audio(content) => {120
+ let info = content.info.as_deref();121
+ MediaDescriptor {122
+ mimetype: info.and_then(|info| info.mimetype.clone()),123
+ size_bytes: info.and_then(|info| info.size).map(u64::from),124
+ duration_ms: info.and_then(|info| info.duration).and_then(millis),125
+ ..descriptor(MediaKind::Audio, content.filename(), content.caption())126
+ }127
+ }128
+ MessageType::File(content) => {129
+ let info = content.info.as_deref();130
+ MediaDescriptor {131
+ mimetype: info.and_then(|info| info.mimetype.clone()),132
+ size_bytes: info.and_then(|info| info.size).map(u64::from),133
+ has_thumbnail: info.is_some_and(|info| info.thumbnail_source.is_some()),134
+ ..descriptor(MediaKind::File, content.filename(), content.caption())135
+ }136
+ }137
+ _ => return None,159138
})160139
}161140
@@ -172,6 +151,9 @@ pub(super) fn media_descriptor_for_sticker(
172151
size_bytes: content.info.size.map(u64::from),173152
width: content.info.width.map(u64::from),174153
height: content.info.height.map(u64::from),154
+ duration_ms: None,155
+ blurhash: content.info.blurhash.clone(),156
+ has_thumbnail: content.info.thumbnail_source.is_some(),175157
}176158
}177159
@@ -7,4 +7,4 @@ pub(crate) use media::{MediaRegistration, MediaScope};
77
pub(crate) use member::member_summary;88
pub(crate) use room::notification_mode;99
pub(crate) use room::{room_summaries, space_child};10
-pub(crate) use timeline::{normalise_timeline, normalise_timeline_diffs};10
+pub(crate) use timeline::{message_kind, normalise_timeline, normalise_timeline_diffs};@@ -5,14 +5,17 @@ use matrix_sdk::{
55
latest_events::LatestEventValue,66
ruma::events::{77
AnyMessageLikeEventContent, AnySyncMessageLikeEvent, AnySyncTimelineEvent, SyncStateEvent,8
+ room::message::{Relation, RoomMessageEventContent},89
space::child::SpaceChildEventContent,910
},1011
};1112
use matrix_sdk_ui::spaces::{SpaceRoom, SpaceService};1213
use ruma::OwnedRoomId;1314
15
+use super::message_kind;1416
use crate::{15
- LatestEvent, RoomHero, RoomMembership, RoomNotificationMode, RoomSummary, SendState, SpaceChild,17
+ LatestEvent, MessageKind, RoomHero, RoomMembership, RoomNotificationMode, RoomSummary,18
+ SendState, SpaceChild,1619
};1720
1821
pub(crate) async fn room_summaries(client: &Client, spaces: &SpaceService) -> Vec<RoomSummary> {@@ -61,6 +64,7 @@ async fn room_summary(room: &Room, spaces: &SpaceService) -> RoomSummary {
6164
membership: membership(room.state()),6265
unread_messages: room.num_unread_messages(),6366
unread_mentions: room.num_unread_mentions(),67
+ unread_notifications: room.num_unread_notifications(),6468
encrypted: room.encryption_state().is_encrypted(),6569
space: room.is_space(),6670
direct: room.is_direct().await.unwrap_or(false),@@ -127,60 +131,65 @@ async fn space_children(room: &Room) -> Vec<OwnedRoomId> {
127131
}128132
129133
fn latest_event(room: &Room) -> Option<LatestEvent> {130
- let (sender, body, timestamp, send_state) = match room.latest_event() {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() {131143
LatestEventValue::None => return None,132144
LatestEventValue::Remote(event) => {133
- let body = match event.raw().deserialize() {145
+ let preview = match event.raw().deserialize() {134146
Ok(AnySyncTimelineEvent::MessageLike(AnySyncMessageLikeEvent::RoomMessage(135147
message,136148
))) => message137149
.as_original()138
- .map(|message| message.content.body().to_owned()),139
- _ => None,150
+ .map_or((None, None), |message| preview(&message.content)),151
+ _ => (None, None),140152
};141
- (event.sender(), body, event.timestamp(), SendState::Sent)153
+ (event.sender(), preview, event.timestamp(), SendState::Sent)142154
}143155
LatestEventValue::RemoteInvite {144156
timestamp, inviter, ..145
- } => (inviter, None, Some(timestamp), SendState::Sent),146
- LatestEventValue::LocalIsSending(local) => local_event(room, &local, SendState::Sending),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
+ }147162
LatestEventValue::LocalHasBeenSent { value, .. } => {148
- local_event(room, &value, SendState::Sent)163
+ let (preview, timestamp) = local(&value);164
+ (own(), preview, timestamp, SendState::Sent)149165
}150
- LatestEventValue::LocalCannotBeSent(local) => {151
- local_event(room, &local, SendState::Failed { retriable: true })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
+ )152175
}153176
};154177
Some(LatestEvent {155178
sender,179
+ kind,156180
body,157181
timestamp_ms: timestamp.map(|timestamp| timestamp.get().into()),158182
send_state,159183
})160184
}161185
162
-type LatestParts = (163
- Option<ruma::OwnedUserId>,164
- Option<String>,165
- Option<ruma::MilliSecondsSinceUnixEpoch>,166
- SendState,167
-);168
-169
-fn local_event(170
- room: &Room,171
- local: &matrix_sdk::latest_events::LocalLatestEventValue,172
- send_state: SendState,173
-) -> LatestParts {174
- let body = match local.content.deserialize() {175
- Ok(AnyMessageLikeEventContent::RoomMessage(content)) => Some(content.body().to_owned()),176
- _ => None,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,177191
};178
- (179
- room.own_user_id().to_owned().into(),180
- body,181
- Some(local.timestamp),182
- send_state,183
- )192
+ (Some(message_kind(msgtype)), Some(msgtype.body().to_owned()))184193
}185194
186195
pub(crate) fn space_child(room: SpaceRoom) -> SpaceChild {@@ -203,12 +203,12 @@ fn content(content: &TimelineItemContent, media_identity: Option<&str>) -> Timel
203203
}),204204
},205205
TimelineItemContent::OtherState(state) => match state.content() {206
- AnyOtherStateEventContentChange::RoomName(change) => {207
- TimelineContent::RoomName(original(change).map(|content| content.name.clone()))208
- }209
- AnyOtherStateEventContentChange::RoomTopic(change) => {210
- TimelineContent::RoomTopic(original(change).map(|content| content.topic.clone()))211
- }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
+ ),212212
AnyOtherStateEventContentChange::RoomAvatar(change) => TimelineContent::RoomAvatar(213213
original(change).and_then(|content| content.url.clone()),214214
),@@ -243,6 +243,11 @@ fn content(content: &TimelineItemContent, media_identity: Option<&str>) -> Timel
243243
}244244
}245245
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
+246251
fn other_state(event_type: String, state_key: &str) -> TimelineContent {247252
TimelineContent::OtherState {248253
event_type,@@ -265,6 +270,10 @@ fn msg_like(content: &MsgLikeContent, media_identity: Option<&str>) -> TimelineC
265270
MsgLikeKind::Message(message) => TimelineContent::Message(Message {266271
kind: message_kind(message.msgtype()),267272
body: message.body().to_owned(),273
+ geo_uri: match message.msgtype() {274
+ MessageType::Location(location) => Some(location.geo_uri.clone()),275
+ _ => None,276
+ },268277
formatted_body: formatted_body(message.msgtype()),269278
mentions: message.mentions().map(|mentions| MessageMentions {270279
user_ids: mentions.user_ids.iter().cloned().collect(),@@ -305,7 +314,7 @@ fn msg_like(content: &MsgLikeContent, media_identity: Option<&str>) -> TimelineC
305314
}306315
}307316
308
-fn message_kind(message: &MessageType) -> MessageKind {317
+pub(crate) fn message_kind(message: &MessageType) -> MessageKind {309318
match message {310319
MessageType::Text(_) => MessageKind::Text,311320
MessageType::Emote(_) => MessageKind::Emote,@@ -379,10 +388,10 @@ fn sender(user_id: &UserId, profile: &TimelineDetails<Profile>) -> Sender {
379388
fn send_state(state: Option<&EventSendState>) -> SendState {380389
match state {381390
None | Some(EventSendState::Sent { .. }) => SendState::Sent,382
- Some(EventSendState::NotSentYet { progress: Some(_) }) => SendState::Sending,383
- Some(EventSendState::NotSentYet { progress: None }) => SendState::Queued,391
+ Some(EventSendState::NotSentYet { .. }) => SendState::Sending,392
+ // recoverable means the sdk parks the queue and we unpark it, nothing for the user to do384393
Some(EventSendState::SendingFailed { is_recoverable, .. }) => SendState::Failed {385
- retriable: *is_recoverable,394
+ will_retry: *is_recoverable,386395
},387396
}388397
}@@ -719,6 +719,8 @@ impl ClientHandle {
719719
MemberFilter::Active => RoomMemberships::ACTIVE,720720
MemberFilter::Joined => RoomMemberships::JOIN,721721
MemberFilter::Invited => RoomMemberships::INVITE,722
+ MemberFilter::Banned => RoomMemberships::BAN,723
+ MemberFilter::Left => RoomMemberships::LEAVE,722724
// An empty mask is the SDK's unfiltered wildcard: it matches every723725
// membership, including custom states that `all()` would exclude.724726
MemberFilter::All => RoomMemberships::empty(),@@ -758,6 +760,12 @@ impl ClientHandle {
758760
.into_iter()759761
.map(|(event_type, level)| (event_type.to_string(), level.into()))760762
.collect(),763
+ creators: levels764
+ .rules765
+ .privileged_creators766
+ .into_iter()767
+ .flatten()768
+ .collect(),761769
})762770
}763771
@@ -974,6 +982,10 @@ impl DriverPort {
974982
self.requests.recv().await975983
}976984
985
+ pub(crate) fn try_recv(&mut self) -> Option<Request> {986
+ self.requests.try_recv().ok()987
+ }988
+977989
pub(crate) fn close(&mut self) {978990
self.requests.close();979991
}@@ -127,7 +127,11 @@ pub(crate) fn prepare_upload(
127127
|| thumbnail.height == 0128128
{129129
return Err(Error::new(130
- ErrorKind::MediaTooLarge,130
+ if thumbnail.bytes.len() > max_media_bytes {131
+ ErrorKind::MediaTooLarge132
+ } else {133
+ ErrorKind::Invalid134
+ },131135
"thumbnail size or dimensions are invalid",132136
));133137
}@@ -102,6 +102,65 @@ async fn encrypted_media_is_cached_as_plaintext_like_the_sdk_does() {
102102
}103103
104104
#[tokio::test]105
+async fn a_plain_event_cannot_poison_an_encrypted_files_cache() {106
+ use std::io::Read as _;107
+108
+ let plaintext = b"very secret image".to_vec();109
+ let mut cursor = std::io::Cursor::new(plaintext.clone());110
+ let mut encryptor = matrix_sdk_crypto::AttachmentEncryptor::new(&mut cursor);111
+ let mut ciphertext = Vec::new();112
+ encryptor.read_to_end(&mut ciphertext).unwrap();113
+ let keys = encryptor.finish();114
+ let uri = mxc_uri!("mxc://localhost/secret").to_owned();115
+ let file = EncryptedFile::new(uri.clone(), keys.encryption_info, keys.hashes);116
+ let mut harness = start(RoomMessageEventContent::new(MessageType::Image(117
+ ImageMessageEventContent::plain("bait.png".to_owned(), uri),118
+ )))119
+ .await;120
+ Mock::given(method("GET"))121
+ .and(path_regex("/download/localhost/secret$"))122
+ .respond_with(ResponseTemplate::new(200).set_body_bytes(ciphertext.clone()))123
+ .mount(harness.server.server())124
+ .await;125
+ assert_eq!(&*fetch(&harness, MediaSize::Full).await, &ciphertext[..]);126
+127
+ harness128
+ .server129
+ .sync_room(130
+ &harness.client,131
+ JoinedRoomBuilder::new(room_id!("!media:localhost")).add_timeline_event(132
+ EventFactory::new()133
+ .event(RoomMessageEventContent::new(MessageType::Image(134
+ ImageMessageEventContent::encrypted("secret.png".to_owned(), file),135
+ )))136
+ .sender(&ALICE),137
+ ),138
+ )139
+ .await;140
+ let event = support::receive_until(&mut harness.events, |event| {141
+ support::event_messages(event).into_iter().any(|message| {142
+ support::media_of(message).is_some_and(|media| media.filename == "secret.png")143
+ })144
+ })145
+ .await;146
+ let media_id = support::event_messages(&event)147
+ .into_iter()148
+ .find_map(|message| {149
+ support::media_of(message).filter(|media| media.filename == "secret.png")150
+ })151
+ .map(|media| media.id.clone())152
+ .unwrap();153
+ let media = harness154
+ .handle155
+ .media(&media_id, MediaSize::Full)156
+ .await157
+ .unwrap();158
+ assert_eq!(&*media.bytes, &plaintext[..]);159
+160
+ support::shut_down(&harness.handle, &mut harness.events, harness.running).await;161
+}162
+163
+#[tokio::test]105164
async fn simultaneous_fetches_share_one_download() {106165
let mut harness = start(plain_image()).await;107166
Mock::given(method("GET"))