atproto Thingiverse but good
10

Configure Feed

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

PM-43: lean publish wizard + server-side drafts

Orual (Jun 29, 2026, 7:06 PM EDT) 5a316b81 28acf361

+385 -242
+1
AGENTS.md
··· 93 93 - **Only `space.polymodel.*` is indexed.** Other records are proxied without local persistence. 94 94 - **Upstream errors are forwarded faithfully.** The passthrough surface forwards the real upstream HTTP status + error body verbatim (`RecordNotFound` stays HTTP 400, `InvalidSwap` stays 409, 429/5xx unchanged); only genuine transport/internal failures become 500. 95 95 - **The proxy allowlist is structural.** Non-`space.polymodel.*` methods are served only for the direct repo record operations (`createRecord`, `putRecord`, `deleteRecord`, `getRecord`, `listRecords`, `describeRepo`, `uploadBlob`) plus `identity.resolveHandle` and `actor.getProfile`, dispatched over their generated typed request structs through the OAuth session agent's signing send path. Batch/admin repo methods (`applyWrites`, `importRepo`, `listMissingBlobs`) and every other NSID have no route and 404. 96 + - **Server-side drafts are app-internal, not federated.** The publish wizard (PM-43) saves in-progress compositions as **owner-scoped server-side drafts** via app-internal authenticated appview endpoints (under `/app/drafts*` + `/app/images`, reusing `ExtractOAuthSession` + `authenticated_did`) backed by the `drafts` table — *not* ATProto records and *not* new federated lexicons. A draft stores an all-optional `DraftThingInput` payload; publishing assembles it to a real `ThingInput`, runs `publish_composition` under `state.write_lock`, and deletes the draft **only on success**. This is the **first browser-side authenticated mutation** in the app — the wasm `PolymodelClient` relies on the same-origin OAuth session cookie (no token handling). **Accepted limitation:** `stageFile` and the app-internal image-upload endpoint write blobs to the actor's PDS *during drafting* (only the records are deferred to publish); unreferenced blobs are PDS-GC'd. Deferring blob upload (server-side blob staging) + permissioned draft spaces is tracked in **PM-58**. 96 97 97 98 ## Agent behavior 98 99
+15
Dioxus.toml
··· 1 + [application] 2 + name = "polymodel" 3 + 4 + # Restrict the dev-server file watcher to source + styling. Without this, dx 5 + # watches the whole crate directory and fires hot-patches/reloads on the 6 + # firehose's continuous writes to ./data (SQLite projection) and ./hydrant.db 7 + # (fjall store), which silently reset in-progress UI state (e.g. the publish 8 + # wizard). Both dirs are gitignored but the dx 0.7 watcher does not honor 9 + # .gitignore, so the watch set must be scoped explicitly. 10 + [web.watcher] 11 + watch_path = ["src", "assets"] 12 + reload_html = true 13 + # Serve the root document for unknown routes so client-side router deep links 14 + # (e.g. /publish, /:repo/thing/:rkey) resolve on direct navigation. 15 + index_on_404 = true
+9
assets/styling/publish.css
··· 128 128 } 129 129 130 130 .field-input { 131 + width: 100%; 131 132 min-height: 2.1rem; 132 133 padding: 0.5rem 0.65rem; 133 134 border: 1px solid var(--color-border); ··· 297 298 aspect-ratio: 4 / 3; 298 299 border: 1px solid var(--color-border); 299 300 border-radius: var(--radius-panel); 301 + overflow: hidden; 302 + } 303 + 304 + .wizard-image-img { 305 + display: block; 306 + width: 100%; 307 + height: 100%; 308 + object-fit: cover; 300 309 } 301 310 302 311 .wizard-image-alt {
+1 -1
crates/polymodel-api/src/com_atproto/repo/import_repo.rs
··· 47 47 where 48 48 Self: Serialize, 49 49 { 50 - Ok(buffer.copy_from_slice(self.body.as_ref())) 50 + Ok(buffer.extend_from_slice(self.body.as_ref())) 51 51 } 52 52 fn decode_body<'de>( 53 53 body: &'de [u8],
+1 -1
crates/polymodel-api/src/com_atproto/repo/upload_blob.rs
··· 57 57 where 58 58 Self: Serialize, 59 59 { 60 - Ok(buffer.copy_from_slice(self.body.as_ref())) 60 + Ok(buffer.extend_from_slice(self.body.as_ref())) 61 61 } 62 62 fn decode_body<'de>( 63 63 body: &'de [u8],
+1 -1
crates/polymodel-api/src/space_polymodel/library/stage_file.rs
··· 138 138 where 139 139 Self: Serialize, 140 140 { 141 - Ok(buffer.copy_from_slice(self.body.as_ref())) 141 + Ok(buffer.extend_from_slice(self.body.as_ref())) 142 142 } 143 143 fn decode_body<'de>( 144 144 body: &'de [u8],
+2 -3
e2e/tests/publish.spec.ts
··· 10 10 test('signed-out /publish shows the sign-in callout, not the wizard', async ({ page }) => { 11 11 await page.goto('/publish'); 12 12 13 - await expect(page.getByRole('heading', { name: 'Publish a thing' })).toBeVisible(); 14 - await expect(page.getByRole('heading', { name: 'Sign in to publish' })).toBeVisible(); 13 + await expect(page.getByRole('heading', { name: 'Share your models' })).toBeVisible(); 15 14 const callout = page.locator('section[aria-label="Sign in to publish"]'); 16 15 await expect(callout.getByRole('button', { name: 'Sign in' })).toBeVisible(); 17 16 18 17 // The signed-out surface must not expose the wizard stepper or publish action. 19 18 await expect(page.getByRole('navigation', { name: 'Publish steps' })).toHaveCount(0); 20 - await expect(page.getByRole('button', { name: 'Publish to your PDS' })).toHaveCount(0); 19 + await expect(page.getByRole('button', { name: 'Publish', exact: true })).toHaveCount(0); 21 20 }); 22 21 23 22 test('signed-out publish callout returns the user to /publish after sign-in', async ({ page }) => {
migrations/005_drafts.sql migrations/006_drafts.sql
+59 -54
src/appview/views.rs
··· 247 247 Some(r) => (r.handle, r.display_name, r.avatar_json), 248 248 None => (None, None, None), 249 249 }; 250 + let did_typed = did_of(did)?; 251 + // Read-your-own-writes / not-yet-indexed authors: fall back to resolving the 252 + // handle (local cache, then DID document) instead of 500-ing the read. 253 + let handle = match handle { 254 + Some(h) => h, 255 + None => resolve_handle(state, &did_typed).await?, 256 + }; 250 257 Ok(Actor { 251 - did: did_of(did)?, 252 - handle: handle_of( 253 - handle.ok_or_else(|| internal(format!("missing handle for actor {did}")))?, 254 - )?, 258 + did: did_typed, 259 + handle: handle_of(handle)?, 255 260 display_name: display_name.map(s), 256 261 avatar: avatar_url(did, avatar_json.as_deref())?, 257 262 description: None, ··· 935 940 engagement / (age_hours + 2.0).powf(1.5) 936 941 } 937 942 938 - #[cfg(test)] 939 - mod hot_tests { 940 - use super::*; 941 - 942 - fn row(rkey: &str, uri: &str) -> ThingRow { 943 - ThingRow { 944 - did: "did:plc:test".to_owned(), 945 - rkey: rkey.to_owned(), 946 - uri: uri.to_owned(), 947 - cid: "cid".to_owned(), 948 - name: "test".to_owned(), 949 - summary: None, 950 - license: "CC-BY-4.0".to_owned(), 951 - tags_json: None, 952 - cover_json: None, 953 - derived_from_uri: None, 954 - created_at: 0, 955 - indexed_at: 0, 956 - record_json: None, 957 - like_count: 0, 958 - save_count: 0, 959 - model_count: 0, 960 - part_count: 0, 961 - } 962 - } 963 - 964 - #[test] 965 - fn positive_scan_bound_stops_only_when_unseen_scores_cannot_enter_page() { 966 - let page = vec![ 967 - (10.0, row("3c", "at://did:plc:c/thing/3c")), 968 - (9.0, row("3b", "at://did:plc:b/thing/3b")), 969 - ]; 970 - assert!(can_stop_positive_scan(&page, 1, 1)); 971 - assert!(!can_stop_positive_scan(&page, 1, 100)); 972 - assert!(!can_stop_positive_scan(&page[..1], 1, 1)); 973 - } 974 - 975 - #[test] 976 - fn rejects_malformed_hot_cursor_score_bits() { 977 - let bits = |s: f64| format!("{:016x}", s.to_bits()); 978 - let cursor = |score_bits: String| format!("hot:v1:100:{score_bits}:rkey:at://did:plc:x/x"); 979 - assert!(parse_hot_cursor(Some(&cursor(bits(f64::NAN))), 0).is_err()); 980 - assert!(parse_hot_cursor(Some(&cursor(bits(f64::INFINITY))), 0).is_err()); 981 - assert!(parse_hot_cursor(Some(&cursor(bits(f64::NEG_INFINITY))), 0).is_err()); 982 - assert!(parse_hot_cursor(Some(&cursor(bits(-1.0))), 0).is_err()); 983 - assert!(parse_hot_cursor(Some(&cursor(bits(0.0))), 0).is_ok()); 984 - assert!(parse_hot_cursor(Some(&cursor(bits(12.5))), 0).is_ok()); 985 - } 986 - } 987 - 988 943 pub(super) async fn search_things( 989 944 state: &AppState, 990 945 q: &str, ··· 1218 1173 extra_data: None, 1219 1174 })) 1220 1175 } 1176 + 1177 + #[cfg(test)] 1178 + mod hot_tests { 1179 + use super::*; 1180 + 1181 + fn row(rkey: &str, uri: &str) -> ThingRow { 1182 + ThingRow { 1183 + did: "did:plc:test".to_owned(), 1184 + rkey: rkey.to_owned(), 1185 + uri: uri.to_owned(), 1186 + cid: "cid".to_owned(), 1187 + name: "test".to_owned(), 1188 + summary: None, 1189 + license: "CC-BY-4.0".to_owned(), 1190 + tags_json: None, 1191 + cover_json: None, 1192 + derived_from_uri: None, 1193 + created_at: 0, 1194 + indexed_at: 0, 1195 + record_json: None, 1196 + like_count: 0, 1197 + save_count: 0, 1198 + model_count: 0, 1199 + part_count: 0, 1200 + } 1201 + } 1202 + 1203 + #[test] 1204 + fn positive_scan_bound_stops_only_when_unseen_scores_cannot_enter_page() { 1205 + let page = vec![ 1206 + (10.0, row("3c", "at://did:plc:c/thing/3c")), 1207 + (9.0, row("3b", "at://did:plc:b/thing/3b")), 1208 + ]; 1209 + assert!(can_stop_positive_scan(&page, 1, 1)); 1210 + assert!(!can_stop_positive_scan(&page, 1, 100)); 1211 + assert!(!can_stop_positive_scan(&page[..1], 1, 1)); 1212 + } 1213 + 1214 + #[test] 1215 + fn rejects_malformed_hot_cursor_score_bits() { 1216 + let bits = |s: f64| format!("{:016x}", s.to_bits()); 1217 + let cursor = |score_bits: String| format!("hot:v1:100:{score_bits}:rkey:at://did:plc:x/x"); 1218 + assert!(parse_hot_cursor(Some(&cursor(bits(f64::NAN))), 0).is_err()); 1219 + assert!(parse_hot_cursor(Some(&cursor(bits(f64::INFINITY))), 0).is_err()); 1220 + assert!(parse_hot_cursor(Some(&cursor(bits(f64::NEG_INFINITY))), 0).is_err()); 1221 + assert!(parse_hot_cursor(Some(&cursor(bits(-1.0))), 0).is_err()); 1222 + assert!(parse_hot_cursor(Some(&cursor(bits(0.0))), 0).is_ok()); 1223 + assert!(parse_hot_cursor(Some(&cursor(bits(12.5))), 0).is_ok()); 1224 + } 1225 + }
+90 -43
src/appview/writes.rs
··· 404 404 .ok_or_else(|| unauthorized("OAuth session is required")) 405 405 } 406 406 407 + /// Ensure the actor's handle is present in the `identities` projection, mirroring 408 + /// what a firehose `#identity` event would write. No-op when already projected; 409 + /// otherwise resolves the handle (local cache, then DID document) and upserts it. 410 + async fn ensure_actor_identity(state: &AppState, actor: &Did) -> AppResult<()> { 411 + if views::handle_for_did(state, actor).await?.is_some() { 412 + return Ok(()); 413 + } 414 + let handle = views::resolve_handle(state, actor).await?; 415 + let now = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default(); 416 + let actor_ref = actor.as_ref(); 417 + db(sqlx::query!( 418 + "INSERT INTO identities (did, handle, updated_at) VALUES (?, ?, ?) \ 419 + ON CONFLICT(did) DO UPDATE SET handle = excluded.handle, updated_at = excluded.updated_at", 420 + actor_ref, 421 + handle, 422 + now, 423 + ) 424 + .execute(&state.pool) 425 + .await)?; 426 + Ok(()) 427 + } 428 + 407 429 pub(super) async fn ensure_polymodel_profile( 408 430 state: &AppState, 409 431 agent: &UserAgent, 410 432 actor: &Did, 411 433 allow_create: bool, 412 434 ) -> AppResult<BootstrapOutcome> { 435 + // Read-your-own-writes: record the actor's handle in the identity projection 436 + // so author hydration on an immediate read doesn't 500 (the firehose is 437 + // otherwise the only writer of `identities`, and it lags a fresh sign-in). 438 + ensure_actor_identity(state, actor).await?; 439 + 413 440 if let Some(profile) = views::polymodel_profile_view(state, actor).await? { 414 441 return Ok(BootstrapOutcome { 415 442 profile, ··· 420 447 } 421 448 422 449 let profile_uri = at_uri_owned(actor, PROFILE_COLLECTION, "self")?; 423 - match agent.get_record::<PolymodelProfile, _>(&profile_uri).await { 424 - Ok(resp) => { 425 - let output = resp 426 - .into_output() 427 - .map_err(|e| internal(format!("profile record decode failed: {e}")))?; 428 - eager_project_record( 429 - state, 430 - actor, 431 - PROFILE_COLLECTION, 432 - "self", 433 - output.cid.clone(), 434 - &output.value, 435 - "create", 436 - ) 437 - .await?; 438 - let profile = views::polymodel_profile_view(state, actor) 439 - .await? 440 - .ok_or_else(|| internal("projected profile was not readable"))?; 441 - return Ok(BootstrapOutcome { 442 - profile, 443 - status: BootstrapProfileOutputStatus::ExistingRemote, 444 - uri: Some(output.uri), 445 - cid: output.cid, 446 - }); 447 - } 448 - Err(e) if client_error_is_not_found(&e) => {} 450 + // A missing record surfaces as an XRPC `RecordNotFound` decoded by 451 + // `into_output()`, not as a transport-level `Err` — so a not-found must be 452 + // caught in both arms or it becomes a spurious 500 "decode failed". 453 + let existing_remote = match agent.get_record::<PolymodelProfile, _>(&profile_uri).await { 454 + Ok(resp) => match resp.into_output() { 455 + Ok(output) => Some(output), 456 + Err(e) if display_is_not_found(&e) => None, 457 + Err(e) => return Err(internal(format!("profile record decode failed: {e}"))), 458 + }, 459 + Err(e) if client_error_is_not_found(&e) => None, 449 460 Err(e) => return Err(client_error("fetch polymodel profile", e)), 461 + }; 462 + if let Some(output) = existing_remote { 463 + eager_project_record( 464 + state, 465 + actor, 466 + PROFILE_COLLECTION, 467 + "self", 468 + output.cid.clone(), 469 + &output.value, 470 + "create", 471 + ) 472 + .await?; 473 + let profile = views::polymodel_profile_view(state, actor) 474 + .await? 475 + .ok_or_else(|| internal("projected profile was not readable"))?; 476 + return Ok(BootstrapOutcome { 477 + profile, 478 + status: BootstrapProfileOutputStatus::ExistingRemote, 479 + uri: Some(output.uri), 480 + cid: output.cid, 481 + }); 450 482 } 451 483 452 484 if !allow_create { ··· 457 489 458 490 let bsky_uri = at_uri_owned(actor, "app.bsky.actor.profile", "self")?; 459 491 let bsky = match agent.get_record::<BskyProfile, _>(&bsky_uri).await { 460 - Ok(resp) => { 461 - resp.into_output() 462 - .map_err(|e| internal(format!("bsky profile decode failed: {e}")))? 463 - .value 464 - } 465 - Err(e) if client_error_is_not_found(&e) => BskyProfile { 466 - avatar: None, 467 - banner: None, 468 - created_at: None, 469 - description: None, 470 - display_name: None, 471 - joined_via_starter_pack: None, 472 - labels: None, 473 - pinned_post: None, 474 - pronouns: None, 475 - website: None, 476 - extra_data: None, 492 + Ok(resp) => match resp.into_output() { 493 + Ok(output) => output.value, 494 + Err(e) if display_is_not_found(&e) => empty_bsky_profile(), 495 + Err(e) => return Err(internal(format!("bsky profile decode failed: {e}"))), 477 496 }, 497 + Err(e) if client_error_is_not_found(&e) => empty_bsky_profile(), 478 498 Err(e) => return Err(client_error("fetch bsky profile", e)), 479 499 }; 480 500 ··· 1541 1561 fn client_error_is_not_found(err: &jacquard_common::error::ClientError) -> bool { 1542 1562 let text = format!("{err:?}"); 1543 1563 text.contains("RecordNotFound") || text.contains("not found") || text.contains("404") 1564 + } 1565 + 1566 + /// A `getRecord` for a missing record can come back as a successful HTTP 1567 + /// response whose body decodes (via `into_output()`) to an XRPC `RecordNotFound` 1568 + /// error rather than a transport-level `Err`. Detect that from the error's 1569 + /// `Display` so the caller can treat the record as absent instead of failing. 1570 + fn display_is_not_found<E: std::fmt::Display>(err: &E) -> bool { 1571 + let text = err.to_string(); 1572 + text.contains("RecordNotFound") || text.contains("not found") || text.contains("404") 1573 + } 1574 + 1575 + /// An empty `app.bsky.actor.profile` used when an actor has no Bluesky profile 1576 + /// record to copy basics from during polymodel profile bootstrap. 1577 + fn empty_bsky_profile() -> BskyProfile { 1578 + BskyProfile { 1579 + avatar: None, 1580 + banner: None, 1581 + created_at: None, 1582 + description: None, 1583 + display_name: None, 1584 + joined_via_starter_pack: None, 1585 + labels: None, 1586 + pinned_post: None, 1587 + pronouns: None, 1588 + website: None, 1589 + extra_data: None, 1590 + } 1544 1591 } 1545 1592 1546 1593 fn agent_error_is_auth(err: &jacquard::client::AgentError) -> bool {
+21 -4
src/main.rs
··· 1 1 use dioxus::prelude::ServerFnError; 2 2 use dioxus::prelude::*; 3 3 use jacquard_common::xrpc::XrpcClient; 4 + use polymodel_api::space_polymodel::actor::bootstrap_profile::BootstrapProfile; 4 5 use polymodel_api::space_polymodel::actor::get_session::{GetSession, GetSessionOutput}; 5 6 6 7 mod auth; ··· 137 138 #[component] 138 139 fn App() -> Element { 139 140 let client = use_context_provider(PolymodelClient::default); 140 - let mut session = use_context_provider(|| Signal::new(SessionIdentity::Anonymous)); 141 + let mut session = use_context_provider(|| Signal::new(SessionIdentity::Unknown)); 141 142 let toasts = use_context_provider(|| Signal::new(auth::ToastState::default())); 142 143 143 144 let ssr_session_seed = use_server_future(server_session_seed)?; ··· 155 156 } 156 157 match client.send(GetSession).await { 157 158 Ok(response) => match response.into_output() { 158 - Ok(output) => session.set(SessionIdentity::from_get_session(output)), 159 - Err(error) => tracing::warn!(%error, "failed to decode Polymodel session"), 159 + Ok(output) => { 160 + let identity = SessionIdentity::from_get_session(output); 161 + let authenticated = matches!(identity, SessionIdentity::Authenticated(_)); 162 + session.set(identity); 163 + // First-auth profile bootstrap: create a polymodel profile from 164 + // Bluesky basics if the signed-in actor has none yet. Idempotent 165 + // server-side (returns the existing profile when present). 166 + if authenticated && let Err(error) = client.send(BootstrapProfile).await { 167 + tracing::warn!(%error, "failed to bootstrap Polymodel profile"); 168 + } 169 + } 170 + Err(error) => { 171 + tracing::warn!(%error, "failed to decode Polymodel session"); 172 + session.set(SessionIdentity::Anonymous); 173 + } 160 174 }, 161 - Err(error) => tracing::warn!(%error, "failed to fetch Polymodel session"), 175 + Err(error) => { 176 + tracing::warn!(%error, "failed to fetch Polymodel session"); 177 + session.set(SessionIdentity::Anonymous); 178 + } 162 179 } 163 180 } 164 181 });
+9 -33
src/profile.rs
··· 4 4 use jacquard_common::types::string::{Did, Handle, UriValue}; 5 5 use jacquard_common::xrpc::XrpcClient; 6 6 use polymodel_api::space_polymodel::actor::{ 7 - ProfileViewUnion, get_profile::GetProfile, get_session::GetSession, 7 + ProfileViewUnion, get_profile::GetProfile, 8 8 }; 9 9 use polymodel_api::space_polymodel::library; 10 10 use polymodel_api::space_polymodel::library::get_author_things::GetAuthorThings; ··· 228 228 #[allow(clippy::useless_format)] 229 229 #[component] 230 230 pub(crate) fn ProfileSelf() -> Element { 231 - let client = use_context::<PolymodelClient>(); 232 231 let session = use_context::<Signal<SessionIdentity>>(); 233 - // Sign-in must return the user to the page they started on; here that is 234 - // the current profile route rather than a hardcoded path. 232 + // Sign-in returns the user to the page they started on, not a hardcoded path. 235 233 let return_to = use_route::<Route>().to_string(); 236 - // Re-check the session on mount — the App-level seed runs once and may have 237 - // failed or not resolved yet. This ensures /profile always has a fresh read. 238 - let session_check = use_resource(move || { 239 - let client = client.clone(); 240 - let mut session = session; 241 - async move { 242 - match client.send(GetSession).await { 243 - Ok(response) => match response.into_output() { 244 - Ok(output) => { 245 - let identity = SessionIdentity::from_get_session(output); 246 - session.set(identity); 247 - } 248 - Err(error) => { 249 - tracing::warn!(error = ?error, "profile: failed to decode session"); 250 - } 251 - }, 252 - Err(error) => { 253 - tracing::warn!(error = ?error, "profile: failed to fetch session"); 254 - } 255 - } 256 - } 257 - }); 258 234 259 - if session_check.pending() { 260 - return rsx! { 235 + // Reuse the shared session resolution from the App/shell: `Unknown` is the 236 + // brief in-flight state (the App resolves it to a terminal Authenticated/ 237 + // Anonymous even on failure), so /profile needs no per-page session re-check. 238 + match session.read().clone() { 239 + SessionIdentity::Unknown => rsx! { 261 240 main { class: "profile-page product-shell", 262 241 section { class: "profile-shell blueprint-panel", 263 242 h1 { class: "sr-only", "Loading profile" } ··· 269 248 } 270 249 } 271 250 } 272 - }; 273 - } 274 - 275 - match session.read().clone() { 251 + }, 276 252 SessionIdentity::Authenticated(auth) => { 277 253 rsx! { ProfileBody { actor: auth.handle.as_str().to_string() } } 278 254 } ··· 349 325 SessionIdentity::Authenticated(identity) => { 350 326 (identity.did == data.header.did, false) 351 327 } 352 - SessionIdentity::Anonymous => (false, true), 328 + SessionIdentity::Anonymous | SessionIdentity::Unknown => (false, true), 353 329 }; 354 330 rsx! { 355 331 ProfilePopulated { data, is_own, is_anonymous, active_tab }
+134 -93
src/publish.rs
··· 25 25 ]; 26 26 const DEFAULT_LICENSE: &str = "CC-BY-4.0"; 27 27 28 + /// Bluesky CDN thumbnail URL for an uploaded image blob (mirrors the appview's 29 + /// `blob_cdn_url`) so the wizard can preview an image right after upload. 30 + fn image_cdn_url(did: &str, image: &Image) -> String { 31 + let cid = image.image.blob().r#ref.as_str(); 32 + format!("https://cdn.bsky.app/img/feed_thumbnail/plain/{did}/{cid}@jpeg") 33 + } 34 + 28 35 /// Transient state of a single in-flight raw upload (file or image). 29 36 #[derive(Clone, PartialEq)] 30 37 enum UploadStatus { ··· 36 43 /// Whether the server-side draft is in sync with the in-memory composition. 37 44 #[derive(Clone, Copy, PartialEq)] 38 45 enum SaveState { 46 + /// Initial state: nothing saved yet, so the status reads as empty rather than 47 + /// falsely claiming everything is saved. 48 + New, 39 49 Saved, 40 50 Saving, 41 51 Dirty, ··· 49 59 #[component] 50 60 pub(crate) fn Publish(draft: String) -> Element { 51 61 let session = use_context::<Signal<SessionIdentity>>(); 52 - let is_anonymous = matches!(*session.read(), SessionIdentity::Anonymous) && false; // TEMP-SCREENSHOT-BYPASS 62 + let identity = session.read().clone(); 53 63 54 - if is_anonymous { 64 + // Session still resolving on first load: show a neutral loading state rather 65 + // than flashing the signed-out callout at users who are actually signed in. 66 + if matches!(identity, SessionIdentity::Unknown) { 55 67 return rsx! { 56 68 main { class: "publish-page", 57 69 section { class: "publish-intro blueprint-panel", aria_label: "Publish", 58 - h1 { "Publish a thing" } 59 - p { class: "product-lede", "Assemble a model — files, images, instructions, and a license — and publish it to your ATProto repository." } 70 + h1 { "Publish" } 71 + div { class: "skeleton-line skeleton-wide" } 72 + div { class: "skeleton-line" } 60 73 } 74 + } 75 + }; 76 + } 77 + 78 + if matches!(identity, SessionIdentity::Anonymous) { 79 + return rsx! { 80 + main { class: "publish-page", 61 81 section { class: "publish-signed-out blueprint-panel", aria_label: "Sign in to publish", 62 - span { class: "status-pill", "Signed out" } 63 - h2 { "Sign in to publish" } 64 - p { "Publishing needs an ATProto account. Sign in and you return right here to start drafting." } 82 + h1 { "Share your models" } 83 + p { class: "product-lede", "Sign in to upload your files and publish under your handle." } 65 84 form { 66 85 class: "session-control", 67 86 action: "/oauth/start", ··· 71 90 r#type: "text", 72 91 name: "identifier", 73 92 placeholder: "handle.test", 74 - aria_label: "ATProto handle for publishing", 93 + aria_label: "ATProto handle", 75 94 } 76 95 button { class: "button button-primary", r#type: "submit", "Sign in" } 77 96 } ··· 96 115 upload: Signal<UploadStatus>, 97 116 image_upload: Signal<UploadStatus>, 98 117 drafts: Signal<Vec<DraftSummary>>, 118 + /// Authenticated actor DID, used to build CDN preview URLs for uploaded images. 119 + did: Option<String>, 99 120 } 100 121 101 122 #[component] 102 123 fn PublishWizard(resume: String) -> Element { 103 124 let client = use_context::<PolymodelClient>(); 104 - let composition = use_signal(DraftThingInput::default); 125 + let session = use_context::<Signal<SessionIdentity>>(); 126 + let did = match &*session.read() { 127 + SessionIdentity::Authenticated(identity) => Some(identity.did.as_str().to_string()), 128 + _ => None, 129 + }; 130 + // Seed a new draft with the effective default license so the control reflects 131 + // real state from the start (a control only fires change when its value changes). 132 + let composition = use_signal(|| DraftThingInput { 133 + license: Some(DEFAULT_LICENSE.to_string()), 134 + ..DraftThingInput::default() 135 + }); 105 136 let draft_id = use_signal(|| Option::<String>::None); 106 137 let step = use_signal(|| WizardStep::Basics); 107 138 let phase = use_signal(|| PublishPhase::Editing); 108 - let save = use_signal(|| SaveState::Saved); 139 + let save = use_signal(|| SaveState::New); 109 140 let upload = use_signal(|| UploadStatus::Idle); 110 141 let image_upload = use_signal(|| UploadStatus::Idle); 111 142 let drafts = use_signal(Vec::<DraftSummary>::new); ··· 120 151 upload, 121 152 image_upload, 122 153 drafts, 154 + did, 123 155 }; 124 156 125 157 // Resume a draft once on mount when `?draft=<id>` is present; always load ··· 148 180 }); 149 181 } 150 182 183 + // Mirror the active draft id into the URL so a full reload (e.g. the dev 184 + // server's hot-reload websocket dropping and reconnecting) resumes the same 185 + // draft via `?draft=<id>` instead of dropping you on a blank `/publish`. 186 + // replace() avoids stacking a history entry on every autosave. 187 + { 188 + let draft_id = wizard.draft_id; 189 + use_effect(move || { 190 + if let Some(id) = draft_id.read().clone() { 191 + navigator().replace(Route::Publish { draft: id }); 192 + } 193 + }); 194 + } 195 + 151 196 let current = *wizard.step.read(); 152 197 let body = match current { 153 198 WizardStep::Basics => wizard.render_basics(), ··· 325 370 PublishPhase::Published { .. } => ("status-pill", "Published"), 326 371 PublishPhase::Publishing => ("status-pill", "Publishing…"), 327 372 PublishPhase::Failed(_) => ("status-pill status-error", "Publish failed"), 328 - PublishPhase::Editing => ("status-pill status-muted", "Draft — not public yet"), 373 + PublishPhase::Editing => ("status-pill status-muted", "Draft"), 329 374 }; 330 375 let save_label = match save { 331 - SaveState::Saved => "All changes saved", 376 + SaveState::New => "", 377 + SaveState::Saved => "Saved", 332 378 SaveState::Saving => "Saving…", 333 379 SaveState::Dirty => "Unsaved changes", 334 - SaveState::Error => "Could not save draft", 380 + SaveState::Error => "Save failed", 335 381 }; 336 382 rsx! { 337 383 header { class: "publish-head", ··· 429 475 let composition = self.composition.read(); 430 476 let name = composition.name.clone().unwrap_or_default(); 431 477 let summary = composition.summary.clone().unwrap_or_default(); 432 - let license = composition 433 - .license 434 - .clone() 435 - .unwrap_or_else(|| DEFAULT_LICENSE.to_string()); 436 - let tags = composition.tags.clone().unwrap_or_default().join(", "); 437 478 drop(composition); 438 479 439 480 let name_w = self.clone(); 440 481 let summary_w = self.clone(); 441 - let license_w = self.clone(); 442 - let tags_w = self.clone(); 443 482 444 483 rsx! { 445 484 h2 { "Basics" } 446 - p { class: "wizard-step-hint", "Name your thing and pick a license. You can change everything before you publish." } 447 485 label { class: "field", 448 486 span { class: "field-label", "Name" } 449 487 input { ··· 463 501 textarea { 464 502 class: "field-input field-textarea", 465 503 value: "{summary}", 466 - placeholder: "A short description of what this is.", 504 + placeholder: "What is it?", 467 505 oninput: move |evt| { 468 506 let mut c = summary_w.composition; 469 507 let v = evt.value(); ··· 472 510 }, 473 511 } 474 512 } 475 - label { class: "field", 476 - span { class: "field-label", "License" } 477 - select { 478 - class: "field-input", 479 - value: "{license}", 480 - onchange: move |evt| { 481 - let mut c = license_w.composition; 482 - c.write().license = Some(evt.value()); 483 - license_w.mark_dirty(); 484 - }, 485 - for option in LICENSE_OPTIONS { 486 - option { key: "{option}", value: "{option}", selected: *option == license, "{option}" } 487 - } 488 - } 489 - span { class: "field-help card-meta", "Effective license: {license}" } 490 - } 491 - label { class: "field", 492 - span { class: "field-label", "Tags" } 493 - input { 494 - class: "field-input", 495 - r#type: "text", 496 - value: "{tags}", 497 - placeholder: "comma, separated, tags", 498 - oninput: move |evt| { 499 - let mut c = tags_w.composition; 500 - let parsed: Vec<String> = evt 501 - .value() 502 - .split(',') 503 - .map(|t| t.trim().to_string()) 504 - .filter(|t| !t.is_empty()) 505 - .collect(); 506 - c.write().tags = if parsed.is_empty() { None } else { Some(parsed) }; 507 - tags_w.mark_dirty(); 508 - }, 509 - } 510 - } 511 513 } 512 514 } 513 515 ··· 699 701 let preview_w = self.clone(); 700 702 rsx! { 701 703 h2 { "Images" } 702 - p { class: "wizard-step-hint", "Upload a cover image and optional preview images. Each needs alt text for accessibility." } 703 704 match &status { 704 705 UploadStatus::Uploading(label) => rsx! { 705 706 div { class: "wizard-upload-status", role: "status", ··· 759 760 { 760 761 let alt = image.alt.to_string(); 761 762 let ratio = format!("{}×{}", image.aspect_ratio.width, image.aspect_ratio.height); 763 + let preview_url = self.did.as_ref().map(|did| image_cdn_url(did, image)); 762 764 let alt_w = self.clone(); 763 765 let remove_w = self.clone(); 764 766 rsx! { 765 767 li { key: "image-{is_cover}-{i}", class: "wizard-image", 766 768 div { class: "wizard-image-thumb blueprint-media", aria_hidden: "true", 767 - span { class: "blueprint-dimension", "{ratio}" } 769 + if let Some(url) = preview_url { 770 + img { class: "wizard-image-img", src: "{url}", alt: "" } 771 + } else { 772 + span { class: "blueprint-dimension", "{ratio}" } 773 + } 768 774 } 769 775 label { class: "field wizard-image-alt", 770 776 span { class: "field-label", "Alt text" } ··· 864 870 let tags = composition.tags.clone().unwrap_or_default(); 865 871 drop(composition); 866 872 873 + let tags = tags.join(", "); 874 + let license_w = self.clone(); 875 + let tags_w = self.clone(); 867 876 let instructions_w = self.clone(); 868 877 rsx! { 869 - h2 { "Instructions & license" } 870 - p { class: "wizard-step-hint", "Add assembly or printing instructions. One step per line." } 878 + h2 { "Details" } 879 + label { class: "field", 880 + span { class: "field-label", "License" } 881 + input { 882 + class: "field-input", 883 + r#type: "text", 884 + list: "license-options", 885 + value: "{license}", 886 + placeholder: "{DEFAULT_LICENSE}", 887 + oninput: move |evt| { 888 + let mut c = license_w.composition; 889 + let v = evt.value(); 890 + c.write().license = if v.trim().is_empty() { None } else { Some(v) }; 891 + license_w.mark_dirty(); 892 + }, 893 + } 894 + datalist { id: "license-options", 895 + for option in LICENSE_OPTIONS { 896 + option { key: "{option}", value: "{option}" } 897 + } 898 + } 899 + } 900 + label { class: "field", 901 + span { class: "field-label", "Tags" } 902 + input { 903 + class: "field-input", 904 + r#type: "text", 905 + value: "{tags}", 906 + placeholder: "comma, separated", 907 + oninput: move |evt| { 908 + let mut c = tags_w.composition; 909 + let parsed: Vec<String> = evt 910 + .value() 911 + .split(',') 912 + .map(|t| t.trim().to_string()) 913 + .filter(|t| !t.is_empty()) 914 + .collect(); 915 + c.write().tags = if parsed.is_empty() { None } else { Some(parsed) }; 916 + tags_w.mark_dirty(); 917 + }, 918 + } 919 + } 871 920 label { class: "field", 872 921 span { class: "field-label", "Instructions" } 873 922 textarea { 874 923 class: "field-input field-textarea field-textarea-tall", 875 924 value: "{instructions}", 876 - placeholder: "One instruction per line.", 925 + placeholder: "One step per line", 877 926 oninput: move |evt| { 878 927 let mut c = instructions_w.composition; 879 - let lines: Vec<String> = evt 880 - .value() 881 - .lines() 882 - .map(|l| l.trim_end().to_string()) 883 - .filter(|l| !l.trim().is_empty()) 884 - .collect(); 885 - c.write().instructions = if lines.is_empty() { None } else { Some(lines) }; 928 + let value = evt.value(); 929 + // Store the text exactly as typed (split only on newlines) so 930 + // spaces and blank lines survive editing; empty lines are 931 + // dropped at assembly. 932 + let lines: Vec<String> = value.split('\n').map(str::to_string).collect(); 933 + c.write().instructions = if value.trim().is_empty() { None } else { Some(lines) }; 886 934 instructions_w.mark_dirty(); 887 935 }, 888 936 } 889 937 } 890 - div { class: "wizard-recap", 891 - span { class: "field-label", "License" } 892 - span { class: "status-pill", "{license}" } 893 - } 894 - div { class: "wizard-recap", 895 - span { class: "field-label", "Tags" } 896 - if tags.is_empty() { 897 - span { class: "card-meta", "None" } 898 - } else { 899 - div { class: "thing-card-chips", 900 - for tag in tags { 901 - span { key: "{tag}", class: "status-pill", "{tag}" } 902 - } 903 - } 904 - } 905 - } 906 938 } 907 939 } 908 940 ··· 925 957 926 958 let publishing = matches!(*self.phase.read(), PublishPhase::Publishing); 927 959 let issues = assembled.as_ref().err().cloned(); 928 - let name_error = issues.as_ref().map(|e| e.has_field("name")).unwrap_or(false); 960 + let name_error = issues 961 + .as_ref() 962 + .map(|e| e.has_field("name")) 963 + .unwrap_or(false); 929 964 let license_error = issues 930 965 .as_ref() 931 966 .map(|e| e.has_field("license")) ··· 945 980 946 981 rsx! { 947 982 h2 { "Review & publish" } 948 - p { class: "wizard-step-hint", "This is what will be public when you publish. Nothing is published until you choose to." } 983 + p { class: "wizard-step-hint", "This is what becomes public when you publish." } 949 984 950 985 div { class: "wizard-review", 951 986 div { class: "{name_row_class}", ··· 1011 1046 } 1012 1047 1013 1048 div { class: "wizard-publish-row", 1014 - span { class: "status-pill status-muted", "Draft — not public yet" } 1015 1049 button { 1016 1050 class: "button button-primary wizard-publish", 1017 1051 disabled: !can_publish, ··· 1019 1053 let wizard = publish_w.clone(); 1020 1054 spawn(async move { wizard.publish().await }); 1021 1055 }, 1022 - if publishing { "Publishing…" } else { "Publish to your PDS" } 1056 + if publishing { "Publishing…" } else { "Publish" } 1023 1057 } 1024 1058 } 1025 1059 } 1026 1060 } 1027 1061 1028 1062 async fn publish(self) { 1029 - // Ensure the latest edits are persisted, then publish the saved draft. 1063 + // Persist the latest edits first. If that save fails, do NOT publish — 1064 + // otherwise we would publish stale server-side draft state. 1030 1065 self.clone().save_draft().await; 1066 + if matches!(*self.save.read(), SaveState::Error) { 1067 + let mut phase = self.phase; 1068 + phase.set(PublishPhase::Failed( 1069 + "Could not save your latest changes, so nothing was published. Try again.".into(), 1070 + )); 1071 + return; 1072 + } 1031 1073 let Some(id) = self.draft_id.read().clone() else { 1032 1074 let mut phase = self.phase; 1033 1075 phase.set(PublishPhase::Failed( ··· 1055 1097 section { class: "wizard-published state-card", aria_label: "Published", 1056 1098 span { class: "status-pill", "Published" } 1057 1099 h2 { "Your thing is live" } 1058 - p { "It was published to your repository and is now public." } 1059 1100 Link { 1060 1101 class: "button button-primary", 1061 1102 to: Route::ThingDetail { repo, rkey }, 1062 - "View your published thing" 1103 + "View it" 1063 1104 } 1064 1105 } 1065 1106 }
+35 -8
src/publish/draft.rs
··· 225 225 .map(|items| items.iter().map(|s| S::from(s.trim())).collect::<Vec<_>>()) 226 226 } 227 227 228 + /// Convert a freeform multi-line text field (stored raw, one entry per line) 229 + /// into a list of non-empty steps. Whitespace-only lines are dropped here, at 230 + /// assembly, so the live textarea can keep spaces and blank lines while typing. 231 + fn clean_lines(value: &Option<Vec<String>>) -> Option<Vec<S>> { 232 + let lines: Vec<S> = value 233 + .as_ref()? 234 + .iter() 235 + .map(|line| line.trim_end()) 236 + .filter(|line| !line.trim().is_empty()) 237 + .map(S::from) 238 + .collect(); 239 + if lines.is_empty() { None } else { Some(lines) } 240 + } 241 + 228 242 impl DraftPartInput { 229 243 /// A part is backed by a staged file when it has either an inline manifest 230 244 /// or a non-empty upload id. ··· 262 276 self.models.len() as i64 263 277 } 264 278 265 - /// Step 1 (Basics) is complete when name and license are both set. 279 + /// Basics is complete once the thing has a name. License has an effective 280 + /// default and is chosen in the details step, so it never gates Basics. 266 281 pub fn basics_complete(&self) -> bool { 267 - nonempty(&self.name) && nonempty(&self.license) 282 + nonempty(&self.name) 268 283 } 269 284 270 285 /// Step 2 (Files & parts) is complete when there is at least one model and ··· 284 299 285 300 if !nonempty(&self.name) { 286 301 errors.push("name", "A name is required to publish."); 287 - } 288 - if !nonempty(&self.license) { 289 - errors.push("license", "A license is required to publish."); 290 302 } 291 303 if self.models.is_empty() { 292 304 errors.push("models", "Add at least one model with a file."); ··· 364 376 let thing = ThingInput { 365 377 cover: self.cover.clone(), 366 378 derived_from: self.derived_from.clone(), 367 - instructions: opt_vec_s(&self.instructions), 379 + instructions: clean_lines(&self.instructions), 368 380 intended_to_work_with: self.intended_to_work_with.clone(), 369 - license: into_s(self.license.as_deref().unwrap_or_default()), 381 + license: into_s( 382 + self.license 383 + .as_deref() 384 + .map(str::trim) 385 + .filter(|v| !v.is_empty()) 386 + .unwrap_or(super::DEFAULT_LICENSE), 387 + ), 370 388 models, 371 389 name: into_s(self.name.as_deref().unwrap_or_default()), 372 390 previews: self.previews.clone(), ··· 475 493 let draft = DraftThingInput::default(); 476 494 let errors = draft.assemble().expect_err("empty draft fails"); 477 495 assert!(errors.has_field("name")); 478 - assert!(errors.has_field("license")); 479 496 assert!(errors.has_field("models")); 497 + // License is not required: it defaults at assembly, never an error. 498 + assert!(!errors.has_field("license")); 499 + } 500 + 501 + #[test] 502 + fn unset_license_defaults_at_assembly() { 503 + let mut draft = complete_draft(); 504 + draft.license = None; 505 + let thing = draft.assemble().expect("unset license defaults"); 506 + assert_eq!(thing.license.as_str(), "CC-BY-4.0"); 480 507 } 481 508 482 509 #[test]
+4
src/session.rs
··· 4 4 use polymodel_api::space_polymodel::actor::get_session::{GetSessionOutput, SessionView}; 5 5 #[derive(Clone, Debug, Default, PartialEq, Eq)] 6 6 pub enum SessionIdentity { 7 + /// Session not yet determined — the initial state before `getSession` 8 + /// resolves. Distinct from `Anonymous` so the UI can show a loading state 9 + /// instead of flashing signed-out chrome at users who are actually signed in. 7 10 #[default] 11 + Unknown, 8 12 Anonymous, 9 13 Authenticated(AuthenticatedIdentity), 10 14 }
+3
src/shell.rs
··· 97 97 let return_to = current_return_to(); 98 98 99 99 match identity { 100 + // Session still resolving: render nothing rather than flashing a sign-in 101 + // form at users who turn out to be authenticated. 102 + SessionIdentity::Unknown => rsx! {}, 100 103 SessionIdentity::Anonymous => rsx! { 101 104 form { 102 105 class: "session-control",
-1
src/thing_detail.rs
··· 260 260 261 261 rsx! { 262 262 header { class: "thing-detail-header", 263 - div { class: "product-kicker", "Thing detail" } 264 263 div { class: "thing-detail-title-row", 265 264 div { 266 265 h1 { "{title}" }