atagen

macltok

public
Star0
Watch0

Commit 5a52012c9b1d

tarball

atagen <boss@atagen.co>2026-09-25 01:04 UTC

21 files changed+908-342
engine: fix what the second review found in the rework

Three parallel reviews of the new engine found closes and opens racing a self-closing actor answering Interrupted, verification watchers subscribing after the state they needed, and a plain event able to plant an encrypted file's ciphertext in the shared cache. SendState::Failed { retriable } had the SDK's inverted meaning for a UI, and downloads died on the SDK client's 30 second timeout. Incoming verification comes over from Malice's vendored copy, thumbnails the server refuses fall back to the file, and Engine::run returns a boxed future because the deeper actor tripped the query depth limit in every consuming crate.
Modified file: Cargo.lock+1 additions
@@ -1830,6 +1830,7 @@ dependencies = [
18301830 "ruma",
18311831 "serde",
18321832 "serde_json",
1833+ "sha2",
18331834 "thiserror 2.0.20",
18341835 "tokio",
18351836 "tracing",
Modified file: Cargo.toml+1 additions
@@ -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 }
Modified file: docs/design-decisions.md+36 additions−12 deletions
@@ -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, and
36-`ConnectionChanged` shows `Offline` meanwhile. every return to `Online` re-arms
36+`ConnectionChanged` shows `Offline` meanwhile. a run that drops again inside
37+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-arms
3739 the send queues, since the sdk parks a room's queue on any send failure and
3840 never unparks it. a recoverable failure while online gets the room another go
3941 after a few seconds
4042
4143 logout and a rejected token both stop the engine. `Shutdown` answers once
42-`Stopped` has gone out
44+`Stopped` has gone out, every one of them if several were queued
45+
46+`Engine::run` hands back a boxed future. the real one nests deep enough that
47+every crate spawning it would trip the compiler's query depth
4348
4449 ## rooms
4550
4651 one actor per open room. the engine holds a slot per room, `Active` with a
4752 sender or `Closing` with a fifo of opens and closes that arrived while it wound
4853 down. those replay in order once the actor exits. a room you leave, or get
49-kicked or banned from, closes itself
54+kicked or banned from, closes itself. it tells the engine first, and anything
55+already queued to it comes back to be answered or replayed, so an open racing a
56+leave still gets a fresh actor
5057
5158 opening sends `TimelineReset` on the stream before it answers. opening an
5259 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 gappy
6471 sync can unload it again
6572
66-every task inside an actor is supervised, and a panic in any of them panics the
67-actor
73+the actor's watchers are supervised, and a panic in any of them panics the
74+actor. its command work isn't, a panicking redaction just answers `Interrupted`
75+and gets logged
6876
6977 ## timelines
7078
@@ -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 english
8391 in a body field
8492
93+thread receipts for a thread that isn't open go straight to the room, so
94+marking a thread read works the same whether its room is on screen or not
95+
8596 replies carry the replied-to event once it's loaded. the watcher asks the sdk
8697 for any it hasn't got, once per event, since the sdk won't on its own
8798
@@ -106,17 +117,26 @@ sdk's own patcher is private
106117
107118 media is a read. `ClientHandle::media` takes a `MediaId` a retained timeline
108119 handed you, `mxc_media` takes any plain mxc uri. both share one bounded
109-download path with the byte cap the sdk's own download lacks, and
110-concurrent asks for the same bytes share one flight
120+download path with the byte cap the sdk's own download lacks, an hour's total
121+deadline instead of the sdk client's thirty seconds, and a minute's patience
122+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 it
111125
112126 the cache holds plaintext, keyed like the sdk keys it, so the sdk and macltok
113-can read each other's entries. that's how your own encrypted uploads come back
127+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 at
129+someone's encrypted file can leave ciphertext in the cache. an encrypted read
130+checks the cached bytes against the file's hash and decrypts them if they match
114131
115132 media ids are registered as timelines render and forgotten when their timeline
116133 closes, least recently seen first once the cap is hit
117134
118-uploads are commands that answer once the send queue has them. progress and
119-failure arrive as events from one watcher on the sdk's global send queue
135+uploads are commands that answer once the send queue has them. progress only
136+goes out on a room's own send queue channel, so each room that uploads gets a
137+forwarder, subscribed before the upload is queued and restarted for uploads the
138+sdk resumes from last time. failures do reach the client-wide channel, so one
139+watcher there reports them
120140
121141 ## sessions and secrets
122142
@@ -156,8 +176,12 @@ touching fifty rooms is one event
156176
157177 ## verification
158178
159-`Confirm`, `Reject` and `Cancel`. reject tells the other side the emoji didn't
160-match. the watcher and the action paths claim the terminal event, so a racing
179+`Accept`, `Confirm`, `Reject` and `Cancel`. a request another device sends
180+shows up as `Incoming` and waits for `Accept`, or `Cancel` to turn it down. one
181+is only taken up while nothing else is in flight. reject tells the other side
182+the emoji didn't match. the watchers subscribe before they look at the current
183+state, since the sdk only reports what changes after you ask. the watcher and
184+the action paths claim the terminal event, so a racing
161185 cancel can't turn `Done` into `Cancelled`
162186
163187 ## public model
Modified file: docs/review-2026-09-24.md+28 additions−1 deletions
@@ -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 latest
250250 event preview, and space parents and children (Malice gap 3).
251251
252-### A6. No incoming verification. open
252+### A6. No incoming verification. done, ported from malice's vendored copy
253253
254254 Only outgoing `StartVerification` exists. A request started from another
255255 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-25
398+
399+three parallel reviews of the reworked engine, each finding checked against the
400+code before fixing. all fixed in the same change
401+
402+- close answered `Interrupted` when the actor had already gone, and anything
403+ sent to an actor stopping after a leave was dropped. actors now say they're
404+ leaving and hand back what they couldn't serve
405+- closing a timeline released its media before stopping the watcher, so late
406+ diffs re-registered it
407+- thread receipts failed while the room was open and the thread wasn't
408+- verification watchers subscribed after the state they needed, so a peer
409+ starting sas left the flow waiting forever
410+- a plain event could leave an encrypted file's ciphertext in the cache
411+- downloads died at thirty seconds on the sdk client's timeout
412+- `create_direct` made a second dm when asked twice before sync caught up
413+- offline mode plus a server refusing sync bounced forever with no backoff
414+- upload progress missed resumed uploads, failures could be lost to lag
415+- `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's
418+ mimetype and ignored the asked size, v12 creators' power was dropped,
419+ cleared names came back as empty strings
420+- the example logged in afresh every run and broke its own store
421+- adding the fixes tipped `Engine::run` past the compiler's query depth in every
422+ consumer, so it now returns a boxed future
Modified file: examples/headless.rs+28 additions−13 deletions
@@ -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 token
11+ 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+ session
28+ }
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 }
Modified file: src/encryption.rs+87 additions−24 deletions
@@ -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 clears
7374 // `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's
253+ // answer lands while we're blocked and nobody ever hears it
254+ let changes = request.changes();
251255 self.active.lock().await.request = Some(request.clone());
252256 sender
253257 .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 accepted
276+ /// 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+ request
339+ .accept_with_methods(vec![VerificationMethod::SasV1])
340+ .await
341+ .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 state
637+/// is handled first, since the stream only reports what happens after it was taken
563638 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) = request
574- .accept_with_methods(vec![VerificationMethod::SasV1])
575- .await
576- {
577- tracing::warn!(error = %error, "accepting verification request failed");
578- // Claim the session so this terminal outcome is emitted exactly
579- // 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 starting
728+ // sas is the usual case, so take the stream first and the current state after
729+ 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 { .. } => {
Modified file: src/engine/commands.rs+13 additions−3 deletions
@@ -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
Modified file: src/engine/media.rs+93 additions−32 deletions
@@ -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 for
40-const TIMELINE_THUMBNAIL: u32 = 800;
40+/// a stalled download gives up after this long without a byte
41+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 video
43+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 lie
117+ let format = if is_local(&media.full.source) {
118+ MediaFormat::File
119+ } 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 .await
133137 }
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 .await
151155 }
152156
157+ /// plenty of servers won't thumbnail everything, and the file itself is the next best
158+ /// thing. the byte cap still holds either way
159+ 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).await
174+ }
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 flight
194230 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 uri
262+ // leaves ciphertext here. its hash gives it away
263+ 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 anything
229272 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 _ = store
232280 .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.hashes
295+ .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 hand
385+/// the sdk's own download has no size cap, so we do it by hand
329386 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 uri
339- .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 = client
404+ .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+ .await
452+ .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 .await
502559 .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::RoomUnknown
565+ }
566+ RoomSendQueueError::StorageError(_) => ErrorKind::Store,
567+ _ => ErrorKind::MediaUnavailable,
568+ };
569+ Error::with_source(kind, "media upload could not be queued", source)
509570 })
510571 }
511572
Modified file: src/engine/mod.rs+163 additions−58 deletions
@@ -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 worker
212+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 store
227+ 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 yet
227233 uploads: HashMap<OwnedRoomId, (AbortHandle, watch::Receiver<bool>)>,
234+ /// the client can outlive us, so the handler has to come off it when we stop
235+ 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 depth
311+ /// in every crate that spawns it, which isn't something a client should have to fix
312+ 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 = self
303319 .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 happen
381+ 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 = client
489+ .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 stopped
439504 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.uploads
557- .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- ready
568- }
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 ticks
572623 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.uploads
634+ .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.uploads
644+ .insert(room.room_id().to_owned(), (forwarder, ready.clone()));
645+ ready
646+ }
647+
648+ /// the sdk resumes uploads left over from last time, and they deserve progress too
649+ 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 enough
628- Ok(()) | Err(mpsc::error::TrySendError::Closed(_)) => {}
706+ Ok(()) => {}
707+ // the actor already left on its own, which is closed enough
708+ 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 &note {
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 self
690775 .rooms
691776 .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 first
796+ 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 = self
709814 .rooms
710815 .iter()
711816 .filter(|(_, slot)| matches!(slot, RoomSlot::Active { .. }))
712- .map(|(room_id, _)| room_id.as_ref())
713- .collect::<Vec<&RoomId>>();
714- self.sync
715- .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();
Modified file: src/engine/mutations.rs+55 additions−14 deletions
@@ -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 there
109+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 round
150+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 key
142165 });
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 .await
247274 .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 => client
276+ 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_dms
281+ .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 = client
252289 .create_dm(&user_id)
253290 .await
254- .map(joined)
255- .map_err(sdk("creating direct room failed")),
256- },
291+ .map_err(sdk("creating direct room failed"))?;
292+ fresh_dms
293+ .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 }
Modified file: src/engine/room.rs+52 additions−13 deletions
@@ -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 replay
41+ 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 them
95115 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 stopped
106126 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 again
129+ 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+ } = &command
239+ && !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);
Modified file: src/engine/timeline.rs+11 additions−1 deletions
@@ -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 it
59+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 it
5968 #[derive(Clone)]
6069 pub(super) struct SessionContext {
@@ -145,11 +154,12 @@ impl TimelineSession {
145154
146155 /// pending paginations answer `Interrupted` as their responders drop
147156 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 gone
162+ self.context.media.release(&self.scope).await;
153163 }
154164
155165 pub(super) async fn execute(
Modified file: src/engine/watchers.rs+94 additions−44 deletions
@@ -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 /versions
78+ // but refuses sync would have us bounce between offline and online forever
79+ let stable = online_since
80+ .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 hear
87+ 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 it
6691 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 to
162+ Err(RecvError::Lagged(_)) => continue,
163+ Err(RecvError::Closed) => return,
164+ };
165+ let RoomSendQueueUpdate::MediaUpload {
166+ related_to,
167+ file,
168+ progress,
169+ ..
170+ } = update
171+ 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 does
188+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 = room
160- .send_queue()
161- .subscribe()
162- .await
163- .is_ok_and(|(echoes, _)| {
164- echoes.iter().any(|echo| {
165- echo.transaction_id == transaction_id
166- && 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.update
207+ else {
208+ continue;
209+ };
210+ let Some(room) = client.get_room(&update.room_id) else {
211+ continue;
212+ };
213+ let is_attachment = room
214+ .send_queue()
215+ .subscribe()
216+ .await
217+ .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;
Modified file: src/model.rs+49 additions−5 deletions
@@ -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 a
680- /// hard `SessionAccessRejected` came first. commands still in flight get no result
681+ /// 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 above
706+ 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 message
741+/// enough for a room list preview line. `kind` and `body` are only set for messages,
742+/// and an edit shows its new text
739743 #[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 cleared
1322+ /// `None` means it was cleared, or the event was redacted
13121323 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 you
1432+ /// `retry_send` or `cancel_send`, so that's when to show a retry button
1433+ 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 resize
1485+ 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 down
1634+ Incoming {
1635+ user_id: OwnedUserId,
1636+ device_id: Option<OwnedDeviceId>,
1637+ },
15941638 Comparison {
15951639 emojis: Vec<(String, String)>,
15961640 decimals: (u16, u16, u16),
Modified file: src/normalise/media.rs+60 additions−78 deletions
@@ -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- content
86- .info
87- .as_ref()
88- .and_then(|info| info.size)
89- .map(u64::from),
90- content
91- .info
92- .as_ref()
93- .and_then(|info| info.width)
94- .map(u64::from),
95- content
96- .info
97- .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- content
107- .info
108- .as_ref()
109- .and_then(|info| info.size)
110- .map(u64::from),
111- content
112- .info
113- .as_ref()
114- .and_then(|info| info.width)
115- .map(u64::from),
116- content
117- .info
118- .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- content
128- .info
129- .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- content
141- .info
142- .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
Modified file: src/normalise/mod.rs+1 additions−1 deletions
@@ -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};
Modified file: src/normalise/room.rs+41 additions−32 deletions
@@ -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 ))) => message
137149 .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 to
167+ 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 {
Modified file: src/normalise/timeline.rs+19 additions−10 deletions
@@ -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 string
247+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 do
384393 Some(EventSendState::SendingFailed { is_recoverable, .. }) => SendState::Failed {
385- retriable: *is_recoverable,
394+ will_retry: *is_recoverable,
386395 },
387396 }
388397 }
Modified file: src/transport.rs+12 additions
@@ -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 every
723725 // 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: levels
764+ .rules
765+ .privileged_creators
766+ .into_iter()
767+ .flatten()
768+ .collect(),
761769 })
762770 }
763771
@@ -974,6 +982,10 @@ impl DriverPort {
974982 self.requests.recv().await
975983 }
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 }
Modified file: src/upload.rs+5 additions−1 deletions
@@ -127,7 +127,11 @@ pub(crate) fn prepare_upload(
127127 || thumbnail.height == 0
128128 {
129129 return Err(Error::new(
130- ErrorKind::MediaTooLarge,
130+ if thumbnail.bytes.len() > max_media_bytes {
131+ ErrorKind::MediaTooLarge
132+ } else {
133+ ErrorKind::Invalid
134+ },
131135 "thumbnail size or dimensions are invalid",
132136 ));
133137 }
Modified file: tests/media.rs+59 additions
@@ -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+ harness
128+ .server
129+ .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 = harness
154+ .handle
155+ .media(&media_id, MediaSize::Full)
156+ .await
157+ .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"))