locate.rs
⎇
Raw
1//! Sending this browser's own position, once or on a timer.
2
3use std::time::Duration;
4
5use api::{Point, Uploaded};
6use leptos::prelude::*;
7use leptos::task::spawn_local;
8use wasm_bindgen::prelude::*;
9
10use crate::http;
11
12#[wasm_bindgen(inline_js = r#"
13export 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"#)]
32extern "C" {
33 #[wasm_bindgen(catch)]
34 async fn locate() -> Result<JsValue, JsValue>;
35}
36
37const STORAGE_KEY: &str = "autoSendSecs";
38const MIN_SECS: u32 = 5;
39const DEFAULT_SECS: u32 = 30;
40
41/// Lives in the shell, so automatic sending goes on while the settings are open.
42#[derive(Clone, Copy)]
43pub 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
50fn storage() -> Option<web_sys::Storage> {
51 window().local_storage().ok().flatten()
52}
53
54impl 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]
143pub 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