Skip to main content

tuwunel_api/client/sync/
v3.rs

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/// MSC4222 `state_after` opt-in: which room-state field the response carries.
141#[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		// Unstable opt-in wins: such a client reads the unstable field name.
166		match (stable, unstable) {
167			| (_, true) => Self::Unstable,
168			| (true, _) => Self::Stable,
169			| _ => Self::Off,
170		}
171	}
172}
173
174/// # `GET /_matrix/client/r0/sync`
175///
176/// Synchronize the client's state with the latest state on the server.
177///
178/// - This endpoint takes a `since` parameter which should be the `next_batch`
179///   value from a previous request for incremental syncs.
180///
181/// Calling this endpoint without a `since` parameter returns:
182/// - Some of the most recent events of each timeline
183/// - Notification counts for each room
184/// - Joined and invited member counts, heroes
185/// - All state events
186///
187/// Calling this endpoint with a `since` parameter from a previous `next_batch`
188/// returns: For joined rooms:
189/// - Some of the most recent events of each timeline that happened after since
190/// - If user joined the room after since: All state events (unless lazy loading
191///   is activated) and all device list updates in that room
192/// - If the user was already in the room: A list of all events that are in the
193///   state now, but were not in the state at `since`
194/// - If the state we send contains a member event: Joined and invited member
195///   counts, heroes
196/// - Device list updates that happened after `since`
197/// - If there are events in the timeline we send or the user send updated his
198///   read mark: Notification counts
199/// - EDUs that are active now (read receipts, typing updates, presence)
200/// - TODO: Allow multiple sync streams to support Pantalaimon
201///
202/// For invited rooms:
203/// - If the user was invited after `since`: A subset of the state of the room
204///   at the point of the invite
205///
206/// For left rooms:
207/// - If the user left after `since`: `prev_batch` token, empty state (TODO:
208///   subset of the state at the point of the leave)
209#[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	// Record user as actively syncing for push suppression heuristic.
259	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		// Wait for activity
330		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	// MSC4380: when m.invite_permission_config blocks invites, suppress stored
401	// invite events from /sync entirely; a later unblock re-exposes them.
402	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	// Remove all to-device events the device received *last time*
467	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			// Invited before last sync
629			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			// Knocked before last sync; or after the cutoff for this sync
662			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	// Cannot sync unless the event falls within the snapshot. The room is only
783	// sync'ed once to the client, after that it's too late.
784	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		// For rejected invites, deleted, missing, or broken room state this is the last
797		// resort to convey a the minimum of information to the client.
798		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			// The following keys are dropped on conversion
806			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	// MSC4222 `state_after`: state at the leave (end of timeline). The
894	// stored shortstatehash at the leave PDU is state-before-leave, so
895	// step to the next PDU; if no event followed, the room's current
896	// shortstatehash is the post-leave state.
897	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	// `encrypted_room` is `Some` whenever timeline or state events are emitted.
1159	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	// MSC4222: when the client opts into `state_after`, state events that
1316	// took effect within the timeline appear in both the timeline and the
1317	// state section, so the in-timeline exclusion is bypassed.
1318	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	// MSC4222 `state_after` semantics: state at the *end* of the timeline
1633	// window. `next_shortstatehash` reads state-before the next PDU, which
1634	// equals state-after our last PDU; falling back to the room's current
1635	// state covers the case where our window already touches HEAD.
1636	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	// MSC3773: per-thread counts. Filtered downstream by per-thread last-read
1767	// so quiet threads are omitted on rounds where the main cursor advanced.
1768	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	// Reset lazy loading because this is an initial sync
1834	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	// Send room-level counts when either the main read cursor or any thread
1922	// cursor advanced within the window. Thread-only resets do not bump the
1923	// main cursor, so without the thread leg they would never reach the
1924	// client.
1925	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	// MSC3773: when the client opts in via the timeline filter, partition
2067	// notification counts per thread. Otherwise sum into the room total.
2068	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	// On quiet rounds (timeline empty) `thread_last_reads` is `Some`; emit
2087	// only threads whose read cursor advanced within the window. When the
2088	// timeline carried events `thread_last_reads` is `None`; emit all.
2089	// Initial sync (since == 0) is a full snapshot; bypass the gate so
2090	// clients with no prior cursor still see existing thread counts.
2091	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	// MSC4222: `state_after` requests need state at the *end* of the
2146	// timeline; legacy `state` requests need state at the *start*. Pick
2147	// the right delta endpoint, falling back to the room's current
2148	// shortstatehash when the preferred lookup is unavailable.
2149	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	// Full dump is strict; the delta relaxes the member filter under MSC4222.
2194	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	// An MSC4222 delta also keeps changed members the witness will not re-add
2262	// (lazy_state_ids covers witnessed ones), avoiding both a miss and a duplicate.
2263	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	// The membership was and still is invite or join
2335	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		// (use_state_after, use_state_after_unstable)
2387		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}