atproto Thingiverse but good
10

Configure Feed

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

some cleanups post-merge

Orual (Jun 29, 2026, 7:06 PM EDT) 6d265cc3 bf58a463

+290 -126
-26
.sqlx/query-09f1642d54bf17a0bb6b4ba69b2890b476029661eb7e224b7d8e291b691b3109.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "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 ?", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "name": "subject_uri!", 8 - "ordinal": 0, 9 - "type_info": "Text", 10 - "origin": { 11 - "Table": { 12 - "table": "listitems", 13 - "name": "subject_uri" 14 - } 15 - } 16 - } 17 - ], 18 - "parameters": { 19 - "Right": 3 20 - }, 21 - "nullable": [ 22 - false 23 - ] 24 - }, 25 - "hash": "09f1642d54bf17a0bb6b4ba69b2890b476029661eb7e224b7d8e291b691b3109" 26 - }
+198
.sqlx/query-114ff4742bcf2b4583776214c171eb643b5253738efa0d455cb7fc7b0044cc54.json
··· 1 + { 2 + "db_name": "SQLite", 3 + "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 ?", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "name": "did!", 8 + "ordinal": 0, 9 + "type_info": "Text", 10 + "origin": { 11 + "Table": { 12 + "table": "things", 13 + "name": "did" 14 + } 15 + } 16 + }, 17 + { 18 + "name": "rkey!", 19 + "ordinal": 1, 20 + "type_info": "Text", 21 + "origin": { 22 + "Table": { 23 + "table": "things", 24 + "name": "rkey" 25 + } 26 + } 27 + }, 28 + { 29 + "name": "uri!", 30 + "ordinal": 2, 31 + "type_info": "Text", 32 + "origin": { 33 + "Table": { 34 + "table": "things", 35 + "name": "uri" 36 + } 37 + } 38 + }, 39 + { 40 + "name": "cid!", 41 + "ordinal": 3, 42 + "type_info": "Text", 43 + "origin": { 44 + "Table": { 45 + "table": "things", 46 + "name": "cid" 47 + } 48 + } 49 + }, 50 + { 51 + "name": "name!", 52 + "ordinal": 4, 53 + "type_info": "Text", 54 + "origin": { 55 + "Table": { 56 + "table": "things", 57 + "name": "name" 58 + } 59 + } 60 + }, 61 + { 62 + "name": "summary", 63 + "ordinal": 5, 64 + "type_info": "Text", 65 + "origin": { 66 + "Table": { 67 + "table": "things", 68 + "name": "summary" 69 + } 70 + } 71 + }, 72 + { 73 + "name": "license!", 74 + "ordinal": 6, 75 + "type_info": "Text", 76 + "origin": { 77 + "Table": { 78 + "table": "things", 79 + "name": "license" 80 + } 81 + } 82 + }, 83 + { 84 + "name": "tags_json", 85 + "ordinal": 7, 86 + "type_info": "Text", 87 + "origin": { 88 + "Table": { 89 + "table": "things", 90 + "name": "tags_json" 91 + } 92 + } 93 + }, 94 + { 95 + "name": "cover_json", 96 + "ordinal": 8, 97 + "type_info": "Text", 98 + "origin": { 99 + "Table": { 100 + "table": "things", 101 + "name": "cover_json" 102 + } 103 + } 104 + }, 105 + { 106 + "name": "derived_from_uri", 107 + "ordinal": 9, 108 + "type_info": "Text", 109 + "origin": { 110 + "Table": { 111 + "table": "things", 112 + "name": "derived_from_uri" 113 + } 114 + } 115 + }, 116 + { 117 + "name": "created_at!", 118 + "ordinal": 10, 119 + "type_info": "Integer", 120 + "origin": { 121 + "Table": { 122 + "table": "things", 123 + "name": "created_at" 124 + } 125 + } 126 + }, 127 + { 128 + "name": "indexed_at!", 129 + "ordinal": 11, 130 + "type_info": "Integer", 131 + "origin": { 132 + "Table": { 133 + "table": "things", 134 + "name": "indexed_at" 135 + } 136 + } 137 + }, 138 + { 139 + "name": "record_json", 140 + "ordinal": 12, 141 + "type_info": "Text", 142 + "origin": { 143 + "Table": { 144 + "table": "things", 145 + "name": "record_json" 146 + } 147 + } 148 + }, 149 + { 150 + "name": "like_count!: i64", 151 + "ordinal": 13, 152 + "type_info": "Integer", 153 + "origin": "Expression" 154 + }, 155 + { 156 + "name": "save_count!: i64", 157 + "ordinal": 14, 158 + "type_info": "Integer", 159 + "origin": "Expression" 160 + }, 161 + { 162 + "name": "model_count!: i64", 163 + "ordinal": 15, 164 + "type_info": "Integer", 165 + "origin": "Expression" 166 + }, 167 + { 168 + "name": "part_count!: i64", 169 + "ordinal": 16, 170 + "type_info": "Integer", 171 + "origin": "Expression" 172 + } 173 + ], 174 + "parameters": { 175 + "Right": 3 176 + }, 177 + "nullable": [ 178 + false, 179 + false, 180 + false, 181 + false, 182 + false, 183 + true, 184 + false, 185 + true, 186 + true, 187 + true, 188 + false, 189 + false, 190 + true, 191 + false, 192 + false, 193 + false, 194 + false 195 + ] 196 + }, 197 + "hash": "114ff4742bcf2b4583776214c171eb643b5253738efa0d455cb7fc7b0044cc54" 198 + }
+5 -24
src/appview/ssr.rs
··· 7 7 8 8 use axum::extract::FromRequestParts; 9 9 use dioxus::prelude::{ServerFnError, dioxus_fullstack::FullstackContext}; 10 - use http::HeaderValue; 11 10 use jacquard::identity::PublicResolver; 12 11 use jacquard::oauth::client::OAuthSession; 13 12 use jacquard_axum::oauth::ExtractOptionalOAuthSession; 14 - use jacquard_common::session::SessionKey; 15 13 use jacquard_common::types::ident::AtIdentifier; 16 - use jacquard_common::xrpc::CallOptions; 17 14 use polymodel_api::space_polymodel::actor::ProfileViewUnion; 18 15 use polymodel_api::space_polymodel::actor::get_session::GetSessionOutput; 19 16 use polymodel_api::space_polymodel::library::FeedView; ··· 87 84 } 88 85 89 86 /// Resolve a feed server-side, same path as `getFeed`. The `algorithm` string 90 - /// dispatches exactly as the XRPC handler: `"recent"` or `"hot"`. 87 + /// dispatches exactly as the XRPC handler. 91 88 #[cfg_attr(not(feature = "server"), allow(dead_code))] 92 89 pub(crate) async fn get_feed_from_fullstack_context( 93 90 algorithm: &str, ··· 101 98 let feed = match algorithm { 102 99 "recent" => views::feed_recent(&state, limit, None, viewer).await, 103 100 "hot" => views::feed_hot(&state, limit, None, viewer).await, 101 + "following" => match viewer { 102 + Some(viewer) => views::feed_following(&state, viewer, limit, None).await, 103 + None => Ok(views::empty_feed()), 104 + }, 104 105 other => return Err(ServerFnError::new(format!("unknown algorithm: {other}"))), 105 106 }; 106 107 feed.map_err(|error| ServerFnError::new(format!("{error:?}"))) ··· 211 212 .map(|ExtractOptionalOAuthSession(session)| session) 212 213 .map_err(|error| ServerFnError::new(error.to_string())) 213 214 } 214 - 215 - /// Build Jacquard call options carrying the encoded session-key header. 216 - /// 217 - /// Use this only when a server-side outbound XRPC hop is unavoidable. Normal 218 - /// SSR/server-function appview reads should stay in-process via the helpers 219 - /// above. 220 - #[allow(dead_code)] 221 - pub fn session_key_call_options( 222 - state: &AppState, 223 - key: &SessionKey, 224 - ) -> Result<CallOptions, ServerFnError> { 225 - let encoded = jacquard_axum::oauth::encode_session_key(key) 226 - .map_err(|error| ServerFnError::new(error.to_string()))?; 227 - let value = 228 - HeaderValue::from_str(&encoded).map_err(|error| ServerFnError::new(error.to_string()))?; 229 - Ok(CallOptions { 230 - extra_headers: vec![(state.oauth_config.session_header.clone(), value)], 231 - ..CallOptions::default() 232 - }) 233 - }
+47
src/appview/tests.rs
··· 949 949 } 950 950 951 951 #[tokio::test] 952 + async fn fullstack_context_feed_helper_supports_following() { 953 + let state = state().await; 954 + seed_identity(&state.pool, DID_A, "alice.com").await; 955 + seed_identity(&state.pool, DID_B, "bob.com").await; 956 + seed_thing( 957 + &state.pool, 958 + DID_B, 959 + "3bbbbbbbbbbbb", 960 + "followed project", 961 + &[], 962 + 0, 963 + ) 964 + .await; 965 + sqlx::query( 966 + "INSERT INTO follows (follower_did, followed_did, rkey, cid, created_at) VALUES (?, ?, ?, ?, ?)", 967 + ) 968 + .bind(DID_A) 969 + .bind(DID_B) 970 + .bind("followb") 971 + .bind("bafyreifollowb") 972 + .bind(1_i64) 973 + .execute(&state.pool) 974 + .await 975 + .unwrap(); 976 + let key = seed_oauth_session(&state, DID_A, "fullstack-following-session").await; 977 + let encoded = jacquard_axum::oauth::encode_session_key(&key).unwrap(); 978 + let header_name = state.oauth_config.session_header.clone(); 979 + 980 + let mut parts = Request::builder() 981 + .uri("/") 982 + .header(header_name, encoded) 983 + .body(()) 984 + .unwrap() 985 + .into_parts() 986 + .0; 987 + parts.extensions.insert(state); 988 + 989 + let feed = FullstackContext::new(parts) 990 + .scope(super::ssr::get_feed_from_fullstack_context("following", 24)) 991 + .await 992 + .unwrap(); 993 + 994 + assert_eq!(feed.items.len(), 1); 995 + assert_eq!(feed.items[0].thing.name.as_str(), "followed project"); 996 + } 997 + 998 + #[tokio::test] 952 999 async fn get_thing_route_decodes_query_and_404s_when_missing() { 953 1000 let state = state().await; 954 1001 let app = super::router().with_state(state);
+24 -38
src/appview/views.rs
··· 519 519 // getThing / getModel lookups 520 520 // --------------------------------------------------------------------------- 521 521 522 - /// Fetch a thing row by URI (with stats + model count). Shared by getThing and 523 - /// getList's per-subject hydration. 522 + /// Fetch a thing row by URI (with stats + model count). 524 523 async fn fetch_thing_by_uri(state: &AppState, uri: &str) -> AppResult<Option<ThingRow>> { 525 524 db(sqlx::query_as!( 526 525 ThingRow, ··· 1147 1146 .fetch_all(&state.pool) 1148 1147 .await, 1149 1148 )?; 1150 - let cursor = if rows.len() as i64 > limit { 1151 - Some((offset + limit).to_string()) 1152 - } else { 1153 - None 1154 - }; 1149 + let cursor = offset_page_cursor(rows.len(), limit, offset); 1155 1150 let page: Vec<ThingRow> = rows.into_iter().take(limit as usize).collect(); 1156 1151 feed_view(state, page, cursor, viewer_did).await 1157 1152 } ··· 1173 1168 } 1174 1169 } 1175 1170 1171 + fn offset_page_cursor(row_count: usize, limit: i64, offset: i64) -> Option<String> { 1172 + (row_count as i64 > limit).then(|| (offset + limit).to_string()) 1173 + } 1174 + 1176 1175 // --------------------------------------------------------------------------- 1177 1176 // getList (thingViewBasic[] from listitems) 1178 1177 // --------------------------------------------------------------------------- ··· 1186 1185 ) -> AppResult<(Vec<ThingViewBasic>, Option<String>)> { 1187 1186 let offset = parse_offset(cursor)?; 1188 1187 let fetch = limit + 1; 1189 - #[derive(sqlx::FromRow)] 1190 - struct ListItemRow { 1191 - subject_uri: String, 1192 - } 1193 1188 let rows = db(sqlx::query_as!( 1194 - ListItemRow, 1195 - r#"SELECT li.subject_uri AS "subject_uri!" 1196 - FROM listitems li 1197 - JOIN things t ON t.uri = li.subject_uri 1198 - WHERE li.list_uri = ? 1199 - ORDER BY li.created_at, li.did, li.rkey 1200 - LIMIT ? OFFSET ?"#, 1189 + ThingRow, 1190 + r#"SELECT t.did AS "did!", t.rkey AS "rkey!", t.uri AS "uri!", t.cid AS "cid!", 1191 + t.name AS "name!", t.summary, t.license AS "license!", t.tags_json, t.cover_json, 1192 + t.derived_from_uri, t.created_at AS "created_at!", t.indexed_at AS "indexed_at!", 1193 + t.record_json, 1194 + COALESCE(cs.like_count, 0) AS "like_count!: i64", 1195 + COALESCE(cs.save_count, 0) AS "save_count!: i64", 1196 + (SELECT COUNT(*) FROM thing_models tm WHERE tm.thing_uri = t.uri) AS "model_count!: i64", 1197 + (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" 1198 + FROM listitems li 1199 + JOIN things t ON t.uri = li.subject_uri 1200 + LEFT JOIN content_stats cs ON cs.uri = t.uri 1201 + WHERE li.list_uri = ? 1202 + ORDER BY li.created_at, li.did, li.rkey 1203 + LIMIT ? OFFSET ?"#, 1201 1204 list_uri, 1202 1205 fetch, 1203 1206 offset, 1204 1207 ) 1205 1208 .fetch_all(&state.pool) 1206 1209 .await)?; 1207 - let cursor = if rows.len() as i64 > limit { 1208 - Some((offset + limit).to_string()) 1209 - } else { 1210 - None 1211 - }; 1210 + let cursor = offset_page_cursor(rows.len(), limit, offset); 1212 1211 let mut items = Vec::with_capacity(rows.len().min(limit as usize)); 1213 - for li in rows.into_iter().take(limit as usize) { 1214 - if let Some(thing) = thing_basic_by_uri(state, &li.subject_uri, viewer_did.clone()).await? { 1215 - items.push(thing); 1216 - } 1212 + for row in rows.into_iter().take(limit as usize) { 1213 + items.push(thing_basic_view(state, row, viewer_did.clone()).await?); 1217 1214 } 1218 1215 Ok((items, cursor)) 1219 - } 1220 - 1221 - async fn thing_basic_by_uri( 1222 - state: &AppState, 1223 - uri: &str, 1224 - viewer_did: Option<Did<&str>>, 1225 - ) -> AppResult<Option<ThingViewBasic>> { 1226 - match fetch_thing_by_uri(state, uri).await? { 1227 - Some(row) => Ok(Some(thing_basic_view(state, row, viewer_did).await?)), 1228 - None => Ok(None), 1229 - } 1230 1216 } 1231 1217 1232 1218 // ---------------------------------------------------------------------------
+12
src/main.rs
··· 160 160 if dioxus::prelude::dioxus_fullstack::FullstackContext::current().is_some() { 161 161 return; 162 162 } 163 + 164 + match session.read().clone() { 165 + SessionIdentity::Authenticated(_) => { 166 + if let Err(error) = client.send(BootstrapProfile).await { 167 + tracing::warn!(%error, "failed to bootstrap Polymodel profile"); 168 + } 169 + return; 170 + } 171 + SessionIdentity::Anonymous => return, 172 + SessionIdentity::Unknown => {} 173 + } 174 + 163 175 match client.send(GetSession).await { 164 176 Ok(response) => match response.into_output() { 165 177 Ok(output) => {
+3 -38
src/profile.rs
··· 5 5 use crate::thing_detail::thing_detail_href; 6 6 use dioxus::prelude::*; 7 7 use jacquard_common::deps::smol_str::SmolStr; 8 - use jacquard_common::types::string::{AtIdentifier, Did, Handle, UriValue}; 8 + use jacquard_common::types::string::{Did, Handle, UriValue}; 9 9 use jacquard_common::xrpc::XrpcClient; 10 - use polymodel_api::space_polymodel::actor::{ProfileViewUnion, get_profile::GetProfile}; 10 + use polymodel_api::space_polymodel::actor::ProfileViewUnion; 11 11 use polymodel_api::space_polymodel::graph::{ 12 12 create_follow::CreateFollow, delete_follow::DeleteFollow, 13 13 }; 14 14 use polymodel_api::space_polymodel::library; 15 - use polymodel_api::space_polymodel::library::get_author_things::GetAuthorThings; 16 15 17 16 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 18 17 enum ProfileTab { ··· 166 165 .map(SmolStr::from) 167 166 } 168 167 168 + #[cfg_attr(not(any(feature = "server", test)), allow(dead_code))] 169 169 fn profile_lists( 170 170 value: &ProfileViewUnion, 171 171 ) -> Vec<polymodel_api::space_polymodel::actor::ProfileListView> { ··· 241 241 (true, false) => current.saturating_sub(1), 242 242 _ => current, 243 243 } 244 - } 245 - 246 - async fn load_profile(client: PolymodelClient, actor_str: &str) -> Result<ProfileData, String> { 247 - let actor = AtIdentifier::new_owned(actor_str) 248 - .map_err(|error| format!("Invalid actor: unsupported actor identifier: {error}"))?; 249 - 250 - let profile_response = client 251 - .send(GetProfile::new().actor(actor.clone()).build()) 252 - .await 253 - .map_err(|error| format!("Profile request failed: {error}"))?; 254 - let profile_output = profile_response 255 - .into_output() 256 - .map_err(|error| format!("Profile decode failed: {error}"))?; 257 - let header = profile_header(&profile_output.value); 258 - let lists = profile_lists(&profile_output.value); 259 - 260 - let things_response = client 261 - .send(GetAuthorThings::new().actor(actor).limit(24).build()) 262 - .await 263 - .map_err(|error| format!("Author things request failed: {error}"))?; 264 - let things_output = things_response 265 - .into_output() 266 - .map_err(|error| format!("Author things decode failed: {error}"))?; 267 - let things = things_output 268 - .value 269 - .items 270 - .into_iter() 271 - .map(|item| item.thing) 272 - .collect(); 273 - 274 - Ok(ProfileData { 275 - header, 276 - things, 277 - lists, 278 - }) 279 244 } 280 245 281 246 #[allow(clippy::useless_format)]
+1
src/thing_detail.rs
··· 91 91 )) 92 92 } 93 93 94 + #[cfg_attr(not(feature = "server"), allow(dead_code))] 94 95 fn thing_uri_from_route(repo: &str, rkey: &str) -> Result<AtUri, String> { 95 96 let repo: AtIdentifier<String> = AtIdentifier::new_owned(repo) 96 97 .map_err(|error| format!("Invalid route: unsupported repository identifier: {error}"))?;