matrix_sdk_ui/room_list_service/mod.rs
1// Copyright 2023 The Matrix.org Foundation C.I.C.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for that specific language governing permissions and
13// limitations under the License.
14
15//! `RoomListService` API.
16//!
17//! The `RoomListService` is a UI API dedicated to present a list of Matrix
18//! rooms to the user. The syncing is handled by [`SlidingSync`]. The idea is to
19//! expose a simple API to handle most of the client app use cases, like:
20//! Showing and updating a list of rooms, filtering a list of rooms, handling
21//! particular updates of a range of rooms (the ones the client app is showing
22//! to the view, i.e. the rooms present in the viewport) etc.
23//!
24//! As such, the `RoomListService` works as an opinionated state machine. The
25//! states are defined by [`State`]. Actions are attached to the each state
26//! transition.
27//!
28//! The API is purposely small. Sliding Sync is versatile. `RoomListService` is
29//! _one_ specific usage of Sliding Sync.
30//!
31//! # Basic principle
32//!
33//! `RoomListService` works with 1 Sliding Sync List:
34//!
35//! * `all_rooms` (referred by the constant [`ALL_ROOMS_LIST_NAME`]) is the only
36//! list. Its goal is to load all the user' rooms. It starts with a
37//! [`SlidingSyncMode::Selective`] sync-mode with a small range (i.e. a small
38//! set of rooms) to load the first rooms quickly, and then updates to a
39//! [`SlidingSyncMode::Growing`] sync-mode to load the remaining rooms “in the
40//! background”: it will sync the existing rooms and will fetch new rooms, by
41//! a certain batch size.
42//!
43//! This behavior has proven to be empirically satisfying to provide a fast and
44//! fluid user experience for a Matrix client.
45//!
46//! [`RoomListService::all_rooms`] provides a way to get a [`RoomList`] for all
47//! the rooms. From that, calling [`RoomList::entries_with_dynamic_adapters`]
48//! provides a way to get a stream of rooms. This stream is sorted, can be
49//! filtered, and the filter can be changed over time.
50//!
51//! [`RoomListService::state`] provides a way to get a stream of the state
52//! machine's state, which can be pretty helpful for the client app.
53
54pub mod filters;
55mod room_list;
56pub mod sorters;
57mod state;
58
59use std::{sync::Arc, time::Duration};
60
61use async_stream::stream;
62use eyeball::Subscriber;
63use futures_util::{Stream, StreamExt, pin_mut};
64use matrix_sdk::{
65 Client, Error as SlidingSyncError, Room, SlidingSync, SlidingSyncList, SlidingSyncMode,
66 config::RequestConfig, event_cache::EventCacheError, timeout::timeout,
67};
68pub use room_list::*;
69use ruma::{
70 OwnedRoomId, RoomId, UInt,
71 api::{FeatureFlag, client::sync::sync_events::v5 as http},
72 assign,
73 events::StateEventType,
74};
75pub use state::*;
76use thiserror::Error;
77use tracing::{debug, error, warn};
78
79/// The default `required_state` constant value for sliding sync lists and
80/// sliding sync room subscriptions.
81const DEFAULT_REQUIRED_STATE: &[(StateEventType, &str)] = &[
82 (StateEventType::RoomName, ""),
83 (StateEventType::RoomEncryption, ""),
84 (StateEventType::RoomMember, "$LAZY"),
85 (StateEventType::RoomMember, "$ME"),
86 (StateEventType::RoomTopic, ""),
87 // Temporary workaround for https://github.com/matrix-org/matrix-rust-sdk/issues/5285
88 (StateEventType::RoomAvatar, ""),
89 (StateEventType::RoomCanonicalAlias, ""),
90 (StateEventType::RoomPowerLevels, ""),
91 (StateEventType::CallMember, "*"),
92 (StateEventType::RoomJoinRules, ""),
93 (StateEventType::RoomTombstone, ""),
94 // Those two events are required to properly compute room previews.
95 // `StateEventType::RoomCreate` is also necessary to compute the room
96 // version, and thus handling the tombstoned room correctly.
97 (StateEventType::RoomCreate, ""),
98 (StateEventType::RoomHistoryVisibility, ""),
99 // Required to correctly calculate the room display name.
100 (StateEventType::MemberHints, ""),
101 (StateEventType::SpaceParent, "*"),
102 (StateEventType::SpaceChild, "*"),
103];
104
105/// The default `required_state` constant value for sliding sync room
106/// subscriptions that must be added to `DEFAULT_REQUIRED_STATE`.
107const DEFAULT_ROOM_SUBSCRIPTION_EXTRA_REQUIRED_STATE: &[(StateEventType, &str)] =
108 &[(StateEventType::RoomPinnedEvents, "")];
109
110/// The default `timeline_limit` value when used with room subscriptions.
111const DEFAULT_ROOM_SUBSCRIPTION_TIMELINE_LIMIT: u32 = 20;
112
113/// The [`RoomListService`] type. See the module's documentation to learn more.
114#[derive(Debug)]
115pub struct RoomListService {
116 /// Client that has created this [`RoomListService`].
117 client: Client,
118
119 /// The Sliding Sync instance.
120 sliding_sync: Arc<SlidingSync>,
121
122 /// The current state of the `RoomListService`.
123 ///
124 /// `RoomListService` is a simple state-machine.
125 state_machine: StateMachine,
126}
127
128impl RoomListService {
129 /// Create a new `RoomList`.
130 ///
131 /// A [`matrix_sdk::SlidingSync`] client will be created, with a cached list
132 /// already pre-configured.
133 ///
134 /// This won't start an encryption sync, and it's the user's responsibility
135 /// to create one in this case using
136 /// [`EncryptionSyncService`][crate::encryption_sync_service::EncryptionSyncService].
137 pub async fn new(client: Client) -> Result<Self, Error> {
138 Self::new_with_share_pos(client, true).await
139 }
140
141 /// Like [`RoomListService::new`] but with a flag to turn the
142 /// [`SlidingSyncBuilder::share_pos`] on and off.
143 ///
144 /// [`SlidingSyncBuilder::share_pos`]: matrix_sdk::sliding_sync::SlidingSyncBuilder::share_pos
145 pub async fn new_with_share_pos(client: Client, share_pos: bool) -> Result<Self, Error> {
146 let mut builder = client
147 .sliding_sync("room-list")
148 .map_err(Error::SlidingSync)?
149 .with_account_data_extension(
150 assign!(http::request::AccountData::default(), { enabled: Some(true) }),
151 )
152 .with_receipt_extension(assign!(http::request::Receipts::default(), {
153 enabled: Some(true),
154 rooms: Some(vec![http::request::ExtensionRoomConfig::AllSubscribed])
155 }))
156 .with_typing_extension(assign!(http::request::Typing::default(), {
157 enabled: Some(true),
158 }));
159
160 if client.enabled_thread_subscriptions() {
161 let server_features = if let Some(cached) = client
162 .supported_versions_cached()
163 .await
164 .map_err(|e| Error::SlidingSync(e.into()))?
165 {
166 cached.features
167 } else {
168 // Our `/versions` calls don't support token refresh as of now (11.11.2025), so
169 // we're going to skip the sending of the authentication headers in case they
170 // might have expired.
171 //
172 // We only care about a feature which is advertised without being authenticaded
173 // anyways.
174 client
175 .fetch_server_versions(Some(RequestConfig::new().skip_auth()))
176 .await
177 .map_err(|e| Error::SlidingSync(e.into()))?
178 .as_supported_versions()
179 .features
180 };
181
182 if !server_features.contains(&FeatureFlag::from("org.matrix.msc4306")) {
183 warn!(
184 "Thread subscriptions extension is requested on the client, but the server doesn't advertise support for it: not enabling."
185 );
186 } else {
187 debug!("Enabling the thread subscriptions extension");
188 builder = builder.with_thread_subscriptions_extension(
189 assign!(http::request::ThreadSubscriptions::default(), {
190 enabled: Some(true),
191 limit: Some(ruma::uint!(10))
192 }),
193 );
194 }
195 }
196
197 if share_pos {
198 // The e2ee extensions aren't enabled in this sliding sync instance, and this is
199 // the only one that could be used from a different process. So it's
200 // fine to enable position sharing (i.e. reloading it from disk),
201 // since it's always exclusively owned by the current process.
202 debug!("Enabling `share_pos` for the room list sliding sync");
203 builder = builder.share_pos();
204 }
205
206 let state_machine = StateMachine::new();
207 let observable_state = state_machine.cloned_state();
208
209 let sliding_sync = builder
210 .add_cached_list(
211 SlidingSyncList::builder(ALL_ROOMS_LIST_NAME)
212 .sync_mode(
213 SlidingSyncMode::new_selective()
214 .add_range(ALL_ROOMS_DEFAULT_SELECTIVE_RANGE),
215 )
216 .timeline_limit(1)
217 .required_state(
218 DEFAULT_REQUIRED_STATE
219 .iter()
220 .map(|(state_event, value)| (state_event.clone(), (*value).to_owned()))
221 .collect(),
222 )
223 .filters(Some(assign!(http::request::ListFilters::default(), {
224 // As defined in the [SlidingSync MSC](https://github.com/matrix-org/matrix-spec-proposals/blob/9450ced7fb9cf5ea9077d029b3adf36aebfa8709/proposals/3575-sync.md?plain=1#L444)
225 // If unset, both invited and joined rooms are returned. If false, no invited rooms are
226 // returned. If true, only invited rooms are returned.
227 is_invite: None,
228 })))
229 .requires_timeout(move |request_generator| {
230 // We want Sliding Sync to apply the poll + network timeout —i.e. to do the
231 // long-polling— in some particular cases. Let's define them.
232 match observable_state.get() {
233 // These are the states where we want an immediate response from the
234 // server, with no long-polling.
235 State::Init
236 | State::SettingUp
237 | State::Recovering
238 | State::Error { .. }
239 | State::Terminated { .. } => false,
240
241 // Otherwise we want long-polling if the list is fully-loaded.
242 State::Running => request_generator.is_fully_loaded(),
243 }
244 }),
245 )
246 .await
247 .map_err(Error::SlidingSync)?
248 .build()
249 .await
250 .map(Arc::new)
251 .map_err(Error::SlidingSync)?;
252
253 // Eagerly subscribe the event cache to sync responses.
254 client.event_cache().subscribe()?;
255
256 Ok(Self { client, sliding_sync, state_machine })
257 }
258
259 /// Start to sync the room list.
260 ///
261 /// It's the main method of this entire API. Calling `sync` allows to
262 /// receive updates on the room list: new rooms, rooms updates etc. Those
263 /// updates can be read with `RoomList::entries` for example. This method
264 /// returns a [`Stream`] where produced items only hold an empty value
265 /// in case of a sync success, otherwise an error.
266 ///
267 /// The `RoomListService`' state machine is run by this method.
268 ///
269 /// Stopping the [`Stream`] (i.e. by calling [`Self::stop_sync`]), and
270 /// calling [`Self::sync`] again will resume from the previous state of
271 /// the state machine.
272 ///
273 /// This should be used only for testing. In practice, most users should be
274 /// using the [`SyncService`](crate::sync_service::SyncService) instead.
275 #[doc(hidden)]
276 pub fn sync(&self) -> impl Stream<Item = Result<(), Error>> + '_ {
277 stream! {
278 let sync = self.sliding_sync.sync();
279 pin_mut!(sync);
280
281 // This is a state machine implementation.
282 // Things happen in this order:
283 //
284 // 1. The next state is calculated,
285 // 2. The actions associated to the next state are run,
286 // 3. A sync is done,
287 // 4. The next state is stored.
288 loop {
289 debug!("Run a sync iteration");
290
291 // Calculate the next state, and run the associated actions.
292 let next_state = self.state_machine.next(&self.sliding_sync).await?;
293
294 // Do the sync.
295 match sync.next().await {
296 // Got a successful result while syncing.
297 Some(Ok(_update_summary)) => {
298 debug!(state = ?next_state, "New state");
299
300 // Update the state.
301 self.state_machine.set(next_state);
302
303 yield Ok(());
304 }
305
306 // Got an error while syncing.
307 Some(Err(error)) => {
308 debug!(expected_state = ?next_state, "New state is an error");
309
310 let next_state = State::Error { from: Box::new(next_state) };
311 self.state_machine.set(next_state);
312
313 yield Err(Error::SlidingSync(error));
314
315 break;
316 }
317
318 // Sync loop has terminated.
319 None => {
320 debug!(expected_state = ?next_state, "New state is a termination");
321
322 let next_state = State::Terminated { from: Box::new(next_state) };
323 self.state_machine.set(next_state);
324
325 break;
326 }
327 }
328 }
329 }
330 }
331
332 /// Force to stop the sync of the `RoomListService` started by
333 /// [`Self::sync`].
334 ///
335 /// It's of utter importance to call this method rather than stop polling
336 /// the `Stream` returned by [`Self::sync`] because it will force the
337 /// cancellation and exit the sync loop, i.e. it will cancel any
338 /// in-flight HTTP requests, cancel any pending futures etc. and put the
339 /// service into a termination state.
340 ///
341 /// Ideally, one wants to consume the `Stream` returned by [`Self::sync`]
342 /// until it returns `None`, because of [`Self::stop_sync`], so that it
343 /// ensures the states are correctly placed.
344 ///
345 /// Stopping the sync of the room list via this method will put the
346 /// state-machine into the [`State::Terminated`] state.
347 ///
348 /// This should be used only for testing. In practice, most users should be
349 /// using the [`SyncService`](crate::sync_service::SyncService) instead.
350 #[doc(hidden)]
351 pub fn stop_sync(&self) -> Result<(), Error> {
352 self.sliding_sync.stop_sync().map_err(Error::SlidingSync)
353 }
354
355 /// Force the sliding sync session to expire.
356 ///
357 /// This is used by [`SyncService`](crate::sync_service::SyncService).
358 ///
359 /// **Warning**: This method **must not** be called while the sync loop is
360 /// running!
361 pub(crate) async fn expire_sync_session(&self) {
362 self.sliding_sync.expire_session().await;
363
364 // Usually, when the session expires, it leads the state to be `Error`,
365 // thus some actions (like refreshing the lists) are executed. However,
366 // if the sync loop has been stopped manually, the state is `Terminated`, and
367 // when the session is forced to expire, the state remains `Terminated`, thus
368 // the actions aren't executed as expected. Consequently, let's update the
369 // state.
370 if let State::Terminated { from } = self.state_machine.get() {
371 self.state_machine.set(State::Error { from });
372 }
373 }
374
375 /// Get a [`Stream`] of [`SyncIndicator`].
376 ///
377 /// Read the documentation of [`SyncIndicator`] to learn more about it.
378 pub fn sync_indicator(
379 &self,
380 delay_before_showing: Duration,
381 delay_before_hiding: Duration,
382 ) -> impl Stream<Item = SyncIndicator> + use<> {
383 let mut state = self.state();
384
385 stream! {
386 // Ensure the `SyncIndicator` is always hidden to start with.
387 yield SyncIndicator::Hide;
388
389 // Let's not wait for an update to happen. The `SyncIndicator` must be
390 // computed as fast as possible.
391 let mut current_state = state.next_now();
392
393 loop {
394 let (sync_indicator, yield_delay) = match current_state {
395 State::Init | State::SettingUp | State::Error { .. } => {
396 (SyncIndicator::Show, delay_before_showing)
397 }
398
399 State::Recovering | State::Running | State::Terminated { .. } => {
400 (SyncIndicator::Hide, delay_before_hiding)
401 }
402 };
403
404 // `state.next().await` has a maximum of `yield_delay` time to execute…
405 let next_state = match timeout(state.next(), yield_delay).await {
406 // A new state has been received before `yield_delay` time. The new
407 // `sync_indicator` value won't be yielded.
408 Ok(next_state) => next_state,
409
410 // No new state has been received before `yield_delay` time. The
411 // `sync_indicator` value can be yielded.
412 Err(_) => {
413 yield sync_indicator;
414
415 // Now that `sync_indicator` has been yielded, let's wait on
416 // the next state again.
417 state.next().await
418 }
419 };
420
421 if let Some(next_state) = next_state {
422 // Update the `current_state`.
423 current_state = next_state;
424 } else {
425 // Something is broken with the state. Let's stop this stream too.
426 break;
427 }
428 }
429 }
430 }
431
432 /// Get the [`Client`] that has been used to create [`Self`].
433 pub fn client(&self) -> &Client {
434 &self.client
435 }
436
437 /// Get a subscriber to the state.
438 pub fn state(&self) -> Subscriber<State> {
439 self.state_machine.subscribe()
440 }
441
442 async fn list_for(&self, sliding_sync_list_name: &str) -> Result<RoomList, Error> {
443 RoomList::new(&self.client, &self.sliding_sync, sliding_sync_list_name, self.state()).await
444 }
445
446 /// Get a [`RoomList`] for all rooms.
447 pub async fn all_rooms(&self) -> Result<RoomList, Error> {
448 self.list_for(ALL_ROOMS_LIST_NAME).await
449 }
450
451 /// Get a [`Room`] if it exists.
452 pub fn room(&self, room_id: &RoomId) -> Result<Room, Error> {
453 self.client.get_room(room_id).ok_or_else(|| Error::RoomNotFound(room_id.to_owned()))
454 }
455
456 /// Subscribe to rooms.
457 ///
458 /// It means that all events from these rooms will be received every time,
459 /// no matter how the `RoomList` is configured.
460 ///
461 /// [`LatestEvents::listen_to_room`][listen_to_room] will be called for each
462 /// room in `room_ids`, so that the [`LatestEventValue`] will automatically
463 /// be calculated and updated for these rooms, for free.
464 ///
465 /// [listen_to_room]: matrix_sdk::latest_events::LatestEvents::listen_to_room
466 /// [`LatestEventValue`]: matrix_sdk::latest_events::LatestEventValue
467 pub async fn subscribe_to_rooms(&self, room_ids: &[&RoomId]) {
468 // Calculate the settings for the room subscriptions.
469 let settings = assign!(http::request::RoomSubscription::default(), {
470 required_state: DEFAULT_REQUIRED_STATE.iter().map(|(state_event, value)| {
471 (state_event.clone(), (*value).to_owned())
472 })
473 .chain(
474 DEFAULT_ROOM_SUBSCRIPTION_EXTRA_REQUIRED_STATE.iter().map(|(state_event, value)| {
475 (state_event.clone(), (*value).to_owned())
476 })
477 )
478 .collect(),
479 timeline_limit: UInt::from(DEFAULT_ROOM_SUBSCRIPTION_TIMELINE_LIMIT),
480 });
481
482 // Decide whether the in-flight request (if any) should be cancelled if needed.
483 let cancel_in_flight_request = match self.state_machine.get() {
484 State::Init | State::Recovering | State::Error { .. } | State::Terminated { .. } => {
485 false
486 }
487 State::SettingUp | State::Running => true,
488 };
489
490 // Before subscribing, let's listen these rooms to calculate their latest
491 // events.
492 if self.client.event_cache().has_subscribed() {
493 let latest_events = self.client.latest_events().await;
494
495 for room_id in room_ids {
496 if let Err(error) = latest_events.listen_to_room(room_id).await {
497 // Let's not fail the room subscription. Instead, emit a log because it's very
498 // unlikely to happen.
499 error!(?error, ?room_id, "Failed to listen to the latest event for this room");
500 }
501 }
502 }
503
504 // Subscribe to the rooms.
505 self.sliding_sync.subscribe_to_rooms(room_ids, Some(settings), cancel_in_flight_request)
506 }
507
508 #[cfg(test)]
509 pub fn sliding_sync(&self) -> &SlidingSync {
510 &self.sliding_sync
511 }
512}
513
514/// [`RoomList`]'s errors.
515#[derive(Debug, Error)]
516pub enum Error {
517 /// Error from [`matrix_sdk::SlidingSync`].
518 #[error(transparent)]
519 SlidingSync(SlidingSyncError),
520
521 /// An operation has been requested on an unknown list.
522 #[error("Unknown list `{0}`")]
523 UnknownList(String),
524
525 /// The requested room doesn't exist.
526 #[error("Room `{0}` not found")]
527 RoomNotFound(OwnedRoomId),
528
529 #[error(transparent)]
530 EventCache(#[from] EventCacheError),
531}
532
533/// An hint whether a _sync spinner/loader/toaster_ should be prompted to the
534/// user, indicating that the [`RoomListService`] is syncing.
535///
536/// This is entirely arbitrary and optinionated. Of course, once
537/// [`RoomListService::sync`] has been called, it's going to be constantly
538/// syncing, until [`RoomListService::stop_sync`] is called, or until an error
539/// happened. But in some cases, it's better for the user experience to prompt
540/// to the user that a sync is happening. It's usually the first sync, or the
541/// recovering sync. However, the sync indicator must be prompted if the
542/// aforementioned sync is “slow”, otherwise the indicator is likely to “blink”
543/// pretty fast, which can be very confusing. It's also common to indicate to
544/// the user that a syncing is happening in case of a network error, that
545/// something is catching up etc.
546#[derive(Debug, Eq, PartialEq)]
547pub enum SyncIndicator {
548 /// Show the sync indicator.
549 Show,
550
551 /// Hide the sync indicator.
552 Hide,
553}
554
555#[cfg(test)]
556mod tests {
557 use std::future::ready;
558
559 use futures_util::{StreamExt, pin_mut};
560 use matrix_sdk::{
561 Client, SlidingSyncMode, config::RequestConfig, test_utils::client::mock_matrix_session,
562 };
563 use matrix_sdk_test::async_test;
564 use ruma::api::MatrixVersion;
565 use serde_json::json;
566 use wiremock::{Match, Mock, MockServer, Request, ResponseTemplate, http::Method};
567
568 use super::{ALL_ROOMS_LIST_NAME, Error, RoomListService, State};
569
570 async fn new_client() -> (Client, MockServer) {
571 let session = mock_matrix_session();
572
573 let server = MockServer::start().await;
574 let client = Client::builder()
575 .homeserver_url(server.uri())
576 .server_versions([MatrixVersion::V1_0])
577 .request_config(RequestConfig::new().disable_retry())
578 .build()
579 .await
580 .unwrap();
581 client.restore_session(session).await.unwrap();
582
583 (client, server)
584 }
585
586 pub(super) async fn new_room_list() -> Result<RoomListService, Error> {
587 let (client, _) = new_client().await;
588
589 RoomListService::new(client).await
590 }
591
592 struct SlidingSyncMatcher;
593
594 impl Match for SlidingSyncMatcher {
595 fn matches(&self, request: &Request) -> bool {
596 request.url.path() == "/_matrix/client/unstable/org.matrix.simplified_msc3575/sync"
597 && request.method == Method::POST
598 }
599 }
600
601 #[async_test]
602 async fn test_all_rooms_are_declared() -> Result<(), Error> {
603 let room_list = new_room_list().await?;
604 let sliding_sync = room_list.sliding_sync();
605
606 // List is present, in Selective mode.
607 assert_eq!(
608 sliding_sync
609 .on_list(ALL_ROOMS_LIST_NAME, |list| ready(matches!(
610 list.sync_mode(),
611 SlidingSyncMode::Selective { ranges } if ranges == vec![0..=19]
612 )))
613 .await,
614 Some(true)
615 );
616
617 Ok(())
618 }
619
620 #[async_test]
621 async fn test_expire_sliding_sync_session_manually() -> Result<(), Error> {
622 let (client, server) = new_client().await;
623
624 let room_list = RoomListService::new(client).await?;
625
626 let sync = room_list.sync();
627 pin_mut!(sync);
628
629 // Run a first sync.
630 {
631 let _mock_guard = Mock::given(SlidingSyncMatcher)
632 .respond_with(move |_request: &Request| {
633 ResponseTemplate::new(200).set_body_json(json!({
634 "pos": "0",
635 "lists": {
636 ALL_ROOMS_LIST_NAME: {
637 "count": 0,
638 "ops": [],
639 },
640 },
641 "rooms": {},
642 }))
643 })
644 .mount_as_scoped(&server)
645 .await;
646
647 let _ = sync.next().await;
648 }
649
650 assert_eq!(room_list.state().get(), State::SettingUp);
651
652 // Stop the sync.
653 room_list.stop_sync()?;
654
655 // Do another sync.
656 let _ = sync.next().await;
657
658 // State is `Terminated`, as expected!
659 assert_eq!(
660 room_list.state_machine.get(),
661 State::Terminated { from: Box::new(State::Running) }
662 );
663
664 // Now, let's make the sliding sync session to expire.
665 room_list.expire_sync_session().await;
666
667 // State is `Error`, as a regular session expiration would generate!
668 assert_eq!(room_list.state_machine.get(), State::Error { from: Box::new(State::Running) });
669
670 Ok(())
671 }
672}