locate.rs
| 1 | //! Sending this browser's own position, once or on a timer. |
| 2 | |
| 3 | use std::time::Duration; |
| 4 | |
| 5 | use api::{Point, Uploaded}; |
| 6 | use leptos::prelude::*; |
| 7 | use leptos::task::spawn_local; |
| 8 | use wasm_bindgen::prelude::*; |
| 9 | |
| 10 | use crate::http; |
| 11 | |
| 12 | #[wasm_bindgen(inline_js = r#" |
| 13 | export function locate() { |
| 14 | return new Promise((resolve, reject) => { |
| 15 | if (!navigator.geolocation) return reject("This browser cannot share its location."); |
| 16 | navigator.geolocation.getCurrentPosition(async (p) => { |
| 17 | let battery = null; |
| 18 | try { |
| 19 | if (navigator.getBattery) battery = Math.round((await navigator.getBattery()).level * 100); |
| 20 | } catch (e) {} |
| 21 | const c = p.coords; |
| 22 | // JSON.stringify turns a NaN heading (standing still) into null. |
| 23 | resolve(JSON.stringify({ |
| 24 | ts: Math.floor(p.timestamp / 1000), lat: c.latitude, lon: c.longitude, acc: c.accuracy, |
| 25 | alt: c.altitude, speed: c.speed, bearing: c.heading, battery, |
| 26 | })); |
| 27 | }, (e) => reject(e.message || "The location is not available."), |
| 28 | { enableHighAccuracy: true, timeout: 20000, maximumAge: 0 }); |
| 29 | }); |
| 30 | } |
| 31 | "#)] |
| 32 | extern "C" { |
| 33 | #[wasm_bindgen(catch)] |
| 34 | async fn locate() -> Result<JsValue, JsValue>; |
| 35 | } |
| 36 | |
| 37 | const STORAGE_KEY: &str = "autoSendSecs"; |
| 38 | const MIN_SECS: u32 = 5; |
| 39 | const DEFAULT_SECS: u32 = 30; |
| 40 | |
| 41 | /// Lives in the shell, so automatic sending goes on while the settings are open. |
| 42 | #[derive(Clone, Copy)] |
| 43 | pub struct Locator { |
| 44 | pub auto: RwSignal<bool>, |
| 45 | pub secs: RwSignal<String>, |
| 46 | pub status: RwSignal<Option<Result<String, String>>>, |
| 47 | busy: RwSignal<bool>, |
| 48 | } |
| 49 | |
| 50 | fn storage() -> Option<web_sys::Storage> { |
| 51 | window().local_storage().ok().flatten() |
| 52 | } |
| 53 | |
| 54 | impl Locator { |
| 55 | /// The choice is kept in localStorage, so automatic sending resumes after a reload. |
| 56 | pub fn provide() -> Self { |
| 57 | let saved = storage().and_then(|s| s.get_item(STORAGE_KEY).ok().flatten()); |
| 58 | let locator = Locator { |
| 59 | auto: RwSignal::new(saved.is_some()), |
| 60 | secs: RwSignal::new(saved.unwrap_or_else(|| DEFAULT_SECS.to_string())), |
| 61 | status: RwSignal::new(None), |
| 62 | busy: RwSignal::new(false), |
| 63 | }; |
| 64 | provide_context(locator); |
| 65 | |
| 66 | let timer = StoredValue::new(None::<IntervalHandle>); |
| 67 | Effect::new(move |was_on: Option<bool>| { |
| 68 | if let Some(handle) = timer.get_value() { |
| 69 | handle.clear(); |
| 70 | } |
| 71 | timer.set_value(None); |
| 72 | let secs = locator.interval(); |
| 73 | let on = locator.auto.get(); |
| 74 | if let Some(s) = storage() { |
| 75 | let _ = match on { |
| 76 | true => s.set_item(STORAGE_KEY, &secs.to_string()), |
| 77 | false => s.remove_item(STORAGE_KEY), |
| 78 | }; |
| 79 | } |
| 80 | if on { |
| 81 | // Send at once when switched on, but not on every edit of the interval. |
| 82 | if was_on != Some(true) { |
| 83 | locator.send(); |
| 84 | } |
| 85 | let handle = set_interval_with_handle( |
| 86 | move || locator.send(), |
| 87 | Duration::from_secs(secs.into()), |
| 88 | ); |
| 89 | timer.set_value(handle.ok()); |
| 90 | } |
| 91 | on |
| 92 | }); |
| 93 | on_cleanup(move || { |
| 94 | if let Some(handle) = timer.get_value() { |
| 95 | handle.clear(); |
| 96 | } |
| 97 | }); |
| 98 | locator |
| 99 | } |
| 100 | |
| 101 | fn interval(&self) -> u32 { |
| 102 | self.secs |
| 103 | .get() |
| 104 | .trim() |
| 105 | .parse() |
| 106 | .unwrap_or(DEFAULT_SECS) |
| 107 | .max(MIN_SECS) |
| 108 | } |
| 109 | |
| 110 | pub fn send(self) { |
| 111 | // A slow GPS fix must not pile up requests behind it. |
| 112 | if self.busy.get_untracked() { |
| 113 | return; |
| 114 | } |
| 115 | self.busy.set(true); |
| 116 | spawn_local(async move { |
| 117 | let result = async { |
| 118 | let json = locate().await.map_err(|e| { |
| 119 | e.as_string() |
| 120 | .unwrap_or_else(|| "The location is not available.".into()) |
| 121 | })?; |
| 122 | let point: Point = serde_json::from_str(&json.as_string().unwrap_or_default()) |
| 123 | .map_err(|e| e.to_string())?; |
| 124 | http::post::<Uploaded>("/api/points", &[&point]) |
| 125 | .await |
| 126 | .map_err(|e| e.to_string())?; |
| 127 | Ok::<_, String>(point) |
| 128 | } |
| 129 | .await; |
| 130 | self.status.set(Some(result.map(|p| { |
| 131 | let acc = p.acc.map(|a| format!(", ±{a:.0} m")).unwrap_or_default(); |
| 132 | let time: String = js_sys::Date::new(&JsValue::from_f64(p.ts as f64 * 1000.0)) |
| 133 | .to_locale_time_string("default") |
| 134 | .into(); |
| 135 | format!("Last sent at {time}{acc}.") |
| 136 | }))); |
| 137 | self.busy.set(false); |
| 138 | }); |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | #[component] |
| 143 | pub fn LocationControls() -> impl IntoView { |
| 144 | let locator = expect_context::<Locator>(); |
| 145 | view! { |
| 146 | <div class="send-location"> |
| 147 | <h3>"Share from this browser"</h3> |
| 148 | <button on:click=move |_| locator.send() disabled=move || locator.busy.get()> |
| 149 | "Send my location now" |
| 150 | </button> |
| 151 | <div class="auto-send"> |
| 152 | <label class="radio"> |
| 153 | <input type="checkbox" bind:checked=locator.auto /> |
| 154 | "Send every" |
| 155 | </label> |
| 156 | <input type="number" min=MIN_SECS aria-label="Seconds between sends" bind:value=locator.secs /> |
| 157 | "seconds" |
| 158 | </div> |
| 159 | {move || { |
| 160 | locator.status.get().map(|s| match s { |
| 161 | Ok(text) => view! { <p class="hint">{text}</p> }.into_any(), |
| 162 | Err(text) => view! { <p class="error">{text}</p> }.into_any(), |
| 163 | }) |
| 164 | }} |
| 165 | </div> |
| 166 | } |
| 167 | } |
| 168 |