A political conference and discussion platform, in Rust and Dioxus
Something went wrong. Try again.
27 kB · 588 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589use crate::model;use dioxus::prelude::*;
use crate::graphql;use crate::i18n::t;use crate::route::Route;use crate::session::use_session;
use super::loader::{icon_el, user_avatar};
/// ProfileApp — the signed-in user's profile (#78): who they are, plus what they/// have contributed. Reachable via `?app=profile`. Their groups and events are/// not listed here; the home page and the navigation tree are where those live.#[component]pub fn ProfileApp() -> Element { let session = use_session(); let user = session.read().user.clone(); let access_token = session.read().access_token.clone(); let user_id = user.as_ref().map(|u| u.id.clone()); // Bluesky handle to link — declared before the no-user early return below so // the hook order stays stable across renders. let mut bsky_handle = use_signal(String::new); // Whether the account was just unlinked this session (flips the card back to // the link form without needing to refetch), and the current link status. let mut just_unlinked = use_signal(|| false); let status_token = access_token.clone(); let bsky_status = crate::use_data_resource!(move || { let token = status_token.clone(); async move { match token { Some(t) => crate::backend_api::atproto_status(&t).await, None => crate::backend_api::AtprotoLink::default(), } } });
// Bluesky handle typeahead: preview matching accounts as the user types, so // they can pick their handle instead of typing it exactly. Hidden once a // suggestion is chosen (until they edit the field again). let mut sug_dismissed = use_signal(|| false); let suggestions = crate::use_data_resource!(move || { let q = bsky_handle.read().trim().to_string(); async move { if q.len() < 2 { return Vec::new(); } // Debounce: while the user keeps typing, this resource re-runs and the // pending future is dropped before the request fires. gloo_timers::future::TimeoutFuture::new(220).await; crate::backend_api::search_bsky_actors(&q).await } });
// Background Web Push (#128): whether THIS browser is subscribed (None until // the async check resolves). Declared before the early return for hook order. let mut push_on = use_signal(|| None::<bool>); use_hook(|| { spawn(async move { push_on.set(Some(crate::pwa::push_subscribed().await)); }); });
// The user's most recent authored contributions (resolutions, amendments, // candidacies, comments, questions), each linking to the item. let contrib_token = access_token.clone(); let contrib_uid = user_id.clone(); let contributions = crate::use_data_resource!(|(contrib_token, contrib_uid)| async move { match contrib_uid { Some(uid) => { graphql::query_user_contributions(contrib_token.as_deref(), &uid, 12).await } None => Vec::new(), } });
let Some(user) = user else { return rsx! { div { class: "card app-card", div { class: "card-content", p { class: "body-large", "{t(\"node.maybeLoginForAccess\")}" } Link { to: Route::Login {}, class: "btn btn-primary", "{t(\"common.logIn\")}" } } } }; };
let contrib_state = contributions.read().clone(); let link = bsky_status.read().clone().unwrap_or_default(); let show_linked = link.linked && !*just_unlinked.read();
rsx! { div { class: "card app-card", div { class: "card-header", div { class: "avatar", {user_avatar(&user.avatar_url, icon_el("app/profile"))} } div { h3 { class: "title-medium", "{user.display_name}" } p { class: "body-medium", class: "text-muted", "{t(\"profile.signedInAs\")} {user.email}" } } } div { class: "card-content", p { class: "body-small", class: "text-muted", "{t(\"profile.userId\")}: {user.id}" } } }
// Bluesky (atproto) account: when linked, show the handle + an unlink // action; otherwise hand off to the backend OAuth flow with the handle + // current NHost access token (it redirects back to APP_ORIGIN with // ?linked=bluesky|error, surfaced in a snackbar by App on load). div { class: "card app-card", div { class: "card-header", div { class: "avatar small", {crate::components::loader::bsky_logo()} } h3 { class: "title-medium", if show_linked { "{t(\"profile.blueskyAccount\")}" } else { "{t(\"profile.linkBluesky\")}" } } } div { class: "card-content", if show_linked { p { class: "body-medium mb-1", "{t(\"profile.linkedAs\")} " a { class: "link-accent", href: "https://bsky.app/profile/{link.handle}", target: "_blank", rel: "noopener noreferrer", "@{link.handle}" } } // On the AppView the account IS the sign-in: nothing to unlink. if cfg!(not(feature = "appview")) { button { class: "btn btn-secondary mt-1", onclick: move |_| { let tok = session.read().access_token.clone(); spawn(async move { let Some(tok) = tok else { return }; if crate::backend_api::atproto_unlink(&tok).await { just_unlinked.set(true); crate::snackbar::show_snackbar(&t("profile.unlinkedOk")); } else { crate::snackbar::show_snackbar(&t("profile.unlinkErr")); } }); }, span { class: "material-icons", "link_off" } " {t(\"profile.unlink\")}" } } } else { p { class: "body-medium text-muted mb-1", "{t(\"profile.linkBlueskyHint\")}" } div { class: "text-field", label { "{t(\"profile.blueskyHandle\")}" } input { r#type: "text", placeholder: "alice.bsky.social", autocomplete: "off", value: "{bsky_handle}", oninput: move |e| { bsky_handle.set(e.value()); sug_dismissed.set(false); }, } } // Live preview of matching Bluesky accounts — click to pick. { let items = suggestions.read().clone().unwrap_or_default(); if !items.is_empty() && !*sug_dismissed.read() { rsx! { div { class: "list mt-1", for actor in items.iter() { { let handle = actor.handle.clone(); rsx! { div { key: "{actor.handle}", class: "list-item", onclick: move |_| { bsky_handle.set(handle.clone()); sug_dismissed.set(true); }, div { class: "avatar small", {user_avatar(&actor.avatar, icon_el("wiki/user"))} } div { class: "list-item-text", div { class: "list-item-primary", "@{actor.handle}" } if !actor.display_name.is_empty() { div { class: "list-item-secondary", "{actor.display_name}" } } } } } } } } } } else { rsx! {} } } button { // mt-2 (16px), the gap .auth-form puts between a field and // its submit. At mt-1 the button crowded the input, whose // floating label makes the pair read tighter still. class: "btn btn-primary mt-2", disabled: bsky_handle.read().trim().is_empty(), onclick: move |_| { let handle = bsky_handle.read().trim().to_string(); let token = session.read().access_token.clone(); if let (false, Some(token)) = (handle.is_empty(), token) { // handle (a domain) and the base64url JWT are URL-safe. let url = crate::backend_api::atproto_start_url(&handle, &token); if let Some(w) = web_sys::window() { let _ = w.location().set_href(&url); } } }, span { class: "material-icons", "link" } " {t(\"profile.linkBluesky\")}" } } } }
// Background Web Push: opt this device in/out of notifications that arrive // even when the app is closed (e.g. a vote opening in a group you're in). if crate::pwa::push_supported() { div { class: "card app-card", div { class: "card-header", div { class: "avatar small", span { class: "material-icons", "notifications" } } h3 { class: "title-medium", "{t(\"profile.notifications\")}" } } div { class: "card-content", p { class: "body-medium text-muted mb-1", "{t(\"profile.notifyHint\")}" } { let on = *push_on.read(); let label = match on { Some(true) => t("profile.disableNotify"), _ => t("profile.enableNotify"), }; rsx! { button { class: if on == Some(true) { "btn btn-secondary mt-1" } else { "btn btn-primary mt-1" }, disabled: on.is_none(), onclick: move |_| { let tok = session.read().access_token.clone(); let currently = (*push_on.read()).unwrap_or(false); spawn(async move { let Some(tok) = tok else { return }; if currently { let _ = crate::pwa::unsubscribe_push(&tok).await; push_on.set(Some(false)); crate::snackbar::show_snackbar(&t("profile.notifyDisabled")); } else { match crate::pwa::subscribe_push(&tok).await { Ok(()) => { push_on.set(Some(true)); crate::snackbar::show_snackbar(&t("profile.notifyEnabled")); } Err(_) => { crate::snackbar::show_snackbar(&t("profile.notifyErr")); } } } }); }, span { class: "material-icons", if on == Some(true) { "notifications_off" } else { "notifications_active" } } " {label}" } } } } } }
// Latest contributions the user authored, each linking to the item. div { class: "card app-card", div { class: "card-header", div { class: "avatar small", span { class: "material-icons", "history_edu" } } h3 { class: "title-medium", "{t(\"profile.contributions\")}" } } {match &contrib_state { None => rsx! { div { class: "card-content", crate::components::widgets::Spinner {} } }, Some(items) => rsx! { ContributionList { items: items.clone() } }, }} } }}
/// An authored-contributions list, revealed a handful at a time via an/// incremental "show more".////// Shared with the group page: a group is credited on content the same way a/// person is, so it lists the same way (see `FolderApp`).#[component]pub(crate) fn ContributionList(items: Vec<model::ChildNodeFields>) -> Element { const STEP: usize = 5; let mut shown = use_signal(|| STEP); if items.is_empty() { return rsx! { div { class: "card-content", div { class: "empty-state empty-state-sm", div { class: "empty-state-orb empty-state-orb-sm", span { class: "material-icons", "history_edu" } } p { class: "empty-state-body", "{t(\"common.noContent\")}" } } } }; } let n = (*shown.read()).min(items.len()); rsx! { div { class: "list", for node in items[..n].iter() { ContributionItem { key: "{node.id.0}", node: node.clone() } } } if items.len() > n { button { class: "btn btn-text", onclick: move |_| { let s = *shown.read(); shown.set(s + STEP); }, "{t(\"layout.showMore\")}" } } }}
/// One authored contribution row: its mime icon, a primary line (the comment text/// for a comment, else the node name) and its parent as context. Clicking resolves/// the node's full path (like the home "Newest" list) and navigates to it.#[component]fn ContributionItem(node: model::ChildNodeFields) -> Element { let session = use_session(); let nav = use_navigator(); let node_id = node.id.0.clone(); let mime = node.mime_id.clone().unwrap_or_default(); let primary = if mime == "vote/comment" { node.data .as_ref() .and_then(|d| d.0.get("text")) .and_then(|t| t.as_str()) .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .unwrap_or_else(|| t("vote.comments")) } else { node.name.clone() }; let parent_name = node.parent.as_ref().map(|p| p.name.clone()); let created = node.created_at.as_ref().map(|c| c.0.clone()); let is_comment = mime == "vote/comment";
rsx! { div { class: "list-item", onclick: move |_| { let node_id = node_id.clone(); let token = session.read().access_token.clone(); spawn(async move { // A comment is not a page: open the content hosting its // thread. Only comments pay for the extra lookup. let target = if is_comment { graphql::thread_host_id(token.as_deref(), &node_id).await } else { node_id.clone() }; if let Ok(segments) = graphql::path_from_id(token.as_deref(), &target).await { if !segments.is_empty() { nav.push(Route::PathPage { segments, app: None }); } } }); }, div { class: "avatar small", {icon_el(&mime)} } div { class: "list-item-text", div { class: "list-item-primary", "{primary}" } // Where and when, on one secondary line — the same relative time // (with the exact date on hover) the home "Newest" list shows. if parent_name.is_some() || created.is_some() { div { class: "list-item-secondary", if let Some(pn) = parent_name.as_ref() { span { "{pn}" } } if let Some(iso) = created.as_ref() { if parent_name.is_some() { span { " \u{00b7} " } } span { title: "{super::loader::full_datetime(iso)}", "{super::loader::relative_time(iso)}" } } } } } } }}
/// The profile route (`/profile/:id`) — the one place a person is shown.////// Your own id renders the full self view ([`ProfileApp`]: identity, the Bluesky/// link, your contributions). Anyone else's renders what you may see of them:/// their name + avatar and the groups and events you share. The memberships/// query is filtered by what the VIEWER may see, so it is the intersection —/// permission-safe. User representations across the app link here via/// [`super::loader::UserPopover`].#[component]pub fn UserProfile(id: String) -> Element { let session = use_session(); let access_token = session.read().access_token.clone();
// Your own profile is the richer view, and it is the same page — there is no // separate `?app=profile` any more. let is_me = session.read().user.as_ref().map(|u| u.id.clone()) == Some(id.clone()); if is_me { return rsx! { ProfileApp {} }; }
let uid = id.clone(); let tok = access_token.clone(); let user_res = crate::use_data_resource!(|(uid, tok)| async move { graphql::query_user(tok.as_deref(), &uid).await });
let uid2 = id.clone(); let tok2 = access_token.clone(); let memberships = crate::use_data_resource!(|(uid2, tok2)| async move { let mut out = Vec::new(); for mime in crate::model::CONTEXT_MIMES { if let Ok(nodes) = graphql::query_contexts(tok2.as_deref(), &uid2, mime).await { out.extend(nodes); } } out });
// What this person has written, the same list your own profile shows. The // query is row-filtered by Hasura, so it is the intersection of their work // and what you may see — never a window into contexts you are not in. let uid3 = id.clone(); let tok3 = access_token.clone(); let contributions = crate::use_data_resource!(|(uid3, tok3)| async move { graphql::query_user_contributions(tok3.as_deref(), &uid3, 12).await });
let user = user_res.read().clone().flatten(); let name = user .as_ref() .map(|u| u.display_name.clone()) .filter(|s| !s.is_empty()) .unwrap_or_else(|| t("common.unknown")); let avatar_url = user .as_ref() .map(|u| u.avatar_url.clone()) .unwrap_or_default(); let contexts = memberships.read().clone().unwrap_or_default(); // Their linked Bluesky account, read off the avatar: once linked, the picture // is served from the bsky CDN and its path carries the account's DID, which // bsky.app resolves. `/atproto/status` only answers for the caller, so this // is the only way to show someone else's. Same trick as `UserPopover`. let bsky_did = avatar_url .contains("cdn.bsky.app/") .then(|| avatar_url.split('/').find(|seg| seg.starts_with("did:"))) .flatten() .map(str::to_string); let contrib_state = contributions.read().clone();
rsx! { div { class: "card app-card", div { class: "profile-hero", div { class: "profile-hero-avatar", {user_avatar(&avatar_url, icon_el("wiki/user"))} } div { h3 { class: "profile-hero-name", "{name}" } if let Some(did) = bsky_did.as_ref() { a { // stack stack-h so the mark sits on the text baseline // row; .bsky-logo is already sized for inline use. class: "link-accent stack stack-h", href: "https://bsky.app/profile/{did}", target: "_blank", rel: "noopener noreferrer", {crate::components::loader::bsky_logo()} "{t(\"profile.blueskyAccount\")}" } } } } } // What they have written, first: it is why you opened someone's profile. // The same card your own profile carries, but titled without the "Your", // since this is someone else's page. div { class: "card app-card mt-1", div { class: "card-header", div { class: "avatar small", span { class: "material-icons", "history_edu" } } h3 { class: "title-medium", "{t(\"profile.contributionsOther\")}" } } {match &contrib_state { None => rsx! { div { class: "card-content", crate::components::widgets::Spinner {} } }, Some(items) => rsx! { ContributionList { items: items.clone() } }, }} } // Where you overlap, below it: context for the reader, not the point. div { class: "card app-card mt-1", div { class: "card-header", // join_inner, the set-intersection mark: this card is precisely // the overlap between their memberships and yours, and it does not // read as the plain "Groups" list, which owns the `groups` glyph. div { class: "avatar small", span { class: "material-icons", "join_inner" } } h3 { class: "title-medium", "{t(\"profile.sharedMemberships\")}" } } if contexts.is_empty() { div { class: "empty-state empty-state-sm", div { class: "empty-state-orb empty-state-orb-sm", span { class: "material-icons", "groups" } } p { class: "empty-state-body", "{t(\"common.noContent\")}" } } } else { div { class: "list", for ctx in contexts.iter() { SharedContextItem { key: "{ctx.id.0}", node: ctx.clone() } } } } } }}
/// One shared group/event row. Resolves the context's full ancestor path on/// click rather than linking to its bare key: a group or event nested inside/// another lives at `/parent/child`, so the one-segment link 404'd for exactly/// those. Mirrors the home list's `ContextItem`.#[component]fn SharedContextItem(node: model::ContextNodeFields) -> Element { let session = use_session(); let nav = use_navigator(); let node_id = node.id.0.clone(); let key = node.key.clone();
rsx! { div { class: "list-link", onclick: move |_| { let node_id = node_id.clone(); let key = key.clone(); let token = session.read().access_token.clone(); spawn(async move { let mut segments = graphql::path_from_id(token.as_deref(), &node_id) .await .unwrap_or_default(); // A path we cannot resolve is still better attempted than // dropped: the bare key works for a top-level context. if segments.is_empty() { segments = vec![key]; } nav.push(Route::PathPage { segments, app: None }); }); }, super::widgets::ListItem { headline: node.name.clone(), leading: rsx! { div { class: "avatar small", {icon_el(node.mime_id.as_deref().unwrap_or(""))} } }, } } }}