diff --git a/.sqlx/query-09f1642d54bf17a0bb6b4ba69b2890b476029661eb7e224b7d8e291b691b3109.json b/.sqlx/query-09f1642d54bf17a0bb6b4ba69b2890b476029661eb7e224b7d8e291b691b3109.json deleted file mode 100644 index 242b5a2..0000000 --- a/.sqlx/query-09f1642d54bf17a0bb6b4ba69b2890b476029661eb7e224b7d8e291b691b3109.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT li.subject_uri AS \"subject_uri!\"\n FROM listitems li\n JOIN things t ON t.uri = li.subject_uri\n WHERE li.list_uri = ?\n ORDER BY li.created_at, li.did, li.rkey\n LIMIT ? OFFSET ?", - "describe": { - "columns": [ - { - "name": "subject_uri!", - "ordinal": 0, - "type_info": "Text", - "origin": { - "Table": { - "table": "listitems", - "name": "subject_uri" - } - } - } - ], - "parameters": { - "Right": 3 - }, - "nullable": [ - false - ] - }, - "hash": "09f1642d54bf17a0bb6b4ba69b2890b476029661eb7e224b7d8e291b691b3109" -} diff --git a/.sqlx/query-114ff4742bcf2b4583776214c171eb643b5253738efa0d455cb7fc7b0044cc54.json b/.sqlx/query-114ff4742bcf2b4583776214c171eb643b5253738efa0d455cb7fc7b0044cc54.json new file mode 100644 index 0000000..2361302 --- /dev/null +++ b/.sqlx/query-114ff4742bcf2b4583776214c171eb643b5253738efa0d455cb7fc7b0044cc54.json @@ -0,0 +1,198 @@ +{ + "db_name": "SQLite", + "query": "SELECT t.did AS \"did!\", t.rkey AS \"rkey!\", t.uri AS \"uri!\", t.cid AS \"cid!\",\n t.name AS \"name!\", t.summary, t.license AS \"license!\", t.tags_json, t.cover_json,\n t.derived_from_uri, t.created_at AS \"created_at!\", t.indexed_at AS \"indexed_at!\",\n t.record_json,\n COALESCE(cs.like_count, 0) AS \"like_count!: i64\",\n COALESCE(cs.save_count, 0) AS \"save_count!: i64\",\n (SELECT COUNT(*) FROM thing_models tm WHERE tm.thing_uri = t.uri) AS \"model_count!: i64\",\n (SELECT COUNT(*) FROM thing_models tm JOIN model_parts mp ON mp.model_uri = tm.model_uri WHERE tm.thing_uri = t.uri) AS \"part_count!: i64\"\n FROM listitems li\n JOIN things t ON t.uri = li.subject_uri\n LEFT JOIN content_stats cs ON cs.uri = t.uri\n WHERE li.list_uri = ?\n ORDER BY li.created_at, li.did, li.rkey\n LIMIT ? OFFSET ?", + "describe": { + "columns": [ + { + "name": "did!", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "things", + "name": "did" + } + } + }, + { + "name": "rkey!", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "things", + "name": "rkey" + } + } + }, + { + "name": "uri!", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "things", + "name": "uri" + } + } + }, + { + "name": "cid!", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "things", + "name": "cid" + } + } + }, + { + "name": "name!", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "things", + "name": "name" + } + } + }, + { + "name": "summary", + "ordinal": 5, + "type_info": "Text", + "origin": { + "Table": { + "table": "things", + "name": "summary" + } + } + }, + { + "name": "license!", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "things", + "name": "license" + } + } + }, + { + "name": "tags_json", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "things", + "name": "tags_json" + } + } + }, + { + "name": "cover_json", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "things", + "name": "cover_json" + } + } + }, + { + "name": "derived_from_uri", + "ordinal": 9, + "type_info": "Text", + "origin": { + "Table": { + "table": "things", + "name": "derived_from_uri" + } + } + }, + { + "name": "created_at!", + "ordinal": 10, + "type_info": "Integer", + "origin": { + "Table": { + "table": "things", + "name": "created_at" + } + } + }, + { + "name": "indexed_at!", + "ordinal": 11, + "type_info": "Integer", + "origin": { + "Table": { + "table": "things", + "name": "indexed_at" + } + } + }, + { + "name": "record_json", + "ordinal": 12, + "type_info": "Text", + "origin": { + "Table": { + "table": "things", + "name": "record_json" + } + } + }, + { + "name": "like_count!: i64", + "ordinal": 13, + "type_info": "Integer", + "origin": "Expression" + }, + { + "name": "save_count!: i64", + "ordinal": 14, + "type_info": "Integer", + "origin": "Expression" + }, + { + "name": "model_count!: i64", + "ordinal": 15, + "type_info": "Integer", + "origin": "Expression" + }, + { + "name": "part_count!: i64", + "ordinal": 16, + "type_info": "Integer", + "origin": "Expression" + } + ], + "parameters": { + "Right": 3 + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + false, + true, + true, + true, + false, + false, + true, + false, + false, + false, + false + ] + }, + "hash": "114ff4742bcf2b4583776214c171eb643b5253738efa0d455cb7fc7b0044cc54" +} diff --git a/src/appview/ssr.rs b/src/appview/ssr.rs index 3bf788c..1b3694c 100644 --- a/src/appview/ssr.rs +++ b/src/appview/ssr.rs @@ -7,13 +7,10 @@ use axum::extract::FromRequestParts; use dioxus::prelude::{ServerFnError, dioxus_fullstack::FullstackContext}; -use http::HeaderValue; use jacquard::identity::PublicResolver; use jacquard::oauth::client::OAuthSession; use jacquard_axum::oauth::ExtractOptionalOAuthSession; -use jacquard_common::session::SessionKey; use jacquard_common::types::ident::AtIdentifier; -use jacquard_common::xrpc::CallOptions; use polymodel_api::space_polymodel::actor::ProfileViewUnion; use polymodel_api::space_polymodel::actor::get_session::GetSessionOutput; use polymodel_api::space_polymodel::library::FeedView; @@ -87,7 +84,7 @@ pub(crate) async fn get_model_from_fullstack_context( } /// Resolve a feed server-side, same path as `getFeed`. The `algorithm` string -/// dispatches exactly as the XRPC handler: `"recent"` or `"hot"`. +/// dispatches exactly as the XRPC handler. #[cfg_attr(not(feature = "server"), allow(dead_code))] pub(crate) async fn get_feed_from_fullstack_context( algorithm: &str, @@ -101,6 +98,10 @@ pub(crate) async fn get_feed_from_fullstack_context( let feed = match algorithm { "recent" => views::feed_recent(&state, limit, None, viewer).await, "hot" => views::feed_hot(&state, limit, None, viewer).await, + "following" => match viewer { + Some(viewer) => views::feed_following(&state, viewer, limit, None).await, + None => Ok(views::empty_feed()), + }, other => return Err(ServerFnError::new(format!("unknown algorithm: {other}"))), }; feed.map_err(|error| ServerFnError::new(format!("{error:?}"))) @@ -211,23 +212,3 @@ pub(crate) async fn extract_optional_oauth_session( .map(|ExtractOptionalOAuthSession(session)| session) .map_err(|error| ServerFnError::new(error.to_string())) } - -/// Build Jacquard call options carrying the encoded session-key header. -/// -/// Use this only when a server-side outbound XRPC hop is unavoidable. Normal -/// SSR/server-function appview reads should stay in-process via the helpers -/// above. -#[allow(dead_code)] -pub fn session_key_call_options( - state: &AppState, - key: &SessionKey, -) -> Result { - let encoded = jacquard_axum::oauth::encode_session_key(key) - .map_err(|error| ServerFnError::new(error.to_string()))?; - let value = - HeaderValue::from_str(&encoded).map_err(|error| ServerFnError::new(error.to_string()))?; - Ok(CallOptions { - extra_headers: vec![(state.oauth_config.session_header.clone(), value)], - ..CallOptions::default() - }) -} diff --git a/src/appview/tests.rs b/src/appview/tests.rs index 08e823f..af37bf6 100644 --- a/src/appview/tests.rs +++ b/src/appview/tests.rs @@ -948,6 +948,53 @@ async fn fullstack_context_bridge_returns_authenticated_session_from_header() { } } +#[tokio::test] +async fn fullstack_context_feed_helper_supports_following() { + let state = state().await; + seed_identity(&state.pool, DID_A, "alice.com").await; + seed_identity(&state.pool, DID_B, "bob.com").await; + seed_thing( + &state.pool, + DID_B, + "3bbbbbbbbbbbb", + "followed project", + &[], + 0, + ) + .await; + sqlx::query( + "INSERT INTO follows (follower_did, followed_did, rkey, cid, created_at) VALUES (?, ?, ?, ?, ?)", + ) + .bind(DID_A) + .bind(DID_B) + .bind("followb") + .bind("bafyreifollowb") + .bind(1_i64) + .execute(&state.pool) + .await + .unwrap(); + let key = seed_oauth_session(&state, DID_A, "fullstack-following-session").await; + let encoded = jacquard_axum::oauth::encode_session_key(&key).unwrap(); + let header_name = state.oauth_config.session_header.clone(); + + let mut parts = Request::builder() + .uri("/") + .header(header_name, encoded) + .body(()) + .unwrap() + .into_parts() + .0; + parts.extensions.insert(state); + + let feed = FullstackContext::new(parts) + .scope(super::ssr::get_feed_from_fullstack_context("following", 24)) + .await + .unwrap(); + + assert_eq!(feed.items.len(), 1); + assert_eq!(feed.items[0].thing.name.as_str(), "followed project"); +} + #[tokio::test] async fn get_thing_route_decodes_query_and_404s_when_missing() { let state = state().await; diff --git a/src/appview/views.rs b/src/appview/views.rs index 7588cd2..60a2663 100644 --- a/src/appview/views.rs +++ b/src/appview/views.rs @@ -519,8 +519,7 @@ async fn part_view( // getThing / getModel lookups // --------------------------------------------------------------------------- -/// Fetch a thing row by URI (with stats + model count). Shared by getThing and -/// getList's per-subject hydration. +/// Fetch a thing row by URI (with stats + model count). async fn fetch_thing_by_uri(state: &AppState, uri: &str) -> AppResult> { db(sqlx::query_as!( ThingRow, @@ -1147,11 +1146,7 @@ pub(super) async fn search_things( .fetch_all(&state.pool) .await, )?; - let cursor = if rows.len() as i64 > limit { - Some((offset + limit).to_string()) - } else { - None - }; + let cursor = offset_page_cursor(rows.len(), limit, offset); let page: Vec = rows.into_iter().take(limit as usize).collect(); feed_view(state, page, cursor, viewer_did).await } @@ -1173,6 +1168,10 @@ fn parse_offset(cursor: Option<&str>) -> AppResult { } } +fn offset_page_cursor(row_count: usize, limit: i64, offset: i64) -> Option { + (row_count as i64 > limit).then(|| (offset + limit).to_string()) +} + // --------------------------------------------------------------------------- // getList (thingViewBasic[] from listitems) // --------------------------------------------------------------------------- @@ -1186,49 +1185,36 @@ pub(super) async fn list_things( ) -> AppResult<(Vec, Option)> { let offset = parse_offset(cursor)?; let fetch = limit + 1; - #[derive(sqlx::FromRow)] - struct ListItemRow { - subject_uri: String, - } let rows = db(sqlx::query_as!( - ListItemRow, - r#"SELECT li.subject_uri AS "subject_uri!" - FROM listitems li - JOIN things t ON t.uri = li.subject_uri - WHERE li.list_uri = ? - ORDER BY li.created_at, li.did, li.rkey - LIMIT ? OFFSET ?"#, + ThingRow, + r#"SELECT t.did AS "did!", t.rkey AS "rkey!", t.uri AS "uri!", t.cid AS "cid!", + t.name AS "name!", t.summary, t.license AS "license!", t.tags_json, t.cover_json, + t.derived_from_uri, t.created_at AS "created_at!", t.indexed_at AS "indexed_at!", + t.record_json, + COALESCE(cs.like_count, 0) AS "like_count!: i64", + COALESCE(cs.save_count, 0) AS "save_count!: i64", + (SELECT COUNT(*) FROM thing_models tm WHERE tm.thing_uri = t.uri) AS "model_count!: i64", + (SELECT COUNT(*) FROM thing_models tm JOIN model_parts mp ON mp.model_uri = tm.model_uri WHERE tm.thing_uri = t.uri) AS "part_count!: i64" + FROM listitems li + JOIN things t ON t.uri = li.subject_uri + LEFT JOIN content_stats cs ON cs.uri = t.uri + WHERE li.list_uri = ? + ORDER BY li.created_at, li.did, li.rkey + LIMIT ? OFFSET ?"#, list_uri, fetch, offset, ) .fetch_all(&state.pool) .await)?; - let cursor = if rows.len() as i64 > limit { - Some((offset + limit).to_string()) - } else { - None - }; + let cursor = offset_page_cursor(rows.len(), limit, offset); let mut items = Vec::with_capacity(rows.len().min(limit as usize)); - for li in rows.into_iter().take(limit as usize) { - if let Some(thing) = thing_basic_by_uri(state, &li.subject_uri, viewer_did.clone()).await? { - items.push(thing); - } + for row in rows.into_iter().take(limit as usize) { + items.push(thing_basic_view(state, row, viewer_did.clone()).await?); } Ok((items, cursor)) } -async fn thing_basic_by_uri( - state: &AppState, - uri: &str, - viewer_did: Option>, -) -> AppResult> { - match fetch_thing_by_uri(state, uri).await? { - Some(row) => Ok(Some(thing_basic_view(state, row, viewer_did).await?)), - None => Ok(None), - } -} - // --------------------------------------------------------------------------- // getProfile (polymodel variant) // --------------------------------------------------------------------------- diff --git a/src/main.rs b/src/main.rs index 4054629..2b67ac3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -160,6 +160,18 @@ fn App() -> Element { if dioxus::prelude::dioxus_fullstack::FullstackContext::current().is_some() { return; } + + match session.read().clone() { + SessionIdentity::Authenticated(_) => { + if let Err(error) = client.send(BootstrapProfile).await { + tracing::warn!(%error, "failed to bootstrap Polymodel profile"); + } + return; + } + SessionIdentity::Anonymous => return, + SessionIdentity::Unknown => {} + } + match client.send(GetSession).await { Ok(response) => match response.into_output() { Ok(output) => { diff --git a/src/profile.rs b/src/profile.rs index a48f81b..b812311 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -5,14 +5,13 @@ use crate::thing_card::ThingCard; use crate::thing_detail::thing_detail_href; use dioxus::prelude::*; use jacquard_common::deps::smol_str::SmolStr; -use jacquard_common::types::string::{AtIdentifier, Did, Handle, UriValue}; +use jacquard_common::types::string::{Did, Handle, UriValue}; use jacquard_common::xrpc::XrpcClient; -use polymodel_api::space_polymodel::actor::{ProfileViewUnion, get_profile::GetProfile}; +use polymodel_api::space_polymodel::actor::ProfileViewUnion; use polymodel_api::space_polymodel::graph::{ create_follow::CreateFollow, delete_follow::DeleteFollow, }; use polymodel_api::space_polymodel::library; -use polymodel_api::space_polymodel::library::get_author_things::GetAuthorThings; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum ProfileTab { @@ -166,6 +165,7 @@ fn optional_smolstr(value: &Option>) -> Option { .map(SmolStr::from) } +#[cfg_attr(not(any(feature = "server", test)), allow(dead_code))] fn profile_lists( value: &ProfileViewUnion, ) -> Vec { @@ -243,41 +243,6 @@ fn adjust_count(current: i64, was_active: bool, is_active: bool) -> i64 { } } -async fn load_profile(client: PolymodelClient, actor_str: &str) -> Result { - let actor = AtIdentifier::new_owned(actor_str) - .map_err(|error| format!("Invalid actor: unsupported actor identifier: {error}"))?; - - let profile_response = client - .send(GetProfile::new().actor(actor.clone()).build()) - .await - .map_err(|error| format!("Profile request failed: {error}"))?; - let profile_output = profile_response - .into_output() - .map_err(|error| format!("Profile decode failed: {error}"))?; - let header = profile_header(&profile_output.value); - let lists = profile_lists(&profile_output.value); - - let things_response = client - .send(GetAuthorThings::new().actor(actor).limit(24).build()) - .await - .map_err(|error| format!("Author things request failed: {error}"))?; - let things_output = things_response - .into_output() - .map_err(|error| format!("Author things decode failed: {error}"))?; - let things = things_output - .value - .items - .into_iter() - .map(|item| item.thing) - .collect(); - - Ok(ProfileData { - header, - things, - lists, - }) -} - #[allow(clippy::useless_format)] #[component] pub(crate) fn Profile(actor: String) -> Element { diff --git a/src/thing_detail.rs b/src/thing_detail.rs index e7075d6..db5685f 100644 --- a/src/thing_detail.rs +++ b/src/thing_detail.rs @@ -91,6 +91,7 @@ fn part_download_href(uri: &AtUri) -> Option { )) } +#[cfg_attr(not(feature = "server"), allow(dead_code))] fn thing_uri_from_route(repo: &str, rkey: &str) -> Result { let repo: AtIdentifier = AtIdentifier::new_owned(repo) .map_err(|error| format!("Invalid route: unsupported repository identifier: {error}"))?;