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 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 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() .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 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 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 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() .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() .into_future()
333 });
334
335 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 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
445pub 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 if !matches!(content.membership, MembershipState::Join | MembershipState::Invite) {
483 return None;
484 }
485
486 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}