Skip to main content

nightshade_api/web/
browser.rs

1//! Browser-platform helpers: text file download and pick, local-storage-backed
2//! persisted signals, and a reconnecting WebSocket handle.
3
4use std::cell::RefCell;
5use std::rc::Rc;
6use std::time::Duration;
7
8use leptos::prelude::*;
9use serde::Serialize;
10use serde::de::DeserializeOwned;
11use wasm_bindgen::JsCast;
12use wasm_bindgen::prelude::Closure;
13
14fn local_storage() -> Option<web_sys::Storage> {
15    web_sys::window().and_then(|window| window.local_storage().ok().flatten())
16}
17
18/// Trigger a browser download of `contents` as a plain-text file named
19/// `filename` using a temporary data-URL anchor.
20pub fn download_text(filename: &str, contents: &str) {
21    let Some(document) = web_sys::window().and_then(|window| window.document()) else {
22        return;
23    };
24    let encoded: String = js_sys::encode_uri_component(contents).into();
25    let href = format!("data:text/plain;charset=utf-8,{encoded}");
26    if let Ok(element) = document.create_element("a") {
27        let anchor: web_sys::HtmlAnchorElement = element.unchecked_into();
28        anchor.set_href(&href);
29        anchor.set_download(filename);
30        anchor.click();
31    }
32}
33
34/// Open the browser's file picker and read the chosen file as text, invoking
35/// `on_pick` with its contents.
36pub fn pick_file_text(on_pick: Callback<String>) {
37    let Some(document) = web_sys::window().and_then(|window| window.document()) else {
38        return;
39    };
40    let Ok(element) = document.create_element("input") else {
41        return;
42    };
43    let input: web_sys::HtmlInputElement = element.unchecked_into();
44    input.set_type("file");
45    let retained = crate::web::use_retained_closures();
46    let on_change = Closure::<dyn Fn(web_sys::Event)>::new(move |event: web_sys::Event| {
47        let Some(target) = event.target() else {
48            return;
49        };
50        let input: web_sys::HtmlInputElement = target.unchecked_into();
51        let Some(file) = input.files().and_then(|files| files.get(0)) else {
52            return;
53        };
54        let Ok(reader) = web_sys::FileReader::new() else {
55            return;
56        };
57        let reader_ref = reader.clone();
58        let on_load = Closure::<dyn Fn()>::new(move || {
59            if let Ok(result) = reader_ref.result()
60                && let Some(text) = result.as_string()
61            {
62                on_pick.run(text);
63            }
64        });
65        reader.set_onload(Some(on_load.as_ref().unchecked_ref()));
66        retained.retain(on_load);
67        let _ = reader.read_as_text(&file);
68    });
69    input.set_onchange(Some(on_change.as_ref().unchecked_ref()));
70    retained.retain(on_change);
71    input.click();
72}
73
74/// Create a signal seeded from JSON in local storage under `key` (or `default`
75/// when absent or invalid) that re-serializes back to storage on every change.
76pub fn use_persisted<T>(key: impl Into<String>, default: T) -> RwSignal<T>
77where
78    T: Serialize + DeserializeOwned + Send + Sync + Clone + 'static,
79{
80    let key = key.into();
81    let stored = local_storage()
82        .and_then(|storage| storage.get_item(&key).ok().flatten())
83        .and_then(|raw| serde_json::from_str::<T>(&raw).ok());
84    let signal = RwSignal::new(stored.unwrap_or(default));
85    Effect::new(move |_| {
86        let value = signal.get();
87        if let (Some(storage), Ok(raw)) = (local_storage(), serde_json::to_string(&value)) {
88            let _ = storage.set_item(&key, &raw);
89        }
90    });
91    signal
92}
93
94/// A cheap, copyable handle to a reconnecting WebSocket created by
95/// [`use_reconnecting_socket`].
96#[derive(Clone, Copy)]
97pub struct SocketHandle {
98    connected: RwSignal<bool>,
99    socket: StoredValue<Option<web_sys::WebSocket>, LocalStorage>,
100}
101
102impl SocketHandle {
103    /// A reactive signal that is `true` while the socket is open.
104    pub fn connected(&self) -> Signal<bool> {
105        let connected = self.connected;
106        Signal::derive(move || connected.get())
107    }
108
109    /// Send a text frame if the socket is currently connected; otherwise a no-op.
110    pub fn send(&self, text: &str) {
111        self.socket.with_value(|socket| {
112            if let Some(socket) = socket {
113                let _ = socket.send_with_str(text);
114            }
115        });
116    }
117}
118
119const RECONNECT_MS: u64 = 1000;
120
121type ConnectSlot = Rc<RefCell<Option<Rc<dyn Fn()>>>>;
122type ClosureKeep = StoredValue<Vec<Box<dyn std::any::Any>>, LocalStorage>;
123
124/// Open a WebSocket to `url` that auto-reconnects after a short delay when
125/// closed, forwarding each received text message to `on_message`. The socket is
126/// closed and reconnection cancelled on cleanup.
127pub fn use_reconnecting_socket(
128    url: impl Into<String>,
129    on_message: Callback<String>,
130) -> SocketHandle {
131    let connected = RwSignal::new(false);
132    let socket = StoredValue::new_local(None::<web_sys::WebSocket>);
133    let keep: ClosureKeep = StoredValue::new_local(Vec::new());
134    let cancelled = StoredValue::new(false);
135    let url = url.into();
136    let connect_slot: ConnectSlot = Rc::new(RefCell::new(None));
137
138    let slot_for_close = connect_slot.clone();
139    let make: Rc<dyn Fn()> = Rc::new(move || {
140        if cancelled.get_value() {
141            return;
142        }
143        let Ok(ws) = web_sys::WebSocket::new(&url) else {
144            return;
145        };
146
147        let on_open = Closure::<dyn Fn()>::new(move || connected.set(true));
148        ws.set_onopen(Some(on_open.as_ref().unchecked_ref()));
149
150        let on_message_closure =
151            Closure::<dyn Fn(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
152                if let Some(text) = event.data().as_string() {
153                    on_message.run(text);
154                }
155            });
156        ws.set_onmessage(Some(on_message_closure.as_ref().unchecked_ref()));
157
158        let reconnect = slot_for_close.clone();
159        let on_close = Closure::<dyn Fn()>::new(move || {
160            connected.set(false);
161            if !cancelled.get_value()
162                && let Some(again) = reconnect.borrow().clone()
163            {
164                set_timeout(move || again(), Duration::from_millis(RECONNECT_MS));
165            }
166        });
167        ws.set_onclose(Some(on_close.as_ref().unchecked_ref()));
168
169        keep.update_value(|closures| {
170            closures.push(Box::new(on_open));
171            closures.push(Box::new(on_message_closure));
172            closures.push(Box::new(on_close));
173        });
174        socket.set_value(Some(ws));
175    });
176
177    *connect_slot.borrow_mut() = Some(make.clone());
178    make();
179
180    on_cleanup(move || {
181        cancelled.set_value(true);
182        if let Some(ws) = socket.get_value() {
183            ws.set_onopen(None);
184            ws.set_onmessage(None);
185            ws.set_onclose(None);
186            let _ = ws.close();
187        }
188        keep.update_value(Vec::clear);
189    });
190
191    SocketHandle { connected, socket }
192}