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;
11use crate::i18n::tr;
12
13#[wasm_bindgen(inline_js = r#"
14export 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"#)]
33extern "C" {
34 #[wasm_bindgen(catch)]
35 async fn locate() -> Result<JsValue, JsValue>;
36}
37
38const STORAGE_KEY: &str = "autoSendSecs";
39const MIN_SECS: u32 = 5;
40const DEFAULT_SECS: u32 = 30;
41
42/// Lives in the shell, so automatic sending goes on while the settings are open.
43#[derive(Clone, Copy)]
44pub 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
51fn storage() -> Option<web_sys::Storage> {
52 window().local_storage().ok().flatten()
53}
54
55impl 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]
146pub 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