atproto Thingiverse but good
10

Configure Feed

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

PM-62: SSR-first appview reads via server functions

Orual (Jun 29, 2026, 7:06 PM EDT) 788faa4a 2b26a05f

+295 -156
+1 -1
src/appview/actor.rs
··· 69 69 /// Fetch `app.bsky.actor.getProfile` from `https://public.api.bsky.app` via the 70 70 /// unauthenticated jacquard client. This is the only network path in the read 71 71 /// endpoints; tests exercise union-variant construction without it. 72 - async fn fetch_bsky_profile( 72 + pub(crate) async fn fetch_bsky_profile( 73 73 state: &AppState, 74 74 ident: &AtIdentifier, 75 75 ) -> AppResult<ProfileViewDetailed> {
+168 -5
src/appview/ssr.rs
··· 12 12 use jacquard::oauth::client::OAuthSession; 13 13 use jacquard_axum::oauth::ExtractOptionalOAuthSession; 14 14 use jacquard_common::session::SessionKey; 15 + use jacquard_common::types::ident::AtIdentifier; 15 16 use jacquard_common::xrpc::CallOptions; 17 + use polymodel_api::space_polymodel::actor::ProfileViewUnion; 16 18 use polymodel_api::space_polymodel::actor::get_session::GetSessionOutput; 19 + use polymodel_api::space_polymodel::library::FeedView; 17 20 18 21 use super::actor; 22 + use super::error::clamp_limit; 19 23 use super::state::AppState; 24 + use super::views::{self, ModelDetail}; 20 25 use crate::oauth::SqliteAuthStore; 21 26 27 + /// Maximum time to wait for the Bluesky profile fallback during SSR before 28 + /// degrading to an error state. The polymodel-first path is pure SQLite and 29 + /// needs no timeout; this only bounds the outbound `public.api.bsky.app` call. 30 + const SSR_BSKY_PROFILE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3); 31 + 22 32 pub type PolymodelOAuthSession = OAuthSession<PublicResolver, SqliteAuthStore>; 23 33 24 34 /// Extract the current request's optional OAuth session and resolve the 25 35 /// `getSession` appview output without issuing a same-origin XRPC request. 26 36 pub async fn get_session_output_from_fullstack_context() -> Result<GetSessionOutput, ServerFnError> 27 37 { 38 + let (context, state) = context_and_state()?; 39 + let session = extract_optional_oauth_session(&context, &state).await?; 40 + actor::get_session_output(&state, session.as_ref()) 41 + .await 42 + .map_err(|error| ServerFnError::new(format!("{error:?}"))) 43 + } 44 + 45 + // --------------------------------------------------------------------------- 46 + // Per-read "from fullstack context" helpers 47 + // 48 + // Each helper resolves the viewer from the request's OAuth session and calls 49 + // the same in-process `views::`/`actor::` helper that the corresponding 50 + // `/xrpc/*` handler calls. Parity is at the helper level: both paths share the 51 + // same query/view logic. 52 + // --------------------------------------------------------------------------- 53 + 54 + /// Resolve a thing (with its models) server-side, same path as `getThing`. 55 + #[cfg_attr(not(feature = "server"), allow(dead_code))] 56 + pub(crate) async fn get_thing_from_fullstack_context( 57 + uri: &str, 58 + ) -> Result< 59 + ( 60 + polymodel_api::space_polymodel::library::ThingView, 61 + Vec<polymodel_api::space_polymodel::library::ModelView>, 62 + ), 63 + ServerFnError, 64 + > { 65 + let (context, state) = context_and_state()?; 66 + let session = extract_optional_oauth_session(&context, &state).await?; 67 + let viewer = views::viewer_did(session.as_ref()).await; 68 + let viewer = viewer.as_ref().map(|d| d.borrow()); 69 + views::get_thing(&state, uri, viewer) 70 + .await 71 + .map_err(|error| ServerFnError::new(format!("{error:?}"))) 72 + } 73 + 74 + /// Resolve a model (with its parts and parent thing) server-side, same path as 75 + /// `getModel`. 76 + #[cfg_attr(not(feature = "server"), allow(dead_code))] 77 + pub(crate) async fn get_model_from_fullstack_context( 78 + uri: &str, 79 + ) -> Result<ModelDetail, ServerFnError> { 80 + let (context, state) = context_and_state()?; 81 + let session = extract_optional_oauth_session(&context, &state).await?; 82 + let viewer = views::viewer_did(session.as_ref()).await; 83 + let viewer = viewer.as_ref().map(|d| d.borrow()); 84 + views::get_model(&state, uri, viewer) 85 + .await 86 + .map_err(|error| ServerFnError::new(format!("{error:?}"))) 87 + } 88 + 89 + /// Resolve a feed server-side, same path as `getFeed`. The `algorithm` string 90 + /// dispatches exactly as the XRPC handler: `"recent"` or `"hot"`. 91 + #[cfg_attr(not(feature = "server"), allow(dead_code))] 92 + pub(crate) async fn get_feed_from_fullstack_context( 93 + algorithm: &str, 94 + limit: i64, 95 + ) -> Result<FeedView, ServerFnError> { 96 + let (context, state) = context_and_state()?; 97 + let session = extract_optional_oauth_session(&context, &state).await?; 98 + let viewer = views::viewer_did(session.as_ref()).await; 99 + let viewer = viewer.as_ref().map(|d| d.borrow()); 100 + let limit = clamp_limit(Some(limit)); 101 + let feed = match algorithm { 102 + "recent" => views::feed_recent(&state, limit, None, viewer).await, 103 + "hot" => views::feed_hot(&state, limit, None, viewer).await, 104 + other => return Err(ServerFnError::new(format!("unknown algorithm: {other}"))), 105 + }; 106 + feed.map_err(|error| ServerFnError::new(format!("{error:?}"))) 107 + } 108 + 109 + /// Resolve a profile union server-side, same path as `getProfile`. Polymodel 110 + /// profile is tried first (pure SQLite); the Bluesky fallback is bounded by 111 + /// [`SSR_BSKY_PROFILE_TIMEOUT`] so a slow upstream cannot block first paint. 112 + /// On timeout or error the caller receives an `Err`, which the route maps to 113 + /// its existing error state. 114 + #[cfg_attr(not(feature = "server"), allow(dead_code))] 115 + pub(crate) async fn get_profile_union_from_fullstack_context( 116 + actor_ident: &AtIdentifier, 117 + ) -> Result<ProfileViewUnion, ServerFnError> { 118 + let (_context, state) = context_and_state()?; 119 + let did = actor::resolve_actor(&state, actor_ident) 120 + .await 121 + .map_err(|error| ServerFnError::new(format!("{error:?}")))?; 122 + 123 + if let Some(profile) = views::polymodel_profile_view(&state, &did) 124 + .await 125 + .map_err(|error| ServerFnError::new(format!("{error:?}")))? 126 + { 127 + return Ok(ProfileViewUnion::ProfileView(Box::new(profile))); 128 + } 129 + 130 + match tokio::time::timeout( 131 + SSR_BSKY_PROFILE_TIMEOUT, 132 + actor::fetch_bsky_profile(&state, actor_ident), 133 + ) 134 + .await 135 + { 136 + Ok(Ok(detailed)) => Ok(ProfileViewUnion::ProfileViewDetailed(Box::new(detailed))), 137 + Ok(Err(error)) => Err(ServerFnError::new(format!( 138 + "Profile request failed: {error:?}" 139 + ))), 140 + Err(_) => Err(ServerFnError::new( 141 + "Profile request timed out waiting for Bluesky fallback", 142 + )), 143 + } 144 + } 145 + 146 + /// Resolve an actor's things server-side, same path as `getAuthorThings`. The 147 + /// actor identifier is canonicalized to a DID before querying, exactly as the 148 + /// XRPC handler does. 149 + #[cfg_attr(not(feature = "server"), allow(dead_code))] 150 + pub(crate) async fn get_author_things_from_fullstack_context( 151 + actor_ident: &AtIdentifier, 152 + limit: i64, 153 + cursor: Option<&str>, 154 + ) -> Result<FeedView, ServerFnError> { 155 + let (context, state) = context_and_state()?; 156 + let session = extract_optional_oauth_session(&context, &state).await?; 157 + let viewer = views::viewer_did(session.as_ref()).await; 158 + let viewer = viewer.as_ref().map(|d| d.borrow()); 159 + let did = actor::resolve_actor(&state, actor_ident) 160 + .await 161 + .map_err(|error| ServerFnError::new(format!("{error:?}")))?; 162 + views::author_things( 163 + &state, 164 + did.as_ref(), 165 + clamp_limit(Some(limit)), 166 + cursor, 167 + viewer, 168 + ) 169 + .await 170 + .map_err(|error| ServerFnError::new(format!("{error:?}"))) 171 + } 172 + 173 + /// Search things server-side, same path as `searchThings`. 174 + #[cfg_attr(not(feature = "server"), allow(dead_code))] 175 + pub(crate) async fn get_search_from_fullstack_context( 176 + query: &str, 177 + cursor: Option<&str>, 178 + limit: i64, 179 + ) -> Result<FeedView, ServerFnError> { 180 + let (context, state) = context_and_state()?; 181 + let session = extract_optional_oauth_session(&context, &state).await?; 182 + let viewer = views::viewer_did(session.as_ref()).await; 183 + let viewer = viewer.as_ref().map(|d| d.borrow()); 184 + views::search_things(&state, query, clamp_limit(Some(limit)), cursor, viewer) 185 + .await 186 + .map_err(|error| ServerFnError::new(format!("{error:?}"))) 187 + } 188 + 189 + // --------------------------------------------------------------------------- 190 + // Internals 191 + // --------------------------------------------------------------------------- 192 + 193 + fn context_and_state() -> Result<(FullstackContext, AppState), ServerFnError> { 28 194 let context = FullstackContext::current() 29 195 .ok_or_else(|| ServerFnError::new("missing Dioxus fullstack request context"))?; 30 196 let state = context 31 197 .extension::<AppState>() 32 198 .ok_or_else(|| ServerFnError::new("missing Polymodel AppState request extension"))?; 33 - let session = extract_optional_oauth_session(&context, &state).await?; 34 - actor::get_session_output(&state, session.as_ref()) 35 - .await 36 - .map_err(|error| ServerFnError::new(format!("{error:?}"))) 199 + Ok((context, state)) 37 200 } 38 201 39 - async fn extract_optional_oauth_session( 202 + pub(crate) async fn extract_optional_oauth_session( 40 203 context: &FullstackContext, 41 204 state: &AppState, 42 205 ) -> Result<Option<PolymodelOAuthSession>, ServerFnError> {
+19 -27
src/browse.rs
··· 1 1 use dioxus::prelude::ServerFnError; 2 2 use dioxus::prelude::*; 3 - use jacquard_common::xrpc::XrpcClient; 4 3 use polymodel_api::space_polymodel::library; 5 4 6 5 use crate::Route; 7 - use crate::client::PolymodelClient; 8 6 use crate::examples::{HERO_SAMPLE_HANDLES, first_hero_sample_handle}; 9 7 use crate::profile::profile_href; 10 8 use crate::session::SessionIdentity; ··· 120 118 #[allow(clippy::useless_format)] 121 119 #[component] 122 120 pub(crate) fn Browse() -> Element { 123 - let client = use_context::<PolymodelClient>(); 124 121 let selected_algorithm = use_signal(|| FeedAlgorithm::Hot); 125 122 126 - let mut feed = use_resource(move || { 127 - let client = client.clone(); 128 - let algorithm = *selected_algorithm.read(); 129 - async move { 130 - let request = library::get_feed::GetFeed::new() 131 - .algorithm(Some(algorithm.as_str().into())) 132 - .limit(24) 133 - .build(); 134 - let response = client 135 - .send(request) 136 - .await 137 - .map_err(|error| format!("Feed request failed: {error}"))?; 138 - let output = response 139 - .into_output() 140 - .map_err(|error| format!("Feed decode failed: {error}"))?; 141 - Ok(output 142 - .value 143 - .items 144 - .into_iter() 145 - .map(|item| item.thing) 146 - .collect::<Vec<_>>()) 147 - } 148 - }); 123 + let mut feed = use_server_future(move || { 124 + let algorithm = selected_algorithm.read().as_str().to_owned(); 125 + feed_seed(algorithm, 24) 126 + })?; 149 127 150 128 let feed_result = feed.read(); 151 - let feed_state = browse_feed_state(feed.pending(), feed_result.as_ref()); 129 + let mapped = feed_result.as_ref().map(|r| match r { 130 + Ok(things) => Ok(things.clone()), 131 + Err(ServerFnError::ServerError { message, .. }) => Err(message.clone()), 132 + Err(other) => Err(other.to_string()), 133 + }); 134 + let feed_state = browse_feed_state(feed.pending(), mapped.as_ref()); 152 135 153 136 // Show the logged-out hero band only to definitively anonymous visitors who 154 137 // haven't dismissed it. Both inputs resolve server-side during SSR (session ··· 301 284 let parts = context.parts_mut().clone(); 302 285 let jar = CookieJar::from_headers(&parts.headers); 303 286 Ok(jar.get(HERO_DISMISS_COOKIE).is_some()) 287 + } 288 + 289 + #[server] 290 + async fn feed_seed( 291 + algorithm: String, 292 + limit: i64, 293 + ) -> Result<Vec<library::ThingViewBasic>, ServerFnError> { 294 + let feed = crate::appview::ssr::get_feed_from_fullstack_context(&algorithm, limit).await?; 295 + Ok(feed.items.into_iter().map(|item| item.thing).collect()) 304 296 } 305 297 306 298 #[cfg(test)]
+26 -37
src/profile.rs
··· 1 1 use dioxus::prelude::*; 2 2 use jacquard_common::deps::smol_str::SmolStr; 3 - use jacquard_common::types::ident::AtIdentifier; 4 3 use jacquard_common::types::string::{Did, Handle, UriValue}; 5 - use jacquard_common::xrpc::XrpcClient; 6 - use polymodel_api::space_polymodel::actor::{ProfileViewUnion, get_profile::GetProfile}; 4 + use polymodel_api::space_polymodel::actor::ProfileViewUnion; 7 5 use polymodel_api::space_polymodel::library; 8 - use polymodel_api::space_polymodel::library::get_author_things::GetAuthorThings; 9 6 10 7 use crate::Route; 11 - use crate::client::PolymodelClient; 12 8 use crate::session::SessionIdentity; 13 9 use crate::thing_card::ThingCard; 14 10 use crate::thing_detail::thing_detail_href; ··· 40 36 Populated(Box<ProfileData>), 41 37 } 42 38 43 - #[derive(Clone, Debug, PartialEq, Eq)] 39 + #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] 44 40 struct ProfileData { 45 41 header: ProfileHeader, 46 42 things: Vec<library::ThingViewBasic>, ··· 54 50 Populated(Vec<library::ThingViewBasic>), 55 51 } 56 52 57 - #[derive(Clone, Debug, PartialEq, Eq)] 53 + #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] 58 54 struct ProfileHeader { 59 55 avatar: Option<UriValue>, 60 56 default_license: Option<SmolStr>, ··· 109 105 /// bare `handle` string is branded into a `Handle` here (single conversion site); 110 106 /// the bsky arm already carries a branded `Handle`. `did` is `Did` on both arms 111 107 /// and is cloned directly. 108 + #[cfg_attr(not(any(feature = "server", test)), allow(dead_code))] 112 109 fn profile_header(value: &ProfileViewUnion) -> ProfileHeader { 113 110 match value { 114 111 ProfileViewUnion::ProfileView(view) => ProfileHeader { ··· 141 138 } 142 139 } 143 140 141 + #[cfg_attr(not(any(feature = "server", test)), allow(dead_code))] 144 142 fn optional_smolstr(value: &Option<impl AsRef<str>>) -> Option<SmolStr> { 145 143 value 146 144 .as_ref() ··· 187 185 }) 188 186 } 189 187 190 - async fn load_profile(client: PolymodelClient, actor_str: &str) -> Result<ProfileData, String> { 191 - let actor = AtIdentifier::new_owned(actor_str) 192 - .map_err(|error| format!("Invalid actor: unsupported actor identifier: {error}"))?; 188 + #[server] 189 + async fn profile_seed(actor: String) -> Result<ProfileData, ServerFnError> { 190 + use jacquard_common::types::ident::AtIdentifier; 193 191 194 - let profile_response = client 195 - .send(GetProfile::new().actor(actor.clone()).build()) 196 - .await 197 - .map_err(|error| format!("Profile request failed: {error}"))?; 198 - let profile_output = profile_response 199 - .into_output() 200 - .map_err(|error| format!("Profile decode failed: {error}"))?; 201 - let header = profile_header(&profile_output.value); 192 + let actor_id = AtIdentifier::new_owned(&actor).map_err(|e| { 193 + ServerFnError::new(format!("Invalid actor: unsupported actor identifier: {e}")) 194 + })?; 195 + 196 + let union = crate::appview::ssr::get_profile_union_from_fullstack_context(&actor_id).await?; 197 + let header = profile_header(&union); 202 198 203 - let things_response = client 204 - .send(GetAuthorThings::new().actor(actor).limit(24).build()) 205 - .await 206 - .map_err(|error| format!("Author things request failed: {error}"))?; 207 - let things_output = things_response 208 - .into_output() 209 - .map_err(|error| format!("Author things decode failed: {error}"))?; 210 - let things = things_output 211 - .value 212 - .items 213 - .into_iter() 214 - .map(|item| item.thing) 215 - .collect(); 199 + let feed = 200 + crate::appview::ssr::get_author_things_from_fullstack_context(&actor_id, 24, None).await?; 201 + let things = feed.items.into_iter().map(|item| item.thing).collect(); 216 202 217 203 Ok(ProfileData { header, things }) 218 204 } ··· 278 264 #[allow(clippy::useless_format)] 279 265 #[component] 280 266 fn ProfileBody(actor: String) -> Element { 281 - let client = use_context::<PolymodelClient>(); 282 267 let session = use_context::<Signal<SessionIdentity>>(); 283 268 let active_tab = use_signal(|| ProfileTab::Things); 284 269 285 - let mut profile = use_resource(move || { 286 - let client = client.clone(); 270 + let mut profile = use_server_future(move || { 287 271 let actor = actor.clone(); 288 - async move { load_profile(client, &actor).await } 289 - }); 272 + profile_seed(actor) 273 + })?; 290 274 291 275 let profile_result = profile.read(); 292 - let state = profile_state(profile.pending(), profile_result.as_ref()); 276 + let mapped = profile_result.as_ref().map(|r| match r { 277 + Ok(data) => Ok(data.clone()), 278 + Err(ServerFnError::ServerError { message, .. }) => Err(message.clone()), 279 + Err(other) => Err(other.to_string()), 280 + }); 281 + let state = profile_state(profile.pending(), mapped.as_ref()); 293 282 294 283 rsx! { 295 284 main { class: "profile-page product-shell",
+32 -38
src/search.rs
··· 1 1 use dioxus::prelude::*; 2 - use jacquard_common::xrpc::XrpcClient; 3 2 use polymodel_api::space_polymodel::library; 4 3 5 4 use crate::Route; 6 - use crate::client::PolymodelClient; 7 5 use crate::profile::profile_href; 8 6 use crate::thing_card::ThingCard; 9 7 use crate::thing_detail::thing_detail_href; 10 8 11 9 const SEARCH_LIMIT: i64 = 24; 12 10 13 - #[derive(Clone, Debug, PartialEq, Eq)] 11 + #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] 14 12 struct SearchPageResult { 15 13 query: String, 16 14 items: Vec<library::ThingViewBasic>, ··· 97 95 #[allow(clippy::useless_format)] 98 96 #[component] 99 97 pub(crate) fn Search(q: String) -> Element { 100 - let client = use_context::<PolymodelClient>(); 101 98 let route = use_route::<Route>(); 102 99 let route_query = match route { 103 100 Route::Search { q } => q.trim().to_string(), ··· 111 108 results.set(None); 112 109 } 113 110 114 - let resource_client = client.clone(); 115 - let mut search = use_resource(move || { 116 - let client = resource_client.clone(); 111 + let mut search = use_server_future(move || { 117 112 let query = active_query.read().clone(); 118 - async move { load_search_page(client, query, None).await } 119 - }); 113 + search_seed(query, None, SEARCH_LIMIT) 114 + })?; 120 115 121 116 let search_result = search.read(); 117 + let mapped = search_result.as_ref().map(|r| match r { 118 + Ok(page) => Ok(page.clone()), 119 + Err(ServerFnError::ServerError { message, .. }) => Err(message.clone()), 120 + Err(other) => Err(other.to_string()), 121 + }); 122 122 let state = search_state( 123 123 &route_query, 124 124 search.pending(), 125 - search_result.as_ref(), 125 + mapped.as_ref(), 126 126 results.read().as_ref(), 127 127 ); 128 128 ··· 179 179 } 180 180 } 181 181 {search_pagination( 182 - client.clone(), 183 182 route_query.clone(), 184 183 results, 185 184 search_results.clone(), ··· 193 192 } 194 193 } 195 194 196 - async fn load_search_page( 197 - client: PolymodelClient, 195 + #[server] 196 + async fn search_seed( 198 197 query: String, 199 198 cursor: Option<String>, 200 - ) -> Result<SearchPageResult, String> { 199 + limit: i64, 200 + ) -> Result<SearchPageResult, ServerFnError> { 201 201 let query = query.trim().to_string(); 202 202 if query.is_empty() { 203 203 return Ok(SearchPageResult { ··· 207 207 }); 208 208 } 209 209 210 - let request = library::search_things::SearchThings::new() 211 - .q(query.clone()) 212 - .limit(SEARCH_LIMIT) 213 - .maybe_cursor(cursor.map(Into::into)) 214 - .build(); 215 - let response = client 216 - .send(request) 217 - .await 218 - .map_err(|error| format!("Search request failed: {error}"))?; 219 - let output = response 220 - .into_output() 221 - .map_err(|error| format!("Search decode failed: {error}"))?; 210 + let feed = 211 + crate::appview::ssr::get_search_from_fullstack_context(&query, cursor.as_deref(), limit) 212 + .await?; 213 + 222 214 Ok(SearchPageResult { 223 215 query, 224 - items: output 225 - .value 226 - .items 227 - .into_iter() 228 - .map(|item| item.thing) 229 - .collect(), 230 - cursor: output.value.cursor.map(|cursor| cursor.to_string()), 216 + items: feed.items.into_iter().map(|item| item.thing).collect(), 217 + cursor: feed.cursor.map(|c| c.to_string()), 231 218 }) 232 219 } 233 220 ··· 239 226 } 240 227 241 228 fn search_pagination( 242 - client: PolymodelClient, 243 229 query: String, 244 230 results: Signal<Option<SearchResults>>, 245 231 current_results: SearchResults, ··· 252 238 button { 253 239 class: "button button-secondary", 254 240 onclick: move |_| { 255 - let client = client.clone(); 256 241 let query = query.clone(); 257 242 let cursor = cursor.clone(); 258 243 let current_results = current_results.clone(); ··· 262 247 ..current_results 263 248 })); 264 249 spawn(async move { 265 - let page = load_search_page(client, query.clone(), Some(cursor)).await; 250 + let page = search_seed(query.clone(), Some(cursor), SEARCH_LIMIT) 251 + .await 252 + .map_err(|e| match e { 253 + ServerFnError::ServerError { message, .. } => message, 254 + other => other.to_string(), 255 + }); 266 256 results.with_mut(|state| { 267 257 let Some(state) = state else { return; }; 268 258 apply_load_more_result(state, &query, page); ··· 286 276 button { 287 277 class: "button button-secondary", 288 278 onclick: move |_| { 289 - let client = client.clone(); 290 279 let query = query.clone(); 291 280 let cursor = cursor.clone(); 292 281 let current_results = current_results.clone(); ··· 296 285 ..current_results 297 286 })); 298 287 spawn(async move { 299 - let page = load_search_page(client, query.clone(), Some(cursor)).await; 288 + let page = search_seed(query.clone(), Some(cursor), SEARCH_LIMIT) 289 + .await 290 + .map_err(|e| match e { 291 + ServerFnError::ServerError { message, .. } => message, 292 + other => other.to_string(), 293 + }); 300 294 results.with_mut(|state| { 301 295 let Some(state) = state else { return; }; 302 296 apply_load_more_result(state, &query, page);
+1 -1
src/shell.rs
··· 122 122 let identity = session.read().clone(); 123 123 let return_to = current_return_to(); 124 124 let handle_placeholder = use_server_cached(random_sample_handle); 125 - let mut identifier = use_signal(|| String::new()); 125 + let mut identifier = use_signal(String::new); 126 126 127 127 match identity { 128 128 // Session still resolving: render nothing rather than flashing a sign-in
+48 -47
src/thing_detail.rs
··· 3 3 use jacquard::common::types::recordkey::Rkey; 4 4 use jacquard::common::types::string::AtUri; 5 5 use jacquard::common::types::value::from_data; 6 - use jacquard_common::xrpc::XrpcClient; 7 6 use polymodel_api::space_polymodel::library; 8 7 9 8 use crate::Route; 10 - use crate::client::PolymodelClient; 11 9 use crate::profile::profile_href; 12 10 use crate::viewer::{AssetViewer, ViewerFallbackImage, demo_models}; 13 11 ··· 19 17 Populated(Box<ThingDetailData>), 20 18 } 21 19 22 - #[derive(Clone, Debug, PartialEq, Eq)] 20 + #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] 23 21 struct ThingDetailData { 24 22 thing: library::ThingView, 25 23 thing_record: library::thing::Thing, ··· 27 25 selected_model: Option<SelectedModelDetail>, 28 26 } 29 27 30 - #[derive(Clone, Debug, PartialEq, Eq)] 28 + #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] 31 29 struct ModelDetailSummary { 32 30 view: library::ModelView, 33 31 record: library::model::Model, 34 32 } 35 33 36 - #[derive(Clone, Debug, PartialEq, Eq)] 34 + #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] 37 35 struct SelectedModelDetail { 38 36 model: library::ModelView, 39 37 record: library::model::Model, 40 38 parts: Vec<PartDetail>, 41 39 } 42 40 43 - #[derive(Clone, Debug, PartialEq, Eq)] 41 + #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] 44 42 struct PartDetail { 45 43 view: library::PartView, 46 44 record: library::part::Part, ··· 71 69 Some(format!("/{}/thing/{}", repo.as_str(), rkey.as_ref())) 72 70 } 73 71 72 + #[cfg_attr(not(feature = "server"), allow(dead_code))] 74 73 fn thing_uri_from_route(repo: &str, rkey: &str) -> Result<AtUri, String> { 75 74 let repo: AtIdentifier<String> = AtIdentifier::new_owned(repo) 76 75 .map_err(|error| format!("Invalid route: unsupported repository identifier: {error}"))?; ··· 84 83 .map_err(|error| format!("Invalid route: could not build thing URI: {error}")) 85 84 } 86 85 86 + #[cfg_attr(not(feature = "server"), allow(dead_code))] 87 87 fn thing_record(view: &library::ThingView) -> Result<library::thing::Thing, String> { 88 88 from_data(&view.record).map_err(|error| format!("Thing record decode failed: {error}")) 89 89 } 90 90 91 + #[cfg_attr(not(feature = "server"), allow(dead_code))] 91 92 fn model_record(view: &library::ModelView) -> Result<library::model::Model, String> { 92 93 from_data(&view.record).map_err(|error| format!("Model record decode failed: {error}")) 93 94 } 94 95 96 + #[cfg_attr(not(feature = "server"), allow(dead_code))] 95 97 fn part_record(view: &library::PartView) -> Result<library::part::Part, String> { 96 98 from_data(&view.record).map_err(|error| format!("Part record decode failed: {error}")) 97 99 } 98 100 99 - async fn load_thing_detail( 100 - client: PolymodelClient, 101 - route_uri: Result<AtUri, String>, 102 - selected_model_uri: Option<AtUri>, 103 - ) -> Result<ThingDetailData, String> { 104 - let uri = route_uri?; 105 - let thing_response = client 106 - .send(library::get_thing::GetThing::new().uri(uri).build()) 107 - .await 108 - .map_err(|error| format!("Thing request failed: {error}"))?; 109 - let output = thing_response 110 - .into_output() 111 - .map_err(|error| format!("Thing decode failed: {error}"))?; 112 - let thing_record = thing_record(&output.thing)?; 113 - let models = output 114 - .models 115 - .unwrap_or_default() 101 + #[server] 102 + async fn thing_detail_seed( 103 + repo: String, 104 + rkey: String, 105 + selected_model_uri: Option<String>, 106 + ) -> Result<ThingDetailData, ServerFnError> { 107 + let uri = thing_uri_from_route(&repo, &rkey).map_err(ServerFnError::new)?; 108 + 109 + let (thing, model_views) = 110 + crate::appview::ssr::get_thing_from_fullstack_context(uri.as_str()).await?; 111 + 112 + let thing_record = thing_record(&thing).map_err(ServerFnError::new)?; 113 + let models = model_views 116 114 .into_iter() 117 115 .map(|view| model_record(&view).map(|record| ModelDetailSummary { view, record })) 118 - .collect::<Result<Vec<_>, _>>()?; 116 + .collect::<Result<Vec<_>, _>>() 117 + .map_err(ServerFnError::new)?; 118 + 119 119 let selected_uri = 120 - selected_model_uri.or_else(|| models.first().map(|model| model.view.uri.clone())); 120 + selected_model_uri.or_else(|| models.first().map(|m| m.view.uri.as_str().to_owned())); 121 + 121 122 let selected_model = if let Some(uri) = selected_uri { 122 - let response = client 123 - .send(library::get_model::GetModel::new().uri(uri).build()) 124 - .await 125 - .map_err(|error| format!("Model request failed: {error}"))?; 126 - let output = response 127 - .into_output() 128 - .map_err(|error| format!("Model decode failed: {error}"))?; 129 - let record = model_record(&output.model)?; 130 - let parts = output 123 + let detail = crate::appview::ssr::get_model_from_fullstack_context(&uri).await?; 124 + let record = model_record(&detail.model).map_err(ServerFnError::new)?; 125 + let parts = detail 131 126 .parts 132 - .unwrap_or_default() 133 127 .into_iter() 134 128 .map(|view| part_record(&view).map(|record| PartDetail { view, record })) 135 - .collect::<Result<Vec<_>, _>>()?; 129 + .collect::<Result<Vec<_>, _>>() 130 + .map_err(ServerFnError::new)?; 136 131 Some(SelectedModelDetail { 137 - model: output.model, 132 + model: detail.model, 138 133 record, 139 134 parts, 140 135 }) ··· 143 138 }; 144 139 145 140 Ok(ThingDetailData { 146 - thing: output.thing, 141 + thing, 147 142 thing_record, 148 143 models, 149 144 selected_model, ··· 163 158 #[allow(clippy::useless_format)] 164 159 #[component] 165 160 pub(crate) fn ThingDetail(repo: String, rkey: String) -> Element { 166 - let client = use_context::<PolymodelClient>(); 167 161 let selected_model_uri = use_signal(|| None::<AtUri>); 168 - let route_uri = thing_uri_from_route(&repo, &rkey); 169 162 170 - let detail = use_resource(move || { 171 - let client = client.clone(); 172 - let route_uri = route_uri.clone(); 173 - let selected_model_uri = selected_model_uri.read().clone(); 174 - async move { load_thing_detail(client, route_uri, selected_model_uri).await } 175 - }); 163 + let detail = use_server_future(move || { 164 + let repo = repo.clone(); 165 + let rkey = rkey.clone(); 166 + let selected = selected_model_uri 167 + .read() 168 + .as_ref() 169 + .map(|uri| uri.as_str().to_owned()); 170 + thing_detail_seed(repo, rkey, selected) 171 + })?; 176 172 177 173 let detail_result = detail.read(); 178 - let state = detail_state(detail.pending(), detail_result.as_ref()); 174 + let mapped = detail_result.as_ref().map(|r| match r { 175 + Ok(data) => Ok(data.clone()), 176 + Err(ServerFnError::ServerError { message, .. }) => Err(message.clone()), 177 + Err(other) => Err(other.to_string()), 178 + }); 179 + let state = detail_state(detail.pending(), mapped.as_ref()); 179 180 180 181 rsx! { 181 182 main { class: "thing-detail-page product-shell",