Skip to main content

telegram_webapp_sdk/leptos/
bottom_button.rs

1// SPDX-FileCopyrightText: 2025 RAprogramm <andrey.rozanov.vl@gmail.com>
2// SPDX-License-Identifier: MIT
3
4use std::{cell::RefCell, collections::HashMap};
5
6use leptos::prelude::*;
7
8use crate::{
9    logger,
10    webapp::{BottomButton as WebBottomButton, EventHandle, TelegramWebApp}
11};
12
13thread_local! {
14    static BUTTON_HANDLES: RefCell<HashMap<WebBottomButton, EventHandle<dyn FnMut()>>> =
15        RefCell::new(HashMap::new());
16}
17
18/// Leptos component that controls a Telegram bottom button.
19///
20/// The component shows the selected bottom button and keeps its text and
21/// colors in sync with the provided reactive signals. An optional click
22/// callback can be registered and is automatically removed when the component
23/// unmounts.
24///
25/// # Examples
26///
27/// ```no_run
28/// use leptos::prelude::*;
29/// use telegram_webapp_sdk::{
30///     leptos::{BottomButton, provide_telegram_context},
31///     webapp::{BottomButton as Btn, TelegramWebApp}
32/// };
33///
34/// #[component]
35/// fn App() -> impl IntoView {
36///     provide_telegram_context().expect("context");
37///     let (text, _set_text) = signal("Send".to_owned());
38///     view! {
39///         <BottomButton
40///             button=Btn::Main
41///             text
42///             on_click=move || {
43///                 if let Some(app) = TelegramWebApp::instance() {
44///                     let _ = app.send_data("clicked");
45///                 }
46///             }
47///         />
48///     }
49/// }
50/// ```
51#[component]
52pub fn BottomButton<F>(
53    /// Reactive text label displayed on the button.
54    #[prop(into)]
55    text: Signal<String>,
56    /// Optional reactive background color as a `#RRGGBB` hex string.
57    #[prop(optional, into)]
58    color: Option<Signal<String>>,
59    /// Optional reactive text color as a `#RRGGBB` hex string.
60    #[prop(optional, into)]
61    text_color: Option<Signal<String>>,
62    /// Optional callback invoked when the button is clicked.
63    #[prop(optional)]
64    on_click: Option<F>,
65    /// Which bottom button to control; defaults to the main button.
66    #[prop(default = WebBottomButton::Main)]
67    button: WebBottomButton
68) -> impl IntoView
69where
70    F: Fn() + Clone + 'static
71{
72    // Show button on mount.
73    Effect::new(move |_| {
74        if let Some(app) = TelegramWebApp::instance() {
75            if let Err(err) = app.show_bottom_button(button) {
76                logger::error(&format!("show_bottom_button failed: {err:?}"));
77            }
78        } else {
79            logger::error("TelegramWebApp instance not available");
80        }
81    });
82
83    // Update text when signal changes.
84    Effect::new(move |_| {
85        if let Some(app) = TelegramWebApp::instance()
86            && let Err(err) = app.set_bottom_button_text(button, &text.get())
87        {
88            logger::error(&format!("set_bottom_button_text failed: {err:?}"));
89        }
90    });
91
92    // Update button color.
93    if let Some(color) = color {
94        Effect::new(move |_| {
95            if let Some(app) = TelegramWebApp::instance()
96                && let Err(err) = app.set_bottom_button_color(button, &color.get())
97            {
98                logger::error(&format!("set_bottom_button_color failed: {err:?}"));
99            }
100        });
101    }
102
103    // Update text color.
104    if let Some(text_color) = text_color {
105        Effect::new(move |_| {
106            if let Some(app) = TelegramWebApp::instance()
107                && let Err(err) = app.set_bottom_button_text_color(button, &text_color.get())
108            {
109                logger::error(&format!("set_bottom_button_text_color failed: {err:?}"));
110            }
111        });
112    }
113
114    // Register click callback if provided and keep handle for cleanup.
115    if let Some(cb) = on_click {
116        if let Some(app) = TelegramWebApp::instance() {
117            match app.set_bottom_button_callback(button, cb) {
118                Ok(handle) => BUTTON_HANDLES.with(|handles| {
119                    handles.borrow_mut().insert(button, handle);
120                }),
121                Err(err) => logger::error(&format!("set_bottom_button_callback failed: {err:?}"))
122            }
123        } else {
124            logger::error("TelegramWebApp instance not available");
125        }
126    }
127
128    // Cleanup: remove callback and hide button when component unmounts.
129    on_cleanup(move || {
130        if let Some(app) = TelegramWebApp::instance() {
131            BUTTON_HANDLES.with(|handles| {
132                if let Some(handle) = handles.borrow_mut().remove(&button)
133                    && let Err(err) = app.remove_bottom_button_callback(handle)
134                {
135                    logger::error(&format!("remove_bottom_button_callback failed: {err:?}"));
136                }
137            });
138            if let Err(err) = app.hide_bottom_button(button) {
139                logger::error(&format!("hide_bottom_button failed: {err:?}"));
140            }
141        } else {
142            logger::error("TelegramWebApp instance not available");
143        }
144    });
145
146    // Component renders no DOM nodes.
147    View::new(())
148}
149
150#[cfg(all(test, feature = "leptos"))]
151mod tests {
152    use std::{
153        cell::{Cell, RefCell},
154        rc::Rc
155    };
156
157    use js_sys::{Function, Object, Reflect};
158    use leptos::prelude::*;
159    use wasm_bindgen::{JsCast, JsValue, closure::Closure};
160    use wasm_bindgen_test::wasm_bindgen_test;
161    use web_sys::window;
162
163    use super::BottomButton;
164    use crate::webapp::BottomButton as WebBottomButton;
165
166    #[allow(dead_code, clippy::type_complexity)]
167    fn setup_webapp() -> (
168        Rc<Cell<bool>>,
169        Rc<Cell<bool>>,
170        Rc<RefCell<Vec<String>>>,
171        Rc<RefCell<Option<Function>>>
172    ) {
173        let win = window().unwrap();
174        let tg = Object::new();
175        let webapp = Object::new();
176        let button = Object::new();
177
178        let show_called = Rc::new(Cell::new(false));
179        let show_cb = {
180            let show_called = Rc::clone(&show_called);
181            Closure::<dyn FnMut()>::new(move || {
182                show_called.set(true);
183            })
184        };
185        Reflect::set(&button, &"show".into(), show_cb.as_ref().unchecked_ref()).unwrap();
186        show_cb.forget();
187
188        let hide_called = Rc::new(Cell::new(false));
189        let hide_cb = {
190            let hide_called = Rc::clone(&hide_called);
191            Closure::<dyn FnMut()>::new(move || {
192                hide_called.set(true);
193            })
194        };
195        Reflect::set(&button, &"hide".into(), hide_cb.as_ref().unchecked_ref()).unwrap();
196        hide_cb.forget();
197
198        let texts = Rc::new(RefCell::new(Vec::new()));
199        let set_text_cb = {
200            let texts = Rc::clone(&texts);
201            Closure::<dyn FnMut(JsValue)>::new(move |val: JsValue| {
202                if let Some(s) = val.as_string() {
203                    texts.borrow_mut().push(s);
204                }
205            })
206        };
207        Reflect::set(
208            &button,
209            &"setText".into(),
210            set_text_cb.as_ref().unchecked_ref()
211        )
212        .unwrap();
213        set_text_cb.forget();
214
215        let click_fn = Rc::new(RefCell::new(None));
216        let on_click_cb = {
217            let click_fn = Rc::clone(&click_fn);
218            Closure::<dyn Fn(JsValue)>::new(move |f: JsValue| {
219                *click_fn.borrow_mut() = f.dyn_into::<Function>().ok();
220            })
221        };
222        Reflect::set(
223            &button,
224            &"onClick".into(),
225            on_click_cb.as_ref().unchecked_ref()
226        )
227        .unwrap();
228        on_click_cb.forget();
229
230        let off_click_cb = {
231            let click_fn = Rc::clone(&click_fn);
232            Closure::<dyn FnMut(JsValue)>::new(move |_f: JsValue| {
233                *click_fn.borrow_mut() = None;
234            })
235        };
236        Reflect::set(
237            &button,
238            &"offClick".into(),
239            off_click_cb.as_ref().unchecked_ref()
240        )
241        .unwrap();
242        off_click_cb.forget();
243
244        Reflect::set(&webapp, &"MainButton".into(), &button).unwrap();
245        Reflect::set(&tg, &"WebApp".into(), &webapp).unwrap();
246        Reflect::set(&win, &"Telegram".into(), &tg).unwrap();
247
248        (show_called, hide_called, texts, click_fn)
249    }
250
251    #[wasm_bindgen_test]
252    #[allow(dead_code)]
253    fn bottom_button_updates_and_cleans_up() {
254        let (show_called, hide_called, texts, click_fn) = setup_webapp();
255
256        let owner = Owner::new();
257        owner.set();
258        let (text, set_text) = signal("Start".to_owned());
259        let clicked = Rc::new(Cell::new(false));
260        let clicked_clone = Rc::clone(&clicked);
261        let _view = view! {
262            <BottomButton
263                button=WebBottomButton::Main
264                text
265                on_click=move || clicked_clone.set(true)
266            />
267        };
268        set_text.set("Next".to_owned());
269        if let Some(func) = click_fn.borrow().as_ref() {
270            let _ = func.call0(&JsValue::NULL);
271        }
272        assert!(clicked.get());
273        drop(owner);
274
275        assert!(show_called.get());
276        assert!(hide_called.get());
277        assert!(click_fn.borrow().is_none());
278        let stored = texts.borrow();
279        assert_eq!(stored.as_slice(), ["Start", "Next"]);
280    }
281}