layer_client/typing_guard.rs
1//! RAII typing indicator guard.
2//!
3//! [`TypingGuard`] automatically cancels the "typing…" chat action when
4//! dropped, eliminating the need to remember to call `send_chat_action`
5//! with `SendMessageAction::SendMessageCancelAction` manually.
6//!
7//! # Example
8//! ```rust,no_run
9//! use layer_client::{Client, TypingGuard};
10//! use layer_tl_types as tl;
11//!
12//! async fn handle(client: Client, peer: tl::enums::Peer) {
13//! // Typing indicator is sent immediately and auto-cancelled on drop.
14//! let _typing = TypingGuard::start(&client, peer.clone(),
15//! tl::enums::SendMessageAction::SendMessageTypingAction).await.unwrap();
16//!
17//! do_expensive_work().await;
18//! // `_typing` is dropped here — Telegram sees the typing stop.
19//! }
20//! # async fn do_expensive_work() {}
21//! ```
22
23use std::sync::Arc;
24use std::time::Duration;
25use tokio::sync::Notify;
26use tokio::task::JoinHandle;
27use layer_tl_types as tl;
28use crate::{Client, InvocationError};
29
30// ─── TypingGuard ─────────────────────────────────────────────────────────────
31
32/// Scoped typing indicator. Keeps the action alive by re-sending it every
33/// ~4 seconds (Telegram drops the indicator after ~5 s).
34///
35/// Drop this guard to cancel the action immediately.
36pub struct TypingGuard {
37 stop: Arc<Notify>,
38 task: Option<JoinHandle<()>>,
39}
40
41impl TypingGuard {
42 /// Send `action` to `peer` and keep repeating it until the guard is dropped.
43 pub async fn start(
44 client: &Client,
45 peer: tl::enums::Peer,
46 action: tl::enums::SendMessageAction,
47 ) -> Result<Self, InvocationError> {
48 // Send once immediately so the indicator appears without delay.
49 client.send_chat_action(peer.clone(), action.clone()).await?;
50
51 let stop = Arc::new(Notify::new());
52 let stop2 = stop.clone();
53 let client = client.clone();
54
55 let task = tokio::spawn(async move {
56 loop {
57 tokio::select! {
58 _ = tokio::time::sleep(Duration::from_secs(4)) => {
59 if let Err(e) = client.send_chat_action(peer.clone(), action.clone()).await {
60 log::warn!("[typing_guard] Failed to refresh typing action: {e}");
61 break;
62 }
63 }
64 _ = stop2.notified() => break,
65 }
66 }
67 // Cancel the action
68 let cancel = tl::enums::SendMessageAction::SendMessageCancelAction;
69 let _ = client.send_chat_action(peer.clone(), cancel).await;
70 });
71
72 Ok(Self { stop, task: Some(task) })
73 }
74
75 /// Cancel the typing indicator immediately without waiting for the drop.
76 pub fn cancel(&mut self) {
77 self.stop.notify_one();
78 }
79}
80
81impl Drop for TypingGuard {
82 fn drop(&mut self) {
83 self.stop.notify_one();
84 // Detach the task — it will see the notify, send a cancel action, then exit.
85 // We don't abort it because we want the cancel action to reach Telegram.
86 if let Some(t) = self.task.take() {
87 t.abort(); // abort is fine since notify already fired — cancel fires in select
88 }
89 }
90}
91
92// ─── Client extension ─────────────────────────────────────────────────────────
93
94impl Client {
95 /// Start a scoped typing indicator that auto-cancels when dropped.
96 ///
97 /// This is a convenience wrapper around [`TypingGuard::start`].
98 pub async fn typing(
99 &self,
100 peer: tl::enums::Peer,
101 ) -> Result<TypingGuard, InvocationError> {
102 TypingGuard::start(self, peer, tl::enums::SendMessageAction::SendMessageTypingAction).await
103 }
104
105 /// Start a scoped "uploading document" action that auto-cancels when dropped.
106 pub async fn uploading_document(
107 &self,
108 peer: tl::enums::Peer,
109 ) -> Result<TypingGuard, InvocationError> {
110 TypingGuard::start(self, peer, tl::enums::SendMessageAction::SendMessageUploadDocumentAction(
111 tl::types::SendMessageUploadDocumentAction { progress: 0 }
112 )).await
113 }
114
115 /// Start a scoped "recording video" action that auto-cancels when dropped.
116 pub async fn recording_video(
117 &self,
118 peer: tl::enums::Peer,
119 ) -> Result<TypingGuard, InvocationError> {
120 TypingGuard::start(self, peer, tl::enums::SendMessageAction::SendMessageRecordVideoAction).await
121 }
122}