use dioxus::prelude::*; use crate::graphql::{self}; use crate::i18n::t; use crate::model::NodeWithChildren; use crate::route::Route; use crate::session::use_session; use super::content::ContentApp; use super::editor::EditorApp; use super::file::FileApp; use super::folder::FolderApp; use super::home::HomeApp; use super::member::MemberApp; use super::node::NodeApp; use super::sort::SortApp; use super::speak::SpeakApp; use super::vote::{PolicyApp, PollApp, PositionApp, VoteApp}; /// The catch-all path page. Re-keys the resolver on the full path so navigating /// between two `PathPage` routes remounts it and re-runs the query: `use_resource` /// only re-runs for reactive reads inside its closure, not for a changed prop, so /// without this the view would keep showing the previously resolved node. #[component] pub fn PathPage(segments: Vec, app: Option) -> Element { // Read the route directly (like the breadcrumbs and app rail) so this // re-renders on EVERY navigation. Relying only on the router handing us new // props is unreliable when moving between two `PathPage` routes (e.g. a // breadcrumb click going up a level): some renders keep the old props, so // the URL changed but the view stayed put. Subscribing via `use_route` // guarantees the re-render, after which the key below remounts the resolver. let route = use_route::(); let (segments, app) = match route { Route::PathPage { segments, app } => (segments, app), _ => (segments, app), }; // Re-key the resolver on the path (not the app) so navigating between two // paths remounts and refetches, while switching apps at the same path just // re-renders and swaps the view without a redundant query. Join with a // separator that cannot appear in node keys (not "/", which a lint mistakes // for filesystem path joining) so the key is unique per path. let key = segments.join("\u{1f}"); rsx! { PathResolver { key: "{key}", segments, app } } } /// `/`: its own route (Dioxus 0.7 can't serialize the empty catch-all to a /// usable URL), sharing the same [`PathResolver`] as every other path. Without /// an app it renders the welcome; `?app=editor` opens the owner-only root editor. #[component] pub fn Home(app: Option) -> Element { rsx! { PathResolver { segments: Vec::::new(), app } } } /// Whether the signed-in user owns the currently resolved page's context. /// `None` while the page is still resolving. Written by [`PathResolver`]; read /// by the navigation surfaces (`layout::breadcrumbs::context_apps`) so the /// owner-only apps (members, console) only show for context owners — and never /// flash before ownership is known (they appear once it resolves, animated by /// the rail/bar item entry animation). pub(crate) static CTX_IS_OWNER: GlobalSignal> = Signal::global(|| None); /// The id of the context the current page belongs to, or `None` while that is /// unknown. Read by the pending-invitation prompt (`layout::InviteToJoin`) to /// tell whether the reader has an unaccepted invitation to THIS place. /// /// Published from the same resolve as [`CTX_IS_OWNER`] and on the same terms: /// a context is a property of the page, so working it out twice would be two /// chances to disagree. pub(crate) static CTX_ID: GlobalSignal> = Signal::global(|| None); /// Which context the flags above were worked out for, as its path. /// /// Ownership belongs to the CONTEXT, not to the node inside it, so stepping from /// one document to the next in the same place cannot change the answer. It was /// being recomputed from each page's own node all the same, so it went unknown /// on every step and the owner-only rail items vanished and came back at every /// move. /// /// Remembering what the answer was ABOUT keeps both halves: within the context /// the answer stands while the next node loads, and going somewhere else still /// goes unknown, which is what stops the previous place's apps from flashing. static CTX_OWNER_FOR: GlobalSignal>> = Signal::global(|| None); /// Resolves a path to a node and renders the matching app. The query re-runs /// whenever the path (or token) changes. #[component] fn PathResolver(segments: Vec, app: Option) -> Element { let session = use_session(); let access_token = session.read().access_token.clone(); // Depend reactively on `segments` so the query re-runs when the path // changes, WITHOUT relying on a keyed remount: changing a single child's // `key` does not reliably force a remount in the web renderer (it does in // Servo, which is why this only showed up in real browsers), so a // path-change navigation would otherwise keep the previously resolved node. // Also depend on the global data version so a mutation (e.g. saving the // editor and returning to this same path) forces a refetch: the path and // token are unchanged, so without this the resolver would serve the stale // pre-edit node until a full reload. let segs = segments.clone(); // Keyed on WHO as well: the page carries its children, and which children // depends on the reader (their own drafts are theirs to see). let who = session.read().identity(); let node_future = crate::use_data_resource!(|(segs, access_token, who)| async move { // Empty segments is `/?app=editor` (the root editor): the root has no path // row, so `resolve_path(&[])` is `None`; fetch it by id instead. The // plain `/` welcome below does not depend on this succeeding. if segs.is_empty() { graphql::query_root_node(access_token.as_deref(), &who).await } else { graphql::resolve_path(access_token.as_deref(), &segs, &who).await } }); // Publish whether the user owns this page's context, for the nav surfaces // (owner-only rail apps). An effect rather than a render-time write. While // a navigation is still resolving the state is UNKNOWN (`None`): the owner // apps stay hidden rather than flashing the previous context's set and // then disappearing. let owner_segments = segments.clone(); use_effect(move || { // Subscribed, not peeked: the depth arrives with the crumbs, which can // land after this runs. Re-running then is the point -- a step that // turns out to have entered a NEW context stops counting as the same // one, and the answer goes back to unknown until this node says. let here = super::layout::context_path(&owner_segments); let answered = node_future.read().is_some(); let owner = match &*node_future.read() { Some(Ok(Some(node))) => Some(node.is_context_owner.unwrap_or(false)), Some(_) => Some(false), // Still resolving. The answer stands if it was about this same // context; anywhere else is unknown. None => match CTX_OWNER_FOR.peek().as_deref() == Some(here.as_slice()) { true => *CTX_IS_OWNER.peek(), false => None, }, }; if *CTX_IS_OWNER.peek() != owner { *CTX_IS_OWNER.write() = owner; } // Same three cases as the owner flag: answered, answered-but-nothing, // or still resolving (in which case the previous answer stands only // while it was about this same context). let ctx_id = match &*node_future.read() { Some(Ok(Some(node))) => node.context_id.as_ref().map(|u| u.0.clone()), Some(_) => None, None => match CTX_OWNER_FOR.peek().as_deref() == Some(here.as_slice()) { true => CTX_ID.peek().clone(), false => None, }, }; if *CTX_ID.peek() != ctx_id { *CTX_ID.write() = ctx_id; } if answered && CTX_OWNER_FOR.peek().as_deref() != Some(here.as_slice()) { *CTX_OWNER_FOR.write() = Some(here); } }); // Node-independent apps (profile, parent) take no node, so render them // directly — otherwise the empty-path HomeApp short-circuit below would // swallow `/?app=profile` and show the home page instead. match app.as_deref() { // No `profile` arm: a person is shown at /profile/:id, your own id // included, so there is one profile page rather than two. Some("parent") => return rsx! { super::parent::ParentApp {} }, Some("feedback") => return rsx! { super::feedback_app::FeedbackApp {} }, Some("game") => return rsx! { super::game::GameApp {} }, _ => {} } // `/` (empty path, no app) is the welcome page. Render HomeApp directly: it // fetches the root itself and still renders when logged out (or when the root // isn't readable), whereas resolving the root here would 404 for an anonymous // visitor and hide the welcome card + login links. // // WITH an app it falls through to the resolver, which resolves the root node // and opens that app on it. The root is a context like any other: it has // members (its owners), permission rules and editable content, and this is // the only way to reach them. Only `?app=editor` used to get through, so the // root's own member list could not be opened at all. if segments.is_empty() && app.is_none() { return rsx! { HomeApp {} }; } let result = node_future.read().clone(); // At the root, a resolve that fails or finds nothing is not an error page: // `/` is the welcome page, and an anonymous visitor cannot read the root node // at all. Only real paths get "not found" and the error card. if segments.is_empty() && matches!(result, Some(Err(_)) | Some(Ok(None))) { return rsx! { HomeApp {} }; } match result { Some(Ok(Some(node))) => { // The active app comes from the route's `?app=` query. match app.as_deref() { Some("feed") => rsx! { super::feed::FeedApp { node } }, Some("bin") => rsx! { super::bin::BinApp { node } }, Some("vote") => rsx! { VoteApp { node } }, Some("speak") => rsx! { SpeakApp { node, mode: super::speak::SpeakMode::Full } }, Some("member") => rsx! { MemberApp { node } }, Some("editor") => rsx! { EditorApp { node } }, Some("sort") => rsx! { SortApp { node } }, // `pixel` as well as `canvas`: the app was called that until it // was renamed, and links to it are already out there. Some("canvas") | Some("pixel") => { rsx! { super::pixel::PixelCanvasesApp { node } } } Some("screen") => rsx! { super::screen::ScreenApp { node } }, Some("follow") => rsx! { super::screen::FollowApp { node } }, Some("admin") => rsx! { super::admin::AdminApp { node } }, Some("perm") => rsx! { super::perm::PermApp { node } }, Some("map") => rsx! { super::map::MapApp { node } }, Some("graph") => rsx! { super::graph::GraphApp { node, path: segments.clone() } }, Some("program") => { rsx! { super::program::ProgramApp { node, path: segments.clone() } } } // profile / parent are handled before node resolution (they need // no node), so they don't appear here. Some("redirect") => rsx! { super::redirect::RedirectApp { node } }, Some("social") => rsx! { super::social::SocialApp { node } }, Some("cow") => rsx! { super::cow::CowApp { node } }, _ => rsx! { MimeLoader { node, path: segments.clone() } }, } } Some(Ok(None)) => { rsx! { NodeNotFound {} } } Some(Err(e)) => { // Log the detail; show a friendly state, never a raw debug dump. crate::errors::log_handled("resolve node", &e); // A refusal is not a fault. Signed out, most pages answer "you may // not", and telling that reader something went wrong is both untrue // and unhelpful: nothing is broken and retrying will not fix it. Say // it needs an account instead, which is the actual next step. match crate::errors::classify(e.as_str()) { crate::errors::Failure::Refused => rsx! { NodeNotFound {} }, _ => rsx! { div { class: "card accent-error", super::widgets::ErrorState { title: t("error.somethingWentWrong") } } }, } } None => { rsx! { ContentSkeleton {} } } } } /// DESIGN (functional): a content-shaped shimmer placeholder shown while a /// node loads, instead of a bare spinner — better perceived performance because /// the layout appears immediately in roughly its final shape. #[component] fn ContentSkeleton() -> Element { rsx! { div { class: "card app-card", div { class: "skeleton-card", div { class: "skeleton-row", div { class: "skeleton skeleton-avatar" } div { class: "flex-grow", // Only the shape (the share of the line each placeholder // fills) is per-instance; every other value is the // .skeleton-line treatment. div { class: "skeleton skeleton-line", style: "width: 45%;" } div { class: "skeleton skeleton-line", style: "width: 25%;" } } } div { class: "skeleton skeleton-line", style: "width: 92%;" } div { class: "skeleton skeleton-line", style: "width: 97%;" } div { class: "skeleton skeleton-line", style: "width: 83%;" } div { class: "skeleton skeleton-line", style: "width: 89%;" } } } } } /// Routes a node to the appropriate app based on its MIME type. `projector` is set /// only by the Screen/projector view, where a poll shows its live tally (not the /// ballot) and the room-facing CSS strips interactive chrome. #[component] pub fn MimeLoader( node: NodeWithChildren, path: Vec, #[props(default)] projector: bool, ) -> Element { let mime_id = node.mime_id.as_deref().unwrap_or(""); match mime_id { "wiki/folder" => rsx! { FolderApp { node: node.clone(), parent_path: path, projector } }, "wiki/document" if projector => rsx! { ContentApp { node: node.clone() } }, // Comments UNDER the text, not beside it. The supporting pane stands a // thread next to the primary content once the column is wide enough, // which is right for a console or a profile, where the thread is part of // the work. Reading is not that: the split takes the width off the // reading column at exactly the sizes that could have given it more, and // it arrives without warning -- one window drag and the article is // narrower and a conversation has appeared at its shoulder. The file // viewer made this call already, for the same reason. "wiki/document" => rsx! { ContentApp { node: node.clone() } super::comments::CommentSection { node_id: node.id.0.clone(), context_id: node.context_id.as_ref().map(|u| u.0.clone()), } }, "wiki/file" => rsx! { FileApp { node: node.clone() } }, "wiki/home" => rsx! { HomeApp {} }, m if crate::model::is_context_mime(Some(m)) => { rsx! { FolderApp { node: node.clone(), parent_path: path, projector } } } "vote/policy" | "vote/change" => { rsx! { PolicyApp { node: node.clone(), path } } } "vote/position" => { rsx! { PositionApp { node: node.clone(), path } } } // A candidate reads as content (its photo is `data.image`, description is // the content); React hides members, which the port already omits here. "vote/candidate" if projector => rsx! { ContentApp { node: node.clone() } }, "vote/candidate" => { // Under the portrait, for the reason a document's are under its text. rsx! { ContentApp { node: node.clone() } super::comments::CommentSection { node_id: node.id.0.clone(), context_id: node.context_id.as_ref().map(|u| u.0.clone()), } } } "vote/poll" => rsx! { PollApp { node: node.clone(), projector } }, // A canvas is a node type, like a poll or a speaker list, so a context // can hold as many as it likes and each lives wherever it was made. // // On the projector it is the board and nothing else: a hall does not // want the talk about the picture up beside the picture. "canvas/canvas" if projector => { rsx! { super::pixel::PixelApp { node: node.clone(), projector } } } // Off the projector it takes comments like any other thing worth // discussing, in the same supporting pane a document and a candidature // use. A locked canvas still takes them: the rule that enforces the lock // exempts discussion (`migrations/0015`), so a finished board can still // be talked about. "canvas/canvas" => rsx! { super::widgets::SupportingPaneLayout { primary: rsx! { super::pixel::PixelApp { node: node.clone(), projector } }, supporting: rsx! { super::comments::CommentSection { node_id: node.id.0.clone(), context_id: node.context_id.as_ref().map(|u| u.0.clone()), } }, } }, "map/map" => rsx! { super::map::MapApp { node: node.clone() } }, // Leaf text nodes (a plain note, a Q&A question, a single comment) carry // their body in `data.text` / `data.content`; without an arm they fell to // NodeApp, which dropped the text entirely — bad on the projector. "text/plain" | "vote/question" | "vote/comment" => { rsx! { TextNode { node: node.clone() } } } _ => rsx! { NodeApp { node: node.clone(), title: t("mime.unknown") } }, } } /// A minimal renderer for leaf text nodes (plain notes, questions, comments): /// the name as a heading and the body (`data.content` rich text or `data.text`). #[component] fn TextNode(node: NodeWithChildren) -> Element { let mime = node .mime_id .clone() .unwrap_or_else(|| "text/plain".to_string()); let name = node.name.clone(); let data = node.data.as_ref().map(|d| d.0.clone()); let has_rich = super::content::has_rich_content(data.as_ref()); let text = data .as_ref() .and_then(|d| d.get("text")) .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); rsx! { div { class: "card app-card", div { class: "card-header", div { class: "avatar", {icon_el(&mime)} } h3 { class: "title-medium", "{name}" } } div { class: "card-content", if has_rich { super::content::SlateRenderer { data: data.clone() } } else if !text.is_empty() { p { class: "body-large text-preserve-breaks", super::content::AutoLinked { text: text.clone() } } } else { p { class: "body-medium text-muted", "{t(\"common.noContent\")}" } } } } } } /// A localized relative-time sentence ("3 hours ago" / "for 3 timer siden") for /// an ISO timestamp, via the browser's `Intl.RelativeTimeFormat` in the current /// UI language. Mirrors the old wiki's date-fns `formatDistance`; the precise /// datetime is the tooltip (see [`full_datetime`]). Shared by the comment thread /// and the content/file "created" subtitles. pub fn relative_time(iso: &str) -> String { let then = js_sys::Date::new(&wasm_bindgen::JsValue::from_str(iso)).get_time(); if then.is_nan() { return String::new(); } // On the SERVER's clock at both ends: the timestamp is the database's, and a // device whose own clock is minutes out would otherwise greet a comment posted // this second with "11 minutes ago". let secs = ((crate::session::server_now_ms() - then) / 1000.0).max(0.0); // Largest sensible unit; the value is negative because it is in the past // (the sign `Intl.RelativeTimeFormat` expects). let (value, unit) = if secs < 60.0 { (-(secs as i64), "second") } else if secs < 3600.0 { (-((secs / 60.0) as i64), "minute") } else if secs < 86_400.0 { (-((secs / 3600.0) as i64), "hour") } else if secs < 2_592_000.0 { (-((secs / 86_400.0) as i64), "day") } else if secs < 31_536_000.0 { (-((secs / 2_592_000.0) as i64), "month") } else { (-((secs / 31_536_000.0) as i64), "year") }; intl_relative_format(value, unit, crate::i18n::current_locale()) // Fallback to a compact form if Intl.RelativeTimeFormat is unavailable. .unwrap_or_else(|| format!("{}{}", -value, unit.chars().next().unwrap_or('s'))) } /// `new Intl.RelativeTimeFormat(locale, {numeric:'auto'}).format(value, unit)` /// via reflection, so no extra wasm-bindgen binding is needed. Returns `None` /// when the API is unavailable. fn intl_relative_format(value: i64, unit: &str, locale: &str) -> Option { use wasm_bindgen::{JsCast, JsValue}; let intl = js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("Intl")).ok()?; let ctor: js_sys::Function = js_sys::Reflect::get(&intl, &JsValue::from_str("RelativeTimeFormat")) .ok()? .dyn_into() .ok()?; let opts = js_sys::Object::new(); js_sys::Reflect::set( &opts, &JsValue::from_str("numeric"), &JsValue::from_str("auto"), ) .ok()?; let args = js_sys::Array::of2(&JsValue::from_str(locale), &opts); let instance = js_sys::Reflect::construct(&ctor, &args).ok()?; let format_fn: js_sys::Function = js_sys::Reflect::get(&instance, &JsValue::from_str("format")) .ok()? .dyn_into() .ok()?; format_fn .call2( &instance, &JsValue::from_f64(value as f64), &JsValue::from_str(unit), ) .ok()? .as_string() } /// The absolute, localised date/time for an ISO timestamp — used as the tooltip /// behind a compact relative time. pub fn full_datetime(iso: &str) -> String { let d = js_sys::Date::new(&wasm_bindgen::JsValue::from_str(iso)); if d.get_time().is_nan() { return String::new(); } String::from(&d.to_locale_string( crate::i18n::current_locale(), &wasm_bindgen::JsValue::UNDEFINED, )) } /// Mime type to a **Material Icons ligature name**, matching the reference React /// app's `IconId` (core/mime.tsx), which renders `@mui/icons-material` (filled /// Material Icons). Render via `icon_el` (or a `.material-icons` span) so the /// drawer, folder list, headers and app rail show the exact same icons. pub fn mime_icon(mime_id: &str) -> &'static str { match mime_id { // wiki/* "wiki/search" | "app/search" => "search", "wiki/home" | "app/home" => "home", "wiki/group" | "app/member" => "group", "wiki/event" => "event", // A site publishes rather than convenes, so neither the people glyph nor // the calendar one fits: this is the page-layout glyph. "wiki/site" => "web", "wiki/folder" | "app/folder" => "folder", "wiki/document" => "article", "canvas/canvas" | "app/canvas" | "app/pixel" => "grid_on", "wiki/file" => "upload_file", "wiki/user" => "person", "text/plain" => "subject", // vote/* "vote/policy" => "gavel", "vote/position" => "how_to_reg", "vote/candidate" => "face", "vote/question" => "question_mark", "vote/comment" => "add_comment", "vote/reaction" => "add_reaction", "vote/change" => "rate_review", "vote/poll" => "poll", // speak / apps (old wiki: speak/list=InterpreterMode, app/speak=RecordVoiceOver) "speak/list" => "interpreter_mode", "app/feed" => "view_agenda", "app/bin" => "restore_from_trash", "app/speak" => "record_voice_over", "app/editor" => "edit", "app/sort" => "low_priority", "app/vote" => "how_to_vote", "app/screen" => "connected_tv", "app/follow" => "sensors", "app/admin" => "co_present", "application/pdf" => "picture_as_pdf", "app/map" | "map/map" => "map", // Apps the old wiki did not have — closest Material icons. "app/graph" => "hub", "app/program" => "calendar_month", "app/profile" => "account_circle", "app/social" => "public", "app/redirect" => "open_in_new", "app/cow" => "pets", "app/parent" => "link_off", // A speech bubble with a star, not the one with an exclamation mark: // asking what people think should not look like a warning. "wiki/feedback" | "app/feedback" => "reviews", _ => mime_icon_by_prefix(mime_id), } } /// An icon element for a mime type: a `.material-icons` span holding the ligature /// so the Material Icons webfont renders it. Use in place of the old emoji text. pub fn icon_el(mime_id: &str) -> Element { if matches!(mime_id, "wiki/feedback" | "app/feedback") { return feedback_icon_el(); } // Word, Excel and PowerPoint are drawn: the font has no mark for any of // them. Checked before the font lookup so every caller gets it. if let Some(path) = office_glyph(mime_id) { return office_icon_el(path); } let name = mime_icon(mime_id); rsx! { span { class: "material-icons", "{name}" } } } /// The Word, Excel and PowerPoint marks, for the file formats that have one. /// /// The icon font has no glyph for any of these — the closest it offers are /// `description`, `table_chart` and `slideshow`, which are a page of lines, a /// grid and a projector screen. They are three different icons, but none of /// them says which program wrote the file, and a wiki whose folders are mostly /// Word documents wants that at a glance. The old wiki drew them, and this is /// the same three shapes it used, from Material Design Icons (Pictogrammers, /// Apache-2.0): `microsoft-word`, `microsoft-excel`, `microsoft-powerpoint`. /// /// `currentColor`, as they were there: these sit inside tonal avatars whose /// colour is the theme's, and a brand colour dropped into one would fight it. /// /// Returns `None` for everything else, which keeps the font glyph. fn office_glyph(mime_id: &str) -> Option<&'static str> { // Matched by substring in the same order as the font fallback, because the // OOXML content types contain more than one of these words: // `…officedocument.spreadsheetml.sheet` is a spreadsheet AND a document. if mime_id.contains("spreadsheet") { Some(MDI_EXCEL) } else if mime_id.contains("presentation") { Some(MDI_POWERPOINT) } else if mime_id.contains("wordprocessing") || mime_id == "application/msword" { Some(MDI_WORD) } else { None } } const MDI_WORD: &str = "M21.17 3.25Q21.5 3.25 21.76 3.5 22 3.74 22 4.08V19.92Q22 20.26 21.76 20.5 \ 21.5 20.75 21.17 20.75H7.83Q7.5 20.75 7.24 20.5 7 20.26 7 19.92V17H2.83Q2.5 17 2.24 16.76 2 \ 16.5 2 16.17V7.83Q2 7.5 2.24 7.24 2.5 7 2.83 7H7V4.08Q7 3.74 7.24 3.5 7.5 3.25 7.83 \ 3.25M7.03 11.34L8.23 15.28H9.6L10.91 8.72H9.53L8.75 12.6L7.64 8.85H6.5L5.31 12.62L4.53 \ 8.72H3.09L4.4 15.28H5.77M20.75 19.5V17H8.25V19.5M20.75 15.75V12.63H12V15.75M20.75 \ 11.38V8.25H12V11.38M20.75 7V4.5H8.25V7Z"; const MDI_EXCEL: &str = "M21.17 3.25Q21.5 3.25 21.76 3.5 22 3.74 22 4.08V19.92Q22 20.26 21.76 \ 20.5 21.5 20.75 21.17 20.75H7.83Q7.5 20.75 7.24 20.5 7 20.26 7 19.92V17H2.83Q2.5 17 2.24 \ 16.76 2 16.5 2 16.17V7.83Q2 7.5 2.24 7.24 2.5 7 2.83 7H7V4.08Q7 3.74 7.24 3.5 7.5 3.25 7.83 \ 3.25M7 13.06L8.18 15.28H9.97L8 12.06L9.93 8.89H8.22L7.13 10.9L7.09 10.96L7.06 11.03Q6.8 10.5 \ 6.5 9.96 6.25 9.43 5.97 8.89H4.16L6.05 12.08L4 15.28H5.78M13.88 19.5V17H8.25V19.5M13.88 \ 15.75V12.63H12V15.75M13.88 11.38V8.25H12V11.38M13.88 7V4.5H8.25V7M20.75 \ 19.5V17H15.13V19.5M20.75 15.75V12.63H15.13V15.75M20.75 11.38V8.25H15.13V11.38M20.75 \ 7V4.5H15.13V7Z"; const MDI_POWERPOINT: &str = "M13.25 3.25q1.21 0 2.33.31q1.12.32 2.09.89q.97.55 1.77 1.36q.79.8 \ 1.36 1.77q.58.97.89 2.09Q22 10.79 22 12t-.31 2.33q-.31 1.12-.89 2.09q-.57.97-1.36 1.77q-.8.81 \ -1.77 1.36q-.97.58-2.09.89q-1.12.31-2.33.31q-1.07 0-2.1-.25q-1.03-.26-1.95-.74q-.92-.49-1.7 \ -1.18q-.81-.7-1.43-1.58H2.83q-.33 0-.59-.24Q2 16.5 2 16.17V7.83q0-.33.24-.58Q2.5 7 2.83 \ 7h3.24q.62-.88 1.43-1.58q.78-.7 1.7-1.18q.93-.48 1.95-.74q1.03-.25 2.1-.25m.63 1.28v6.84h6.84q \ -.12-1.37-.69-2.56T18.55 6.7q-.91-.91-2.12-1.48q-1.2-.57-2.55-.69M9.5 10.84q0-.57-.2-.97q-.19 \ -.41-.52-.66q-.33-.26-.78-.37q-.45-.12-1-.12H4.37v6.55h1.54V13h1.03q.48 0 .93-.16q.46-.14.82 \ -.41q.36-.26.58-.67q.23-.4.23-.92m3.75 8.66q.98 0 1.89-.24q.9-.26 1.71-.68q.81-.45 1.48-1.08q \ .67-.61 1.17-1.37q.5-.77.83-1.66q.31-.89.39-1.85h-8.08V4.53q-1.45.12-2.73.76T7.67 7h3.5q.33 0 \ .59.25q.24.25.24.58v8.34q0 .33-.24.59q-.26.24-.59.24h-3.5q.53.6 1.17 1.06q.66.44 1.35.78q.72 \ .33 1.49.49q.77.17 1.57.17M6.85 10q.47 0 .76.19q.28.19.28.7q0 .22-.1.36q-.1.14-.26.25q-.16.07 \ -.35.1q-.18.04-.38.04h-.89V10h.94Z"; /// One of those marks, drawn. Sized on the element for the same reason the /// feedback glyph is: an inline svg has no intrinsic size, and before the /// stylesheet arrives a bare one is 300x150. fn office_icon_el(path: &'static str) -> Element { rsx! { span { class: "material-icons", svg { view_box: "0 0 24 24", width: "1em", height: "1em", fill: "currentColor", "aria-hidden": "true", "focusable": "false", path { d: "{path}" } } } } } /// A speech bubble with a five-pointed star, drawn rather than typed. /// /// The font's own bubble-with-a-star (`reviews`) puts a FOUR-pointed sparkle in /// it, and at the 20-24px the rail and cards actually use, the sparkle's concave /// sides disappear and it reads as a plus — all but identical to `add_comment`. /// Nothing else in the font is a bubble with a star, so the shape is drawn here: /// one path, the star knocked out of the bubble by `evenodd`. /// /// Wrapped in a `.material-icons` span so every rule that already sizes, colours /// and aligns an icon keeps applying, with the `1em` box in CSS following the /// font-size those rules set. pub fn feedback_icon_el() -> Element { rsx! { span { class: "material-icons", {feedback_glyph()} } } } /// The bare drawn glyph, for a slot that supplies its own icon span (the dialog /// puts `.m3-dialog-icon` on it, which is what sizes it there). pub fn feedback_glyph() -> Element { rsx! { svg { view_box: "0 0 24 24", // Sized on the ELEMENT, not only in the stylesheet. An inline svg // has no intrinsic size, so until the stylesheet arrives it falls // back to the 300x150 a replaced element gets — and inside the // rail's anchor `currentColor` is the browser's default link blue, // so a refresh flashed an enormous blue bubble across the page // before the CSS landed. The rule in the stylesheet still wins // wherever a container sizes its icons differently. width: "1em", height: "1em", fill: "currentColor", "aria-hidden": "true", "focusable": "false", path { fill_rule: "evenodd", d: "M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z \ M12.00 3.40 L13.48 7.36 L17.71 7.55 L14.40 10.18 L15.53 14.25 \ L12.00 11.92 L8.47 14.25 L9.60 10.18 L6.29 7.55 L10.52 7.36Z", } } } } /// Content for a user's `.avatar`: their profile image (e.g. the linked Bluesky /// picture, from `users.avatarUrl`) if set, otherwise `fallback` (an icon or /// initial). Place the result inside an `.avatar` element. /// The picture to show for one of a node's authors. /// /// A member's own `user` row is only readable by someone who shares a context /// with them. On a page open to VISITORS that row is null, so every author chip /// fell back to a grey silhouette: the welcome page said "Niclas Overby" beside /// an anonymous outline, and so did every public document. /// /// The node's `author_name` and `author_avatar` are computed fields that see /// past that rule, carrying the name and picture only (the email stays behind /// it). They describe ONE person, the node's author, so they stand in only for /// the member who is that person: a co-author whose own row is hidden still gets /// the fallback glyph, which is honest, since we genuinely do not know their /// face. /// /// Matched by id where the ids are readable and by name where they are not. A /// document open to visitors is the second case: its members come back with a /// name and nothing else. pub fn member_avatar( member: &crate::model::MemberFields, owner_id: Option<&crate::model::Uuid>, author_name: Option<&str>, author_avatar: Option<&str>, ) -> String { let own = member .user .as_ref() .map(|u| u.avatar_url.clone()) .unwrap_or_default(); if !own.is_empty() { return own; } let is_author = match (member.node_id.as_ref(), owner_id) { (Some(nid), Some(oid)) => nid.0 == oid.0, _ => { let label = member.label(); !label.is_empty() && author_name == Some(label.as_str()) } }; if is_author { return author_avatar.unwrap_or_default().to_string(); } String::new() } pub fn user_avatar(avatar_url: &str, fallback: Element) -> Element { // NHost gives every user a gravatar URL (with `default=blank`, i.e. usually a // blank image), so only a non-gravatar URL — e.g. the linked Bluesky picture — // counts as a real avatar; otherwise fall back to the icon/initial. if avatar_url.is_empty() || avatar_url.contains("gravatar") { fallback } else { rsx! { img { class: "avatar-img", src: "{avatar_url}", alt: "", loading: "lazy" } } } } /// A presigned URL for a protected file, for the `src`/`href` attributes that /// cannot carry an `Authorization` header: `