Skip to main content

tuwunel_service/rooms/typing/
mod.rs

1use std::{collections::BTreeMap, sync::Arc};
2
3use futures::{FutureExt, TryStreamExt, future::try_join};
4use ruma::{
5	OwnedRoomId, OwnedUserId, RoomId, UserId,
6	api::{
7		appservice::event::push_events::v1::EphemeralData,
8		federation::transactions::edu::{Edu, TypingContent},
9	},
10	events::{
11		EphemeralRoomEvent, GlobalAccountDataEventType, ignored_user_list::IgnoredUserListEvent,
12		typing::TypingEventContent,
13	},
14};
15use tokio::sync::{RwLock, broadcast};
16use tuwunel_core::{
17	Result, Server,
18	debug::INFO_SPAN_LEVEL,
19	debug_info, trace,
20	utils::{BoolExt, IterStream, millis_since_unix_epoch},
21};
22
23use crate::sending::EduBuf;
24
25pub struct Service {
26	server: Arc<Server>,
27	services: Arc<crate::services::OnceServices>,
28	typing: RwLock<BTreeMap<OwnedRoomId, RoomTyping>>,
29	pub typing_update_sender: broadcast::Sender<OwnedRoomId>,
30}
31
32#[derive(Default)]
33struct RoomTyping {
34	// Unix epoch millisecond timestamp when each typing indicator expires.
35	users: BTreeMap<OwnedUserId, u64>,
36	// Global stream position of the last change to this room. The count permit must
37	// retire only after releasing the typing state lock.
38	update: u64,
39}
40
41impl crate::Service for Service {
42	fn build(args: &crate::Args<'_>) -> Result<Arc<Self>> {
43		Ok(Arc::new(Self {
44			server: args.server.clone(),
45			services: args.services.clone(),
46			typing: RwLock::new(BTreeMap::new()),
47			typing_update_sender: broadcast::channel(100).0,
48		}))
49	}
50
51	fn name(&self) -> &str { crate::service::make_name(std::module_path!()) }
52}
53
54impl Service {
55	/// Sets a user as typing until the timeout timestamp is reached or
56	/// roomtyping_remove is called.
57	#[tracing::instrument(
58		name = "typing_start"
59		level = INFO_SPAN_LEVEL,
60		skip_all,
61		 fields(
62			%room_id,
63			%user_id,
64			%timeout,
65		)
66	)]
67	pub async fn typing_add(&self, user_id: &UserId, room_id: &RoomId, timeout: u64) -> Result {
68		debug_info!("typing started {user_id:?} in {room_id:?} timeout:{timeout:?}");
69
70		// update clients
71		let mut typing = self.typing.write().await;
72		let room = typing.entry(room_id.to_owned()).or_default();
73		room.users.insert(user_id.to_owned(), timeout);
74
75		let count = self.services.globals.next_count();
76
77		room.update = *count;
78
79		drop(typing);
80		drop(count);
81
82		if self
83			.typing_update_sender
84			.send(room_id.to_owned())
85			.is_err()
86		{
87			trace!("receiver found what it was looking for and is no longer interested");
88		}
89
90		// update appservices
91		let appservice_send = self.appservice_send(room_id);
92
93		// update federation
94		let federation_send = self
95			.services
96			.globals
97			.user_is_local(user_id)
98			.then_async(|| self.federation_send(room_id, user_id, true))
99			.map(Option::transpose);
100
101		try_join(appservice_send, federation_send)
102			.await
103			.map(|_| ())
104	}
105
106	/// Removes a user from typing before the timeout is reached.
107	#[tracing::instrument(
108		name = "typing_stop"
109		level = INFO_SPAN_LEVEL,
110		skip_all,
111		 fields(
112			%room_id,
113			%user_id,
114		)
115	)]
116	pub async fn typing_remove(&self, user_id: &UserId, room_id: &RoomId) -> Result {
117		debug_info!("typing stopped {user_id:?} in {room_id:?}");
118
119		// update clients
120		let mut typing = self.typing.write().await;
121		let room = typing.entry(room_id.to_owned()).or_default();
122		room.users.remove(user_id);
123
124		let count = self.services.globals.next_count();
125
126		room.update = *count;
127
128		drop(typing);
129		drop(count);
130
131		if self
132			.typing_update_sender
133			.send(room_id.to_owned())
134			.is_err()
135		{
136			trace!("receiver found what it was looking for and is no longer interested");
137		}
138
139		// update appservices
140		let appservice_send = self.appservice_send(room_id);
141
142		// update federation
143		let federation_send = self
144			.services
145			.globals
146			.user_is_local(user_id)
147			.then_async(|| self.federation_send(room_id, user_id, false))
148			.map(Option::transpose);
149
150		try_join(appservice_send, federation_send)
151			.await
152			.map(|_| ())
153	}
154
155	pub async fn wait_for_update(&self, room_id: &RoomId) {
156		let mut receiver = self.typing_update_sender.subscribe();
157		while let Ok(next) = receiver.recv().await {
158			if next == room_id {
159				break;
160			}
161		}
162	}
163
164	/// Makes sure that typing events with old timestamps get removed.
165	async fn typings_maintain(&self, room_id: &RoomId) -> Result {
166		let current_timestamp = millis_since_unix_epoch();
167		let typing = self.typing.read().await;
168		let has_expired = typing.get(room_id).is_some_and(|room| {
169			room.users
170				.values()
171				.any(|timeout| *timeout < current_timestamp)
172		});
173
174		drop(typing);
175
176		if !has_expired {
177			return Ok(());
178		}
179
180		let current_timestamp = millis_since_unix_epoch();
181		let mut removable = Vec::new();
182		let mut typing = self.typing.write().await;
183		let Some(room) = typing.get_mut(room_id) else {
184			return Ok(());
185		};
186
187		room.users.retain(|user, timeout| {
188			let expired = *timeout < current_timestamp;
189			if expired {
190				removable.push(user.clone());
191			}
192
193			expired.is_false()
194		});
195
196		if removable.is_empty() {
197			return Ok(());
198		}
199
200		// update clients
201		let count = self.services.globals.next_count();
202
203		room.update = *count;
204
205		drop(typing);
206		drop(count);
207
208		for user in &removable {
209			debug_info!("typing timeout {user:?} in {room_id:?}");
210		}
211
212		if self
213			.typing_update_sender
214			.send(room_id.to_owned())
215			.is_err()
216		{
217			trace!("receiver found what it was looking for and is no longer interested");
218		}
219
220		// update appservices
221		let appservice_send = self.appservice_send(room_id);
222
223		// update federation
224		let federation_sends = removable
225			.iter()
226			.filter(|user_id| self.services.globals.user_is_local(user_id))
227			.try_stream()
228			.try_for_each(|user_id| self.federation_send(room_id, user_id, false));
229
230		try_join(appservice_send, federation_sends)
231			.boxed()
232			.await
233			.map(|_| ())
234	}
235
236	/// Returns the count of the last typing update in this room.
237	pub async fn last_typing_update(&self, room_id: &RoomId) -> Result<u64> {
238		self.typings_maintain(room_id).await?;
239
240		self.typing
241			.read()
242			.await
243			.get(room_id)
244			.map(|room| room.update)
245			.map(Ok)
246			.unwrap_or(Ok(0))
247	}
248
249	/// Returns the typing content with all typing users in the room.
250	async fn typings_content(&self, room_id: &RoomId) -> TypingEventContent {
251		let typing = self.typing.read().await;
252		let user_ids = typing
253			.get(room_id)
254			.into_iter()
255			.flat_map(|room| room.users.keys().cloned())
256			.collect();
257
258		TypingEventContent { user_ids }
259	}
260
261	/// Sends a typing EDU to all appservices interested in the room.
262	async fn appservice_send(&self, room_id: &RoomId) -> Result {
263		let content = self.typings_content(room_id).await;
264
265		self.services
266			.sending
267			.send_edu_room_appservices(room_id, |buf| {
268				let edu = EphemeralData::Typing(EphemeralRoomEvent {
269					room_id: room_id.to_owned(),
270					content: content.clone(),
271				});
272
273				Ok(serde_json::to_writer(buf, &edu)?)
274			})
275			.await
276	}
277
278	/// Returns a new typing EDU.
279	pub async fn typing_users_for_user(
280		&self,
281		room_id: &RoomId,
282		sender_user: &UserId,
283	) -> Result<Vec<OwnedUserId>> {
284		let typing = self.typing.read().await;
285		let user_ids = typing
286			.get(room_id)
287			.into_iter()
288			.flat_map(|room| room.users.keys().cloned())
289			.collect();
290		drop(typing);
291
292		Ok(self
293			.filter_typing_users(user_ids, sender_user)
294			.await)
295	}
296
297	/// Returns one coherent typing update token and visible user snapshot.
298	///
299	/// The predicate checks the token under the same lock and can skip cloning
300	/// stale users. Selected user IDs are captured before ignored users are
301	/// filtered after releasing the lock.
302	pub async fn typing_snapshot_for_user<Select>(
303		&self,
304		room_id: &RoomId,
305		sender_user: &UserId,
306		select: Select,
307	) -> Result<Option<(u64, Vec<OwnedUserId>)>>
308	where
309		Select: FnOnce(u64) -> bool + Send,
310	{
311		self.typings_maintain(room_id).await?;
312
313		let typing = self.typing.read().await;
314		let room = typing.get(room_id);
315		let update = room.map_or(0, |room| room.update);
316
317		if !select(update) {
318			return Ok(None);
319		}
320
321		let user_ids = room
322			.into_iter()
323			.flat_map(|room| room.users.keys().cloned())
324			.collect();
325
326		drop(typing);
327
328		let user_ids = self
329			.filter_typing_users(user_ids, sender_user)
330			.await;
331
332		Ok(Some((update, user_ids)))
333	}
334
335	async fn filter_typing_users(
336		&self,
337		user_ids: Vec<OwnedUserId>,
338		sender_user: &UserId,
339	) -> Vec<OwnedUserId> {
340		if user_ids.is_empty() {
341			return user_ids;
342		}
343
344		let ignored: Option<IgnoredUserListEvent> = self
345			.services
346			.account_data
347			.get_global(sender_user, GlobalAccountDataEventType::IgnoredUserList)
348			.await
349			.ok();
350
351		user_ids
352			.into_iter()
353			.filter(|user_id| {
354				ignored.as_ref().is_none_or(|ignored| {
355					!ignored
356						.content
357						.ignored_users
358						.contains_key::<UserId>(user_id.as_ref())
359				})
360			})
361			.collect()
362	}
363
364	async fn federation_send(&self, room_id: &RoomId, user_id: &UserId, typing: bool) -> Result {
365		debug_assert!(
366			self.services.globals.user_is_local(user_id),
367			"tried to broadcast typing status of remote user",
368		);
369
370		if !self.server.config.allow_outgoing_typing {
371			return Ok(());
372		}
373
374		let content = TypingContent::new(room_id.to_owned(), user_id.to_owned(), typing);
375		let edu = Edu::Typing(content);
376
377		let mut buf = EduBuf::new();
378		serde_json::to_writer(&mut buf, &edu).expect("Serialized Edu::Typing");
379
380		self.services
381			.sending
382			.send_edu_room(room_id, buf)
383			.await?;
384
385		Ok(())
386	}
387}