Skip to main content

tuwunel_api_client/sync/v3/joined/
notifications.rs

1use std::collections::BTreeMap;
2
3use futures::FutureExt;
4use ruma::{
5	OwnedEventId, RoomId, UInt, UserId, api::client::sync::sync_events::UnreadNotificationsCount,
6	uint,
7};
8use tuwunel_core::utils::{BoolExt, TryFutureExtExt};
9use tuwunel_service::Services;
10
11use super::ThreadCounts;
12
13pub(in super::super) struct GateInputs<'a> {
14	pub(in super::super) last_notification_read: Option<u64>,
15	pub(in super::super) thread_last_reads: &'a BTreeMap<OwnedEventId, u64>,
16	pub(in super::super) quiet_round: bool,
17	pub(in super::super) since: u64,
18}
19
20pub(in super::super) struct Gates<F> {
21	pub(in super::super) send_notification_counts: bool,
22	pub(in super::super) send_notification_count_filter: F,
23}
24
25pub(in super::super) fn count_futures<'a>(
26	services: &'a Services,
27	sender_user: &'a UserId,
28	room_id: &'a RoomId,
29	send: bool,
30) -> (
31	impl Future<Output = Option<UInt>> + Send + 'a,
32	impl Future<Output = Option<UInt>> + Send + 'a,
33	impl Future<Output = Option<ThreadCounts>> + Send + 'a,
34) {
35	let notification_count = send.then_async(move || {
36		services
37			.pusher
38			.notification_count(sender_user, room_id)
39			.map(TryInto::try_into)
40			.unwrap_or(uint!(0))
41	});
42
43	let highlight_count = send.then_async(move || {
44		services
45			.pusher
46			.highlight_count(sender_user, room_id)
47			.map(TryInto::try_into)
48			.unwrap_or(uint!(0))
49	});
50
51	// MSC3773: per-thread counts.
52	let thread_counts = send.then_async(move || {
53		services
54			.pusher
55			.thread_notification_counts(sender_user, room_id)
56	});
57
58	(notification_count, highlight_count, thread_counts)
59}
60
61/// The window a sync round reports a cursor advance in.
62///
63/// The lower bound is the round's own token and the upper bound is the
64/// response cutoff captured before the room is assembled, so a cursor stamped
65/// after that cutoff falls outside it.
66pub(in super::super) fn in_window(since: u64, next_batch: u64) -> impl Fn(u64) -> bool + Copy {
67	move |count| count > since && count <= next_batch
68}
69
70/// The gates deciding what notification counts a joined room reports.
71///
72/// A room count, or a zero thread count, reaches the client when the round
73/// carried timeline events or a read cursor advanced within the window. An
74/// explicit zero room count additionally needs a cursor newer than the request
75/// token, and because the response cutoff is captured before that cursor is
76/// read, a cursor past the cutoff repeats the zero until the token catches up.
77pub(in super::super) fn compute_gates<F: Fn(u64) -> bool>(
78	GateInputs {
79		last_notification_read,
80		thread_last_reads,
81		quiet_round,
82		since,
83	}: GateInputs<'_>,
84	in_window: F,
85) -> Gates<impl Fn(&UInt) -> bool + use<F>> {
86	// Thread-only resets leave the main cursor alone, so without the thread leg
87	// a quiet round would never report them.
88	let send_notification_counts = !quiet_round
89		|| last_notification_read.is_none_or(&in_window)
90		|| thread_last_reads
91			.values()
92			.copied()
93			.any(&in_window);
94
95	let after_since = |count: u64| count > since;
96
97	// A cursor newer than since means the client may still cache the pre-reset
98	// count, so allow an explicit zero to reconcile it.
99	let send_notification_resets = last_notification_read.is_some_and(after_since)
100		|| thread_last_reads
101			.values()
102			.copied()
103			.any(&after_since);
104
105	let send_notification_count_filter =
106		move |count: &UInt| *count != uint!(0) || send_notification_resets;
107
108	Gates {
109		send_notification_counts,
110		send_notification_count_filter,
111	}
112}
113
114pub(in super::super) fn assemble_unread(
115	notification_count: Option<UInt>,
116	highlight_count: Option<UInt>,
117	thread_counts: Option<&ThreadCounts>,
118	send_notification_count_filter: impl Fn(&UInt) -> bool,
119	want_thread_unread: bool,
120) -> UnreadNotificationsCount {
121	// MSC3773: an opted-in client takes thread counts per thread, so they stay
122	// out of the room totals.
123	let (thread_total_notifications, thread_total_highlights) = thread_counts
124		.filter(|_| want_thread_unread.is_false())
125		.into_iter()
126		.flat_map(BTreeMap::values)
127		.fold((0_u64, 0_u64), |(n, h), &(notifs, hl)| {
128			(n.saturating_add(notifs), h.saturating_add(hl))
129		});
130
131	let merge_total = |total: u64| {
132		move |count: UInt| count.saturating_add(UInt::try_from(total).unwrap_or_default())
133	};
134
135	UnreadNotificationsCount {
136		highlight_count: highlight_count
137			.map(merge_total(thread_total_highlights))
138			.filter(&send_notification_count_filter),
139		notification_count: notification_count
140			.map(merge_total(thread_total_notifications))
141			.filter(&send_notification_count_filter),
142	}
143}
144
145/// Whether a joined-room entry carries MSC3773 per-thread counts.
146///
147/// A thread `admitted` names may report an otherwise empty room. Any other
148/// count only rides on a room the round already reports, or a room whose only
149/// change is a count would repeat in every round.
150pub(in super::super) fn send_thread_counts<'a, I>(
151	reported: bool,
152	thread_roots: I,
153	admitted: impl Fn(&OwnedEventId) -> bool,
154) -> bool
155where
156	I: IntoIterator<Item = &'a OwnedEventId>,
157{
158	reported || thread_roots.into_iter().any(admitted)
159}
160
161/// MSC3773 per-thread counts for a room the round reports.
162///
163/// Clients read a thread missing from the map as having no notifications
164/// (matrix-js-sdk clears that thread's badge), so every non-zero count is sent
165/// whether or not it changed. A zero is sent only where `send_zero` admits it.
166pub(in super::super) fn assemble_threads(
167	thread_counts: Option<ThreadCounts>,
168	send_zero: impl Fn(&OwnedEventId) -> bool,
169) -> BTreeMap<OwnedEventId, UnreadNotificationsCount> {
170	thread_counts
171		.into_iter()
172		.flatten()
173		.filter(|(root, counts)| *counts != (0, 0) || send_zero(root))
174		.map(|(root, (notifications, highlights))| {
175			let counts = UnreadNotificationsCount {
176				notification_count: UInt::try_from(notifications).ok(),
177				highlight_count: UInt::try_from(highlights).ok(),
178			};
179
180			(root, counts)
181		})
182		.collect()
183}