use dioxus::prelude::ServerFnError; use dioxus::prelude::*; use polymodel_api::space_polymodel::library; use crate::Route; use crate::examples::{HERO_SAMPLE_HANDLES, first_hero_sample_handle}; use crate::session::SessionIdentity; use crate::thing_card::ThingCard; /// Cookie set client-side when the logged-out hero band is dismissed; read /// server-side so SSR omits the band on return without a hydration flash. const HERO_DISMISS_COOKIE: &str = "pm_hero_dismissed"; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum FeedAlgorithm { Recent, Hot, Following, } impl FeedAlgorithm { fn as_str(self) -> &'static str { match self { Self::Recent => "recent", Self::Hot => "hot", Self::Following => "following", } } fn label(self) -> &'static str { match self { Self::Recent => "Recent", Self::Hot => "Hot", Self::Following => "Following", } } } #[derive(Clone, Debug, PartialEq, Eq)] enum BrowseFeedState { Loading, Error(String), Empty, Populated(Vec), } fn browse_feed_state( pending: bool, result: Option<&Result, String>>, ) -> BrowseFeedState { if pending && result.is_none() { return BrowseFeedState::Loading; } match result { Some(Ok(things)) if things.is_empty() => BrowseFeedState::Empty, Some(Ok(things)) => BrowseFeedState::Populated(things.clone()), Some(Err(error)) => BrowseFeedState::Error(error.clone()), None => BrowseFeedState::Loading, } } /// Whether the logged-out hero band should render. It shows only to a /// definitively anonymous visitor who has not dismissed it; an unresolved /// session (`Unknown`), a server-evaluated dismissal seed of `true`, or a /// live client-read dismissal cookie each suppress it. /// /// `hero_dismissed_seed` is the SSR/first-paint decision (server cookie read, /// cached through hydration), so the band is correct in the initial markup /// with no flash. `client_dismissed` is the post-hydration backstop: a fresh /// read of the live cookie that keeps the band suppressed across client-side /// navigation, where the cached seed would otherwise still read `false`. fn should_show_hero( session: &SessionIdentity, hero_dismissed_seed: Option<&Result>, client_dismissed: bool, ) -> bool { !client_dismissed && matches!(session, SessionIdentity::Anonymous) && !matches!(hero_dismissed_seed, Some(Ok(true))) } /// True when the cookie header carries the named flag (presence only; the /// dismiss control always sets it to `1`). Pure so it stays unit-testable. // Only reached from the wasm client read and the unit tests; on native // non-test builds it is legitimately unreferenced. #[cfg_attr(not(any(target_arch = "wasm32", test)), allow(dead_code))] fn cookie_has_flag(cookie_header: &str, name: &str) -> bool { cookie_header .split(';') .filter_map(|pair| pair.split_once('=')) .any(|(key, _)| key.trim() == name) } /// Read the dismissal cookie from the browser on the client. Returns `false` /// on the server (where SSR's `hero_dismissed_seed` owns the decision) and on /// non-wasm targets. #[cfg(target_arch = "wasm32")] fn client_cookie_dismissed() -> bool { use wasm_bindgen::JsValue; let Some(document) = web_sys::window().and_then(|window| window.document()) else { return false; }; // `Document::cookie` lives behind the `HtmlDocument` web-sys feature, which // isn't enabled; read the property reflectively to avoid widening features. let cookie = js_sys::Reflect::get(document.as_ref(), &JsValue::from_str("cookie")) .ok() .and_then(|value| value.as_string()) .unwrap_or_default(); cookie_has_flag(&cookie, HERO_DISMISS_COOKIE) } #[cfg(not(target_arch = "wasm32"))] fn client_cookie_dismissed() -> bool { false } #[allow(clippy::useless_format)] #[component] pub(crate) fn Browse() -> Element { let selected_algorithm = use_signal(|| FeedAlgorithm::Hot); let mut feed = use_server_future(move || { let algorithm = selected_algorithm.read().as_str().to_owned(); feed_seed(algorithm, 24) })?; let feed_result = feed.read(); let mapped = feed_result.as_ref().map(|r| match r { Ok(things) => Ok(things.clone()), Err(ServerFnError::ServerError { message, .. }) => Err(message.clone()), Err(other) => Err(other.to_string()), }); let feed_state = browse_feed_state(feed.pending(), mapped.as_ref()); // Show the logged-out hero band only to definitively anonymous visitors who // haven't dismissed it. Both inputs resolve server-side during SSR (session // via the App seed, dismissal via the cookie server fn), so the band is // present or absent in the initial markup with no post-hydration flash. let session = use_context::>(); let hero_dismissed = use_server_future(hero_dismissed_seed)?; let client_dismissed = use_hook(client_cookie_dismissed); let show_hero = should_show_hero(&session.read(), hero_dismissed().as_ref(), client_dismissed); rsx! { main { class: "browse-page", if show_hero { HeroBand {} } section { class: "browse-feed", aria_label: "Catalog feed", div { class: "tab-row", role: "tablist", aria_label: "Feeds", FeedTab { algorithm: FeedAlgorithm::Hot, selected_algorithm } FeedTab { algorithm: FeedAlgorithm::Recent, selected_algorithm } FeedTab { algorithm: FeedAlgorithm::Following, selected_algorithm } } section { class: "browse-results-grid", aria_label: "Browse results", match feed_state { BrowseFeedState::Loading => rsx! { for index in 0..6 { div { key: "skeleton-{index}", class: "state-card loading-state", span { class: "status-pill", "Loading" } div { class: "skeleton-line skeleton-wide" } div { class: "skeleton-line" } div { class: "skeleton-box" } } } }, BrowseFeedState::Error(error) => rsx! { div { class: "state-card error-state browse-state-card", p { "{error}" } button { class: "button button-secondary", onclick: move |_| feed.restart(), "Retry" } } }, BrowseFeedState::Empty => rsx! { div { class: "state-card empty-state browse-state-card", span { class: "status-pill status-muted", "Empty" } if *selected_algorithm.read() == FeedAlgorithm::Following && matches!(&*session.read(), SessionIdentity::Anonymous) { h2 { "Sign in to build a following feed" } p { "Follow makers to collect their latest Polymodel projects here." } form { class: "hero-band-signin", action: "/oauth/start", method: "get", input { r#type: "hidden", name: "return_to", value: "/" } button { class: "button button-primary", r#type: "submit", "Sign in" } } } else if *selected_algorithm.read() == FeedAlgorithm::Following { h2 { "No followed projects yet" } p { "Follow makers from their profiles and their projects will appear here." } } else { h2 { "No projects yet" } p { "Polymodel reached the appview feed, but this local index has no projects for the selected feed yet." } } } }, BrowseFeedState::Populated(things) => rsx! { for thing in things { BrowseThingCard { key: "{thing.uri}", thing } } }, } } } } } } #[component] fn BrowseThingCard(thing: library::ThingViewBasic) -> Element { rsx! { ThingCard { thing } } } #[component] fn FeedTab(algorithm: FeedAlgorithm, selected_algorithm: Signal) -> Element { let is_active = *selected_algorithm.read() == algorithm; let class = if is_active { "tab is-active" } else { "tab" }; rsx! { button { class, role: "tab", aria_selected: "{is_active}", onclick: move |_| selected_algorithm.set(algorithm), "{algorithm.label()}" } } } #[component] fn HeroBand() -> Element { // Rendered as raw HTML so the dismiss control carries a literal `onclick` // that fires before (or without) wasm hydration. A Dioxus event handler // would require the wasm bundle to be live, defeating the no-flash goal. // // Hiding via `style.display` rather than removing the node keeps Dioxus // hydration reconciliation intact: `style` is an attribute Dioxus never // rendered here, so re-renders of this same node (feed loads, tab // switches) won't clobber the hidden state. A *remount* (client-side // navigation back to `/` builds a fresh node) does drop the inline style, // so the cookie this sets is also read client-side in `should_show_hero` // to keep a dismissed band suppressed across navigation. let dismiss_html = format!( "", cookie = HERO_DISMISS_COOKIE ); let sample_handles = serde_json::to_string(HERO_SAMPLE_HANDLES) .expect("hero sample handles should serialize as JSON"); let rotation_js = format!( r#" (() => {{ const samples = {sample_handles}; const input = document.currentScript.closest('.hero-band')?.querySelector('.hero-band-input'); if (!input || samples.length < 2) return; let index = Math.floor(Math.random() * samples.length); input.placeholder = samples[index]; window.setInterval(() => {{ index = (index + 1) % samples.length; input.placeholder = samples[index]; }}, 30000); }})(); "#, ); rsx! { section { class: "hero-band blueprint-panel", aria_label: "About Polymodel", div { class: "hero-band-body", p { class: "hero-band-pitch", "Share your creations. Make things, remix them. Without getting locked in to a closed platform." } div { class: "hero-band-actions", form { class: "hero-band-signin", action: "/oauth/start", method: "get", input { r#type: "hidden", name: "return_to", value: "/" } input { class: "hero-band-input", r#type: "text", name: "identifier", placeholder: "{first_hero_sample_handle()}", aria_label: "Bluesky handle", } button { class: "button button-secondary", r#type: "submit", "Sign in" } } Link { class: "hero-band-about", to: Route::About {}, "what's this?" } } } div { class: "hero-band-dismiss", dangerous_inner_html: dismiss_html } script { dangerous_inner_html: rotation_js } } } } #[server] async fn hero_dismissed_seed() -> Result { use axum_extra::extract::cookie::CookieJar; use dioxus::prelude::dioxus_fullstack::FullstackContext; let context = FullstackContext::current() .ok_or_else(|| ServerFnError::new("missing Dioxus fullstack request context"))?; let parts = context.parts_mut().clone(); let jar = CookieJar::from_headers(&parts.headers); Ok(jar.get(HERO_DISMISS_COOKIE).is_some()) } #[server] async fn feed_seed( algorithm: String, limit: i64, ) -> Result, ServerFnError> { let feed = crate::appview::ssr::get_feed_from_fullstack_context(&algorithm, limit).await?; Ok(feed.items.into_iter().map(|item| item.thing).collect()) } #[cfg(test)] mod tests { use super::*; use crate::thing_card::thing_card_fixtures; #[test] fn browse_state_distinguishes_loading_empty_error_and_populated() { assert_eq!(browse_feed_state(true, None), BrowseFeedState::Loading); assert_eq!( browse_feed_state(false, Some(&Ok(vec![]))), BrowseFeedState::Empty ); assert_eq!( browse_feed_state(false, Some(&Err("boom".to_string()))), BrowseFeedState::Error("boom".to_string()) ); match browse_feed_state(false, Some(&Ok(thing_card_fixtures()))) { BrowseFeedState::Populated(things) => assert_eq!(things.len(), 3), state => panic!("expected populated state, got {state:?}"), } } #[test] fn hero_shows_only_for_anonymous_and_undismissed() { use crate::session::AuthenticatedIdentity; use jacquard_common::types::string::{Did, Handle}; assert!(should_show_hero(&SessionIdentity::Anonymous, None, false)); assert!(should_show_hero( &SessionIdentity::Anonymous, Some(&Ok(false)), false )); // Either dismissal signal suppresses the band: the SSR seed... assert!(!should_show_hero( &SessionIdentity::Anonymous, Some(&Ok(true)), false )); // ...or the live client-read cookie (e.g. after client-side navigation). assert!(!should_show_hero( &SessionIdentity::Anonymous, Some(&Ok(false)), true )); // An unresolved session must not flash the band before identity lands. assert!(!should_show_hero(&SessionIdentity::Unknown, None, false)); let authed = SessionIdentity::Authenticated(AuthenticatedIdentity { avatar: None, did: Did::new_owned("did:plc:tester").unwrap(), handle: Handle::new_owned("tester.test").unwrap(), display_name: None, }); assert!(!should_show_hero(&authed, None, false)); } #[test] fn cookie_flag_detects_presence_only_among_other_cookies() { assert!(cookie_has_flag("pm_hero_dismissed=1", HERO_DISMISS_COOKIE)); assert!(cookie_has_flag( "session=abc; pm_hero_dismissed=1; theme=dark", HERO_DISMISS_COOKIE )); assert!(!cookie_has_flag( "session=abc; theme=dark", HERO_DISMISS_COOKIE )); assert!(!cookie_has_flag("", HERO_DISMISS_COOKIE)); // Must not match a cookie whose name merely contains the flag. assert!(!cookie_has_flag( "not_pm_hero_dismissed=1", HERO_DISMISS_COOKIE )); } }