A political conference and discussion platform, in Rust and Dioxus
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456// A UI crate's f64 casts are chart geometry, list counts and epoch millis -// all far below 2^52, so usize/i64 -> f64 is exact here. The lint stays on// for the decoders and wire formats where truncation is real.#![allow(clippy::cast_precision_loss)]// A native build of the AppView layer is for its tests, which run without a// browser and so leave out the half that needs one (storage, toasts, the log).#![cfg_attr( all(feature = "appview", not(target_arch = "wasm32")), allow(dead_code))]#[cfg(feature = "appview")]mod appview;// The switch: under the feature the three modules that speak to the interim's// backend are the AppView's, under the names every caller already uses.#[cfg(feature = "appview")]use appview::{self as graphql, account as nhost, api as backend_api};#[cfg(not(feature = "appview"))]mod backend_api;mod bsky;mod build_info;mod components;mod crash;mod errors;mod export;// cynic query-result structs must select fields the code doesn't read (e.g. an// insert's returned `id`, a delete's `affected_rows`), which reads as dead code./// The trail of what the reader did, kept in every build: a crash report says/// what broke, and this says what they were doing when it did.mod breadcrumbs;#[allow(dead_code)]#[cfg(not(feature = "appview"))]mod graphql;mod i18n;#[cfg(feature = "remote-logging")]mod logging;/// Frontend-owned domain types (the anti-corruption seam over `graphql.rs`).pub mod model;mod nav_memory;#[cfg(not(feature = "appview"))]mod nhost;/// Last-read pages kept in localStorage, served when nothing can be reached.mod offline;mod pdf_text;mod pwa;mod query_cache;mod route;mod runtime;mod scroll_host;mod session;pub mod snackbar;mod subscription;mod theme;mod update;mod window_size;
use dioxus::prelude::*;use route::Route;use wasm_bindgen::closure::Closure;use wasm_bindgen::JsCast;
const STYLE_CSS: Asset = asset!("/assets/style.css");// The dioxus-components (shadcn-style) theme tokens, loaded before our own CSS// so app styles can build on / override them as screens migrate to primitives.const DX_THEME_CSS: Asset = asset!("/assets/dx-components-theme.css");// The Material Design 3 colour scheme (generated from the Radikale brand seeds// by scripts/gen-theme.ts). Defines the canonical --md-sys-color-* roles that// style.css's --md-* tokens alias, so the whole app re-skins from one file.const M3_THEME_CSS: Asset = asset!("/assets/m3-theme.css");// Non-colour M3 system tokens (type scale, shape/corner scale, elevation,// state-layer opacities, motion) — hand-authored from the M3 spec.const M3_TOKENS_CSS: Asset = asset!("/assets/m3-tokens.css");
// Self-hosted fonts (privacy + offline + sovereignty: no Google Fonts CDN). The// body face (Atkinson Hyperlegible, latin subset) and the Material Icons ligature// font are bundled via asset!() and declared with @font-face at mount, replacing// the two CDN @imports style.css used to carry. Bundling them means the service// worker's cache-first /assets rule also caches them, so they work offline.const FONT_ATK_400: Asset = asset!("/assets/fonts/atkinson-400.woff2");const FONT_ATK_700: Asset = asset!("/assets/fonts/atkinson-700.woff2");const FONT_ATK_400I: Asset = asset!("/assets/fonts/atkinson-400i.woff2");const FONT_ATK_700I: Asset = asset!("/assets/fonts/atkinson-700i.woff2");const FONT_MATERIAL: Asset = asset!("/assets/fonts/material-icons.woff2");// Metric-compatible with Calibri, and used for ONE thing: laying a Word// document out at the size Word would, to work out where its pages end (see// `components::docx::PagedDocx`). Nothing on screen is set in it. A face is// fetched only when something actually renders in it, so declaring it here// costs nothing until someone opens a Word document -- and then the service// worker keeps it, like the others.//// It has to be shipped rather than hoped for: Calibri is on no Linux or// Android device and few Macs, so the measurement fell back to whatever the// browser chose, which is 8% wider and lays the text out somewhere else. That// made the page numbers depend on the reader's machine.const FONT_CARLITO_400: Asset = asset!("/assets/fonts/carlito-400.woff2");const FONT_CARLITO_700: Asset = asset!("/assets/fonts/carlito-700.woff2");// And the same for a document set in Times New Roman, which several of these// are: Liberation Serif has Times' metrics, as Carlito has Calibri's. One face// cannot serve both -- a Times document measured in Carlito came out a tenth// too tall, which is a page.const FONT_SERIF_400: Asset = asset!("/assets/fonts/serif-400.woff2");const FONT_SERIF_700: Asset = asset!("/assets/fonts/serif-700.woff2");// And for Cambria, which is what Word's older Danish templates set their body// in: Caladea has Cambria's metrics. Times' are not close enough to borrow --// Cambria is the wider face, and measuring a Cambria document in Liberation// Serif fitted five list items too many onto every page.const FONT_CALADEA_400: Asset = asset!("/assets/fonts/caladea-400.woff2");const FONT_CALADEA_700: Asset = asset!("/assets/fonts/caladea-700.woff2");// And for Arial, which the older attachments are set in outright: Liberation// Sans has Arial's metrics. Calibri's are not a stand-in for them -- Arial is// the wider face by about a tenth, so an Arial table measured in Carlito wraps// later than Word wraps it and comes out rows short.const FONT_SANS_400: Asset = asset!("/assets/fonts/sans-400.woff2");const FONT_SANS_700: Asset = asset!("/assets/fonts/sans-700.woff2");
fn main() { // Print real panic messages (with a JS stack trace) to the console instead // of a bare `unreachable executed` wasm trap — the single highest-value // change for debugging authenticated-load traps in Servo. console_error_panic_hook::set_once(); // With `remote-logging`, ship warn/error (plus breadcrumbs + panics) to // Logtail and mirror to the console; otherwise just log to the console. // Whether or not anyone is collecting logs, the trail is collected: it is // what a crash report needs to be worth reading. breadcrumbs::watch(); #[cfg(feature = "remote-logging")] logging::init(); #[cfg(not(feature = "remote-logging"))] { wasm_logger::init(wasm_logger::Config::default()); // `logging::init` installs the same hook with a reporter attached; // without that feature the app still needs to tell the reader it died. crash::install_hook(|_| {}); } log::info!("RadikalWiki starting...");
// Clean the stray trailing "?" the router emits for the optional `app` query // before the first navigation writes it to the address bar. install_history_query_shim();
// Capture a password-reset deep link (`/?type=passwordReset&refreshToken=...`) // now, before the router mounts and rewrites `/`'s query away; Layout then // exchanges the token and shows the set-password form. components::layout::capture_reset_token(); // Likewise the one-time code a sign-in on the AppView comes back with. #[cfg(feature = "appview")] components::auth::capture_returned_code();
// PWA: install the manifest / icon / theme-color head tags and register the // service worker (offline where the SW controls the root). pwa::setup();
dioxus::launch(App);}
/// Strip the stray trailing "?" the Dioxus router writes for the optional `app`/// query (a route with no `app=` serializes to e.g. "/group?"). Wrap/// `history.pushState` / `history.replaceState` so any URL ending in a bare "?"/// is cleaned at the source, before it reaches the address bar. This is/// race-free, unlike stripping after the fact in an effect: the router rewrites/// the URL on its own schedule and would re-add the "?" after such a strip.fn install_history_query_shim() { use wasm_bindgen::JsValue; let Some(win) = web_sys::window() else { return }; let Ok(history) = js_sys::Reflect::get(&win, &JsValue::from_str("history")) else { return; }; for method in ["pushState", "replaceState"] { let key = JsValue::from_str(method); let Ok(orig) = js_sys::Reflect::get(&history, &key) else { continue; }; let Ok(orig_fn) = orig.dyn_into::<js_sys::Function>() else { continue; }; let this = history.clone(); let wrapper = Closure::wrap( Box::new(move |state: JsValue, title: JsValue, url: JsValue| { let url = match url.as_string().as_deref().and_then(|s| s.strip_suffix('?')) { Some(stripped) => JsValue::from_str(stripped), _ => url, }; let args = js_sys::Array::of3(&state, &title, &url); let _ = js_sys::Reflect::apply(&orig_fn, &this, &args); }) as Box<dyn FnMut(JsValue, JsValue, JsValue)>, ); let _ = js_sys::Reflect::set(&history, &key, wrapper.as_ref().unchecked_ref()); // Leak the closure so the wrapped method lives for the app's lifetime. wrapper.forget(); }}
/// Extract the `claim` query-param value from a `location.search` string/// (e.g. `?claim=abc&x=1` → `abc`). The token is a uuid, so no decoding needed.fn claim_token_from_search(search: &str) -> Option<String> { search .trim_start_matches('?') .split('&') .find_map(|pair| pair.strip_prefix("claim=")) .map(str::to_string) .filter(|v| !v.is_empty())}
/// How long the boot screen will wait for the stylesheets before giving up on/// them, and how often it checks. A ceiling is required: a stylesheet that 404s/// or is blocked never applies, and unstyled content is bad where a loading/// screen that never leaves is worse.const CSS_WAIT_MS: u32 = 3000;const POLL_MS: u32 = 16;
/// True once every stylesheet on the page has actually applied.////// A `<link>` that has been fetched and parsed exposes a `sheet`; one still in/// flight — or one that failed — has null there. Read through `Reflect` because/// `HtmlLinkElement` is not among this crate's web-sys features, the same way/// `back_to_top` reaches VisualViewport.////// No links yet means not ready rather than ready: at the moment this starts/// running, Dioxus may not have injected them.fn stylesheets_applied(doc: &web_sys::Document) -> bool { let Ok(links) = doc.query_selector_all("link[rel=stylesheet]") else { // Nothing can be checked, so nothing can be waited for. return true; }; if links.length() == 0 { return false; } (0..links.length()).all(|i| { links.item(i).is_some_and(|node| { js_sys::Reflect::get(&node, &wasm_bindgen::JsValue::from_str("sheet")) .is_ok_and(|sheet| !sheet.is_null() && !sheet.is_undefined()) }) })}
#[component]fn App() -> Element { // Hand the runtime to the raw JS callbacks (scroll, focus, socket messages), // which the browser calls with nothing of ours on the stack. Here because // this is the outermost place that is unambiguously inside it. use_hook(crate::runtime::remember);
// Initialize global state once, *inside* the Dioxus runtime. Writing to a // `GlobalSignal` (SESSION / LANG) from `main` before `launch` panics with // "Must be called from inside a Dioxus runtime" — and did so non-obviously // only when localStorage already held a session (so `load_session` wrote), // which is exactly the flaky authenticated-load trap from PLAN.md issue 1. // Take the boot screen down. It is in `index.html` so it can be drawn before // the stylesheet or the wasm exist (see the comment there); this is the // other half — the app is on screen now, so the placeholder goes. An effect // rather than the hook below, because a hook runs BEFORE the first render // and would clear the screen a frame early. use_effect(|| { let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return; }; let Some(boot) = doc.get_element_by_id("boot") else { return; }; spawn(async move { // Mounted is not the same as ready. Dioxus adds its `asset!` // stylesheets as <link> elements at RUNTIME, so the first render can // land before any of them have applied — for those frames the page // is real content with no CSS, which reads as an avatar at its full // natural size and unstyled text. Hold the screen until the CSS is // actually on. // // Twice in a row, because the links are injected around the same // render this effect follows: a single pass can fall in the gap // before the last one exists and call an empty set ready. let mut settled = 0; for _ in 0..(CSS_WAIT_MS / POLL_MS) { if stylesheets_applied(&doc) { settled += 1; if settled == 2 { break; } } else { settled = 0; } gloo_timers::future::TimeoutFuture::new(POLL_MS).await; } // Fade, then remove: the class starts the transition in `index.html`, // and the node goes once it is over so it can never swallow a click. let _ = boot.set_attribute("class", "is-done"); gloo_timers::future::TimeoutFuture::new(300).await; boot.remove(); }); });
use_hook(|| { // Load persisted session from localStorage. session::load_session();
// Watch for a newer deploy. A single-page app in a tab left open never // re-fetches itself, and the service worker can serve a stale shell even // on reload, so being out of date is otherwise invisible. update::spawn_update_check();
// If we just came back from the atproto (Bluesky) linking flow, surface the // outcome and drop the ?linked query so it does not re-fire on refresh. if let Some(win) = web_sys::window() { if let Ok(search) = win.location().search() { let msg = if search.contains("linked=bluesky") { Some(i18n::t("profile.linkedOk")) } else if search.contains("linked=error") { Some(i18n::t("profile.linkedErr")) } else { None }; if let Some(msg) = msg { snackbar::show_snackbar(&msg); if let Ok(history) = win.history() { let _ = history.replace_state_with_url( &wasm_bindgen::JsValue::NULL, "", Some("/"), ); } }
// A `?claim=<token>` invitation link: stash the token (so it // survives a login) and strip it from the URL so the secret token // isn't left in history. Consumed by the effect below once signed in. if let Some(tok) = claim_token_from_search(&search) { *session::PENDING_CLAIM.write() = Some(tok); if let Ok(history) = win.history() { let _ = history.replace_state_with_url( &wasm_bindgen::JsValue::NULL, "", Some("/"), ); } } } }
// Detect browser language for i18n. if let Some(window) = web_sys::window() { if let Some(lang) = window.navigator().language() { if lang.starts_with("da") { *i18n::LANG.write() = i18n::Lang::Da; } } } // Reflect the language on <html lang> so `hyphens: auto` works. i18n::apply_lang(&i18n::LANG.read());
// Load the persisted theme, then apply it to the document element. theme::load_theme(); theme::apply_theme(&theme::THEME.read()); // Load any user-picked M3 seed colours and inject the override scheme. theme::load_seeds();
// Nudge the token-refresh loop whenever the tab becomes visible again: // a backgrounded tab throttles timers, so the access token can lapse // while it sits stale. Refreshing on return keeps the session alive. if let Some(document) = web_sys::window().and_then(|w| w.document()) { let doc = document.clone(); let closure = Closure::wrap(Box::new(move || { if !doc.hidden() { session::nudge_refresh(); } }) as Box<dyn FnMut()>); let _ = document.add_event_listener_with_callback( "visibilitychange", closure.as_ref().unchecked_ref(), ); // Leak the closure so the listener lives for the app's lifetime. closure.forget(); } });
// Consume a pending `?claim=` invitation once signed in: bind the membership // to this account (regardless of the roster email) and land on the context. let session_sig = session::use_session(); use_effect(move || { let authed = session_sig.read().is_authenticated(); let pending = session::PENDING_CLAIM(); if authed { if let Some(claim) = pending { *session::PENDING_CLAIM.write() = None; let token = session_sig.read().access_token.clone(); spawn(async move { let Some(token) = token else { return }; match backend_api::claim_membership(&token, &claim).await { Ok(context) => { session::bump_data_version(); snackbar::show_snackbar(&i18n::t("invite.claimedOk")); if let Ok(segments) = graphql::path_from_id(Some(&token), &context).await { if !segments.is_empty() { if let Some(win) = web_sys::window() { let _ = win .location() .set_href(&format!("/{}", segments.join("/"))); } } } } Err(_) => snackbar::show_snackbar(&i18n::t("invite.claimErr")), } }); } } });
// Keep the NHost access token fresh (renew before expiry / on return). use_future(session::run_token_refresh);
// @font-face for the self-hosted (asset-bundled) fonts. Built here so the src // URLs are the content-hashed asset paths, avoiding any CSS url() rewriting // dependency. `block` display for icons prevents a flash of ligature text. let font_face = format!( concat!( "@font-face{{font-family:'Atkinson Hyperlegible';font-style:normal;font-weight:400;font-display:swap;src:url({}) format('woff2')}}", "@font-face{{font-family:'Atkinson Hyperlegible';font-style:normal;font-weight:700;font-display:swap;src:url({}) format('woff2')}}", "@font-face{{font-family:'Atkinson Hyperlegible';font-style:italic;font-weight:400;font-display:swap;src:url({}) format('woff2')}}", "@font-face{{font-family:'Atkinson Hyperlegible';font-style:italic;font-weight:700;font-display:swap;src:url({}) format('woff2')}}", "@font-face{{font-family:'Material Icons';font-style:normal;font-weight:400;font-display:block;src:url({}) format('woff2')}}", "@font-face{{font-family:'Carlito';font-style:normal;font-weight:400;font-display:swap;src:url({}) format('woff2')}}", "@font-face{{font-family:'Carlito';font-style:normal;font-weight:700;font-display:swap;src:url({}) format('woff2')}}", "@font-face{{font-family:'Liberation Serif';font-style:normal;font-weight:400;font-display:swap;src:url({}) format('woff2')}}", "@font-face{{font-family:'Liberation Serif';font-style:normal;font-weight:700;font-display:swap;src:url({}) format('woff2')}}", "@font-face{{font-family:'Caladea';font-style:normal;font-weight:400;font-display:swap;src:url({}) format('woff2')}}", "@font-face{{font-family:'Caladea';font-style:normal;font-weight:700;font-display:swap;src:url({}) format('woff2')}}", "@font-face{{font-family:'Liberation Sans';font-style:normal;font-weight:400;font-display:swap;src:url({}) format('woff2')}}", "@font-face{{font-family:'Liberation Sans';font-style:normal;font-weight:700;font-display:swap;src:url({}) format('woff2')}}", ), FONT_ATK_400, FONT_ATK_700, FONT_ATK_400I, FONT_ATK_700I, FONT_MATERIAL, FONT_CARLITO_400, FONT_CARLITO_700, FONT_SERIF_400, FONT_SERIF_700, FONT_CALADEA_400, FONT_CALADEA_700, FONT_SANS_400, FONT_SANS_700 );
rsx! { style { dangerous_inner_html: font_face } document::Stylesheet { href: DX_THEME_CSS } document::Stylesheet { href: M3_THEME_CSS } document::Stylesheet { href: M3_TOKENS_CSS } document::Stylesheet { href: STYLE_CSS } Router::<Route> {} snackbar::Snackbar {} update::UpdateBanner {} }}