Skip to main content

tuwunel_service/pusher/
notification.rs

1use std::{collections::BTreeMap, fmt::Debug};
2
3use futures::{StreamExt, future::join3, stream::select};
4use ruma::{EventId, OwnedEventId, RoomId, UserId, events::receipt::ReceiptThread};
5use serde::Serialize;
6use tuwunel_core::{
7	Result, implement, trace,
8	utils::{
9		stream::{BroadbandExt, ReadyExt, TryIgnore},
10		u64_from_u8,
11	},
12};
13use tuwunel_database::{
14	Deserialized, Ignore, IgnoreAll, Interfix, KeyBuf, deserialize_from_slice as deserialize_key,
15};
16
17/// Per-thread unread counts: `(notification, highlight)` keyed by thread root.
18type ThreadCounts = BTreeMap<OwnedEventId, (u64, u64)>;
19
20/// Per-thread last-read counts keyed by thread root. Used by sync v3 to
21/// gate emission of `unread_thread_notifications` to threads whose read
22/// cursor advanced within the sync window.
23type ThreadLastReads = BTreeMap<OwnedEventId, u64>;
24
25/// Reset the room's main-timeline notification counts.
26///
27/// The last-read stamp gates sync output; callers dispatch the badge refresh
28/// after every reset.
29#[implement(super::Service)]
30#[tracing::instrument(level = "debug", skip(self))]
31pub async fn reset_notification_counts(&self, user_id: &UserId, room_id: &RoomId) {
32	let count = self.services.globals.next_count();
33
34	let userroom_id = (user_id, room_id);
35
36	self.reset_notification_count(room_id, user_id, userroom_id)
37		.await;
38
39	self.db
40		.userroomid_highlightcount
41		.put(userroom_id, 0_u64);
42
43	let roomuser_id = (room_id, user_id);
44	self.db
45		.roomuserid_lastnotificationread
46		.put(roomuser_id, *count);
47
48	let removed = self.clear_suppressed_room(user_id, room_id);
49	if removed > 0 {
50		trace!(?user_id, ?room_id, removed, "Cleared suppressed push events after read");
51	}
52}
53
54#[implement(super::Service)]
55async fn reset_notification_count<K>(&self, room_id: &RoomId, user_id: &UserId, key: K)
56where
57	K: Serialize + Debug + Send + Sync,
58{
59	// The increment path is a read-modify-write under this lock; an unlocked
60	// zero could land inside it and be overwritten by the stale sum.
61	let _lock = self
62		.notification_increment_mutex
63		.lock(&(room_id.to_owned(), user_id.to_owned()))
64		.await;
65
66	self.db
67		.userroomid_notificationcount
68		.put(key, 0_u64);
69}
70
71/// Reset counts for a single thread within a room.
72///
73/// The last-read stamp gates sync output.
74#[implement(super::Service)]
75#[tracing::instrument(level = "debug", skip(self))]
76pub async fn reset_thread_notification_counts(
77	&self,
78	user_id: &UserId,
79	room_id: &RoomId,
80	thread_root: &EventId,
81) {
82	let count = self.services.globals.next_count();
83
84	let userroom_thread = (user_id, room_id, thread_root);
85
86	self.reset_notification_count(room_id, user_id, userroom_thread)
87		.await;
88
89	self.db
90		.userroomid_highlightcount
91		.put(userroom_thread, 0_u64);
92
93	let roomuser_thread = (room_id, user_id, thread_root);
94	self.db
95		.roomuserid_lastnotificationread
96		.put(roomuser_thread, *count);
97}
98
99/// Clear all per-thread notification state for this user and room.
100///
101/// The `Interfix` prefix excludes the main row. The notification-count sweep
102/// runs under the increment mutex so a concurrent read-modify-write cannot
103/// resurrect a cleared row.
104#[implement(super::Service)]
105#[tracing::instrument(level = "debug", skip(self))]
106pub async fn clear_all_thread_notification_counts(&self, user_id: &UserId, room_id: &RoomId) {
107	let userroom_prefix = (user_id, room_id, Interfix);
108	let roomuser_prefix = (room_id, user_id, Interfix);
109
110	let highlights = self
111		.db
112		.userroomid_highlightcount
113		.del_prefix(&userroom_prefix);
114
115	let last_reads = self
116		.db
117		.roomuserid_lastnotificationread
118		.del_prefix(&roomuser_prefix);
119
120	let notifications = async {
121		let _lock = self
122			.notification_increment_mutex
123			.lock(&(room_id.to_owned(), user_id.to_owned()))
124			.await;
125
126		self.db
127			.userroomid_notificationcount
128			.del_prefix(&userroom_prefix)
129			.await;
130	};
131
132	join3(notifications, highlights, last_reads).await;
133}
134
135/// Dispatcher: route a receipt's `ReceiptThread` to the matching reset path.
136///
137/// `Unthreaded` clears all room and thread counts; `Main` clears only the
138/// main-timeline counts; `Thread(id)` clears just that thread.
139#[implement(super::Service)]
140pub async fn reset_notification_counts_for_thread(
141	&self,
142	user_id: &UserId,
143	room_id: &RoomId,
144	thread: &ReceiptThread,
145) {
146	match thread {
147		| ReceiptThread::Main =>
148			self.reset_notification_counts(user_id, room_id)
149				.await,
150		| ReceiptThread::Thread(root) =>
151			self.reset_thread_notification_counts(user_id, room_id, root)
152				.await,
153		| _ => {
154			self.reset_notification_counts(user_id, room_id)
155				.await;
156
157			self.clear_all_thread_notification_counts(user_id, room_id)
158				.await;
159		},
160	}
161}
162
163#[implement(super::Service)]
164#[tracing::instrument(level = "debug", skip(self), ret(level = "trace"))]
165pub async fn notification_count(&self, user_id: &UserId, room_id: &RoomId) -> u64 {
166	let key = (user_id, room_id);
167	self.db
168		.userroomid_notificationcount
169		.qry(&key)
170		.await
171		.deserialized()
172		.unwrap_or(0)
173}
174
175/// Return the user's account-wide unread notification count.
176///
177/// Joined main and thread rows contribute to a saturating total.
178#[implement(super::Service)]
179#[tracing::instrument(level = "trace", skip(self), ret)]
180pub async fn global_notification_count(&self, user_id: &UserId) -> u64 {
181	self.db
182		.userroomid_notificationcount
183		.stream_prefix_raw(&(user_id, Interfix))
184		.ignore_err()
185		.ready_filter_map(|(key, count)| {
186			let count = u64_from_u8(count);
187
188			(count > 0).then(|| (KeyBuf::from(key), count))
189		})
190		.broad_filter_map(async |(key, count)| {
191			let (_, room_id, _): (Ignore, &RoomId, IgnoreAll) =
192				deserialize_key(&key).expect("notification count key");
193
194			self.services
195				.state_cache
196				.is_joined(user_id, room_id)
197				.await
198				.then_some(count)
199		})
200		.ready_fold(0_u64, u64::saturating_add)
201		.await
202}
203
204#[implement(super::Service)]
205#[tracing::instrument(level = "debug", skip(self), ret(level = "trace"))]
206pub async fn highlight_count(&self, user_id: &UserId, room_id: &RoomId) -> u64 {
207	let key = (user_id, room_id);
208	self.db
209		.userroomid_highlightcount
210		.qry(&key)
211		.await
212		.deserialized()
213		.unwrap_or(0)
214}
215
216/// Per-thread `(notification, highlight)` counts for one room and user.
217/// `Interfix` excludes the legacy 2-tuple main row from the scan; only
218/// 3-tuple `(user, room, root)` rows match.
219#[implement(super::Service)]
220#[tracing::instrument(level = "debug", skip(self))]
221pub async fn thread_notification_counts(
222	&self,
223	user_id: &UserId,
224	room_id: &RoomId,
225) -> ThreadCounts {
226	let prefix = (user_id, room_id, Interfix);
227	let notifications = self
228		.db
229		.userroomid_notificationcount
230		.stream_prefix(&prefix)
231		.ignore_err()
232		.map(notification_kv);
233
234	let highlights = self
235		.db
236		.userroomid_highlightcount
237		.stream_prefix(&prefix)
238		.ignore_err()
239		.map(highlight_kv);
240
241	select(notifications, highlights)
242		.ready_fold(ThreadCounts::default(), merge_thread_count)
243		.await
244}
245
246fn notification_kv(
247	(key, notifications): ((&UserId, &RoomId, OwnedEventId), u64),
248) -> (OwnedEventId, (u64, u64)) {
249	(key.2, (notifications, 0))
250}
251
252fn highlight_kv(
253	(key, highlights): ((&UserId, &RoomId, OwnedEventId), u64),
254) -> (OwnedEventId, (u64, u64)) {
255	(key.2, (0, highlights))
256}
257
258fn merge_thread_count(
259	mut counts: ThreadCounts,
260	(root, (notifications, highlights)): (OwnedEventId, (u64, u64)),
261) -> ThreadCounts {
262	let entry = counts.entry(root).or_default();
263	entry.0 = entry.0.saturating_add(notifications);
264	entry.1 = entry.1.saturating_add(highlights);
265	counts
266}
267
268#[implement(super::Service)]
269#[tracing::instrument(level = "debug", skip(self), ret(level = "trace"))]
270pub async fn last_notification_read(&self, user_id: &UserId, room_id: &RoomId) -> Result<u64> {
271	let key = (room_id, user_id);
272	self.db
273		.roomuserid_lastnotificationread
274		.qry(&key)
275		.await
276		.deserialized()
277}
278
279/// Per-thread last-read counts for one room and user. `Interfix` keeps the
280/// scan to 3-tuple `(room, user, root)` rows; the legacy 2-tuple main row
281/// is excluded by construction and lives behind `last_notification_read`.
282#[implement(super::Service)]
283#[tracing::instrument(level = "debug", skip(self))]
284pub async fn thread_last_notification_reads(
285	&self,
286	user_id: &UserId,
287	room_id: &RoomId,
288) -> ThreadLastReads {
289	let prefix = (room_id, user_id, Interfix);
290	self.db
291		.roomuserid_lastnotificationread
292		.stream_prefix(&prefix)
293		.ignore_err()
294		.map(|((_, _, root), count): ((Ignore, Ignore, OwnedEventId), u64)| (root, count))
295		.collect()
296		.await
297}
298
299#[implement(super::Service)]
300pub async fn delete_room_notification_read(&self, room_id: &RoomId) -> Result {
301	let key = (room_id, Interfix);
302	self.db
303		.roomuserid_lastnotificationread
304		.keys_prefix_raw(&key)
305		.ignore_err()
306		.ready_for_each(|key| {
307			trace!("Removing key: {key:?}");
308			self.db
309				.roomuserid_lastnotificationread
310				.remove(key);
311		})
312		.await;
313
314	Ok(())
315}