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