app.rs
⎇
Raw
1//! Pages for the Android app: `#device` inside its WebView, `#pair` in the browser.
2
3use api::{PairBegin, PairCode};
4use leptos::prelude::*;
5use leptos::task::spawn_local;
6use wasm_bindgen::prelude::*;
7
8use crate::{http, map};
9
10#[wasm_bindgen(inline_js = r#"
11export function appToken() {
12 return window.OtApp ? window.OtApp.token() : null;
13}
14"#)]
15extern "C" {
16 #[wasm_bindgen(js_name = appToken)]
17 fn app_token() -> Option<String>;
18}
19
20/// The map of this device only. The app hands over its token through a JavaScript bridge.
21#[component]
22pub fn DevicePage() -> impl IntoView {
23 match app_token() {
24 Some(token) => {
25 view! { <main class="device"><map::MapPage source=map::Source::Device(token) /></main> }
26 .into_any()
27 }
28 None => view! { <p class="login">"This page is for the opentracker app."</p> }.into_any(),
29 }
30}
31
32/// A query parameter of `#pair?challenge=…&name=…`.
33fn hash_param(key: &str) -> Option<String> {
34 let hash = window().location().hash().ok()?;
35 let query = hash.split_once('?')?.1;
36 web_sys::UrlSearchParams::new_with_str(query).ok()?.get(key)
37}
38
39/// Confirms a new device for the signed-in user, then hands a one-time code back to the app.
40#[component]
41pub fn PairPage() -> impl IntoView {
42 let challenge = hash_param("challenge");
43 let name = RwSignal::new(hash_param("name").unwrap_or_else(|| "Android".into()));
44 let error = RwSignal::new(None::<String>);
45 let done = RwSignal::new(None::<String>);
46 let busy = RwSignal::new(false);
47 let has_challenge = challenge.is_some();
48
49 let connect = move |ev: leptos::ev::SubmitEvent| {
50 ev.prevent_default();
51 let Some(challenge) = challenge.clone() else {
52 return;
53 };
54 busy.set(true);
55 let body = PairBegin {
56 challenge,
57 name: name.get_untracked(),
58 };
59 spawn_local(async move {
60 match http::post::<PairCode>("/api/devices/pair/begin", &body).await {
61 Ok(c) => {
62 let url = format!("opentracker://paired?code={}", c.code);
63 let _ = window().location().assign(&url);
64 done.set(Some(url));
65 }
66 Err(e) => error.set(Some(e.to_string())),
67 }
68 busy.set(false);
69 });
70 };
71
72 view! {
73 <form class="login" on:submit=connect>
74 <h1>"Connect the app"</h1>
75 {move || match (has_challenge, done.get()) {
76 (false, _) => view! { <p class="error">"This link is incomplete. Start again in the app."</p> }.into_any(),
77 // Browsers may block opening the app without a tap, so the link stays as a fallback.
78 (true, Some(url)) => view! {
79 <p>"Done. Return to the app to finish."</p>
80 <a class="button primary" href=url>"Open the app"</a>
81 }
82 .into_any(),
83 (true, None) => view! {
84 <p>"The opentracker app asks to upload positions to your account as a new device."</p>
85 <label>"Device name" <input required maxlength="100" bind:value=name /></label>
86 <button class="primary" disabled=busy>"Connect"</button>
87 }
88 .into_any(),
89 }}
90 <p class="error">{move || error.get()}</p>
91 </form>
92 }
93}
94