Skip to main content

tuwunel_api_client/sync/v3/
state.rs

1use std::collections::BTreeMap;
2
3use futures::{
4	FutureExt, StreamExt, TryFutureExt, TryStreamExt,
5	future::{join, join3, join4, try_join},
6};
7use ruma::{
8	DeviceId, OwnedEventId, OwnedUserId, RoomId, UserId,
9	api::client::filter::FilterDefinition,
10	events::{
11		AnySyncEphemeralRoomEvent, StateEventType, SyncEphemeralRoomEvent,
12		TimelineEventType::*,
13		room::member::{MembershipState, RoomMemberEventContent},
14		typing::TypingEventContent,
15	},
16	serde::Raw,
17};
18use tuwunel_core::{
19	Result, at,
20	error::inspect_debug_log,
21	is_false, ref_at,
22	result::FlatOk,
23	utils::{
24		BoolExt, FutureBoolExt, IterStream, ReadyExt, TryFutureExtExt,
25		future::{OptionStream, ReadyBoolExt},
26		option::OptionExt,
27		stream::{BroadbandExt, TryBroadbandExt, TryReadyExt},
28	},
29};
30use tuwunel_matrix::{
31	Event,
32	pdu::{PduCount, PduEvent},
33};
34use tuwunel_service::{
35	Services,
36	rooms::{
37		lazy_loading,
38		lazy_loading::{Options, Witness},
39		short::{ShortEventId, ShortStateKey},
40	},
41};
42
43use super::{
44	super::joined_since, StateAfter, StateChangeParams, StateChanges, joined::RoomMetadata,
45	left::log_horizon_error, missing_room_state,
46};
47
48pub(super) struct UserMetadata {
49	pub(super) witness: Option<Witness>,
50	pub(super) last_notification_read: Option<u64>,
51	pub(super) thread_last_reads: BTreeMap<OwnedEventId, u64>,
52	pub(super) last_privateread_update: u64,
53	pub(super) joined_since_last_sync: bool,
54}
55
56#[expect(clippy::too_many_arguments)]
57pub(super) async fn gather_room_metadata(
58	services: &Services,
59	sender_user: &UserId,
60	room_id: &RoomId,
61	since: u64,
62	next_batch: u64,
63	timeline_pdus: &[(PduCount, PduEvent)],
64	last_timeline_count: PduCount,
65	include_state: bool,
66	state_after: StateAfter,
67) -> Result<RoomMetadata> {
68	let since_shortstatehash = include_state.then_async(|| {
69		services
70			.timeline
71			.prev_shortstatehash(room_id, PduCount::Normal(since).saturating_add(1))
72			.ok()
73	});
74
75	let horizon_shortstatehash = timeline_pdus
76		.first()
77		.map(at!(0))
78		.map_async(|count| {
79			services
80				.timeline
81				.get_shortstatehash(room_id, count)
82				.inspect_err(log_horizon_error)
83		});
84
85	// MSC4222 `state_after` describes the state at the end of the timeline.
86	let after_shortstatehash = state_after.requested().then_async(|| {
87		services
88			.timeline
89			.shortstatehash_after(room_id, last_timeline_count)
90			.inspect_err(inspect_debug_log)
91	});
92
93	let current_shortstatehash = include_state.then_async(|| {
94		// An empty timeline needs state after the last event, not before it.
95		timeline_pdus
96			.is_empty()
97			.then(|| {
98				services
99					.timeline
100					.next_shortstatehash(room_id, last_timeline_count)
101					.left_future()
102			})
103			.unwrap_or_else(|| {
104				services
105					.timeline
106					.get_shortstatehash(room_id, last_timeline_count)
107					.inspect_err(inspect_debug_log)
108					.right_future()
109			})
110			.or_else(|_| services.state.get_room_shortstatehash(room_id))
111			.map_err(|_| missing_room_state(room_id))
112	});
113
114	let encrypted_room =
115		include_state.then_async(|| services.state_accessor.is_encrypted_room(room_id));
116
117	let receipt_events = services
118		.read_receipt
119		.readreceipts_since(room_id, since, Some(next_batch))
120		.filter_map(async |(read_user, _, edu)| {
121			services
122				.users
123				.user_is_ignored(read_user, sender_user)
124				.await
125				.or_some((read_user.to_owned(), edu))
126		})
127		.collect::<Vec<(OwnedUserId, Raw<AnySyncEphemeralRoomEvent>)>>();
128
129	let (
130		(
131			since_shortstatehash,
132			horizon_shortstatehash,
133			after_shortstatehash,
134			current_shortstatehash,
135		),
136		receipt_events,
137		encrypted_room,
138	) = join3(
139		join4(
140			since_shortstatehash,
141			horizon_shortstatehash,
142			after_shortstatehash,
143			current_shortstatehash,
144		),
145		receipt_events,
146		encrypted_room,
147	)
148	.boxed() // erase region
149	.await;
150
151	Ok(RoomMetadata {
152		since_shortstatehash: since_shortstatehash.flatten(),
153		horizon_shortstatehash: horizon_shortstatehash.flat_ok(),
154		after_shortstatehash: after_shortstatehash.flat_ok(),
155		current_shortstatehash: current_shortstatehash.transpose()?,
156		receipt_events,
157		encrypted_room,
158	})
159}
160
161#[expect(clippy::too_many_arguments)]
162pub(super) async fn gather_user_metadata(
163	services: &Services,
164	sender_user: &UserId,
165	sender_device: Option<&DeviceId>,
166	room_id: &RoomId,
167	filter: &FilterDefinition,
168	timeline_pdus: &[(PduCount, PduEvent)],
169	receipt_events: &[(OwnedUserId, Raw<AnySyncEphemeralRoomEvent>)],
170	since: u64,
171	initial: bool,
172	timeline_changed: bool,
173	encrypted_room: Option<bool>,
174) -> UserMetadata {
175	let lazy_load_options =
176		[&filter.room.state.lazy_load_options, &filter.room.timeline.lazy_load_options];
177
178	let lazy_loading_enabled = encrypted_room.is_some_and(is_false!())
179		&& lazy_load_options
180			.iter()
181			.any(|opts| opts.is_enabled());
182
183	let lazy_loading_context = &lazy_loading::Context {
184		user_id: sender_user,
185		device_id: sender_device,
186		room_id,
187		token: Some(since),
188		options: Some(&filter.room.state.lazy_load_options),
189		mode: lazy_loading::Mode::Update,
190	};
191
192	// Reset lazy loading because this is an initial sync
193	let lazy_load_reset =
194		initial.then_async(|| services.lazy_loading.reset(lazy_loading_context));
195
196	lazy_load_reset.await;
197	let witness = lazy_loading_enabled.then_async(|| {
198		let witness: Witness = timeline_pdus
199			.iter()
200			.map(ref_at!(1))
201			.map(Event::sender)
202			.map(Into::into)
203			.chain(receipt_events.iter().map(ref_at!(0)).cloned())
204			.collect();
205
206		services
207			.lazy_loading
208			.witness_retain(witness, lazy_loading_context)
209	});
210
211	let joined_since_last_sync =
212		timeline_changed.then_async(|| joined_since(services, room_id, sender_user, since));
213
214	// Busy rounds need these cursors too, to authorize an explicit zero count.
215	let last_notification_read = services
216		.pusher
217		.last_notification_read(sender_user, room_id)
218		.ok();
219
220	let thread_last_reads = services
221		.pusher
222		.thread_last_notification_reads(sender_user, room_id);
223
224	let last_privateread_update = services
225		.read_receipt
226		.last_privateread_update(sender_user, room_id);
227
228	let (
229		(last_privateread_update, last_notification_read, thread_last_reads),
230		joined_since_last_sync,
231		witness,
232	) = join3(
233		join3(last_privateread_update, last_notification_read, thread_last_reads),
234		joined_since_last_sync,
235		witness,
236	)
237	.await;
238
239	UserMetadata {
240		witness,
241		last_notification_read,
242		thread_last_reads,
243		last_privateread_update,
244		joined_since_last_sync: joined_since_last_sync.unwrap_or_default(),
245	}
246}
247
248#[tracing::instrument(
249	target = "tuwunel_api_client::sync::v3",
250	name = "state",
251	level = "trace",
252	skip_all,
253	fields(
254	    full = %full_state,
255	    after = ?state_after,
256	    ss = ?since_shortstatehash,
257	    hs = ?horizon_shortstatehash,
258	    as = ?after_shortstatehash,
259	    cs = %current_shortstatehash,
260    )
261)]
262pub(super) async fn calculate_state_changes<'a>(
263	services: &Services,
264	sender_user: &UserId,
265	room_id: &RoomId,
266	StateChangeParams {
267		full_state,
268		state_after,
269		since_shortstatehash,
270		horizon_shortstatehash,
271		after_shortstatehash,
272		current_shortstatehash,
273		joined_since_last_sync,
274		witness,
275		include_heroes,
276	}: StateChangeParams<'a>,
277) -> Result<StateChanges> {
278	let incremental = !full_state && !joined_since_last_sync && since_shortstatehash.is_some();
279
280	// MSC4222: `state_after` requests need state at the *end* of the
281	// timeline; legacy `state` requests need state at the *start*. Pick
282	// the right delta endpoint, falling back to the room's current
283	// shortstatehash when the preferred lookup is unavailable.
284	let horizon_shortstatehash = state_after
285		.requested()
286		.then_some(after_shortstatehash)
287		.unwrap_or(horizon_shortstatehash)
288		.unwrap_or(current_shortstatehash);
289
290	let since_shortstatehash = since_shortstatehash.unwrap_or(horizon_shortstatehash);
291
292	let state_get_shorteventid = |user_id: &'a UserId| {
293		services
294			.state_accessor
295			.state_get_shortid(
296				horizon_shortstatehash,
297				&StateEventType::RoomMember,
298				user_id.as_str(),
299			)
300			.ok()
301	};
302
303	let get_pdu = |shorteventid: ShortEventId| {
304		services
305			.timeline
306			.get_pdu_from_shorteventid(shorteventid)
307			.ok()
308	};
309
310	let lazy_state_ids = witness.map_async(|witness| {
311		witness
312			.iter()
313			.stream()
314			.ready_filter(|&user_id| user_id != sender_user)
315			.broad_filter_map(|user_id| state_get_shorteventid(user_id))
316			.into_future()
317	});
318
319	let state_diff_ids = incremental.then_async(|| {
320		services
321			.state_accessor
322			.state_added((since_shortstatehash, horizon_shortstatehash))
323			.boxed() // Unpin for into_future
324			.into_future()
325	});
326
327	let current_state_ids = (!incremental).then_async(|| {
328		services
329			.state_accessor
330			.state_full_shortids(horizon_shortstatehash)
331			.boxed() // Unpin for into_future
332			.into_future()
333	});
334
335	// Full dump is strict; the delta relaxes the member filter under MSC4222.
336	let after = state_after.requested();
337	let state_events = current_state_ids
338		.stream()
339		.map_ok(|ids| (false, ids))
340		.chain(
341			state_diff_ids
342				.stream()
343				.map(move |ids| Ok((after, ids))),
344		)
345		.broad_and_then(async |(after, (shortstatekey, shorteventid))| {
346			let event_id =
347				lazy_filter(services, sender_user, witness, shortstatekey, shorteventid, after)
348					.await;
349
350			Ok(event_id)
351		})
352		.ready_try_filter_map(Result::Ok)
353		.broad_and_then(|shorteventid| get_pdu(shorteventid).map(Ok))
354		.ready_try_filter_map(Result::Ok)
355		.try_collect::<Vec<_>>();
356
357	let lazy_members = lazy_state_ids
358		.stream()
359		.broad_filter_map(get_pdu)
360		.collect::<Vec<_>>()
361		.map(Ok);
362
363	let (state_events, lazy_members) = try_join(state_events, lazy_members).await?;
364
365	let send_member_counts = state_events
366		.iter()
367		.chain(lazy_members.iter())
368		.any(|event| *event.kind() == RoomMember);
369
370	let member_counts = send_member_counts
371		.then_async(|| calculate_counts(services, room_id, sender_user, include_heroes));
372
373	let (joined_member_count, invited_member_count, heroes) =
374		member_counts.await.unwrap_or((None, None, None));
375
376	Ok(StateChanges {
377		heroes,
378		joined_member_count,
379		invited_member_count,
380		state_events,
381		lazy_members,
382	})
383}
384
385async fn lazy_filter(
386	services: &Services,
387	sender_user: &UserId,
388	witness: Option<&Witness>,
389	shortstatekey: ShortStateKey,
390	shorteventid: ShortEventId,
391	after: bool,
392) -> Option<ShortEventId> {
393	let Some(witness) = witness else {
394		return Some(shorteventid);
395	};
396
397	let (event_type, state_key) = services
398		.short
399		.get_statekey_from_short(shortstatekey)
400		.await
401		.ok()?;
402
403	// An MSC4222 delta also keeps changed members the witness will not re-add
404	// (lazy_state_ids covers witnessed ones), avoiding both a miss and a duplicate.
405	let keep = event_type != StateEventType::RoomMember
406		|| state_key == sender_user.as_str()
407		|| (after && <&UserId>::try_from(state_key.as_str()).is_ok_and(|u| !witness.contains(u)));
408
409	keep.then_some(shorteventid)
410}
411
412async fn calculate_counts(
413	services: &Services,
414	room_id: &RoomId,
415	sender_user: &UserId,
416	include_heroes: bool,
417) -> (Option<u64>, Option<u64>, Option<Vec<OwnedUserId>>) {
418	let joined_member_count = services
419		.state_cache
420		.room_joined_count(room_id)
421		.unwrap_or(0);
422
423	let invited_member_count = services
424		.state_cache
425		.room_invited_count(room_id)
426		.unwrap_or(0);
427
428	let (joined_member_count, invited_member_count) =
429		join(joined_member_count, invited_member_count).await;
430
431	let small_room = joined_member_count.saturating_add(invited_member_count) <= 5;
432
433	let heroes = services
434		.config
435		.client
436		.sync
437		.calculate_heroes
438		.and_is(include_heroes)
439		.and_is(small_room)
440		.then_async(|| calculate_heroes(services, room_id, sender_user));
441
442	(Some(joined_member_count), Some(invited_member_count), heroes.await)
443}
444
445/// Selects up to five room members for the room summary.
446///
447/// The requesting user and members excluded by the hero filter are omitted.
448pub async fn calculate_heroes(
449	services: &Services,
450	room_id: &RoomId,
451	sender_user: &UserId,
452) -> Vec<OwnedUserId> {
453	const LIMIT: usize = 5;
454
455	services
456		.state_accessor
457		.room_state_type_pdus(room_id, &StateEventType::RoomMember)
458		.ready_filter_map(Result::ok)
459		.filter_map(|pdu| filter_hero(services, room_id, sender_user, pdu))
460		.take(LIMIT)
461		.collect::<Vec<_>>()
462		.await
463}
464
465async fn filter_hero<Pdu: Event>(
466	services: &Services,
467	room_id: &RoomId,
468	sender_user: &UserId,
469	pdu: Pdu,
470) -> Option<OwnedUserId> {
471	let user_id = pdu.state_key().map(TryInto::try_into).flat_ok()?;
472
473	if user_id == sender_user {
474		return None;
475	}
476
477	let Ok(content): Result<RoomMemberEventContent, _> = pdu.get_content() else {
478		return None;
479	};
480
481	// The membership was and still is invite or join
482	if !matches!(content.membership, MembershipState::Join | MembershipState::Invite) {
483		return None;
484	}
485
486	// The join read leads; a joined hero settles it before the invite read.
487	let is_hero = services
488		.state_cache
489		.is_joined(user_id, room_id)
490		.is_false()
491		.and(services.state_cache.is_invited(user_id, room_id))
492		.is_false();
493
494	is_hero.await.then(|| user_id.to_owned())
495}
496
497pub(super) async fn typings_event_for_user(
498	services: &Services,
499	room_id: &RoomId,
500	sender_user: &UserId,
501) -> Result<SyncEphemeralRoomEvent<TypingEventContent>> {
502	Ok(SyncEphemeralRoomEvent {
503		content: TypingEventContent {
504			user_ids: services
505				.typing
506				.typing_users_for_user(room_id, sender_user)
507				.await?,
508		},
509	})
510}