Skip to main content

telegram_webapp_sdk/webapp/
types.rs

1// SPDX-FileCopyrightText: 2025 RAprogramm <andrey.rozanov.vl@gmail.com>
2// SPDX-License-Identifier: MIT
3
4use js_sys::{Function, Object, Reflect};
5use serde::Serialize;
6use wasm_bindgen::{JsCast, JsValue, prelude::Closure};
7
8use crate::logger;
9
10/// Handle returned when registering callbacks.
11///
12/// Automatically unregisters the callback when dropped, implementing RAII
13/// cleanup pattern to prevent memory leaks.
14///
15/// # Examples
16///
17/// ```no_run
18/// use telegram_webapp_sdk::TelegramWebApp;
19///
20/// if let Some(app) = TelegramWebApp::instance() {
21///     // Handle is automatically cleaned up when scope ends
22///     let handle = app
23///         .on_theme_changed(|| {
24///             println!("Theme changed!");
25///         })
26///         .expect("subscribe");
27///
28///     // No manual cleanup needed - Drop handles it
29/// } // <- handle dropped here, callback unregistered automatically
30/// ```
31pub struct EventHandle<T: ?Sized> {
32    pub(super) target:       Object,
33    pub(super) method:       &'static str,
34    pub(super) event:        Option<String>,
35    pub(super) callback:     Closure<T>,
36    pub(super) unregistered: bool
37}
38
39impl<T: ?Sized> EventHandle<T> {
40    pub(super) fn new(
41        target: Object,
42        method: &'static str,
43        event: Option<String>,
44        callback: Closure<T>
45    ) -> Self {
46        Self {
47            target,
48            method,
49            event,
50            callback,
51            unregistered: false
52        }
53    }
54
55    pub(crate) fn unregister(mut self) -> Result<(), JsValue> {
56        if self.unregistered {
57            return Ok(());
58        }
59
60        let f = Reflect::get(&self.target, &self.method.into())?;
61        let func = f
62            .dyn_ref::<Function>()
63            .ok_or_else(|| JsValue::from_str(&format!("{} is not a function", self.method)))?;
64        match &self.event {
65            Some(event) => func.call2(
66                &self.target,
67                &event.clone().into(),
68                self.callback.as_ref().unchecked_ref()
69            )?,
70            None => func.call1(&self.target, self.callback.as_ref().unchecked_ref())?
71        };
72
73        self.unregistered = true;
74        Ok(())
75    }
76}
77
78impl<T: ?Sized> Drop for EventHandle<T> {
79    /// Automatically unregisters the event callback when the handle is dropped.
80    ///
81    /// This implements the RAII pattern, ensuring that event handlers are
82    /// properly cleaned up even if the user forgets to manually unregister.
83    /// Errors during unregistration are logged but do not panic.
84    fn drop(&mut self) {
85        if self.unregistered {
86            return;
87        }
88
89        let f = match Reflect::get(&self.target, &self.method.into()) {
90            Ok(f) => f,
91            Err(_) => {
92                logger::error("Failed to get unregister method");
93                return;
94            }
95        };
96
97        let func = match f.dyn_ref::<Function>() {
98            Some(func) => func,
99            None => {
100                logger::error(&format!("{} is not a function", self.method));
101                return;
102            }
103        };
104
105        let result = match &self.event {
106            Some(event) => func.call2(
107                &self.target,
108                &event.clone().into(),
109                self.callback.as_ref().unchecked_ref()
110            ),
111            None => func.call1(&self.target, self.callback.as_ref().unchecked_ref())
112        };
113
114        if result.is_err() {
115            logger::error("Failed to unregister event callback");
116        }
117
118        self.unregistered = true;
119    }
120}
121
122/// Identifies which bottom button to operate on.
123#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
124pub enum BottomButton {
125    /// Primary bottom button.
126    Main,
127    /// Secondary bottom button.
128    Secondary
129}
130
131impl BottomButton {
132    pub(super) const fn js_name(self) -> &'static str {
133        match self {
134            BottomButton::Main => "MainButton",
135            BottomButton::Secondary => "SecondaryButton"
136        }
137    }
138}
139
140/// Position of the secondary bottom button.
141///
142/// # Examples
143/// ```no_run
144/// use telegram_webapp_sdk::webapp::{SecondaryButtonPosition, TelegramWebApp};
145///
146/// if let Some(app) = TelegramWebApp::instance() {
147///     match app.secondary_button_position() {
148///         Some(SecondaryButtonPosition::Top) => {}
149///         _ => {}
150///     }
151/// }
152/// ```
153#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)]
154#[serde(rename_all = "lowercase")]
155pub enum SecondaryButtonPosition {
156    /// Displayed above the main button.
157    Top,
158    /// Displayed to the left of the main button.
159    Left,
160    /// Displayed below the main button.
161    Bottom,
162    /// Displayed to the right of the main button.
163    Right
164}
165
166impl SecondaryButtonPosition {
167    pub(super) fn from_js_value(value: JsValue) -> Option<Self> {
168        let as_str = value.as_string()?;
169        match as_str.as_str() {
170            "top" => Some(Self::Top),
171            "left" => Some(Self::Left),
172            "bottom" => Some(Self::Bottom),
173            "right" => Some(Self::Right),
174            _ => None
175        }
176    }
177}
178
179/// Safe area insets reported by Telegram.
180///
181/// # Examples
182/// ```no_run
183/// use telegram_webapp_sdk::webapp::{SafeAreaInset, TelegramWebApp};
184///
185/// if let Some(app) = TelegramWebApp::instance() {
186///     if let Some(SafeAreaInset {
187///         top,
188///         bottom,
189///         ..
190///     }) = app.safe_area_inset()
191///     {
192///         let _ = (top, bottom);
193///     }
194/// }
195/// ```
196#[derive(Clone, Copy, Debug, PartialEq)]
197pub struct SafeAreaInset {
198    /// Distance from the top edge in CSS pixels.
199    pub top:    f64,
200    /// Distance from the bottom edge in CSS pixels.
201    pub bottom: f64,
202    /// Distance from the left edge in CSS pixels.
203    pub left:   f64,
204    /// Distance from the right edge in CSS pixels.
205    pub right:  f64
206}
207
208impl SafeAreaInset {
209    pub(super) fn from_js(value: JsValue) -> Option<Self> {
210        let object = value.dyn_into::<Object>().ok()?;
211        let top = Reflect::get(&object, &"top".into()).ok()?.as_f64()?;
212        let bottom = Reflect::get(&object, &"bottom".into()).ok()?.as_f64()?;
213        let left = Reflect::get(&object, &"left".into()).ok()?.as_f64()?;
214        let right = Reflect::get(&object, &"right".into()).ok()?.as_f64()?;
215        Some(Self {
216            top,
217            bottom,
218            left,
219            right
220        })
221    }
222}
223
224/// Parameters accepted by bottom buttons when updating state via `setParams`.
225///
226/// # Examples
227/// ```no_run
228/// use telegram_webapp_sdk::webapp::{BottomButton, BottomButtonParams, TelegramWebApp};
229///
230/// if let Some(app) = TelegramWebApp::instance() {
231///     let params = BottomButtonParams {
232///         text: Some("Send"),
233///         is_active: Some(true),
234///         ..Default::default()
235///     };
236///     let _ = app.set_bottom_button_params(BottomButton::Main, &params);
237/// }
238/// ```
239#[derive(Debug, Default, Serialize)]
240pub struct BottomButtonParams<'a> {
241    /// Text label displayed on the button.
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub text:                 Option<&'a str>,
244    /// Button background color as a `#RRGGBB` hex string.
245    #[serde(skip_serializing_if = "Option::is_none")]
246    pub color:                Option<&'a str>,
247    /// Button text color as a `#RRGGBB` hex string.
248    #[serde(skip_serializing_if = "Option::is_none")]
249    pub text_color:           Option<&'a str>,
250    /// Whether the button is active (tappable) rather than disabled.
251    #[serde(skip_serializing_if = "Option::is_none")]
252    pub is_active:            Option<bool>,
253    /// Whether the button is visible.
254    #[serde(skip_serializing_if = "Option::is_none")]
255    pub is_visible:           Option<bool>,
256    /// Whether the button plays a shimmering shine animation.
257    #[serde(skip_serializing_if = "Option::is_none")]
258    pub has_shine_effect:     Option<bool>,
259    /// Custom emoji ID for the button icon (Bot API 9.5+).
260    ///
261    /// # Examples
262    /// ```no_run
263    /// use telegram_webapp_sdk::webapp::{BottomButton, BottomButtonParams, TelegramWebApp};
264    ///
265    /// if let Some(app) = TelegramWebApp::instance() {
266    ///     let params = BottomButtonParams {
267    ///         text: Some("Send"),
268    ///         icon_custom_emoji_id: Some("123456789"),
269    ///         ..Default::default()
270    ///     };
271    ///     let _ = app.set_bottom_button_params(BottomButton::Main, &params);
272    /// }
273    /// ```
274    #[serde(
275        skip_serializing_if = "Option::is_none",
276        rename = "icon_custom_emoji_id"
277    )]
278    pub icon_custom_emoji_id: Option<&'a str>
279}
280
281/// Additional parameters supported by the secondary button.
282///
283/// # Examples
284/// ```no_run
285/// use telegram_webapp_sdk::webapp::{
286///     SecondaryButtonParams, SecondaryButtonPosition, TelegramWebApp
287/// };
288///
289/// if let Some(app) = TelegramWebApp::instance() {
290///     let params = SecondaryButtonParams {
291///         common:   Default::default(),
292///         position: Some(SecondaryButtonPosition::Top)
293///     };
294///     let _ = app.set_secondary_button_params(&params);
295/// }
296/// ```
297#[derive(Debug, Default, Serialize)]
298pub struct SecondaryButtonParams<'a> {
299    /// Parameters shared with the main button (text, colors, visibility, …).
300    #[serde(flatten)]
301    pub common:   BottomButtonParams<'a>,
302    /// Placement of the secondary button relative to the main button.
303    #[serde(skip_serializing_if = "Option::is_none")]
304    pub position: Option<SecondaryButtonPosition>
305}
306
307/// Options supported by [`crate::webapp::TelegramWebApp::open_link`].
308///
309/// # Examples
310/// ```no_run
311/// use telegram_webapp_sdk::webapp::{OpenLinkOptions, TelegramWebApp};
312///
313/// if let Some(app) = TelegramWebApp::instance() {
314///     let options = OpenLinkOptions {
315///         try_instant_view: Some(true),
316///         try_browser:      None
317///     };
318///     let _ = app.open_link("https://example.com", Some(&options));
319/// }
320/// ```
321#[derive(Debug, Default, Serialize)]
322pub struct OpenLinkOptions {
323    /// If `true`, attempts to open the link in Instant View mode when
324    /// supported.
325    #[serde(skip_serializing_if = "Option::is_none")]
326    pub try_instant_view: Option<bool>,
327    /// Preferred external browser (Bot API 7.6+). Pass values like `"chrome"`,
328    /// `"firefox"`, `"safari"`. Ignored by older clients.
329    #[serde(skip_serializing_if = "Option::is_none")]
330    pub try_browser:      Option<String>
331}
332
333/// Options supported by [`crate::webapp::TelegramWebApp::close_with_options`].
334///
335/// # Examples
336/// ```no_run
337/// use telegram_webapp_sdk::webapp::{CloseOptions, TelegramWebApp};
338///
339/// if let Some(app) = TelegramWebApp::instance() {
340///     let _ = app.close_with_options(&CloseOptions {
341///         return_back: Some(true)
342///     });
343/// }
344/// ```
345#[derive(Debug, Default, Serialize)]
346pub struct CloseOptions {
347    /// If `true` and the host client is Bot API 7.6+, returns the user to the
348    /// previous chat instead of just closing the mini app.
349    #[serde(skip_serializing_if = "Option::is_none")]
350    pub return_back: Option<bool>
351}
352
353/// Background events delivered by Telegram when the Mini App runs in the
354/// background.
355#[derive(Clone, Copy, Debug)]
356pub enum BackgroundEvent {
357    /// The main button was clicked. Payload: [`JsValue::UNDEFINED`].
358    MainButtonClicked,
359    /// The back button was clicked. Payload: [`JsValue::UNDEFINED`].
360    BackButtonClicked,
361    /// The settings button was clicked. Payload: [`JsValue::UNDEFINED`].
362    SettingsButtonClicked,
363    /// User responded to a write access request. Payload: `bool`.
364    WriteAccessRequested,
365    /// User responded to a contact request. Payload: `bool`.
366    ContactRequested,
367    /// An invoice was closed. Payload: status string.
368    InvoiceClosed,
369    /// A popup was closed. Payload: object containing `button_id`.
370    PopupClosed,
371    /// Text was received from the QR scanner. Payload: scanned text.
372    QrTextReceived,
373    /// Text was read from the clipboard. Payload: clipboard text.
374    ClipboardTextReceived,
375    /// User picked a chat in response to `WebApp.requestChat`
376    /// (Bot API 9.6+). Payload: `JsValue::UNDEFINED`.
377    RequestedChatSent,
378    /// `WebApp.requestChat` failed (user cancelled or Telegram error).
379    /// Payload: object containing `error: String`.
380    RequestedChatFailed
381}
382
383impl BackgroundEvent {
384    pub(super) const fn as_str(self) -> &'static str {
385        match self {
386            BackgroundEvent::MainButtonClicked => "mainButtonClicked",
387            BackgroundEvent::BackButtonClicked => "backButtonClicked",
388            BackgroundEvent::SettingsButtonClicked => "settingsButtonClicked",
389            BackgroundEvent::WriteAccessRequested => "writeAccessRequested",
390            BackgroundEvent::ContactRequested => "contactRequested",
391            BackgroundEvent::InvoiceClosed => "invoiceClosed",
392            BackgroundEvent::PopupClosed => "popupClosed",
393            BackgroundEvent::QrTextReceived => "qrTextReceived",
394            BackgroundEvent::ClipboardTextReceived => "clipboardTextReceived",
395            BackgroundEvent::RequestedChatSent => "requestedChatSent",
396            BackgroundEvent::RequestedChatFailed => "requestedChatFailed"
397        }
398    }
399}