atproto Thingiverse but good
10

Configure Feed

Select the types of activity you want to include in your feed.

PM-44: profile page, signed-in chrome, PM-50 login fix + infra

Profile: /profile/:actor + /profile self route; two-column identity rail + tabs + reused ThingCard grid; Bluesky fallback for actors with no polymodel profile; pre-wrap + linkified bio; clickable maker handles (profile_href) on cards + detail.

Signed-in chrome: avatar-only account dropdown in the top nav; AuthenticatedIdentity retains the avatar from getSession; getSession enriches the session profile from the Bluesky profile when there is no polymodel profile record.

PM-50: getSession resolves the handle via projection + DID-document fallback (resolve_handle) instead of projection-only handle_for_did, so authenticated users are no longer reported signed-out.

Infra: OAuth keyset provisioning now uses a durable keyring keyset or falls back to public OAuth (no ephemeral keys that poison the PDS cache); tracing initialized before dioxus::serve with an info,hydrant=warn default and wasm capped at INFO; appview per-request timing middleware.

Validation: just test-all green (53 default/wasm + 90 server). Follow-ups: PM-53 (renderer -> Web Worker + separate bundle), PM-54 (SSR first paint).

Orual (Jun 29, 2026, 7:06 PM EDT) c1837818 083c4ae6

