1use std::{
2 collections::{BTreeMap, HashMap, HashSet},
3 time::Duration,
4};
5
6use axum::extract::State;
7use futures::{
8 FutureExt, StreamExt, TryFutureExt, TryStreamExt,
9 future::{join, join3, join4, join5},
10 pin_mut,
11};
12use ruma::{
13 DeviceId, EventId, OwnedEventId, OwnedRoomId, OwnedUserId, RoomId, UInt, UserId,
14 api::client::{
15 filter::FilterDefinition,
16 sync::sync_events::{
17 self, DeviceLists, UnreadNotificationsCount,
18 v3::{
19 Ephemeral, Filter, GlobalAccountData, InviteState, InvitedRoom, JoinedRoom,
20 KnockState, KnockedRoom, LeftRoom, Presence, RoomAccountData, RoomSummary, Rooms,
21 State as RoomState, StateEvents, Timeline, ToDevice,
22 },
23 },
24 },
25 events::{
26 AnyGlobalAccountDataEvent, AnyRawAccountDataEvent, AnyRoomAccountDataEvent,
27 AnySyncEphemeralRoomEvent, AnySyncStateEvent, StateEventType, SyncEphemeralRoomEvent,
28 TimelineEventType::*,
29 presence::{PresenceEvent, PresenceEventContent},
30 room::member::{MembershipState, RoomMemberEventContent},
31 typing::TypingEventContent,
32 },
33 serde::Raw,
34 uint,
35};
36use tokio::time;
37use tuwunel_core::{
38 Result, at,
39 debug::INFO_SPAN_LEVEL,
40 debug_error, err,
41 error::{inspect_debug_log, inspect_log},
42 extract_variant, is_equal_to, is_false, is_true,
43 matrix::{
44 Event,
45 event::{Matches, trim_event_fields},
46 pdu::{EventHash, PduCount, PduEvent},
47 },
48 pair_of, ref_at,
49 result::FlatOk,
50 smallvec::SmallVec,
51 trace,
52 utils::{
53 self, BoolExt, FutureBoolExt, IterStream, ReadyExt, TryFutureExtExt,
54 future::{OptionStream, ReadyBoolExt},
55 math::ruma_from_u64,
56 option::OptionExt,
57 result::MapExpect,
58 stream::{BroadbandExt, Tools, TryBroadbandExt, TryReadyExt, WidebandExt},
59 },
60 warn,
61};
62use tuwunel_service::{
63 Services,
64 presence::Ping,
65 rooms::{
66 lazy_loading,
67 lazy_loading::{Options, Witness},
68 read_receipt::PrivateReadEvents,
69 short::{ShortEventId, ShortStateHash, ShortStateKey},
70 },
71};
72
73use super::{load_timeline, share_encrypted_room, strip_prev_state};
74use crate::{
75 ClientIp, Ruma,
76 client::{ignored_filter, is_empty_account_data_event, with_membership},
77};
78
79#[derive(Default)]
80struct StateChanges {
81 heroes: Option<Vec<OwnedUserId>>,
82 joined_member_count: Option<u64>,
83 invited_member_count: Option<u64>,
84 state_events: Vec<PduEvent>,
85}
86
87struct StateChangeParams<'a> {
88 full_state: bool,
89 state_after: StateAfter,
90 since_shortstatehash: Option<ShortStateHash>,
91 horizon_shortstatehash: Option<ShortStateHash>,
92 after_shortstatehash: Option<ShortStateHash>,
93 current_shortstatehash: ShortStateHash,
94 joined_since_last_sync: bool,
95 witness: Option<&'a Witness>,
96 include_heroes: bool,
97}
98
99struct RoomMetadata {
100 since_shortstatehash: Option<ShortStateHash>,
101 horizon_shortstatehash: Option<ShortStateHash>,
102 after_shortstatehash: Option<ShortStateHash>,
103 current_shortstatehash: Option<ShortStateHash>,
104 receipt_events: Vec<(OwnedUserId, Raw<AnySyncEphemeralRoomEvent>)>,
105 encrypted_room: Option<bool>,
106}
107
108struct UserMetadata {
109 witness: Option<Witness>,
110 #[expect(clippy::option_option)]
111 last_notification_read: Option<Option<u64>>,
112 thread_last_reads: Option<BTreeMap<OwnedEventId, u64>>,
113 last_privateread_update: u64,
114 joined_since_last_sync: bool,
115}
116
117struct NotificationGates<F> {
118 send_notification_counts: bool,
119 send_notification_count_filter: F,
120}
121
122struct BuildJoinedRoom {
123 receipt_events: Vec<(OwnedUserId, Raw<AnySyncEphemeralRoomEvent>)>,
124 typing_events: Vec<Raw<AnySyncEphemeralRoomEvent>>,
125 private_read_events: Option<PrivateReadEvents>,
126 state_events: Vec<Raw<AnySyncStateEvent>>,
127 account_data_events: Vec<Raw<AnyRoomAccountDataEvent>>,
128 room_events: Vec<PduEvent>,
129 heroes: Option<Vec<OwnedUserId>>,
130 joined_member_count: Option<u64>,
131 invited_member_count: Option<u64>,
132 unread_notifications: UnreadNotificationsCount,
133 unread_thread_notifications: BTreeMap<OwnedEventId, UnreadNotificationsCount>,
134 state_after: StateAfter,
135 limited: bool,
136 joined_since_last_sync: bool,
137 prev_batch: Option<PduCount>,
138}
139
140#[derive(Clone, Copy, Debug)]
142enum StateAfter {
143 Off,
144 Stable,
145 Unstable,
146}
147
148type PresenceUpdates = HashMap<OwnedUserId, PresenceEventContent>;
149type TimelineEventIds = SmallVec<[OwnedEventId; 1]>;
150
151impl StateAfter {
152 fn requested(self) -> bool { !matches!(self, Self::Off) }
153
154 fn wrap(self, events: StateEvents) -> RoomState {
155 match self {
156 | Self::Off => RoomState::Before(events),
157 | Self::Stable => RoomState::After(events),
158 | Self::Unstable => RoomState::AfterUnstable(events),
159 }
160 }
161}
162
163impl From<(bool, bool)> for StateAfter {
164 fn from((stable, unstable): (bool, bool)) -> Self {
165 match (stable, unstable) {
167 | (_, true) => Self::Unstable,
168 | (true, _) => Self::Stable,
169 | _ => Self::Off,
170 }
171 }
172}
173
174#[tracing::instrument(
210 name = "sync",
211 level = "debug",
212 skip_all,
213 fields(
214 user_id = %body.sender_user(),
215 device_id = %body.sender_device.as_deref().map_or("<no device>", |x| x.as_str()),
216 )
217)]
218pub(crate) async fn sync_events_route(
219 State(services): State<crate::State>,
220 ClientIp(client): ClientIp,
221 body: Ruma<sync_events::v3::Request>,
222) -> Result<sync_events::v3::Response> {
223 let sender_user = body.sender_user();
224 let sender_device = body.sender_device.as_deref();
225
226 let filter = body
227 .body
228 .filter
229 .as_ref()
230 .map_async(async |filter| match filter {
231 | Filter::FilterDefinition(filter) => filter.clone(),
232 | Filter::FilterId(filter_id) => services
233 .users
234 .get_filter(sender_user, filter_id)
235 .await
236 .unwrap_or_default(),
237 });
238
239 let filter = filter.map(Option::unwrap_or_default);
240 let full_state = body.body.full_state;
241 let set_presence = &body.body.set_presence;
242 let state_after =
243 StateAfter::from((body.body.use_state_after, body.body.use_state_after_unstable));
244
245 let ping = Ping {
246 device_id: body.sender_device.as_deref(),
247 client_ip: Some(client),
248 new_state: Some(set_presence),
249 appservice: body.appservice_info.as_ref(),
250 };
251
252 let ping_presence = services
253 .presence
254 .maybe_ping_presence(sender_user, ping)
255 .inspect_err(inspect_log)
256 .ok();
257
258 let note_sync = services
260 .presence
261 .note_sync(sender_user, body.appservice_info.as_ref());
262
263 let (filter, ..) = join3(filter, ping_presence, note_sync).await;
264
265 let mut since = body
266 .body
267 .since
268 .as_deref()
269 .map(str::parse)
270 .flat_ok()
271 .unwrap_or(0);
272
273 let timeout = body
274 .body
275 .timeout
276 .as_ref()
277 .map(Duration::as_millis)
278 .map(TryInto::try_into)
279 .flat_ok()
280 .unwrap_or(services.config.client_sync_timeout_default)
281 .max(services.config.client_sync_timeout_min)
282 .min(services.config.client_sync_timeout_max);
283
284 let stop_at = time::Instant::now()
285 .checked_add(Duration::from_millis(timeout))
286 .expect("configuration must limit maximum timeout");
287
288 loop {
289 let watch_rooms = services
290 .state_cache
291 .rooms_joined(sender_user)
292 .chain(services.state_cache.rooms_invited(sender_user));
293
294 let watchers = services
295 .sync
296 .watch(sender_user, sender_device, watch_rooms)
297 .await;
298
299 let next_batch = services.globals.wait_pending().await?;
300 if since > next_batch {
301 debug_error!(since, next_batch, "received since > next_batch, clamping");
302 since = next_batch;
303 }
304
305 if since < next_batch || full_state {
306 let response = build_sync_events(
307 &services,
308 sender_user,
309 sender_device,
310 since,
311 next_batch,
312 full_state,
313 state_after,
314 &filter,
315 )
316 .await?;
317
318 let empty = response.rooms.is_empty()
319 && response.presence.is_empty()
320 && response.account_data.is_empty()
321 && response.device_lists.is_empty()
322 && response.to_device.is_empty();
323
324 if !empty || full_state {
325 return Ok(response);
326 }
327 }
328
329 if time::timeout_at(stop_at, watchers).await.is_err() || services.server.is_stopping() {
331 let response =
332 build_empty_response(&services, sender_user, sender_device, next_batch).await;
333
334 trace!(since, next_batch, "empty response");
335 return Ok(response);
336 }
337
338 trace!(
339 since,
340 last_batch = ?next_batch,
341 count = ?services.globals.pending_count(),
342 stop_at = ?stop_at,
343 "notified by watcher"
344 );
345
346 since = next_batch;
347 }
348}
349
350async fn build_empty_response(
351 services: &Services,
352 sender_user: &UserId,
353 sender_device: Option<&DeviceId>,
354 next_batch: u64,
355) -> sync_events::v3::Response {
356 let device_one_time_keys_count = sender_device.map_async(|sender_device| {
357 services
358 .users
359 .count_one_time_keys(sender_user, sender_device)
360 });
361
362 let device_unused_fallback_key_types = sender_device.map_async(|sender_device| {
363 services
364 .users
365 .unused_fallback_key_algorithms(sender_user, sender_device)
366 .collect::<Vec<_>>()
367 });
368
369 let (device_one_time_keys_count, device_unused_fallback_key_types) =
370 join(device_one_time_keys_count, device_unused_fallback_key_types).await;
371
372 sync_events::v3::Response {
373 device_one_time_keys_count: device_one_time_keys_count.unwrap_or_default(),
374 device_unused_fallback_key_types,
375 ..sync_events::v3::Response::new(next_batch.to_string())
376 }
377}
378
379#[tracing::instrument(
380 name = "build",
381 level = INFO_SPAN_LEVEL,
382 skip_all,
383 fields(
384 %since,
385 %next_batch,
386 count = ?services.globals.pending_count(),
387 )
388)]
389#[expect(clippy::too_many_arguments)]
390async fn build_sync_events(
391 services: &Services,
392 sender_user: &UserId,
393 sender_device: Option<&DeviceId>,
394 since: u64,
395 next_batch: u64,
396 full_state: bool,
397 state_after: StateAfter,
398 filter: &FilterDefinition,
399) -> Result<sync_events::v3::Response> {
400 let invites_blocked = services.users.invites_blocked(sender_user).await;
403
404 let joined_rooms = collect_joined_rooms(
405 services,
406 sender_user,
407 sender_device,
408 since,
409 next_batch,
410 full_state,
411 state_after,
412 filter,
413 );
414
415 let left_rooms = collect_left_rooms(
416 services,
417 sender_user,
418 since,
419 next_batch,
420 full_state,
421 state_after,
422 filter,
423 );
424
425 let invited_rooms =
426 collect_invited_rooms(services, sender_user, since, next_batch, filter, invites_blocked);
427
428 let knocked_rooms = collect_knocked_rooms(services, sender_user, since, next_batch, filter);
429
430 let presence_updates = services
431 .config
432 .allow_local_presence
433 .then_async(|| {
434 process_presence_updates(services, since, next_batch, sender_user, filter)
435 });
436
437 let account_data = collect_global_account_data(services, sender_user, since, next_batch);
438
439 let keys_changed = services
440 .users
441 .keys_changed(sender_user, since, Some(next_batch))
442 .map(ToOwned::to_owned)
443 .collect::<HashSet<_>>();
444
445 let to_device_events = sender_device.map_async(|sender_device| {
446 services
447 .users
448 .get_to_device_events(sender_user, sender_device, Some(since), Some(next_batch))
449 .map(at!(1))
450 .collect::<Vec<_>>()
451 });
452
453 let device_one_time_keys_count = sender_device.map_async(|sender_device| {
454 services
455 .users
456 .count_one_time_keys(sender_user, sender_device)
457 });
458
459 let device_unused_fallback_key_types = sender_device.map_async(|sender_device| {
460 services
461 .users
462 .unused_fallback_key_algorithms(sender_user, sender_device)
463 .collect::<Vec<_>>()
464 });
465
466 let remove_to_device_events = sender_device.map_async(|sender_device| {
468 services
469 .users
470 .remove_to_device_events(sender_user, sender_device, since)
471 });
472
473 let (
474 account_data,
475 keys_changed,
476 presence_updates,
477 (_, to_device_events, device_one_time_keys_count, device_unused_fallback_key_types),
478 (
479 (joined_rooms, mut device_list_updates, left_encrypted_users),
480 left_rooms,
481 invited_rooms,
482 knocked_rooms,
483 ),
484 ) = join5(
485 account_data,
486 keys_changed,
487 presence_updates,
488 join4(
489 remove_to_device_events,
490 to_device_events,
491 device_one_time_keys_count,
492 device_unused_fallback_key_types,
493 ),
494 join4(joined_rooms, left_rooms, invited_rooms, knocked_rooms),
495 )
496 .boxed()
497 .await;
498
499 device_list_updates.extend(keys_changed);
500
501 let device_list_left =
502 collect_device_list_left(services, sender_user, left_encrypted_users).await;
503
504 let presence_events = build_presence_events(presence_updates);
505
506 Ok(sync_events::v3::Response {
507 account_data: GlobalAccountData { events: account_data },
508 device_lists: DeviceLists {
509 left: device_list_left,
510 changed: device_list_updates.into_iter().collect(),
511 },
512 device_one_time_keys_count: device_one_time_keys_count.unwrap_or_default(),
513 device_unused_fallback_key_types,
514 next_batch: next_batch.to_string(),
515 presence: Presence { events: presence_events },
516 rooms: Rooms {
517 leave: left_rooms,
518 join: joined_rooms,
519 invite: invited_rooms,
520 knock: knocked_rooms,
521 },
522 to_device: ToDevice {
523 events: to_device_events.unwrap_or_default(),
524 },
525 })
526}
527
528#[expect(clippy::too_many_arguments)]
529fn collect_joined_rooms<'a>(
530 services: &'a Services,
531 sender_user: &'a UserId,
532 sender_device: Option<&'a DeviceId>,
533 since: u64,
534 next_batch: u64,
535 full_state: bool,
536 state_after: StateAfter,
537 filter: &'a FilterDefinition,
538) -> impl Future<
539 Output = (BTreeMap<OwnedRoomId, JoinedRoom>, HashSet<OwnedUserId>, HashSet<OwnedUserId>),
540> + Send
541+ 'a {
542 services
543 .state_cache
544 .rooms_joined(sender_user)
545 .ready_filter(|&room_id| filter.room.matches(room_id))
546 .map(ToOwned::to_owned)
547 .broad_filter_map(move |room_id| {
548 load_joined_room(
549 services,
550 sender_user,
551 sender_device,
552 room_id.clone(),
553 since,
554 next_batch,
555 full_state,
556 state_after,
557 filter,
558 )
559 .map_ok(move |(joined_room, dlu, jeu)| (room_id, joined_room, dlu, jeu))
560 .ok()
561 })
562 .ready_fold(
563 (BTreeMap::new(), HashSet::new(), HashSet::new()),
564 |(mut joined_rooms, mut device_list_updates, mut left_encrypted_users),
565 (room_id, joined_room, dlu, leu)| {
566 device_list_updates.extend(dlu);
567 left_encrypted_users.extend(leu);
568 if !joined_room.is_empty() {
569 joined_rooms.insert(room_id, joined_room);
570 }
571
572 (joined_rooms, device_list_updates, left_encrypted_users)
573 },
574 )
575}
576
577fn collect_left_rooms<'a>(
578 services: &'a Services,
579 sender_user: &'a UserId,
580 since: u64,
581 next_batch: u64,
582 full_state: bool,
583 state_after: StateAfter,
584 filter: &'a FilterDefinition,
585) -> impl Future<Output = BTreeMap<OwnedRoomId, LeftRoom>> + Send + 'a {
586 services
587 .state_cache
588 .rooms_left_state(sender_user)
589 .ready_filter(|(room_id, _)| filter.room.matches(room_id))
590 .broad_filter_map(move |(room_id, _)| {
591 handle_left_room(
592 services,
593 since,
594 room_id.clone(),
595 sender_user,
596 next_batch,
597 full_state,
598 state_after,
599 filter,
600 )
601 .map_ok(move |left_room| (room_id, left_room))
602 .ok()
603 })
604 .ready_filter_map(|(room_id, left_room)| left_room.map(|left_room| (room_id, left_room)))
605 .collect()
606}
607
608async fn collect_invited_rooms<'a>(
609 services: &'a Services,
610 sender_user: &'a UserId,
611 since: u64,
612 next_batch: u64,
613 filter: &'a FilterDefinition,
614 invites_blocked: bool,
615) -> BTreeMap<OwnedRoomId, InvitedRoom> {
616 services
617 .state_cache
618 .rooms_invited_state(sender_user)
619 .ready_filter(move |_| !invites_blocked)
620 .ready_filter(|(room_id, _)| filter.room.matches(room_id))
621 .fold_default(async |mut invited_rooms: BTreeMap<_, _>, (room_id, invite_state)| {
622 let invite_count = services
623 .state_cache
624 .get_invite_count(&room_id, sender_user)
625 .await
626 .ok();
627
628 if Some(since) >= invite_count || Some(next_batch) < invite_count {
630 return invited_rooms;
631 }
632
633 let invited_room = InvitedRoom {
634 invite_state: InviteState { events: invite_state },
635 };
636
637 invited_rooms.insert(room_id, invited_room);
638 invited_rooms
639 })
640 .await
641}
642
643async fn collect_knocked_rooms<'a>(
644 services: &'a Services,
645 sender_user: &'a UserId,
646 since: u64,
647 next_batch: u64,
648 filter: &'a FilterDefinition,
649) -> BTreeMap<OwnedRoomId, KnockedRoom> {
650 services
651 .state_cache
652 .rooms_knocked_state(sender_user)
653 .ready_filter(|(room_id, _)| filter.room.matches(room_id))
654 .fold_default(async |mut knocked_rooms: BTreeMap<_, _>, (room_id, knock_state)| {
655 let knock_count = services
656 .state_cache
657 .get_knock_count(&room_id, sender_user)
658 .await
659 .ok();
660
661 if Some(since) >= knock_count || Some(next_batch) < knock_count {
663 return knocked_rooms;
664 }
665
666 let knocked_room = KnockedRoom {
667 knock_state: KnockState { events: knock_state },
668 };
669
670 knocked_rooms.insert(room_id, knocked_room);
671 knocked_rooms
672 })
673 .await
674}
675
676fn collect_global_account_data<'a>(
677 services: &'a Services,
678 sender_user: &'a UserId,
679 since: u64,
680 next_batch: u64,
681) -> impl Future<Output = Vec<Raw<AnyGlobalAccountDataEvent>>> + Send + 'a {
682 services
683 .account_data
684 .changes_since(None, sender_user, since, Some(next_batch))
685 .ready_filter_map(|e| extract_variant!(e, AnyRawAccountDataEvent::Global))
686 .ready_filter(move |e| since != 0 || !is_empty_account_data_event(e))
687 .collect()
688}
689
690fn collect_device_list_left<'a>(
691 services: &'a Services,
692 sender_user: &'a UserId,
693 left_encrypted_users: HashSet<OwnedUserId>,
694) -> impl Future<Output = Vec<OwnedUserId>> + Send + 'a {
695 left_encrypted_users
696 .into_iter()
697 .stream()
698 .broad_filter_map(async |user_id: OwnedUserId| {
699 share_encrypted_room(services, sender_user, &user_id, None)
700 .await
701 .eq(&false)
702 .then_some(user_id)
703 })
704 .collect()
705}
706
707fn build_presence_events(presence_updates: Option<PresenceUpdates>) -> Vec<Raw<PresenceEvent>> {
708 presence_updates
709 .into_iter()
710 .flat_map(IntoIterator::into_iter)
711 .map(|(sender, content)| PresenceEvent { content, sender })
712 .map(|ref event| Raw::new(event))
713 .filter_map(Result::ok)
714 .collect()
715}
716
717#[tracing::instrument(name = "presence", level = "debug", skip_all)]
718async fn process_presence_updates(
719 services: &Services,
720 since: u64,
721 next_batch: u64,
722 syncing_user: &UserId,
723 filter: &FilterDefinition,
724) -> PresenceUpdates {
725 services
726 .presence
727 .presence_since(since, Some(next_batch))
728 .ready_filter(|(user_id, ..)| filter.presence.matches(user_id))
729 .filter(|(user_id, ..)| {
730 services
731 .state_cache
732 .user_sees_user(syncing_user, user_id)
733 })
734 .filter_map(|(user_id, _, presence_bytes)| {
735 services
736 .presence
737 .from_json_bytes_to_event(presence_bytes, user_id)
738 .map_ok(move |event| (user_id, event))
739 .ok()
740 })
741 .map(|(user_id, event)| (user_id.to_owned(), event.content))
742 .collect()
743 .boxed()
744 .await
745}
746
747#[tracing::instrument(
748 name = "left",
749 level = "debug",
750 skip_all,
751 fields(
752 room_id = %room_id,
753 full = %full_state,
754 ),
755)]
756#[expect(clippy::too_many_arguments)]
757async fn handle_left_room(
758 services: &Services,
759 since: u64,
760 ref room_id: OwnedRoomId,
761 sender_user: &UserId,
762 next_batch: u64,
763 full_state: bool,
764 state_after: StateAfter,
765 filter: &FilterDefinition,
766) -> Result<Option<LeftRoom>> {
767 let left_count = services
768 .state_cache
769 .get_left_count(room_id, sender_user)
770 .await
771 .unwrap_or(0);
772
773 if left_count == 0 || left_count > next_batch {
774 return Ok(None);
775 }
776
777 let include_leave = filter.room.include_leave;
778 if since == 0 && !include_leave {
779 return Ok(None);
780 }
781
782 if since != 0 && left_count <= since {
785 return Ok(None);
786 }
787
788 let is_not_found = services.metadata.exists(room_id).is_false();
789
790 let is_disabled = services.metadata.is_disabled(room_id);
791
792 let is_banned = services.metadata.is_banned(room_id);
793
794 pin_mut!(is_not_found, is_disabled, is_banned);
795 if is_not_found.or(is_disabled).or(is_banned).await {
796 let event = PduEvent {
799 event_id: EventId::new_v1(services.globals.server_name()),
800 origin_server_ts: utils::millis_since_unix_epoch().try_into()?,
801 kind: RoomMember,
802 state_key: Some(sender_user.as_str().into()),
803 sender: sender_user.to_owned(),
804 content: serde_json::from_str(r#"{"membership":"leave"}"#)?,
805 room_id: room_id.clone(),
807 depth: uint!(1),
808 origin: None,
809 unsigned: None,
810 redacts: None,
811 hashes: EventHash::default(),
812 auth_events: Default::default(),
813 prev_events: Default::default(),
814 };
815
816 let state = state_after.wrap(StateEvents {
817 events: vec![trim_event_fields(event.into_format(), filter.event_fields.as_deref())],
818 });
819
820 return Ok(Some(LeftRoom {
821 account_data: RoomAccountData::default(),
822 state,
823 timeline: Timeline {
824 limited: false,
825 events: Default::default(),
826 prev_batch: Some(left_count.to_string()),
827 },
828 }));
829 }
830
831 load_left_room(
832 services,
833 sender_user,
834 room_id,
835 since,
836 left_count,
837 full_state,
838 state_after,
839 filter,
840 )
841 .await
842}
843
844#[tracing::instrument(name = "load", level = "debug", skip_all)]
845#[expect(clippy::too_many_arguments)]
846async fn load_left_room(
847 services: &Services,
848 sender_user: &UserId,
849 room_id: &RoomId,
850 since: u64,
851 left_count: u64,
852 full_state: bool,
853 state_after: StateAfter,
854 filter: &FilterDefinition,
855) -> Result<Option<LeftRoom>> {
856 let initial = since == 0;
857 let timeline_limit: usize = filter
858 .room
859 .timeline
860 .limit
861 .map(TryInto::try_into)
862 .map_expect("UInt to usize")
863 .unwrap_or(10)
864 .min(100);
865
866 let (timeline_pdus, limited, _) = load_timeline(
867 services,
868 sender_user,
869 room_id,
870 PduCount::Normal(since),
871 Some(PduCount::Normal(left_count)),
872 timeline_limit.max(1),
873 )
874 .await
875 .unwrap_or_default();
876
877 let since_shortstatehash = services
878 .timeline
879 .prev_shortstatehash(room_id, PduCount::Normal(since).saturating_add(1))
880 .ok();
881
882 let horizon_shortstatehash = timeline_pdus
883 .first()
884 .map(at!(0))
885 .map_async(|count| {
886 services
887 .timeline
888 .get_shortstatehash(room_id, count)
889 .inspect_err(inspect_debug_log)
890 .ok()
891 });
892
893 let after_shortstatehash = state_after.requested().then_async(|| {
898 services
899 .timeline
900 .next_shortstatehash(room_id, PduCount::Normal(left_count))
901 .or_else(|_| services.state.get_room_shortstatehash(room_id))
902 .inspect_err(inspect_debug_log)
903 });
904
905 let left_shortstatehash = services
906 .timeline
907 .get_shortstatehash(room_id, PduCount::Normal(left_count))
908 .inspect_err(inspect_debug_log)
909 .or_else(|_| services.state.get_room_shortstatehash(room_id))
910 .map_err(|_| err!(Database(error!("Room {room_id} has no state"))));
911
912 let (since_shortstatehash, horizon_shortstatehash, after_shortstatehash, left_shortstatehash) =
913 join4(
914 since_shortstatehash,
915 horizon_shortstatehash,
916 after_shortstatehash,
917 left_shortstatehash,
918 )
919 .boxed()
920 .await;
921
922 let StateChanges { state_events, .. } =
923 calculate_state_changes(services, sender_user, room_id, StateChangeParams {
924 full_state: full_state || initial,
925 state_after,
926 since_shortstatehash,
927 horizon_shortstatehash: horizon_shortstatehash.flatten(),
928 after_shortstatehash: after_shortstatehash.flat_ok(),
929 current_shortstatehash: left_shortstatehash?,
930 joined_since_last_sync: false,
931 witness: None,
932 include_heroes: true,
933 })
934 .boxed()
935 .await?;
936
937 let is_sender_membership = |event: &PduEvent| {
938 *event.kind() == RoomMember && event.state_key() == Some(sender_user.as_str())
939 };
940
941 let timeline_sender_member = timeline_limit
942 .eq(&0)
943 .then(|| timeline_pdus.last().map(ref_at!(1)).cloned())
944 .into_iter()
945 .flat_map(Option::into_iter);
946
947 let encrypted = services
948 .state_accessor
949 .is_encrypted_room(room_id)
950 .await;
951
952 let event_fields = filter.event_fields.as_deref();
953
954 let in_timeline = in_timeline(&timeline_pdus);
955
956 let state_events = state_events
957 .into_iter()
958 .filter(|pdu| filter.room.state.matches(pdu))
959 .filter(|pdu| timeline_limit > 0 || !is_sender_membership(pdu))
960 .chain(timeline_sender_member)
961 .stream()
962 .wide_then(|pdu| with_membership(services, pdu, sender_user, encrypted))
963 .map(|pdu| strip_prev_state(pdu, sender_user, &in_timeline))
964 .map(|pdu| trim_event_fields(pdu.into_format(), event_fields))
965 .collect();
966
967 let left_prev_batch = timeline_limit
968 .eq(&0)
969 .then_some(left_count)
970 .map(PduCount::Normal);
971
972 let prev_batch = timeline_pdus
973 .first()
974 .filter(|_| timeline_limit > 0)
975 .map(at!(0))
976 .or(left_prev_batch)
977 .as_ref()
978 .map(ToString::to_string);
979
980 let timeline_events = timeline_pdus
981 .into_iter()
982 .stream()
983 .wide_filter_map(|item| ignored_filter(services, item, sender_user))
984 .map(at!(1))
985 .ready_filter(|pdu| filter.room.timeline.matches(pdu))
986 .take(timeline_limit)
987 .wide_then(|pdu| with_membership(services, pdu, sender_user, encrypted))
988 .wide_then(|pdu| {
989 services
990 .pdu_metadata
991 .bundle_aggregations(sender_user, pdu)
992 })
993 .collect::<Vec<_>>();
994
995 let account_data_events = services
996 .account_data
997 .changes_since(Some(room_id), sender_user, since, None)
998 .ready_filter_map(|e| extract_variant!(e, AnyRawAccountDataEvent::Room))
999 .ready_filter(move |e| since != 0 || !is_empty_account_data_event(e))
1000 .collect();
1001
1002 let (state_events, account_data_events, timeline_events) =
1003 join3(state_events, account_data_events, timeline_events)
1004 .boxed()
1005 .await;
1006
1007 let state = state_after.wrap(StateEvents { events: state_events });
1008
1009 Ok(Some(LeftRoom {
1010 account_data: RoomAccountData { events: account_data_events },
1011 state,
1012 timeline: Timeline {
1013 prev_batch,
1014 limited: limited || timeline_limit == 0,
1015 events: timeline_events
1016 .into_iter()
1017 .map(|pdu| trim_event_fields(pdu.into_format(), event_fields))
1018 .collect(),
1019 },
1020 }))
1021}
1022
1023fn in_timeline(timeline_pdus: &[(PduCount, PduEvent)]) -> impl Fn(&PduEvent) -> bool + use<> {
1024 let timeline_ids: TimelineEventIds = timeline_pdus
1025 .iter()
1026 .map(ref_at!(1))
1027 .map(Event::event_id)
1028 .map(ToOwned::to_owned)
1029 .collect();
1030
1031 move |event: &PduEvent| {
1032 timeline_ids
1033 .iter()
1034 .any(is_equal_to!(event.event_id()))
1035 }
1036}
1037
1038#[tracing::instrument(
1039 name = "joined",
1040 level = "debug",
1041 skip_all,
1042 fields(
1043 room_id = ?room_id,
1044 ),
1045)]
1046#[expect(clippy::too_many_arguments)]
1047async fn load_joined_room(
1048 services: &Services,
1049 sender_user: &UserId,
1050 sender_device: Option<&DeviceId>,
1051 ref room_id: OwnedRoomId,
1052 since: u64,
1053 next_batch: u64,
1054 full_state: bool,
1055 state_after: StateAfter,
1056 filter: &FilterDefinition,
1057) -> Result<(JoinedRoom, HashSet<OwnedUserId>, HashSet<OwnedUserId>)> {
1058 let initial = since == 0;
1059 let (timeline_pdus, limited, last_timeline_count) =
1060 load_join_timeline(services, sender_user, room_id, since, next_batch, filter).await?;
1061
1062 let timeline_changed = last_timeline_count.into_unsigned() > since;
1063 debug_assert!(
1064 timeline_pdus.is_empty() || timeline_changed,
1065 "if timeline events, last_timeline_count must be in the since window."
1066 );
1067
1068 let RoomMetadata {
1069 since_shortstatehash,
1070 horizon_shortstatehash,
1071 after_shortstatehash,
1072 current_shortstatehash,
1073 receipt_events,
1074 encrypted_room,
1075 } = gather_room_metadata(
1076 services,
1077 sender_user,
1078 room_id,
1079 since,
1080 next_batch,
1081 &timeline_pdus,
1082 last_timeline_count,
1083 timeline_changed,
1084 state_after,
1085 )
1086 .boxed()
1087 .await?;
1088
1089 let UserMetadata {
1090 witness,
1091 last_notification_read,
1092 thread_last_reads,
1093 last_privateread_update,
1094 joined_since_last_sync,
1095 } = gather_user_metadata(
1096 services,
1097 sender_user,
1098 sender_device,
1099 room_id,
1100 filter,
1101 &timeline_pdus,
1102 &receipt_events,
1103 since,
1104 initial,
1105 timeline_changed,
1106 encrypted_room,
1107 since_shortstatehash,
1108 )
1109 .boxed()
1110 .await;
1111
1112 let (
1113 state_after,
1114 StateChanges {
1115 heroes,
1116 joined_member_count,
1117 invited_member_count,
1118 mut state_events,
1119 },
1120 ) = compute_join_state_changes(
1121 services,
1122 sender_user,
1123 room_id,
1124 full_state || initial,
1125 state_after,
1126 since_shortstatehash,
1127 horizon_shortstatehash,
1128 after_shortstatehash,
1129 current_shortstatehash,
1130 joined_since_last_sync,
1131 witness.as_ref(),
1132 )
1133 .await?;
1134
1135 let joined_sender_member = take_sender_membership_for_join(
1136 &mut state_events,
1137 sender_user,
1138 joined_since_last_sync,
1139 timeline_pdus.is_empty(),
1140 initial,
1141 );
1142
1143 let prev_batch =
1144 compute_join_prev_batch(&timeline_pdus, joined_sender_member.as_ref(), since);
1145
1146 let in_window = |count: u64| count > since && count <= next_batch;
1147
1148 let NotificationGates {
1149 send_notification_counts,
1150 send_notification_count_filter,
1151 } = compute_notification_gates(
1152 last_notification_read,
1153 thread_last_reads.as_ref(),
1154 since,
1155 in_window,
1156 );
1157
1158 let encrypted = encrypted_room.unwrap_or(false);
1160
1161 let aggregates = await_join_aggregates(
1162 services,
1163 sender_user,
1164 room_id,
1165 &state_events,
1166 timeline_pdus,
1167 joined_sender_member,
1168 encrypted,
1169 initial,
1170 since,
1171 next_batch,
1172 last_privateread_update,
1173 send_notification_counts,
1174 filter,
1175 )
1176 .await;
1177
1178 let (joined_room, device_list_updates, left_encrypted_users) = finalize_joined_room(
1179 services,
1180 sender_user,
1181 filter,
1182 state_events,
1183 aggregates,
1184 receipt_events,
1185 heroes,
1186 joined_member_count,
1187 invited_member_count,
1188 thread_last_reads.as_ref(),
1189 send_notification_count_filter,
1190 FinalizeJoinFlags {
1191 encrypted,
1192 full_state,
1193 state_after,
1194 limited,
1195 joined_since_last_sync,
1196 initial,
1197 },
1198 in_window,
1199 prev_batch,
1200 )
1201 .await;
1202
1203 Ok((joined_room, device_list_updates, left_encrypted_users))
1204}
1205
1206#[expect(clippy::too_many_arguments)]
1207async fn compute_join_state_changes(
1208 services: &Services,
1209 sender_user: &UserId,
1210 room_id: &RoomId,
1211 full_state: bool,
1212 state_after: StateAfter,
1213 since_shortstatehash: Option<ShortStateHash>,
1214 horizon_shortstatehash: Option<ShortStateHash>,
1215 after_shortstatehash: Option<ShortStateHash>,
1216 current_shortstatehash: Option<ShortStateHash>,
1217 joined_since_last_sync: bool,
1218 witness: Option<&Witness>,
1219) -> Result<(StateAfter, StateChanges)> {
1220 let Some(current_shortstatehash) = current_shortstatehash else {
1221 return Ok((state_after, StateChanges::default()));
1222 };
1223
1224 let state_changes =
1225 calculate_state_changes(services, sender_user, room_id, StateChangeParams {
1226 full_state,
1227 state_after,
1228 since_shortstatehash,
1229 horizon_shortstatehash,
1230 after_shortstatehash,
1231 current_shortstatehash,
1232 joined_since_last_sync,
1233 witness,
1234 include_heroes: true,
1235 })
1236 .await;
1237
1238 let incremental = !full_state && !joined_since_last_sync && since_shortstatehash.is_some();
1239
1240 if !state_after.requested() || incremental {
1241 return state_changes.map(|state_changes| (state_after, state_changes));
1242 }
1243
1244 match state_changes {
1245 | Ok(state_changes) => Ok((state_after, state_changes)),
1246 | Err(after_error) => {
1247 let after_boundary = after_shortstatehash.unwrap_or(current_shortstatehash);
1248 let legacy_boundary = horizon_shortstatehash.unwrap_or(current_shortstatehash);
1249
1250 warn!(
1251 %room_id,
1252 %after_boundary,
1253 ?after_error,
1254 "Failed to load requested state-after boundary; retrying legacy state."
1255 );
1256
1257 calculate_state_changes(services, sender_user, room_id, StateChangeParams {
1258 full_state,
1259 state_after: StateAfter::Off,
1260 since_shortstatehash,
1261 horizon_shortstatehash,
1262 after_shortstatehash,
1263 current_shortstatehash,
1264 joined_since_last_sync,
1265 witness,
1266 include_heroes: false,
1267 })
1268 .await
1269 .inspect_err(|legacy_error| {
1270 warn!(
1271 %room_id,
1272 %after_boundary,
1273 %legacy_boundary,
1274 ?after_error,
1275 ?legacy_error,
1276 "Failed to load state-after and legacy state boundaries."
1277 );
1278 })
1279 .map(|state_changes| (StateAfter::Off, state_changes))
1280 },
1281 }
1282}
1283
1284fn compute_join_prev_batch(
1285 timeline_pdus: &[(PduCount, PduEvent)],
1286 joined_sender_member: Option<&PduEvent>,
1287 since: u64,
1288) -> Option<PduCount> {
1289 timeline_pdus.first().map(at!(0)).or_else(|| {
1290 joined_sender_member
1291 .is_some()
1292 .then_some(since)
1293 .map(Into::into)
1294 })
1295}
1296
1297#[expect(clippy::too_many_arguments)]
1298async fn assemble_join_state_events(
1299 services: &Services,
1300 state_events: Vec<PduEvent>,
1301 sender_user: &UserId,
1302 encrypted: bool,
1303 room_events: &[PduEvent],
1304 filter: &FilterDefinition,
1305 full_state: bool,
1306 state_after: StateAfter,
1307) -> Vec<Raw<AnySyncStateEvent>> {
1308 let is_in_timeline = |event: &PduEvent| {
1309 room_events
1310 .iter()
1311 .map(Event::event_id)
1312 .any(is_equal_to!(event.event_id()))
1313 };
1314
1315 let include_in_state = |event: &PduEvent| {
1319 let filter = &filter.room.state;
1320 filter.matches(event) && (full_state || state_after.requested() || !is_in_timeline(event))
1321 };
1322
1323 assemble_state_events(
1324 services,
1325 state_events,
1326 sender_user,
1327 encrypted,
1328 include_in_state,
1329 &is_in_timeline,
1330 filter.event_fields.as_deref(),
1331 )
1332 .await
1333}
1334
1335async fn load_join_timeline(
1336 services: &Services,
1337 sender_user: &UserId,
1338 room_id: &RoomId,
1339 since: u64,
1340 next_batch: u64,
1341 filter: &FilterDefinition,
1342) -> Result<(Vec<(PduCount, PduEvent)>, bool, PduCount)> {
1343 let timeline_limit: usize = filter
1344 .room
1345 .timeline
1346 .limit
1347 .map(TryInto::try_into)
1348 .map_expect("UInt to usize")
1349 .unwrap_or(10)
1350 .min(100);
1351
1352 load_timeline(
1353 services,
1354 sender_user,
1355 room_id,
1356 PduCount::Normal(since),
1357 Some(PduCount::Normal(next_batch)),
1358 timeline_limit,
1359 )
1360 .await
1361}
1362
1363struct JoinAggregates {
1364 room_events: Vec<PduEvent>,
1365 account_data_events: Vec<Raw<AnyRoomAccountDataEvent>>,
1366 typing_events: Vec<Raw<AnySyncEphemeralRoomEvent>>,
1367 private_read_events: Option<PrivateReadEvents>,
1368 notification_count: Option<UInt>,
1369 highlight_count: Option<UInt>,
1370 thread_counts: Option<BTreeMap<OwnedEventId, (u64, u64)>>,
1371 device_list_updates: HashSet<OwnedUserId>,
1372 left_encrypted_users: HashSet<OwnedUserId>,
1373}
1374
1375#[expect(clippy::too_many_arguments)]
1376async fn await_join_aggregates(
1377 services: &Services,
1378 sender_user: &UserId,
1379 room_id: &RoomId,
1380 state_events: &[PduEvent],
1381 timeline_pdus: Vec<(PduCount, PduEvent)>,
1382 joined_sender_member: Option<PduEvent>,
1383 encrypted: bool,
1384 initial: bool,
1385 since: u64,
1386 next_batch: u64,
1387 last_privateread_update: u64,
1388 send_notification_counts: bool,
1389 filter: &FilterDefinition,
1390) -> JoinAggregates {
1391 let (notification_count, highlight_count, thread_counts) =
1392 notification_count_futures(services, sender_user, room_id, send_notification_counts);
1393
1394 let private_read_events = last_privateread_update.gt(&since).then_async(|| {
1395 services
1396 .read_receipt
1397 .private_read_get(room_id, sender_user)
1398 .unwrap_or_default()
1399 });
1400
1401 let typing_events = gather_typing_events(services, room_id, sender_user, since);
1402
1403 let device_list_updates = gather_device_list_updates(
1404 services,
1405 sender_user,
1406 room_id,
1407 timeline_membership_changes(&timeline_pdus, initial),
1408 state_events,
1409 initial,
1410 since,
1411 next_batch,
1412 );
1413
1414 let room_events = collect_room_events(
1415 services,
1416 sender_user,
1417 timeline_pdus,
1418 joined_sender_member,
1419 encrypted,
1420 filter,
1421 );
1422
1423 let account_data_events = collect_room_account_data(services, sender_user, room_id, since);
1424
1425 let (
1426 (room_events, account_data_events),
1427 (typing_events, private_read_events),
1428 (notification_count, highlight_count, thread_counts),
1429 (device_list_updates, left_encrypted_users),
1430 ) = join4(
1431 join(room_events, account_data_events),
1432 join(typing_events, private_read_events),
1433 join3(notification_count, highlight_count, thread_counts),
1434 device_list_updates,
1435 )
1436 .boxed()
1437 .await;
1438
1439 JoinAggregates {
1440 room_events,
1441 account_data_events,
1442 typing_events,
1443 private_read_events,
1444 notification_count,
1445 highlight_count,
1446 thread_counts,
1447 device_list_updates,
1448 left_encrypted_users,
1449 }
1450}
1451
1452struct FinalizeJoinFlags {
1453 encrypted: bool,
1454 full_state: bool,
1455 state_after: StateAfter,
1456 limited: bool,
1457 joined_since_last_sync: bool,
1458 initial: bool,
1459}
1460
1461#[expect(clippy::too_many_arguments)]
1462async fn finalize_joined_room(
1463 services: &Services,
1464 sender_user: &UserId,
1465 filter: &FilterDefinition,
1466 state_events: Vec<PduEvent>,
1467 aggregates: JoinAggregates,
1468 receipt_events: Vec<(OwnedUserId, Raw<AnySyncEphemeralRoomEvent>)>,
1469 heroes: Option<Vec<OwnedUserId>>,
1470 joined_member_count: Option<u64>,
1471 invited_member_count: Option<u64>,
1472 thread_last_reads: Option<&BTreeMap<OwnedEventId, u64>>,
1473 send_notification_count_filter: impl Fn(&UInt) -> bool,
1474 flags: FinalizeJoinFlags,
1475 in_window: impl Fn(u64) -> bool,
1476 prev_batch: Option<PduCount>,
1477) -> (JoinedRoom, HashSet<OwnedUserId>, HashSet<OwnedUserId>) {
1478 let JoinAggregates {
1479 room_events,
1480 account_data_events,
1481 typing_events,
1482 private_read_events,
1483 notification_count,
1484 highlight_count,
1485 thread_counts,
1486 device_list_updates,
1487 left_encrypted_users,
1488 } = aggregates;
1489
1490 let FinalizeJoinFlags {
1491 encrypted,
1492 full_state,
1493 state_after,
1494 limited,
1495 joined_since_last_sync,
1496 initial,
1497 } = flags;
1498
1499 let state_events = assemble_join_state_events(
1500 services,
1501 state_events,
1502 sender_user,
1503 encrypted,
1504 &room_events,
1505 filter,
1506 full_state,
1507 state_after,
1508 )
1509 .await;
1510
1511 let (unread_notifications, unread_thread_notifications) = assemble_unread_notifications(
1512 notification_count,
1513 highlight_count,
1514 thread_counts,
1515 thread_last_reads,
1516 send_notification_count_filter,
1517 filter.room.timeline.unread_thread_notifications,
1518 initial,
1519 in_window,
1520 );
1521
1522 let joined_room = build_joined_room(
1523 BuildJoinedRoom {
1524 receipt_events,
1525 typing_events,
1526 private_read_events,
1527 state_events,
1528 account_data_events,
1529 room_events,
1530 heroes,
1531 joined_member_count,
1532 invited_member_count,
1533 unread_notifications,
1534 unread_thread_notifications,
1535 state_after,
1536 limited,
1537 joined_since_last_sync,
1538 prev_batch,
1539 },
1540 filter.event_fields.as_deref(),
1541 );
1542
1543 (joined_room, device_list_updates, left_encrypted_users)
1544}
1545
1546fn build_joined_room(args: BuildJoinedRoom, event_fields: Option<&[String]>) -> JoinedRoom {
1547 let BuildJoinedRoom {
1548 receipt_events,
1549 typing_events,
1550 private_read_events,
1551 state_events,
1552 account_data_events,
1553 room_events,
1554 heroes,
1555 joined_member_count,
1556 invited_member_count,
1557 unread_notifications,
1558 unread_thread_notifications,
1559 state_after,
1560 limited,
1561 joined_since_last_sync,
1562 prev_batch,
1563 } = args;
1564
1565 let edus: Vec<Raw<AnySyncEphemeralRoomEvent>> = receipt_events
1566 .into_iter()
1567 .map(at!(1))
1568 .chain(typing_events)
1569 .chain(private_read_events.into_iter().flatten())
1570 .collect();
1571
1572 let state = state_after.wrap(StateEvents { events: state_events });
1573
1574 let heroes = heroes
1575 .into_iter()
1576 .flatten()
1577 .map(TryInto::try_into)
1578 .filter_map(Result::ok)
1579 .collect();
1580
1581 JoinedRoom {
1582 account_data: RoomAccountData { events: account_data_events },
1583 ephemeral: Ephemeral { events: edus },
1584 state,
1585 summary: RoomSummary {
1586 joined_member_count: joined_member_count.map(ruma_from_u64),
1587 invited_member_count: invited_member_count.map(ruma_from_u64),
1588 heroes,
1589 },
1590 timeline: Timeline {
1591 limited: limited || joined_since_last_sync,
1592 prev_batch: prev_batch.as_ref().map(ToString::to_string),
1593 events: room_events
1594 .into_iter()
1595 .map(|pdu| trim_event_fields(pdu.into_format(), event_fields))
1596 .collect(),
1597 },
1598 unread_notifications,
1599 unread_thread_notifications,
1600 }
1601}
1602
1603#[expect(clippy::too_many_arguments)]
1604async fn gather_room_metadata(
1605 services: &Services,
1606 sender_user: &UserId,
1607 room_id: &RoomId,
1608 since: u64,
1609 next_batch: u64,
1610 timeline_pdus: &[(PduCount, PduEvent)],
1611 last_timeline_count: PduCount,
1612 timeline_changed: bool,
1613 state_after: StateAfter,
1614) -> Result<RoomMetadata> {
1615 let since_shortstatehash = timeline_changed.then_async(|| {
1616 services
1617 .timeline
1618 .prev_shortstatehash(room_id, PduCount::Normal(since).saturating_add(1))
1619 .ok()
1620 });
1621
1622 let horizon_shortstatehash = timeline_pdus
1623 .first()
1624 .map(at!(0))
1625 .map_async(|count| {
1626 services
1627 .timeline
1628 .get_shortstatehash(room_id, count)
1629 .inspect_err(inspect_debug_log)
1630 });
1631
1632 let after_shortstatehash = state_after.requested().then_async(|| {
1637 services
1638 .timeline
1639 .next_shortstatehash(room_id, last_timeline_count)
1640 .or_else(|_| services.state.get_room_shortstatehash(room_id))
1641 .inspect_err(inspect_debug_log)
1642 });
1643
1644 let current_shortstatehash = timeline_changed.then_async(|| {
1645 services
1646 .timeline
1647 .get_shortstatehash(room_id, last_timeline_count)
1648 .inspect_err(inspect_debug_log)
1649 .or_else(|_| services.state.get_room_shortstatehash(room_id))
1650 .map_err(|_| err!(Database(error!("Room {room_id} has no state"))))
1651 });
1652
1653 let encrypted_room =
1654 timeline_changed.then_async(|| services.state_accessor.is_encrypted_room(room_id));
1655
1656 let receipt_events = services
1657 .read_receipt
1658 .readreceipts_since(room_id, since, Some(next_batch))
1659 .filter_map(async |(read_user, _, edu)| {
1660 services
1661 .users
1662 .user_is_ignored(read_user, sender_user)
1663 .await
1664 .or_some((read_user.to_owned(), edu))
1665 })
1666 .collect::<Vec<(OwnedUserId, Raw<AnySyncEphemeralRoomEvent>)>>();
1667
1668 let (
1669 (
1670 since_shortstatehash,
1671 horizon_shortstatehash,
1672 after_shortstatehash,
1673 current_shortstatehash,
1674 ),
1675 receipt_events,
1676 encrypted_room,
1677 ) = join3(
1678 join4(
1679 since_shortstatehash,
1680 horizon_shortstatehash,
1681 after_shortstatehash,
1682 current_shortstatehash,
1683 ),
1684 receipt_events,
1685 encrypted_room,
1686 )
1687 .boxed()
1688 .await;
1689
1690 Ok(RoomMetadata {
1691 since_shortstatehash: since_shortstatehash.flatten(),
1692 horizon_shortstatehash: horizon_shortstatehash.flat_ok(),
1693 after_shortstatehash: after_shortstatehash.flat_ok(),
1694 current_shortstatehash: current_shortstatehash.transpose()?,
1695 receipt_events,
1696 encrypted_room,
1697 })
1698}
1699
1700fn collect_room_events<'a>(
1701 services: &'a Services,
1702 sender_user: &'a UserId,
1703 timeline_pdus: Vec<(PduCount, PduEvent)>,
1704 joined_sender_member: Option<PduEvent>,
1705 encrypted: bool,
1706 filter: &'a FilterDefinition,
1707) -> impl Future<Output = Vec<PduEvent>> + Send + 'a {
1708 let include_in_timeline = |event: &PduEvent| filter.room.timeline.matches(event);
1709 timeline_pdus
1710 .into_iter()
1711 .stream()
1712 .wide_filter_map(|item| ignored_filter(services, item, sender_user))
1713 .map(at!(1))
1714 .chain(joined_sender_member.into_iter().stream())
1715 .ready_filter(include_in_timeline)
1716 .wide_then(move |pdu| with_membership(services, pdu, sender_user, encrypted))
1717 .wide_then(move |pdu| {
1718 services
1719 .pdu_metadata
1720 .bundle_aggregations(sender_user, pdu)
1721 })
1722 .collect::<Vec<_>>()
1723}
1724
1725fn collect_room_account_data<'a>(
1726 services: &'a Services,
1727 sender_user: &'a UserId,
1728 room_id: &'a RoomId,
1729 since: u64,
1730) -> impl Future<Output = Vec<Raw<AnyRoomAccountDataEvent>>> + Send + 'a {
1731 services
1732 .account_data
1733 .changes_since(Some(room_id), sender_user, since, None)
1734 .ready_filter_map(|e| extract_variant!(e, AnyRawAccountDataEvent::Room))
1735 .ready_filter(move |e| since != 0 || !is_empty_account_data_event(e))
1736 .collect()
1737}
1738
1739#[expect(clippy::type_complexity)]
1740fn notification_count_futures<'a>(
1741 services: &'a Services,
1742 sender_user: &'a UserId,
1743 room_id: &'a RoomId,
1744 send: bool,
1745) -> (
1746 impl Future<Output = Option<UInt>> + Send + 'a,
1747 impl Future<Output = Option<UInt>> + Send + 'a,
1748 impl Future<Output = Option<BTreeMap<OwnedEventId, (u64, u64)>>> + Send + 'a,
1749) {
1750 let notification_count = send.then_async(move || {
1751 services
1752 .pusher
1753 .notification_count(sender_user, room_id)
1754 .map(TryInto::try_into)
1755 .unwrap_or(uint!(0))
1756 });
1757
1758 let highlight_count = send.then_async(move || {
1759 services
1760 .pusher
1761 .highlight_count(sender_user, room_id)
1762 .map(TryInto::try_into)
1763 .unwrap_or(uint!(0))
1764 });
1765
1766 let thread_counts = send.then_async(move || {
1769 services
1770 .pusher
1771 .thread_notification_counts(sender_user, room_id)
1772 });
1773
1774 (notification_count, highlight_count, thread_counts)
1775}
1776
1777fn take_sender_membership_for_join(
1778 state_events: &mut Vec<PduEvent>,
1779 sender_user: &UserId,
1780 joined_since_last_sync: bool,
1781 timeline_empty: bool,
1782 initial: bool,
1783) -> Option<PduEvent> {
1784 if !(joined_since_last_sync && timeline_empty && !initial) {
1785 return None;
1786 }
1787
1788 let is_sender_membership = |event: &PduEvent| {
1789 *event.event_type() == StateEventType::RoomMember.into()
1790 && event
1791 .state_key()
1792 .is_some_and(is_equal_to!(sender_user.as_str()))
1793 };
1794
1795 state_events
1796 .iter()
1797 .position(is_sender_membership)
1798 .map(|pos| state_events.swap_remove(pos))
1799}
1800
1801#[expect(clippy::too_many_arguments)]
1802async fn gather_user_metadata(
1803 services: &Services,
1804 sender_user: &UserId,
1805 sender_device: Option<&DeviceId>,
1806 room_id: &RoomId,
1807 filter: &FilterDefinition,
1808 timeline_pdus: &[(PduCount, PduEvent)],
1809 receipt_events: &[(OwnedUserId, Raw<AnySyncEphemeralRoomEvent>)],
1810 since: u64,
1811 initial: bool,
1812 timeline_changed: bool,
1813 encrypted_room: Option<bool>,
1814 since_shortstatehash: Option<ShortStateHash>,
1815) -> UserMetadata {
1816 let lazy_load_options =
1817 [&filter.room.state.lazy_load_options, &filter.room.timeline.lazy_load_options];
1818
1819 let lazy_loading_enabled = encrypted_room.is_some_and(is_false!())
1820 && lazy_load_options
1821 .iter()
1822 .any(|opts| opts.is_enabled());
1823
1824 let lazy_loading_context = &lazy_loading::Context {
1825 user_id: sender_user,
1826 device_id: sender_device,
1827 room_id,
1828 token: Some(since),
1829 options: Some(&filter.room.state.lazy_load_options),
1830 mode: lazy_loading::Mode::Update,
1831 };
1832
1833 let lazy_load_reset =
1835 initial.then_async(|| services.lazy_loading.reset(lazy_loading_context));
1836
1837 lazy_load_reset.await;
1838 let witness = lazy_loading_enabled.then_async(|| {
1839 let witness: Witness = timeline_pdus
1840 .iter()
1841 .map(ref_at!(1))
1842 .map(Event::sender)
1843 .map(Into::into)
1844 .chain(receipt_events.iter().map(ref_at!(0)).cloned())
1845 .collect();
1846
1847 services
1848 .lazy_loading
1849 .witness_retain(witness, lazy_loading_context)
1850 });
1851
1852 let sender_joined_count = timeline_changed.then_async(|| {
1853 services
1854 .state_cache
1855 .get_joined_count(room_id, sender_user)
1856 .unwrap_or(0)
1857 });
1858
1859 let since_encryption = since_shortstatehash.map_async(|shortstatehash| {
1860 services
1861 .state_accessor
1862 .state_get(shortstatehash, &StateEventType::RoomEncryption, "")
1863 });
1864
1865 let last_notification_read = timeline_pdus.is_empty().then_async(|| {
1866 services
1867 .pusher
1868 .last_notification_read(sender_user, room_id)
1869 .ok()
1870 });
1871
1872 let thread_last_reads = timeline_pdus.is_empty().then_async(|| {
1873 services
1874 .pusher
1875 .thread_last_notification_reads(sender_user, room_id)
1876 });
1877
1878 let last_privateread_update = services
1879 .read_receipt
1880 .last_privateread_update(sender_user, room_id);
1881
1882 let (
1883 (last_privateread_update, last_notification_read, thread_last_reads),
1884 (sender_joined_count, since_encryption),
1885 witness,
1886 ) = join3(
1887 join3(last_privateread_update, last_notification_read, thread_last_reads),
1888 join(sender_joined_count, since_encryption),
1889 witness,
1890 )
1891 .await;
1892
1893 let _encrypted_since_last_sync =
1894 !initial && encrypted_room.is_some_and(is_true!()) && since_encryption.is_none();
1895
1896 let joined_since_last_sync = sender_joined_count.unwrap_or(0) > since;
1897
1898 UserMetadata {
1899 witness,
1900 last_notification_read,
1901 thread_last_reads,
1902 last_privateread_update,
1903 joined_since_last_sync,
1904 }
1905}
1906
1907#[expect(clippy::option_option)]
1908fn compute_notification_gates(
1909 last_notification_read: Option<Option<u64>>,
1910 thread_last_reads: Option<&BTreeMap<OwnedEventId, u64>>,
1911 since: u64,
1912 in_window: impl Fn(u64) -> bool,
1913) -> NotificationGates<impl Fn(&UInt) -> bool> {
1914 let send_main_counts = last_notification_read
1915 .flatten()
1916 .is_none_or(&in_window);
1917
1918 let send_thread_counts =
1919 thread_last_reads.is_none_or(|reads| reads.values().copied().any(&in_window));
1920
1921 let send_notification_counts = send_main_counts || send_thread_counts;
1926
1927 let send_notification_resets = last_notification_read
1928 .flatten()
1929 .is_some_and(|last_count| last_count > since);
1930
1931 let send_notification_count_filter =
1932 move |count: &UInt| *count != uint!(0) || send_notification_resets;
1933
1934 NotificationGates {
1935 send_notification_counts,
1936 send_notification_count_filter,
1937 }
1938}
1939
1940async fn gather_typing_events(
1941 services: &Services,
1942 room_id: &RoomId,
1943 sender_user: &UserId,
1944 since: u64,
1945) -> Vec<Raw<AnySyncEphemeralRoomEvent>> {
1946 services
1947 .typing
1948 .last_typing_update(room_id)
1949 .and_then(async |count| {
1950 if count <= since {
1951 return Ok(Vec::<Raw<AnySyncEphemeralRoomEvent>>::new());
1952 }
1953
1954 let typings = typings_event_for_user(services, room_id, sender_user).await?;
1955
1956 Ok(vec![serde_json::from_str(&serde_json::to_string(&typings)?)?])
1957 })
1958 .unwrap_or(Vec::new())
1959 .await
1960}
1961
1962fn timeline_membership_changes(
1963 timeline_pdus: &[(PduCount, PduEvent)],
1964 initial: bool,
1965) -> Vec<(MembershipState, OwnedUserId)> {
1966 timeline_pdus
1967 .iter()
1968 .filter(|_| !initial)
1969 .map(ref_at!(1))
1970 .filter_map(extract_membership)
1971 .collect::<Vec<_>>()
1972}
1973
1974fn extract_membership(event: &PduEvent) -> Option<(MembershipState, OwnedUserId)> {
1975 let content: RoomMemberEventContent = event.get_content().ok()?;
1976 let user_id: OwnedUserId = event.state_key()?.parse().ok()?;
1977
1978 Some((content.membership, user_id))
1979}
1980
1981#[expect(clippy::too_many_arguments)]
1982async fn gather_device_list_updates(
1983 services: &Services,
1984 sender_user: &UserId,
1985 room_id: &RoomId,
1986 timeline_membership_changes: Vec<(MembershipState, OwnedUserId)>,
1987 state_events: &[PduEvent],
1988 initial: bool,
1989 since: u64,
1990 next_batch: u64,
1991) -> (HashSet<OwnedUserId>, HashSet<OwnedUserId>) {
1992 let keys_changed = services
1993 .users
1994 .room_keys_changed(room_id, since, Some(next_batch))
1995 .map(|(user_id, _)| user_id)
1996 .map(ToOwned::to_owned)
1997 .collect::<Vec<_>>();
1998
1999 let (mut dlu, leu) = state_events
2000 .iter()
2001 .stream()
2002 .ready_filter(|_| !initial)
2003 .ready_filter(|state_event| *state_event.event_type() == RoomMember)
2004 .ready_filter_map(extract_membership)
2005 .chain(timeline_membership_changes.into_iter().stream())
2006 .fold_default(async |(mut dlu, mut leu): pair_of!(HashSet<_>), (membership, user_id)| {
2007 use MembershipState::*;
2008
2009 let requires_update = async |user_id| {
2010 !share_encrypted_room(services, sender_user, user_id, Some(room_id)).await
2011 };
2012
2013 match membership {
2014 | Join if requires_update(&user_id).await => dlu.insert(user_id),
2015 | Leave => leu.insert(user_id),
2016 | _ => false,
2017 };
2018
2019 (dlu, leu)
2020 })
2021 .await;
2022
2023 dlu.extend(keys_changed.await);
2024 (dlu, leu)
2025}
2026
2027async fn assemble_state_events(
2028 services: &Services,
2029 state_events: Vec<PduEvent>,
2030 sender_user: &UserId,
2031 encrypted: bool,
2032 include_in_state: impl Fn(&PduEvent) -> bool + Send + Sync,
2033 in_timeline: impl Fn(&PduEvent) -> bool + Send + Sync,
2034 event_fields: Option<&[String]>,
2035) -> Vec<Raw<AnySyncStateEvent>> {
2036 state_events
2037 .into_iter()
2038 .filter(include_in_state)
2039 .stream()
2040 .wide_then(|pdu| with_membership(services, pdu, sender_user, encrypted))
2041 .map(|pdu| strip_prev_state(pdu, sender_user, &in_timeline))
2042 .map(|pdu| trim_event_fields(pdu.into_format(), event_fields))
2043 .collect()
2044 .await
2045}
2046
2047#[expect(clippy::too_many_arguments)]
2048fn assemble_unread_notifications(
2049 notification_count: Option<UInt>,
2050 highlight_count: Option<UInt>,
2051 thread_counts: Option<BTreeMap<OwnedEventId, (u64, u64)>>,
2052 thread_last_reads: Option<&BTreeMap<OwnedEventId, u64>>,
2053 send_notification_count_filter: impl Fn(&UInt) -> bool,
2054 want_thread_unread: bool,
2055 initial: bool,
2056 in_window: impl Fn(u64) -> bool,
2057) -> (UnreadNotificationsCount, BTreeMap<OwnedEventId, UnreadNotificationsCount>) {
2058 let thread_counts = thread_counts.unwrap_or_default();
2059
2060 let (thread_total_notifications, thread_total_highlights) = thread_counts
2061 .values()
2062 .fold((0_u64, 0_u64), |(n, h), &(notifs, hl)| {
2063 (n.saturating_add(notifs), h.saturating_add(hl))
2064 });
2065
2066 let merge_total = |total: u64| {
2069 move |count: UInt| {
2070 want_thread_unread
2071 .is_false()
2072 .then(|| count.saturating_add(UInt::try_from(total).unwrap_or_default()))
2073 .unwrap_or(count)
2074 }
2075 };
2076
2077 let unread_notifications = UnreadNotificationsCount {
2078 highlight_count: highlight_count
2079 .map(merge_total(thread_total_highlights))
2080 .filter(&send_notification_count_filter),
2081 notification_count: notification_count
2082 .map(merge_total(thread_total_notifications))
2083 .filter(&send_notification_count_filter),
2084 };
2085
2086 let advanced_in_window = |root: &EventId| {
2092 initial
2093 || thread_last_reads
2094 .is_none_or(|reads| reads.get(root).copied().is_some_and(&in_window))
2095 };
2096
2097 let unread_thread_notifications = thread_counts
2098 .into_iter()
2099 .filter(|_| want_thread_unread)
2100 .filter(|(root, _)| advanced_in_window(root))
2101 .map(|(root, (notifications, highlights))| {
2102 let counts = UnreadNotificationsCount {
2103 notification_count: UInt::try_from(notifications).ok(),
2104 highlight_count: UInt::try_from(highlights).ok(),
2105 };
2106
2107 (root, counts)
2108 })
2109 .collect();
2110
2111 (unread_notifications, unread_thread_notifications)
2112}
2113
2114#[tracing::instrument(
2115 name = "state",
2116 level = "trace",
2117 skip_all,
2118 fields(
2119 full = %full_state,
2120 after = ?state_after,
2121 ss = ?since_shortstatehash,
2122 hs = ?horizon_shortstatehash,
2123 as = ?after_shortstatehash,
2124 cs = %current_shortstatehash,
2125 )
2126)]
2127async fn calculate_state_changes<'a>(
2128 services: &Services,
2129 sender_user: &UserId,
2130 room_id: &RoomId,
2131 StateChangeParams {
2132 full_state,
2133 state_after,
2134 since_shortstatehash,
2135 horizon_shortstatehash,
2136 after_shortstatehash,
2137 current_shortstatehash,
2138 joined_since_last_sync,
2139 witness,
2140 include_heroes,
2141 }: StateChangeParams<'a>,
2142) -> Result<StateChanges> {
2143 let incremental = !full_state && !joined_since_last_sync && since_shortstatehash.is_some();
2144
2145 let horizon_shortstatehash = state_after
2150 .requested()
2151 .then_some(after_shortstatehash)
2152 .unwrap_or(horizon_shortstatehash)
2153 .unwrap_or(current_shortstatehash);
2154
2155 let since_shortstatehash = since_shortstatehash.unwrap_or(horizon_shortstatehash);
2156
2157 let state_get_shorteventid = |user_id: &'a UserId| {
2158 services
2159 .state_accessor
2160 .state_get_shortid(
2161 horizon_shortstatehash,
2162 &StateEventType::RoomMember,
2163 user_id.as_str(),
2164 )
2165 .ok()
2166 };
2167
2168 let lazy_state_ids = witness.map_async(|witness| {
2169 witness
2170 .iter()
2171 .stream()
2172 .ready_filter(|&user_id| user_id != sender_user)
2173 .broad_filter_map(|user_id| state_get_shorteventid(user_id))
2174 .into_future()
2175 });
2176
2177 let state_diff_ids = incremental.then_async(|| {
2178 services
2179 .state_accessor
2180 .state_added((since_shortstatehash, horizon_shortstatehash))
2181 .boxed()
2182 .into_future()
2183 });
2184
2185 let current_state_ids = (!incremental).then_async(|| {
2186 services
2187 .state_accessor
2188 .state_full_shortids(horizon_shortstatehash)
2189 .boxed()
2190 .into_future()
2191 });
2192
2193 let after = state_after.requested();
2195 let state_events = current_state_ids
2196 .stream()
2197 .map_ok(|ids| (false, ids))
2198 .chain(
2199 state_diff_ids
2200 .stream()
2201 .map(move |ids| Ok((after, ids))),
2202 )
2203 .broad_and_then(async |(after, (shortstatekey, shorteventid))| {
2204 let event_id =
2205 lazy_filter(services, sender_user, witness, shortstatekey, shorteventid, after)
2206 .await;
2207
2208 Ok(event_id)
2209 })
2210 .ready_try_filter_map(Result::Ok)
2211 .chain(lazy_state_ids.stream().map(Result::Ok))
2212 .broad_and_then(async |shorteventid| {
2213 let pdu = services
2214 .timeline
2215 .get_pdu_from_shorteventid(shorteventid)
2216 .ok()
2217 .await;
2218
2219 Ok(pdu)
2220 })
2221 .ready_try_filter_map(Result::Ok)
2222 .try_collect::<Vec<_>>()
2223 .await?;
2224
2225 let send_member_counts = state_events
2226 .iter()
2227 .any(|event| *event.kind() == RoomMember);
2228
2229 let member_counts = send_member_counts
2230 .then_async(|| calculate_counts(services, room_id, sender_user, include_heroes));
2231
2232 let (joined_member_count, invited_member_count, heroes) =
2233 member_counts.await.unwrap_or((None, None, None));
2234
2235 Ok(StateChanges {
2236 heroes,
2237 joined_member_count,
2238 invited_member_count,
2239 state_events,
2240 })
2241}
2242
2243async fn lazy_filter(
2244 services: &Services,
2245 sender_user: &UserId,
2246 witness: Option<&Witness>,
2247 shortstatekey: ShortStateKey,
2248 shorteventid: ShortEventId,
2249 after: bool,
2250) -> Option<ShortEventId> {
2251 let Some(witness) = witness else {
2252 return Some(shorteventid);
2253 };
2254
2255 let (event_type, state_key) = services
2256 .short
2257 .get_statekey_from_short(shortstatekey)
2258 .await
2259 .ok()?;
2260
2261 let keep = event_type != StateEventType::RoomMember
2264 || state_key == sender_user.as_str()
2265 || (after && <&UserId>::try_from(state_key.as_str()).is_ok_and(|u| !witness.contains(u)));
2266
2267 keep.then_some(shorteventid)
2268}
2269
2270async fn calculate_counts(
2271 services: &Services,
2272 room_id: &RoomId,
2273 sender_user: &UserId,
2274 include_heroes: bool,
2275) -> (Option<u64>, Option<u64>, Option<Vec<OwnedUserId>>) {
2276 let joined_member_count = services
2277 .state_cache
2278 .room_joined_count(room_id)
2279 .unwrap_or(0);
2280
2281 let invited_member_count = services
2282 .state_cache
2283 .room_invited_count(room_id)
2284 .unwrap_or(0);
2285
2286 let (joined_member_count, invited_member_count) =
2287 join(joined_member_count, invited_member_count).await;
2288
2289 let small_room = joined_member_count.saturating_add(invited_member_count) <= 5;
2290
2291 let heroes = services
2292 .config
2293 .calculate_heroes
2294 .and_is(include_heroes)
2295 .and_is(small_room)
2296 .then_async(|| calculate_heroes(services, room_id, sender_user));
2297
2298 (Some(joined_member_count), Some(invited_member_count), heroes.await)
2299}
2300
2301pub(crate) async fn calculate_heroes(
2302 services: &Services,
2303 room_id: &RoomId,
2304 sender_user: &UserId,
2305) -> Vec<OwnedUserId> {
2306 const LIMIT: usize = 5;
2307
2308 services
2309 .state_accessor
2310 .room_state_type_pdus(room_id, &StateEventType::RoomMember)
2311 .ready_filter_map(Result::ok)
2312 .filter_map(|pdu| filter_hero(services, room_id, sender_user, pdu))
2313 .take(LIMIT)
2314 .collect::<Vec<_>>()
2315 .await
2316}
2317
2318async fn filter_hero<Pdu: Event>(
2319 services: &Services,
2320 room_id: &RoomId,
2321 sender_user: &UserId,
2322 pdu: Pdu,
2323) -> Option<OwnedUserId> {
2324 let user_id = pdu.state_key().map(TryInto::try_into).flat_ok()?;
2325
2326 if user_id == sender_user {
2327 return None;
2328 }
2329
2330 let Ok(content): Result<RoomMemberEventContent, _> = pdu.get_content() else {
2331 return None;
2332 };
2333
2334 if !matches!(content.membership, MembershipState::Join | MembershipState::Invite) {
2336 return None;
2337 }
2338
2339 let (is_invited, is_joined) = join(
2340 services.state_cache.is_invited(user_id, room_id),
2341 services.state_cache.is_joined(user_id, room_id),
2342 )
2343 .await;
2344
2345 if !is_joined && is_invited {
2346 return None;
2347 }
2348
2349 Some(user_id.to_owned())
2350}
2351
2352async fn typings_event_for_user(
2353 services: &Services,
2354 room_id: &RoomId,
2355 sender_user: &UserId,
2356) -> Result<SyncEphemeralRoomEvent<TypingEventContent>> {
2357 Ok(SyncEphemeralRoomEvent {
2358 content: TypingEventContent {
2359 user_ids: services
2360 .typing
2361 .typing_users_for_user(room_id, sender_user)
2362 .await?,
2363 },
2364 })
2365}
2366
2367#[cfg(test)]
2368mod tests {
2369 use super::*;
2370
2371 #[test]
2372 fn state_after_wraps_into_named_variant() {
2373 let events = StateEvents::default;
2374
2375 assert!(matches!(StateAfter::Off.wrap(events()), RoomState::Before(_)));
2376 assert!(matches!(StateAfter::Stable.wrap(events()), RoomState::After(_)));
2377 assert!(matches!(StateAfter::Unstable.wrap(events()), RoomState::AfterUnstable(_)));
2378
2379 assert!(!StateAfter::Off.requested());
2380 assert!(StateAfter::Stable.requested());
2381 assert!(StateAfter::Unstable.requested());
2382 }
2383
2384 #[test]
2385 fn state_after_selects_unstable_when_both_opted_in() {
2386 assert!(matches!(StateAfter::from((false, false)), StateAfter::Off));
2388 assert!(matches!(StateAfter::from((true, false)), StateAfter::Stable));
2389 assert!(matches!(StateAfter::from((false, true)), StateAfter::Unstable));
2390 assert!(matches!(StateAfter::from((true, true)), StateAfter::Unstable));
2391 }
2392}