Something went wrong. Try again.
atproto Thingiverse but good
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062use crate::PolymodelClient;use crate::Route;use crate::session::SessionIdentity;use crate::thing_card::ThingCard;use dioxus::prelude::*;use jacquard_common::deps::smol_str::SmolStr;use jacquard_common::types::string::{Did, Handle, UriValue};use jacquard_common::xrpc::XrpcClient;use polymodel_api::space_polymodel::actor::ProfileViewUnion;use polymodel_api::space_polymodel::graph::{ create_follow::CreateFollow, delete_follow::DeleteFollow,};use polymodel_api::space_polymodel::library;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]enum ProfileTab { Things, Lists, Makes, About,}
impl ProfileTab { fn label(self) -> &'static str { match self { Self::Things => "Things", Self::Lists => "Lists", Self::Makes => "Makes", Self::About => "About", } }}
#[derive(Clone, Debug, PartialEq, Eq)]enum ProfileState { Loading, InvalidActor(String), Error(String), Populated(Box<ProfileData>),}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]struct ProfileData { header: ProfileHeader, things: Vec<library::ThingViewBasic>, lists: Vec<polymodel_api::space_polymodel::actor::ProfileListView>,}
#[derive(Clone, Debug, PartialEq, Eq)]enum ProfileFeedState { Loading, Error(String), Empty, Populated(Vec<library::ThingViewBasic>),}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]struct ProfileHeader { avatar: Option<UriValue>, default_license: Option<SmolStr>, description: Option<SmolStr>, did: Did, display_name: Option<SmolStr>, handle: Handle, links: Option<Vec<UriValue>>, pronouns: Option<SmolStr>, follower_count: Option<i64>, following_count: Option<i64>, following: Option<jacquard_common::types::string::AtUri>, thing_count: Option<i64>, total_likes: Option<i64>, is_bsky_fallback: bool,}
fn profile_state(pending: bool, result: Option<&Result<ProfileData, String>>) -> ProfileState { if pending && result.is_none() { return ProfileState::Loading; }
match result { Some(Ok(data)) => ProfileState::Populated(Box::new(data.clone())), Some(Err(error)) if error.starts_with("Invalid actor:") => { ProfileState::InvalidActor(error.clone()) } Some(Err(error)) => ProfileState::Error(error.clone()), None => ProfileState::Loading, }}
fn profile_feed_state( pending: bool, result: Option<&Result<&[library::ThingViewBasic], String>>,) -> ProfileFeedState { if pending && result.is_none() { return ProfileFeedState::Loading; }
match result { Some(Ok(things)) => { if things.is_empty() { ProfileFeedState::Empty } else { ProfileFeedState::Populated(things.to_vec()) } } Some(Err(error)) => ProfileFeedState::Error(error.clone()), None => ProfileFeedState::Loading, }}
/// Build a unified display header from either union arm. The polymodel arm's/// bare `handle` string is branded into a `Handle` here (single conversion site);/// the bsky arm already carries a branded `Handle`. `did` is `Did` on both arms/// and is cloned directly.#[cfg_attr(not(any(feature = "server", test)), allow(dead_code))]fn profile_header(value: &ProfileViewUnion) -> ProfileHeader { match value { ProfileViewUnion::ProfileView(view) => ProfileHeader { avatar: view.avatar.clone(), default_license: optional_smolstr(&view.default_license), description: optional_smolstr(&view.description), did: view.did.clone(), display_name: optional_smolstr(&view.display_name), handle: Handle::new_owned(view.handle.as_str()) .expect("polymodel profile handle is server-validated"), links: view.links.clone(), pronouns: optional_smolstr(&view.pronouns), follower_count: view.follower_count, following_count: view.following_count, following: view .viewer .as_ref() .and_then(|viewer| viewer.following.clone()), thing_count: view.thing_count, total_likes: view.total_likes, is_bsky_fallback: false, }, ProfileViewUnion::ProfileViewDetailed(view) => ProfileHeader { avatar: view.avatar.clone(), default_license: None, description: optional_smolstr(&view.description), did: view.did.clone(), display_name: optional_smolstr(&view.display_name), handle: view.handle.clone(), links: None, pronouns: optional_smolstr(&view.pronouns), follower_count: None, following_count: None, following: None, thing_count: None, total_likes: None, is_bsky_fallback: true, }, }}
#[cfg_attr(not(any(feature = "server", test)), allow(dead_code))]fn optional_smolstr(value: &Option<impl AsRef<str>>) -> Option<SmolStr> { value .as_ref() .map(|v| v.as_ref()) .map(str::trim) .filter(|v| !v.is_empty()) .map(SmolStr::from)}
#[cfg_attr(not(any(feature = "server", test)), allow(dead_code))]fn profile_lists( value: &ProfileViewUnion,) -> Vec<polymodel_api::space_polymodel::actor::ProfileListView> { match value { ProfileViewUnion::ProfileView(view) => view.lists.clone().unwrap_or_default(), ProfileViewUnion::ProfileViewDetailed(_) => Vec::new(), }}
fn avatar_initial(header: &ProfileHeader) -> char { let name = header .display_name .as_ref() .map(SmolStr::as_str) .unwrap_or_else(|| header.handle.as_ref()); name.chars().next().unwrap_or('?').to_ascii_uppercase()}
fn header_display_label(header: &ProfileHeader) -> String { header .display_name .as_ref() .map(SmolStr::as_str) .filter(|s| !s.trim().is_empty()) .map(ToString::to_string) .unwrap_or_else(|| header.handle.as_ref().to_string())}
fn count_label(value: Option<i64>, noun: &str) -> Option<String> { value.map(|n| { let suffix = if n == 1 { "" } else { "s" }; format!("{n} {noun}{suffix}") })}
#[server]async fn profile_seed(actor: String) -> Result<ProfileData, ServerFnError> { use jacquard_common::types::ident::AtIdentifier;
let actor_id = AtIdentifier::new_owned(&actor).map_err(|e| { ServerFnError::new(format!("Invalid actor: unsupported actor identifier: {e}")) })?;
let union = crate::appview::ssr::get_profile_union_from_fullstack_context(&actor_id).await?; let header = profile_header(&union);
let feed = crate::appview::ssr::get_author_things_from_fullstack_context(&actor_id, 24, None).await?; let things = feed.items.into_iter().map(|item| item.thing).collect(); let lists = profile_lists(&union);
Ok(ProfileData { header, things, lists, })}
fn adjust_count(current: i64, was_active: bool, is_active: bool) -> i64 { match (was_active, is_active) { (false, true) => current + 1, (true, false) => current.saturating_sub(1), _ => current, }}
#[allow(clippy::useless_format)]#[component]pub(crate) fn Profile(actor: String) -> Element { rsx! { ProfileBody { actor } }}
#[allow(clippy::useless_format)]#[component]pub(crate) fn ProfileSelf() -> Element { let session = use_context::<Signal<SessionIdentity>>(); // Sign-in returns the user to the page they started on, not a hardcoded path. let return_to = use_route::<Route>().to_string();
// Reuse the shared session resolution from the App/shell: `Unknown` is the // brief in-flight state (the App resolves it to a terminal Authenticated/ // Anonymous even on failure), so /profile needs no per-page session re-check. match session.read().clone() { SessionIdentity::Unknown => rsx! { main { class: "profile-page product-shell", section { class: "profile-shell blueprint-panel", h1 { class: "sr-only", "Loading profile" } div { class: "state-card loading-state", span { class: "status-pill", "Loading" } div { class: "skeleton-line skeleton-wide" } div { class: "skeleton-line" } div { class: "skeleton-box" } } } } }, SessionIdentity::Authenticated(auth) => { rsx! { ProfileBody { actor: auth.handle.as_str().to_string() } } } SessionIdentity::Anonymous => rsx! { main { class: "profile-page product-shell", section { class: "profile-sign-in blueprint-panel", span { class: "status-pill", "Signed out" } h1 { "Your profile" } p { class: "product-lede", "Sign in to view and share your Polymodel profile." } form { class: "session-control", action: "/oauth/start", method: "get", input { r#type: "hidden", name: "return_to", value: "{return_to}" }, input { r#type: "text", name: "identifier", placeholder: "handle.test", aria_label: "ATProto handle", } button { class: "button button-primary", r#type: "submit", "Sign in" } } } } }, }}
#[allow(clippy::useless_format)]#[component]fn ProfileBody(actor: String) -> Element { let session = use_context::<Signal<SessionIdentity>>(); let active_tab = use_signal(|| ProfileTab::Things);
let mut profile = use_server_future(move || { let actor = actor.clone(); profile_seed(actor) })?;
let profile_result = profile.read(); let mapped = profile_result.as_ref().map(|r| match r { Ok(data) => Ok(data.clone()), Err(ServerFnError::ServerError { message, .. }) => Err(message.clone()), Err(other) => Err(other.to_string()), }); let state = profile_state(profile.pending(), mapped.as_ref());
rsx! { main { class: "profile-page product-shell", match state { ProfileState::Loading => rsx! { ProfileLoading {} }, ProfileState::InvalidActor(message) => rsx! { section { class: "profile-shell blueprint-panel", aria_label: "Profile error", div { class: "state-card error-state", span { class: "status-pill status-error", "Error" } h1 { "Profile unavailable" } p { "{message}" } Link { class: "button button-secondary", to: Route::Browse {}, "Back to browse" } } } }, ProfileState::Error(message) => rsx! { section { class: "profile-shell blueprint-panel", aria_label: "Profile error", div { class: "state-card error-state", span { class: "status-pill status-error", "Error" } h1 { "Profile unavailable" } p { "{message}" } button { class: "button button-secondary", onclick: move |_| profile.restart(), "Retry" } Link { class: "button button-secondary", to: Route::Browse {}, "Back to browse" } } } }, ProfileState::Populated(data) => { let data = *data; let session_identity = session.read().clone(); let (is_own, is_anonymous) = match session_identity { SessionIdentity::Authenticated(identity) => { (identity.did == data.header.did, false) } SessionIdentity::Anonymous | SessionIdentity::Unknown => (false, true), }; rsx! { ProfilePopulated { data, is_own, is_anonymous, active_tab } } } } } }}
#[allow(clippy::useless_format)]#[component]fn ProfileLoading() -> Element { rsx! { section { class: "profile-shell blueprint-panel", aria_label: "Profile loading", h1 { class: "sr-only", "Loading profile" } div { class: "state-card loading-state", span { class: "status-pill", "Loading" } div { class: "skeleton-line skeleton-wide" } div { class: "skeleton-line" } div { class: "skeleton-box" } } } }}
/// A bio rendered as alternating plain-text and URL segments. Newlines stay/// inside the text segments; the bio container uses `white-space: pre-wrap` so/// they render as authored.enum BioSegment { Text(String), Link(String),}
/// Split bio text into text/link segments, linkifying `http(s)://` and `www.`/// tokens while leaving every other character (including newlines) untouched.fn linkify_bio(text: &str) -> Vec<BioSegment> { let mut segments = Vec::new(); let mut rest = text; while let Some(start) = next_url_start(rest) { let (before, from) = rest.split_at(start); if !before.is_empty() { segments.push(BioSegment::Text(before.to_string())); } let end = from.find(char::is_whitespace).unwrap_or(from.len()); let (token, after) = from.split_at(end); // Keep trailing sentence punctuation out of the linked span. let trimmed_len = token.trim_end_matches([',', '.', ')', ';', ':', '!']).len(); let (url, tail) = token.split_at(trimmed_len); segments.push(BioSegment::Link(url.to_string())); if !tail.is_empty() { segments.push(BioSegment::Text(tail.to_string())); } rest = after; } if !rest.is_empty() { segments.push(BioSegment::Text(rest.to_string())); } segments}
fn next_url_start(s: &str) -> Option<usize> { [s.find("https://"), s.find("http://"), s.find("www.")] .into_iter() .flatten() .min()}
/// Build an href for a linkified bio token, adding a scheme for `www.` tokens.fn bio_link_href(url: &str) -> String { if url.starts_with("http://") || url.starts_with("https://") { url.to_string() } else { format!("https://{url}") }}
#[allow(clippy::useless_format)]#[component]fn ProfilePopulated( data: ProfileData, is_own: bool, is_anonymous: bool, active_tab: Signal<ProfileTab>,) -> Element { let client = use_context::<PolymodelClient>(); let header = data.header; let return_to = use_route::<Route>().to_string(); let things = data.things; let display_label = header_display_label(&header); let handle_str = header.handle.as_ref().to_string(); let avatar_url = header.avatar.as_ref().map(|u| u.as_ref().to_string()); let initial = avatar_initial(&header); let description = header .description .as_ref() .map(SmolStr::as_str) .map(ToString::to_string); let pronouns = header .pronouns .as_ref() .map(SmolStr::as_str) .map(ToString::to_string); let thing_count_label = count_label(header.thing_count, "thing"); let total_likes_label = count_label(header.total_likes, "like"); let mut follower_count = use_signal(|| header.follower_count.unwrap_or(0)); let follower_count_label = if header.follower_count.is_some() { count_label(Some(*follower_count.read()), "follower") } else { None }; let following_count_label = count_label(header.following_count, "following"); let mut following = use_signal(|| header.following.clone()); let mut follow_pending = use_signal(|| false); let mut follow_error = use_signal(|| None::<String>); let follow_label = if following.read().is_some() { "Following" } else { "Follow" }; let is_bsky_fallback = header.is_bsky_fallback; let links_rail = header.links.clone(); let bio_segments = description.as_deref().map(linkify_bio); let tab = *active_tab.read(); let follow_subject = header.did.clone();
rsx! { div { class: "profile-layout", aside { class: "profile-rail blueprint-panel", aria_label: "Maker identity", if let Some(url) = &avatar_url { img { class: "profile-avatar", src: "{url}", alt: "{display_label} avatar" } } else { div { class: "profile-avatar profile-avatar-fallback", aria_label: "Avatar placeholder", span { "{initial}" } } }
div { class: "profile-rail-identity", h1 { "{display_label}" } p { class: "profile-handle", "@{handle_str}" if let Some(pronouns) = &pronouns { span { class: "profile-pronouns", " · {pronouns}" } } } if is_bsky_fallback { span { class: "profile-bsky-note", "Bluesky profile" } } }
if thing_count_label.is_some() || total_likes_label.is_some() || header.follower_count.is_some() || following_count_label.is_some() { div { class: "profile-stats", aria_label: "Maker stats", if let Some(label) = &thing_count_label { span { "{label}" } } if let Some(label) = &total_likes_label { span { "{label}" } } if let Some(label) = &follower_count_label { span { "{label}" } } if let Some(label) = &following_count_label { span { "{label}" } } } }
div { class: "profile-rail-action", if is_own { button { class: "button button-secondary", disabled: true, "Edit profile" } } else if is_anonymous { form { class: "profile-follow-form", action: "/oauth/start", method: "get", input { r#type: "hidden", name: "return_to", value: "{return_to}" } button { class: "button button-primary", r#type: "submit", "Sign in to follow" } } } else { button { class: if following.read().is_some() { "button button-secondary" } else { "button button-primary" }, disabled: *follow_pending.read(), aria_pressed: "{following.read().is_some()}", onclick: move |_| { let client = client.clone(); let subject = follow_subject.clone(); let current = following.read().clone(); follow_pending.set(true); follow_error.set(None); spawn(async move { let result = if let Some(uri) = current { match client.send(DeleteFollow { uri: Some(uri), subject: None, extra_data: None }).await { Ok(response) => response.into_output().map(|_| None).map_err(|error| error.to_string()), Err(error) => Err(error.to_string()), } } else { match client.send(CreateFollow { subject, extra_data: None }).await { Ok(response) => response.into_output().map(|output| Some(output.uri)).map_err(|error| error.to_string()), Err(error) => Err(error.to_string()), } }; match result { Ok(next) => { let was_following = following.read().is_some(); following.set(next); let current_count = *follower_count.read(); let is_following = following.read().is_some(); follower_count.set(adjust_count(current_count, was_following, is_following)); } Err(error) => follow_error.set(Some(format!("Follow failed: {error}"))), } follow_pending.set(false); }); }, "{follow_label}" } if let Some(error) = follow_error.read().as_ref() { p { class: "profile-action-error", role: "status", "{error}" } } } }
if let Some(segments) = &bio_segments { p { class: "profile-bio", for segment in segments { match segment { BioSegment::Text(text) => rsx! { "{text}" }, BioSegment::Link(url) => rsx! { a { href: "{bio_link_href(url)}", rel: "nofollow noopener", "{url}" } }, } } } }
if let Some(links) = links_rail.as_ref().filter(|links| !links.is_empty()) { ul { class: "profile-rail-links", aria_label: "Maker links", for (index, link) in links.iter().enumerate() { li { key: "link-{index}", a { href: "{link.as_ref()}", rel: "nofollow noopener", "{link.as_ref()}" } } } } } }
div { class: "profile-content", nav { class: "tab-row profile-tabs", role: "tablist", aria_label: "Profile sections", ProfileTabButton { tab: ProfileTab::Things, active_tab } ProfileTabButton { tab: ProfileTab::Lists, active_tab } ProfileTabButton { tab: ProfileTab::Makes, active_tab } ProfileTabButton { tab: ProfileTab::About, active_tab } }
div { class: "profile-tab-content", match tab { ProfileTab::Things => rsx! { ProfileThingsGrid { things } }, ProfileTab::Lists => rsx! { ProfileLists { lists: data.lists.clone() } }, ProfileTab::Makes => rsx! { div { class: "profile-empty-panel", span { class: "status-pill status-muted", "Not modelled yet" } h2 { "No make activity model yet" } p { "Polymodel does not yet have a dedicated make, remix, or activity record to show here without faking it." } } }, ProfileTab::About => rsx! { ProfileAbout { header } }, } } } } }}
#[allow(clippy::useless_format)]#[component]fn ProfileTabButton(tab: ProfileTab, active_tab: Signal<ProfileTab>) -> Element { let is_active = *active_tab.read() == tab; let class = if is_active { "tab is-active" } else { "tab" };
rsx! { button { class, role: "tab", aria_selected: "{is_active}", onclick: move |_| active_tab.set(tab), "{tab.label()}" } }}
#[allow(clippy::useless_format)]#[component]fn ProfileThingsGrid(things: Vec<library::ThingViewBasic>) -> Element { let things_result: Result<&[library::ThingViewBasic], String> = Ok(things.as_slice()); let feed = profile_feed_state(false, Some(&things_result));
rsx! { section { class: "profile-things-grid browse-results-grid", aria_label: "Profile things", match feed { ProfileFeedState::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" } } } }, ProfileFeedState::Error(error) => rsx! { div { class: "state-card error-state", p { "{error}" } } }, ProfileFeedState::Empty => rsx! { div { class: "state-card empty-state", span { class: "status-pill status-muted", "Empty" } h2 { "No things yet" } p { "This maker has not published any Polymodel things yet." } } }, ProfileFeedState::Populated(items) => rsx! { for thing in items { ProfileThingCard { key: "{thing.uri}", thing } } }, } } }}
#[allow(clippy::useless_format)]#[component]fn ProfileThingCard(thing: library::ThingViewBasic) -> Element { rsx! { ThingCard { thing } }}
fn list_item_count_label(count: i64) -> String { let suffix = if count == 1 { "" } else { "s" }; format!("{count} item{suffix}")}
#[allow(clippy::useless_format)]#[component]fn ProfileLists(lists: Vec<polymodel_api::space_polymodel::actor::ProfileListView>) -> Element { rsx! { section { class: "profile-lists", aria_label: "Profile lists", if lists.is_empty() { div { class: "profile-empty-panel", span { class: "status-pill status-muted", "Empty" } h2 { "No lists yet" } p { "Lists are real Polymodel records; this maker just has not published one yet." } } } else { for list in lists { article { key: "{list.uri}", class: "profile-list-card blueprint-panel", div { class: "profile-list-card-header", h2 { "{list.name}" } span { class: "status-pill status-muted", "{list_item_count_label(list.item_count)}" } } if let Some(description) = &list.description { p { "{description}" } } dl { class: "profile-list-meta", if let Some(purpose) = &list.purpose { div { dt { "Purpose" } dd { "{purpose}" } } } div { dt { "List URI" } dd { "{list.uri.as_ref()}" } } } } } } } }}
#[allow(clippy::useless_format)]#[component]fn ProfileAbout(header: ProfileHeader) -> Element { let description = header .description .as_ref() .map(SmolStr::as_str) .map(ToString::to_string); let pronouns = header .pronouns .as_ref() .map(SmolStr::as_str) .map(ToString::to_string); let links = header.links.clone(); let default_license = header .default_license .as_ref() .map(SmolStr::as_str) .map(ToString::to_string); let thing_count_label = count_label(header.thing_count, "thing"); let total_likes_label = count_label(header.total_likes, "like"); let is_bsky_fallback = header.is_bsky_fallback;
rsx! { section { class: "profile-about blueprint-panel", if let Some(description) = &description { p { class: "product-lede", "{description}" } } else { p { class: "product-lede profile-muted", "No description is published yet." } } dl { class: "profile-about-meta", if let Some(pronouns) = &pronouns { div { dt { "Pronouns" } dd { "{pronouns}" } } } if let Some(license) = &default_license { div { dt { "Default license" } dd { "{license}" } } } if let Some(label) = &thing_count_label { div { dt { "Things published" } dd { "{label}" } } } if let Some(label) = &total_likes_label { div { dt { "Total likes" } dd { "{label}" } } } } if let Some(links) = &links { if !links.is_empty() { div { class: "profile-about-links", h2 { "Links" } ul { for link in links { li { a { href: "{link.as_ref()}", "{link.as_ref()}" } } } } } } } if is_bsky_fallback { p { class: "profile-about-fallback-note", "This actor does not have a Polymodel profile yet. Showing available Bluesky identity." } } } }}
#[cfg(test)]mod tests { use super::*; use crate::thing_card::thing_card_fixtures; use jacquard_common::types::string::{Did, Handle}; use polymodel_api::app_bsky::actor::ProfileViewDetailed; use polymodel_api::space_polymodel::actor::get_profile::GetProfileOutput; use polymodel_api::space_polymodel::actor::{ProfileListView, ProfileView}; use polymodel_api::space_polymodel::library::get_author_things::GetAuthorThingsOutput;
fn polymodel_view() -> ProfileView { ProfileView { avatar: Some(UriValue::new_owned("https://cdn.example/alice.jpg").unwrap()), default_license: Some("CC-BY-4.0".into()), indexed_at: None, description: Some("Maker of enclosures".into()), did: Did::new_owned("did:plc:alice").unwrap(), display_name: Some("Alice Maker".into()), handle: "alice.test".into(), pronouns: Some("she/her".into()), thing_count: Some(5), extra_data: None, links: Some(vec![UriValue::new_owned("https://alice.example").unwrap()]), printers: None, record: jacquard::Data::Null, total_likes: Some(42), follower_count: None, following_count: None, lists: None, viewer: None, } }
fn bsky_view() -> ProfileViewDetailed { ProfileViewDetailed { associated: None, avatar: None, banner: None, created_at: None, debug: None, description: Some("Bluesky-only user".into()), did: Did::new_owned("did:plc:bob").unwrap(), display_name: Some("Bob".into()), followers_count: Some(100), follows_count: Some(50), handle: Handle::new_owned("bob.test").unwrap(), indexed_at: None, joined_via_starter_pack: None, labels: None, pinned_post: None, posts_count: Some(200), pronouns: None, status: None, verification: None, viewer: None, website: None, extra_data: None, } }
fn profile_list() -> ProfileListView { ProfileListView { cid: jacquard_common::types::string::Cid::new_owned(b"bafyreilistcid").unwrap(), created_at: jacquard_common::types::datetime::Datetime::raw_str( "2024-01-01T00:00:00.000Z", ), description: Some("Shop fixtures".into()), item_count: 2, name: "Fixture jigs".into(), purpose: Some("curation".into()), uri: jacquard_common::types::string::AtUri::new_owned( "at://did:plc:alice/space.polymodel.graph.list/fixtures", ) .unwrap(), extra_data: None, } }
#[test] fn profile_state_distinguishes_loading_invalid_error_and_populated() { assert_eq!(profile_state(true, None), ProfileState::Loading); assert_eq!(profile_state(false, None), ProfileState::Loading);
let invalid = profile_state(false, Some(&Err("Invalid actor: bad".to_string()))); assert!(matches!(invalid, ProfileState::InvalidActor(_)));
let error = profile_state(false, Some(&Err("boom".to_string()))); assert!(matches!(error, ProfileState::Error(_)));
let data = ProfileData { header: profile_header(&ProfileViewUnion::ProfileView(Box::new(polymodel_view()))), things: vec![], lists: vec![], }; let populated = profile_state(false, Some(&Ok(data))); assert!(matches!(populated, ProfileState::Populated(_))); }
#[test] fn profile_feed_state_classifies_loading_empty_error_populated() { assert_eq!(profile_feed_state(true, None), ProfileFeedState::Loading); assert_eq!(profile_feed_state(false, None), ProfileFeedState::Loading);
let empty: Result<&[library::ThingViewBasic], String> = Ok(&[]); assert_eq!( profile_feed_state(false, Some(&empty)), ProfileFeedState::Empty );
let err: Result<&[library::ThingViewBasic], String> = Err("feed down".to_string()); assert_eq!( profile_feed_state(false, Some(&err)), ProfileFeedState::Error("feed down".to_string()) );
let fixtures = thing_card_fixtures(); let populated = profile_feed_state(false, Some(&Ok(&fixtures[..]))); match populated { ProfileFeedState::Populated(items) => assert_eq!(items.len(), fixtures.len()), other => panic!("expected populated, got {other:?}"), } }
#[test] fn header_extracts_polymodel_fields() { let header = profile_header(&ProfileViewUnion::ProfileView(Box::new(polymodel_view()))); assert_eq!(header.did.as_ref(), "did:plc:alice"); assert_eq!(header.handle.as_ref(), "alice.test"); assert_eq!( header.display_name.as_ref().map(SmolStr::as_str), Some("Alice Maker") ); assert_eq!(header.thing_count, Some(5)); assert_eq!(header.total_likes, Some(42)); assert_eq!( header.default_license.as_ref().map(SmolStr::as_str), Some("CC-BY-4.0") ); assert!(!header.is_bsky_fallback); assert!(header.avatar.is_some()); assert!(header.links.is_some()); }
#[test] fn header_extracts_bsky_fallback_fields() { let header = profile_header(&ProfileViewUnion::ProfileViewDetailed( Box::new(bsky_view()), )); assert_eq!(header.did.as_ref(), "did:plc:bob"); assert_eq!(header.handle.as_ref(), "bob.test"); assert_eq!( header.display_name.as_ref().map(SmolStr::as_str), Some("Bob") ); assert!(header.is_bsky_fallback); // Polymodel-specific fields degrade to None, not fake values. assert_eq!(header.thing_count, None); assert_eq!(header.total_likes, None); assert_eq!(header.default_license, None); assert_eq!(header.links, None); }
#[test] fn profile_lists_extracts_polymodel_lists_only() { let mut view = polymodel_view(); view.lists = Some(vec![profile_list()]); let lists = profile_lists(&ProfileViewUnion::ProfileView(Box::new(view))); assert_eq!(lists.len(), 1); assert_eq!(lists[0].name.as_str(), "Fixture jigs");
let fallback = profile_lists(&ProfileViewUnion::ProfileViewDetailed( Box::new(bsky_view()), )); assert!(fallback.is_empty()); }
#[test] fn list_item_count_label_pluralizes() { assert_eq!(list_item_count_label(1), "1 item"); assert_eq!(list_item_count_label(2), "2 items"); }
#[test] fn is_own_compares_did_without_stringify() { let header = profile_header(&ProfileViewUnion::ProfileView(Box::new(polymodel_view()))); let own_did = Did::new_owned("did:plc:alice").unwrap(); let other_did = Did::new_owned("did:plc:eve").unwrap(); assert_eq!(header.did, own_did); assert_ne!(header.did, other_did); }
#[test] fn optional_smolstr_trims_and_filters_empty() { let trimmed: Option<&str> = Some(" hi "); assert_eq!(optional_smolstr(&trimmed), Some("hi".into())); let blank: Option<&str> = Some(" "); assert_eq!(optional_smolstr(&blank), None); let none: Option<&str> = None; assert_eq!(optional_smolstr(&none), None); }
#[test] fn get_profile_output_decodes_polymodel_arm() { let json = r#"{ "$type":"space.polymodel.actor.defs#profileView", "did":"did:plc:alice", "handle":"alice.test", "record":null, "displayName":"Alice Maker", "description":"Maker of things", "pronouns":"she/her", "thingCount":5, "totalLikes":42, "defaultLicense":"CC-BY-4.0" }"#; let output: GetProfileOutput = serde_json::from_str(json).unwrap(); match output.value { ProfileViewUnion::ProfileView(view) => { assert_eq!(view.did.as_ref(), "did:plc:alice"); assert_eq!(view.handle.as_str(), "alice.test"); assert_eq!( view.display_name.as_ref().map(|s| s.as_str()), Some("Alice Maker") ); assert_eq!(view.thing_count, Some(5)); assert_eq!(view.total_likes, Some(42)); } _ => panic!("expected ProfileView arm"), } }
#[test] fn get_profile_output_decodes_bsky_fallback_arm() { let json = r#"{ "$type":"app.bsky.actor.defs#profileViewDetailed", "did":"did:plc:bob", "handle":"bob.test", "displayName":"Bob", "description":"Bluesky user", "followersCount":100 }"#; let output: GetProfileOutput = serde_json::from_str(json).unwrap(); match output.value { ProfileViewUnion::ProfileViewDetailed(view) => { assert_eq!(view.did.as_ref(), "did:plc:bob"); assert_eq!(view.handle.as_str(), "bob.test"); assert_eq!(view.display_name.as_ref().map(|s| s.as_str()), Some("Bob")); } _ => panic!("expected ProfileViewDetailed arm"), } }
#[test] fn get_author_things_output_decodes_feed_items() { let json = r#"{ "items":[ {"thing":{ "uri":"at://did:plc:alice/space.polymodel.library.thing/thing1", "cid":"bafyrei1", "author":{"did":"did:plc:alice","handle":"alice.test"}, "name":"Thing one", "modelCount":1, "partCount":2, "likeCount":3, "saveCount":1, "indexedAt":"2026-06-21T12:00:00Z", "viewer":{} }} ] }"#; let output: GetAuthorThingsOutput = serde_json::from_str(json).unwrap(); let things: Vec<_> = output.value.items.into_iter().map(|i| i.thing).collect(); assert_eq!(things.len(), 1); assert_eq!(things[0].name.as_str(), "Thing one"); assert_eq!(things[0].author.handle.as_ref(), "alice.test"); assert_eq!(things[0].model_count, 1); }}