+1714 -128
-1
assets/styling/browse.css
··· 495 495 gap: 1rem; 496 496 } 497 497 498 - 499 498 @media (max-width: 820px) { 500 499 .foundation-hero, 501 500 .browse-controls,
+398
assets/styling/profile.css
··· 1 + /* Profile page: two-column identity rail + content column. 2 + Reuses blueprint tokens (color-*, font-mono, status-pill, tab, state-card, 3 + card-surface, button) from theme/base/browse/cards CSS. */ 4 + 5 + .profile-page { 6 + max-width: 1180px; 7 + margin: 0 auto; 8 + padding: clamp(0.75rem, 2vw, 1.75rem) clamp(1rem, 3vw, 2.5rem); 9 + } 10 + 11 + .profile-nav { 12 + margin-bottom: 0.9rem; 13 + } 14 + 15 + .profile-nav a { 16 + color: var(--color-secondary); 17 + font-size: 0.875rem; 18 + } 19 + 20 + .profile-shell { 21 + display: flex; 22 + flex-direction: column; 23 + gap: 0.9rem; 24 + } 25 + 26 + /* Two-column layout: narrow identity rail + wide content. */ 27 + 28 + .profile-layout { 29 + display: grid; 30 + grid-template-columns: minmax(0, 17rem) minmax(0, 1fr); 31 + gap: clamp(1rem, 2.5vw, 2rem); 32 + align-items: start; 33 + } 34 + 35 + @media (max-width: 52rem) { 36 + .profile-layout { 37 + grid-template-columns: 1fr; 38 + } 39 + } 40 + 41 + /* Identity rail */ 42 + 43 + .profile-rail { 44 + display: flex; 45 + flex-direction: column; 46 + gap: 0.85rem; 47 + padding: clamp(1rem, 2vw, 1.4rem); 48 + } 49 + 50 + @media (min-width: 52rem) { 51 + .profile-rail { 52 + position: sticky; 53 + top: 1rem; 54 + } 55 + } 56 + 57 + .profile-rail .profile-avatar { 58 + width: 7rem; 59 + height: 7rem; 60 + object-fit: cover; 61 + border: 1px solid var(--color-border); 62 + } 63 + 64 + .profile-avatar-fallback { 65 + display: flex; 66 + align-items: center; 67 + justify-content: center; 68 + background-color: var(--color-overlay); 69 + color: var(--color-primary); 70 + font-family: var(--font-heading, var(--font-ui)); 71 + font-size: 2.5rem; 72 + font-weight: 700; 73 + } 74 + 75 + .profile-rail-identity { 76 + display: flex; 77 + flex-direction: column; 78 + gap: 0.2rem; 79 + } 80 + 81 + .profile-rail-identity h1 { 82 + margin: 0; 83 + font-size: clamp(1.5rem, 3vw, 1.85rem); 84 + line-height: 1.12; 85 + color: var(--color-emphasis); 86 + letter-spacing: -0.02em; 87 + word-break: break-word; 88 + } 89 + 90 + .profile-handle { 91 + margin: 0; 92 + color: var(--color-secondary); 93 + font-size: 0.95rem; 94 + word-break: break-word; 95 + } 96 + 97 + .profile-pronouns { 98 + color: var(--color-muted); 99 + } 100 + 101 + .profile-bsky-note { 102 + align-self: flex-start; 103 + margin-top: 0.15rem; 104 + padding: 0.15rem 0.45rem; 105 + border: 1px dashed var(--color-border); 106 + color: var(--color-muted); 107 + font-family: var(--font-mono); 108 + font-size: 0.7rem; 109 + letter-spacing: 0.07em; 110 + text-transform: uppercase; 111 + } 112 + 113 + /* Dashed section dividers within the rail (blueprint rhythm). */ 114 + 115 + .profile-stats, 116 + .profile-bio, 117 + .profile-rail-links { 118 + padding-top: 0.8rem; 119 + border-top: 1px dashed var(--color-border); 120 + } 121 + 122 + .profile-stats { 123 + display: flex; 124 + flex-wrap: wrap; 125 + gap: 0.15rem 0.9rem; 126 + color: var(--color-subtle); 127 + font-size: 0.9rem; 128 + } 129 + 130 + .profile-rail-action { 131 + display: flex; 132 + } 133 + 134 + .profile-rail-action .profile-follow-form { 135 + width: 100%; 136 + margin: 0; 137 + } 138 + 139 + .profile-rail-action .button { 140 + width: 100%; 141 + justify-content: center; 142 + } 143 + 144 + .profile-bio { 145 + margin: 0; 146 + color: var(--color-text); 147 + font-size: 0.95rem; 148 + line-height: 1.5; 149 + white-space: pre-wrap; 150 + word-break: break-word; 151 + } 152 + 153 + .profile-bio a { 154 + color: var(--color-link); 155 + } 156 + 157 + .profile-rail-links { 158 + list-style: none; 159 + margin: 0; 160 + padding-right: 0; 161 + padding-bottom: 0; 162 + padding-left: 0; 163 + display: flex; 164 + flex-direction: column; 165 + gap: 0.25rem; 166 + } 167 + 168 + .profile-rail-links a { 169 + color: var(--color-link); 170 + font-family: var(--font-mono); 171 + font-size: 0.82rem; 172 + word-break: break-all; 173 + } 174 + 175 + /* Content column */ 176 + 177 + .profile-content { 178 + display: flex; 179 + flex-direction: column; 180 + gap: 0.9rem; 181 + min-width: 0; 182 + } 183 + 184 + .profile-tabs { 185 + display: flex; 186 + flex-wrap: wrap; 187 + gap: 0.25rem; 188 + } 189 + 190 + .profile-tab-content { 191 + min-height: 12rem; 192 + } 193 + 194 + .profile-things-grid { 195 + display: grid; 196 + grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); 197 + gap: 1rem; 198 + align-content: start; 199 + } 200 + 201 + .profile-placeholder-panel { 202 + display: flex; 203 + flex-direction: column; 204 + gap: 0.5rem; 205 + padding: 2rem; 206 + background-color: var(--color-surface); 207 + border: 1px solid var(--color-border); 208 + color: var(--color-muted); 209 + } 210 + 211 + /* About tab */ 212 + 213 + .profile-about { 214 + display: flex; 215 + flex-direction: column; 216 + gap: 0.9rem; 217 + padding: clamp(1rem, 3vw, 1.5rem); 218 + } 219 + 220 + .profile-about-meta { 221 + display: flex; 222 + flex-wrap: wrap; 223 + gap: 0.75rem 2rem; 224 + margin: 0; 225 + } 226 + 227 + .profile-about-meta div { 228 + display: flex; 229 + flex-direction: column; 230 + gap: 0.1rem; 231 + } 232 + 233 + .profile-about-meta dt { 234 + color: var(--color-muted); 235 + font-size: 0.8rem; 236 + } 237 + 238 + .profile-about-meta dd { 239 + margin: 0; 240 + color: var(--color-text); 241 + } 242 + 243 + .profile-about-links h2 { 244 + margin: 0 0 0.4rem; 245 + font-size: 1rem; 246 + color: var(--color-subtle); 247 + } 248 + 249 + .profile-about-links ul { 250 + list-style: none; 251 + margin: 0; 252 + padding: 0; 253 + display: flex; 254 + flex-direction: column; 255 + gap: 0.2rem; 256 + } 257 + 258 + .profile-about-links a { 259 + font-family: var(--font-mono); 260 + font-size: 0.85rem; 261 + } 262 + 263 + .profile-about-fallback-note { 264 + margin: 0; 265 + color: var(--color-muted); 266 + font-size: 0.875rem; 267 + } 268 + 269 + .profile-muted { 270 + color: var(--color-muted); 271 + } 272 + 273 + /* Sign-in CTA */ 274 + 275 + .profile-sign-in { 276 + display: flex; 277 + flex-direction: column; 278 + gap: 0.75rem; 279 + padding: clamp(1.5rem, 4vw, 2.5rem); 280 + } 281 + 282 + .profile-sign-in h1 { 283 + margin: 0; 284 + color: var(--color-emphasis); 285 + } 286 + 287 + /* Creator links on cards and detail */ 288 + 289 + .thing-card-creator-link, 290 + .thing-detail-creator-link { 291 + color: var(--color-secondary); 292 + } 293 + 294 + .thing-card-creator-link:hover, 295 + .thing-detail-creator-link:hover { 296 + color: var(--color-link); 297 + } 298 + 299 + /* Signed-in account chrome: avatar trigger + click-to-open dropdown menu. */ 300 + 301 + .account-menu { 302 + position: relative; 303 + display: inline-flex; 304 + align-items: center; 305 + } 306 + 307 + .account-menu > summary { 308 + display: inline-flex; 309 + align-items: center; 310 + cursor: pointer; 311 + list-style: none; 312 + } 313 + 314 + .account-menu > summary::-webkit-details-marker { 315 + display: none; 316 + } 317 + 318 + .account-menu > summary:focus-visible { 319 + outline: 2px solid color-mix(in srgb, var(--color-primary) 35%, transparent); 320 + outline-offset: 2px; 321 + } 322 + 323 + .session-avatar { 324 + display: block; 325 + width: 2.12rem; 326 + height: 2.12rem; 327 + object-fit: cover; 328 + border: 1px solid var(--color-border); 329 + flex-shrink: 0; 330 + } 331 + 332 + .session-avatar-fallback { 333 + display: flex; 334 + align-items: center; 335 + justify-content: center; 336 + background-color: var(--color-overlay); 337 + color: var(--color-primary); 338 + font-weight: 700; 339 + font-size: 0.9rem; 340 + } 341 + 342 + .account-menu-panel { 343 + position: absolute; 344 + right: 0; 345 + top: calc(100% + 0.4rem); 346 + z-index: 30; 347 + min-width: 13rem; 348 + display: flex; 349 + flex-direction: column; 350 + gap: 0.1rem; 351 + padding: 0.4rem; 352 + } 353 + 354 + .account-menu-identity { 355 + display: flex; 356 + flex-direction: column; 357 + gap: 0.05rem; 358 + padding: 0.35rem 0.5rem 0.5rem; 359 + margin-bottom: 0.2rem; 360 + border-bottom: 1px dashed var(--color-border); 361 + } 362 + 363 + .account-menu-identity strong { 364 + color: var(--color-text); 365 + } 366 + 367 + .account-menu-handle { 368 + color: var(--color-muted); 369 + font-size: 0.8rem; 370 + word-break: break-word; 371 + } 372 + 373 + .account-menu-form { 374 + margin: 0; 375 + } 376 + 377 + .account-menu-item { 378 + display: block; 379 + width: 100%; 380 + box-sizing: border-box; 381 + padding: 0.4rem 0.5rem; 382 + text-align: left; 383 + background: transparent; 384 + border: 0; 385 + color: var(--color-text); 386 + font-family: var(--font-ui); 387 + font-size: 0.9rem; 388 + cursor: pointer; 389 + } 390 + 391 + .account-menu-item:hover { 392 + background-color: var(--color-overlay); 393 + text-decoration: none; 394 + } 395 + 396 + .account-menu-signout { 397 + color: var(--color-error); 398 + }
+69 -16
src/appview/actor.rs
··· 93 93 >, 94 94 ) -> AppResult<XrpcResponse<GetSessionRequest>> { 95 95 if let Some(session) = session.as_ref() { 96 + let t_session = std::time::Instant::now(); 96 97 let did = session.session_info().await.0; 97 - if let Some(handle_str) = views::handle_for_did(&state, &did).await? { 98 - let handle = Handle::new_owned(handle_str.clone()) 99 - .map_err(|e| internal(format!("invalid session handle: {e}")))?; 100 - let profile = match views::polymodel_profile_view(&state, &did).await? { 101 - Some(profile) => profile, 102 - None => minimal_session_profile(did.clone(), handle_str)?, 103 - }; 104 - return Ok(XrpcResponse(GetSessionOutput { 105 - value: SessionView::SessionAuthenticated(Box::new(SessionAuthenticated { 106 - authenticated: true, 107 - did, 108 - handle, 109 - profile, 98 + let session_info_ms = t_session.elapsed().as_millis(); 99 + let t_resolve = std::time::Instant::now(); 100 + let resolved = views::resolve_handle(&state, &did).await; 101 + let resolve_handle_ms = t_resolve.elapsed().as_millis(); 102 + match resolved { 103 + Ok(handle_str) => { 104 + let handle = Handle::new_owned(handle_str.clone()) 105 + .map_err(|e| internal(format!("invalid session handle: {e}")))?; 106 + let t_profile = std::time::Instant::now(); 107 + let profile = match views::polymodel_profile_view(&state, &did).await? { 108 + Some(profile) => profile, 109 + None => { 110 + // No polymodel profile record: fall back to the actor's 111 + // Bluesky profile so the session carries their real avatar 112 + // and display name. If that fetch fails, degrade to a 113 + // handle-only profile rather than dropping the session. 114 + match fetch_bsky_profile(&state, &AtIdentifier::Did(did.clone())).await { 115 + Ok(detailed) => { 116 + bsky_session_profile(detailed, did.clone(), &handle_str) 117 + } 118 + Err(error) => { 119 + tracing::warn!(error = ?error, did = %did, "bsky profile fetch failed for session; using handle-only profile"); 120 + minimal_session_profile(did.clone(), handle_str)? 121 + } 122 + } 123 + } 124 + }; 125 + tracing::info!( 126 + session_info_ms, 127 + resolve_handle_ms, 128 + profile_ms = t_profile.elapsed().as_millis(), 129 + "get_session authed timing" 130 + ); 131 + return Ok(XrpcResponse(GetSessionOutput { 132 + value: SessionView::SessionAuthenticated(Box::new(SessionAuthenticated { 133 + authenticated: true, 134 + did, 135 + handle, 136 + profile, 137 + extra_data: None, 138 + })), 110 139 extra_data: None, 111 - })), 112 - extra_data: None, 113 - })); 140 + })); 141 + } 142 + Err(error) => { 143 + tracing::warn!(error = ?error, did = %did, "session handle resolution failed; reporting unauthenticated"); 144 + } 114 145 } 115 146 } 116 147 ··· 145 176 extra_data: None, 146 177 }) 147 178 } 179 + 180 + /// Build a session `ProfileView` from the actor's Bluesky profile so the 181 + /// signed-in chrome can show their real avatar and display name when they have 182 + /// no polymodel profile record. Polymodel-specific fields stay absent. 183 + fn bsky_session_profile(detailed: ProfileViewDetailed, did: Did, handle: &str) -> ProfileView { 184 + ProfileView { 185 + avatar: detailed.avatar, 186 + default_license: None, 187 + description: detailed.description, 188 + did, 189 + display_name: detailed.display_name, 190 + handle: SmolStr::from(handle), 191 + indexed_at: detailed.indexed_at, 192 + links: None, 193 + printers: None, 194 + pronouns: detailed.pronouns, 195 + record: Data::Null, 196 + thing_count: None, 197 + total_likes: None, 198 + extra_data: None, 199 + } 200 + }
+23
src/appview/mod.rs
··· 56 56 GetSessionRequest::PATH, 57 57 axum::routing::get(actor::get_session), 58 58 ) 59 + // Per-request timing: logs total server-side handling time for every 60 + // appview request, *including* the OAuth session-restore extractor that 61 + // runs before each handler body. Use this to localize latency (server 62 + // handler vs. extractor vs. a client firing the request late). 63 + .layer(axum::middleware::from_fn(time_request)) 64 + } 65 + 66 + async fn time_request( 67 + req: axum::extract::Request, 68 + next: axum::middleware::Next, 69 + ) -> axum::response::Response { 70 + let method = req.method().clone(); 71 + let path = req.uri().path().to_string(); 72 + let start = std::time::Instant::now(); 73 + let response = next.run(req).await; 74 + let elapsed_ms = start.elapsed().as_millis(); 75 + let status = response.status().as_u16(); 76 + if elapsed_ms >= 500 { 77 + tracing::warn!(%method, path, status, elapsed_ms, "slow appview request"); 78 + } else { 79 + tracing::info!(%method, path, status, elapsed_ms, "appview request"); 80 + } 81 + response 59 82 }
+1 -1
src/appview/views.rs
··· 908 908 /// DID document if no identity event has landed yet. A polymodel-profiled actor 909 909 /// always has a resolvable handle — never return an empty string, which would 910 910 /// fail handle validation. 911 - async fn resolve_handle(state: &AppState, did: &Did) -> AppResult<String> { 911 + pub(super) async fn resolve_handle(state: &AppState, did: &Did) -> AppResult<String> { 912 912 if let Some(h) = handle_for_did(state, did).await? { 913 913 return Ok(h); 914 914 }
+10 -5
src/browse.rs
··· 2 2 use jacquard_common::xrpc::XrpcClient; 3 3 use polymodel_api::space_polymodel::library; 4 4 5 + use crate::Route; 5 6 use crate::auth::ToastState; 6 7 use crate::client::PolymodelClient; 8 + use crate::profile::profile_href; 7 9 use crate::session::SessionIdentity; 8 10 use crate::shell::SessionControl; 9 11 use crate::thing_card::ThingCard; 10 12 use crate::thing_detail::thing_detail_href; 11 13 use crate::viewer::demo_models; 12 - use crate::Route; 13 14 14 15 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 15 16 enum FeedAlgorithm { ··· 96 97 rsx! { 97 98 main { class: "browse-page product-shell", 98 99 header { class: "product-header", 99 - div { class: "product-kicker", "ATProto model sharing" } 100 + div { class: "product-kicker", "Show off your creations" } 100 101 div { class: "product-header-row", 101 102 div { h1 { "Polymodel" } } 102 103 nav { class: "product-nav", aria_label: "Primary", ··· 110 111 111 112 section { id: "browse-shell", class: "browse-controls blueprint-panel", aria_label: "Browse shell", 112 113 label { class: "search-field", 113 - span { "Search model projects" } 114 + span { "Go find something" } 114 115 input { r#type: "search", placeholder: "Search by project, tag, or maker", aria_label: "Search model projects" } 115 116 } 116 117 div { class: "tab-row", role: "tablist", aria_label: "Feeds", ··· 159 160 #[component] 160 161 fn BrowseThingCard(thing: library::ThingViewBasic) -> Element { 161 162 let detail_href = thing_detail_href(&thing.uri); 162 - rsx! { ThingCard { thing, detail_href } } 163 + let creator_href = profile_href(&thing.author); 164 + rsx! { ThingCard { thing, detail_href, creator_href } } 163 165 } 164 166 165 167 #[component] ··· 180 182 #[test] 181 183 fn browse_state_distinguishes_loading_empty_error_and_populated() { 182 184 assert_eq!(browse_feed_state(true, None), BrowseFeedState::Loading); 183 - assert_eq!(browse_feed_state(false, Some(&Ok(vec![]))), BrowseFeedState::Empty); 185 + assert_eq!( 186 + browse_feed_state(false, Some(&Ok(vec![]))), 187 + BrowseFeedState::Empty 188 + ); 184 189 assert_eq!( 185 190 browse_feed_state(false, Some(&Err("boom".to_string()))), 186 191 BrowseFeedState::Error("boom".to_string())
+23 -4
src/foundation.rs
··· 61 61 62 62 fn foundation_items() -> [FoundationItem; 3] { 63 63 [ 64 - FoundationItem { label: "Panel", title: "Project card surface", body: "A crisp bordered card for future model summaries, previews, and maker metadata.", meta: "primitive/card" }, 65 - FoundationItem { label: "Input", title: "Search and filter shell", body: "Reusable input styling for browse, profile, and publish flows without binding to a data source yet.", meta: "primitive/input" }, 66 - FoundationItem { label: "Status", title: "Metadata and chips", body: "Compact mono labels for license, dimensions, file state, and feed status.", meta: "primitive/status" }, 64 + FoundationItem { 65 + label: "Panel", 66 + title: "Project card surface", 67 + body: "A crisp bordered card for future model summaries, previews, and maker metadata.", 68 + meta: "primitive/card", 69 + }, 70 + FoundationItem { 71 + label: "Input", 72 + title: "Search and filter shell", 73 + body: "Reusable input styling for browse, profile, and publish flows without binding to a data source yet.", 74 + meta: "primitive/input", 75 + }, 76 + FoundationItem { 77 + label: "Status", 78 + title: "Metadata and chips", 79 + body: "Compact mono labels for license, dimensions, file state, and feed status.", 80 + meta: "primitive/status", 81 + }, 67 82 ] 68 83 } 69 84 ··· 91 106 92 107 #[test] 93 108 fn foundation_copy_avoids_workflow_marketing() { 94 - let combined = foundation_items().iter().map(|item| item.body).collect::<Vec<_>>().join(" "); 109 + let combined = foundation_items() 110 + .iter() 111 + .map(|item| item.body) 112 + .collect::<Vec<_>>() 113 + .join(" "); 95 114 assert!(!combined.contains("Jira")); 96 115 assert!(!combined.contains("Polytoken")); 97 116 }
+12 -3
src/indexing/config.rs
··· 100 100 101 101 #[test] 102 102 fn sample_data_env_parses_known_values() { 103 - assert_eq!(SampleDataMode::from_env(Some("off".into())), SampleDataMode::Off); 104 - assert_eq!(SampleDataMode::from_env(Some("force".into())), SampleDataMode::Force); 105 - assert_eq!(SampleDataMode::from_env(Some("auto".into())), SampleDataMode::Auto); 103 + assert_eq!( 104 + SampleDataMode::from_env(Some("off".into())), 105 + SampleDataMode::Off 106 + ); 107 + assert_eq!( 108 + SampleDataMode::from_env(Some("force".into())), 109 + SampleDataMode::Force 110 + ); 111 + assert_eq!( 112 + SampleDataMode::from_env(Some("auto".into())), 113 + SampleDataMode::Auto 114 + ); 106 115 } 107 116 }
+37 -18
src/indexing/sample_data.rs
··· 28 28 .fetch_one(pool) 29 29 .await?; 30 30 if count > 0 { 31 - tracing::debug!(count, "sample data skipped because things table is populated"); 31 + tracing::debug!( 32 + count, 33 + "sample data skipped because things table is populated" 34 + ); 32 35 return Ok(()); 33 36 } 34 37 35 38 seed(pool).await?; 36 - tracing::info!(thing_uri = SAMPLE_THING_URI, "seeded local/demo Polymodel sample data"); 39 + tracing::info!( 40 + thing_uri = SAMPLE_THING_URI, 41 + "seeded local/demo Polymodel sample data" 42 + ); 37 43 Ok(()) 38 44 } 39 45 ··· 43 49 insert_identity(&mut tx, SAMPLE_DID, SAMPLE_HANDLE).await?; 44 50 insert_profile(&mut tx).await?; 45 51 46 - let model_uris = vec![ 52 + let model_uris = [ 47 53 model_uri("enclosure-v1"), 48 54 model_uri("panel-variant"), 49 55 model_uri("ruggedized-lid"), ··· 80 86 81 87 let instructions = vec![ 82 88 "Print the body and lid in PETG or PLA+ with 0.2 mm layers.".to_string(), 83 - "Use the model selector to choose the standard, panel-mount, or ruggedized variant.".to_string(), 89 + "Use the model selector to choose the standard, panel-mount, or ruggedized variant." 90 + .to_string(), 84 91 "Press brass inserts into the corner bosses before final assembly.".to_string(), 85 92 ]; 86 93 let tags = vec!["enclosure", "parametric", "print-in-place"]; ··· 121 128 .await?; 122 129 123 130 for (position, model_uri) in model_uris.iter().enumerate() { 124 - sqlx::query( 125 - "INSERT INTO thing_models (thing_uri, model_uri, position) VALUES (?, ?, ?)", 126 - ) 127 - .bind(SAMPLE_THING_URI) 128 - .bind(model_uri) 129 - .bind(position as i64) 130 - .execute(&mut *tx) 131 - .await?; 131 + sqlx::query("INSERT INTO thing_models (thing_uri, model_uri, position) VALUES (?, ?, ?)") 132 + .bind(SAMPLE_THING_URI) 133 + .bind(model_uri) 134 + .bind(position as i64) 135 + .execute(&mut *tx) 136 + .await?; 132 137 } 133 138 134 139 sqlx::query( ··· 142 147 Ok(()) 143 148 } 144 149 145 - async fn insert_identity(conn: &mut SqliteConnection, did: &str, handle: &str) -> anyhow::Result<()> { 150 + async fn insert_identity( 151 + conn: &mut SqliteConnection, 152 + did: &str, 153 + handle: &str, 154 + ) -> anyhow::Result<()> { 146 155 sqlx::query("INSERT INTO identities (did, handle, updated_at) VALUES (?, ?, ?)") 147 156 .bind(did) 148 157 .bind(handle) ··· 218 227 for (position, part) in parts.iter().enumerate() { 219 228 sqlx::query("INSERT INTO model_parts (model_uri, part_uri, position) VALUES (?, ?, ?)") 220 229 .bind(&uri) 221 - .bind(part_uri(part.rkey)) 230 + .bind(part_uri(part.rkey)) 222 231 .bind(position as i64) 223 232 .execute(&mut *conn) 224 233 .await?; ··· 375 384 376 385 let feed = views::feed_recent(&state, 10, None, None).await.unwrap(); 377 386 assert_eq!(feed.items.len(), 1); 378 - assert_eq!(feed.items[0].thing.name.as_str(), "Parametric enclosure kit"); 387 + assert_eq!( 388 + feed.items[0].thing.name.as_str(), 389 + "Parametric enclosure kit" 390 + ); 379 391 assert_eq!(feed.items[0].thing.model_count, 3); 380 392 assert_eq!(feed.items[0].thing.part_count, 18); 381 393 382 - let (thing, models) = views::get_thing(&state, SAMPLE_THING_URI, None).await.unwrap(); 383 - assert_eq!(thing.author.display_name.as_ref().unwrap().as_str(), "Ari Chen"); 394 + let (thing, models) = views::get_thing(&state, SAMPLE_THING_URI, None) 395 + .await 396 + .unwrap(); 397 + assert_eq!( 398 + thing.author.display_name.as_ref().unwrap().as_str(), 399 + "Ari Chen" 400 + ); 384 401 assert_eq!(models.len(), 3); 385 402 386 - let detail = views::get_model(&state, models[0].uri.as_ref(), None).await.unwrap(); 403 + let detail = views::get_model(&state, models[0].uri.as_ref(), None) 404 + .await 405 + .unwrap(); 387 406 assert_eq!(detail.parts.len(), 6); 388 407 assert_eq!(detail.parts[0].name.as_str(), "Main enclosure body"); 389 408 }
+23 -10
src/main.rs
··· 8 8 mod fonts; 9 9 mod foundation; 10 10 mod mesh; 11 + mod profile; 11 12 mod session; 12 13 mod shell; 13 14 mod thing_card; ··· 18 19 // Frontend compile-time configuration, generated by build.rs from POLYMODEL_*. 19 20 mod env; 20 21 // Server-only indexing infrastructure (Hydrant firehose + SQLite projection). 21 - #[cfg(any(feature = "server", test))] 22 + #[cfg(feature = "server")] 22 23 mod indexing; 23 24 // Server-only XRPC read endpoints (appview query layer, PM-26). 24 - #[cfg(any(feature = "server", test))] 25 + #[cfg(feature = "server")] 25 26 mod appview; 26 - #[cfg(any(feature = "server", test))] 27 + #[cfg(feature = "server")] 27 28 mod oauth; 28 29 29 30 use browse::Browse; 30 31 use client::PolymodelClient; 31 32 use foundation::Foundation; 33 + use profile::{Profile, ProfileSelf}; 32 34 use session::SessionIdentity; 33 35 use shell::ToastShelf; 34 36 use thing_detail::ThingDetail; ··· 40 42 const CARDS_CSS: Asset = asset!("/assets/styling/cards.css"); 41 43 const BROWSE_CSS: Asset = asset!("/assets/styling/browse.css"); 42 44 const POLYMODEL_CSS: Asset = asset!("/assets/styling/polymodel.css"); 45 + const PROFILE_CSS: Asset = asset!("/assets/styling/profile.css"); 43 46 44 47 #[cfg(feature = "server")] 45 48 fn main() { 49 + // Initialise tracing and env before dioxus::serve so our filter takes 50 + // precedence over any subscriber the Dioxus runtime installs. 51 + let _ = dotenvy::dotenv(); 52 + let _ = tracing_subscriber::fmt() 53 + .with_env_filter( 54 + tracing_subscriber::EnvFilter::try_from_default_env() 55 + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info,hydrant=warn")), 56 + ) 57 + .try_init(); 46 58 // The closure runs inside the server's Tokio runtime at startup, so the 47 59 // indexing tasks spawned here live for the process lifetime alongside Axum. 48 60 dioxus::serve(|| async { 49 - // Server runtime config: load .env, then read DATABASE_URL (and any 50 - // future server env) in one place. Hydrant reads its own HYDRANT_*. 51 - let _ = dotenvy::dotenv(); 52 - let _ = tracing_subscriber::fmt() 53 - .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) 54 - .try_init(); 55 61 let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); // Hydrant selects AWS-LC while other deps enable ring; choose before websocket TLS setup. 56 62 let cfg = indexing::config::ServerConfig::load(); 57 63 let db = indexing::db::init_db(&cfg) ··· 86 92 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 87 93 { 88 94 console_error_panic_hook::set_once(); 89 - tracing_wasm::set_as_global_default(); 95 + let mut wasm_log_config = tracing_wasm::WASMLayerConfigBuilder::new(); 96 + wasm_log_config.set_max_level(tracing::Level::INFO); 97 + tracing_wasm::set_as_global_default_with_config(wasm_log_config.build()); 90 98 } 91 99 92 100 dioxus::launch(App); ··· 98 106 Browse {}, 99 107 #[route("/foundation")] 100 108 Foundation {}, 109 + #[route("/profile")] 110 + ProfileSelf {}, 111 + #[route("/profile/:actor")] 112 + Profile { actor: String }, 101 113 #[route("/viewer/:model_id")] 102 114 Viewer { model_id: String }, 103 115 #[route("/:repo/thing/:rkey")] ··· 133 145 Stylesheet { href: CARDS_CSS } 134 146 Stylesheet { href: BROWSE_CSS } 135 147 Stylesheet { href: POLYMODEL_CSS } 148 + Stylesheet { href: PROFILE_CSS } 136 149 137 150 Router::<Route> {} 138 151 ToastShelf { toasts }
+47 -22
src/oauth/mod.rs
··· 279 279 pub fn bootstrap_oauth(pool: SqlitePool, base_url: Option<&str>) -> anyhow::Result<OAuthBootstrap> { 280 280 let (keyset, cookie_key) = provision_oauth_secrets()?; 281 281 let metadata = hosted_client_metadata(base_url)?; 282 - let client_data = ClientData::new(Some(keyset), metadata); 282 + let client_data = ClientData::new(keyset, metadata); 283 283 let client: OAuthClient<PublicResolver, SqliteAuthStore> = OAuthClient::new( 284 284 SqliteAuthStore::new(pool), 285 285 client_data, ··· 336 336 337 337 /// Provision the OAuth signing keyset and cookie-jar key. 338 338 /// 339 - /// Priority: (1) OS keyring — preferred, persists across restarts; on first run 340 - /// a fresh keyset/key is generated and stored so subsequent restarts reuse it; 341 - /// (2) environment override (`POLYMODEL_OAUTH_KEYSET_B64` / 342 - /// `POLYMODEL_COOKIE_KEY_B64`) — read-only; (3) ephemeral in-memory generation 343 - /// with a loud warning that identity + sessions are non-persistent and lost on 344 - /// restart (the demo default). 345 - fn provision_oauth_secrets() -> anyhow::Result<(Keyset, Key)> { 339 + /// Keyset priority: (1) OS keyring — preferred, persists across restarts; on 340 + /// first run a fresh keyset is generated and stored so subsequent restarts 341 + /// reuse it; (2) environment override (`POLYMODEL_OAUTH_KEYSET_B64`) — 342 + /// read-only; (3) `None` — fall back to public OAuth (no client assertion). 343 + /// Ephemeral keysets are never generated because they change on restart and 344 + /// poison the PDS client-assertion cache. 345 + /// 346 + /// Cookie key: same keyring/env priority, then ephemeral generation (sessions 347 + /// are lost on restart but the PDS is unaffected). 348 + fn provision_oauth_secrets() -> anyhow::Result<(Option<Keyset>, Key)> { 346 349 let keyring = KeyringSource; 347 - let (keyset, generated_keyset) = provision_keyset(&keyring)?; 350 + let keyset = provision_keyset(&keyring)?; 348 351 let (cookie, generated_cookie) = provision_cookie_key(&keyring)?; 349 - if generated_keyset || generated_cookie { 352 + if generated_cookie { 350 353 tracing::warn!( 351 - "Generated ephemeral OAuth secret material because no persisted keyring/env value was \ 352 - available for at least one secret. OAuth identity and browser sessions are \ 353 - NON-PERSISTENT for generated secrets and may be lost on restart. Provision both the \ 354 - OAuth keyset and cookie key in keyring or environment for durable sessions." 354 + "No durable cookie key found (keyring/env). Generated an ephemeral one — browser \ 355 + sessions will not survive restart." 355 356 ); 356 357 } 357 358 Ok((keyset, cookie)) 358 359 } 359 360 360 - fn provision_keyset(keyring: &KeyringSource) -> anyhow::Result<(Keyset, bool)> { 361 + fn provision_keyset(keyring: &KeyringSource) -> anyhow::Result<Option<Keyset>> { 361 362 if !cfg!(test) 362 363 && let Some(keyset) = keyring.load_keyset()? 363 364 { 364 - return Ok((keyset, false)); 365 + return Ok(Some(keyset)); 365 366 } 366 367 if !cfg!(test) 367 368 && let Some(keyset) = EnvSource.load_keyset()? 368 369 { 369 - return Ok((keyset, false)); 370 + return Ok(Some(keyset)); 370 371 } 371 - 372 - let keyset = Keyset::generate_es256("polymodel").context("generate OAuth keyset")?; 373 - // Best-effort: persist to keyring so a later restart (on a host with a usable 374 - // secret store) reuses this instead of regenerating. 372 + // Try to generate and persist. Only return the keyset if the keyring 373 + // actually stored it — ephemeral keys change on restart and poison the PDS 374 + // client-assertion cache, so we fall back to public OAuth instead. 375 375 if !cfg!(test) { 376 + let keyset = Keyset::generate_es256("polymodel").context("generate OAuth keyset")?; 376 377 let _ = keyring.store_keyset(&keyset); 378 + if keyring.load_keyset()?.is_some() { 379 + return Ok(Some(keyset)); 380 + } 377 381 } 378 - Ok((keyset, true)) 382 + tracing::warn!( 383 + "No durable OAuth keyset found (keyring/env). Using public OAuth (no client assertion). \ 384 + Set POLYMODEL_OAUTH_KEYSET_B64 or install an OS keyring backend for DPoP client auth." 385 + ); 386 + Ok(None) 379 387 } 380 388 381 389 fn provision_cookie_key(keyring: &KeyringSource) -> anyhow::Result<(Key, bool)> { ··· 416 424 /// operation degrades to `None` / no-op so provisioning falls through to env. 417 425 struct KeyringSource; 418 426 427 + #[cfg(feature = "server")] 419 428 impl KeyringSource { 420 429 const SERVICE: &'static str = "polymodel"; 421 430 const KEYSET_USER: &'static str = "oauth-client-keyset"; ··· 426 435 } 427 436 } 428 437 438 + #[cfg(feature = "server")] 429 439 impl SecretSource for KeyringSource { 430 440 fn load_keyset(&self) -> anyhow::Result<Option<Keyset>> { 431 441 let Some(entry) = Self::entry(Self::KEYSET_USER) else { ··· 465 475 if let Some(entry) = Self::entry(Self::COOKIE_USER) { 466 476 let _ = entry.set_password(&b64::STANDARD.encode(key.master())); 467 477 } 478 + Ok(()) 479 + } 480 + } 481 + #[cfg(not(feature = "server"))] 482 + impl SecretSource for KeyringSource { 483 + fn load_keyset(&self) -> anyhow::Result<Option<Keyset>> { 484 + Ok(None) 485 + } 486 + fn store_keyset(&self, _keyset: &Keyset) -> anyhow::Result<()> { 487 + Ok(()) 488 + } 489 + fn load_cookie_key(&self) -> anyhow::Result<Option<Key>> { 490 + Ok(None) 491 + } 492 + fn store_cookie_key(&self, _key: &Key) -> anyhow::Result<()> { 468 493 Ok(()) 469 494 } 470 495 }
+957
src/profile.rs
··· 1 + use dioxus::prelude::*; 2 + use jacquard_common::deps::smol_str::SmolStr; 3 + use jacquard_common::types::ident::AtIdentifier; 4 + use jacquard_common::types::string::{Did, Handle, UriValue}; 5 + use jacquard_common::xrpc::XrpcClient; 6 + use polymodel_api::space_polymodel::actor::{ 7 + ProfileViewUnion, get_profile::GetProfile, get_session::GetSession, 8 + }; 9 + use polymodel_api::space_polymodel::library; 10 + use polymodel_api::space_polymodel::library::get_author_things::GetAuthorThings; 11 + 12 + use crate::Route; 13 + use crate::client::PolymodelClient; 14 + use crate::session::SessionIdentity; 15 + use crate::thing_card::ThingCard; 16 + use crate::thing_detail::thing_detail_href; 17 + 18 + #[derive(Clone, Copy, Debug, PartialEq, Eq)] 19 + enum ProfileTab { 20 + Things, 21 + Lists, 22 + Makes, 23 + About, 24 + } 25 + 26 + impl ProfileTab { 27 + fn label(self) -> &'static str { 28 + match self { 29 + Self::Things => "Things", 30 + Self::Lists => "Lists", 31 + Self::Makes => "Makes", 32 + Self::About => "About", 33 + } 34 + } 35 + } 36 + 37 + #[derive(Clone, Debug, PartialEq, Eq)] 38 + enum ProfileState { 39 + Loading, 40 + InvalidActor(String), 41 + Error(String), 42 + Populated(Box<ProfileData>), 43 + } 44 + 45 + #[derive(Clone, Debug, PartialEq, Eq)] 46 + struct ProfileData { 47 + header: ProfileHeader, 48 + things: Vec<library::ThingViewBasic>, 49 + } 50 + 51 + #[derive(Clone, Debug, PartialEq, Eq)] 52 + enum ProfileFeedState { 53 + Loading, 54 + Error(String), 55 + Empty, 56 + Populated(Vec<library::ThingViewBasic>), 57 + } 58 + 59 + #[derive(Clone, Debug, PartialEq, Eq)] 60 + struct ProfileHeader { 61 + avatar: Option<UriValue>, 62 + default_license: Option<SmolStr>, 63 + description: Option<SmolStr>, 64 + did: Did, 65 + display_name: Option<SmolStr>, 66 + handle: Handle, 67 + links: Option<Vec<UriValue>>, 68 + pronouns: Option<SmolStr>, 69 + thing_count: Option<i64>, 70 + total_likes: Option<i64>, 71 + is_bsky_fallback: bool, 72 + } 73 + 74 + fn profile_state(pending: bool, result: Option<&Result<ProfileData, String>>) -> ProfileState { 75 + if pending && result.is_none() { 76 + return ProfileState::Loading; 77 + } 78 + 79 + match result { 80 + Some(Ok(data)) => ProfileState::Populated(Box::new(data.clone())), 81 + Some(Err(error)) if error.starts_with("Invalid actor:") => { 82 + ProfileState::InvalidActor(error.clone()) 83 + } 84 + Some(Err(error)) => ProfileState::Error(error.clone()), 85 + None => ProfileState::Loading, 86 + } 87 + } 88 + 89 + fn profile_feed_state( 90 + pending: bool, 91 + result: Option<&Result<&[library::ThingViewBasic], String>>, 92 + ) -> ProfileFeedState { 93 + if pending && result.is_none() { 94 + return ProfileFeedState::Loading; 95 + } 96 + 97 + match result { 98 + Some(Ok(things)) => { 99 + if things.is_empty() { 100 + ProfileFeedState::Empty 101 + } else { 102 + ProfileFeedState::Populated(things.to_vec()) 103 + } 104 + } 105 + Some(Err(error)) => ProfileFeedState::Error(error.clone()), 106 + None => ProfileFeedState::Loading, 107 + } 108 + } 109 + 110 + /// Build a unified display header from either union arm. The polymodel arm's 111 + /// bare `handle` string is branded into a `Handle` here (single conversion site); 112 + /// the bsky arm already carries a branded `Handle`. `did` is `Did` on both arms 113 + /// and is cloned directly. 114 + fn profile_header(value: &ProfileViewUnion) -> ProfileHeader { 115 + match value { 116 + ProfileViewUnion::ProfileView(view) => ProfileHeader { 117 + avatar: view.avatar.clone(), 118 + default_license: optional_smolstr(&view.default_license), 119 + description: optional_smolstr(&view.description), 120 + did: view.did.clone(), 121 + display_name: optional_smolstr(&view.display_name), 122 + handle: Handle::new_owned(view.handle.as_str()) 123 + .expect("polymodel profile handle is server-validated"), 124 + links: view.links.clone(), 125 + pronouns: optional_smolstr(&view.pronouns), 126 + thing_count: view.thing_count, 127 + total_likes: view.total_likes, 128 + is_bsky_fallback: false, 129 + }, 130 + ProfileViewUnion::ProfileViewDetailed(view) => ProfileHeader { 131 + avatar: view.avatar.clone(), 132 + default_license: None, 133 + description: optional_smolstr(&view.description), 134 + did: view.did.clone(), 135 + display_name: optional_smolstr(&view.display_name), 136 + handle: view.handle.clone(), 137 + links: None, 138 + pronouns: optional_smolstr(&view.pronouns), 139 + thing_count: None, 140 + total_likes: None, 141 + is_bsky_fallback: true, 142 + }, 143 + } 144 + } 145 + 146 + fn optional_smolstr(value: &Option<impl AsRef<str>>) -> Option<SmolStr> { 147 + value 148 + .as_ref() 149 + .map(|v| v.as_ref()) 150 + .map(str::trim) 151 + .filter(|v| !v.is_empty()) 152 + .map(SmolStr::from) 153 + } 154 + 155 + /// Canonical profile route for an actor. Prefers the handle (always present 156 + /// and validated on `library::Actor`); falls back to the DID defensively. 157 + pub(crate) fn profile_href(actor: &library::Actor) -> Option<String> { 158 + let handle = actor.handle.as_ref(); 159 + if !handle.is_empty() { 160 + Some(format!("/profile/{handle}")) 161 + } else { 162 + Some(format!("/profile/{}", actor.did.as_ref())) 163 + } 164 + } 165 + 166 + fn avatar_initial(header: &ProfileHeader) -> char { 167 + let name = header 168 + .display_name 169 + .as_ref() 170 + .map(SmolStr::as_str) 171 + .unwrap_or_else(|| header.handle.as_ref()); 172 + name.chars().next().unwrap_or('?').to_ascii_uppercase() 173 + } 174 + 175 + fn header_display_label(header: &ProfileHeader) -> String { 176 + header 177 + .display_name 178 + .as_ref() 179 + .map(SmolStr::as_str) 180 + .filter(|s| !s.trim().is_empty()) 181 + .map(ToString::to_string) 182 + .unwrap_or_else(|| header.handle.as_ref().to_string()) 183 + } 184 + 185 + fn count_label(value: Option<i64>, noun: &str) -> Option<String> { 186 + value.map(|n| { 187 + let suffix = if n == 1 { "" } else { "s" }; 188 + format!("{n} {noun}{suffix}") 189 + }) 190 + } 191 + 192 + async fn load_profile(client: PolymodelClient, actor_str: &str) -> Result<ProfileData, String> { 193 + let actor = AtIdentifier::new_owned(actor_str) 194 + .map_err(|error| format!("Invalid actor: unsupported actor identifier: {error}"))?; 195 + 196 + let profile_response = client 197 + .send(GetProfile::new().actor(actor.clone()).build()) 198 + .await 199 + .map_err(|error| format!("Profile request failed: {error}"))?; 200 + let profile_output = profile_response 201 + .into_output() 202 + .map_err(|error| format!("Profile decode failed: {error}"))?; 203 + let header = profile_header(&profile_output.value); 204 + 205 + let things_response = client 206 + .send(GetAuthorThings::new().actor(actor).limit(24).build()) 207 + .await 208 + .map_err(|error| format!("Author things request failed: {error}"))?; 209 + let things_output = things_response 210 + .into_output() 211 + .map_err(|error| format!("Author things decode failed: {error}"))?; 212 + let things = things_output 213 + .value 214 + .items 215 + .into_iter() 216 + .map(|item| item.thing) 217 + .collect(); 218 + 219 + Ok(ProfileData { header, things }) 220 + } 221 + 222 + #[allow(clippy::useless_format)] 223 + #[component] 224 + pub(crate) fn Profile(actor: String) -> Element { 225 + rsx! { ProfileBody { actor } } 226 + } 227 + 228 + #[allow(clippy::useless_format)] 229 + #[component] 230 + pub(crate) fn ProfileSelf() -> Element { 231 + let client = use_context::<PolymodelClient>(); 232 + let session = use_context::<Signal<SessionIdentity>>(); 233 + // Re-check the session on mount — the App-level seed runs once and may have 234 + // failed or not resolved yet. This ensures /profile always has a fresh read. 235 + let session_check = use_resource(move || { 236 + let client = client.clone(); 237 + let mut session = session; 238 + async move { 239 + match client.send(GetSession).await { 240 + Ok(response) => match response.into_output() { 241 + Ok(output) => { 242 + let identity = SessionIdentity::from_get_session(output); 243 + session.set(identity); 244 + } 245 + Err(error) => { 246 + tracing::warn!(error = ?error, "profile: failed to decode session"); 247 + } 248 + }, 249 + Err(error) => { 250 + tracing::warn!(error = ?error, "profile: failed to fetch session"); 251 + } 252 + } 253 + } 254 + }); 255 + 256 + if session_check.pending() { 257 + return rsx! { 258 + main { class: "profile-page product-shell", 259 + nav { class: "profile-nav", aria_label: "Profile navigation", 260 + Link { to: Route::Browse {}, "← Back to browse" } 261 + } 262 + section { class: "profile-shell blueprint-panel", 263 + div { class: "state-card loading-state", 264 + span { class: "status-pill", "Loading" } 265 + div { class: "skeleton-line skeleton-wide" } 266 + div { class: "skeleton-line" } 267 + div { class: "skeleton-box" } 268 + } 269 + } 270 + } 271 + }; 272 + } 273 + 274 + match session.read().clone() { 275 + SessionIdentity::Authenticated(auth) => { 276 + rsx! { ProfileBody { actor: auth.handle.as_str().to_string() } } 277 + } 278 + SessionIdentity::Anonymous => rsx! { 279 + main { class: "profile-page product-shell", 280 + nav { class: "profile-nav", aria_label: "Profile navigation", 281 + Link { to: Route::Browse {}, "← Back to browse" } 282 + } 283 + section { class: "profile-sign-in blueprint-panel", 284 + span { class: "status-pill", "Signed out" } 285 + h1 { "Your profile" } 286 + p { class: "product-lede", "Sign in to view and share your Polymodel profile." } 287 + form { 288 + class: "session-control", 289 + action: "/oauth/start", 290 + method: "get", 291 + input { r#type: "hidden", name: "return_to", value: "/profile" }, 292 + input { 293 + r#type: "text", 294 + name: "identifier", 295 + placeholder: "handle.test", 296 + aria_label: "ATProto handle", 297 + } 298 + button { class: "button button-primary", r#type: "submit", "Sign in" } 299 + } 300 + } 301 + } 302 + }, 303 + } 304 + } 305 + 306 + #[allow(clippy::useless_format)] 307 + #[component] 308 + fn ProfileBody(actor: String) -> Element { 309 + let client = use_context::<PolymodelClient>(); 310 + let session = use_context::<Signal<SessionIdentity>>(); 311 + let active_tab = use_signal(|| ProfileTab::Things); 312 + 313 + let mut profile = use_resource(move || { 314 + let client = client.clone(); 315 + let actor = actor.clone(); 316 + async move { load_profile(client, &actor).await } 317 + }); 318 + 319 + let profile_result = profile.read(); 320 + let state = profile_state(profile.pending(), profile_result.as_ref()); 321 + 322 + rsx! { 323 + main { class: "profile-page product-shell", 324 + nav { class: "profile-nav", aria_label: "Profile navigation", 325 + Link { to: Route::Browse {}, "← Back to browse" } 326 + } 327 + match state { 328 + ProfileState::Loading => rsx! { ProfileLoading {} }, 329 + ProfileState::InvalidActor(message) => rsx! { 330 + section { class: "profile-shell blueprint-panel", aria_label: "Profile error", 331 + div { class: "state-card error-state", 332 + span { class: "status-pill status-error", "Error" } 333 + h1 { "Profile unavailable" } 334 + p { "{message}" } 335 + Link { class: "button button-secondary", to: Route::Browse {}, "Back to browse" } 336 + } 337 + } 338 + }, 339 + ProfileState::Error(message) => rsx! { 340 + section { class: "profile-shell blueprint-panel", aria_label: "Profile error", 341 + div { class: "state-card error-state", 342 + span { class: "status-pill status-error", "Error" } 343 + h1 { "Profile unavailable" } 344 + p { "{message}" } 345 + button { class: "button button-secondary", onclick: move |_| profile.restart(), "Retry" } 346 + Link { class: "button button-secondary", to: Route::Browse {}, "Back to browse" } 347 + } 348 + } 349 + }, 350 + ProfileState::Populated(data) => { 351 + let data = *data; 352 + let session_identity = session.read().clone(); 353 + let (is_own, is_anonymous) = match session_identity { 354 + SessionIdentity::Authenticated(identity) => { 355 + (identity.did == data.header.did, false) 356 + } 357 + SessionIdentity::Anonymous => (false, true), 358 + }; 359 + rsx! { 360 + ProfilePopulated { data, is_own, is_anonymous, active_tab } 361 + } 362 + } 363 + } 364 + } 365 + } 366 + } 367 + 368 + #[allow(clippy::useless_format)] 369 + #[component] 370 + fn ProfileLoading() -> Element { 371 + rsx! { 372 + section { class: "profile-shell blueprint-panel", aria_label: "Profile loading", 373 + div { class: "state-card loading-state", 374 + span { class: "status-pill", "Loading" } 375 + div { class: "skeleton-line skeleton-wide" } 376 + div { class: "skeleton-line" } 377 + div { class: "skeleton-box" } 378 + } 379 + } 380 + } 381 + } 382 + 383 + /// A bio rendered as alternating plain-text and URL segments. Newlines stay 384 + /// inside the text segments; the bio container uses `white-space: pre-wrap` so 385 + /// they render as authored. 386 + enum BioSegment { 387 + Text(String), 388 + Link(String), 389 + } 390 + 391 + /// Split bio text into text/link segments, linkifying `http(s)://` and `www.` 392 + /// tokens while leaving every other character (including newlines) untouched. 393 + fn linkify_bio(text: &str) -> Vec<BioSegment> { 394 + let mut segments = Vec::new(); 395 + let mut rest = text; 396 + while let Some(start) = next_url_start(rest) { 397 + let (before, from) = rest.split_at(start); 398 + if !before.is_empty() { 399 + segments.push(BioSegment::Text(before.to_string())); 400 + } 401 + let end = from.find(char::is_whitespace).unwrap_or(from.len()); 402 + let (token, after) = from.split_at(end); 403 + // Keep trailing sentence punctuation out of the linked span. 404 + let trimmed_len = token.trim_end_matches([',', '.', ')', ';', ':', '!']).len(); 405 + let (url, tail) = token.split_at(trimmed_len); 406 + segments.push(BioSegment::Link(url.to_string())); 407 + if !tail.is_empty() { 408 + segments.push(BioSegment::Text(tail.to_string())); 409 + } 410 + rest = after; 411 + } 412 + if !rest.is_empty() { 413 + segments.push(BioSegment::Text(rest.to_string())); 414 + } 415 + segments 416 + } 417 + 418 + fn next_url_start(s: &str) -> Option<usize> { 419 + [s.find("https://"), s.find("http://"), s.find("www.")] 420 + .into_iter() 421 + .flatten() 422 + .min() 423 + } 424 + 425 + /// Build an href for a linkified bio token, adding a scheme for `www.` tokens. 426 + fn bio_link_href(url: &str) -> String { 427 + if url.starts_with("http://") || url.starts_with("https://") { 428 + url.to_string() 429 + } else { 430 + format!("https://{url}") 431 + } 432 + } 433 + 434 + #[allow(clippy::useless_format)] 435 + #[component] 436 + fn ProfilePopulated( 437 + data: ProfileData, 438 + is_own: bool, 439 + is_anonymous: bool, 440 + active_tab: Signal<ProfileTab>, 441 + ) -> Element { 442 + let header = data.header; 443 + let things = data.things; 444 + let display_label = header_display_label(&header); 445 + let handle_str = header.handle.as_ref().to_string(); 446 + let avatar_url = header.avatar.as_ref().map(|u| u.as_ref().to_string()); 447 + let initial = avatar_initial(&header); 448 + let description = header 449 + .description 450 + .as_ref() 451 + .map(SmolStr::as_str) 452 + .map(ToString::to_string); 453 + let pronouns = header 454 + .pronouns 455 + .as_ref() 456 + .map(SmolStr::as_str) 457 + .map(ToString::to_string); 458 + let thing_count_label = count_label(header.thing_count, "thing"); 459 + let total_likes_label = count_label(header.total_likes, "like"); 460 + let is_bsky_fallback = header.is_bsky_fallback; 461 + let links_rail = header.links.clone(); 462 + let bio_segments = description.as_deref().map(linkify_bio); 463 + let tab = *active_tab.read(); 464 + 465 + rsx! { 466 + div { class: "profile-layout", 467 + aside { class: "profile-rail blueprint-panel", aria_label: "Maker identity", 468 + if let Some(url) = &avatar_url { 469 + img { class: "profile-avatar", src: "{url}", alt: "{display_label} avatar" } 470 + } else { 471 + div { class: "profile-avatar profile-avatar-fallback", aria_label: "Avatar placeholder", 472 + span { "{initial}" } 473 + } 474 + } 475 + 476 + div { class: "profile-rail-identity", 477 + h1 { "{display_label}" } 478 + p { class: "profile-handle", 479 + "@{handle_str}" 480 + if let Some(pronouns) = &pronouns { 481 + span { class: "profile-pronouns", " · {pronouns}" } 482 + } 483 + } 484 + if is_bsky_fallback { 485 + span { class: "profile-bsky-note", "Bluesky profile" } 486 + } 487 + } 488 + 489 + if thing_count_label.is_some() || total_likes_label.is_some() { 490 + div { class: "profile-stats", aria_label: "Maker stats", 491 + if let Some(label) = &thing_count_label { 492 + span { "{label}" } 493 + } 494 + if let Some(label) = &total_likes_label { 495 + span { "{label}" } 496 + } 497 + } 498 + } 499 + 500 + div { class: "profile-rail-action", 501 + if is_own { 502 + button { class: "button button-secondary", disabled: true, "Edit profile" } 503 + } else if is_anonymous { 504 + form { class: "profile-follow-form", action: "/oauth/start", method: "get", 505 + input { r#type: "hidden", name: "return_to", value: "/profile/{handle_str}" } 506 + button { class: "button button-primary", r#type: "submit", "Sign in to follow" } 507 + } 508 + } else { 509 + button { class: "button button-primary", disabled: true, "Follow" } 510 + } 511 + } 512 + 513 + if let Some(segments) = &bio_segments { 514 + p { class: "profile-bio", 515 + for segment in segments { 516 + match segment { 517 + BioSegment::Text(text) => rsx! { "{text}" }, 518 + BioSegment::Link(url) => rsx! { 519 + a { href: "{bio_link_href(url)}", rel: "nofollow noopener", "{url}" } 520 + }, 521 + } 522 + } 523 + } 524 + } 525 + 526 + if let Some(links) = links_rail.as_ref().filter(|links| !links.is_empty()) { 527 + ul { class: "profile-rail-links", aria_label: "Maker links", 528 + for (index, link) in links.iter().enumerate() { 529 + li { key: "link-{index}", 530 + a { href: "{link.as_ref()}", rel: "nofollow noopener", "{link.as_ref()}" } 531 + } 532 + } 533 + } 534 + } 535 + } 536 + 537 + div { class: "profile-content", 538 + nav { class: "tab-row profile-tabs", role: "tablist", aria_label: "Profile sections", 539 + ProfileTabButton { tab: ProfileTab::Things, active_tab } 540 + ProfileTabButton { tab: ProfileTab::Lists, active_tab } 541 + ProfileTabButton { tab: ProfileTab::Makes, active_tab } 542 + ProfileTabButton { tab: ProfileTab::About, active_tab } 543 + } 544 + 545 + div { class: "profile-tab-content", 546 + match tab { 547 + ProfileTab::Things => rsx! { ProfileThingsGrid { things } }, 548 + ProfileTab::Lists => rsx! { 549 + div { class: "profile-placeholder-panel", 550 + span { class: "status-pill status-muted", "Coming soon" } 551 + p { "Lists and collections are not available yet." } 552 + } 553 + }, 554 + ProfileTab::Makes => rsx! { 555 + div { class: "profile-placeholder-panel", 556 + span { class: "status-pill status-muted", "Coming soon" } 557 + p { "Makes and activity are not available yet." } 558 + } 559 + }, 560 + ProfileTab::About => rsx! { 561 + ProfileAbout { header } 562 + }, 563 + } 564 + } 565 + } 566 + } 567 + } 568 + } 569 + 570 + #[allow(clippy::useless_format)] 571 + #[component] 572 + fn ProfileTabButton(tab: ProfileTab, active_tab: Signal<ProfileTab>) -> Element { 573 + let is_active = *active_tab.read() == tab; 574 + let class = if is_active { "tab is-active" } else { "tab" }; 575 + 576 + rsx! { 577 + button { 578 + class, 579 + role: "tab", 580 + aria_selected: "{is_active}", 581 + onclick: move |_| active_tab.set(tab), 582 + "{tab.label()}" 583 + } 584 + } 585 + } 586 + 587 + #[allow(clippy::useless_format)] 588 + #[component] 589 + fn ProfileThingsGrid(things: Vec<library::ThingViewBasic>) -> Element { 590 + let things_result: Result<&[library::ThingViewBasic], String> = Ok(things.as_slice()); 591 + let feed = profile_feed_state(false, Some(&things_result)); 592 + 593 + rsx! { 594 + section { class: "profile-things-grid browse-results-grid", aria_label: "Profile things", 595 + match feed { 596 + ProfileFeedState::Loading => rsx! { 597 + for index in 0..6 { 598 + div { key: "skeleton-{index}", class: "state-card loading-state", 599 + span { class: "status-pill", "Loading" } 600 + div { class: "skeleton-line skeleton-wide" } 601 + div { class: "skeleton-line" } 602 + div { class: "skeleton-box" } 603 + } 604 + } 605 + }, 606 + ProfileFeedState::Error(error) => rsx! { 607 + div { class: "state-card error-state", 608 + p { "{error}" } 609 + } 610 + }, 611 + ProfileFeedState::Empty => rsx! { 612 + div { class: "state-card empty-state", 613 + span { class: "status-pill status-muted", "Empty" } 614 + h2 { "No things yet" } 615 + p { "This maker has not published any Polymodel things yet." } 616 + } 617 + }, 618 + ProfileFeedState::Populated(items) => rsx! { 619 + for thing in items { 620 + ProfileThingCard { key: "{thing.uri}", thing } 621 + } 622 + }, 623 + } 624 + } 625 + } 626 + } 627 + 628 + #[allow(clippy::useless_format)] 629 + #[component] 630 + fn ProfileThingCard(thing: library::ThingViewBasic) -> Element { 631 + let detail_href = thing_detail_href(&thing.uri); 632 + let creator_href = profile_href(&thing.author); 633 + rsx! { ThingCard { thing, detail_href, creator_href } } 634 + } 635 + 636 + #[allow(clippy::useless_format)] 637 + #[component] 638 + fn ProfileAbout(header: ProfileHeader) -> Element { 639 + let description = header 640 + .description 641 + .as_ref() 642 + .map(SmolStr::as_str) 643 + .map(ToString::to_string); 644 + let pronouns = header 645 + .pronouns 646 + .as_ref() 647 + .map(SmolStr::as_str) 648 + .map(ToString::to_string); 649 + let links = header.links.clone(); 650 + let default_license = header 651 + .default_license 652 + .as_ref() 653 + .map(SmolStr::as_str) 654 + .map(ToString::to_string); 655 + let thing_count_label = count_label(header.thing_count, "thing"); 656 + let total_likes_label = count_label(header.total_likes, "like"); 657 + let is_bsky_fallback = header.is_bsky_fallback; 658 + 659 + rsx! { 660 + section { class: "profile-about blueprint-panel", 661 + if let Some(description) = &description { 662 + p { class: "product-lede", "{description}" } 663 + } else { 664 + p { class: "product-lede profile-muted", "No description is published yet." } 665 + } 666 + dl { class: "profile-about-meta", 667 + if let Some(pronouns) = &pronouns { 668 + div { dt { "Pronouns" } dd { "{pronouns}" } } 669 + } 670 + if let Some(license) = &default_license { 671 + div { dt { "Default license" } dd { "{license}" } } 672 + } 673 + if let Some(label) = &thing_count_label { 674 + div { dt { "Things published" } dd { "{label}" } } 675 + } 676 + if let Some(label) = &total_likes_label { 677 + div { dt { "Total likes" } dd { "{label}" } } 678 + } 679 + } 680 + if let Some(links) = &links { 681 + if !links.is_empty() { 682 + div { class: "profile-about-links", 683 + h2 { "Links" } 684 + ul { 685 + for link in links { 686 + li { a { href: "{link.as_ref()}", "{link.as_ref()}" } } 687 + } 688 + } 689 + } 690 + } 691 + } 692 + if is_bsky_fallback { 693 + p { class: "profile-about-fallback-note", 694 + "This actor does not have a Polymodel profile yet. Showing available Bluesky identity." 695 + } 696 + } 697 + } 698 + } 699 + } 700 + 701 + #[cfg(test)] 702 + mod tests { 703 + use super::*; 704 + use crate::thing_card::thing_card_fixtures; 705 + use jacquard_common::types::string::{Did, Handle}; 706 + use polymodel_api::app_bsky::actor::ProfileViewDetailed; 707 + use polymodel_api::space_polymodel::actor::ProfileView; 708 + use polymodel_api::space_polymodel::actor::get_profile::GetProfileOutput; 709 + use polymodel_api::space_polymodel::library::get_author_things::GetAuthorThingsOutput; 710 + 711 + fn polymodel_view() -> ProfileView { 712 + ProfileView { 713 + avatar: Some(UriValue::new_owned("https://cdn.example/alice.jpg").unwrap()), 714 + default_license: Some("CC-BY-4.0".into()), 715 + indexed_at: None, 716 + description: Some("Maker of enclosures".into()), 717 + did: Did::new_owned("did:plc:alice").unwrap(), 718 + display_name: Some("Alice Maker".into()), 719 + handle: "alice.test".into(), 720 + pronouns: Some("she/her".into()), 721 + thing_count: Some(5), 722 + extra_data: None, 723 + links: Some(vec![UriValue::new_owned("https://alice.example").unwrap()]), 724 + printers: None, 725 + record: jacquard::Data::Null, 726 + total_likes: Some(42), 727 + } 728 + } 729 + 730 + fn bsky_view() -> ProfileViewDetailed { 731 + ProfileViewDetailed { 732 + associated: None, 733 + avatar: None, 734 + banner: None, 735 + created_at: None, 736 + debug: None, 737 + description: Some("Bluesky-only user".into()), 738 + did: Did::new_owned("did:plc:bob").unwrap(), 739 + display_name: Some("Bob".into()), 740 + followers_count: Some(100), 741 + follows_count: Some(50), 742 + handle: Handle::new_owned("bob.test").unwrap(), 743 + indexed_at: None, 744 + joined_via_starter_pack: None, 745 + labels: None, 746 + pinned_post: None, 747 + posts_count: Some(200), 748 + pronouns: None, 749 + status: None, 750 + verification: None, 751 + viewer: None, 752 + website: None, 753 + extra_data: None, 754 + } 755 + } 756 + 757 + fn fixture_actor(did: &str, handle: &str) -> library::Actor { 758 + library::Actor { 759 + avatar: None, 760 + description: None, 761 + did: Did::new_owned(did).unwrap(), 762 + display_name: None, 763 + follower_count: None, 764 + following_count: None, 765 + handle: Handle::new_owned(handle).unwrap(), 766 + pronouns: None, 767 + thing_count: None, 768 + viewer: None, 769 + extra_data: None, 770 + } 771 + } 772 + 773 + #[test] 774 + fn profile_state_distinguishes_loading_invalid_error_and_populated() { 775 + assert_eq!(profile_state(true, None), ProfileState::Loading); 776 + assert_eq!(profile_state(false, None), ProfileState::Loading); 777 + 778 + let invalid = profile_state(false, Some(&Err("Invalid actor: bad".to_string()))); 779 + assert!(matches!(invalid, ProfileState::InvalidActor(_))); 780 + 781 + let error = profile_state(false, Some(&Err("boom".to_string()))); 782 + assert!(matches!(error, ProfileState::Error(_))); 783 + 784 + let data = ProfileData { 785 + header: profile_header(&ProfileViewUnion::ProfileView(Box::new(polymodel_view()))), 786 + things: vec![], 787 + }; 788 + let populated = profile_state(false, Some(&Ok(data))); 789 + assert!(matches!(populated, ProfileState::Populated(_))); 790 + } 791 + 792 + #[test] 793 + fn profile_feed_state_classifies_loading_empty_error_populated() { 794 + assert_eq!(profile_feed_state(true, None), ProfileFeedState::Loading); 795 + assert_eq!(profile_feed_state(false, None), ProfileFeedState::Loading); 796 + 797 + let empty: Result<&[library::ThingViewBasic], String> = Ok(&[]); 798 + assert_eq!( 799 + profile_feed_state(false, Some(&empty)), 800 + ProfileFeedState::Empty 801 + ); 802 + 803 + let err: Result<&[library::ThingViewBasic], String> = Err("feed down".to_string()); 804 + assert_eq!( 805 + profile_feed_state(false, Some(&err)), 806 + ProfileFeedState::Error("feed down".to_string()) 807 + ); 808 + 809 + let fixtures = thing_card_fixtures(); 810 + let populated = profile_feed_state(false, Some(&Ok(&fixtures[..]))); 811 + match populated { 812 + ProfileFeedState::Populated(items) => assert_eq!(items.len(), fixtures.len()), 813 + other => panic!("expected populated, got {other:?}"), 814 + } 815 + } 816 + 817 + #[test] 818 + fn profile_href_prefers_handle_then_did() { 819 + let actor = fixture_actor("did:plc:alice", "alice.test"); 820 + assert_eq!(profile_href(&actor).as_deref(), Some("/profile/alice.test")); 821 + } 822 + 823 + #[test] 824 + fn header_extracts_polymodel_fields() { 825 + let header = profile_header(&ProfileViewUnion::ProfileView(Box::new(polymodel_view()))); 826 + assert_eq!(header.did.as_ref(), "did:plc:alice"); 827 + assert_eq!(header.handle.as_ref(), "alice.test"); 828 + assert_eq!( 829 + header.display_name.as_ref().map(SmolStr::as_str), 830 + Some("Alice Maker") 831 + ); 832 + assert_eq!(header.thing_count, Some(5)); 833 + assert_eq!(header.total_likes, Some(42)); 834 + assert_eq!( 835 + header.default_license.as_ref().map(SmolStr::as_str), 836 + Some("CC-BY-4.0") 837 + ); 838 + assert!(!header.is_bsky_fallback); 839 + assert!(header.avatar.is_some()); 840 + assert!(header.links.is_some()); 841 + } 842 + 843 + #[test] 844 + fn header_extracts_bsky_fallback_fields() { 845 + let header = profile_header(&ProfileViewUnion::ProfileViewDetailed( 846 + Box::new(bsky_view()), 847 + )); 848 + assert_eq!(header.did.as_ref(), "did:plc:bob"); 849 + assert_eq!(header.handle.as_ref(), "bob.test"); 850 + assert_eq!( 851 + header.display_name.as_ref().map(SmolStr::as_str), 852 + Some("Bob") 853 + ); 854 + assert!(header.is_bsky_fallback); 855 + // Polymodel-specific fields degrade to None, not fake values. 856 + assert_eq!(header.thing_count, None); 857 + assert_eq!(header.total_likes, None); 858 + assert_eq!(header.default_license, None); 859 + assert_eq!(header.links, None); 860 + } 861 + 862 + #[test] 863 + fn is_own_compares_did_without_stringify() { 864 + let header = profile_header(&ProfileViewUnion::ProfileView(Box::new(polymodel_view()))); 865 + let own_did = Did::new_owned("did:plc:alice").unwrap(); 866 + let other_did = Did::new_owned("did:plc:eve").unwrap(); 867 + assert_eq!(header.did, own_did); 868 + assert_ne!(header.did, other_did); 869 + } 870 + 871 + #[test] 872 + fn optional_smolstr_trims_and_filters_empty() { 873 + let trimmed: Option<&str> = Some(" hi "); 874 + assert_eq!(optional_smolstr(&trimmed), Some("hi".into())); 875 + let blank: Option<&str> = Some(" "); 876 + assert_eq!(optional_smolstr(&blank), None); 877 + let none: Option<&str> = None; 878 + assert_eq!(optional_smolstr(&none), None); 879 + } 880 + 881 + #[test] 882 + fn get_profile_output_decodes_polymodel_arm() { 883 + let json = r#"{ 884 + "$type":"space.polymodel.actor.defs#profileView", 885 + "did":"did:plc:alice", 886 + "handle":"alice.test", 887 + "record":null, 888 + "displayName":"Alice Maker", 889 + "description":"Maker of things", 890 + "pronouns":"she/her", 891 + "thingCount":5, 892 + "totalLikes":42, 893 + "defaultLicense":"CC-BY-4.0" 894 + }"#; 895 + let output: GetProfileOutput = serde_json::from_str(json).unwrap(); 896 + match output.value { 897 + ProfileViewUnion::ProfileView(view) => { 898 + assert_eq!(view.did.as_ref(), "did:plc:alice"); 899 + assert_eq!(view.handle.as_str(), "alice.test"); 900 + assert_eq!( 901 + view.display_name.as_ref().map(|s| s.as_str()), 902 + Some("Alice Maker") 903 + ); 904 + assert_eq!(view.thing_count, Some(5)); 905 + assert_eq!(view.total_likes, Some(42)); 906 + } 907 + _ => panic!("expected ProfileView arm"), 908 + } 909 + } 910 + 911 + #[test] 912 + fn get_profile_output_decodes_bsky_fallback_arm() { 913 + let json = r#"{ 914 + "$type":"app.bsky.actor.defs#profileViewDetailed", 915 + "did":"did:plc:bob", 916 + "handle":"bob.test", 917 + "displayName":"Bob", 918 + "description":"Bluesky user", 919 + "followersCount":100 920 + }"#; 921 + let output: GetProfileOutput = serde_json::from_str(json).unwrap(); 922 + match output.value { 923 + ProfileViewUnion::ProfileViewDetailed(view) => { 924 + assert_eq!(view.did.as_ref(), "did:plc:bob"); 925 + assert_eq!(view.handle.as_str(), "bob.test"); 926 + assert_eq!(view.display_name.as_ref().map(|s| s.as_str()), Some("Bob")); 927 + } 928 + _ => panic!("expected ProfileViewDetailed arm"), 929 + } 930 + } 931 + 932 + #[test] 933 + fn get_author_things_output_decodes_feed_items() { 934 + let json = r#"{ 935 + "items":[ 936 + {"thing":{ 937 + "uri":"at://did:plc:alice/space.polymodel.library.thing/thing1", 938 + "cid":"bafyrei1", 939 + "author":{"did":"did:plc:alice","handle":"alice.test"}, 940 + "name":"Thing one", 941 + "modelCount":1, 942 + "partCount":2, 943 + "likeCount":3, 944 + "saveCount":1, 945 + "indexedAt":"2026-06-21T12:00:00Z", 946 + "viewer":{} 947 + }} 948 + ] 949 + }"#; 950 + let output: GetAuthorThingsOutput = serde_json::from_str(json).unwrap(); 951 + let things: Vec<_> = output.value.items.into_iter().map(|i| i.thing).collect(); 952 + assert_eq!(things.len(), 1); 953 + assert_eq!(things[0].name.as_str(), "Thing one"); 954 + assert_eq!(things[0].author.handle.as_ref(), "alice.test"); 955 + assert_eq!(things[0].model_count, 1); 956 + } 957 + }
+13 -13
src/session.rs
··· 32 32 } 33 33 } 34 34 } 35 + } 35 36 37 + impl AuthenticatedIdentity { 36 38 pub fn label(&self) -> SmolStr { 37 - match self { 38 - Self::Anonymous => SmolStr::new_static("Signed out"), 39 - Self::Authenticated(identity) => identity 40 - .display_name 41 - .clone() 42 - .unwrap_or_else(|| identity.handle.as_str().to_smolstr()), 43 - } 39 + self.display_name 40 + .clone() 41 + .unwrap_or_else(|| self.handle.as_str().to_smolstr()) 44 42 } 45 43 } 46 44 ··· 105 103 }; 106 104 107 105 let identity = SessionIdentity::from_get_session(output); 108 - assert!(matches!(identity, SessionIdentity::Authenticated(_))); 109 - assert_eq!(identity.label(), "Alice Maker"); 106 + match identity { 107 + SessionIdentity::Authenticated(auth) => assert_eq!(auth.label(), "Alice Maker"), 108 + _ => panic!("expected authenticated identity"), 109 + } 110 110 } 111 111 #[test] 112 112 fn authenticated_get_session_retains_avatar() { ··· 157 157 #[test] 158 158 fn get_session_json_decodes_authenticated_union_arm() { 159 159 let output: GetSessionOutput = serde_json::from_str(r#"{"$type":"space.polymodel.actor.getSession#sessionAuthenticated","authenticated":true,"did":"did:plc:alice","handle":"alice.test","profile":{"avatar":null,"description":null,"did":"did:plc:alice","displayName":"Alice Maker","handle":"alice.test","record":null}}"#).unwrap(); 160 - assert_eq!( 161 - SessionIdentity::from_get_session(output).label(), 162 - "Alice Maker" 163 - ); 160 + match SessionIdentity::from_get_session(output) { 161 + SessionIdentity::Authenticated(auth) => assert_eq!(auth.label(), "Alice Maker"), 162 + _ => panic!("expected authenticated identity"), 163 + } 164 164 } 165 165 }
+42 -21
src/shell.rs
··· 1 1 use dioxus::prelude::*; 2 2 3 + use crate::Route; 3 4 use crate::auth::{ToastState, broadcast_sign_out, logout_url}; 4 5 use crate::session::SessionIdentity; 5 6 7 + #[allow(clippy::useless_format)] 6 8 #[component] 7 - pub(crate) fn SessionControl(session: Signal<SessionIdentity>, toasts: Signal<ToastState>) -> Element { 9 + pub(crate) fn SessionControl( 10 + session: Signal<SessionIdentity>, 11 + toasts: Signal<ToastState>, 12 + ) -> Element { 8 13 let identity = session.read().clone(); 9 14 10 15 match identity { 11 16 SessionIdentity::Anonymous => rsx! { 12 - form { 13 - class: "session-control", 14 - action: "/oauth/start", 15 - method: "get", 16 - input { 17 + form { 18 + class: "session-control", 19 + action: "/oauth/start", 20 + method: "get", 21 + input { r#type: "hidden", name: "return_to", value: "/" }, 22 + input { 17 23 r#type: "text", 18 24 name: "identifier", 19 25 placeholder: "handle.test", ··· 23 29 } 24 30 }, 25 31 SessionIdentity::Authenticated(identity) => { 26 - let label = SessionIdentity::Authenticated(identity).label(); 32 + let label = identity.label().to_string(); 33 + let handle = identity.handle.as_str().to_string(); 34 + let avatar_url = identity.avatar.as_ref().map(|u| u.as_ref().to_string()); 35 + let initial = label.chars().next().unwrap_or('?').to_ascii_uppercase(); 27 36 rsx! { 28 - div { class: "session-control session-control-authenticated", 29 - span { class: "status-pill", "{label}" } 30 - form { 31 - method: "post", 32 - action: "{logout_url()}", 33 - button { 34 - class: "button button-secondary", 35 - r#type: "submit", 36 - onclick: move |_| { 37 - broadcast_sign_out(); 38 - session.set(SessionIdentity::Anonymous); 39 - toasts.write().push("Signing out…"); 40 - }, 41 - "Sign out" 37 + details { class: "account-menu", 38 + summary { class: "account-trigger", aria_label: "Account menu for {label}", 39 + if let Some(url) = &avatar_url { 40 + img { class: "session-avatar", src: "{url}", alt: "{label}", loading: "lazy" } 41 + } else { 42 + span { class: "session-avatar session-avatar-fallback", aria_hidden: "true", "{initial}" } 43 + } 44 + } 45 + div { class: "account-menu-panel blueprint-panel", 46 + div { class: "account-menu-identity", 47 + strong { "{label}" } 48 + span { class: "account-menu-handle", "@{handle}" } 49 + } 50 + Link { class: "account-menu-item", to: Route::ProfileSelf {}, "Profile" } 51 + form { class: "account-menu-form", method: "post", action: "{logout_url()}", 52 + button { 53 + class: "account-menu-item account-menu-signout", 54 + r#type: "submit", 55 + onclick: move |_| { 56 + broadcast_sign_out(); 57 + session.set(SessionIdentity::Anonymous); 58 + toasts.write().push("Signing out…"); 59 + }, 60 + "Sign out" 61 + } 42 62 } 43 63 } 44 64 } ··· 47 67 } 48 68 } 49 69 70 + #[allow(clippy::useless_format)] 50 71 #[component] 51 72 pub(crate) fn ToastShelf(toasts: Signal<ToastState>) -> Element { 52 73 let items = toasts.read().items().to_vec();
+12 -2
src/thing_card.rs
··· 116 116 117 117 #[allow(clippy::useless_format)] 118 118 #[component] 119 - pub fn ThingCard(thing: library::ThingViewBasic, detail_href: Option<String>) -> Element { 119 + pub fn ThingCard( 120 + thing: library::ThingViewBasic, 121 + detail_href: Option<String>, 122 + #[props(default)] creator_href: Option<String>, 123 + ) -> Element { 120 124 let title = thing_title(&thing); 121 125 let creator = creator_label(&thing.author); 122 126 let media = select_card_media(thing.cover.as_deref(), thing.previews.as_deref(), title); ··· 155 159 "{title}" 156 160 } 157 161 } 158 - p { class: "thing-card-creator", "by {creator}" } 162 + p { class: "thing-card-creator", 163 + if let Some(href) = &creator_href { 164 + a { class: "thing-card-creator-link", href: "{href}", "by {creator}" } 165 + } else { 166 + "by {creator}" 167 + } 168 + } 159 169 } 160 170 if let Some(tags) = hydrated_tags(&thing) { 161 171 div { class: "thing-card-chips", aria_label: "Hydrated display tags",
+42 -11
src/thing_detail.rs
··· 8 8 9 9 use crate::Route; 10 10 use crate::client::PolymodelClient; 11 + use crate::profile::profile_href; 11 12 use crate::viewer::{AssetViewer, ViewerFallbackImage, demo_models}; 12 13 13 14 #[derive(Clone, Debug, PartialEq, Eq)] ··· 45 46 record: library::part::Part, 46 47 } 47 48 48 - fn detail_state( 49 - pending: bool, 50 - result: Option<&Result<ThingDetailData, String>>, 51 - ) -> DetailState { 49 + fn detail_state(pending: bool, result: Option<&Result<ThingDetailData, String>>) -> DetailState { 52 50 if pending && result.is_none() { 53 51 return DetailState::Loading; 54 52 } ··· 118 116 .into_iter() 119 117 .map(|view| model_record(&view).map(|record| ModelDetailSummary { view, record })) 120 118 .collect::<Result<Vec<_>, _>>()?; 121 - let selected_uri = selected_model_uri.or_else(|| models.first().map(|model| model.view.uri.clone())); 119 + let selected_uri = 120 + selected_model_uri.or_else(|| models.first().map(|model| model.view.uri.clone())); 122 121 let selected_model = if let Some(uri) = selected_uri { 123 122 let response = client 124 123 .send(library::get_model::GetModel::new().uri(uri).build()) ··· 161 160 } 162 161 } 163 162 163 + #[allow(clippy::useless_format)] 164 164 #[component] 165 165 pub(crate) fn ThingDetail(repo: String, rkey: String) -> Element { 166 166 let client = use_context::<PolymodelClient>(); ··· 192 192 } 193 193 } 194 194 195 + #[allow(clippy::useless_format)] 195 196 #[component] 196 197 fn DetailLoading() -> Element { 197 198 rsx! { ··· 206 207 } 207 208 } 208 209 210 + #[allow(clippy::useless_format)] 209 211 #[component] 210 212 fn DetailError(title: String, message: String) -> Element { 211 213 rsx! { ··· 222 224 223 225 #[allow(clippy::useless_format)] 224 226 #[component] 225 - fn DetailPopulated(data: ThingDetailData, mut selected_model_uri: Signal<Option<AtUri>>) -> Element { 227 + fn DetailPopulated( 228 + data: ThingDetailData, 229 + mut selected_model_uri: Signal<Option<AtUri>>, 230 + ) -> Element { 226 231 let ThingDetailData { 227 232 thing, 228 233 thing_record, ··· 261 266 div { class: "thing-detail-title-row", 262 267 div { 263 268 h1 { "{title}" } 264 - p { class: "thing-detail-creator", "by {creator}" } 269 + p { class: "thing-detail-creator", 270 + if let Some(href) = profile_href(&thing.author) { 271 + a { class: "thing-detail-creator-link", href: "{href}", "by {creator}" } 272 + } else { 273 + "by {creator}" 274 + } 275 + } 265 276 } 266 277 dl { class: "thing-detail-stats", aria_label: "Thing stats", 267 278 StatItem { label: "Models".to_string(), value: model_count } ··· 348 359 } 349 360 } 350 361 362 + #[allow(clippy::useless_format)] 351 363 #[component] 352 364 fn DetailPanel(title: String, children: Element) -> Element { 353 365 rsx! { ··· 360 372 } 361 373 } 362 374 375 + #[allow(clippy::useless_format)] 363 376 #[component] 364 377 fn StatItem(label: String, value: String) -> Element { 365 378 rsx! { ··· 370 383 } 371 384 } 372 385 386 + #[allow(clippy::useless_format)] 373 387 #[component] 374 - fn InstructionList(instructions: Option<Vec<jacquard_common::DefaultStr>>, empty: String) -> Element { 388 + fn InstructionList( 389 + instructions: Option<Vec<jacquard_common::DefaultStr>>, 390 + empty: String, 391 + ) -> Element { 375 392 let instructions = instructions.unwrap_or_default(); 376 393 let items = instructions 377 394 .iter() ··· 391 408 } 392 409 } 393 410 411 + #[allow(clippy::useless_format)] 394 412 #[component] 395 - fn ModelRow(model: ModelDetailSummary, selected: bool, onselect: EventHandler<MouseEvent>) -> Element { 413 + fn ModelRow( 414 + model: ModelDetailSummary, 415 + selected: bool, 416 + onselect: EventHandler<MouseEvent>, 417 + ) -> Element { 396 418 let name = clean_text(Some(model.view.name.as_ref())) 397 419 .unwrap_or("Untitled model") 398 420 .to_string(); 399 421 let summary = clean_text(model.view.summary.as_ref().map(AsRef::as_ref)) 400 422 .or_else(|| clean_text(model.record.summary.as_ref().map(AsRef::as_ref))) 401 423 .map(ToString::to_string); 402 - let class = if selected { "model-row is-selected" } else { "model-row" }; 424 + let class = if selected { 425 + "model-row is-selected" 426 + } else { 427 + "model-row" 428 + }; 403 429 let license = model 404 430 .record 405 431 .license ··· 420 446 } 421 447 } 422 448 449 + #[allow(clippy::useless_format)] 423 450 #[component] 424 451 fn SelectedModelPanel(model: library::ModelView, record: library::model::Model) -> Element { 425 452 let name = clean_text(Some(model.name.as_ref())) ··· 458 485 } 459 486 } 460 487 488 + #[allow(clippy::useless_format)] 461 489 #[component] 462 490 fn PartsPanel(parts: Vec<PartDetail>) -> Element { 463 491 rsx! { ··· 473 501 } 474 502 } 475 503 504 + #[allow(clippy::useless_format)] 476 505 #[component] 477 506 fn PartRow(position: usize, part: PartDetail) -> Element { 478 507 let name = clean_text(Some(part.view.name.as_ref())) ··· 526 555 } 527 556 } 528 557 558 + #[allow(clippy::useless_format)] 529 559 #[component] 530 560 fn BlueprintPlaceholder(label: String, detail: String) -> Element { 531 561 rsx! { ··· 573 603 } 574 604 575 605 fn actor_label(actor: &library::Actor) -> &str { 576 - clean_text(actor.display_name.as_ref().map(AsRef::as_ref)).unwrap_or_else(|| actor.handle.as_ref()) 606 + clean_text(actor.display_name.as_ref().map(AsRef::as_ref)) 607 + .unwrap_or_else(|| actor.handle.as_ref()) 577 608 } 578 609 579 610 fn clean_text(value: Option<&str>) -> Option<&str> {
+5 -1
src/viewer.rs
··· 1448 1448 .expect("mount should succeed"); 1449 1449 assert!(lifecycle.has_active_session()); 1450 1450 assert!(lifecycle.active_mut().is_some()); 1451 - assert_eq!(events.get(), 2, "initial mount must also draw the first frame"); 1451 + assert_eq!( 1452 + events.get(), 1453 + 2, 1454 + "initial mount must also draw the first frame" 1455 + ); 1452 1456 assert_eq!(lifecycle.mounts(), 1); 1453 1457 1454 1458 // Resize forwards to the active renderer.