auth impl record view

Orual e8840cba 9cdd0379

+2628 -859
+26
Cargo.lock
··· 3268 ] 3269 3270 [[package]] 3271 name = "gloo-timers" 3272 version = "0.3.0" 3273 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4084 "smol_str", 4085 "thiserror 2.0.17", 4086 "tokio", 4087 "trait-variant", 4088 "url", 4089 "webpage", ··· 4163 "tokio", 4164 "tokio-tungstenite-wasm", 4165 "tokio-util", 4166 "trait-variant", 4167 "url", 4168 "zstd", ··· 4200 "serde_json", 4201 "thiserror 2.0.17", 4202 "tokio", 4203 "trait-variant", 4204 "url", 4205 "urlencoding", ··· 4257 "smol_str", 4258 "thiserror 2.0.17", 4259 "tokio", 4260 "trait-variant", 4261 "url", 4262 "webbrowser", ··· 8642 "diesel_migrations", 8643 "dioxus", 8644 "dioxus-free-icons", 8645 "dioxus-primitives", 8646 "hex_fmt", 8647 "humansize", 8648 "jacquard", 8649 "jacquard-axum", ··· 8654 "n0-future", 8655 "reqwest", 8656 "serde", 8657 "serde_json", 8658 "sqlite-wasm-rs", 8659 "time", 8660 "wasm-bindgen", 8661 "wasm-bindgen-futures", 8662 "weaver-api", 8663 "weaver-common", 8664 "weaver-renderer", 8665 "web-sys", 8666 ] 8667 8668 [[package]]
··· 3268 ] 3269 3270 [[package]] 3271 + name = "gloo-storage" 3272 + version = "0.3.0" 3273 + source = "registry+https://github.com/rust-lang/crates.io-index" 3274 + checksum = "fbc8031e8c92758af912f9bc08fbbadd3c6f3cfcbf6b64cdf3d6a81f0139277a" 3275 + dependencies = [ 3276 + "gloo-utils", 3277 + "js-sys", 3278 + "serde", 3279 + "serde_json", 3280 + "thiserror 1.0.69", 3281 + "wasm-bindgen", 3282 + "web-sys", 3283 + ] 3284 + 3285 + [[package]] 3286 name = "gloo-timers" 3287 version = "0.3.0" 3288 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4099 "smol_str", 4100 "thiserror 2.0.17", 4101 "tokio", 4102 + "tracing", 4103 "trait-variant", 4104 "url", 4105 "webpage", ··· 4179 "tokio", 4180 "tokio-tungstenite-wasm", 4181 "tokio-util", 4182 + "tracing", 4183 "trait-variant", 4184 "url", 4185 "zstd", ··· 4217 "serde_json", 4218 "thiserror 2.0.17", 4219 "tokio", 4220 + "tracing", 4221 "trait-variant", 4222 "url", 4223 "urlencoding", ··· 4275 "smol_str", 4276 "thiserror 2.0.17", 4277 "tokio", 4278 + "tracing", 4279 "trait-variant", 4280 "url", 4281 "webbrowser", ··· 8661 "diesel_migrations", 8662 "dioxus", 8663 "dioxus-free-icons", 8664 + "dioxus-logger", 8665 "dioxus-primitives", 8666 + "dotenvy", 8667 + "gloo-storage", 8668 "hex_fmt", 8669 + "http", 8670 "humansize", 8671 "jacquard", 8672 "jacquard-axum", ··· 8677 "n0-future", 8678 "reqwest", 8679 "serde", 8680 + "serde_html_form", 8681 "serde_json", 8682 "sqlite-wasm-rs", 8683 "time", 8684 + "tokio", 8685 "wasm-bindgen", 8686 "wasm-bindgen-futures", 8687 "weaver-api", 8688 "weaver-common", 8689 "weaver-renderer", 8690 "web-sys", 8691 + "webbrowser", 8692 ] 8693 8694 [[package]]
+1 -1
Cargo.toml
··· 47 # jacquard-derive = { git = "https://tangled.org/@nonbinary.computer/jacquard" } 48 # jacquard-lexicon = { git = "https://tangled.org/@nonbinary.computer/jacquard", default-features = false } 49 50 - jacquard = { path = "../jacquard/crates/jacquard", default-features = false, features = ["derive", "api_bluesky"] } 51 jacquard-api = { path = "../jacquard/crates/jacquard-api" } 52 jacquard-common = { path = "../jacquard/crates/jacquard-common" } 53 jacquard-axum = {path = "../jacquard/crates/jacquard-axum" }
··· 47 # jacquard-derive = { git = "https://tangled.org/@nonbinary.computer/jacquard" } 48 # jacquard-lexicon = { git = "https://tangled.org/@nonbinary.computer/jacquard", default-features = false } 49 50 + jacquard = { path = "../jacquard/crates/jacquard", default-features = false, features = ["derive", "api_bluesky", "tracing"] } 51 jacquard-api = { path = "../jacquard/crates/jacquard-api" } 52 jacquard-common = { path = "../jacquard/crates/jacquard-common" } 53 jacquard-axum = {path = "../jacquard/crates/jacquard-axum" }
+12 -3
crates/weaver-app/Cargo.toml
··· 2 name = "weaver-app" 3 version = "0.1.0" 4 authors = ["Orual <orual@nonbinary.computer>"] 5 - edition = "2021" 6 7 [features] 8 default = ["web", "fullstack-server", "no-app-index"] ··· 14 desktop = ["dioxus/desktop"] 15 mobile = ["dioxus/mobile"] 16 server = [ "dioxus/server", "dep:jacquard-axum", "dep:axum"] 17 - 18 - 19 20 21 [dependencies] ··· 39 serde_json = "1.0" 40 hex_fmt = "0.3" 41 humansize = "2.0.0" 42 reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } 43 dioxus-free-icons = { version = "0.10.0" } 44 diesel = { version = "2.3", features = ["sqlite", "returning_clauses_for_sqlite_3_35", "chrono", "serde_json"] } 45 diesel_migrations = { version = "2.3", features = ["sqlite"] } 46 47 48 [target.'cfg(all(target_family = "wasm", target_os = "unknown"))'.dependencies] ··· 55 wasm-bindgen-futures = "0.4" 56 web-sys = { version = "0.3", features = ["ServiceWorkerContainer", "ServiceWorker", "ServiceWorkerRegistration", "RegistrationOptions", "Window", "Navigator", "MessageEvent", "console"] } 57 js-sys = "0.3"
··· 2 name = "weaver-app" 3 version = "0.1.0" 4 authors = ["Orual <orual@nonbinary.computer>"] 5 + edition = "2024" 6 7 [features] 8 default = ["web", "fullstack-server", "no-app-index"] ··· 14 desktop = ["dioxus/desktop"] 15 mobile = ["dioxus/mobile"] 16 server = [ "dioxus/server", "dep:jacquard-axum", "dep:axum"] 17 18 19 [dependencies] ··· 37 serde_json = "1.0" 38 hex_fmt = "0.3" 39 humansize = "2.0.0" 40 + http = "1.3" 41 reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } 42 dioxus-free-icons = { version = "0.10.0" } 43 diesel = { version = "2.3", features = ["sqlite", "returning_clauses_for_sqlite_3_35", "chrono", "serde_json"] } 44 diesel_migrations = { version = "2.3", features = ["sqlite"] } 45 + tokio = { version = "1.28", features = ["sync"] } 46 + dioxus-logger = "0.7.1" 47 + serde_html_form = "0.2.8" 48 + webbrowser = "1.0.6" 49 + 50 + 51 52 53 [target.'cfg(all(target_family = "wasm", target_os = "unknown"))'.dependencies] ··· 60 wasm-bindgen-futures = "0.4" 61 web-sys = { version = "0.3", features = ["ServiceWorkerContainer", "ServiceWorker", "ServiceWorkerRegistration", "RegistrationOptions", "Window", "Navigator", "MessageEvent", "console"] } 62 js-sys = "0.3" 63 + gloo-storage = "0.3" 64 + 65 + [build-dependencies] 66 + dotenvy = "0.15.7"
-1
crates/weaver-app/assets/styling/main.css
··· 2 background-color: var(--color-base); 3 color: var(--color-text); 4 font-family: var(--font-body); 5 - margin: 20px; 6 } 7 8 #header {
··· 2 background-color: var(--color-base); 3 color: var(--color-text); 4 font-family: var(--font-body); 5 } 6 7 #header {
+13
crates/weaver-app/assets/styling/navbar.css
··· 1 #navbar { 2 display: flex; 3 flex-direction: row; 4 } 5 6 .breadcrumbs { ··· 29 color: var(--color-text-muted, #999); 30 user-select: none; 31 }
··· 1 #navbar { 2 display: flex; 3 flex-direction: row; 4 + justify-content: space-between; 5 + padding-left: 4rem; 6 + padding-top: 1rem; 7 + padding-right: 4rem; 8 } 9 10 .breadcrumbs { ··· 33 color: var(--color-text-muted, #999); 34 user-select: none; 35 } 36 + 37 + .auth-button { 38 + align-self: flex-end; 39 + } 40 + 41 + .auth-handle { 42 + color: var(--color-text, #666); 43 + font-weight: 500; 44 + }
+1
crates/weaver-app/assets/styling/record-view.css
··· 198 font-weight: 600; 199 padding-left: 1rem; 200 padding-top: 0.5rem; 201 border-left: 2px solid var(--color-secondary); 202 } 203
··· 198 font-weight: 600; 199 padding-left: 1rem; 200 padding-top: 0.5rem; 201 + margin-left: -0.01rem; 202 border-left: 2px solid var(--color-secondary); 203 } 204
+24
crates/weaver-app/build.rs
···
··· 1 + use dotenvy::dotenv; 2 + use std::env; 3 + use std::fs::File; 4 + use std::io::Write; 5 + 6 + fn main() { 7 + println!("cargo:rerun-if-changed=.env"); 8 + let dest_path = "./src/env.rs"; 9 + let mut f = File::create(&dest_path).unwrap(); 10 + 11 + dotenv().ok(); 12 + f.write_all(b"// This file is automatically generated by build.rs\n\n") 13 + .unwrap(); 14 + for (key, value) in env::vars() { 15 + if key.starts_with("WEAVER_") { 16 + let line = format!( 17 + "#[allow(unused)]\npub const {}: &'static str = \"{}\";\n", 18 + key, 19 + value.replace("\"", "\\\"") 20 + ); 21 + f.write_all(line.as_bytes()).unwrap(); 22 + } 23 + } 24 + }
+82
crates/weaver-app/src/auth/mod.rs
···
··· 1 + mod storage; 2 + use dioxus::CapturedError; 3 + pub use storage::AuthStore; 4 + 5 + mod state; 6 + pub use state::AuthState; 7 + 8 + #[cfg(all(feature = "fullstack-server", feature = "server"))] 9 + use dioxus::prelude::*; 10 + #[cfg(all(feature = "fullstack-server", feature = "server"))] 11 + use jacquard::oauth::types::OAuthClientMetadata; 12 + 13 + #[cfg(all(feature = "fullstack-server", feature = "server"))] 14 + #[get("/client-metadata.json")] 15 + pub async fn client_metadata() -> Result<axum::Json<serde_json::Value>> { 16 + use jacquard::oauth::atproto::atproto_client_metadata; 17 + 18 + use crate::CONFIG; 19 + 20 + let atproto_metadata = atproto_client_metadata(CONFIG.oauth.clone(), &None)?; 21 + 22 + Ok(axum::response::Json(serde_json::to_value( 23 + atproto_metadata, 24 + )?)) 25 + } 26 + 27 + #[cfg(not(target_arch = "wasm32"))] 28 + pub async fn restore_session() -> Result<(), String> { 29 + Ok(()) 30 + } 31 + 32 + #[cfg(target_arch = "wasm32")] 33 + pub async fn restore_session() -> Result<(), CapturedError> { 34 + use crate::fetch::CachedFetcher; 35 + use dioxus::prelude::*; 36 + use gloo_storage::{LocalStorage, Storage}; 37 + // Look for session keys in localStorage (format: oauth_session_{did}_{session_id}) 38 + let keys = LocalStorage::get_all::<serde_json::Value>()?; 39 + let mut found_session: Option<(String, String)> = None; 40 + 41 + let keys = keys 42 + .as_object() 43 + .ok_or(CapturedError::from_display(format!("{}", keys)))?; 44 + for key in keys.keys() { 45 + if key.starts_with("oauth_session_") { 46 + let parts: Vec<&str> = key 47 + .strip_prefix("oauth_session_") 48 + .unwrap() 49 + .split('_') 50 + .collect(); 51 + if parts.len() >= 2 { 52 + found_session = Some((parts[0].to_string(), parts[1..].join("_"))); 53 + break; 54 + } 55 + } 56 + } 57 + 58 + let (did_str, session_id) = 59 + found_session.ok_or(CapturedError::from_display("No saved session found"))?; 60 + let did = jacquard::types::string::Did::new_owned(did_str)?; 61 + let fetcher = use_context::<CachedFetcher>(); 62 + 63 + let session = fetcher 64 + .client 65 + .oauth_client 66 + .restore(&did, &session_id) 67 + .await?; 68 + 69 + // Get DID and handle from session 70 + let (restored_did, session_id) = session.session_info().await; 71 + 72 + // Update auth state 73 + let mut auth_state = try_consume_context::<Signal<AuthState>>() 74 + .ok_or(CapturedError::from_display("AuthState not in context"))?; 75 + auth_state 76 + .write() 77 + .set_authenticated(restored_did, session_id); 78 + fetcher.upgrade_to_authenticated(session).await; 79 + 80 + dioxus_logger::tracing::debug!("session restored"); 81 + Ok(()) 82 + }
+32
crates/weaver-app/src/auth/state.rs
···
··· 1 + use jacquard::{CowStr, IntoStatic, types::string::Did}; 2 + 3 + #[derive(Clone, Debug, PartialEq)] 4 + pub struct AuthState { 5 + pub did: Option<Did<'static>>, 6 + pub session_id: Option<CowStr<'static>>, 7 + } 8 + 9 + impl Default for AuthState { 10 + fn default() -> Self { 11 + Self { 12 + did: None, 13 + session_id: None, 14 + } 15 + } 16 + } 17 + 18 + impl AuthState { 19 + pub fn is_authenticated(&self) -> bool { 20 + self.did.is_some() 21 + } 22 + 23 + pub fn set_authenticated(&mut self, did: Did<'_>, session_id: CowStr<'_>) { 24 + self.did = Some(did.into_static()); 25 + self.session_id = Some(session_id.into_static()); 26 + } 27 + 28 + pub fn clear(&mut self) { 29 + self.did = None; 30 + self.session_id = None; 31 + } 32 + }
+209
crates/weaver-app/src/auth/storage.rs
···
··· 1 + #[cfg(target_arch = "wasm32")] 2 + use gloo_storage::{LocalStorage, SessionStorage, Storage}; 3 + use jacquard::client::SessionStoreError; 4 + use jacquard::oauth::authstore::ClientAuthStore; 5 + #[cfg(not(target_arch = "wasm32"))] 6 + use jacquard::oauth::authstore::MemoryAuthStore; 7 + use jacquard::oauth::session::{AuthRequestData, ClientSessionData}; 8 + use jacquard::types::string::Did; 9 + use std::future::Future; 10 + #[cfg(not(target_arch = "wasm32"))] 11 + use std::sync::LazyLock; 12 + 13 + #[cfg(target_arch = "wasm32")] 14 + #[derive(Clone)] 15 + pub struct AuthStore; 16 + 17 + #[cfg(target_arch = "wasm32")] 18 + impl AuthStore { 19 + pub fn new() -> Self { 20 + Self 21 + } 22 + 23 + fn session_key(did: &Did<'_>, session_id: &str) -> String { 24 + format!("oauth_session_{}_{}", did.as_ref(), session_id) 25 + } 26 + 27 + fn auth_req_key(state: &str) -> String { 28 + format!("oauth_auth_req_{}", state) 29 + } 30 + } 31 + 32 + #[cfg(target_arch = "wasm32")] 33 + impl ClientAuthStore for AuthStore { 34 + fn get_session( 35 + &self, 36 + did: &Did<'_>, 37 + session_id: &str, 38 + ) -> impl Future<Output = Result<Option<ClientSessionData<'_>>, SessionStoreError>> { 39 + let key = Self::session_key(did, session_id); 40 + async move { 41 + match LocalStorage::get::<serde_json::Value>(&key) { 42 + Ok(value) => { 43 + let data: ClientSessionData<'static> = 44 + jacquard::from_json_value::<ClientSessionData>(value).map_err(|e| { 45 + SessionStoreError::Other(format!("Deserialize error: {}", e).into()) 46 + })?; 47 + Ok(Some(data)) 48 + } 49 + Err(gloo_storage::errors::StorageError::KeyNotFound(_)) => Ok(None), 50 + Err(e) => Err(SessionStoreError::Other( 51 + format!("LocalStorage error: {}", e).into(), 52 + )), 53 + } 54 + } 55 + } 56 + 57 + fn upsert_session( 58 + &self, 59 + session: ClientSessionData<'_>, 60 + ) -> impl Future<Output = Result<(), SessionStoreError>> { 61 + async move { 62 + use jacquard::IntoStatic; 63 + 64 + let key = Self::session_key(&session.account_did, &session.session_id); 65 + let static_session = session.into_static(); 66 + 67 + let value = serde_json::to_value(&static_session) 68 + .map_err(|e| SessionStoreError::Other(format!("Serialize error: {}", e).into()))?; 69 + 70 + LocalStorage::set(&key, &value).map_err(|e| { 71 + SessionStoreError::Other(format!("LocalStorage error: {}", e).into()) 72 + })?; 73 + 74 + Ok(()) 75 + } 76 + } 77 + 78 + fn delete_session( 79 + &self, 80 + did: &Did<'_>, 81 + session_id: &str, 82 + ) -> impl Future<Output = Result<(), SessionStoreError>> { 83 + let key = Self::session_key(did, session_id); 84 + async move { 85 + LocalStorage::delete(&key); 86 + Ok(()) 87 + } 88 + } 89 + 90 + fn get_auth_req_info( 91 + &self, 92 + state: &str, 93 + ) -> impl Future<Output = Result<Option<AuthRequestData<'_>>, SessionStoreError>> { 94 + let key = Self::auth_req_key(state); 95 + async move { 96 + match LocalStorage::get::<serde_json::Value>(&key) { 97 + Ok(value) => { 98 + let data: AuthRequestData<'static> = 99 + jacquard::from_json_value::<AuthRequestData>(value).map_err(|e| { 100 + SessionStoreError::Other(format!("Deserialize error: {}", e).into()) 101 + })?; 102 + Ok(Some(data)) 103 + } 104 + Err(gloo_storage::errors::StorageError::KeyNotFound(err)) => { 105 + dioxus_logger::tracing::debug!("gloo error: {}", err); 106 + Ok(None) 107 + } 108 + Err(e) => Err(SessionStoreError::Other( 109 + format!("SessionStorage error: {}", e).into(), 110 + )), 111 + } 112 + } 113 + } 114 + 115 + fn save_auth_req_info( 116 + &self, 117 + auth_req_info: &AuthRequestData<'_>, 118 + ) -> impl Future<Output = Result<(), SessionStoreError>> { 119 + async move { 120 + use jacquard::IntoStatic; 121 + 122 + let key = Self::auth_req_key(&auth_req_info.state); 123 + let static_info = auth_req_info.clone().into_static(); 124 + 125 + let value = serde_json::to_value(&static_info) 126 + .map_err(|e| SessionStoreError::Other(format!("Serialize error: {}", e).into()))?; 127 + 128 + LocalStorage::set(&key, &value).map_err(|e| { 129 + SessionStoreError::Other(format!("SessionStorage error: {}", e).into()) 130 + })?; 131 + 132 + Ok(()) 133 + } 134 + } 135 + 136 + fn delete_auth_req_info( 137 + &self, 138 + state: &str, 139 + ) -> impl Future<Output = Result<(), SessionStoreError>> { 140 + let key = Self::auth_req_key(state); 141 + async move { 142 + LocalStorage::delete(&key); 143 + Ok(()) 144 + } 145 + } 146 + } 147 + #[cfg(not(target_arch = "wasm32"))] 148 + use std::sync::Arc; 149 + 150 + #[cfg(not(target_arch = "wasm32"))] 151 + pub struct AuthStore(Arc<MemoryAuthStore>); 152 + 153 + #[cfg(not(target_arch = "wasm32"))] 154 + static MEM_STORE: LazyLock<Arc<MemoryAuthStore>> = 155 + LazyLock::new(|| Arc::new(MemoryAuthStore::new())); 156 + 157 + #[cfg(not(target_arch = "wasm32"))] 158 + impl AuthStore { 159 + pub fn new() -> Self { 160 + Self(MEM_STORE.clone()) 161 + } 162 + } 163 + 164 + #[cfg(not(target_arch = "wasm32"))] 165 + impl ClientAuthStore for AuthStore { 166 + fn get_session( 167 + &self, 168 + did: &Did<'_>, 169 + session_id: &str, 170 + ) -> impl Future<Output = Result<Option<ClientSessionData<'_>>, SessionStoreError>> { 171 + self.0.get_session(did, session_id) 172 + } 173 + 174 + fn upsert_session( 175 + &self, 176 + session: ClientSessionData<'_>, 177 + ) -> impl Future<Output = Result<(), SessionStoreError>> { 178 + self.0.upsert_session(session) 179 + } 180 + 181 + fn delete_session( 182 + &self, 183 + did: &Did<'_>, 184 + session_id: &str, 185 + ) -> impl Future<Output = Result<(), SessionStoreError>> { 186 + self.0.delete_session(did, session_id) 187 + } 188 + 189 + fn get_auth_req_info( 190 + &self, 191 + state: &str, 192 + ) -> impl Future<Output = Result<Option<AuthRequestData<'_>>, SessionStoreError>> { 193 + self.0.get_auth_req_info(state) 194 + } 195 + 196 + fn save_auth_req_info( 197 + &self, 198 + auth_req_info: &AuthRequestData<'_>, 199 + ) -> impl Future<Output = Result<(), SessionStoreError>> { 200 + self.0.save_auth_req_info(auth_req_info) 201 + } 202 + 203 + fn delete_auth_req_info( 204 + &self, 205 + state: &str, 206 + ) -> impl Future<Output = Result<(), SessionStoreError>> { 207 + self.0.delete_auth_req_info(state) 208 + } 209 + }
+4 -3
crates/weaver-app/src/blobcache.rs
··· 2 use dioxus::{CapturedError, Result}; 3 use jacquard::{ 4 bytes::Bytes, 5 - client::BasicClient, 6 prelude::*, 7 smol_str::SmolStr, 8 types::{cid::Cid, ident::AtIdentifier}, ··· 15 16 #[derive(Clone)] 17 pub struct BlobCache { 18 - client: Arc<BasicClient>, 19 cache: cache_impl::Cache<Cid<'static>, Bytes>, 20 map: cache_impl::Cache<SmolStr, Cid<'static>>, 21 } 22 23 impl BlobCache { 24 - pub fn new(client: Arc<BasicClient>) -> Self { 25 let cache = cache_impl::new_cache(100, Duration::from_secs(1200)); 26 let map = cache_impl::new_cache(500, Duration::from_secs(1200)); 27
··· 2 use dioxus::{CapturedError, Result}; 3 use jacquard::{ 4 bytes::Bytes, 5 + client::UnauthenticatedSession, 6 + identity::JacquardResolver, 7 prelude::*, 8 smol_str::SmolStr, 9 types::{cid::Cid, ident::AtIdentifier}, ··· 16 17 #[derive(Clone)] 18 pub struct BlobCache { 19 + client: Arc<UnauthenticatedSession<JacquardResolver>>, 20 cache: cache_impl::Cache<Cid<'static>, Bytes>, 21 map: cache_impl::Cache<SmolStr, Cid<'static>>, 22 } 23 24 impl BlobCache { 25 + pub fn new(client: Arc<UnauthenticatedSession<JacquardResolver>>) -> Self { 26 let cache = cache_impl::new_cache(100, Duration::from_secs(1200)); 27 let map = cache_impl::new_cache(500, Duration::from_secs(1200)); 28
+8 -1
crates/weaver-app/src/cache_impl.rs
··· 35 cache.insert(key, value); 36 } 37 38 pub fn iter<K, V>(cache: &Cache<K, V>) -> Vec<V> 39 where 40 K: std::hash::Hash + Eq + Send + Sync + 'static, ··· 80 cache.lock().unwrap().insert(key, value); 81 } 82 83 pub fn iter<K, V>(cache: &Cache<K, V>) -> Vec<V> 84 where 85 K: std::hash::Hash + Eq + 'static, 86 V: Clone + 'static, 87 { 88 - cache.lock().unwrap().iter().map(|(_, v)| v.clone()).collect() 89 } 90 } 91
··· 35 cache.insert(key, value); 36 } 37 38 + #[allow(dead_code)] 39 pub fn iter<K, V>(cache: &Cache<K, V>) -> Vec<V> 40 where 41 K: std::hash::Hash + Eq + Send + Sync + 'static, ··· 81 cache.lock().unwrap().insert(key, value); 82 } 83 84 + #[allow(dead_code)] 85 pub fn iter<K, V>(cache: &Cache<K, V>) -> Vec<V> 86 where 87 K: std::hash::Hash + Eq + 'static, 88 V: Clone + 'static, 89 { 90 + cache 91 + .lock() 92 + .unwrap() 93 + .iter() 94 + .map(|(_, v)| v.clone()) 95 + .collect() 96 } 97 } 98
+62
crates/weaver-app/src/components/button/component.rs
···
··· 1 + use dioxus::prelude::*; 2 + 3 + #[derive(Copy, Clone, PartialEq, Default)] 4 + #[non_exhaustive] 5 + pub enum ButtonVariant { 6 + #[default] 7 + Primary, 8 + Secondary, 9 + Destructive, 10 + Outline, 11 + Ghost, 12 + } 13 + 14 + impl ButtonVariant { 15 + pub fn class(&self) -> &'static str { 16 + match self { 17 + ButtonVariant::Primary => "primary", 18 + ButtonVariant::Secondary => "secondary", 19 + ButtonVariant::Destructive => "destructive", 20 + ButtonVariant::Outline => "outline", 21 + ButtonVariant::Ghost => "ghost", 22 + } 23 + } 24 + } 25 + 26 + #[component] 27 + pub fn Button( 28 + #[props(default)] variant: ButtonVariant, 29 + #[props(extends=GlobalAttributes)] 30 + #[props(extends=button)] 31 + attributes: Vec<Attribute>, 32 + onclick: Option<EventHandler<MouseEvent>>, 33 + onmousedown: Option<EventHandler<MouseEvent>>, 34 + onmouseup: Option<EventHandler<MouseEvent>>, 35 + children: Element, 36 + ) -> Element { 37 + rsx! { 38 + document::Link { rel: "stylesheet", href: asset!("./style.css") } 39 + 40 + button { 41 + class: "button", 42 + "data-style": variant.class(), 43 + onclick: move |event| { 44 + if let Some(f) = &onclick { 45 + f.call(event); 46 + } 47 + }, 48 + onmousedown: move |event| { 49 + if let Some(f) = &onmousedown { 50 + f.call(event); 51 + } 52 + }, 53 + onmouseup: move |event| { 54 + if let Some(f) = &onmouseup { 55 + f.call(event); 56 + } 57 + }, 58 + ..attributes, 59 + {children} 60 + } 61 + } 62 + }
+2
crates/weaver-app/src/components/button/mod.rs
···
··· 1 + mod component; 2 + pub use component::*;
+62
crates/weaver-app/src/components/button/style.css
···
··· 1 + .button { 2 + padding: 8px 18px; 3 + border: none; 4 + border-radius: 0.5rem; 5 + cursor: pointer; 6 + font-size: 0.85rem; 7 + font-weight: 600; 8 + transition: 9 + background-color 0.2s ease, 10 + color 0.2s ease; 11 + } 12 + 13 + .button:focus-visible { 14 + box-shadow: 0 0 0 2px var(--color-border); 15 + } 16 + 17 + .button[data-style="primary"] { 18 + background-color: var(--color-primary); 19 + color: var(--color-base); 20 + } 21 + 22 + .button[data-style="primary"]:hover { 23 + background-color: var(--color-link); 24 + } 25 + 26 + .button[data-style="secondary"] { 27 + background-color: var(--color-muted); 28 + color: var(--color-base); 29 + } 30 + 31 + .button[data-style="secondary"]:hover { 32 + background-color: var(--color-subtle); 33 + } 34 + 35 + .button[data-style="ghost"] { 36 + background-color: transparent; 37 + color: var(--color-text); 38 + } 39 + 40 + .button[data-style="ghost"]:hover { 41 + background-color: var(--color-highlight); 42 + color: var(--color-emphasis); 43 + } 44 + 45 + .button[data-style="outline"] { 46 + border: 1px solid var(--color-border); 47 + background-color: var(--color-surface); 48 + color: var(--color-text); 49 + } 50 + 51 + .button[data-style="outline"]:hover { 52 + background-color: var(--color-emphasis); 53 + } 54 + 55 + .button[data-style="destructive"] { 56 + background-color: var(--color-error); 57 + color: var(--color-base); 58 + } 59 + 60 + .button[data-style="destructive"]:hover { 61 + background-color: var(--color-emphasis); 62 + }
+2 -1
crates/weaver-app/src/components/css.rs
··· 105 if let Ok(theme_response) = fetcher.client.get_record::<Theme>(&theme_ref.uri).await { 106 if let Ok(theme_output) = theme_response.into_output() { 107 let theme: Theme = theme_output.into(); 108 // Try to resolve the theme (fetch colour schemes from PDS) 109 - resolve_theme(fetcher.client.as_ref(), &theme) 110 .await 111 .unwrap_or_else(|_| default_resolved_theme()) 112 } else {
··· 105 if let Ok(theme_response) = fetcher.client.get_record::<Theme>(&theme_ref.uri).await { 106 if let Ok(theme_output) = theme_response.into_output() { 107 let theme: Theme = theme_output.into(); 108 + let client = fetcher.get_client(); 109 // Try to resolve the theme (fetch colour schemes from PDS) 110 + resolve_theme(client.as_ref(), &theme) 111 .await 112 .unwrap_or_else(|_| default_resolved_theme()) 113 } else {
+52
crates/weaver-app/src/components/dialog/component.rs
···
··· 1 + use dioxus::prelude::*; 2 + use dioxus_primitives::dialog::{ 3 + self, DialogContentProps, DialogDescriptionProps, DialogRootProps, DialogTitleProps, 4 + }; 5 + 6 + #[component] 7 + pub fn DialogRoot(props: DialogRootProps) -> Element { 8 + rsx! { 9 + document::Link { rel: "stylesheet", href: asset!("./style.css") } 10 + dialog::DialogRoot { 11 + class: "dialog-backdrop", 12 + id: props.id, 13 + is_modal: props.is_modal, 14 + open: props.open, 15 + default_open: props.default_open, 16 + on_open_change: props.on_open_change, 17 + attributes: props.attributes, 18 + {props.children} 19 + } 20 + } 21 + } 22 + 23 + #[component] 24 + pub fn DialogContent(props: DialogContentProps) -> Element { 25 + rsx! { 26 + dialog::DialogContent { class: "dialog", id: props.id, attributes: props.attributes, {props.children} } 27 + } 28 + } 29 + 30 + #[component] 31 + pub fn DialogTitle(props: DialogTitleProps) -> Element { 32 + rsx! { 33 + dialog::DialogTitle { 34 + class: "dialog-title", 35 + id: props.id, 36 + attributes: props.attributes, 37 + {props.children} 38 + } 39 + } 40 + } 41 + 42 + #[component] 43 + pub fn DialogDescription(props: DialogDescriptionProps) -> Element { 44 + rsx! { 45 + dialog::DialogDescription { 46 + class: "dialog-description", 47 + id: props.id, 48 + attributes: props.attributes, 49 + {props.children} 50 + } 51 + } 52 + }
+2
crates/weaver-app/src/components/dialog/mod.rs
···
··· 1 + mod component; 2 + pub use component::*;
+105
crates/weaver-app/src/components/dialog/style.css
···
··· 1 + /* Dialog Backdrop */ 2 + .dialog-backdrop { 3 + position: fixed; 4 + z-index: 1000; 5 + background: rgb(0 0 0 / 30%); 6 + inset: 0; 7 + opacity: 0; 8 + will-change: transform, opacity; 9 + } 10 + 11 + .dialog-backdrop[data-state="closed"] { 12 + animation: dialog-backdrop-animate-out 150ms ease-in forwards; 13 + pointer-events: none; 14 + } 15 + 16 + @keyframes dialog-backdrop-animate-out { 17 + 0% { 18 + opacity: 1; 19 + transform: scale(1) translateY(0); 20 + } 21 + 22 + 100% { 23 + opacity: 0; 24 + transform: scale(0.95) translateY(-2px); 25 + } 26 + } 27 + 28 + .dialog-backdrop[data-state="open"] { 29 + animation: dialog-content-animate-in 150ms ease-out forwards; 30 + } 31 + 32 + @keyframes dialog-content-animate-in { 33 + 0% { 34 + opacity: 0; 35 + transform: scale(0.95) translateY(-2px); 36 + } 37 + 38 + 100% { 39 + opacity: 1; 40 + transform: scale(1) translateY(0); 41 + } 42 + } 43 + 44 + /* Dialog Container - improved for theme consistency */ 45 + .dialog { 46 + position: fixed; 47 + z-index: 1001; 48 + top: 50%; 49 + left: 50%; 50 + display: flex; 51 + width: 100%; 52 + max-width: calc(100% - 2rem); 53 + box-sizing: border-box; 54 + flex-direction: column; 55 + padding: 32px 24px 24px; 56 + border: 1px solid var(--color-border); 57 + border-radius: 8px; 58 + margin: 0; 59 + background: var(--color-overlay); 60 + box-shadow: 0 2px 10px rgb(0 0 0 / 18%); 61 + color: var(--color-text); 62 + font-family: var(--font-heading); 63 + gap: 16px; 64 + text-align: center; 65 + transform: translate(-50%, -50%); 66 + } 67 + 68 + .dialog-title { 69 + margin: 0; 70 + color: var(--color-primary); 71 + font-size: 1.25rem; 72 + font-weight: 700; 73 + } 74 + 75 + .dialog-description { 76 + margin: 0; 77 + color: var(--color-text); 78 + font-size: 1rem; 79 + } 80 + 81 + @media (width >= 40rem) { 82 + .dialog { 83 + max-width: 32rem; 84 + text-align: left; 85 + } 86 + } 87 + 88 + .dialog-close { 89 + position: absolute; 90 + top: 1rem; 91 + right: 1rem; 92 + align-self: flex-start; 93 + padding: 0; 94 + border: none; 95 + margin: 0; 96 + background: none; 97 + color: var(--color-highlight); 98 + cursor: pointer; 99 + font-size: 18px; 100 + line-height: 1; 101 + } 102 + 103 + .dialog-close:hover { 104 + color: var(--color-primary); 105 + }
+13 -14
crates/weaver-app/src/components/entry.rs
··· 22 }; 23 #[allow(unused_imports)] 24 use std::sync::Arc; 25 - use weaver_api::sh_weaver::notebook::{entry, BookEntryView}; 26 27 #[component] 28 - pub fn Entry(ident: AtIdentifier<'static>, book_title: SmolStr, title: SmolStr) -> Element { 29 - let ident_clone = ident.clone(); 30 - let book_title_clone = book_title.clone(); 31 - 32 // Use feature-gated hook for SSR support 33 - let entry = crate::data::use_entry_data(ident.clone(), book_title.clone(), title.clone())?; 34 35 // Handle blob caching when entry data is available 36 use_effect(move || { ··· 44 not(feature = "fullstack-server") 45 ))] 46 { 47 - let ident = ident.clone(); 48 - let book_title = book_title.clone(); 49 let images = images.clone(); 50 spawn(async move { 51 let fetcher = use_context::<fetch::CachedFetcher>(); 52 let _ = crate::service_worker::register_entry_blobs( 53 - &ident, 54 - book_title.as_str(), 55 &images, 56 &fetcher, 57 ) ··· 60 } 61 #[cfg(feature = "fullstack-server")] 62 { 63 - let ident = ident.clone(); 64 let images = images.clone(); 65 spawn(async move { 66 for image in &images.images { ··· 87 rsx! { EntryPage { 88 book_entry_view: book_entry_view.clone(), 89 entry_record: entry_record.clone(), 90 - ident: use_handle(ident_clone)?(), 91 - book_title: book_title_clone 92 } } 93 } 94 _ => rsx! { p { "Loading..." } }, ··· 167 author_count: usize, 168 ) -> Element { 169 use crate::Route; 170 - use jacquard::{from_data, IntoStatic}; 171 use weaver_api::sh_weaver::notebook::entry::Entry; 172 173 let entry_view = &entry.entry;
··· 22 }; 23 #[allow(unused_imports)] 24 use std::sync::Arc; 25 + use weaver_api::sh_weaver::notebook::{BookEntryView, entry}; 26 27 #[component] 28 + pub fn Entry( 29 + ident: ReadSignal<AtIdentifier<'static>>, 30 + book_title: ReadSignal<SmolStr>, 31 + title: ReadSignal<SmolStr>, 32 + ) -> Element { 33 // Use feature-gated hook for SSR support 34 + let entry = crate::data::use_entry_data(ident(), book_title(), title())?; 35 36 // Handle blob caching when entry data is available 37 use_effect(move || { ··· 45 not(feature = "fullstack-server") 46 ))] 47 { 48 let images = images.clone(); 49 spawn(async move { 50 let fetcher = use_context::<fetch::CachedFetcher>(); 51 let _ = crate::service_worker::register_entry_blobs( 52 + &ident(), 53 + book_title().as_str(), 54 &images, 55 &fetcher, 56 ) ··· 59 } 60 #[cfg(feature = "fullstack-server")] 61 { 62 + let ident = ident(); 63 let images = images.clone(); 64 spawn(async move { 65 for image in &images.images { ··· 86 rsx! { EntryPage { 87 book_entry_view: book_entry_view.clone(), 88 entry_record: entry_record.clone(), 89 + ident: use_handle(ident())?(), 90 + book_title: book_title() 91 } } 92 } 93 _ => rsx! { p { "Loading..." } }, ··· 166 author_count: usize, 167 ) -> Element { 168 use crate::Route; 169 + use jacquard::{IntoStatic, from_data}; 170 use weaver_api::sh_weaver::notebook::entry::Entry; 171 172 let entry_view = &entry.entry;
+8 -8
crates/weaver-app/src/components/identity.rs
··· 1 - use crate::{fetch, Route}; 2 use dioxus::prelude::*; 3 use jacquard::{smol_str::SmolStr, types::ident::AtIdentifier}; 4 use weaver_api::com_atproto::repo::strong_ref::StrongRef; ··· 7 const NOTEBOOK_CARD_CSS: Asset = asset!("/assets/styling/notebook-card.css"); 8 9 #[component] 10 - pub fn Repository(ident: AtIdentifier<'static>) -> Element { 11 rsx! { 12 // We can create elements inside the rsx macro with the element name followed by a block of attributes and children. 13 div { ··· 17 } 18 19 #[component] 20 - pub fn RepositoryIndex(ident: AtIdentifier<'static>) -> Element { 21 use crate::components::ProfileDisplay; 22 23 let fetcher = use_context::<fetch::CachedFetcher>(); 24 25 // Fetch notebooks for this specific DID 26 - let notebooks = use_resource(use_reactive!(|ident| { 27 let fetcher = fetcher.clone(); 28 - async move { fetcher.fetch_notebooks_for_did(&ident).await } 29 - })); 30 31 rsx! { 32 document::Stylesheet { href: NOTEBOOK_CARD_CSS } ··· 34 div { class: "repository-layout", 35 // Profile sidebar (desktop) / header (mobile) 36 aside { class: "repository-sidebar", 37 - ProfileDisplay { ident: ident.clone() } 38 } 39 40 // Main content area ··· 178 if entry_list.len() <= 5 { 179 // Show all entries if 5 or fewer 180 rsx! { 181 - for (i, entry_view) in entry_list.iter().enumerate() { 182 { 183 let entry_title = entry_view.entry.title.as_ref() 184 .map(|t| t.as_ref())
··· 1 + use crate::{Route, fetch}; 2 use dioxus::prelude::*; 3 use jacquard::{smol_str::SmolStr, types::ident::AtIdentifier}; 4 use weaver_api::com_atproto::repo::strong_ref::StrongRef; ··· 7 const NOTEBOOK_CARD_CSS: Asset = asset!("/assets/styling/notebook-card.css"); 8 9 #[component] 10 + pub fn Repository(ident: ReadSignal<AtIdentifier<'static>>) -> Element { 11 rsx! { 12 // We can create elements inside the rsx macro with the element name followed by a block of attributes and children. 13 div { ··· 17 } 18 19 #[component] 20 + pub fn RepositoryIndex(ident: ReadSignal<AtIdentifier<'static>>) -> Element { 21 use crate::components::ProfileDisplay; 22 23 let fetcher = use_context::<fetch::CachedFetcher>(); 24 25 // Fetch notebooks for this specific DID 26 + let notebooks = use_resource(move || { 27 let fetcher = fetcher.clone(); 28 + async move { fetcher.fetch_notebooks_for_did(&ident()).await } 29 + }); 30 31 rsx! { 32 document::Stylesheet { href: NOTEBOOK_CARD_CSS } ··· 34 div { class: "repository-layout", 35 // Profile sidebar (desktop) / header (mobile) 36 aside { class: "repository-sidebar", 37 + ProfileDisplay { ident: ident() } 38 } 39 40 // Main content area ··· 178 if entry_list.len() <= 5 { 179 // Show all entries if 5 or fewer 180 rsx! { 181 + for entry_view in entry_list.iter() { 182 { 183 let entry_title = entry_view.entry.title.as_ref() 184 .map(|t| t.as_ref())
+54
crates/weaver-app/src/components/input/component.rs
···
··· 1 + use dioxus::prelude::*; 2 + 3 + #[component] 4 + pub fn Input( 5 + oninput: Option<EventHandler<FormEvent>>, 6 + onchange: Option<EventHandler<FormEvent>>, 7 + oninvalid: Option<EventHandler<FormEvent>>, 8 + onselect: Option<EventHandler<SelectionEvent>>, 9 + onselectionchange: Option<EventHandler<SelectionEvent>>, 10 + onfocus: Option<EventHandler<FocusEvent>>, 11 + onblur: Option<EventHandler<FocusEvent>>, 12 + onfocusin: Option<EventHandler<FocusEvent>>, 13 + onfocusout: Option<EventHandler<FocusEvent>>, 14 + onkeydown: Option<EventHandler<KeyboardEvent>>, 15 + onkeypress: Option<EventHandler<KeyboardEvent>>, 16 + onkeyup: Option<EventHandler<KeyboardEvent>>, 17 + oncompositionstart: Option<EventHandler<CompositionEvent>>, 18 + oncompositionupdate: Option<EventHandler<CompositionEvent>>, 19 + oncompositionend: Option<EventHandler<CompositionEvent>>, 20 + oncopy: Option<EventHandler<ClipboardEvent>>, 21 + oncut: Option<EventHandler<ClipboardEvent>>, 22 + onpaste: Option<EventHandler<ClipboardEvent>>, 23 + #[props(extends=GlobalAttributes)] 24 + #[props(extends=input)] 25 + attributes: Vec<Attribute>, 26 + children: Element, 27 + ) -> Element { 28 + rsx! { 29 + document::Link { rel: "stylesheet", href: asset!("./style.css") } 30 + input { 31 + class: "input", 32 + oninput: move |e| _ = oninput.map(|callback| callback(e)), 33 + onchange: move |e| _ = onchange.map(|callback| callback(e)), 34 + oninvalid: move |e| _ = oninvalid.map(|callback| callback(e)), 35 + onselect: move |e| _ = onselect.map(|callback| callback(e)), 36 + onselectionchange: move |e| _ = onselectionchange.map(|callback| callback(e)), 37 + onfocus: move |e| _ = onfocus.map(|callback| callback(e)), 38 + onblur: move |e| _ = onblur.map(|callback| callback(e)), 39 + onfocusin: move |e| _ = onfocusin.map(|callback| callback(e)), 40 + onfocusout: move |e| _ = onfocusout.map(|callback| callback(e)), 41 + onkeydown: move |e| _ = onkeydown.map(|callback| callback(e)), 42 + onkeypress: move |e| _ = onkeypress.map(|callback| callback(e)), 43 + onkeyup: move |e| _ = onkeyup.map(|callback| callback(e)), 44 + oncompositionstart: move |e| _ = oncompositionstart.map(|callback| callback(e)), 45 + oncompositionupdate: move |e| _ = oncompositionupdate.map(|callback| callback(e)), 46 + oncompositionend: move |e| _ = oncompositionend.map(|callback| callback(e)), 47 + oncopy: move |e| _ = oncopy.map(|callback| callback(e)), 48 + oncut: move |e| _ = oncut.map(|callback| callback(e)), 49 + onpaste: move |e| _ = onpaste.map(|callback| callback(e)), 50 + ..attributes, 51 + {children} 52 + } 53 + } 54 + }
+2
crates/weaver-app/src/components/input/mod.rs
···
··· 1 + mod component; 2 + pub use component::*;
+36
crates/weaver-app/src/components/input/style.css
···
··· 1 + /* Input Styles */ 2 + .input { 3 + position: relative; 4 + display: flex; 5 + box-sizing: border-box; 6 + flex-direction: row; 7 + align-items: center; 8 + justify-content: space-between; 9 + padding: 0.25rem; 10 + padding: 8px 12px; 11 + border: 1px dashed var(--color-border); 12 + background: none; 13 + background: var(--color-overlay); 14 + box-shadow: inset 0 0 0 1px var(--light, var(--color-text)) var(--dark, var(--color-surface)); 15 + color: var(--color-text); 16 + cursor: pointer; 17 + gap: 0.25rem; 18 + transition: background-color 100ms ease-out; 19 + } 20 + 21 + .input::placeholder { 22 + color: var(--color-text); 23 + } 24 + 25 + .input:disabled { 26 + color: var(--color-muted); 27 + cursor: not-allowed; 28 + } 29 + 30 + .input:hover:not(:disabled), 31 + .input:focus-visible { 32 + background: var(--color-overlay); 33 + color: var(--color-text); 34 + outline: none; 35 + border: 1px dashed var(--color-border); 36 + }
+145
crates/weaver-app/src/components/login.rs
···
··· 1 + use dioxus::logger::tracing::{error, info}; 2 + use dioxus::prelude::*; 3 + use jacquard::oauth::client::OAuthClient; 4 + use jacquard::oauth::session::ClientData; 5 + use jacquard::{oauth::types::AuthorizeOptions, smol_str::SmolStr}; 6 + 7 + use crate::CONFIG; 8 + use crate::{ 9 + components::{ 10 + button::{Button, ButtonVariant}, 11 + dialog::{DialogContent, DialogRoot, DialogTitle}, 12 + input::Input, 13 + }, 14 + fetch::CachedFetcher, 15 + }; 16 + 17 + #[component] 18 + pub fn LoginModal(open: Signal<bool>) -> Element { 19 + let mut handle_input = use_signal(|| String::new()); 20 + let mut error = use_signal(|| Option::<String>::None); 21 + let mut is_loading = use_signal(|| false); 22 + 23 + let mut handle_submit = move || { 24 + let handle = handle_input.read().clone(); 25 + if handle.is_empty() { 26 + error.set(Some("Please enter a handle".to_string())); 27 + return; 28 + } 29 + 30 + is_loading.set(true); 31 + error.set(None); 32 + 33 + let fetcher = use_context::<CachedFetcher>(); 34 + 35 + #[cfg(target_arch = "wasm32")] 36 + { 37 + use crate::Route; 38 + use gloo_storage::Storage; 39 + let full_route = use_route::<Route>(); 40 + gloo_storage::LocalStorage::set("cached_route", format!("{}", full_route)); 41 + } 42 + 43 + use_effect(move || { 44 + let handle = handle.clone(); 45 + let fetcher = fetcher.clone(); 46 + spawn(async move { 47 + if let Err(e) = start_oauth_flow(handle, fetcher).await { 48 + error!("Authentication failed: {}", e); 49 + error.set(Some(format!("Authentication failed: {}", e))); 50 + is_loading.set(false); 51 + } 52 + open.set(false); 53 + }); 54 + }); 55 + }; 56 + 57 + rsx! { 58 + DialogRoot { open: open(), on_open_change: move |v| open.set(v), 59 + DialogContent { 60 + button { 61 + class: "dialog-close", 62 + r#type: "button", 63 + aria_label: "Close", 64 + tabindex: if open() { "0" } else { "-1" }, 65 + onclick: move |_| { 66 + open.set(false) 67 + }, 68 + "×" 69 + } 70 + DialogTitle { "Sign In with AT Protocol" } 71 + Input { 72 + oninput: move |e: FormEvent| handle_input.set(e.value()), 73 + onkeypress: move |k: KeyboardEvent| { 74 + if k.key() == Key::Enter { 75 + handle_submit(); 76 + } 77 + }, 78 + placeholder: "Enter your handle", 79 + value: "{handle_input}", 80 + } 81 + if let Some(err) = error() { 82 + div { class: "error", "{err}" } 83 + } 84 + Button { 85 + r#type: "button", 86 + onclick: move |_| { 87 + open.set(false) 88 + }, 89 + disabled: is_loading(), 90 + variant: ButtonVariant::Secondary, 91 + "Cancel" 92 + } 93 + Button { 94 + r#type: "submit", 95 + disabled: is_loading(), 96 + onclick: move |_| { 97 + handle_submit(); 98 + }, 99 + if is_loading() { "Authenticating..." } else { "Sign In" } 100 + } 101 + 102 + } 103 + } 104 + } 105 + } 106 + 107 + async fn start_oauth_flow(handle: String, fetcher: CachedFetcher) -> Result<(), SmolStr> { 108 + info!("Starting OAuth flow for handle: {}", handle); 109 + 110 + let client_data = ClientData { 111 + keyset: fetcher 112 + .client 113 + .oauth_client 114 + .registry 115 + .client_data 116 + .keyset 117 + .clone(), 118 + config: CONFIG.oauth.clone(), 119 + }; 120 + 121 + // Build client using store and resolver 122 + let flow_client = OAuthClient::new_with_shared( 123 + fetcher.client.oauth_client.registry.store.clone(), 124 + fetcher.client.oauth_client.client.clone(), 125 + client_data.clone(), 126 + ); 127 + 128 + let auth_url = flow_client 129 + .start_auth(handle, AuthorizeOptions::default()) 130 + .await 131 + .map_err(|e| format!("{:?}", e))?; 132 + #[cfg(target_arch = "wasm32")] 133 + { 134 + let window = web_sys::window().ok_or("no window")?; 135 + let location = window.location(); 136 + location 137 + .set_href(&auth_url) 138 + .map_err(|e| format!("{:?}", e))?; 139 + } 140 + #[cfg(not(target_arch = "wasm32"))] 141 + { 142 + webbrowser::open(&auth_url).map_err(|e| format!("{:?}", e))?; 143 + } 144 + Ok(()) 145 + }
+7
crates/weaver-app/src/components/mod.rs
··· 6 pub use css::NotebookCss; 7 8 mod entry; 9 pub use entry::{Entry, EntryCard, EntryMarkdown}; 10 11 pub mod identity; 12 pub use identity::{NotebookCard, Repository, RepositoryIndex}; 13 pub mod avatar; 14 ··· 17 18 pub mod notebook_cover; 19 pub use notebook_cover::NotebookCover; 20 21 use dioxus::prelude::*; 22 ··· 117 .with_hash_suffix(false) 118 .into_asset_options() 119 );
··· 6 pub use css::NotebookCss; 7 8 mod entry; 9 + #[allow(unused_imports)] 10 pub use entry::{Entry, EntryCard, EntryMarkdown}; 11 12 pub mod identity; 13 + #[allow(unused_imports)] 14 pub use identity::{NotebookCard, Repository, RepositoryIndex}; 15 pub mod avatar; 16 ··· 19 20 pub mod notebook_cover; 21 pub use notebook_cover::NotebookCover; 22 + 23 + pub mod login; 24 25 use dioxus::prelude::*; 26 ··· 121 .with_hash_suffix(false) 122 .into_asset_options() 123 ); 124 + pub mod input; 125 + pub mod dialog; 126 + pub mod button;
-1
crates/weaver-app/src/components/notebook_cover.rs
··· 2 3 use crate::components::avatar::{Avatar, AvatarImage}; 4 use dioxus::prelude::*; 5 - use jacquard::types::ident::AtIdentifier; 6 use weaver_api::sh_weaver::notebook::NotebookView; 7 8 const NOTEBOOK_COVER_CSS: Asset = asset!("/assets/styling/notebook-cover.css");
··· 2 3 use crate::components::avatar::{Avatar, AvatarImage}; 4 use dioxus::prelude::*; 5 use weaver_api::sh_weaver::notebook::NotebookView; 6 7 const NOTEBOOK_COVER_CSS: Asset = asset!("/assets/styling/notebook-cover.css");
-1
crates/weaver-app/src/components/profile.rs
··· 6 BskyIcon, TangledIcon, 7 }, 8 data::use_handle, 9 - Route, 10 }; 11 use dioxus::prelude::*; 12 use jacquard::types::ident::AtIdentifier;
··· 6 BskyIcon, TangledIcon, 7 }, 8 data::use_handle, 9 }; 10 use dioxus::prelude::*; 11 use jacquard::types::ident::AtIdentifier;
+208
crates/weaver-app/src/config.rs
···
··· 1 + use core::fmt; 2 + use std::str::FromStr; 3 + 4 + use jacquard::{ 5 + CowStr, IntoStatic, 6 + oauth::{ 7 + atproto::{AtprotoClientMetadata, GrantType}, 8 + scopes::Scope, 9 + }, 10 + smol_str::{SmolStr, ToSmolStr}, 11 + url::Url, 12 + }; 13 + 14 + use crate::env; 15 + 16 + #[derive(Debug, Clone)] 17 + pub struct Config { 18 + pub oauth: AtprotoClientMetadata<'static>, 19 + } 20 + 21 + #[derive(Debug, Clone)] 22 + pub struct OAuthConfig { 23 + pub client_id: jacquard::url::Url, 24 + pub redirect_uri: jacquard::url::Url, 25 + pub scopes: Vec<Scope<'static>>, 26 + pub client_name: SmolStr, 27 + pub client_uri: Option<jacquard::url::Url>, 28 + pub logo_uri: Option<jacquard::url::Url>, 29 + pub tos_uri: Option<jacquard::url::Url>, 30 + pub privacy_policy_uri: Option<jacquard::url::Url>, 31 + } 32 + 33 + impl OAuthConfig { 34 + /// This will panic if something is incorrect. You kind of can't proceed if these aren't a certain way, so... 35 + pub fn new( 36 + client_id: jacquard::url::Url, 37 + redirect_uri: jacquard::url::Url, 38 + scopes: Vec<Scope<'static>>, 39 + client_name: SmolStr, 40 + client_uri: Option<jacquard::url::Url>, 41 + logo_uri: Option<jacquard::url::Url>, 42 + tos_uri: Option<jacquard::url::Url>, 43 + privacy_policy_uri: Option<jacquard::url::Url>, 44 + ) -> Self { 45 + let scopes = if scopes.is_empty() { 46 + vec![ 47 + Scope::Atproto, 48 + Scope::Transition(jacquard::oauth::scopes::TransitionScope::Generic), 49 + ] 50 + } else { 51 + scopes 52 + }; 53 + if let Some(client_uri) = &client_uri { 54 + if let Some(client_uri_host) = client_uri.host_str() { 55 + if client_uri_host != client_id.host_str().expect("client_id must have a host") { 56 + panic!("client_uri host must match client_id host"); 57 + } 58 + } 59 + } 60 + if let Some(logo_uri) = &logo_uri { 61 + if logo_uri.scheme() != "https" { 62 + panic!("logo_uri scheme must be https"); 63 + } 64 + } 65 + if let Some(tos_uri) = &tos_uri { 66 + if tos_uri.scheme() != "https" { 67 + panic!("tos_uri scheme must be https"); 68 + } 69 + } 70 + if let Some(privacy_policy_uri) = &privacy_policy_uri { 71 + if privacy_policy_uri.scheme() != "https" { 72 + panic!("privacy_policy_uri scheme must be https"); 73 + } 74 + } 75 + Self { 76 + client_id, 77 + redirect_uri, 78 + scopes, 79 + client_name, 80 + client_uri, 81 + logo_uri, 82 + tos_uri, 83 + privacy_policy_uri, 84 + } 85 + } 86 + 87 + pub fn new_dev(port: u32, scopes: Vec<Scope<'static>>, client_name: SmolStr) -> Self { 88 + // determine client_id 89 + #[derive(serde::Serialize)] 90 + struct Parameters<'a> { 91 + #[serde(skip_serializing_if = "Option::is_none")] 92 + redirect_uri: Option<Vec<Url>>, 93 + #[serde(skip_serializing_if = "Option::is_none")] 94 + scope: Option<CowStr<'a>>, 95 + } 96 + let redirect_uri: Url = format!("http://127.0.0.1:{port}/callback").parse().unwrap(); 97 + let query = serde_html_form::to_string(Parameters { 98 + redirect_uri: Some(vec![redirect_uri.clone()]), 99 + scope: Some(Scope::serialize_multiple(scopes.as_slice())), 100 + }) 101 + .ok(); 102 + let mut client_id = String::from("http://localhost"); 103 + if let Some(query) = query 104 + && !query.is_empty() 105 + { 106 + client_id.push_str(&format!("?{query}")); 107 + }; 108 + Self::new( 109 + client_id.parse().unwrap(), 110 + redirect_uri, 111 + scopes, 112 + client_name, 113 + None, 114 + None, 115 + None, 116 + None, 117 + ) 118 + } 119 + 120 + pub fn from_env() -> Self { 121 + let app_env = AppEnv::from_str(env::WEAVER_APP_ENV).unwrap_or(AppEnv::Dev); 122 + 123 + if app_env == AppEnv::Dev { 124 + Self::new_dev( 125 + env::WEAVER_PORT.parse().unwrap_or(8080), 126 + Scope::parse_multiple(env::WEAVER_APP_SCOPES) 127 + .unwrap_or(vec![]) 128 + .into_static(), 129 + env::WEAVER_CLIENT_NAME.to_smolstr(), 130 + ) 131 + } else { 132 + let host = env::WEAVER_APP_HOST; 133 + let client_id = format!("{host}/client-metadata.json"); 134 + let redirect_uri = format!("{host}/callback"); 135 + let logo_uri = if env::WEAVER_LOGO_URI.is_empty() { 136 + None 137 + } else { 138 + Url::parse(env::WEAVER_LOGO_URI).ok() 139 + }; 140 + let tos_uri = if env::WEAVER_TOS_URI.is_empty() { 141 + None 142 + } else { 143 + Url::parse(env::WEAVER_TOS_URI).ok() 144 + }; 145 + let privacy_policy_uri = if env::WEAVER_PRIVACY_POLICY_URI.is_empty() { 146 + None 147 + } else { 148 + Url::parse(env::WEAVER_PRIVACY_POLICY_URI).ok() 149 + }; 150 + Self::new( 151 + Url::parse(&client_id).expect("Failed to parse client ID as valid URL"), 152 + Url::parse(&redirect_uri).expect("Failed to parse redirect URI as valid URL"), 153 + Scope::parse_multiple(env::WEAVER_APP_SCOPES) 154 + .unwrap_or(vec![]) 155 + .into_static(), 156 + env::WEAVER_CLIENT_NAME.to_smolstr(), 157 + Some(Url::parse(&host).expect("Failed to parse host as valid URL")), 158 + logo_uri, 159 + tos_uri, 160 + privacy_policy_uri, 161 + ) 162 + } 163 + } 164 + 165 + pub fn as_metadata(self) -> AtprotoClientMetadata<'static> { 166 + AtprotoClientMetadata::new( 167 + self.client_id, 168 + self.client_uri, 169 + vec![self.redirect_uri], 170 + vec![GrantType::AuthorizationCode, GrantType::RefreshToken], 171 + self.scopes, 172 + None, 173 + ) 174 + .with_prod_info( 175 + self.client_name.as_str(), 176 + self.logo_uri, 177 + self.tos_uri, 178 + self.privacy_policy_uri, 179 + ) 180 + } 181 + } 182 + 183 + #[derive(PartialEq)] 184 + enum AppEnv { 185 + Dev, 186 + Prod, 187 + } 188 + 189 + impl std::str::FromStr for AppEnv { 190 + type Err = String; 191 + 192 + fn from_str(s: &str) -> Result<Self, Self::Err> { 193 + match s { 194 + "dev" => Ok(Self::Dev), 195 + "prod" => Ok(Self::Prod), 196 + s => Err(format!("Invalid AppEnv: {s}")), 197 + } 198 + } 199 + } 200 + 201 + impl fmt::Display for AppEnv { 202 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 203 + match self { 204 + AppEnv::Dev => write!(f, "dev"), 205 + AppEnv::Prod => write!(f, "prod"), 206 + } 207 + } 208 + }
+26 -18
crates/weaver-app/src/data.rs
··· 130 #[cfg(feature = "fullstack-server")] 131 let h_str = { 132 use_server_future(move || { 133 - let fetcher = fetcher.clone(); 134 async move { 135 use jacquard::smol_str::ToSmolStr; 136 - 137 - fetcher 138 - .client 139 .resolve_ident_owned(&ident()) 140 .await 141 .map(|doc| doc.handles().first().map(|h| h.to_smolstr())) ··· 147 #[cfg(not(feature = "fullstack-server"))] 148 let h_str = { 149 use_resource(move || { 150 - let fetcher = fetcher.clone(); 151 async move { 152 use jacquard::smol_str::ToSmolStr; 153 - 154 - fetcher 155 - .client 156 .resolve_ident_owned(&ident()) 157 .await 158 .map(|doc| doc.handles().first().map(|h| h.to_smolstr())) ··· 184 let content = use_signal(|| content); 185 let fetcher = use_context::<crate::fetch::CachedFetcher>(); 186 Ok(use_server_future(move || { 187 - let fetcher = fetcher.clone(); 188 async move { 189 let did = match ident() { 190 AtIdentifier::Did(d) => d, 191 - AtIdentifier::Handle(h) => fetcher.client.resolve_handle(&h).await.ok()?, 192 }; 193 Some(render_markdown_impl(content(), did).await) 194 } ··· 205 let content = use_signal(|| content); 206 let fetcher = use_context::<crate::fetch::CachedFetcher>(); 207 Ok(use_resource(move || { 208 - let fetcher = fetcher.clone(); 209 async move { 210 let did = match ident() { 211 AtIdentifier::Did(d) => d, 212 - AtIdentifier::Handle(h) => fetcher.client.resolve_handle(&h).await.ok()?, 213 }; 214 Some(render_markdown_impl(content(), did).await) 215 } ··· 256 })?; 257 Ok(use_memo(move || { 258 if let Some(Some(value)) = &*res.read_unchecked() { 259 - jacquard::from_json_value::<weaver_api::sh_weaver::actor::ProfileDataView>(value.clone()).ok() 260 } else { 261 None 262 } ··· 271 let fetcher = use_context::<crate::fetch::CachedFetcher>(); 272 let r = use_resource(use_reactive!(|ident| { 273 let fetcher = fetcher.clone(); 274 - async move { fetcher.fetch_profile(&ident).await.ok().map(|arc| (*arc).clone()) } 275 })); 276 Ok(use_memo(move || { 277 r.read_unchecked().as_ref().and_then(|v| v.clone()) ··· 315 if let Some(Some(values)) = &*res.read_unchecked() { 316 values 317 .iter() 318 - .map(|v| jacquard::from_json_value::<( 319 - weaver_api::sh_weaver::notebook::NotebookView, 320 - Vec<weaver_api::com_atproto::repo::strong_ref::StrongRef>, 321 - )>(v.clone()).ok()) 322 .collect::<Option<Vec<_>>>() 323 } else { 324 None
··· 130 #[cfg(feature = "fullstack-server")] 131 let h_str = { 132 use_server_future(move || { 133 + let client = fetcher.get_client(); 134 async move { 135 use jacquard::smol_str::ToSmolStr; 136 + client 137 .resolve_ident_owned(&ident()) 138 .await 139 .map(|doc| doc.handles().first().map(|h| h.to_smolstr())) ··· 145 #[cfg(not(feature = "fullstack-server"))] 146 let h_str = { 147 use_resource(move || { 148 + let client = fetcher.get_client(); 149 async move { 150 use jacquard::smol_str::ToSmolStr; 151 + client 152 .resolve_ident_owned(&ident()) 153 .await 154 .map(|doc| doc.handles().first().map(|h| h.to_smolstr())) ··· 180 let content = use_signal(|| content); 181 let fetcher = use_context::<crate::fetch::CachedFetcher>(); 182 Ok(use_server_future(move || { 183 + let client = fetcher.get_client(); 184 async move { 185 let did = match ident() { 186 AtIdentifier::Did(d) => d, 187 + AtIdentifier::Handle(h) => client.resolve_handle(&h).await.ok()?, 188 }; 189 Some(render_markdown_impl(content(), did).await) 190 } ··· 201 let content = use_signal(|| content); 202 let fetcher = use_context::<crate::fetch::CachedFetcher>(); 203 Ok(use_resource(move || { 204 + let client = fetcher.get_client(); 205 async move { 206 let did = match ident() { 207 AtIdentifier::Did(d) => d, 208 + AtIdentifier::Handle(h) => client.resolve_handle(&h).await.ok()?, 209 }; 210 Some(render_markdown_impl(content(), did).await) 211 } ··· 252 })?; 253 Ok(use_memo(move || { 254 if let Some(Some(value)) = &*res.read_unchecked() { 255 + jacquard::from_json_value::<weaver_api::sh_weaver::actor::ProfileDataView>( 256 + value.clone(), 257 + ) 258 + .ok() 259 } else { 260 None 261 } ··· 270 let fetcher = use_context::<crate::fetch::CachedFetcher>(); 271 let r = use_resource(use_reactive!(|ident| { 272 let fetcher = fetcher.clone(); 273 + async move { 274 + fetcher 275 + .fetch_profile(&ident) 276 + .await 277 + .ok() 278 + .map(|arc| (*arc).clone()) 279 + } 280 })); 281 Ok(use_memo(move || { 282 r.read_unchecked().as_ref().and_then(|v| v.clone()) ··· 320 if let Some(Some(values)) = &*res.read_unchecked() { 321 values 322 .iter() 323 + .map(|v| { 324 + jacquard::from_json_value::<( 325 + weaver_api::sh_weaver::notebook::NotebookView, 326 + Vec<weaver_api::com_atproto::repo::strong_ref::StrongRef>, 327 + )>(v.clone()) 328 + .ok() 329 + }) 330 .collect::<Option<Vec<_>>>() 331 } else { 332 None
+20
crates/weaver-app/src/env.rs
···
··· 1 + // This file is automatically generated by build.rs 2 + 3 + #[allow(unused)] 4 + pub const WEAVER_APP_ENV: &'static str = "dev"; 5 + #[allow(unused)] 6 + pub const WEAVER_APP_HOST: &'static str = "http://localhost"; 7 + #[allow(unused)] 8 + pub const WEAVER_APP_DOMAIN: &'static str = ""; 9 + #[allow(unused)] 10 + pub const WEAVER_PORT: &'static str = "8080"; 11 + #[allow(unused)] 12 + pub const WEAVER_APP_SCOPES: &'static str = "atproto transition:generic"; 13 + #[allow(unused)] 14 + pub const WEAVER_CLIENT_NAME: &'static str = "Weaver"; 15 + #[allow(unused)] 16 + pub const WEAVER_LOGO_URI: &'static str = ""; 17 + #[allow(unused)] 18 + pub const WEAVER_TOS_URI: &'static str = ""; 19 + #[allow(unused)] 20 + pub const WEAVER_PRIVACY_POLICY_URI: &'static str = "";
+517 -24
crates/weaver-app/src/fetch.rs
··· 1 use crate::cache_impl; 2 use dioxus::Result; 3 use jacquard::prelude::*; 4 - use jacquard::{client::BasicClient, smol_str::SmolStr, types::ident::AtIdentifier}; 5 use serde::{Deserialize, Serialize}; 6 use std::{sync::Arc, time::Duration}; 7 use weaver_api::{ 8 com_atproto::repo::strong_ref::StrongRef, 9 sh_weaver::{ ··· 22 time_us: u64, 23 } 24 25 #[derive(Clone)] 26 pub struct CachedFetcher { 27 - pub client: Arc<BasicClient>, 28 book_cache: cache_impl::Cache< 29 (AtIdentifier<'static>, SmolStr), 30 Arc<(NotebookView<'static>, Vec<StrongRef<'static>>)>, ··· 36 profile_cache: cache_impl::Cache<AtIdentifier<'static>, Arc<ProfileDataView<'static>>>, 37 } 38 39 impl CachedFetcher { 40 - pub fn new(client: Arc<BasicClient>) -> Self { 41 Self { 42 - client, 43 book_cache: cache_impl::new_cache(100, Duration::from_secs(1200)), 44 entry_cache: cache_impl::new_cache(100, Duration::from_secs(600)), 45 profile_cache: cache_impl::new_cache(100, Duration::from_secs(1800)), 46 } 47 } 48 49 pub async fn get_notebook( 50 &self, 51 ident: AtIdentifier<'static>, ··· 54 if let Some(entry) = cache_impl::get(&self.book_cache, &(ident.clone(), title.clone())) { 55 Ok(Some(entry)) 56 } else { 57 - if let Some((notebook, entries)) = 58 - self.client 59 - .notebook_by_title(&ident, &title) 60 - .await 61 - .map_err(|e| dioxus::CapturedError::from_display(e))? 62 { 63 let stored = Arc::new((notebook, entries)); 64 cache_impl::insert(&self.book_cache, (ident, title), stored.clone()); ··· 82 { 83 Ok(Some(entry)) 84 } else { 85 - if let Some(entry) = self 86 - .client 87 .entry_by_title(notebook, entries.as_ref(), &entry_title) 88 .await 89 .map_err(|e| dioxus::CapturedError::from_display(e))? ··· 116 .map_err(|e| dioxus::CapturedError::from_display(e))?; 117 118 let mut notebooks = Vec::new(); 119 120 for ufos_record in records { 121 // Construct URI ··· 127 .map_err(|e| dioxus::CapturedError::from_display(format!("Invalid URI: {}", e)))?; 128 129 // Fetch the full notebook view (which hydrates authors) 130 - match self.client.view_notebook(&uri).await { 131 Ok((notebook, entries)) => { 132 let ident = uri.authority().clone().into_static(); 133 let title = notebook ··· 161 com_atproto::repo::list_records::ListRecords, sh_weaver::notebook::book::Book, 162 }; 163 164 // Resolve DID and PDS 165 let (repo_did, pds_url) = match ident { 166 AtIdentifier::Did(did) => { 167 - let pds = self 168 - .client 169 .pds_for_did(did) 170 .await 171 .map_err(|e| dioxus::CapturedError::from_display(e))?; 172 (did.clone(), pds) 173 } 174 - AtIdentifier::Handle(handle) => self 175 - .client 176 .pds_for_handle(handle) 177 .await 178 .map_err(|e| dioxus::CapturedError::from_display(e))?, 179 }; 180 181 // Fetch all notebook records for this repo 182 - let resp = self 183 - .client 184 .xrpc(pds_url) 185 .send( 186 &ListRecords::new() ··· 197 if let Ok(list) = resp.parse() { 198 for record in list.records { 199 // View the notebook (which hydrates authors) 200 - match self.client.view_notebook(&record.uri).await { 201 Ok((notebook, entries)) => { 202 let ident = record.uri.authority().clone().into_static(); 203 let title = notebook ··· 227 if let Some(result) = self.get_notebook(ident.clone(), book_title).await? { 228 let (notebook, entries) = result.as_ref(); 229 let mut book_entries = Vec::new(); 230 231 for index in 0..entries.len() { 232 - match self.client.view_entry(notebook, entries, index).await { 233 Ok(book_entry) => book_entries.push(book_entry), 234 Err(_) => continue, // Skip entries that fail to load 235 } ··· 253 return Ok(cached); 254 } 255 256 let did = match ident { 257 AtIdentifier::Did(d) => d.clone(), 258 - AtIdentifier::Handle(h) => self 259 - .client 260 .resolve_handle(h) 261 .await 262 .map_err(|e| dioxus::CapturedError::from_display(e))?, 263 }; 264 265 - let (_uri, profile_view) = self 266 - .client 267 .hydrate_profile_view(&did) 268 .await 269 .map_err(|e| dioxus::CapturedError::from_display(e))?; ··· 274 Ok(result) 275 } 276 }
··· 1 + use crate::auth::AuthStore; 2 use crate::cache_impl; 3 + use dioxus::prelude::*; 4 use dioxus::Result; 5 + use jacquard::client::Agent; 6 + use jacquard::client::AgentKind; 7 + use jacquard::error::ClientError; 8 + use jacquard::error::XrpcResult; 9 + use jacquard::identity::resolver::DidDocResponse; 10 + use jacquard::identity::resolver::IdentityError; 11 + use jacquard::identity::resolver::ResolverOptions; 12 + use jacquard::identity::JacquardResolver; 13 + use jacquard::oauth::client::OAuthClient; 14 + use jacquard::oauth::client::OAuthSession; 15 use jacquard::prelude::*; 16 + use jacquard::types::string::Did; 17 + use jacquard::types::string::Handle; 18 + use jacquard::xrpc::XrpcResponse; 19 + use jacquard::xrpc::*; 20 + use jacquard::AuthorizationToken; 21 + use jacquard::CowStr; 22 + use jacquard::IntoStatic; 23 + use jacquard::{smol_str::SmolStr, types::ident::AtIdentifier}; 24 use serde::{Deserialize, Serialize}; 25 + use std::future::Future; 26 use std::{sync::Arc, time::Duration}; 27 + use tokio::sync::RwLock; 28 use weaver_api::{ 29 com_atproto::repo::strong_ref::StrongRef, 30 sh_weaver::{ ··· 43 time_us: u64, 44 } 45 46 + pub struct Client { 47 + pub oauth_client: Arc<OAuthClient<JacquardResolver, AuthStore>>, 48 + pub session: RwLock<Option<Arc<Agent<OAuthSession<JacquardResolver, AuthStore>>>>>, 49 + } 50 + 51 + impl Client { 52 + pub fn new(oauth_client: OAuthClient<JacquardResolver, AuthStore>) -> Self { 53 + Self { 54 + oauth_client: Arc::new(oauth_client), 55 + session: RwLock::new(None), 56 + } 57 + } 58 + } 59 + 60 + impl HttpClient for Client { 61 + type Error = reqwest::Error; 62 + 63 + #[cfg(not(target_arch = "wasm32"))] 64 + fn send_http( 65 + &self, 66 + request: http::Request<Vec<u8>>, 67 + ) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>> + Send 68 + { 69 + self.oauth_client.client.send_http(request) 70 + } 71 + 72 + #[doc = " Send an HTTP request and return the response."] 73 + #[cfg(target_arch = "wasm32")] 74 + fn send_http( 75 + &self, 76 + request: http::Request<Vec<u8>>, 77 + ) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>> { 78 + self.oauth_client.client.send_http(request) 79 + } 80 + } 81 + 82 + impl XrpcClient for Client { 83 + #[doc = " Get the base URI for the client."] 84 + fn base_uri(&self) -> impl Future<Output = jacquard::url::Url> + Send { 85 + async { 86 + let guard = self.session.read().await; 87 + if let Some(session) = guard.clone() { 88 + session.base_uri().await 89 + } else { 90 + self.oauth_client.base_uri().await 91 + } 92 + } 93 + } 94 + 95 + #[doc = " Send an XRPC request and parse the response"] 96 + #[cfg(not(target_arch = "wasm32"))] 97 + fn send<R>(&self, request: R) -> impl Future<Output = XrpcResult<XrpcResponse<R>>> + Send 98 + where 99 + R: XrpcRequest + Send + Sync, 100 + <R as XrpcRequest>::Response: Send + Sync, 101 + Self: Sync, 102 + { 103 + async { 104 + let guard = self.session.read().await; 105 + if let Some(session) = guard.clone() { 106 + session.send(request).await 107 + } else { 108 + self.oauth_client.send(request).await 109 + } 110 + } 111 + } 112 + 113 + #[doc = " Send an XRPC request and parse the response"] 114 + #[cfg(not(target_arch = "wasm32"))] 115 + fn send_with_opts<R>( 116 + &self, 117 + request: R, 118 + opts: CallOptions<'_>, 119 + ) -> impl Future<Output = XrpcResult<XrpcResponse<R>>> + Send 120 + where 121 + R: XrpcRequest + Send + Sync, 122 + <R as XrpcRequest>::Response: Send + Sync, 123 + Self: Sync, 124 + { 125 + async { 126 + let guard = self.session.read().await; 127 + if let Some(session) = guard.clone() { 128 + session.send_with_opts(request, opts).await 129 + } else { 130 + self.oauth_client.send_with_opts(request, opts).await 131 + } 132 + } 133 + } 134 + 135 + #[doc = " Send an XRPC request and parse the response"] 136 + #[cfg(target_arch = "wasm32")] 137 + fn send<R>(&self, request: R) -> impl Future<Output = XrpcResult<XrpcResponse<R>>> 138 + where 139 + R: XrpcRequest + Send + Sync, 140 + <R as XrpcRequest>::Response: Send + Sync, 141 + { 142 + async { 143 + let guard = self.session.read().await; 144 + if let Some(session) = guard.clone() { 145 + session.send(request).await 146 + } else { 147 + self.oauth_client.send(request).await 148 + } 149 + } 150 + } 151 + 152 + #[doc = " Send an XRPC request and parse the response"] 153 + #[cfg(target_arch = "wasm32")] 154 + fn send_with_opts<R>( 155 + &self, 156 + request: R, 157 + opts: CallOptions<'_>, 158 + ) -> impl Future<Output = XrpcResult<XrpcResponse<R>>> 159 + where 160 + R: XrpcRequest + Send + Sync, 161 + <R as XrpcRequest>::Response: Send + Sync, 162 + { 163 + async { 164 + let guard = self.session.read().await; 165 + if let Some(session) = guard.clone() { 166 + session.send_with_opts(request, opts).await 167 + } else { 168 + self.oauth_client.send_with_opts(request, opts).await 169 + } 170 + } 171 + } 172 + 173 + #[doc = " Set the base URI for the client."] 174 + fn set_base_uri(&self, url: jacquard::url::Url) -> impl Future<Output = ()> + Send { 175 + async { 176 + let guard = self.session.read().await; 177 + if let Some(session) = guard.clone() { 178 + session.set_base_uri(url).await 179 + } else { 180 + self.oauth_client.set_base_uri(url).await 181 + } 182 + } 183 + } 184 + 185 + #[doc = " Get the call options for the client."] 186 + fn opts(&self) -> impl Future<Output = CallOptions<'_>> + Send { 187 + async { 188 + let guard = self.session.read().await; 189 + if let Some(session) = guard.clone() { 190 + session.opts().await.into_static() 191 + } else { 192 + self.oauth_client.opts().await 193 + } 194 + } 195 + } 196 + 197 + #[doc = " Set the call options for the client."] 198 + fn set_opts(&self, opts: CallOptions) -> impl Future<Output = ()> + Send { 199 + async { 200 + let guard = self.session.read().await; 201 + if let Some(session) = guard.clone() { 202 + session.set_opts(opts).await 203 + } else { 204 + self.oauth_client.set_opts(opts).await 205 + } 206 + } 207 + } 208 + } 209 + 210 + impl IdentityResolver for Client { 211 + #[doc = " Access options for validation decisions in default methods"] 212 + fn options(&self) -> &ResolverOptions { 213 + self.oauth_client.client.options() 214 + } 215 + 216 + #[doc = " Resolve handle"] 217 + #[cfg(not(target_arch = "wasm32"))] 218 + fn resolve_handle( 219 + &self, 220 + handle: &Handle<'_>, 221 + ) -> impl Future<Output = core::result::Result<Did<'static>, IdentityError>> + Send 222 + where 223 + Self: Sync, 224 + { 225 + self.oauth_client.client.resolve_handle(handle) 226 + } 227 + 228 + #[doc = " Resolve DID document"] 229 + #[cfg(not(target_arch = "wasm32"))] 230 + fn resolve_did_doc( 231 + &self, 232 + did: &Did<'_>, 233 + ) -> impl Future<Output = core::result::Result<DidDocResponse, IdentityError>> + Send 234 + where 235 + Self: Sync, 236 + { 237 + self.oauth_client.client.resolve_did_doc(did) 238 + } 239 + 240 + #[doc = " Resolve handle"] 241 + #[cfg(target_arch = "wasm32")] 242 + fn resolve_handle( 243 + &self, 244 + handle: &Handle<'_>, 245 + ) -> impl Future<Output = core::result::Result<Did<'static>, IdentityError>> { 246 + self.oauth_client.client.resolve_handle(handle) 247 + } 248 + 249 + #[doc = " Resolve DID document"] 250 + #[cfg(target_arch = "wasm32")] 251 + fn resolve_did_doc( 252 + &self, 253 + did: &Did<'_>, 254 + ) -> impl Future<Output = core::result::Result<DidDocResponse, IdentityError>> { 255 + self.oauth_client.client.resolve_did_doc(did) 256 + } 257 + } 258 + 259 + impl AgentSession for Client { 260 + #[doc = " Identify the kind of session."] 261 + fn session_kind(&self) -> AgentKind { 262 + self.oauth_client.session_kind() 263 + } 264 + 265 + #[doc = " Return current DID and an optional session id (always Some for OAuth)."] 266 + async fn session_info(&self) -> Option<(Did<'static>, Option<CowStr<'static>>)> { 267 + let guard = self.session.read().await; 268 + if let Some(session) = guard.clone() { 269 + session.info().await 270 + } else { 271 + None 272 + } 273 + } 274 + 275 + #[doc = " Current base endpoint."] 276 + async fn endpoint(&self) -> jacquard::url::Url { 277 + let guard = self.session.read().await; 278 + if let Some(session) = guard.clone() { 279 + session.endpoint().await 280 + } else { 281 + self.oauth_client.endpoint().await 282 + } 283 + } 284 + 285 + #[doc = " Override per-session call options."] 286 + async fn set_options<'a>(&'a self, opts: CallOptions<'a>) { 287 + let guard = self.session.read().await; 288 + if let Some(session) = guard.clone() { 289 + session.set_options(opts).await 290 + } else { 291 + self.oauth_client.set_options(opts).await 292 + } 293 + } 294 + 295 + #[doc = " Refresh the session and return a fresh AuthorizationToken."] 296 + async fn refresh(&self) -> XrpcResult<AuthorizationToken<'static>> { 297 + let guard = self.session.read().await; 298 + if let Some(session) = guard.clone() { 299 + session.refresh().await 300 + } else { 301 + Err(ClientError::auth( 302 + jacquard::error::AuthError::NotAuthenticated, 303 + )) 304 + } 305 + } 306 + } 307 + 308 #[derive(Clone)] 309 pub struct CachedFetcher { 310 + pub client: Arc<Client>, 311 book_cache: cache_impl::Cache< 312 (AtIdentifier<'static>, SmolStr), 313 Arc<(NotebookView<'static>, Vec<StrongRef<'static>>)>, ··· 319 profile_cache: cache_impl::Cache<AtIdentifier<'static>, Arc<ProfileDataView<'static>>>, 320 } 321 322 + /// SAFETY: This isn't thread-safe on WASM, but we aren't multithreaded on WASM 323 + unsafe impl Sync for CachedFetcher {} 324 + 325 + /// SAFETY: This isn't thread-safe on WASM, but we aren't multithreaded on WASM 326 + unsafe impl Send for CachedFetcher {} 327 + 328 impl CachedFetcher { 329 + pub fn new(client: OAuthClient<JacquardResolver, AuthStore>) -> Self { 330 Self { 331 + client: Arc::new(Client::new(client)), 332 book_cache: cache_impl::new_cache(100, Duration::from_secs(1200)), 333 entry_cache: cache_impl::new_cache(100, Duration::from_secs(600)), 334 profile_cache: cache_impl::new_cache(100, Duration::from_secs(1800)), 335 } 336 } 337 338 + pub async fn upgrade_to_authenticated( 339 + &self, 340 + session: OAuthSession<JacquardResolver, crate::auth::AuthStore>, 341 + ) { 342 + let mut session_slot = self.client.session.write().await; 343 + *session_slot = Some(Arc::new(Agent::new(session))); 344 + } 345 + 346 + pub async fn downgrade_to_unauthenticated(&self) { 347 + let mut session_slot = self.client.session.write().await; 348 + if let Some(session) = session_slot.take() { 349 + session.inner().logout().await; 350 + } 351 + } 352 + 353 + pub async fn current_did(&self) -> Option<Did<'static>> { 354 + let session_slot = self.client.session.read().await; 355 + if let Some(session) = session_slot.as_ref() { 356 + session.info().await.map(|(d, _)| d) 357 + } else { 358 + None 359 + } 360 + } 361 + 362 + pub fn get_client(&self) -> Arc<Client> { 363 + self.client.clone() 364 + } 365 + 366 pub async fn get_notebook( 367 &self, 368 ident: AtIdentifier<'static>, ··· 371 if let Some(entry) = cache_impl::get(&self.book_cache, &(ident.clone(), title.clone())) { 372 Ok(Some(entry)) 373 } else { 374 + let client = self.get_client(); 375 + if let Some((notebook, entries)) = client 376 + .notebook_by_title(&ident, &title) 377 + .await 378 + .map_err(|e| dioxus::CapturedError::from_display(e))? 379 { 380 let stored = Arc::new((notebook, entries)); 381 cache_impl::insert(&self.book_cache, (ident, title), stored.clone()); ··· 399 { 400 Ok(Some(entry)) 401 } else { 402 + let client = self.get_client(); 403 + if let Some(entry) = client 404 .entry_by_title(notebook, entries.as_ref(), &entry_title) 405 .await 406 .map_err(|e| dioxus::CapturedError::from_display(e))? ··· 433 .map_err(|e| dioxus::CapturedError::from_display(e))?; 434 435 let mut notebooks = Vec::new(); 436 + let client = self.get_client(); 437 438 for ufos_record in records { 439 // Construct URI ··· 445 .map_err(|e| dioxus::CapturedError::from_display(format!("Invalid URI: {}", e)))?; 446 447 // Fetch the full notebook view (which hydrates authors) 448 + match client.view_notebook(&uri).await { 449 Ok((notebook, entries)) => { 450 let ident = uri.authority().clone().into_static(); 451 let title = notebook ··· 479 com_atproto::repo::list_records::ListRecords, sh_weaver::notebook::book::Book, 480 }; 481 482 + let client = self.get_client(); 483 + 484 // Resolve DID and PDS 485 let (repo_did, pds_url) = match ident { 486 AtIdentifier::Did(did) => { 487 + let pds = client 488 .pds_for_did(did) 489 .await 490 .map_err(|e| dioxus::CapturedError::from_display(e))?; 491 (did.clone(), pds) 492 } 493 + AtIdentifier::Handle(handle) => client 494 .pds_for_handle(handle) 495 .await 496 .map_err(|e| dioxus::CapturedError::from_display(e))?, 497 }; 498 499 // Fetch all notebook records for this repo 500 + let resp = client 501 .xrpc(pds_url) 502 .send( 503 &ListRecords::new() ··· 514 if let Ok(list) = resp.parse() { 515 for record in list.records { 516 // View the notebook (which hydrates authors) 517 + match client.view_notebook(&record.uri).await { 518 Ok((notebook, entries)) => { 519 let ident = record.uri.authority().clone().into_static(); 520 let title = notebook ··· 544 if let Some(result) = self.get_notebook(ident.clone(), book_title).await? { 545 let (notebook, entries) = result.as_ref(); 546 let mut book_entries = Vec::new(); 547 + let client = self.get_client(); 548 549 for index in 0..entries.len() { 550 + match client.view_entry(notebook, entries, index).await { 551 Ok(book_entry) => book_entries.push(book_entry), 552 Err(_) => continue, // Skip entries that fail to load 553 } ··· 571 return Ok(cached); 572 } 573 574 + let client = self.get_client(); 575 + 576 let did = match ident { 577 AtIdentifier::Did(d) => d.clone(), 578 + AtIdentifier::Handle(h) => client 579 .resolve_handle(h) 580 .await 581 .map_err(|e| dioxus::CapturedError::from_display(e))?, 582 }; 583 584 + let (_uri, profile_view) = client 585 .hydrate_profile_view(&did) 586 .await 587 .map_err(|e| dioxus::CapturedError::from_display(e))?; ··· 592 Ok(result) 593 } 594 } 595 + 596 + impl HttpClient for CachedFetcher { 597 + #[doc = " Error type returned by the HTTP client"] 598 + type Error = reqwest::Error; 599 + 600 + #[doc = " Send an HTTP request and return the response."] 601 + #[cfg(not(target_arch = "wasm32"))] 602 + fn send_http( 603 + &self, 604 + request: http::Request<Vec<u8>>, 605 + ) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>> + Send 606 + { 607 + async { 608 + let client = self.get_client(); 609 + client.send_http(request).await 610 + } 611 + } 612 + 613 + #[doc = " Send an HTTP request and return the response."] 614 + #[cfg(target_arch = "wasm32")] 615 + fn send_http( 616 + &self, 617 + request: http::Request<Vec<u8>>, 618 + ) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>> { 619 + async { 620 + let client = self.get_client(); 621 + client.send_http(request).await 622 + } 623 + } 624 + } 625 + 626 + impl XrpcClient for CachedFetcher { 627 + #[doc = " Get the base URI for the client."] 628 + fn base_uri(&self) -> impl Future<Output = jacquard::url::Url> + Send { 629 + self.client.base_uri() 630 + } 631 + 632 + #[doc = " Send an XRPC request and parse the response"] 633 + #[cfg(not(target_arch = "wasm32"))] 634 + fn send<R>(&self, request: R) -> impl Future<Output = XrpcResult<XrpcResponse<R>>> + Send 635 + where 636 + R: XrpcRequest + Send + Sync, 637 + <R as XrpcRequest>::Response: Send + Sync, 638 + Self: Sync, 639 + { 640 + self.client.send(request) 641 + } 642 + 643 + #[doc = " Send an XRPC request and parse the response"] 644 + #[cfg(not(target_arch = "wasm32"))] 645 + fn send_with_opts<R>( 646 + &self, 647 + request: R, 648 + opts: CallOptions<'_>, 649 + ) -> impl Future<Output = XrpcResult<XrpcResponse<R>>> + Send 650 + where 651 + R: XrpcRequest + Send + Sync, 652 + <R as XrpcRequest>::Response: Send + Sync, 653 + Self: Sync, 654 + { 655 + self.client.send_with_opts(request, opts) 656 + } 657 + 658 + #[doc = " Send an XRPC request and parse the response"] 659 + #[cfg(target_arch = "wasm32")] 660 + fn send<R>(&self, request: R) -> impl Future<Output = XrpcResult<XrpcResponse<R>>> 661 + where 662 + R: XrpcRequest + Send + Sync, 663 + <R as XrpcRequest>::Response: Send + Sync, 664 + { 665 + self.client.send(request) 666 + } 667 + 668 + #[doc = " Send an XRPC request and parse the response"] 669 + #[cfg(target_arch = "wasm32")] 670 + fn send_with_opts<R>( 671 + &self, 672 + request: R, 673 + opts: CallOptions<'_>, 674 + ) -> impl Future<Output = XrpcResult<XrpcResponse<R>>> 675 + where 676 + R: XrpcRequest + Send + Sync, 677 + <R as XrpcRequest>::Response: Send + Sync, 678 + { 679 + self.client.send_with_opts(request, opts) 680 + } 681 + 682 + #[doc = " Set the base URI for the client."] 683 + fn set_base_uri(&self, url: jacquard::url::Url) -> impl Future<Output = ()> + Send { 684 + self.client.set_base_uri(url) 685 + } 686 + 687 + #[doc = " Get the call options for the client."] 688 + fn opts(&self) -> impl Future<Output = CallOptions<'_>> + Send { 689 + self.client.opts() 690 + } 691 + 692 + #[doc = " Set the call options for the client."] 693 + fn set_opts(&self, opts: CallOptions) -> impl Future<Output = ()> + Send { 694 + self.client.set_opts(opts) 695 + } 696 + } 697 + 698 + impl IdentityResolver for CachedFetcher { 699 + #[doc = " Access options for validation decisions in default methods"] 700 + fn options(&self) -> &ResolverOptions { 701 + self.client.options() 702 + } 703 + 704 + #[doc = " Resolve handle"] 705 + #[cfg(not(target_arch = "wasm32"))] 706 + fn resolve_handle( 707 + &self, 708 + handle: &Handle<'_>, 709 + ) -> impl Future<Output = core::result::Result<Did<'static>, IdentityError>> + Send 710 + where 711 + Self: Sync, 712 + { 713 + self.client.resolve_handle(handle) 714 + } 715 + 716 + #[doc = " Resolve DID document"] 717 + #[cfg(not(target_arch = "wasm32"))] 718 + fn resolve_did_doc( 719 + &self, 720 + did: &Did<'_>, 721 + ) -> impl Future<Output = core::result::Result<DidDocResponse, IdentityError>> + Send 722 + where 723 + Self: Sync, 724 + { 725 + self.client.resolve_did_doc(did) 726 + } 727 + 728 + #[doc = " Resolve handle"] 729 + #[cfg(target_arch = "wasm32")] 730 + fn resolve_handle( 731 + &self, 732 + handle: &Handle<'_>, 733 + ) -> impl Future<Output = core::result::Result<Did<'static>, IdentityError>> { 734 + self.client.resolve_handle(handle) 735 + } 736 + 737 + #[doc = " Resolve DID document"] 738 + #[cfg(target_arch = "wasm32")] 739 + fn resolve_did_doc( 740 + &self, 741 + did: &Did<'_>, 742 + ) -> impl Future<Output = core::result::Result<DidDocResponse, IdentityError>> { 743 + self.client.resolve_did_doc(did) 744 + } 745 + } 746 + 747 + impl AgentSession for CachedFetcher { 748 + #[doc = " Identify the kind of session."] 749 + fn session_kind(&self) -> AgentKind { 750 + self.client.session_kind() 751 + } 752 + 753 + #[doc = " Return current DID and an optional session id (always Some for OAuth)."] 754 + async fn session_info(&self) -> Option<(Did<'static>, Option<CowStr<'static>>)> { 755 + self.client.session_info().await 756 + } 757 + 758 + async fn endpoint(&self) -> jacquard::url::Url { 759 + self.client.endpoint().await 760 + } 761 + 762 + async fn set_options<'a>(&'a self, opts: CallOptions<'a>) { 763 + self.client.set_options(opts).await 764 + } 765 + 766 + async fn refresh(&self) -> XrpcResult<AuthorizationToken<'static>> { 767 + self.client.refresh().await 768 + } 769 + }
+48 -21
crates/weaver-app/src/main.rs
··· 2 // need dioxus 3 use components::{Entry, Repository, RepositoryIndex}; 4 #[allow(unused)] 5 - use dioxus::{prelude::*, CapturedError}; 6 7 #[cfg(all(feature = "fullstack-server", feature = "server"))] 8 use dioxus::fullstack::response::Extension; 9 - #[cfg(feature = "fullstack-server")] 10 - use dioxus::fullstack::FullstackContext; 11 #[allow(unused)] 12 use jacquard::{ 13 - client::BasicClient, 14 smol_str::SmolStr, 15 types::{cid::Cid, string::AtIdentifier}, 16 }; 17 - 18 use std::sync::Arc; 19 #[allow(unused)] 20 - use views::{Home, Navbar, Notebook, NotebookIndex, NotebookPage, RecordView}; 21 22 #[cfg(feature = "server")] 23 mod blobcache; 24 mod cache_impl; 25 /// Define a components module that contains all shared components for our app. 26 mod components; 27 mod data; 28 mod fetch; 29 mod service_worker; 30 /// Define a views module that contains the UI for all Layouts and Routes for our app. ··· 48 #[layout(ErrorLayout)] 49 #[route("/record#:uri")] 50 RecordView { uri: SmolStr }, 51 #[nest("/:ident")] 52 #[layout(Repository)] 53 #[route("/")] ··· 79 .into_response() 80 } 81 82 fn main() { 83 // Set up better panic messages for wasm 84 #[cfg(target_arch = "wasm32")] 85 console_error_panic_hook::set_once(); ··· 88 #[cfg(feature = "server")] 89 dioxus::serve(|| async move { 90 use crate::blobcache::BlobCache; 91 - use crate::fetch::CachedFetcher; 92 use axum::{ 93 extract::{Extension, Request}, 94 middleware, ··· 105 .merge(dioxus::server::router(App)) 106 }; 107 108 - let client = Arc::new(BasicClient::unauthenticated()); 109 - 110 #[cfg(feature = "fullstack-server")] 111 let router = { 112 - let fetcher = Arc::new(CachedFetcher::new(client.clone())); 113 - let blob_cache = Arc::new(BlobCache::new(client.clone())); 114 dioxus::server::router(App).layer(middleware::from_fn({ 115 let fetcher = fetcher.clone(); 116 - let blob_cache = blob_cache.clone(); 117 move |mut req: Request, next: Next| { 118 let fetcher = fetcher.clone(); 119 - let blob_cache = blob_cache.clone(); 120 async move { 121 - // Attach extensions for dioxus server functions 122 req.extensions_mut().insert(fetcher); 123 - req.extensions_mut().insert(blob_cache); 124 125 // And then return the response with `next.run() 126 Ok::<_, Infallible>(next.run(req).await) ··· 137 dioxus::launch(App); 138 } 139 140 - /// App is the main component of our app. Components are the building blocks of dioxus apps. Each component is a function 141 - /// that takes some props and returns an Element. In this case, App takes no props because it is the root of our app. 142 - /// 143 - /// Components should be annotated with `#[component]` to support props, better error messages, and autocomplete 144 #[component] 145 fn App() -> Element { 146 - // The `rsx!` macro lets us define HTML inside of rust. It expands to an Element with all of our HTML inside. 147 - use_context_provider(|| fetch::CachedFetcher::new(Arc::new(BasicClient::unauthenticated()))); 148 149 // Register service worker on startup (only on web) 150 #[cfg(all(
··· 2 // need dioxus 3 use components::{Entry, Repository, RepositoryIndex}; 4 #[allow(unused)] 5 + use dioxus::{CapturedError, prelude::*}; 6 7 + #[cfg(feature = "fullstack-server")] 8 + use dioxus::fullstack::FullstackContext; 9 #[cfg(all(feature = "fullstack-server", feature = "server"))] 10 use dioxus::fullstack::response::Extension; 11 + use dioxus_logger::tracing::Level; 12 + use jacquard::oauth::{client::OAuthClient, session::ClientData}; 13 #[allow(unused)] 14 use jacquard::{ 15 smol_str::SmolStr, 16 types::{cid::Cid, string::AtIdentifier}, 17 }; 18 + #[cfg(feature = "server")] 19 use std::sync::Arc; 20 + use std::sync::{LazyLock, Mutex}; 21 #[allow(unused)] 22 + use views::{Callback, Home, Navbar, Notebook, NotebookIndex, NotebookPage, RecordView}; 23 + 24 + use crate::{ 25 + auth::{AuthState, AuthStore}, 26 + config::{Config, OAuthConfig}, 27 + }; 28 29 + mod auth; 30 #[cfg(feature = "server")] 31 mod blobcache; 32 mod cache_impl; 33 /// Define a components module that contains all shared components for our app. 34 mod components; 35 + mod config; 36 mod data; 37 + mod env; 38 mod fetch; 39 mod service_worker; 40 /// Define a views module that contains the UI for all Layouts and Routes for our app. ··· 58 #[layout(ErrorLayout)] 59 #[route("/record#:uri")] 60 RecordView { uri: SmolStr }, 61 + #[route("/callback?:state&:iss&:code")] 62 + Callback { state: SmolStr, iss: SmolStr, code: SmolStr }, 63 #[nest("/:ident")] 64 #[layout(Repository)] 65 #[route("/")] ··· 91 .into_response() 92 } 93 94 + pub static CONFIG: LazyLock<Config> = LazyLock::new(|| Config { 95 + oauth: OAuthConfig::from_env().as_metadata(), 96 + }); 97 fn main() { 98 + dioxus_logger::init(Level::DEBUG).expect("logger failed to init"); 99 // Set up better panic messages for wasm 100 #[cfg(target_arch = "wasm32")] 101 console_error_panic_hook::set_once(); ··· 104 #[cfg(feature = "server")] 105 dioxus::serve(|| async move { 106 use crate::blobcache::BlobCache; 107 use axum::{ 108 extract::{Extension, Request}, 109 middleware, ··· 120 .merge(dioxus::server::router(App)) 121 }; 122 123 #[cfg(feature = "fullstack-server")] 124 let router = { 125 + use jacquard::client::UnauthenticatedSession; 126 + let fetcher = Arc::new(fetch::CachedFetcher::new(OAuthClient::new( 127 + AuthStore::new(), 128 + ClientData::new_public(CONFIG.oauth.clone()), 129 + ))); 130 + let blob_cache = Arc::new(BlobCache::new(Arc::new( 131 + UnauthenticatedSession::new_public(), 132 + ))); 133 dioxus::server::router(App).layer(middleware::from_fn({ 134 + let blob_cache = blob_cache.clone(); 135 let fetcher = fetcher.clone(); 136 move |mut req: Request, next: Next| { 137 + let blob_cache = blob_cache.clone(); 138 let fetcher = fetcher.clone(); 139 async move { 140 + req.extensions_mut().insert(blob_cache); 141 req.extensions_mut().insert(fetcher); 142 143 // And then return the response with `next.run() 144 Ok::<_, Infallible>(next.run(req).await) ··· 155 dioxus::launch(App); 156 } 157 158 #[component] 159 fn App() -> Element { 160 + use_context_provider(|| { 161 + fetch::CachedFetcher::new(OAuthClient::new( 162 + AuthStore::new(), 163 + ClientData::new_public(CONFIG.oauth.clone()), 164 + )) 165 + }); 166 + use_context_provider(|| Signal::new(AuthState::default())); 167 + 168 + use_effect(move || { 169 + spawn(async move { 170 + if let Err(e) = auth::restore_session().await { 171 + dioxus_logger::tracing::warn!("Session restoration failed: {}", e); 172 + } 173 + }); 174 + }); 175 176 // Register service worker on startup (only on web) 177 #[cfg(all(
+89
crates/weaver-app/src/views/callback.rs
···
··· 1 + use crate::auth::AuthState; 2 + use crate::fetch::CachedFetcher; 3 + use dioxus::logger::tracing::{Level, error, info}; 4 + use dioxus::{CapturedError, prelude::*}; 5 + use jacquard::{ 6 + IntoStatic, 7 + cowstr::ToCowStr, 8 + oauth::{error::OAuthError, types::CallbackParams}, 9 + smol_str::SmolStr, 10 + }; 11 + 12 + #[component] 13 + pub fn Callback( 14 + state: ReadSignal<SmolStr>, 15 + iss: ReadSignal<SmolStr>, 16 + code: ReadSignal<SmolStr>, 17 + ) -> Element { 18 + let fetcher = use_context::<CachedFetcher>(); 19 + let mut auth = use_context::<Signal<AuthState>>(); 20 + #[cfg(feature = "web")] 21 + let result = { 22 + use_resource(move || { 23 + let fetcher = fetcher.clone(); 24 + let callback_params = CallbackParams { 25 + code: code().to_cowstr(), 26 + state: Some(state().to_cowstr()), 27 + iss: Some(iss().to_cowstr()), 28 + } 29 + .into_static(); 30 + info!("Auth Callback: {:?}", callback_params); 31 + async move { 32 + let session = fetcher 33 + .client 34 + .oauth_client 35 + .callback(callback_params) 36 + .await?; 37 + let (did, session_id) = session.session_info().await; 38 + auth.write().set_authenticated(did, session_id); 39 + fetcher.upgrade_to_authenticated(session).await; 40 + Ok::<(), OAuthError>(()) 41 + } 42 + }) 43 + }; 44 + #[cfg(not(feature = "web"))] 45 + let result = { use_resource(move || async { Ok::<(), OAuthError>(()) }) }; 46 + 47 + match &*result.read_unchecked() { 48 + Some(Ok(())) => { 49 + // #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 50 + // { 51 + // if let Some(window) = web_sys::window() { 52 + // window.close().ok(); 53 + // } 54 + // } 55 + #[cfg(target_arch = "wasm32")] 56 + { 57 + use gloo_storage::Storage; 58 + let mut prev = gloo_storage::LocalStorage::get::<String>("cached_route").ok(); 59 + if let Some(prev) = prev.take() { 60 + dioxus_logger::tracing::info!("Navigating to previous page"); 61 + let nav = use_navigator(); 62 + gloo_storage::LocalStorage::delete("cached_route"); 63 + nav.replace(prev); 64 + } 65 + } 66 + rsx! { 67 + div { 68 + h1 { "Success" } 69 + p { "You have successfully authenticated. You can close this browser window." } 70 + } 71 + } 72 + } 73 + Some(Err(err)) => { 74 + error!("Auth Error: {}", err); 75 + rsx! { 76 + 77 + div { 78 + h1 { "Error" } 79 + p { "{err}" } 80 + } 81 + } 82 + } 83 + None => rsx! { 84 + div { 85 + h1 { "Loading..." } 86 + } 87 + }, 88 + } 89 + }
+3
crates/weaver-app/src/views/mod.rs
··· 22 23 mod record; 24 pub use record::RecordView;
··· 22 23 mod record; 24 pub use record::RecordView; 25 + 26 + mod callback; 27 + pub use callback::Callback;
+36 -1
crates/weaver-app/src/views/navbar.rs
··· 1 - use crate::data::use_handle; 2 use crate::Route; 3 use dioxus::prelude::*; 4 5 const THEME_DEFAULTS_CSS: Asset = asset!("/assets/styling/theme-defaults.css"); 6 const NAVBAR_CSS: Asset = asset!("/assets/styling/navbar.css"); ··· 13 #[component] 14 pub fn Navbar() -> Element { 15 let route = use_route::<Route>(); 16 17 rsx! { 18 document::Link { rel: "stylesheet", href: THEME_DEFAULTS_CSS } ··· 59 }, 60 _ => rsx! {} 61 } 62 } 63 } 64
··· 1 use crate::Route; 2 + use crate::components::button::{Button, ButtonVariant}; 3 + use crate::components::login::LoginModal; 4 + use crate::data::use_handle; 5 + use crate::fetch::CachedFetcher; 6 use dioxus::prelude::*; 7 + use jacquard::types::string::AtIdentifier; 8 9 const THEME_DEFAULTS_CSS: Asset = asset!("/assets/styling/theme-defaults.css"); 10 const NAVBAR_CSS: Asset = asset!("/assets/styling/navbar.css"); ··· 17 #[component] 18 pub fn Navbar() -> Element { 19 let route = use_route::<Route>(); 20 + let mut auth_state = use_context::<Signal<crate::auth::AuthState>>(); 21 + let mut show_login_modal = use_signal(|| false); 22 + let fetcher = use_context::<CachedFetcher>(); 23 24 rsx! { 25 document::Link { rel: "stylesheet", href: THEME_DEFAULTS_CSS } ··· 66 }, 67 _ => rsx! {} 68 } 69 + } 70 + if auth_state.read().is_authenticated() { 71 + if let Some(did) = &auth_state.read().did { 72 + Button { 73 + variant: ButtonVariant::Ghost, 74 + onclick: move |_| { 75 + let fetcher = fetcher.clone(); 76 + auth_state.write().clear(); 77 + async move { 78 + fetcher.downgrade_to_unauthenticated().await; 79 + } 80 + }, 81 + span { class: "auth-handle", "@{use_handle(AtIdentifier::Did(did.clone()))?}" } 82 + } 83 + } 84 + } else { 85 + div { 86 + class: "auth-button", 87 + Button { 88 + variant: ButtonVariant::Ghost, 89 + onclick: move |_| show_login_modal.set(true), 90 + span { class: "auth-handle", "Sign In" } 91 + } 92 + } 93 + 94 + } 95 + LoginModal { 96 + open: show_login_modal 97 } 98 } 99
+15 -18
crates/weaver-app/src/views/notebook.rs
··· 1 use crate::{ 2 components::{EntryCard, NotebookCover, NotebookCss}, 3 - fetch, Route, 4 }; 5 use dioxus::prelude::*; 6 use jacquard::{ ··· 15 /// The component takes a `id` prop of type `i32` from the route enum. Whenever the id changes, the component function will be 16 /// re-run and the rendered HTML will be updated. 17 #[component] 18 - pub fn Notebook(ident: AtIdentifier<'static>, book_title: SmolStr) -> Element { 19 rsx! { 20 NotebookCss { ident: ident.to_smolstr(), notebook: book_title } 21 Outlet::<Route> {} ··· 23 } 24 25 #[component] 26 - pub fn NotebookIndex(ident: AtIdentifier<'static>, book_title: SmolStr) -> Element { 27 let fetcher = use_context::<fetch::CachedFetcher>(); 28 - let book_title_clone = book_title.clone(); 29 - 30 // Fetch full notebook to get author count 31 - let ident_for_notebook = ident.clone(); 32 - let book_title_for_notebook = book_title.clone(); 33 let data_fetcher = fetcher.clone(); 34 - let notebook_data = use_resource(use_reactive!(|( 35 - ident_for_notebook, 36 - book_title_for_notebook, 37 - )| { 38 let fetcher = data_fetcher.clone(); 39 async move { 40 fetcher 41 - .get_notebook(ident_for_notebook, book_title_for_notebook) 42 .await 43 .ok() 44 .flatten() 45 } 46 - })); 47 48 // Also fetch entries 49 let entry_fetcher = fetcher.clone(); 50 - let entries_resource = use_resource(use_reactive!(|(ident, book_title)| { 51 let fetcher = entry_fetcher.clone(); 52 async move { 53 fetcher 54 - .list_notebook_entries(ident, book_title) 55 .await 56 .ok() 57 .flatten() 58 } 59 - })); 60 61 rsx! { 62 document::Link { rel: "stylesheet", href: ENTRY_CARD_CSS } ··· 71 aside { class: "notebook-sidebar", 72 NotebookCover { 73 notebook: notebook_view.clone(), 74 - title: book_title_clone.to_string() 75 } 76 } 77 ··· 80 for entry in entries { 81 EntryCard { 82 entry: entry.clone(), 83 - book_title: book_title_clone.clone(), 84 author_count 85 } 86 }
··· 1 use crate::{ 2 + Route, 3 components::{EntryCard, NotebookCover, NotebookCss}, 4 + fetch, 5 }; 6 use dioxus::prelude::*; 7 use jacquard::{ ··· 16 /// The component takes a `id` prop of type `i32` from the route enum. Whenever the id changes, the component function will be 17 /// re-run and the rendered HTML will be updated. 18 #[component] 19 + pub fn Notebook(ident: ReadSignal<AtIdentifier<'static>>, book_title: SmolStr) -> Element { 20 rsx! { 21 NotebookCss { ident: ident.to_smolstr(), notebook: book_title } 22 Outlet::<Route> {} ··· 24 } 25 26 #[component] 27 + pub fn NotebookIndex( 28 + ident: ReadSignal<AtIdentifier<'static>>, 29 + book_title: ReadSignal<SmolStr>, 30 + ) -> Element { 31 let fetcher = use_context::<fetch::CachedFetcher>(); 32 // Fetch full notebook to get author count 33 let data_fetcher = fetcher.clone(); 34 + let notebook_data = use_resource(move || { 35 let fetcher = data_fetcher.clone(); 36 async move { 37 fetcher 38 + .get_notebook(ident(), book_title()) 39 .await 40 .ok() 41 .flatten() 42 } 43 + }); 44 45 // Also fetch entries 46 let entry_fetcher = fetcher.clone(); 47 + let entries_resource = use_resource(move || { 48 let fetcher = entry_fetcher.clone(); 49 async move { 50 fetcher 51 + .list_notebook_entries(ident(), book_title()) 52 .await 53 .ok() 54 .flatten() 55 } 56 + }); 57 58 rsx! { 59 document::Link { rel: "stylesheet", href: ENTRY_CARD_CSS } ··· 68 aside { class: "notebook-sidebar", 69 NotebookCover { 70 notebook: notebook_view.clone(), 71 + title: book_title().to_string() 72 } 73 } 74 ··· 77 for entry in entries { 78 EntryCard { 79 entry: entry.clone(), 80 + book_title: book_title(), 81 author_count 82 } 83 }
+5 -5
crates/weaver-app/src/views/record.rs
··· 1 use crate::fetch::CachedFetcher; 2 use dioxus::prelude::*; 3 - use hex_fmt::HexFmt; 4 use humansize::format_size; 5 use jacquard::{ 6 client::AgentSessionExt, ··· 17 } 18 19 #[component] 20 - pub fn RecordView(uri: SmolStr) -> Element { 21 let fetcher = use_context::<CachedFetcher>(); 22 - let at_uri = AtUri::new_owned(uri.clone()); 23 if let Err(err) = &at_uri { 24 let error = format!("{:?}", err); 25 return rsx! { ··· 33 let uri = use_signal(|| at_uri.unwrap()); 34 let mut view_mode = use_signal(|| ViewMode::Pretty); 35 let record = use_resource(move || { 36 - let fetcher = fetcher.clone(); 37 - async move { fetcher.client.fetch_record_slingshot(&uri()).await } 38 }); 39 if let Some(Ok(record)) = &*record.read_unchecked() { 40 let record_value = record.value.clone().into_static();
··· 1 use crate::fetch::CachedFetcher; 2 use dioxus::prelude::*; 3 use humansize::format_size; 4 use jacquard::{ 5 client::AgentSessionExt, ··· 16 } 17 18 #[component] 19 + pub fn RecordView(uri: ReadSignal<SmolStr>) -> Element { 20 let fetcher = use_context::<CachedFetcher>(); 21 + let at_uri = AtUri::new_owned(uri()); 22 if let Err(err) = &at_uri { 23 let error = format!("{:?}", err); 24 return rsx! { ··· 32 let uri = use_signal(|| at_uri.unwrap()); 33 let mut view_mode = use_signal(|| ViewMode::Pretty); 34 let record = use_resource(move || { 35 + let client = fetcher.get_client(); 36 + 37 + async move { client.fetch_record_slingshot(&uri()).await } 38 }); 39 if let Some(Ok(record)) = &*record.read_unchecked() { 40 let record_value = record.value.clone().into_static();
+2 -1
crates/weaver-cli/src/main.rs
··· 386 .build(); 387 388 // Use WeaverExt to upsert entry (handles notebook + entry creation/updates) 389 use weaver_common::WeaverExt; 390 - let (entry_uri, was_created) = (*agent) 391 .upsert_entry(&title, entry_title.as_ref(), entry) 392 .await?; 393
··· 386 .build(); 387 388 // Use WeaverExt to upsert entry (handles notebook + entry creation/updates) 389 + use jacquard::http_client::HttpClient; 390 use weaver_common::WeaverExt; 391 + let (entry_uri, was_created) = agent 392 .upsert_entry(&title, entry_title.as_ref(), entry) 393 .await?; 394
+691 -585
crates/weaver-common/src/agent.rs
··· 7 use crate::error::WeaverError; 8 pub use jacquard; 9 use jacquard::bytes::Bytes; 10 - use jacquard::client::{Agent, AgentError, AgentErrorKind, AgentSession, AgentSessionExt}; 11 use jacquard::error::ClientError; 12 use jacquard::prelude::*; 13 use jacquard::types::blob::{BlobRef, MimeType}; ··· 22 use weaver_api::sh_weaver::notebook::entry; 23 use weaver_api::sh_weaver::publish::blob::Blob as PublishedBlob; 24 25 - use crate::{PublishResult, W_TICKER, WeaverExt, normalize_title_path}; 26 27 - impl<A: AgentSession + IdentityResolver> WeaverExt for Agent<A> { 28 - async fn publish_notebook(&self, _path: &Path) -> Result<PublishResult<'_>, WeaverError> { 29 - // TODO: Implementation 30 - todo!("publish_notebook not yet implemented") 31 } 32 33 - async fn publish_blob<'a>( 34 &self, 35 blob: Bytes, 36 url_path: &'a str, 37 prev: Option<Tid>, 38 - ) -> Result<(StrongRef<'a>, PublishedBlob<'a>), WeaverError> { 39 - let mime_type = 40 - MimeType::new_owned(blob.sniff_mime_type().unwrap_or("application/octet-stream")); 41 42 - let blob = self.upload_blob(blob, mime_type).await?; 43 - let publish_record = PublishedBlob::new() 44 - .path(url_path) 45 - .upload(BlobRef::Blob(blob)) 46 - .build(); 47 - let tid = W_TICKER.lock().await.next(prev); 48 - let record = self 49 - .create_record(publish_record.clone(), Some(RecordKey::any(tid.as_str())?)) 50 - .await?; 51 - let strong_ref = StrongRef::new().uri(record.uri).cid(record.cid).build(); 52 53 - Ok((strong_ref, publish_record)) 54 } 55 56 - async fn upsert_notebook( 57 &self, 58 title: &str, 59 author_did: &Did<'_>, 60 - ) -> Result<(AtUri<'static>, Vec<StrongRef<'static>>), WeaverError> { 61 - use jacquard::types::collection::Collection; 62 - use jacquard::types::nsid::Nsid; 63 - use jacquard::xrpc::XrpcExt; 64 - use weaver_api::com_atproto::repo::list_records::ListRecords; 65 - use weaver_api::sh_weaver::notebook::book::Book; 66 67 - // Find the PDS for this DID 68 - let pds_url = self.pds_for_did(author_did).await.map_err(|e| { 69 - AgentError::from(ClientError::from(e).with_context("Failed to resolve PDS for DID")) 70 - })?; 71 72 - // Search for existing notebook with this title 73 - let resp = self 74 - .xrpc(pds_url) 75 - .send( 76 - &ListRecords::new() 77 - .repo(author_did.clone()) 78 - .collection(Nsid::raw(Book::NSID)) 79 - .limit(100) 80 - .build(), 81 - ) 82 - .await 83 - .map_err(|e| AgentError::from(ClientError::from(e)))?; 84 85 - if let Ok(list) = resp.parse() { 86 - for record in list.records { 87 - let notebook: Book = jacquard::from_data(&record.value).map_err(|_| { 88 - AgentError::from(ClientError::invalid_request( 89 - "Failed to parse notebook record", 90 - )) 91 - })?; 92 - if let Some(book_title) = notebook.title 93 - && book_title == title 94 - { 95 - let entries = notebook 96 - .entry_list 97 - .iter() 98 - .cloned() 99 - .map(IntoStatic::into_static) 100 - .collect(); 101 - return Ok((record.uri.into_static(), entries)); 102 } 103 } 104 - } 105 106 - // Notebook doesn't exist, create it 107 - use weaver_api::sh_weaver::actor::Author; 108 - let path = normalize_title_path(title); 109 - let author = Author::new().did(author_did.clone()).build(); 110 - let book = Book::new() 111 - .authors(vec![author]) 112 - .entry_list(vec![]) 113 - .maybe_title(Some(title.into())) 114 - .maybe_path(Some(path.into())) 115 - .maybe_created_at(Some(jacquard::types::string::Datetime::now())) 116 - .build(); 117 118 - let response = self.create_record(book, None).await?; 119 - Ok((response.uri, Vec::new())) 120 } 121 122 - async fn upsert_entry( 123 &self, 124 notebook_title: &str, 125 entry_title: &str, 126 entry: entry::Entry<'_>, 127 - ) -> Result<(AtUri<'static>, bool), WeaverError> { 128 - // Get our own DID 129 - let (did, _) = self.info().await.ok_or_else(|| { 130 - AgentError::from(ClientError::invalid_request("No session info available")) 131 - })?; 132 133 - // Find or create notebook 134 - let (notebook_uri, entry_refs) = self.upsert_notebook(notebook_title, &did).await?; 135 136 - // Check if entry with this title exists in the notebook 137 - for entry_ref in &entry_refs { 138 - let existing = self 139 - .get_record::<entry::Entry>(&entry_ref.uri) 140 - .await 141 - .map_err(|e| AgentError::from(ClientError::from(e)))?; 142 - if let Ok(existing_entry) = existing.parse() { 143 - if existing_entry.value.title == entry_title { 144 - // Update existing entry 145 - self.update_record::<entry::Entry>(&entry_ref.uri, |e| { 146 - e.content = entry.content.clone(); 147 - e.embeds = entry.embeds.clone(); 148 - e.tags = entry.tags.clone(); 149 - }) 150 - .await?; 151 - return Ok((entry_ref.uri.clone().into_static(), false)); 152 } 153 } 154 - } 155 156 - // Entry doesn't exist, create it 157 - let response = self.create_record(entry, None).await?; 158 - let entry_uri = response.uri.clone(); 159 160 - // Add to notebook's entry_list 161 - use weaver_api::sh_weaver::notebook::book::Book; 162 - let new_ref = StrongRef::new().uri(response.uri).cid(response.cid).build(); 163 164 - self.update_record::<Book>(&notebook_uri, |book| { 165 - book.entry_list.push(new_ref); 166 - }) 167 - .await?; 168 169 - Ok((entry_uri, true)) 170 } 171 172 - async fn view_notebook( 173 &self, 174 uri: &AtUri<'_>, 175 - ) -> Result<(NotebookView<'static>, Vec<StrongRef<'static>>), WeaverError> { 176 - use jacquard::to_data; 177 - use weaver_api::sh_weaver::notebook::AuthorListView; 178 - use weaver_api::sh_weaver::notebook::book::Book; 179 180 - let notebook = self 181 - .get_record::<Book>(uri) 182 - .await 183 - .map_err(|e| AgentError::from(e))? 184 - .into_output() 185 - .map_err(|_| { 186 - AgentError::from(ClientError::invalid_request("Failed to parse Book record")) 187 - })?; 188 189 - let title = notebook.value.title.clone(); 190 - let tags = notebook.value.tags.clone(); 191 192 - let mut authors = Vec::new(); 193 194 - for (index, author) in notebook.value.authors.iter().enumerate() { 195 - let (profile_uri, profile_view) = self.hydrate_profile_view(&author.did).await?; 196 - authors.push( 197 - AuthorListView::new() 198 - .maybe_uri(profile_uri) 199 - .record(profile_view) 200 - .index(index as i64) 201 .build(), 202 - ); 203 } 204 - let entries = notebook 205 - .value 206 - .entry_list 207 - .iter() 208 - .cloned() 209 - .map(IntoStatic::into_static) 210 - .collect(); 211 - 212 - Ok(( 213 - NotebookView::new() 214 - .cid(notebook.cid.ok_or_else(|| { 215 - AgentError::from(ClientError::invalid_request("Notebook missing CID")) 216 - })?) 217 - .uri(notebook.uri) 218 - .indexed_at(jacquard::types::string::Datetime::now()) 219 - .maybe_title(title) 220 - .maybe_tags(tags) 221 - .authors(authors) 222 - .record(to_data(&notebook.value).map_err(|_| { 223 - AgentError::from(ClientError::invalid_request("Failed to serialize notebook")) 224 - })?) 225 - .build(), 226 - entries, 227 - )) 228 } 229 230 - async fn fetch_entry_view<'a>( 231 &self, 232 notebook: &NotebookView<'a>, 233 entry_ref: &StrongRef<'_>, 234 - ) -> Result<EntryView<'a>, WeaverError> { 235 - use jacquard::to_data; 236 - use weaver_api::sh_weaver::notebook::entry::Entry; 237 238 - let entry_uri = Entry::uri(entry_ref.uri.clone()) 239 - .map_err(|_| AgentError::from(ClientError::invalid_request("Invalid entry URI")))?; 240 - let entry = self.fetch_record(&entry_uri).await?; 241 242 - let title = entry.value.title.clone(); 243 - let tags = entry.value.tags.clone(); 244 245 - Ok(EntryView::new() 246 - .cid(entry.cid.ok_or_else(|| { 247 - AgentError::from(ClientError::invalid_request("Entry missing CID")) 248 - })?) 249 - .uri(entry.uri) 250 - .indexed_at(jacquard::types::string::Datetime::now()) 251 - .record(to_data(&entry.value).map_err(|_| { 252 - AgentError::from(ClientError::invalid_request("Failed to serialize entry")) 253 - })?) 254 - .maybe_tags(tags) 255 - .title(title) 256 - .authors(notebook.authors.clone()) 257 - .build()) 258 } 259 260 - async fn entry_by_title<'a>( 261 &self, 262 notebook: &NotebookView<'a>, 263 entries: &[StrongRef<'_>], 264 title: &str, 265 - ) -> Result<Option<(BookEntryView<'a>, entry::Entry<'a>)>, WeaverError> { 266 - use weaver_api::sh_weaver::notebook::BookEntryRef; 267 - use weaver_api::sh_weaver::notebook::entry::Entry; 268 269 - for (index, entry_ref) in entries.iter().enumerate() { 270 - let resp = self 271 - .get_record::<Entry>(&entry_ref.uri) 272 - .await 273 - .map_err(|e| AgentError::from(e))?; 274 - if let Ok(entry) = resp.parse() { 275 - if entry.value.path == title || entry.value.title == title { 276 - // Build BookEntryView with prev/next 277 - let entry_view = self.fetch_entry_view(notebook, entry_ref).await?; 278 279 - let prev_entry = if index > 0 { 280 - let prev_entry_ref = &entries[index - 1]; 281 - self.fetch_entry_view(notebook, prev_entry_ref).await.ok() 282 - } else { 283 - None 284 - } 285 - .map(|e| BookEntryRef::new().entry(e).build()); 286 287 - let next_entry = if index < entries.len() - 1 { 288 - let next_entry_ref = &entries[index + 1]; 289 - self.fetch_entry_view(notebook, next_entry_ref).await.ok() 290 - } else { 291 - None 292 - } 293 - .map(|e| BookEntryRef::new().entry(e).build()); 294 295 - let book_entry_view = BookEntryView::new() 296 - .entry(entry_view) 297 - .maybe_next(next_entry) 298 - .maybe_prev(prev_entry) 299 - .index(index as i64) 300 - .build(); 301 302 - return Ok(Some((book_entry_view, entry.value.into_static()))); 303 } 304 } 305 } 306 - Ok(None) 307 } 308 309 - async fn notebook_by_title( 310 &self, 311 ident: &jacquard::types::ident::AtIdentifier<'_>, 312 title: &str, 313 - ) -> Result<Option<(NotebookView<'static>, Vec<StrongRef<'static>>)>, WeaverError> { 314 - use jacquard::types::collection::Collection; 315 - use jacquard::types::nsid::Nsid; 316 - use jacquard::xrpc::XrpcExt; 317 - use weaver_api::com_atproto::repo::list_records::ListRecords; 318 - use weaver_api::sh_weaver::notebook::AuthorListView; 319 - use weaver_api::sh_weaver::notebook::book::Book; 320 321 - let (repo_did, pds_url) = match ident { 322 - jacquard::types::ident::AtIdentifier::Did(did) => { 323 - let pds = self.pds_for_did(did).await.map_err(|e| { 324 - AgentError::from( 325 - ClientError::from(e).with_context("Failed to resolve PDS for DID"), 326 - ) 327 - })?; 328 - (did.clone(), pds) 329 - } 330 - jacquard::types::ident::AtIdentifier::Handle(handle) => { 331 - self.pds_for_handle(handle).await.map_err(|e| { 332 - AgentError::from(ClientError::from(e).with_context("Failed to resolve handle")) 333 - })? 334 - } 335 - }; 336 337 - // TODO: use the cursor to search through all records with this NSID for the repo 338 - let resp = self 339 - .xrpc(pds_url) 340 - .send( 341 - &ListRecords::new() 342 - .repo(repo_did) 343 - .collection(Nsid::raw(Book::NSID)) 344 - .limit(100) 345 - .build(), 346 - ) 347 - .await 348 - .map_err(|e| AgentError::from(ClientError::from(e)))?; 349 350 - if let Ok(list) = resp.parse() { 351 - for record in list.records { 352 - let notebook: Book = jacquard::from_data(&record.value).map_err(|_| { 353 - AgentError::from(ClientError::invalid_request( 354 - "Failed to parse notebook record", 355 - )) 356 - })?; 357 - if let Some(book_title) = notebook.path 358 - && book_title == title 359 - { 360 - let tags = notebook.tags.clone(); 361 362 - let mut authors = Vec::new(); 363 364 - for (index, author) in notebook.authors.iter().enumerate() { 365 - let (profile_uri, profile_view) = 366 - self.hydrate_profile_view(&author.did).await?; 367 - authors.push( 368 - AuthorListView::new() 369 - .maybe_uri(profile_uri) 370 - .record(profile_view) 371 - .index(index as i64) 372 - .build(), 373 - ); 374 - } 375 - let entries = notebook 376 - .entry_list 377 - .iter() 378 - .cloned() 379 - .map(IntoStatic::into_static) 380 - .collect(); 381 382 - return Ok(Some(( 383 - NotebookView::new() 384 - .cid(record.cid) 385 - .uri(record.uri) 386 - .indexed_at(jacquard::types::string::Datetime::now()) 387 - .title(book_title) 388 - .maybe_tags(tags) 389 - .authors(authors) 390 - .record(record.value.clone()) 391 - .build() 392 - .into_static(), 393 - entries, 394 - ))); 395 - } else if let Some(book_title) = notebook.title 396 - && book_title == title 397 - { 398 - let tags = notebook.tags.clone(); 399 400 - let mut authors = Vec::new(); 401 402 - for (index, author) in notebook.authors.iter().enumerate() { 403 - let (profile_uri, profile_view) = 404 - self.hydrate_profile_view(&author.did).await?; 405 - authors.push( 406 - AuthorListView::new() 407 - .maybe_uri(profile_uri) 408 - .record(profile_view) 409 - .index(index as i64) 410 - .build(), 411 - ); 412 - } 413 - let entries = notebook 414 - .entry_list 415 - .iter() 416 - .cloned() 417 - .map(IntoStatic::into_static) 418 - .collect(); 419 420 - return Ok(Some(( 421 - NotebookView::new() 422 - .cid(record.cid) 423 - .uri(record.uri) 424 - .indexed_at(jacquard::types::string::Datetime::now()) 425 - .title(book_title) 426 - .maybe_tags(tags) 427 - .authors(authors) 428 - .record(record.value.clone()) 429 - .build() 430 - .into_static(), 431 - entries, 432 - ))); 433 } 434 } 435 } 436 - 437 - Ok(None) 438 } 439 440 - async fn confirm_record_ref(&self, uri: &AtUri<'_>) -> Result<StrongRef<'_>, WeaverError> { 441 - let rkey = uri.rkey().ok_or_else(|| { 442 - AgentError::from( 443 - ClientError::invalid_request("AtUri missing rkey") 444 - .with_help("ensure the URI includes a record key after the collection"), 445 - ) 446 - })?; 447 448 - // Resolve authority (DID or handle) to get DID and PDS 449 - use jacquard::types::ident::AtIdentifier; 450 - let (repo_did, pds_url) = match uri.authority() { 451 - AtIdentifier::Did(did) => { 452 - let pds = self.pds_for_did(did).await.map_err(|e| { 453 - AgentError::from( 454 - ClientError::from(e) 455 - .with_context("DID document resolution failed during record retrieval"), 456 - ) 457 - })?; 458 - (did.clone(), pds) 459 - } 460 - AtIdentifier::Handle(handle) => self.pds_for_handle(handle).await.map_err(|e| { 461 - AgentError::from( 462 - ClientError::from(e) 463 - .with_context("handle resolution failed during record retrieval"), 464 - ) 465 - })?, 466 - }; 467 468 - // Make stateless XRPC call to that PDS (no auth required for public records) 469 - use weaver_api::com_atproto::repo::get_record::GetRecord; 470 - let request = GetRecord::new() 471 - .repo(AtIdentifier::Did(repo_did)) 472 - .collection( 473 - uri.collection() 474 - .expect("collection should exist if rkey does") 475 - .clone(), 476 - ) 477 - .rkey(rkey.clone()) 478 - .build(); 479 480 - let response: Response<GetRecordResponse> = { 481 - let http_request = xrpc::build_http_request(&pds_url, &request, &self.opts().await) 482 - .map_err(|e| AgentError::from(ClientError::transport(e)))?; 483 484 - let http_response = self 485 - .send_http(http_request) 486 - .await 487 - .map_err(|e| AgentError::from(ClientError::transport(e)))?; 488 489 - xrpc::process_response(http_response) 490 - } 491 - .map_err(|e| AgentError::new(AgentErrorKind::Client, Some(e.into())))?; 492 - let record = response.parse().map_err(|e| AgentError::xrpc(e))?; 493 - let strong_ref = StrongRef::new() 494 - .uri(record.uri) 495 - .cid(record.cid.expect("when does this NOT have a CID?")) 496 - .build(); 497 - Ok(strong_ref.into_static()) 498 - } 499 - 500 - async fn hydrate_profile_view( 501 - &self, 502 - did: &Did<'_>, 503 - ) -> Result< 504 - ( 505 - Option<AtUri<'static>>, 506 - weaver_api::sh_weaver::actor::ProfileDataView<'static>, 507 - ), 508 - WeaverError, 509 - > { 510 - use weaver_api::app_bsky::actor::{ 511 - ProfileViewDetailed, get_profile::GetProfile, profile::Profile as BskyProfile, 512 - }; 513 - use weaver_api::sh_weaver::actor::{ 514 - ProfileDataView, ProfileDataViewInner, ProfileView, profile::Profile as WeaverProfile, 515 - }; 516 517 - let handles = self.resolve_did_doc_owned(&did).await?.handles(); 518 - let handle = handles.first().ok_or_else(|| { 519 - AgentError::from(ClientError::invalid_request("couldn't resolve handle")) 520 - })?; 521 522 - // Try weaver profile first 523 - let weaver_uri = WeaverProfile::uri(format!("at://{}/sh.weaver.actor.profile/self", did)) 524 - .map_err(|_| { 525 - AgentError::from(ClientError::invalid_request("Invalid weaver profile URI")) 526 - })?; 527 - if let Ok(weaver_record) = self.fetch_record(&weaver_uri).await { 528 - // Convert blobs to CDN URLs 529 - let avatar = weaver_record 530 .value 531 .avatar 532 .as_ref() ··· 541 .map_err(|_| { 542 AgentError::from(ClientError::invalid_request("Invalid avatar URI")) 543 })?; 544 - let banner = weaver_record 545 .value 546 .banner 547 .as_ref() 548 .map(|blob| { 549 let cid = blob.blob().cid(); 550 jacquard::types::string::Uri::new_owned(format!( 551 - "https://cdn.bsky.app/img/banner/plain/{}/{}", 552 did, cid 553 )) 554 }) ··· 557 AgentError::from(ClientError::invalid_request("Invalid banner URI")) 558 })?; 559 560 - let profile_view = ProfileView::new() 561 .did(did.clone()) 562 .handle(handle.clone()) 563 - .maybe_display_name(weaver_record.value.display_name.clone()) 564 - .maybe_description(weaver_record.value.description.clone()) 565 .maybe_avatar(avatar) 566 .maybe_banner(banner) 567 - .maybe_bluesky(weaver_record.value.bluesky) 568 - .maybe_tangled(weaver_record.value.tangled) 569 - .maybe_streamplace(weaver_record.value.streamplace) 570 - .maybe_location(weaver_record.value.location.clone()) 571 - .maybe_links(weaver_record.value.links.clone()) 572 - .maybe_pronouns(weaver_record.value.pronouns.clone()) 573 - .maybe_pinned(weaver_record.value.pinned.clone()) 574 .indexed_at(jacquard::types::string::Datetime::now()) 575 - .maybe_created_at(weaver_record.value.created_at) 576 .build(); 577 578 - return Ok(( 579 - Some(weaver_uri.as_uri().clone().into_static()), 580 ProfileDataView::new() 581 - .inner(ProfileDataViewInner::ProfileView(Box::new(profile_view))) 582 .build() 583 .into_static(), 584 - )); 585 - } 586 - 587 - if let Ok(bsky_resp) = self 588 - .send(GetProfile::new().actor(did.clone()).build()) 589 - .await 590 - { 591 - if let Ok(output) = bsky_resp.parse() { 592 - let bsky_uri = BskyProfile::uri(format!( 593 - "at://{}/app.bsky.actor.profile/self", 594 - did 595 - )) 596 - .map_err(|_| { 597 - AgentError::from(ClientError::invalid_request("Invalid bsky profile URI")) 598 - })?; 599 - return Ok(( 600 - Some(bsky_uri.as_uri().clone().into_static()), 601 - ProfileDataView::new() 602 - .inner(ProfileDataViewInner::ProfileViewDetailed(Box::new( 603 - output.value.into_static(), 604 - ))) 605 - .build() 606 - .into_static(), 607 - )); 608 - } 609 } 610 - 611 - // Fallback: fetch bsky profile record directly and construct minimal ProfileViewDetailed 612 - let bsky_uri = BskyProfile::uri(format!("at://{}/app.bsky.actor.profile/self", did)) 613 - .map_err(|_| { 614 - AgentError::from(ClientError::invalid_request("Invalid bsky profile URI")) 615 - })?; 616 - let bsky_record = self.fetch_record(&bsky_uri).await?; 617 - 618 - let avatar = bsky_record 619 - .value 620 - .avatar 621 - .as_ref() 622 - .map(|blob| { 623 - let cid = blob.blob().cid(); 624 - jacquard::types::string::Uri::new_owned(format!( 625 - "https://cdn.bsky.app/img/avatar/plain/{}/{}", 626 - did, cid 627 - )) 628 - }) 629 - .transpose() 630 - .map_err(|_| AgentError::from(ClientError::invalid_request("Invalid avatar URI")))?; 631 - let banner = bsky_record 632 - .value 633 - .banner 634 - .as_ref() 635 - .map(|blob| { 636 - let cid = blob.blob().cid(); 637 - jacquard::types::string::Uri::new_owned(format!( 638 - "https://cdn.bsky.app/img/feed_fullsize/plain/{}/{}", 639 - did, cid 640 - )) 641 - }) 642 - .transpose() 643 - .map_err(|_| AgentError::from(ClientError::invalid_request("Invalid banner URI")))?; 644 - 645 - let profile_detailed = ProfileViewDetailed::new() 646 - .did(did.clone()) 647 - .handle(handle.clone()) 648 - .maybe_display_name(bsky_record.value.display_name.clone()) 649 - .maybe_description(bsky_record.value.description.clone()) 650 - .maybe_avatar(avatar) 651 - .maybe_banner(banner) 652 - .indexed_at(jacquard::types::string::Datetime::now()) 653 - .maybe_created_at(bsky_record.value.created_at) 654 - .build(); 655 - 656 - Ok(( 657 - Some(bsky_uri.as_uri().clone().into_static()), 658 - ProfileDataView::new() 659 - .inner(ProfileDataViewInner::ProfileViewDetailed(Box::new( 660 - profile_detailed, 661 - ))) 662 - .build() 663 - .into_static(), 664 - )) 665 } 666 667 - async fn view_entry<'a>( 668 &self, 669 notebook: &NotebookView<'a>, 670 entries: &[StrongRef<'_>], 671 index: usize, 672 - ) -> Result<BookEntryView<'a>, WeaverError> { 673 - use weaver_api::sh_weaver::notebook::BookEntryRef; 674 675 - let entry_ref = entries 676 - .get(index) 677 - .ok_or_else(|| AgentError::from(ClientError::invalid_request("entry out of bounds")))?; 678 - let entry = self.fetch_entry_view(notebook, entry_ref).await?; 679 680 - let prev_entry = if index > 0 { 681 - let prev_entry_ref = &entries[index - 1]; 682 - self.fetch_entry_view(notebook, prev_entry_ref).await.ok() 683 - } else { 684 - None 685 - } 686 - .map(|e| BookEntryRef::new().entry(e).build()); 687 688 - let next_entry = if index < entries.len() - 1 { 689 - let next_entry_ref = &entries[index + 1]; 690 - self.fetch_entry_view(notebook, next_entry_ref).await.ok() 691 - } else { 692 - None 693 - } 694 - .map(|e| BookEntryRef::new().entry(e).build()); 695 696 - Ok(BookEntryView::new() 697 - .entry(entry) 698 - .maybe_next(next_entry) 699 - .maybe_prev(prev_entry) 700 - .index(index as i64) 701 - .build()) 702 } 703 704 - async fn view_page<'a>( 705 &self, 706 notebook: &NotebookView<'a>, 707 pages: &[StrongRef<'_>], 708 index: usize, 709 - ) -> Result<BookEntryView<'a>, WeaverError> { 710 - use weaver_api::sh_weaver::notebook::BookEntryRef; 711 712 - let entry_ref = pages 713 - .get(index) 714 - .ok_or_else(|| AgentError::from(ClientError::invalid_request("entry out of bounds")))?; 715 - let entry = self.fetch_page_view(notebook, entry_ref).await?; 716 717 - let prev_entry = if index > 0 { 718 - let prev_entry_ref = &pages[index - 1]; 719 - self.fetch_page_view(notebook, prev_entry_ref).await.ok() 720 - } else { 721 - None 722 - } 723 - .map(|e| BookEntryRef::new().entry(e).build()); 724 725 - let next_entry = if index < pages.len() - 1 { 726 - let next_entry_ref = &pages[index + 1]; 727 - self.fetch_page_view(notebook, next_entry_ref).await.ok() 728 - } else { 729 - None 730 } 731 - .map(|e| BookEntryRef::new().entry(e).build()); 732 - 733 - Ok(BookEntryView::new() 734 - .entry(entry) 735 - .maybe_next(next_entry) 736 - .maybe_prev(prev_entry) 737 - .index(index as i64) 738 - .build()) 739 } 740 741 - async fn fetch_page_view<'a>( 742 &self, 743 notebook: &NotebookView<'a>, 744 entry_ref: &StrongRef<'_>, 745 - ) -> Result<EntryView<'a>, WeaverError> { 746 - use jacquard::to_data; 747 - use weaver_api::sh_weaver::notebook::page::Page; 748 749 - let entry_uri = Page::uri(entry_ref.uri.clone()) 750 - .map_err(|_| AgentError::from(ClientError::invalid_request("Invalid page URI")))?; 751 - let entry = self.fetch_record(&entry_uri).await?; 752 753 - let title = entry.value.title.clone(); 754 - let tags = entry.value.tags.clone(); 755 756 - Ok(EntryView::new() 757 - .cid(entry.cid.ok_or_else(|| { 758 - AgentError::from(ClientError::invalid_request("Page missing CID")) 759 - })?) 760 - .uri(entry.uri) 761 - .indexed_at(jacquard::types::string::Datetime::now()) 762 - .record(to_data(&entry.value).map_err(|_| { 763 - AgentError::from(ClientError::invalid_request("Failed to serialize page")) 764 - })?) 765 - .maybe_tags(tags) 766 - .title(title) 767 - .authors(notebook.authors.clone()) 768 - .build()) 769 } 770 }
··· 7 use crate::error::WeaverError; 8 pub use jacquard; 9 use jacquard::bytes::Bytes; 10 + use jacquard::client::{AgentError, AgentErrorKind, AgentSession, AgentSessionExt}; 11 use jacquard::error::ClientError; 12 use jacquard::prelude::*; 13 use jacquard::types::blob::{BlobRef, MimeType}; ··· 22 use weaver_api::sh_weaver::notebook::entry; 23 use weaver_api::sh_weaver::publish::blob::Blob as PublishedBlob; 24 25 + use crate::{PublishResult, W_TICKER, normalize_title_path}; 26 27 + /// Extension trait providing weaver-specific multi-step operations on Agent 28 + /// 29 + /// This trait extends jacquard's Agent with notebook-specific workflows that 30 + /// involve multiple atproto operations (uploading blobs, creating records, etc.) 31 + /// 32 + /// For single-step operations, use jacquard's built-in methods directly: 33 + /// - `agent.create_record()` - Create a single record 34 + /// - `agent.get_record()` - Get a single record 35 + /// - `agent.upload_blob()` - Upload a single blob 36 + /// 37 + /// This trait is for multi-step workflows that coordinate between multiple operations. 38 + //#[trait_variant::make(Send)] 39 + pub trait WeaverExt: AgentSessionExt + XrpcExt { 40 + /// Publish a notebook directory to the user's PDS 41 + /// 42 + /// Multi-step workflow: 43 + /// 1. Parse markdown files in directory 44 + /// 2. Extract and upload images/assets → BlobRefs 45 + /// 3. Transform markdown refs to point at uploaded blobs 46 + /// 4. Create entry records for each file 47 + /// 5. Create book record with entry refs 48 + /// 49 + /// Returns the AT-URI of the published book 50 + fn publish_notebook( 51 + &self, 52 + path: &Path, 53 + ) -> impl Future<Output = Result<PublishResult<'_>, WeaverError>> { 54 + async { todo!() } 55 } 56 57 + /// Publish a blob to the user's PDS 58 + /// 59 + /// Multi-step workflow: 60 + /// 1. Upload blob to PDS 61 + /// 2. Create blob record with CID 62 + /// 63 + /// Returns the AT-URI of the published blob 64 + fn publish_blob<'a>( 65 &self, 66 blob: Bytes, 67 url_path: &'a str, 68 prev: Option<Tid>, 69 + ) -> impl Future<Output = Result<(StrongRef<'a>, PublishedBlob<'a>), WeaverError>> { 70 + async move { 71 + let mime_type = 72 + MimeType::new_owned(blob.sniff_mime_type().unwrap_or("application/octet-stream")); 73 74 + let blob = self.upload_blob(blob, mime_type).await?; 75 + let publish_record = PublishedBlob::new() 76 + .path(url_path) 77 + .upload(BlobRef::Blob(blob)) 78 + .build(); 79 + let tid = W_TICKER.lock().await.next(prev); 80 + let record = self 81 + .create_record(publish_record.clone(), Some(RecordKey::any(tid.as_str())?)) 82 + .await?; 83 + let strong_ref = StrongRef::new().uri(record.uri).cid(record.cid).build(); 84 85 + Ok((strong_ref, publish_record)) 86 + } 87 } 88 89 + fn confirm_record_ref( 90 + &self, 91 + uri: &AtUri<'_>, 92 + ) -> impl Future<Output = Result<StrongRef<'_>, WeaverError>> { 93 + async move { 94 + let rkey = uri.rkey().ok_or_else(|| { 95 + AgentError::from( 96 + ClientError::invalid_request("AtUri missing rkey") 97 + .with_help("ensure the URI includes a record key after the collection"), 98 + ) 99 + })?; 100 + 101 + // Resolve authority (DID or handle) to get DID and PDS 102 + use jacquard::types::ident::AtIdentifier; 103 + let (repo_did, pds_url) = match uri.authority() { 104 + AtIdentifier::Did(did) => { 105 + let pds = 106 + self.pds_for_did(did).await.map_err(|e| { 107 + AgentError::from(ClientError::from(e).with_context( 108 + "DID document resolution failed during record retrieval", 109 + )) 110 + })?; 111 + (did.clone(), pds) 112 + } 113 + AtIdentifier::Handle(handle) => self.pds_for_handle(handle).await.map_err(|e| { 114 + AgentError::from( 115 + ClientError::from(e) 116 + .with_context("handle resolution failed during record retrieval"), 117 + ) 118 + })?, 119 + }; 120 + 121 + // Make stateless XRPC call to that PDS (no auth required for public records) 122 + use weaver_api::com_atproto::repo::get_record::GetRecord; 123 + let request = GetRecord::new() 124 + .repo(AtIdentifier::Did(repo_did)) 125 + .collection( 126 + uri.collection() 127 + .expect("collection should exist if rkey does") 128 + .clone(), 129 + ) 130 + .rkey(rkey.clone()) 131 + .build(); 132 + 133 + let response: Response<GetRecordResponse> = { 134 + let http_request = xrpc::build_http_request(&pds_url, &request, &self.opts().await) 135 + .map_err(|e| AgentError::from(ClientError::transport(e)))?; 136 + 137 + let http_response = self 138 + .send_http(http_request) 139 + .await 140 + .map_err(|e| AgentError::from(ClientError::transport(e)))?; 141 + 142 + xrpc::process_response(http_response) 143 + } 144 + .map_err(|e| AgentError::new(AgentErrorKind::Client, Some(e.into())))?; 145 + let record = response.parse().map_err(|e| AgentError::xrpc(e))?; 146 + let strong_ref = StrongRef::new() 147 + .uri(record.uri) 148 + .cid(record.cid.expect("when does this NOT have a CID?")) 149 + .build(); 150 + Ok(strong_ref.into_static()) 151 + } 152 + } 153 + 154 + /// Find or create a notebook by title, returning its URI and entry list 155 + /// 156 + /// If the notebook doesn't exist, creates it with the given DID as author. 157 + fn upsert_notebook( 158 &self, 159 title: &str, 160 author_did: &Did<'_>, 161 + ) -> impl Future<Output = Result<(AtUri<'static>, Vec<StrongRef<'static>>), WeaverError>> 162 + where 163 + Self: Sized, 164 + { 165 + async move { 166 + use jacquard::types::collection::Collection; 167 + use jacquard::types::nsid::Nsid; 168 + use weaver_api::com_atproto::repo::list_records::ListRecords; 169 + use weaver_api::sh_weaver::notebook::book::Book; 170 171 + // Find the PDS for this DID 172 + let pds_url = self.pds_for_did(author_did).await.map_err(|e| { 173 + AgentError::from(ClientError::from(e).with_context("Failed to resolve PDS for DID")) 174 + })?; 175 176 + // Search for existing notebook with this title 177 + let resp = self 178 + .xrpc(pds_url) 179 + .send( 180 + &ListRecords::new() 181 + .repo(author_did.clone()) 182 + .collection(Nsid::raw(Book::NSID)) 183 + .limit(100) 184 + .build(), 185 + ) 186 + .await 187 + .map_err(|e| AgentError::from(ClientError::from(e)))?; 188 189 + if let Ok(list) = resp.parse() { 190 + for record in list.records { 191 + let notebook: Book = jacquard::from_data(&record.value).map_err(|_| { 192 + AgentError::from(ClientError::invalid_request( 193 + "Failed to parse notebook record", 194 + )) 195 + })?; 196 + if let Some(book_title) = notebook.title 197 + && book_title == title 198 + { 199 + let entries = notebook 200 + .entry_list 201 + .iter() 202 + .cloned() 203 + .map(IntoStatic::into_static) 204 + .collect(); 205 + return Ok((record.uri.into_static(), entries)); 206 + } 207 } 208 } 209 210 + // Notebook doesn't exist, create it 211 + use weaver_api::sh_weaver::actor::Author; 212 + let path = normalize_title_path(title); 213 + let author = Author::new().did(author_did.clone()).build(); 214 + let book = Book::new() 215 + .authors(vec![author]) 216 + .entry_list(vec![]) 217 + .maybe_title(Some(title.into())) 218 + .maybe_path(Some(path.into())) 219 + .maybe_created_at(Some(jacquard::types::string::Datetime::now())) 220 + .build(); 221 222 + let response = self.create_record(book, None).await?; 223 + Ok((response.uri, Vec::new())) 224 + } 225 } 226 227 + /// Find or create an entry within a notebook by title 228 + /// 229 + /// Multi-step workflow: 230 + /// 1. Find the notebook by title 231 + /// 2. Check notebook's entry_list for entry with matching title 232 + /// 3. If found: update the entry with new content 233 + /// 4. If not found: create new entry and append to notebook's entry_list 234 + /// 235 + /// Returns (entry_uri, was_created) 236 + fn upsert_entry( 237 &self, 238 notebook_title: &str, 239 entry_title: &str, 240 entry: entry::Entry<'_>, 241 + ) -> impl Future<Output = Result<(AtUri<'static>, bool), WeaverError>> 242 + where 243 + Self: Sized, 244 + { 245 + async move { 246 + // Get our own DID 247 + let (did, _) = self.session_info().await.ok_or_else(|| { 248 + AgentError::from(ClientError::invalid_request("No session info available")) 249 + })?; 250 251 + // Find or create notebook 252 + let (notebook_uri, entry_refs) = self.upsert_notebook(notebook_title, &did).await?; 253 254 + // Check if entry with this title exists in the notebook 255 + for entry_ref in &entry_refs { 256 + let existing = self 257 + .get_record::<entry::Entry>(&entry_ref.uri) 258 + .await 259 + .map_err(|e| AgentError::from(ClientError::from(e)))?; 260 + if let Ok(existing_entry) = existing.parse() { 261 + if existing_entry.value.title == entry_title { 262 + // Update existing entry 263 + self.update_record::<entry::Entry>(&entry_ref.uri, |e| { 264 + e.content = entry.content.clone(); 265 + e.embeds = entry.embeds.clone(); 266 + e.tags = entry.tags.clone(); 267 + }) 268 + .await?; 269 + return Ok((entry_ref.uri.clone().into_static(), false)); 270 + } 271 } 272 } 273 274 + // Entry doesn't exist, create it 275 + let response = self.create_record(entry, None).await?; 276 + let entry_uri = response.uri.clone(); 277 278 + // Add to notebook's entry_list 279 + use weaver_api::sh_weaver::notebook::book::Book; 280 + let new_ref = StrongRef::new().uri(response.uri).cid(response.cid).build(); 281 282 + self.update_record::<Book>(&notebook_uri, |book| { 283 + book.entry_list.push(new_ref); 284 + }) 285 + .await?; 286 287 + Ok((entry_uri, true)) 288 + } 289 } 290 291 + /// View functions - generic versions that work with any Agent 292 + 293 + /// Fetch a notebook and construct NotebookView with author profiles 294 + fn view_notebook( 295 &self, 296 uri: &AtUri<'_>, 297 + ) -> impl Future<Output = Result<(NotebookView<'static>, Vec<StrongRef<'static>>), WeaverError>> 298 + { 299 + async move { 300 + use jacquard::to_data; 301 + use weaver_api::sh_weaver::notebook::AuthorListView; 302 + use weaver_api::sh_weaver::notebook::book::Book; 303 304 + let notebook = self 305 + .get_record::<Book>(uri) 306 + .await 307 + .map_err(|e| AgentError::from(e))? 308 + .into_output() 309 + .map_err(|_| { 310 + AgentError::from(ClientError::invalid_request("Failed to parse Book record")) 311 + })?; 312 313 + let title = notebook.value.title.clone(); 314 + let tags = notebook.value.tags.clone(); 315 316 + let mut authors = Vec::new(); 317 318 + for (index, author) in notebook.value.authors.iter().enumerate() { 319 + let (profile_uri, profile_view) = self.hydrate_profile_view(&author.did).await?; 320 + authors.push( 321 + AuthorListView::new() 322 + .maybe_uri(profile_uri) 323 + .record(profile_view) 324 + .index(index as i64) 325 + .build(), 326 + ); 327 + } 328 + let entries = notebook 329 + .value 330 + .entry_list 331 + .iter() 332 + .cloned() 333 + .map(IntoStatic::into_static) 334 + .collect(); 335 + 336 + Ok(( 337 + NotebookView::new() 338 + .cid(notebook.cid.ok_or_else(|| { 339 + AgentError::from(ClientError::invalid_request("Notebook missing CID")) 340 + })?) 341 + .uri(notebook.uri) 342 + .indexed_at(jacquard::types::string::Datetime::now()) 343 + .maybe_title(title) 344 + .maybe_tags(tags) 345 + .authors(authors) 346 + .record(to_data(&notebook.value).map_err(|_| { 347 + AgentError::from(ClientError::invalid_request( 348 + "Failed to serialize notebook", 349 + )) 350 + })?) 351 .build(), 352 + entries, 353 + )) 354 } 355 } 356 357 + /// Fetch an entry and construct EntryView 358 + fn fetch_entry_view<'a>( 359 &self, 360 notebook: &NotebookView<'a>, 361 entry_ref: &StrongRef<'_>, 362 + ) -> impl Future<Output = Result<EntryView<'a>, WeaverError>> { 363 + async move { 364 + use jacquard::to_data; 365 + use weaver_api::sh_weaver::notebook::entry::Entry; 366 367 + let entry_uri = Entry::uri(entry_ref.uri.clone()) 368 + .map_err(|_| AgentError::from(ClientError::invalid_request("Invalid entry URI")))?; 369 + let entry = self.fetch_record(&entry_uri).await?; 370 371 + let title = entry.value.title.clone(); 372 + let tags = entry.value.tags.clone(); 373 374 + Ok(EntryView::new() 375 + .cid(entry.cid.ok_or_else(|| { 376 + AgentError::from(ClientError::invalid_request("Entry missing CID")) 377 + })?) 378 + .uri(entry.uri) 379 + .indexed_at(jacquard::types::string::Datetime::now()) 380 + .record(to_data(&entry.value).map_err(|_| { 381 + AgentError::from(ClientError::invalid_request("Failed to serialize entry")) 382 + })?) 383 + .maybe_tags(tags) 384 + .title(title) 385 + .authors(notebook.authors.clone()) 386 + .build()) 387 + } 388 } 389 390 + /// Search for an entry by title within a notebook's entry list 391 + fn entry_by_title<'a>( 392 &self, 393 notebook: &NotebookView<'a>, 394 entries: &[StrongRef<'_>], 395 title: &str, 396 + ) -> impl Future<Output = Result<Option<(BookEntryView<'a>, entry::Entry<'a>)>, WeaverError>> 397 + { 398 + async move { 399 + use weaver_api::sh_weaver::notebook::BookEntryRef; 400 + use weaver_api::sh_weaver::notebook::entry::Entry; 401 402 + for (index, entry_ref) in entries.iter().enumerate() { 403 + let resp = self 404 + .get_record::<Entry>(&entry_ref.uri) 405 + .await 406 + .map_err(|e| AgentError::from(e))?; 407 + if let Ok(entry) = resp.parse() { 408 + if entry.value.path == title || entry.value.title == title { 409 + // Build BookEntryView with prev/next 410 + let entry_view = self.fetch_entry_view(notebook, entry_ref).await?; 411 412 + let prev_entry = if index > 0 { 413 + let prev_entry_ref = &entries[index - 1]; 414 + self.fetch_entry_view(notebook, prev_entry_ref).await.ok() 415 + } else { 416 + None 417 + } 418 + .map(|e| BookEntryRef::new().entry(e).build()); 419 420 + let next_entry = if index < entries.len() - 1 { 421 + let next_entry_ref = &entries[index + 1]; 422 + self.fetch_entry_view(notebook, next_entry_ref).await.ok() 423 + } else { 424 + None 425 + } 426 + .map(|e| BookEntryRef::new().entry(e).build()); 427 428 + let book_entry_view = BookEntryView::new() 429 + .entry(entry_view) 430 + .maybe_next(next_entry) 431 + .maybe_prev(prev_entry) 432 + .index(index as i64) 433 + .build(); 434 435 + return Ok(Some((book_entry_view, entry.value.into_static()))); 436 + } 437 } 438 } 439 + Ok(None) 440 } 441 } 442 443 + /// Search for a notebook by title for a given DID or handle 444 + fn notebook_by_title( 445 &self, 446 ident: &jacquard::types::ident::AtIdentifier<'_>, 447 title: &str, 448 + ) -> impl Future< 449 + Output = Result<Option<(NotebookView<'static>, Vec<StrongRef<'static>>)>, WeaverError>, 450 + > 451 + where 452 + Self: Sized, 453 + { 454 + async move { 455 + use jacquard::types::collection::Collection; 456 + use jacquard::types::nsid::Nsid; 457 + use weaver_api::com_atproto::repo::list_records::ListRecords; 458 + use weaver_api::sh_weaver::notebook::AuthorListView; 459 + use weaver_api::sh_weaver::notebook::book::Book; 460 461 + let (repo_did, pds_url) = match ident { 462 + jacquard::types::ident::AtIdentifier::Did(did) => { 463 + let pds = self.pds_for_did(did).await.map_err(|e| { 464 + AgentError::from( 465 + ClientError::from(e).with_context("Failed to resolve PDS for DID"), 466 + ) 467 + })?; 468 + (did.clone(), pds) 469 + } 470 + jacquard::types::ident::AtIdentifier::Handle(handle) => { 471 + self.pds_for_handle(handle).await.map_err(|e| { 472 + AgentError::from( 473 + ClientError::from(e).with_context("Failed to resolve handle"), 474 + ) 475 + })? 476 + } 477 + }; 478 479 + // TODO: use the cursor to search through all records with this NSID for the repo 480 + let resp = self 481 + .xrpc(pds_url) 482 + .send( 483 + &ListRecords::new() 484 + .repo(repo_did) 485 + .collection(Nsid::raw(Book::NSID)) 486 + .limit(100) 487 + .build(), 488 + ) 489 + .await 490 + .map_err(|e| AgentError::from(ClientError::from(e)))?; 491 492 + if let Ok(list) = resp.parse() { 493 + for record in list.records { 494 + let notebook: Book = jacquard::from_data(&record.value).map_err(|_| { 495 + AgentError::from(ClientError::invalid_request( 496 + "Failed to parse notebook record", 497 + )) 498 + })?; 499 + if let Some(book_title) = notebook.path 500 + && book_title == title 501 + { 502 + let tags = notebook.tags.clone(); 503 504 + let mut authors = Vec::new(); 505 506 + for (index, author) in notebook.authors.iter().enumerate() { 507 + let (profile_uri, profile_view) = 508 + self.hydrate_profile_view(&author.did).await?; 509 + authors.push( 510 + AuthorListView::new() 511 + .maybe_uri(profile_uri) 512 + .record(profile_view) 513 + .index(index as i64) 514 + .build(), 515 + ); 516 + } 517 + let entries = notebook 518 + .entry_list 519 + .iter() 520 + .cloned() 521 + .map(IntoStatic::into_static) 522 + .collect(); 523 524 + return Ok(Some(( 525 + NotebookView::new() 526 + .cid(record.cid) 527 + .uri(record.uri) 528 + .indexed_at(jacquard::types::string::Datetime::now()) 529 + .title(book_title) 530 + .maybe_tags(tags) 531 + .authors(authors) 532 + .record(record.value.clone()) 533 + .build() 534 + .into_static(), 535 + entries, 536 + ))); 537 + } else if let Some(book_title) = notebook.title 538 + && book_title == title 539 + { 540 + let tags = notebook.tags.clone(); 541 542 + let mut authors = Vec::new(); 543 544 + for (index, author) in notebook.authors.iter().enumerate() { 545 + let (profile_uri, profile_view) = 546 + self.hydrate_profile_view(&author.did).await?; 547 + authors.push( 548 + AuthorListView::new() 549 + .maybe_uri(profile_uri) 550 + .record(profile_view) 551 + .index(index as i64) 552 + .build(), 553 + ); 554 + } 555 + let entries = notebook 556 + .entry_list 557 + .iter() 558 + .cloned() 559 + .map(IntoStatic::into_static) 560 + .collect(); 561 562 + return Ok(Some(( 563 + NotebookView::new() 564 + .cid(record.cid) 565 + .uri(record.uri) 566 + .indexed_at(jacquard::types::string::Datetime::now()) 567 + .title(book_title) 568 + .maybe_tags(tags) 569 + .authors(authors) 570 + .record(record.value.clone()) 571 + .build() 572 + .into_static(), 573 + entries, 574 + ))); 575 + } 576 } 577 } 578 + 579 + Ok(None) 580 } 581 } 582 583 + /// Hydrate a profile view from either weaver or bsky profile 584 + fn hydrate_profile_view( 585 + &self, 586 + did: &Did<'_>, 587 + ) -> impl Future< 588 + Output = Result< 589 + ( 590 + Option<AtUri<'static>>, 591 + weaver_api::sh_weaver::actor::ProfileDataView<'static>, 592 + ), 593 + WeaverError, 594 + >, 595 + > { 596 + async move { 597 + use weaver_api::app_bsky::actor::{ 598 + ProfileViewDetailed, get_profile::GetProfile, profile::Profile as BskyProfile, 599 + }; 600 + use weaver_api::sh_weaver::actor::{ 601 + ProfileDataView, ProfileDataViewInner, ProfileView, 602 + profile::Profile as WeaverProfile, 603 + }; 604 605 + let handles = self.resolve_did_doc_owned(&did).await?.handles(); 606 + let handle = handles.first().ok_or_else(|| { 607 + AgentError::from(ClientError::invalid_request("couldn't resolve handle")) 608 + })?; 609 610 + // Try weaver profile first 611 + let weaver_uri = 612 + WeaverProfile::uri(format!("at://{}/sh.weaver.actor.profile/self", did)).map_err( 613 + |_| { 614 + AgentError::from(ClientError::invalid_request("Invalid weaver profile URI")) 615 + }, 616 + )?; 617 + if let Ok(weaver_record) = self.fetch_record(&weaver_uri).await { 618 + // Convert blobs to CDN URLs 619 + let avatar = weaver_record 620 + .value 621 + .avatar 622 + .as_ref() 623 + .map(|blob| { 624 + let cid = blob.blob().cid(); 625 + jacquard::types::string::Uri::new_owned(format!( 626 + "https://cdn.bsky.app/img/avatar/plain/{}/{}", 627 + did, cid 628 + )) 629 + }) 630 + .transpose() 631 + .map_err(|_| { 632 + AgentError::from(ClientError::invalid_request("Invalid avatar URI")) 633 + })?; 634 + let banner = weaver_record 635 + .value 636 + .banner 637 + .as_ref() 638 + .map(|blob| { 639 + let cid = blob.blob().cid(); 640 + jacquard::types::string::Uri::new_owned(format!( 641 + "https://cdn.bsky.app/img/banner/plain/{}/{}", 642 + did, cid 643 + )) 644 + }) 645 + .transpose() 646 + .map_err(|_| { 647 + AgentError::from(ClientError::invalid_request("Invalid banner URI")) 648 + })?; 649 650 + let profile_view = ProfileView::new() 651 + .did(did.clone()) 652 + .handle(handle.clone()) 653 + .maybe_display_name(weaver_record.value.display_name.clone()) 654 + .maybe_description(weaver_record.value.description.clone()) 655 + .maybe_avatar(avatar) 656 + .maybe_banner(banner) 657 + .maybe_bluesky(weaver_record.value.bluesky) 658 + .maybe_tangled(weaver_record.value.tangled) 659 + .maybe_streamplace(weaver_record.value.streamplace) 660 + .maybe_location(weaver_record.value.location.clone()) 661 + .maybe_links(weaver_record.value.links.clone()) 662 + .maybe_pronouns(weaver_record.value.pronouns.clone()) 663 + .maybe_pinned(weaver_record.value.pinned.clone()) 664 + .indexed_at(jacquard::types::string::Datetime::now()) 665 + .maybe_created_at(weaver_record.value.created_at) 666 + .build(); 667 668 + return Ok(( 669 + Some(weaver_uri.as_uri().clone().into_static()), 670 + ProfileDataView::new() 671 + .inner(ProfileDataViewInner::ProfileView(Box::new(profile_view))) 672 + .build() 673 + .into_static(), 674 + )); 675 + } 676 677 + if let Ok(bsky_resp) = self 678 + .send(GetProfile::new().actor(did.clone()).build()) 679 + .await 680 + { 681 + if let Ok(output) = bsky_resp.parse() { 682 + let bsky_uri = 683 + BskyProfile::uri(format!("at://{}/app.bsky.actor.profile/self", did)) 684 + .map_err(|_| { 685 + AgentError::from(ClientError::invalid_request( 686 + "Invalid bsky profile URI", 687 + )) 688 + })?; 689 + return Ok(( 690 + Some(bsky_uri.as_uri().clone().into_static()), 691 + ProfileDataView::new() 692 + .inner(ProfileDataViewInner::ProfileViewDetailed(Box::new( 693 + output.value.into_static(), 694 + ))) 695 + .build() 696 + .into_static(), 697 + )); 698 + } 699 + } 700 701 + // Fallback: fetch bsky profile record directly and construct minimal ProfileViewDetailed 702 + let bsky_uri = BskyProfile::uri(format!("at://{}/app.bsky.actor.profile/self", did)) 703 + .map_err(|_| { 704 + AgentError::from(ClientError::invalid_request("Invalid bsky profile URI")) 705 + })?; 706 + let bsky_record = self.fetch_record(&bsky_uri).await?; 707 708 + let avatar = bsky_record 709 .value 710 .avatar 711 .as_ref() ··· 720 .map_err(|_| { 721 AgentError::from(ClientError::invalid_request("Invalid avatar URI")) 722 })?; 723 + let banner = bsky_record 724 .value 725 .banner 726 .as_ref() 727 .map(|blob| { 728 let cid = blob.blob().cid(); 729 jacquard::types::string::Uri::new_owned(format!( 730 + "https://cdn.bsky.app/img/feed_fullsize/plain/{}/{}", 731 did, cid 732 )) 733 }) ··· 736 AgentError::from(ClientError::invalid_request("Invalid banner URI")) 737 })?; 738 739 + let profile_detailed = ProfileViewDetailed::new() 740 .did(did.clone()) 741 .handle(handle.clone()) 742 + .maybe_display_name(bsky_record.value.display_name.clone()) 743 + .maybe_description(bsky_record.value.description.clone()) 744 .maybe_avatar(avatar) 745 .maybe_banner(banner) 746 .indexed_at(jacquard::types::string::Datetime::now()) 747 + .maybe_created_at(bsky_record.value.created_at) 748 .build(); 749 750 + Ok(( 751 + Some(bsky_uri.as_uri().clone().into_static()), 752 ProfileDataView::new() 753 + .inner(ProfileDataViewInner::ProfileViewDetailed(Box::new( 754 + profile_detailed, 755 + ))) 756 .build() 757 .into_static(), 758 + )) 759 } 760 } 761 762 + /// View an entry at a specific index with prev/next navigation 763 + fn view_entry<'a>( 764 &self, 765 notebook: &NotebookView<'a>, 766 entries: &[StrongRef<'_>], 767 index: usize, 768 + ) -> impl Future<Output = Result<BookEntryView<'a>, WeaverError>> { 769 + async move { 770 + use weaver_api::sh_weaver::notebook::BookEntryRef; 771 772 + let entry_ref = entries.get(index).ok_or_else(|| { 773 + AgentError::from(ClientError::invalid_request("entry out of bounds")) 774 + })?; 775 + let entry = self.fetch_entry_view(notebook, entry_ref).await?; 776 777 + let prev_entry = if index > 0 { 778 + let prev_entry_ref = &entries[index - 1]; 779 + self.fetch_entry_view(notebook, prev_entry_ref).await.ok() 780 + } else { 781 + None 782 + } 783 + .map(|e| BookEntryRef::new().entry(e).build()); 784 785 + let next_entry = if index < entries.len() - 1 { 786 + let next_entry_ref = &entries[index + 1]; 787 + self.fetch_entry_view(notebook, next_entry_ref).await.ok() 788 + } else { 789 + None 790 + } 791 + .map(|e| BookEntryRef::new().entry(e).build()); 792 793 + Ok(BookEntryView::new() 794 + .entry(entry) 795 + .maybe_next(next_entry) 796 + .maybe_prev(prev_entry) 797 + .index(index as i64) 798 + .build()) 799 + } 800 } 801 802 + /// View a page at a specific index with prev/next navigation 803 + fn view_page<'a>( 804 &self, 805 notebook: &NotebookView<'a>, 806 pages: &[StrongRef<'_>], 807 index: usize, 808 + ) -> impl Future<Output = Result<BookEntryView<'a>, WeaverError>> { 809 + async move { 810 + use weaver_api::sh_weaver::notebook::BookEntryRef; 811 812 + let entry_ref = pages.get(index).ok_or_else(|| { 813 + AgentError::from(ClientError::invalid_request("entry out of bounds")) 814 + })?; 815 + let entry = self.fetch_page_view(notebook, entry_ref).await?; 816 817 + let prev_entry = if index > 0 { 818 + let prev_entry_ref = &pages[index - 1]; 819 + self.fetch_page_view(notebook, prev_entry_ref).await.ok() 820 + } else { 821 + None 822 + } 823 + .map(|e| BookEntryRef::new().entry(e).build()); 824 + 825 + let next_entry = if index < pages.len() - 1 { 826 + let next_entry_ref = &pages[index + 1]; 827 + self.fetch_page_view(notebook, next_entry_ref).await.ok() 828 + } else { 829 + None 830 + } 831 + .map(|e| BookEntryRef::new().entry(e).build()); 832 833 + Ok(BookEntryView::new() 834 + .entry(entry) 835 + .maybe_next(next_entry) 836 + .maybe_prev(prev_entry) 837 + .index(index as i64) 838 + .build()) 839 } 840 } 841 842 + /// Fetch a page view (like fetch_entry_view but for pages) 843 + fn fetch_page_view<'a>( 844 &self, 845 notebook: &NotebookView<'a>, 846 entry_ref: &StrongRef<'_>, 847 + ) -> impl Future<Output = Result<EntryView<'a>, WeaverError>> { 848 + async move { 849 + use jacquard::to_data; 850 + use weaver_api::sh_weaver::notebook::page::Page; 851 852 + let entry_uri = Page::uri(entry_ref.uri.clone()) 853 + .map_err(|_| AgentError::from(ClientError::invalid_request("Invalid page URI")))?; 854 + let entry = self.fetch_record(&entry_uri).await?; 855 856 + let title = entry.value.title.clone(); 857 + let tags = entry.value.tags.clone(); 858 859 + Ok(EntryView::new() 860 + .cid(entry.cid.ok_or_else(|| { 861 + AgentError::from(ClientError::invalid_request("Page missing CID")) 862 + })?) 863 + .uri(entry.uri) 864 + .indexed_at(jacquard::types::string::Datetime::now()) 865 + .record(to_data(&entry.value).map_err(|_| { 866 + AgentError::from(ClientError::invalid_request("Failed to serialize page")) 867 + })?) 868 + .maybe_tags(tags) 869 + .title(title) 870 + .authors(notebook.authors.clone()) 871 + .build()) 872 + } 873 } 874 } 875 + 876 + impl<T: AgentSession + IdentityResolver + XrpcExt> WeaverExt for T {}
+1
crates/weaver-common/src/constellation.rs
··· 9 use serde::{Deserialize, Serialize}; 10 11 const DEFAULT_CURSOR_LIMIT: u64 = 16; 12 const DEFAULT_CURSOR_LIMIT_MAX: u64 = 100; 13 14 fn get_default_cursor_limit() -> u64 {
··· 9 use serde::{Deserialize, Serialize}; 10 11 const DEFAULT_CURSOR_LIMIT: u64 = 16; 12 + #[allow(unused)] 13 const DEFAULT_CURSOR_LIMIT_MAX: u64 = 100; 14 15 fn get_default_cursor_limit() -> u64 {
-1
crates/weaver-common/src/error.rs
··· 56 #[derive(thiserror::Error, Debug, Diagnostic)] 57 #[error("parse error: {}",self.kind)] 58 #[diagnostic(code(weaver::parse))] 59 - 60 pub struct ParseError { 61 #[diagnostic_source] 62 kind: ParseErrorKind,
··· 56 #[derive(thiserror::Error, Debug, Diagnostic)] 57 #[error("parse error: {}",self.kind)] 58 #[diagnostic(code(weaver::parse))] 59 pub struct ParseError { 60 #[diagnostic_source] 61 kind: ParseErrorKind,
+3 -151
crates/weaver-common/src/lib.rs
··· 6 pub mod worker_rt; 7 8 // Re-export jacquard for convenience 9 pub use error::WeaverError; 10 pub use jacquard; 11 use jacquard::CowStr; 12 - use jacquard::bytes::Bytes; 13 - use jacquard::client::{Agent, AgentSession, AgentSessionExt}; 14 use jacquard::prelude::*; 15 use jacquard::types::ident::AtIdentifier; 16 use jacquard::types::string::{AtUri, Cid, Did, Handle}; 17 - use jacquard::types::tid::{Ticker, Tid}; 18 - use std::path::Path; 19 use std::sync::LazyLock; 20 use tokio::sync::Mutex; 21 use weaver_api::com_atproto::repo::strong_ref::StrongRef; 22 - use weaver_api::sh_weaver::notebook::entry; 23 - use weaver_api::sh_weaver::publish::blob::Blob as PublishedBlob; 24 25 static W_TICKER: LazyLock<Mutex<Ticker>> = LazyLock::new(|| Mutex::new(Ticker::new())); 26 - 27 - /// Extension trait providing weaver-specific multi-step operations on Agent 28 - /// 29 - /// This trait extends jacquard's Agent with notebook-specific workflows that 30 - /// involve multiple atproto operations (uploading blobs, creating records, etc.) 31 - /// 32 - /// For single-step operations, use jacquard's built-in methods directly: 33 - /// - `agent.create_record()` - Create a single record 34 - /// - `agent.get_record()` - Get a single record 35 - /// - `agent.upload_blob()` - Upload a single blob 36 - /// 37 - /// This trait is for multi-step workflows that coordinate between multiple operations. 38 - //#[trait_variant::make(Send)] 39 - pub trait WeaverExt: AgentSessionExt { 40 - /// Publish a notebook directory to the user's PDS 41 - /// 42 - /// Multi-step workflow: 43 - /// 1. Parse markdown files in directory 44 - /// 2. Extract and upload images/assets → BlobRefs 45 - /// 3. Transform markdown refs to point at uploaded blobs 46 - /// 4. Create entry records for each file 47 - /// 5. Create book record with entry refs 48 - /// 49 - /// Returns the AT-URI of the published book 50 - fn publish_notebook( 51 - &self, 52 - path: &Path, 53 - ) -> impl Future<Output = Result<PublishResult<'_>, WeaverError>>; 54 - 55 - /// Publish a blob to the user's PDS 56 - /// 57 - /// Multi-step workflow: 58 - /// 1. Upload blob to PDS 59 - /// 2. Create blob record with CID 60 - /// 61 - /// Returns the AT-URI of the published blob 62 - fn publish_blob<'a>( 63 - &self, 64 - blob: Bytes, 65 - url_path: &'a str, 66 - prev: Option<Tid>, 67 - ) -> impl Future<Output = Result<(StrongRef<'a>, PublishedBlob<'a>), WeaverError>>; 68 - 69 - fn confirm_record_ref( 70 - &self, 71 - uri: &AtUri<'_>, 72 - ) -> impl Future<Output = Result<StrongRef<'_>, WeaverError>>; 73 - 74 - /// Find or create a notebook by title, returning its URI and entry list 75 - /// 76 - /// If the notebook doesn't exist, creates it with the given DID as author. 77 - fn upsert_notebook( 78 - &self, 79 - title: &str, 80 - author_did: &Did<'_>, 81 - ) -> impl Future<Output = Result<(AtUri<'static>, Vec<StrongRef<'static>>), WeaverError>>; 82 - 83 - /// Find or create an entry within a notebook by title 84 - /// 85 - /// Multi-step workflow: 86 - /// 1. Find the notebook by title 87 - /// 2. Check notebook's entry_list for entry with matching title 88 - /// 3. If found: update the entry with new content 89 - /// 4. If not found: create new entry and append to notebook's entry_list 90 - /// 91 - /// Returns (entry_uri, was_created) 92 - fn upsert_entry( 93 - &self, 94 - notebook_title: &str, 95 - entry_title: &str, 96 - entry: entry::Entry<'_>, 97 - ) -> impl Future<Output = Result<(AtUri<'static>, bool), WeaverError>>; 98 - 99 - /// View functions - generic versions that work with any Agent 100 - 101 - /// Fetch a notebook and construct NotebookView with author profiles 102 - fn view_notebook( 103 - &self, 104 - uri: &AtUri<'_>, 105 - ) -> impl Future<Output = Result<(agent::NotebookView<'static>, Vec<StrongRef<'static>>), WeaverError>>; 106 - 107 - /// Fetch an entry and construct EntryView 108 - fn fetch_entry_view<'a>( 109 - &self, 110 - notebook: &agent::NotebookView<'a>, 111 - entry_ref: &StrongRef<'_>, 112 - ) -> impl Future<Output = Result<agent::EntryView<'a>, WeaverError>>; 113 - 114 - /// Search for an entry by title within a notebook's entry list 115 - fn entry_by_title<'a>( 116 - &self, 117 - notebook: &agent::NotebookView<'a>, 118 - entries: &[StrongRef<'_>], 119 - title: &str, 120 - ) -> impl Future<Output = Result<Option<(agent::BookEntryView<'a>, entry::Entry<'a>)>, WeaverError>>; 121 - 122 - /// Search for a notebook by title for a given DID or handle 123 - fn notebook_by_title( 124 - &self, 125 - ident: &jacquard::types::ident::AtIdentifier<'_>, 126 - title: &str, 127 - ) -> impl Future< 128 - Output = Result< 129 - Option<(agent::NotebookView<'static>, Vec<StrongRef<'static>>)>, 130 - WeaverError, 131 - >, 132 - >; 133 - 134 - /// Hydrate a profile view from either weaver or bsky profile 135 - fn hydrate_profile_view( 136 - &self, 137 - did: &Did<'_>, 138 - ) -> impl Future< 139 - Output = Result< 140 - ( 141 - Option<AtUri<'static>>, 142 - weaver_api::sh_weaver::actor::ProfileDataView<'static>, 143 - ), 144 - WeaverError, 145 - >, 146 - >; 147 - 148 - /// View an entry at a specific index with prev/next navigation 149 - fn view_entry<'a>( 150 - &self, 151 - notebook: &agent::NotebookView<'a>, 152 - entries: &[StrongRef<'_>], 153 - index: usize, 154 - ) -> impl Future<Output = Result<agent::BookEntryView<'a>, WeaverError>>; 155 - 156 - /// View a page at a specific index with prev/next navigation 157 - fn view_page<'a>( 158 - &self, 159 - notebook: &agent::NotebookView<'a>, 160 - pages: &[StrongRef<'_>], 161 - index: usize, 162 - ) -> impl Future<Output = Result<agent::BookEntryView<'a>, WeaverError>>; 163 - 164 - /// Fetch a page view (like fetch_entry_view but for pages) 165 - fn fetch_page_view<'a>( 166 - &self, 167 - notebook: &agent::NotebookView<'a>, 168 - entry_ref: &StrongRef<'_>, 169 - ) -> impl Future<Output = Result<agent::EntryView<'a>, WeaverError>>; 170 - } 171 172 /// Result of publishing a notebook 173 #[derive(Debug, Clone)]
··· 6 pub mod worker_rt; 7 8 // Re-export jacquard for convenience 9 + pub use agent::WeaverExt; 10 pub use error::WeaverError; 11 pub use jacquard; 12 use jacquard::CowStr; 13 + use jacquard::client::{Agent, AgentSession}; 14 use jacquard::prelude::*; 15 use jacquard::types::ident::AtIdentifier; 16 use jacquard::types::string::{AtUri, Cid, Did, Handle}; 17 + use jacquard::types::tid::Ticker; 18 use std::sync::LazyLock; 19 use tokio::sync::Mutex; 20 use weaver_api::com_atproto::repo::strong_ref::StrongRef; 21 22 static W_TICKER: LazyLock<Mutex<Ticker>> = LazyLock::new(|| Mutex::new(Ticker::new())); 23 24 /// Result of publishing a notebook 25 #[derive(Debug, Clone)]