From e8840cbab176c5733b84124ddf4af81a5a6d4a99 Mon Sep 17 00:00:00 2001 From: Orual Date: Tue, 11 Nov 2025 15:17:23 -0500 Subject: [PATCH] auth impl record view --- Cargo.lock | 26 + Cargo.toml | 2 +- crates/weaver-app/Cargo.toml | 15 +- crates/weaver-app/assets/styling/main.css | 1 - crates/weaver-app/assets/styling/navbar.css | 13 + .../weaver-app/assets/styling/record-view.css | 1 + crates/weaver-app/build.rs | 24 + crates/weaver-app/src/auth/mod.rs | 82 + crates/weaver-app/src/auth/state.rs | 32 + crates/weaver-app/src/auth/storage.rs | 209 +++ crates/weaver-app/src/blobcache.rs | 7 +- crates/weaver-app/src/cache_impl.rs | 9 +- .../src/components/button/component.rs | 62 + .../weaver-app/src/components/button/mod.rs | 2 + .../src/components/button/style.css | 62 + crates/weaver-app/src/components/css.rs | 3 +- .../src/components/dialog/component.rs | 52 + .../weaver-app/src/components/dialog/mod.rs | 2 + .../src/components/dialog/style.css | 105 ++ crates/weaver-app/src/components/entry.rs | 27 +- crates/weaver-app/src/components/identity.rs | 16 +- .../src/components/input/component.rs | 54 + crates/weaver-app/src/components/input/mod.rs | 2 + .../weaver-app/src/components/input/style.css | 36 + crates/weaver-app/src/components/login.rs | 145 ++ crates/weaver-app/src/components/mod.rs | 7 + .../src/components/notebook_cover.rs | 1 - crates/weaver-app/src/components/profile.rs | 1 - crates/weaver-app/src/config.rs | 208 +++ crates/weaver-app/src/data.rs | 44 +- crates/weaver-app/src/env.rs | 20 + crates/weaver-app/src/fetch.rs | 541 ++++++- crates/weaver-app/src/main.rs | 69 +- crates/weaver-app/src/views/callback.rs | 89 ++ crates/weaver-app/src/views/mod.rs | 3 + crates/weaver-app/src/views/navbar.rs | 37 +- crates/weaver-app/src/views/notebook.rs | 33 +- crates/weaver-app/src/views/record.rs | 10 +- crates/weaver-cli/src/main.rs | 3 +- crates/weaver-common/src/agent.rs | 1342 +++++++++-------- crates/weaver-common/src/constellation.rs | 1 + crates/weaver-common/src/error.rs | 1 - crates/weaver-common/src/lib.rs | 154 +- 43 files changed, 2661 insertions(+), 892 deletions(-) create mode 100644 crates/weaver-app/build.rs create mode 100644 crates/weaver-app/src/auth/mod.rs create mode 100644 crates/weaver-app/src/auth/state.rs create mode 100644 crates/weaver-app/src/auth/storage.rs create mode 100644 crates/weaver-app/src/components/button/component.rs create mode 100644 crates/weaver-app/src/components/button/mod.rs create mode 100644 crates/weaver-app/src/components/button/style.css create mode 100644 crates/weaver-app/src/components/dialog/component.rs create mode 100644 crates/weaver-app/src/components/dialog/mod.rs create mode 100644 crates/weaver-app/src/components/dialog/style.css create mode 100644 crates/weaver-app/src/components/input/component.rs create mode 100644 crates/weaver-app/src/components/input/mod.rs create mode 100644 crates/weaver-app/src/components/input/style.css create mode 100644 crates/weaver-app/src/components/login.rs create mode 100644 crates/weaver-app/src/config.rs create mode 100644 crates/weaver-app/src/env.rs create mode 100644 crates/weaver-app/src/views/callback.rs diff --git a/Cargo.lock b/Cargo.lock index dee9b57..4db52eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3267,6 +3267,21 @@ dependencies = [ "web-sys", ] +[[package]] +name = "gloo-storage" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbc8031e8c92758af912f9bc08fbbadd3c6f3cfcbf6b64cdf3d6a81f0139277a" +dependencies = [ + "gloo-utils", + "js-sys", + "serde", + "serde_json", + "thiserror 1.0.69", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "gloo-timers" version = "0.3.0" @@ -4084,6 +4099,7 @@ dependencies = [ "smol_str", "thiserror 2.0.17", "tokio", + "tracing", "trait-variant", "url", "webpage", @@ -4163,6 +4179,7 @@ dependencies = [ "tokio", "tokio-tungstenite-wasm", "tokio-util", + "tracing", "trait-variant", "url", "zstd", @@ -4200,6 +4217,7 @@ dependencies = [ "serde_json", "thiserror 2.0.17", "tokio", + "tracing", "trait-variant", "url", "urlencoding", @@ -4257,6 +4275,7 @@ dependencies = [ "smol_str", "thiserror 2.0.17", "tokio", + "tracing", "trait-variant", "url", "webbrowser", @@ -8642,8 +8661,12 @@ dependencies = [ "diesel_migrations", "dioxus", "dioxus-free-icons", + "dioxus-logger", "dioxus-primitives", + "dotenvy", + "gloo-storage", "hex_fmt", + "http", "humansize", "jacquard", "jacquard-axum", @@ -8654,15 +8677,18 @@ dependencies = [ "n0-future", "reqwest", "serde", + "serde_html_form", "serde_json", "sqlite-wasm-rs", "time", + "tokio", "wasm-bindgen", "wasm-bindgen-futures", "weaver-api", "weaver-common", "weaver-renderer", "web-sys", + "webbrowser", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 703d953..572d753 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,7 +47,7 @@ markdown-weaver-escape = { git = "https://github.com/rsform/markdown-weaver" } # jacquard-derive = { git = "https://tangled.org/@nonbinary.computer/jacquard" } # jacquard-lexicon = { git = "https://tangled.org/@nonbinary.computer/jacquard", default-features = false } -jacquard = { path = "../jacquard/crates/jacquard", default-features = false, features = ["derive", "api_bluesky"] } +jacquard = { path = "../jacquard/crates/jacquard", default-features = false, features = ["derive", "api_bluesky", "tracing"] } jacquard-api = { path = "../jacquard/crates/jacquard-api" } jacquard-common = { path = "../jacquard/crates/jacquard-common" } jacquard-axum = {path = "../jacquard/crates/jacquard-axum" } diff --git a/crates/weaver-app/Cargo.toml b/crates/weaver-app/Cargo.toml index 7a97abc..67c11f1 100644 --- a/crates/weaver-app/Cargo.toml +++ b/crates/weaver-app/Cargo.toml @@ -2,7 +2,7 @@ name = "weaver-app" version = "0.1.0" authors = ["Orual "] -edition = "2021" +edition = "2024" [features] default = ["web", "fullstack-server", "no-app-index"] @@ -16,8 +16,6 @@ mobile = ["dioxus/mobile"] server = [ "dioxus/server", "dep:jacquard-axum", "dep:axum"] - - [dependencies] dashmap = "6.1.0" @@ -39,10 +37,17 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" hex_fmt = "0.3" humansize = "2.0.0" +http = "1.3" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } dioxus-free-icons = { version = "0.10.0" } diesel = { version = "2.3", features = ["sqlite", "returning_clauses_for_sqlite_3_35", "chrono", "serde_json"] } diesel_migrations = { version = "2.3", features = ["sqlite"] } +tokio = { version = "1.28", features = ["sync"] } +dioxus-logger = "0.7.1" +serde_html_form = "0.2.8" +webbrowser = "1.0.6" + + [target.'cfg(all(target_family = "wasm", target_os = "unknown"))'.dependencies] @@ -55,3 +60,7 @@ wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" web-sys = { version = "0.3", features = ["ServiceWorkerContainer", "ServiceWorker", "ServiceWorkerRegistration", "RegistrationOptions", "Window", "Navigator", "MessageEvent", "console"] } js-sys = "0.3" +gloo-storage = "0.3" + +[build-dependencies] +dotenvy = "0.15.7" diff --git a/crates/weaver-app/assets/styling/main.css b/crates/weaver-app/assets/styling/main.css index 2b52392..9e58e2a 100644 --- a/crates/weaver-app/assets/styling/main.css +++ b/crates/weaver-app/assets/styling/main.css @@ -2,7 +2,6 @@ body { background-color: var(--color-base); color: var(--color-text); font-family: var(--font-body); - margin: 20px; } #header { diff --git a/crates/weaver-app/assets/styling/navbar.css b/crates/weaver-app/assets/styling/navbar.css index 6a338c0..85320db 100644 --- a/crates/weaver-app/assets/styling/navbar.css +++ b/crates/weaver-app/assets/styling/navbar.css @@ -1,6 +1,10 @@ #navbar { display: flex; flex-direction: row; + justify-content: space-between; + padding-left: 4rem; + padding-top: 1rem; + padding-right: 4rem; } .breadcrumbs { @@ -29,3 +33,12 @@ color: var(--color-text-muted, #999); user-select: none; } + +.auth-button { + align-self: flex-end; +} + +.auth-handle { + color: var(--color-text, #666); + font-weight: 500; +} diff --git a/crates/weaver-app/assets/styling/record-view.css b/crates/weaver-app/assets/styling/record-view.css index 377f4ea..86637f0 100644 --- a/crates/weaver-app/assets/styling/record-view.css +++ b/crates/weaver-app/assets/styling/record-view.css @@ -198,6 +198,7 @@ font-weight: 600; padding-left: 1rem; padding-top: 0.5rem; + margin-left: -0.01rem; border-left: 2px solid var(--color-secondary); } diff --git a/crates/weaver-app/build.rs b/crates/weaver-app/build.rs new file mode 100644 index 0000000..ff6b3f4 --- /dev/null +++ b/crates/weaver-app/build.rs @@ -0,0 +1,24 @@ +use dotenvy::dotenv; +use std::env; +use std::fs::File; +use std::io::Write; + +fn main() { + println!("cargo:rerun-if-changed=.env"); + let dest_path = "./src/env.rs"; + let mut f = File::create(&dest_path).unwrap(); + + dotenv().ok(); + f.write_all(b"// This file is automatically generated by build.rs\n\n") + .unwrap(); + for (key, value) in env::vars() { + if key.starts_with("WEAVER_") { + let line = format!( + "#[allow(unused)]\npub const {}: &'static str = \"{}\";\n", + key, + value.replace("\"", "\\\"") + ); + f.write_all(line.as_bytes()).unwrap(); + } + } +} diff --git a/crates/weaver-app/src/auth/mod.rs b/crates/weaver-app/src/auth/mod.rs new file mode 100644 index 0000000..59b78f9 --- /dev/null +++ b/crates/weaver-app/src/auth/mod.rs @@ -0,0 +1,82 @@ +mod storage; +use dioxus::CapturedError; +pub use storage::AuthStore; + +mod state; +pub use state::AuthState; + +#[cfg(all(feature = "fullstack-server", feature = "server"))] +use dioxus::prelude::*; +#[cfg(all(feature = "fullstack-server", feature = "server"))] +use jacquard::oauth::types::OAuthClientMetadata; + +#[cfg(all(feature = "fullstack-server", feature = "server"))] +#[get("/client-metadata.json")] +pub async fn client_metadata() -> Result> { + use jacquard::oauth::atproto::atproto_client_metadata; + + use crate::CONFIG; + + let atproto_metadata = atproto_client_metadata(CONFIG.oauth.clone(), &None)?; + + Ok(axum::response::Json(serde_json::to_value( + atproto_metadata, + )?)) +} + +#[cfg(not(target_arch = "wasm32"))] +pub async fn restore_session() -> Result<(), String> { + Ok(()) +} + +#[cfg(target_arch = "wasm32")] +pub async fn restore_session() -> Result<(), CapturedError> { + use crate::fetch::CachedFetcher; + use dioxus::prelude::*; + use gloo_storage::{LocalStorage, Storage}; + // Look for session keys in localStorage (format: oauth_session_{did}_{session_id}) + let keys = LocalStorage::get_all::()?; + let mut found_session: Option<(String, String)> = None; + + let keys = keys + .as_object() + .ok_or(CapturedError::from_display(format!("{}", keys)))?; + for key in keys.keys() { + if key.starts_with("oauth_session_") { + let parts: Vec<&str> = key + .strip_prefix("oauth_session_") + .unwrap() + .split('_') + .collect(); + if parts.len() >= 2 { + found_session = Some((parts[0].to_string(), parts[1..].join("_"))); + break; + } + } + } + + let (did_str, session_id) = + found_session.ok_or(CapturedError::from_display("No saved session found"))?; + let did = jacquard::types::string::Did::new_owned(did_str)?; + let fetcher = use_context::(); + + let session = fetcher + .client + .oauth_client + .restore(&did, &session_id) + .await?; + + // Get DID and handle from session + let (restored_did, session_id) = session.session_info().await; + + // Update auth state + let mut auth_state = try_consume_context::>() + .ok_or(CapturedError::from_display("AuthState not in context"))?; + auth_state + .write() + .set_authenticated(restored_did, session_id); + fetcher.upgrade_to_authenticated(session).await; + + dioxus_logger::tracing::debug!("session restored"); + Ok(()) +} diff --git a/crates/weaver-app/src/auth/state.rs b/crates/weaver-app/src/auth/state.rs new file mode 100644 index 0000000..51b606a --- /dev/null +++ b/crates/weaver-app/src/auth/state.rs @@ -0,0 +1,32 @@ +use jacquard::{CowStr, IntoStatic, types::string::Did}; + +#[derive(Clone, Debug, PartialEq)] +pub struct AuthState { + pub did: Option>, + pub session_id: Option>, +} + +impl Default for AuthState { + fn default() -> Self { + Self { + did: None, + session_id: None, + } + } +} + +impl AuthState { + pub fn is_authenticated(&self) -> bool { + self.did.is_some() + } + + pub fn set_authenticated(&mut self, did: Did<'_>, session_id: CowStr<'_>) { + self.did = Some(did.into_static()); + self.session_id = Some(session_id.into_static()); + } + + pub fn clear(&mut self) { + self.did = None; + self.session_id = None; + } +} diff --git a/crates/weaver-app/src/auth/storage.rs b/crates/weaver-app/src/auth/storage.rs new file mode 100644 index 0000000..953788c --- /dev/null +++ b/crates/weaver-app/src/auth/storage.rs @@ -0,0 +1,209 @@ +#[cfg(target_arch = "wasm32")] +use gloo_storage::{LocalStorage, SessionStorage, Storage}; +use jacquard::client::SessionStoreError; +use jacquard::oauth::authstore::ClientAuthStore; +#[cfg(not(target_arch = "wasm32"))] +use jacquard::oauth::authstore::MemoryAuthStore; +use jacquard::oauth::session::{AuthRequestData, ClientSessionData}; +use jacquard::types::string::Did; +use std::future::Future; +#[cfg(not(target_arch = "wasm32"))] +use std::sync::LazyLock; + +#[cfg(target_arch = "wasm32")] +#[derive(Clone)] +pub struct AuthStore; + +#[cfg(target_arch = "wasm32")] +impl AuthStore { + pub fn new() -> Self { + Self + } + + fn session_key(did: &Did<'_>, session_id: &str) -> String { + format!("oauth_session_{}_{}", did.as_ref(), session_id) + } + + fn auth_req_key(state: &str) -> String { + format!("oauth_auth_req_{}", state) + } +} + +#[cfg(target_arch = "wasm32")] +impl ClientAuthStore for AuthStore { + fn get_session( + &self, + did: &Did<'_>, + session_id: &str, + ) -> impl Future>, SessionStoreError>> { + let key = Self::session_key(did, session_id); + async move { + match LocalStorage::get::(&key) { + Ok(value) => { + let data: ClientSessionData<'static> = + jacquard::from_json_value::(value).map_err(|e| { + SessionStoreError::Other(format!("Deserialize error: {}", e).into()) + })?; + Ok(Some(data)) + } + Err(gloo_storage::errors::StorageError::KeyNotFound(_)) => Ok(None), + Err(e) => Err(SessionStoreError::Other( + format!("LocalStorage error: {}", e).into(), + )), + } + } + } + + fn upsert_session( + &self, + session: ClientSessionData<'_>, + ) -> impl Future> { + async move { + use jacquard::IntoStatic; + + let key = Self::session_key(&session.account_did, &session.session_id); + let static_session = session.into_static(); + + let value = serde_json::to_value(&static_session) + .map_err(|e| SessionStoreError::Other(format!("Serialize error: {}", e).into()))?; + + LocalStorage::set(&key, &value).map_err(|e| { + SessionStoreError::Other(format!("LocalStorage error: {}", e).into()) + })?; + + Ok(()) + } + } + + fn delete_session( + &self, + did: &Did<'_>, + session_id: &str, + ) -> impl Future> { + let key = Self::session_key(did, session_id); + async move { + LocalStorage::delete(&key); + Ok(()) + } + } + + fn get_auth_req_info( + &self, + state: &str, + ) -> impl Future>, SessionStoreError>> { + let key = Self::auth_req_key(state); + async move { + match LocalStorage::get::(&key) { + Ok(value) => { + let data: AuthRequestData<'static> = + jacquard::from_json_value::(value).map_err(|e| { + SessionStoreError::Other(format!("Deserialize error: {}", e).into()) + })?; + Ok(Some(data)) + } + Err(gloo_storage::errors::StorageError::KeyNotFound(err)) => { + dioxus_logger::tracing::debug!("gloo error: {}", err); + Ok(None) + } + Err(e) => Err(SessionStoreError::Other( + format!("SessionStorage error: {}", e).into(), + )), + } + } + } + + fn save_auth_req_info( + &self, + auth_req_info: &AuthRequestData<'_>, + ) -> impl Future> { + async move { + use jacquard::IntoStatic; + + let key = Self::auth_req_key(&auth_req_info.state); + let static_info = auth_req_info.clone().into_static(); + + let value = serde_json::to_value(&static_info) + .map_err(|e| SessionStoreError::Other(format!("Serialize error: {}", e).into()))?; + + LocalStorage::set(&key, &value).map_err(|e| { + SessionStoreError::Other(format!("SessionStorage error: {}", e).into()) + })?; + + Ok(()) + } + } + + fn delete_auth_req_info( + &self, + state: &str, + ) -> impl Future> { + let key = Self::auth_req_key(state); + async move { + LocalStorage::delete(&key); + Ok(()) + } + } +} +#[cfg(not(target_arch = "wasm32"))] +use std::sync::Arc; + +#[cfg(not(target_arch = "wasm32"))] +pub struct AuthStore(Arc); + +#[cfg(not(target_arch = "wasm32"))] +static MEM_STORE: LazyLock> = + LazyLock::new(|| Arc::new(MemoryAuthStore::new())); + +#[cfg(not(target_arch = "wasm32"))] +impl AuthStore { + pub fn new() -> Self { + Self(MEM_STORE.clone()) + } +} + +#[cfg(not(target_arch = "wasm32"))] +impl ClientAuthStore for AuthStore { + fn get_session( + &self, + did: &Did<'_>, + session_id: &str, + ) -> impl Future>, SessionStoreError>> { + self.0.get_session(did, session_id) + } + + fn upsert_session( + &self, + session: ClientSessionData<'_>, + ) -> impl Future> { + self.0.upsert_session(session) + } + + fn delete_session( + &self, + did: &Did<'_>, + session_id: &str, + ) -> impl Future> { + self.0.delete_session(did, session_id) + } + + fn get_auth_req_info( + &self, + state: &str, + ) -> impl Future>, SessionStoreError>> { + self.0.get_auth_req_info(state) + } + + fn save_auth_req_info( + &self, + auth_req_info: &AuthRequestData<'_>, + ) -> impl Future> { + self.0.save_auth_req_info(auth_req_info) + } + + fn delete_auth_req_info( + &self, + state: &str, + ) -> impl Future> { + self.0.delete_auth_req_info(state) + } +} diff --git a/crates/weaver-app/src/blobcache.rs b/crates/weaver-app/src/blobcache.rs index 42d2d0e..1ad73a8 100644 --- a/crates/weaver-app/src/blobcache.rs +++ b/crates/weaver-app/src/blobcache.rs @@ -2,7 +2,8 @@ use crate::cache_impl; use dioxus::{CapturedError, Result}; use jacquard::{ bytes::Bytes, - client::BasicClient, + client::UnauthenticatedSession, + identity::JacquardResolver, prelude::*, smol_str::SmolStr, types::{cid::Cid, ident::AtIdentifier}, @@ -15,13 +16,13 @@ use weaver_api::com_atproto::sync::get_blob::GetBlob; #[derive(Clone)] pub struct BlobCache { - client: Arc, + client: Arc>, cache: cache_impl::Cache, Bytes>, map: cache_impl::Cache>, } impl BlobCache { - pub fn new(client: Arc) -> Self { + pub fn new(client: Arc>) -> Self { let cache = cache_impl::new_cache(100, Duration::from_secs(1200)); let map = cache_impl::new_cache(500, Duration::from_secs(1200)); diff --git a/crates/weaver-app/src/cache_impl.rs b/crates/weaver-app/src/cache_impl.rs index 674e79c..1a4720a 100644 --- a/crates/weaver-app/src/cache_impl.rs +++ b/crates/weaver-app/src/cache_impl.rs @@ -35,6 +35,7 @@ mod native { cache.insert(key, value); } + #[allow(dead_code)] pub fn iter(cache: &Cache) -> Vec where K: std::hash::Hash + Eq + Send + Sync + 'static, @@ -80,12 +81,18 @@ mod wasm { cache.lock().unwrap().insert(key, value); } + #[allow(dead_code)] pub fn iter(cache: &Cache) -> Vec where K: std::hash::Hash + Eq + 'static, V: Clone + 'static, { - cache.lock().unwrap().iter().map(|(_, v)| v.clone()).collect() + cache + .lock() + .unwrap() + .iter() + .map(|(_, v)| v.clone()) + .collect() } } diff --git a/crates/weaver-app/src/components/button/component.rs b/crates/weaver-app/src/components/button/component.rs new file mode 100644 index 0000000..6e6a967 --- /dev/null +++ b/crates/weaver-app/src/components/button/component.rs @@ -0,0 +1,62 @@ +use dioxus::prelude::*; + +#[derive(Copy, Clone, PartialEq, Default)] +#[non_exhaustive] +pub enum ButtonVariant { + #[default] + Primary, + Secondary, + Destructive, + Outline, + Ghost, +} + +impl ButtonVariant { + pub fn class(&self) -> &'static str { + match self { + ButtonVariant::Primary => "primary", + ButtonVariant::Secondary => "secondary", + ButtonVariant::Destructive => "destructive", + ButtonVariant::Outline => "outline", + ButtonVariant::Ghost => "ghost", + } + } +} + +#[component] +pub fn Button( + #[props(default)] variant: ButtonVariant, + #[props(extends=GlobalAttributes)] + #[props(extends=button)] + attributes: Vec, + onclick: Option>, + onmousedown: Option>, + onmouseup: Option>, + children: Element, +) -> Element { + rsx! { + document::Link { rel: "stylesheet", href: asset!("./style.css") } + + button { + class: "button", + "data-style": variant.class(), + onclick: move |event| { + if let Some(f) = &onclick { + f.call(event); + } + }, + onmousedown: move |event| { + if let Some(f) = &onmousedown { + f.call(event); + } + }, + onmouseup: move |event| { + if let Some(f) = &onmouseup { + f.call(event); + } + }, + ..attributes, + {children} + } + } +} diff --git a/crates/weaver-app/src/components/button/mod.rs b/crates/weaver-app/src/components/button/mod.rs new file mode 100644 index 0000000..9a8ae55 --- /dev/null +++ b/crates/weaver-app/src/components/button/mod.rs @@ -0,0 +1,2 @@ +mod component; +pub use component::*; \ No newline at end of file diff --git a/crates/weaver-app/src/components/button/style.css b/crates/weaver-app/src/components/button/style.css new file mode 100644 index 0000000..69f53ee --- /dev/null +++ b/crates/weaver-app/src/components/button/style.css @@ -0,0 +1,62 @@ +.button { + padding: 8px 18px; + border: none; + border-radius: 0.5rem; + cursor: pointer; + font-size: 0.85rem; + font-weight: 600; + transition: + background-color 0.2s ease, + color 0.2s ease; +} + +.button:focus-visible { + box-shadow: 0 0 0 2px var(--color-border); +} + +.button[data-style="primary"] { + background-color: var(--color-primary); + color: var(--color-base); +} + +.button[data-style="primary"]:hover { + background-color: var(--color-link); +} + +.button[data-style="secondary"] { + background-color: var(--color-muted); + color: var(--color-base); +} + +.button[data-style="secondary"]:hover { + background-color: var(--color-subtle); +} + +.button[data-style="ghost"] { + background-color: transparent; + color: var(--color-text); +} + +.button[data-style="ghost"]:hover { + background-color: var(--color-highlight); + color: var(--color-emphasis); +} + +.button[data-style="outline"] { + border: 1px solid var(--color-border); + background-color: var(--color-surface); + color: var(--color-text); +} + +.button[data-style="outline"]:hover { + background-color: var(--color-emphasis); +} + +.button[data-style="destructive"] { + background-color: var(--color-error); + color: var(--color-base); +} + +.button[data-style="destructive"]:hover { + background-color: var(--color-emphasis); +} diff --git a/crates/weaver-app/src/components/css.rs b/crates/weaver-app/src/components/css.rs index 1d23ff2..87ce073 100644 --- a/crates/weaver-app/src/components/css.rs +++ b/crates/weaver-app/src/components/css.rs @@ -105,8 +105,9 @@ pub async fn css(ident: SmolStr, notebook: SmolStr) -> Result { if let Ok(theme_response) = fetcher.client.get_record::(&theme_ref.uri).await { if let Ok(theme_output) = theme_response.into_output() { let theme: Theme = theme_output.into(); + let client = fetcher.get_client(); // Try to resolve the theme (fetch colour schemes from PDS) - resolve_theme(fetcher.client.as_ref(), &theme) + resolve_theme(client.as_ref(), &theme) .await .unwrap_or_else(|_| default_resolved_theme()) } else { diff --git a/crates/weaver-app/src/components/dialog/component.rs b/crates/weaver-app/src/components/dialog/component.rs new file mode 100644 index 0000000..63fd609 --- /dev/null +++ b/crates/weaver-app/src/components/dialog/component.rs @@ -0,0 +1,52 @@ +use dioxus::prelude::*; +use dioxus_primitives::dialog::{ + self, DialogContentProps, DialogDescriptionProps, DialogRootProps, DialogTitleProps, +}; + +#[component] +pub fn DialogRoot(props: DialogRootProps) -> Element { + rsx! { + document::Link { rel: "stylesheet", href: asset!("./style.css") } + dialog::DialogRoot { + class: "dialog-backdrop", + id: props.id, + is_modal: props.is_modal, + open: props.open, + default_open: props.default_open, + on_open_change: props.on_open_change, + attributes: props.attributes, + {props.children} + } + } +} + +#[component] +pub fn DialogContent(props: DialogContentProps) -> Element { + rsx! { + dialog::DialogContent { class: "dialog", id: props.id, attributes: props.attributes, {props.children} } + } +} + +#[component] +pub fn DialogTitle(props: DialogTitleProps) -> Element { + rsx! { + dialog::DialogTitle { + class: "dialog-title", + id: props.id, + attributes: props.attributes, + {props.children} + } + } +} + +#[component] +pub fn DialogDescription(props: DialogDescriptionProps) -> Element { + rsx! { + dialog::DialogDescription { + class: "dialog-description", + id: props.id, + attributes: props.attributes, + {props.children} + } + } +} diff --git a/crates/weaver-app/src/components/dialog/mod.rs b/crates/weaver-app/src/components/dialog/mod.rs new file mode 100644 index 0000000..9a8ae55 --- /dev/null +++ b/crates/weaver-app/src/components/dialog/mod.rs @@ -0,0 +1,2 @@ +mod component; +pub use component::*; \ No newline at end of file diff --git a/crates/weaver-app/src/components/dialog/style.css b/crates/weaver-app/src/components/dialog/style.css new file mode 100644 index 0000000..dff3d97 --- /dev/null +++ b/crates/weaver-app/src/components/dialog/style.css @@ -0,0 +1,105 @@ +/* Dialog Backdrop */ +.dialog-backdrop { + position: fixed; + z-index: 1000; + background: rgb(0 0 0 / 30%); + inset: 0; + opacity: 0; + will-change: transform, opacity; +} + +.dialog-backdrop[data-state="closed"] { + animation: dialog-backdrop-animate-out 150ms ease-in forwards; + pointer-events: none; +} + +@keyframes dialog-backdrop-animate-out { + 0% { + opacity: 1; + transform: scale(1) translateY(0); + } + + 100% { + opacity: 0; + transform: scale(0.95) translateY(-2px); + } +} + +.dialog-backdrop[data-state="open"] { + animation: dialog-content-animate-in 150ms ease-out forwards; +} + +@keyframes dialog-content-animate-in { + 0% { + opacity: 0; + transform: scale(0.95) translateY(-2px); + } + + 100% { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +/* Dialog Container - improved for theme consistency */ +.dialog { + position: fixed; + z-index: 1001; + top: 50%; + left: 50%; + display: flex; + width: 100%; + max-width: calc(100% - 2rem); + box-sizing: border-box; + flex-direction: column; + padding: 32px 24px 24px; + border: 1px solid var(--color-border); + border-radius: 8px; + margin: 0; + background: var(--color-overlay); + box-shadow: 0 2px 10px rgb(0 0 0 / 18%); + color: var(--color-text); + font-family: var(--font-heading); + gap: 16px; + text-align: center; + transform: translate(-50%, -50%); +} + +.dialog-title { + margin: 0; + color: var(--color-primary); + font-size: 1.25rem; + font-weight: 700; +} + +.dialog-description { + margin: 0; + color: var(--color-text); + font-size: 1rem; +} + +@media (width >= 40rem) { + .dialog { + max-width: 32rem; + text-align: left; + } +} + +.dialog-close { + position: absolute; + top: 1rem; + right: 1rem; + align-self: flex-start; + padding: 0; + border: none; + margin: 0; + background: none; + color: var(--color-highlight); + cursor: pointer; + font-size: 18px; + line-height: 1; +} + +.dialog-close:hover { + color: var(--color-primary); +} diff --git a/crates/weaver-app/src/components/entry.rs b/crates/weaver-app/src/components/entry.rs index 1542eca..3938585 100644 --- a/crates/weaver-app/src/components/entry.rs +++ b/crates/weaver-app/src/components/entry.rs @@ -22,15 +22,16 @@ use jacquard::{ }; #[allow(unused_imports)] use std::sync::Arc; -use weaver_api::sh_weaver::notebook::{entry, BookEntryView}; +use weaver_api::sh_weaver::notebook::{BookEntryView, entry}; #[component] -pub fn Entry(ident: AtIdentifier<'static>, book_title: SmolStr, title: SmolStr) -> Element { - let ident_clone = ident.clone(); - let book_title_clone = book_title.clone(); - +pub fn Entry( + ident: ReadSignal>, + book_title: ReadSignal, + title: ReadSignal, +) -> Element { // Use feature-gated hook for SSR support - let entry = crate::data::use_entry_data(ident.clone(), book_title.clone(), title.clone())?; + let entry = crate::data::use_entry_data(ident(), book_title(), title())?; // Handle blob caching when entry data is available use_effect(move || { @@ -44,14 +45,12 @@ pub fn Entry(ident: AtIdentifier<'static>, book_title: SmolStr, title: SmolStr) not(feature = "fullstack-server") ))] { - let ident = ident.clone(); - let book_title = book_title.clone(); let images = images.clone(); spawn(async move { let fetcher = use_context::(); let _ = crate::service_worker::register_entry_blobs( - &ident, - book_title.as_str(), + &ident(), + book_title().as_str(), &images, &fetcher, ) @@ -60,7 +59,7 @@ pub fn Entry(ident: AtIdentifier<'static>, book_title: SmolStr, title: SmolStr) } #[cfg(feature = "fullstack-server")] { - let ident = ident.clone(); + let ident = ident(); let images = images.clone(); spawn(async move { for image in &images.images { @@ -87,8 +86,8 @@ pub fn Entry(ident: AtIdentifier<'static>, book_title: SmolStr, title: SmolStr) rsx! { EntryPage { book_entry_view: book_entry_view.clone(), entry_record: entry_record.clone(), - ident: use_handle(ident_clone)?(), - book_title: book_title_clone + ident: use_handle(ident())?(), + book_title: book_title() } } } _ => rsx! { p { "Loading..." } }, @@ -167,7 +166,7 @@ pub fn EntryCard( author_count: usize, ) -> Element { use crate::Route; - use jacquard::{from_data, IntoStatic}; + use jacquard::{IntoStatic, from_data}; use weaver_api::sh_weaver::notebook::entry::Entry; let entry_view = &entry.entry; diff --git a/crates/weaver-app/src/components/identity.rs b/crates/weaver-app/src/components/identity.rs index 98837d2..f1fe31e 100644 --- a/crates/weaver-app/src/components/identity.rs +++ b/crates/weaver-app/src/components/identity.rs @@ -1,4 +1,4 @@ -use crate::{fetch, Route}; +use crate::{Route, fetch}; use dioxus::prelude::*; use jacquard::{smol_str::SmolStr, types::ident::AtIdentifier}; use weaver_api::com_atproto::repo::strong_ref::StrongRef; @@ -7,7 +7,7 @@ use weaver_api::sh_weaver::notebook::NotebookView; const NOTEBOOK_CARD_CSS: Asset = asset!("/assets/styling/notebook-card.css"); #[component] -pub fn Repository(ident: AtIdentifier<'static>) -> Element { +pub fn Repository(ident: ReadSignal>) -> Element { rsx! { // We can create elements inside the rsx macro with the element name followed by a block of attributes and children. div { @@ -17,16 +17,16 @@ pub fn Repository(ident: AtIdentifier<'static>) -> Element { } #[component] -pub fn RepositoryIndex(ident: AtIdentifier<'static>) -> Element { +pub fn RepositoryIndex(ident: ReadSignal>) -> Element { use crate::components::ProfileDisplay; let fetcher = use_context::(); // Fetch notebooks for this specific DID - let notebooks = use_resource(use_reactive!(|ident| { + let notebooks = use_resource(move || { let fetcher = fetcher.clone(); - async move { fetcher.fetch_notebooks_for_did(&ident).await } - })); + async move { fetcher.fetch_notebooks_for_did(&ident()).await } + }); rsx! { document::Stylesheet { href: NOTEBOOK_CARD_CSS } @@ -34,7 +34,7 @@ pub fn RepositoryIndex(ident: AtIdentifier<'static>) -> Element { div { class: "repository-layout", // Profile sidebar (desktop) / header (mobile) aside { class: "repository-sidebar", - ProfileDisplay { ident: ident.clone() } + ProfileDisplay { ident: ident() } } // Main content area @@ -178,7 +178,7 @@ pub fn NotebookCard( if entry_list.len() <= 5 { // Show all entries if 5 or fewer rsx! { - for (i, entry_view) in entry_list.iter().enumerate() { + for entry_view in entry_list.iter() { { let entry_title = entry_view.entry.title.as_ref() .map(|t| t.as_ref()) diff --git a/crates/weaver-app/src/components/input/component.rs b/crates/weaver-app/src/components/input/component.rs new file mode 100644 index 0000000..5a1807e --- /dev/null +++ b/crates/weaver-app/src/components/input/component.rs @@ -0,0 +1,54 @@ +use dioxus::prelude::*; + +#[component] +pub fn Input( + oninput: Option>, + onchange: Option>, + oninvalid: Option>, + onselect: Option>, + onselectionchange: Option>, + onfocus: Option>, + onblur: Option>, + onfocusin: Option>, + onfocusout: Option>, + onkeydown: Option>, + onkeypress: Option>, + onkeyup: Option>, + oncompositionstart: Option>, + oncompositionupdate: Option>, + oncompositionend: Option>, + oncopy: Option>, + oncut: Option>, + onpaste: Option>, + #[props(extends=GlobalAttributes)] + #[props(extends=input)] + attributes: Vec, + children: Element, +) -> Element { + rsx! { + document::Link { rel: "stylesheet", href: asset!("./style.css") } + input { + class: "input", + oninput: move |e| _ = oninput.map(|callback| callback(e)), + onchange: move |e| _ = onchange.map(|callback| callback(e)), + oninvalid: move |e| _ = oninvalid.map(|callback| callback(e)), + onselect: move |e| _ = onselect.map(|callback| callback(e)), + onselectionchange: move |e| _ = onselectionchange.map(|callback| callback(e)), + onfocus: move |e| _ = onfocus.map(|callback| callback(e)), + onblur: move |e| _ = onblur.map(|callback| callback(e)), + onfocusin: move |e| _ = onfocusin.map(|callback| callback(e)), + onfocusout: move |e| _ = onfocusout.map(|callback| callback(e)), + onkeydown: move |e| _ = onkeydown.map(|callback| callback(e)), + onkeypress: move |e| _ = onkeypress.map(|callback| callback(e)), + onkeyup: move |e| _ = onkeyup.map(|callback| callback(e)), + oncompositionstart: move |e| _ = oncompositionstart.map(|callback| callback(e)), + oncompositionupdate: move |e| _ = oncompositionupdate.map(|callback| callback(e)), + oncompositionend: move |e| _ = oncompositionend.map(|callback| callback(e)), + oncopy: move |e| _ = oncopy.map(|callback| callback(e)), + oncut: move |e| _ = oncut.map(|callback| callback(e)), + onpaste: move |e| _ = onpaste.map(|callback| callback(e)), + ..attributes, + {children} + } + } +} diff --git a/crates/weaver-app/src/components/input/mod.rs b/crates/weaver-app/src/components/input/mod.rs new file mode 100644 index 0000000..9a8ae55 --- /dev/null +++ b/crates/weaver-app/src/components/input/mod.rs @@ -0,0 +1,2 @@ +mod component; +pub use component::*; \ No newline at end of file diff --git a/crates/weaver-app/src/components/input/style.css b/crates/weaver-app/src/components/input/style.css new file mode 100644 index 0000000..fbcbef4 --- /dev/null +++ b/crates/weaver-app/src/components/input/style.css @@ -0,0 +1,36 @@ +/* Input Styles */ +.input { + position: relative; + display: flex; + box-sizing: border-box; + flex-direction: row; + align-items: center; + justify-content: space-between; + padding: 0.25rem; + padding: 8px 12px; + border: 1px dashed var(--color-border); + background: none; + background: var(--color-overlay); + box-shadow: inset 0 0 0 1px var(--light, var(--color-text)) var(--dark, var(--color-surface)); + color: var(--color-text); + cursor: pointer; + gap: 0.25rem; + transition: background-color 100ms ease-out; +} + +.input::placeholder { + color: var(--color-text); +} + +.input:disabled { + color: var(--color-muted); + cursor: not-allowed; +} + +.input:hover:not(:disabled), +.input:focus-visible { + background: var(--color-overlay); + color: var(--color-text); + outline: none; + border: 1px dashed var(--color-border); +} diff --git a/crates/weaver-app/src/components/login.rs b/crates/weaver-app/src/components/login.rs new file mode 100644 index 0000000..2164035 --- /dev/null +++ b/crates/weaver-app/src/components/login.rs @@ -0,0 +1,145 @@ +use dioxus::logger::tracing::{error, info}; +use dioxus::prelude::*; +use jacquard::oauth::client::OAuthClient; +use jacquard::oauth::session::ClientData; +use jacquard::{oauth::types::AuthorizeOptions, smol_str::SmolStr}; + +use crate::CONFIG; +use crate::{ + components::{ + button::{Button, ButtonVariant}, + dialog::{DialogContent, DialogRoot, DialogTitle}, + input::Input, + }, + fetch::CachedFetcher, +}; + +#[component] +pub fn LoginModal(open: Signal) -> Element { + let mut handle_input = use_signal(|| String::new()); + let mut error = use_signal(|| Option::::None); + let mut is_loading = use_signal(|| false); + + let mut handle_submit = move || { + let handle = handle_input.read().clone(); + if handle.is_empty() { + error.set(Some("Please enter a handle".to_string())); + return; + } + + is_loading.set(true); + error.set(None); + + let fetcher = use_context::(); + + #[cfg(target_arch = "wasm32")] + { + use crate::Route; + use gloo_storage::Storage; + let full_route = use_route::(); + gloo_storage::LocalStorage::set("cached_route", format!("{}", full_route)); + } + + use_effect(move || { + let handle = handle.clone(); + let fetcher = fetcher.clone(); + spawn(async move { + if let Err(e) = start_oauth_flow(handle, fetcher).await { + error!("Authentication failed: {}", e); + error.set(Some(format!("Authentication failed: {}", e))); + is_loading.set(false); + } + open.set(false); + }); + }); + }; + + rsx! { + DialogRoot { open: open(), on_open_change: move |v| open.set(v), + DialogContent { + button { + class: "dialog-close", + r#type: "button", + aria_label: "Close", + tabindex: if open() { "0" } else { "-1" }, + onclick: move |_| { + open.set(false) + }, + "×" + } + DialogTitle { "Sign In with AT Protocol" } + Input { + oninput: move |e: FormEvent| handle_input.set(e.value()), + onkeypress: move |k: KeyboardEvent| { + if k.key() == Key::Enter { + handle_submit(); + } + }, + placeholder: "Enter your handle", + value: "{handle_input}", + } + if let Some(err) = error() { + div { class: "error", "{err}" } + } + Button { + r#type: "button", + onclick: move |_| { + open.set(false) + }, + disabled: is_loading(), + variant: ButtonVariant::Secondary, + "Cancel" + } + Button { + r#type: "submit", + disabled: is_loading(), + onclick: move |_| { + handle_submit(); + }, + if is_loading() { "Authenticating..." } else { "Sign In" } + } + + } + } + } +} + +async fn start_oauth_flow(handle: String, fetcher: CachedFetcher) -> Result<(), SmolStr> { + info!("Starting OAuth flow for handle: {}", handle); + + let client_data = ClientData { + keyset: fetcher + .client + .oauth_client + .registry + .client_data + .keyset + .clone(), + config: CONFIG.oauth.clone(), + }; + + // Build client using store and resolver + let flow_client = OAuthClient::new_with_shared( + fetcher.client.oauth_client.registry.store.clone(), + fetcher.client.oauth_client.client.clone(), + client_data.clone(), + ); + + let auth_url = flow_client + .start_auth(handle, AuthorizeOptions::default()) + .await + .map_err(|e| format!("{:?}", e))?; + #[cfg(target_arch = "wasm32")] + { + let window = web_sys::window().ok_or("no window")?; + let location = window.location(); + location + .set_href(&auth_url) + .map_err(|e| format!("{:?}", e))?; + } + #[cfg(not(target_arch = "wasm32"))] + { + webbrowser::open(&auth_url).map_err(|e| format!("{:?}", e))?; + } + Ok(()) +} diff --git a/crates/weaver-app/src/components/mod.rs b/crates/weaver-app/src/components/mod.rs index 4f4597e..5597b79 100644 --- a/crates/weaver-app/src/components/mod.rs +++ b/crates/weaver-app/src/components/mod.rs @@ -6,9 +6,11 @@ pub mod css; pub use css::NotebookCss; mod entry; +#[allow(unused_imports)] pub use entry::{Entry, EntryCard, EntryMarkdown}; pub mod identity; +#[allow(unused_imports)] pub use identity::{NotebookCard, Repository, RepositoryIndex}; pub mod avatar; @@ -18,6 +20,8 @@ pub use profile::ProfileDisplay; pub mod notebook_cover; pub use notebook_cover::NotebookCover; +pub mod login; + use dioxus::prelude::*; #[derive(PartialEq, Props, Clone)] @@ -117,3 +121,6 @@ static _IOSK_ITAL_BOLD: Asset = asset!( .with_hash_suffix(false) .into_asset_options() ); +pub mod input; +pub mod dialog; +pub mod button; diff --git a/crates/weaver-app/src/components/notebook_cover.rs b/crates/weaver-app/src/components/notebook_cover.rs index 5eed2b9..bcdb348 100644 --- a/crates/weaver-app/src/components/notebook_cover.rs +++ b/crates/weaver-app/src/components/notebook_cover.rs @@ -2,7 +2,6 @@ use crate::components::avatar::{Avatar, AvatarImage}; use dioxus::prelude::*; -use jacquard::types::ident::AtIdentifier; use weaver_api::sh_weaver::notebook::NotebookView; const NOTEBOOK_COVER_CSS: Asset = asset!("/assets/styling/notebook-cover.css"); diff --git a/crates/weaver-app/src/components/profile.rs b/crates/weaver-app/src/components/profile.rs index ec89cce..617fc7f 100644 --- a/crates/weaver-app/src/components/profile.rs +++ b/crates/weaver-app/src/components/profile.rs @@ -6,7 +6,6 @@ use crate::{ BskyIcon, TangledIcon, }, data::use_handle, - Route, }; use dioxus::prelude::*; use jacquard::types::ident::AtIdentifier; diff --git a/crates/weaver-app/src/config.rs b/crates/weaver-app/src/config.rs new file mode 100644 index 0000000..b5b1a34 --- /dev/null +++ b/crates/weaver-app/src/config.rs @@ -0,0 +1,208 @@ +use core::fmt; +use std::str::FromStr; + +use jacquard::{ + CowStr, IntoStatic, + oauth::{ + atproto::{AtprotoClientMetadata, GrantType}, + scopes::Scope, + }, + smol_str::{SmolStr, ToSmolStr}, + url::Url, +}; + +use crate::env; + +#[derive(Debug, Clone)] +pub struct Config { + pub oauth: AtprotoClientMetadata<'static>, +} + +#[derive(Debug, Clone)] +pub struct OAuthConfig { + pub client_id: jacquard::url::Url, + pub redirect_uri: jacquard::url::Url, + pub scopes: Vec>, + pub client_name: SmolStr, + pub client_uri: Option, + pub logo_uri: Option, + pub tos_uri: Option, + pub privacy_policy_uri: Option, +} + +impl OAuthConfig { + /// This will panic if something is incorrect. You kind of can't proceed if these aren't a certain way, so... + pub fn new( + client_id: jacquard::url::Url, + redirect_uri: jacquard::url::Url, + scopes: Vec>, + client_name: SmolStr, + client_uri: Option, + logo_uri: Option, + tos_uri: Option, + privacy_policy_uri: Option, + ) -> Self { + let scopes = if scopes.is_empty() { + vec![ + Scope::Atproto, + Scope::Transition(jacquard::oauth::scopes::TransitionScope::Generic), + ] + } else { + scopes + }; + if let Some(client_uri) = &client_uri { + if let Some(client_uri_host) = client_uri.host_str() { + if client_uri_host != client_id.host_str().expect("client_id must have a host") { + panic!("client_uri host must match client_id host"); + } + } + } + if let Some(logo_uri) = &logo_uri { + if logo_uri.scheme() != "https" { + panic!("logo_uri scheme must be https"); + } + } + if let Some(tos_uri) = &tos_uri { + if tos_uri.scheme() != "https" { + panic!("tos_uri scheme must be https"); + } + } + if let Some(privacy_policy_uri) = &privacy_policy_uri { + if privacy_policy_uri.scheme() != "https" { + panic!("privacy_policy_uri scheme must be https"); + } + } + Self { + client_id, + redirect_uri, + scopes, + client_name, + client_uri, + logo_uri, + tos_uri, + privacy_policy_uri, + } + } + + pub fn new_dev(port: u32, scopes: Vec>, client_name: SmolStr) -> Self { + // determine client_id + #[derive(serde::Serialize)] + struct Parameters<'a> { + #[serde(skip_serializing_if = "Option::is_none")] + redirect_uri: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + scope: Option>, + } + let redirect_uri: Url = format!("http://127.0.0.1:{port}/callback").parse().unwrap(); + let query = serde_html_form::to_string(Parameters { + redirect_uri: Some(vec![redirect_uri.clone()]), + scope: Some(Scope::serialize_multiple(scopes.as_slice())), + }) + .ok(); + let mut client_id = String::from("http://localhost"); + if let Some(query) = query + && !query.is_empty() + { + client_id.push_str(&format!("?{query}")); + }; + Self::new( + client_id.parse().unwrap(), + redirect_uri, + scopes, + client_name, + None, + None, + None, + None, + ) + } + + pub fn from_env() -> Self { + let app_env = AppEnv::from_str(env::WEAVER_APP_ENV).unwrap_or(AppEnv::Dev); + + if app_env == AppEnv::Dev { + Self::new_dev( + env::WEAVER_PORT.parse().unwrap_or(8080), + Scope::parse_multiple(env::WEAVER_APP_SCOPES) + .unwrap_or(vec![]) + .into_static(), + env::WEAVER_CLIENT_NAME.to_smolstr(), + ) + } else { + let host = env::WEAVER_APP_HOST; + let client_id = format!("{host}/client-metadata.json"); + let redirect_uri = format!("{host}/callback"); + let logo_uri = if env::WEAVER_LOGO_URI.is_empty() { + None + } else { + Url::parse(env::WEAVER_LOGO_URI).ok() + }; + let tos_uri = if env::WEAVER_TOS_URI.is_empty() { + None + } else { + Url::parse(env::WEAVER_TOS_URI).ok() + }; + let privacy_policy_uri = if env::WEAVER_PRIVACY_POLICY_URI.is_empty() { + None + } else { + Url::parse(env::WEAVER_PRIVACY_POLICY_URI).ok() + }; + Self::new( + Url::parse(&client_id).expect("Failed to parse client ID as valid URL"), + Url::parse(&redirect_uri).expect("Failed to parse redirect URI as valid URL"), + Scope::parse_multiple(env::WEAVER_APP_SCOPES) + .unwrap_or(vec![]) + .into_static(), + env::WEAVER_CLIENT_NAME.to_smolstr(), + Some(Url::parse(&host).expect("Failed to parse host as valid URL")), + logo_uri, + tos_uri, + privacy_policy_uri, + ) + } + } + + pub fn as_metadata(self) -> AtprotoClientMetadata<'static> { + AtprotoClientMetadata::new( + self.client_id, + self.client_uri, + vec![self.redirect_uri], + vec![GrantType::AuthorizationCode, GrantType::RefreshToken], + self.scopes, + None, + ) + .with_prod_info( + self.client_name.as_str(), + self.logo_uri, + self.tos_uri, + self.privacy_policy_uri, + ) + } +} + +#[derive(PartialEq)] +enum AppEnv { + Dev, + Prod, +} + +impl std::str::FromStr for AppEnv { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "dev" => Ok(Self::Dev), + "prod" => Ok(Self::Prod), + s => Err(format!("Invalid AppEnv: {s}")), + } + } +} + +impl fmt::Display for AppEnv { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + AppEnv::Dev => write!(f, "dev"), + AppEnv::Prod => write!(f, "prod"), + } + } +} diff --git a/crates/weaver-app/src/data.rs b/crates/weaver-app/src/data.rs index 455684b..8de6d90 100644 --- a/crates/weaver-app/src/data.rs +++ b/crates/weaver-app/src/data.rs @@ -130,12 +130,10 @@ pub fn use_handle( #[cfg(feature = "fullstack-server")] let h_str = { use_server_future(move || { - let fetcher = fetcher.clone(); + let client = fetcher.get_client(); async move { use jacquard::smol_str::ToSmolStr; - - fetcher - .client + client .resolve_ident_owned(&ident()) .await .map(|doc| doc.handles().first().map(|h| h.to_smolstr())) @@ -147,12 +145,10 @@ pub fn use_handle( #[cfg(not(feature = "fullstack-server"))] let h_str = { use_resource(move || { - let fetcher = fetcher.clone(); + let client = fetcher.get_client(); async move { use jacquard::smol_str::ToSmolStr; - - fetcher - .client + client .resolve_ident_owned(&ident()) .await .map(|doc| doc.handles().first().map(|h| h.to_smolstr())) @@ -184,11 +180,11 @@ pub fn use_rendered_markdown( let content = use_signal(|| content); let fetcher = use_context::(); Ok(use_server_future(move || { - let fetcher = fetcher.clone(); + let client = fetcher.get_client(); async move { let did = match ident() { AtIdentifier::Did(d) => d, - AtIdentifier::Handle(h) => fetcher.client.resolve_handle(&h).await.ok()?, + AtIdentifier::Handle(h) => client.resolve_handle(&h).await.ok()?, }; Some(render_markdown_impl(content(), did).await) } @@ -205,11 +201,11 @@ pub fn use_rendered_markdown( let content = use_signal(|| content); let fetcher = use_context::(); Ok(use_resource(move || { - let fetcher = fetcher.clone(); + let client = fetcher.get_client(); async move { let did = match ident() { AtIdentifier::Did(d) => d, - AtIdentifier::Handle(h) => fetcher.client.resolve_handle(&h).await.ok()?, + AtIdentifier::Handle(h) => client.resolve_handle(&h).await.ok()?, }; Some(render_markdown_impl(content(), did).await) } @@ -256,7 +252,10 @@ pub fn use_profile_data( })?; Ok(use_memo(move || { if let Some(Some(value)) = &*res.read_unchecked() { - jacquard::from_json_value::(value.clone()).ok() + jacquard::from_json_value::( + value.clone(), + ) + .ok() } else { None } @@ -271,7 +270,13 @@ pub fn use_profile_data( let fetcher = use_context::(); let r = use_resource(use_reactive!(|ident| { let fetcher = fetcher.clone(); - async move { fetcher.fetch_profile(&ident).await.ok().map(|arc| (*arc).clone()) } + async move { + fetcher + .fetch_profile(&ident) + .await + .ok() + .map(|arc| (*arc).clone()) + } })); Ok(use_memo(move || { r.read_unchecked().as_ref().and_then(|v| v.clone()) @@ -315,10 +320,13 @@ pub fn use_notebooks_for_did( if let Some(Some(values)) = &*res.read_unchecked() { values .iter() - .map(|v| jacquard::from_json_value::<( - weaver_api::sh_weaver::notebook::NotebookView, - Vec, - )>(v.clone()).ok()) + .map(|v| { + jacquard::from_json_value::<( + weaver_api::sh_weaver::notebook::NotebookView, + Vec, + )>(v.clone()) + .ok() + }) .collect::>>() } else { None diff --git a/crates/weaver-app/src/env.rs b/crates/weaver-app/src/env.rs new file mode 100644 index 0000000..b8c902b --- /dev/null +++ b/crates/weaver-app/src/env.rs @@ -0,0 +1,20 @@ +// This file is automatically generated by build.rs + +#[allow(unused)] +pub const WEAVER_APP_ENV: &'static str = "dev"; +#[allow(unused)] +pub const WEAVER_APP_HOST: &'static str = "http://localhost"; +#[allow(unused)] +pub const WEAVER_APP_DOMAIN: &'static str = ""; +#[allow(unused)] +pub const WEAVER_PORT: &'static str = "8080"; +#[allow(unused)] +pub const WEAVER_APP_SCOPES: &'static str = "atproto transition:generic"; +#[allow(unused)] +pub const WEAVER_CLIENT_NAME: &'static str = "Weaver"; +#[allow(unused)] +pub const WEAVER_LOGO_URI: &'static str = ""; +#[allow(unused)] +pub const WEAVER_TOS_URI: &'static str = ""; +#[allow(unused)] +pub const WEAVER_PRIVACY_POLICY_URI: &'static str = ""; diff --git a/crates/weaver-app/src/fetch.rs b/crates/weaver-app/src/fetch.rs index 2908dfc..1787d81 100644 --- a/crates/weaver-app/src/fetch.rs +++ b/crates/weaver-app/src/fetch.rs @@ -1,9 +1,30 @@ +use crate::auth::AuthStore; use crate::cache_impl; +use dioxus::prelude::*; use dioxus::Result; +use jacquard::client::Agent; +use jacquard::client::AgentKind; +use jacquard::error::ClientError; +use jacquard::error::XrpcResult; +use jacquard::identity::resolver::DidDocResponse; +use jacquard::identity::resolver::IdentityError; +use jacquard::identity::resolver::ResolverOptions; +use jacquard::identity::JacquardResolver; +use jacquard::oauth::client::OAuthClient; +use jacquard::oauth::client::OAuthSession; use jacquard::prelude::*; -use jacquard::{client::BasicClient, smol_str::SmolStr, types::ident::AtIdentifier}; +use jacquard::types::string::Did; +use jacquard::types::string::Handle; +use jacquard::xrpc::XrpcResponse; +use jacquard::xrpc::*; +use jacquard::AuthorizationToken; +use jacquard::CowStr; +use jacquard::IntoStatic; +use jacquard::{smol_str::SmolStr, types::ident::AtIdentifier}; use serde::{Deserialize, Serialize}; +use std::future::Future; use std::{sync::Arc, time::Duration}; +use tokio::sync::RwLock; use weaver_api::{ com_atproto::repo::strong_ref::StrongRef, sh_weaver::{ @@ -22,9 +43,271 @@ struct UfosRecord { time_us: u64, } +pub struct Client { + pub oauth_client: Arc>, + pub session: RwLock>>>>, +} + +impl Client { + pub fn new(oauth_client: OAuthClient) -> Self { + Self { + oauth_client: Arc::new(oauth_client), + session: RwLock::new(None), + } + } +} + +impl HttpClient for Client { + type Error = reqwest::Error; + + #[cfg(not(target_arch = "wasm32"))] + fn send_http( + &self, + request: http::Request>, + ) -> impl Future>, Self::Error>> + Send + { + self.oauth_client.client.send_http(request) + } + + #[doc = " Send an HTTP request and return the response."] + #[cfg(target_arch = "wasm32")] + fn send_http( + &self, + request: http::Request>, + ) -> impl Future>, Self::Error>> { + self.oauth_client.client.send_http(request) + } +} + +impl XrpcClient for Client { + #[doc = " Get the base URI for the client."] + fn base_uri(&self) -> impl Future + Send { + async { + let guard = self.session.read().await; + if let Some(session) = guard.clone() { + session.base_uri().await + } else { + self.oauth_client.base_uri().await + } + } + } + + #[doc = " Send an XRPC request and parse the response"] + #[cfg(not(target_arch = "wasm32"))] + fn send(&self, request: R) -> impl Future>> + Send + where + R: XrpcRequest + Send + Sync, + ::Response: Send + Sync, + Self: Sync, + { + async { + let guard = self.session.read().await; + if let Some(session) = guard.clone() { + session.send(request).await + } else { + self.oauth_client.send(request).await + } + } + } + + #[doc = " Send an XRPC request and parse the response"] + #[cfg(not(target_arch = "wasm32"))] + fn send_with_opts( + &self, + request: R, + opts: CallOptions<'_>, + ) -> impl Future>> + Send + where + R: XrpcRequest + Send + Sync, + ::Response: Send + Sync, + Self: Sync, + { + async { + let guard = self.session.read().await; + if let Some(session) = guard.clone() { + session.send_with_opts(request, opts).await + } else { + self.oauth_client.send_with_opts(request, opts).await + } + } + } + + #[doc = " Send an XRPC request and parse the response"] + #[cfg(target_arch = "wasm32")] + fn send(&self, request: R) -> impl Future>> + where + R: XrpcRequest + Send + Sync, + ::Response: Send + Sync, + { + async { + let guard = self.session.read().await; + if let Some(session) = guard.clone() { + session.send(request).await + } else { + self.oauth_client.send(request).await + } + } + } + + #[doc = " Send an XRPC request and parse the response"] + #[cfg(target_arch = "wasm32")] + fn send_with_opts( + &self, + request: R, + opts: CallOptions<'_>, + ) -> impl Future>> + where + R: XrpcRequest + Send + Sync, + ::Response: Send + Sync, + { + async { + let guard = self.session.read().await; + if let Some(session) = guard.clone() { + session.send_with_opts(request, opts).await + } else { + self.oauth_client.send_with_opts(request, opts).await + } + } + } + + #[doc = " Set the base URI for the client."] + fn set_base_uri(&self, url: jacquard::url::Url) -> impl Future + Send { + async { + let guard = self.session.read().await; + if let Some(session) = guard.clone() { + session.set_base_uri(url).await + } else { + self.oauth_client.set_base_uri(url).await + } + } + } + + #[doc = " Get the call options for the client."] + fn opts(&self) -> impl Future> + Send { + async { + let guard = self.session.read().await; + if let Some(session) = guard.clone() { + session.opts().await.into_static() + } else { + self.oauth_client.opts().await + } + } + } + + #[doc = " Set the call options for the client."] + fn set_opts(&self, opts: CallOptions) -> impl Future + Send { + async { + let guard = self.session.read().await; + if let Some(session) = guard.clone() { + session.set_opts(opts).await + } else { + self.oauth_client.set_opts(opts).await + } + } + } +} + +impl IdentityResolver for Client { + #[doc = " Access options for validation decisions in default methods"] + fn options(&self) -> &ResolverOptions { + self.oauth_client.client.options() + } + + #[doc = " Resolve handle"] + #[cfg(not(target_arch = "wasm32"))] + fn resolve_handle( + &self, + handle: &Handle<'_>, + ) -> impl Future, IdentityError>> + Send + where + Self: Sync, + { + self.oauth_client.client.resolve_handle(handle) + } + + #[doc = " Resolve DID document"] + #[cfg(not(target_arch = "wasm32"))] + fn resolve_did_doc( + &self, + did: &Did<'_>, + ) -> impl Future> + Send + where + Self: Sync, + { + self.oauth_client.client.resolve_did_doc(did) + } + + #[doc = " Resolve handle"] + #[cfg(target_arch = "wasm32")] + fn resolve_handle( + &self, + handle: &Handle<'_>, + ) -> impl Future, IdentityError>> { + self.oauth_client.client.resolve_handle(handle) + } + + #[doc = " Resolve DID document"] + #[cfg(target_arch = "wasm32")] + fn resolve_did_doc( + &self, + did: &Did<'_>, + ) -> impl Future> { + self.oauth_client.client.resolve_did_doc(did) + } +} + +impl AgentSession for Client { + #[doc = " Identify the kind of session."] + fn session_kind(&self) -> AgentKind { + self.oauth_client.session_kind() + } + + #[doc = " Return current DID and an optional session id (always Some for OAuth)."] + async fn session_info(&self) -> Option<(Did<'static>, Option>)> { + let guard = self.session.read().await; + if let Some(session) = guard.clone() { + session.info().await + } else { + None + } + } + + #[doc = " Current base endpoint."] + async fn endpoint(&self) -> jacquard::url::Url { + let guard = self.session.read().await; + if let Some(session) = guard.clone() { + session.endpoint().await + } else { + self.oauth_client.endpoint().await + } + } + + #[doc = " Override per-session call options."] + async fn set_options<'a>(&'a self, opts: CallOptions<'a>) { + let guard = self.session.read().await; + if let Some(session) = guard.clone() { + session.set_options(opts).await + } else { + self.oauth_client.set_options(opts).await + } + } + + #[doc = " Refresh the session and return a fresh AuthorizationToken."] + async fn refresh(&self) -> XrpcResult> { + let guard = self.session.read().await; + if let Some(session) = guard.clone() { + session.refresh().await + } else { + Err(ClientError::auth( + jacquard::error::AuthError::NotAuthenticated, + )) + } + } +} + #[derive(Clone)] pub struct CachedFetcher { - pub client: Arc, + pub client: Arc, book_cache: cache_impl::Cache< (AtIdentifier<'static>, SmolStr), Arc<(NotebookView<'static>, Vec>)>, @@ -36,16 +319,50 @@ pub struct CachedFetcher { profile_cache: cache_impl::Cache, Arc>>, } +/// SAFETY: This isn't thread-safe on WASM, but we aren't multithreaded on WASM +unsafe impl Sync for CachedFetcher {} + +/// SAFETY: This isn't thread-safe on WASM, but we aren't multithreaded on WASM +unsafe impl Send for CachedFetcher {} + impl CachedFetcher { - pub fn new(client: Arc) -> Self { + pub fn new(client: OAuthClient) -> Self { Self { - client, + client: Arc::new(Client::new(client)), book_cache: cache_impl::new_cache(100, Duration::from_secs(1200)), entry_cache: cache_impl::new_cache(100, Duration::from_secs(600)), profile_cache: cache_impl::new_cache(100, Duration::from_secs(1800)), } } + pub async fn upgrade_to_authenticated( + &self, + session: OAuthSession, + ) { + let mut session_slot = self.client.session.write().await; + *session_slot = Some(Arc::new(Agent::new(session))); + } + + pub async fn downgrade_to_unauthenticated(&self) { + let mut session_slot = self.client.session.write().await; + if let Some(session) = session_slot.take() { + session.inner().logout().await; + } + } + + pub async fn current_did(&self) -> Option> { + let session_slot = self.client.session.read().await; + if let Some(session) = session_slot.as_ref() { + session.info().await.map(|(d, _)| d) + } else { + None + } + } + + pub fn get_client(&self) -> Arc { + self.client.clone() + } + pub async fn get_notebook( &self, ident: AtIdentifier<'static>, @@ -54,11 +371,11 @@ impl CachedFetcher { if let Some(entry) = cache_impl::get(&self.book_cache, &(ident.clone(), title.clone())) { Ok(Some(entry)) } else { - if let Some((notebook, entries)) = - self.client - .notebook_by_title(&ident, &title) - .await - .map_err(|e| dioxus::CapturedError::from_display(e))? + let client = self.get_client(); + if let Some((notebook, entries)) = client + .notebook_by_title(&ident, &title) + .await + .map_err(|e| dioxus::CapturedError::from_display(e))? { let stored = Arc::new((notebook, entries)); cache_impl::insert(&self.book_cache, (ident, title), stored.clone()); @@ -82,8 +399,8 @@ impl CachedFetcher { { Ok(Some(entry)) } else { - if let Some(entry) = self - .client + let client = self.get_client(); + if let Some(entry) = client .entry_by_title(notebook, entries.as_ref(), &entry_title) .await .map_err(|e| dioxus::CapturedError::from_display(e))? @@ -116,6 +433,7 @@ impl CachedFetcher { .map_err(|e| dioxus::CapturedError::from_display(e))?; let mut notebooks = Vec::new(); + let client = self.get_client(); for ufos_record in records { // Construct URI @@ -127,7 +445,7 @@ impl CachedFetcher { .map_err(|e| dioxus::CapturedError::from_display(format!("Invalid URI: {}", e)))?; // Fetch the full notebook view (which hydrates authors) - match self.client.view_notebook(&uri).await { + match client.view_notebook(&uri).await { Ok((notebook, entries)) => { let ident = uri.authority().clone().into_static(); let title = notebook @@ -161,26 +479,25 @@ impl CachedFetcher { com_atproto::repo::list_records::ListRecords, sh_weaver::notebook::book::Book, }; + let client = self.get_client(); + // Resolve DID and PDS let (repo_did, pds_url) = match ident { AtIdentifier::Did(did) => { - let pds = self - .client + let pds = client .pds_for_did(did) .await .map_err(|e| dioxus::CapturedError::from_display(e))?; (did.clone(), pds) } - AtIdentifier::Handle(handle) => self - .client + AtIdentifier::Handle(handle) => client .pds_for_handle(handle) .await .map_err(|e| dioxus::CapturedError::from_display(e))?, }; // Fetch all notebook records for this repo - let resp = self - .client + let resp = client .xrpc(pds_url) .send( &ListRecords::new() @@ -197,7 +514,7 @@ impl CachedFetcher { if let Ok(list) = resp.parse() { for record in list.records { // View the notebook (which hydrates authors) - match self.client.view_notebook(&record.uri).await { + match client.view_notebook(&record.uri).await { Ok((notebook, entries)) => { let ident = record.uri.authority().clone().into_static(); let title = notebook @@ -227,9 +544,10 @@ impl CachedFetcher { if let Some(result) = self.get_notebook(ident.clone(), book_title).await? { let (notebook, entries) = result.as_ref(); let mut book_entries = Vec::new(); + let client = self.get_client(); for index in 0..entries.len() { - match self.client.view_entry(notebook, entries, index).await { + match client.view_entry(notebook, entries, index).await { Ok(book_entry) => book_entries.push(book_entry), Err(_) => continue, // Skip entries that fail to load } @@ -253,17 +571,17 @@ impl CachedFetcher { return Ok(cached); } + let client = self.get_client(); + let did = match ident { AtIdentifier::Did(d) => d.clone(), - AtIdentifier::Handle(h) => self - .client + AtIdentifier::Handle(h) => client .resolve_handle(h) .await .map_err(|e| dioxus::CapturedError::from_display(e))?, }; - let (_uri, profile_view) = self - .client + let (_uri, profile_view) = client .hydrate_profile_view(&did) .await .map_err(|e| dioxus::CapturedError::from_display(e))?; @@ -274,3 +592,178 @@ impl CachedFetcher { Ok(result) } } + +impl HttpClient for CachedFetcher { + #[doc = " Error type returned by the HTTP client"] + type Error = reqwest::Error; + + #[doc = " Send an HTTP request and return the response."] + #[cfg(not(target_arch = "wasm32"))] + fn send_http( + &self, + request: http::Request>, + ) -> impl Future>, Self::Error>> + Send + { + async { + let client = self.get_client(); + client.send_http(request).await + } + } + + #[doc = " Send an HTTP request and return the response."] + #[cfg(target_arch = "wasm32")] + fn send_http( + &self, + request: http::Request>, + ) -> impl Future>, Self::Error>> { + async { + let client = self.get_client(); + client.send_http(request).await + } + } +} + +impl XrpcClient for CachedFetcher { + #[doc = " Get the base URI for the client."] + fn base_uri(&self) -> impl Future + Send { + self.client.base_uri() + } + + #[doc = " Send an XRPC request and parse the response"] + #[cfg(not(target_arch = "wasm32"))] + fn send(&self, request: R) -> impl Future>> + Send + where + R: XrpcRequest + Send + Sync, + ::Response: Send + Sync, + Self: Sync, + { + self.client.send(request) + } + + #[doc = " Send an XRPC request and parse the response"] + #[cfg(not(target_arch = "wasm32"))] + fn send_with_opts( + &self, + request: R, + opts: CallOptions<'_>, + ) -> impl Future>> + Send + where + R: XrpcRequest + Send + Sync, + ::Response: Send + Sync, + Self: Sync, + { + self.client.send_with_opts(request, opts) + } + + #[doc = " Send an XRPC request and parse the response"] + #[cfg(target_arch = "wasm32")] + fn send(&self, request: R) -> impl Future>> + where + R: XrpcRequest + Send + Sync, + ::Response: Send + Sync, + { + self.client.send(request) + } + + #[doc = " Send an XRPC request and parse the response"] + #[cfg(target_arch = "wasm32")] + fn send_with_opts( + &self, + request: R, + opts: CallOptions<'_>, + ) -> impl Future>> + where + R: XrpcRequest + Send + Sync, + ::Response: Send + Sync, + { + self.client.send_with_opts(request, opts) + } + + #[doc = " Set the base URI for the client."] + fn set_base_uri(&self, url: jacquard::url::Url) -> impl Future + Send { + self.client.set_base_uri(url) + } + + #[doc = " Get the call options for the client."] + fn opts(&self) -> impl Future> + Send { + self.client.opts() + } + + #[doc = " Set the call options for the client."] + fn set_opts(&self, opts: CallOptions) -> impl Future + Send { + self.client.set_opts(opts) + } +} + +impl IdentityResolver for CachedFetcher { + #[doc = " Access options for validation decisions in default methods"] + fn options(&self) -> &ResolverOptions { + self.client.options() + } + + #[doc = " Resolve handle"] + #[cfg(not(target_arch = "wasm32"))] + fn resolve_handle( + &self, + handle: &Handle<'_>, + ) -> impl Future, IdentityError>> + Send + where + Self: Sync, + { + self.client.resolve_handle(handle) + } + + #[doc = " Resolve DID document"] + #[cfg(not(target_arch = "wasm32"))] + fn resolve_did_doc( + &self, + did: &Did<'_>, + ) -> impl Future> + Send + where + Self: Sync, + { + self.client.resolve_did_doc(did) + } + + #[doc = " Resolve handle"] + #[cfg(target_arch = "wasm32")] + fn resolve_handle( + &self, + handle: &Handle<'_>, + ) -> impl Future, IdentityError>> { + self.client.resolve_handle(handle) + } + + #[doc = " Resolve DID document"] + #[cfg(target_arch = "wasm32")] + fn resolve_did_doc( + &self, + did: &Did<'_>, + ) -> impl Future> { + self.client.resolve_did_doc(did) + } +} + +impl AgentSession for CachedFetcher { + #[doc = " Identify the kind of session."] + fn session_kind(&self) -> AgentKind { + self.client.session_kind() + } + + #[doc = " Return current DID and an optional session id (always Some for OAuth)."] + async fn session_info(&self) -> Option<(Did<'static>, Option>)> { + self.client.session_info().await + } + + async fn endpoint(&self) -> jacquard::url::Url { + self.client.endpoint().await + } + + async fn set_options<'a>(&'a self, opts: CallOptions<'a>) { + self.client.set_options(opts).await + } + + async fn refresh(&self) -> XrpcResult> { + self.client.refresh().await + } +} diff --git a/crates/weaver-app/src/main.rs b/crates/weaver-app/src/main.rs index 56dc7bb..1a2daf6 100644 --- a/crates/weaver-app/src/main.rs +++ b/crates/weaver-app/src/main.rs @@ -2,29 +2,39 @@ // need dioxus use components::{Entry, Repository, RepositoryIndex}; #[allow(unused)] -use dioxus::{prelude::*, CapturedError}; +use dioxus::{CapturedError, prelude::*}; -#[cfg(all(feature = "fullstack-server", feature = "server"))] -use dioxus::fullstack::response::Extension; #[cfg(feature = "fullstack-server")] use dioxus::fullstack::FullstackContext; +#[cfg(all(feature = "fullstack-server", feature = "server"))] +use dioxus::fullstack::response::Extension; +use dioxus_logger::tracing::Level; +use jacquard::oauth::{client::OAuthClient, session::ClientData}; #[allow(unused)] use jacquard::{ - client::BasicClient, smol_str::SmolStr, types::{cid::Cid, string::AtIdentifier}, }; - +#[cfg(feature = "server")] use std::sync::Arc; +use std::sync::{LazyLock, Mutex}; #[allow(unused)] -use views::{Home, Navbar, Notebook, NotebookIndex, NotebookPage, RecordView}; +use views::{Callback, Home, Navbar, Notebook, NotebookIndex, NotebookPage, RecordView}; +use crate::{ + auth::{AuthState, AuthStore}, + config::{Config, OAuthConfig}, +}; + +mod auth; #[cfg(feature = "server")] mod blobcache; mod cache_impl; /// Define a components module that contains all shared components for our app. mod components; +mod config; mod data; +mod env; mod fetch; mod service_worker; /// Define a views module that contains the UI for all Layouts and Routes for our app. @@ -48,6 +58,8 @@ enum Route { #[layout(ErrorLayout)] #[route("/record#:uri")] RecordView { uri: SmolStr }, + #[route("/callback?:state&:iss&:code")] + Callback { state: SmolStr, iss: SmolStr, code: SmolStr }, #[nest("/:ident")] #[layout(Repository)] #[route("/")] @@ -79,7 +91,11 @@ async fn serve_sw() -> impl axum::response::IntoResponse { .into_response() } +pub static CONFIG: LazyLock = LazyLock::new(|| Config { + oauth: OAuthConfig::from_env().as_metadata(), +}); fn main() { + dioxus_logger::init(Level::DEBUG).expect("logger failed to init"); // Set up better panic messages for wasm #[cfg(target_arch = "wasm32")] console_error_panic_hook::set_once(); @@ -88,7 +104,6 @@ fn main() { #[cfg(feature = "server")] dioxus::serve(|| async move { use crate::blobcache::BlobCache; - use crate::fetch::CachedFetcher; use axum::{ extract::{Extension, Request}, middleware, @@ -105,22 +120,25 @@ fn main() { .merge(dioxus::server::router(App)) }; - let client = Arc::new(BasicClient::unauthenticated()); - #[cfg(feature = "fullstack-server")] let router = { - let fetcher = Arc::new(CachedFetcher::new(client.clone())); - let blob_cache = Arc::new(BlobCache::new(client.clone())); + use jacquard::client::UnauthenticatedSession; + let fetcher = Arc::new(fetch::CachedFetcher::new(OAuthClient::new( + AuthStore::new(), + ClientData::new_public(CONFIG.oauth.clone()), + ))); + let blob_cache = Arc::new(BlobCache::new(Arc::new( + UnauthenticatedSession::new_public(), + ))); dioxus::server::router(App).layer(middleware::from_fn({ - let fetcher = fetcher.clone(); let blob_cache = blob_cache.clone(); + let fetcher = fetcher.clone(); move |mut req: Request, next: Next| { - let fetcher = fetcher.clone(); let blob_cache = blob_cache.clone(); + let fetcher = fetcher.clone(); async move { - // Attach extensions for dioxus server functions - req.extensions_mut().insert(fetcher); req.extensions_mut().insert(blob_cache); + req.extensions_mut().insert(fetcher); // And then return the response with `next.run() Ok::<_, Infallible>(next.run(req).await) @@ -137,14 +155,23 @@ fn main() { dioxus::launch(App); } -/// App is the main component of our app. Components are the building blocks of dioxus apps. Each component is a function -/// that takes some props and returns an Element. In this case, App takes no props because it is the root of our app. -/// -/// Components should be annotated with `#[component]` to support props, better error messages, and autocomplete #[component] fn App() -> Element { - // The `rsx!` macro lets us define HTML inside of rust. It expands to an Element with all of our HTML inside. - use_context_provider(|| fetch::CachedFetcher::new(Arc::new(BasicClient::unauthenticated()))); + use_context_provider(|| { + fetch::CachedFetcher::new(OAuthClient::new( + AuthStore::new(), + ClientData::new_public(CONFIG.oauth.clone()), + )) + }); + use_context_provider(|| Signal::new(AuthState::default())); + + use_effect(move || { + spawn(async move { + if let Err(e) = auth::restore_session().await { + dioxus_logger::tracing::warn!("Session restoration failed: {}", e); + } + }); + }); // Register service worker on startup (only on web) #[cfg(all( diff --git a/crates/weaver-app/src/views/callback.rs b/crates/weaver-app/src/views/callback.rs new file mode 100644 index 0000000..3bd43d3 --- /dev/null +++ b/crates/weaver-app/src/views/callback.rs @@ -0,0 +1,89 @@ +use crate::auth::AuthState; +use crate::fetch::CachedFetcher; +use dioxus::logger::tracing::{Level, error, info}; +use dioxus::{CapturedError, prelude::*}; +use jacquard::{ + IntoStatic, + cowstr::ToCowStr, + oauth::{error::OAuthError, types::CallbackParams}, + smol_str::SmolStr, +}; + +#[component] +pub fn Callback( + state: ReadSignal, + iss: ReadSignal, + code: ReadSignal, +) -> Element { + let fetcher = use_context::(); + let mut auth = use_context::>(); + #[cfg(feature = "web")] + let result = { + use_resource(move || { + let fetcher = fetcher.clone(); + let callback_params = CallbackParams { + code: code().to_cowstr(), + state: Some(state().to_cowstr()), + iss: Some(iss().to_cowstr()), + } + .into_static(); + info!("Auth Callback: {:?}", callback_params); + async move { + let session = fetcher + .client + .oauth_client + .callback(callback_params) + .await?; + let (did, session_id) = session.session_info().await; + auth.write().set_authenticated(did, session_id); + fetcher.upgrade_to_authenticated(session).await; + Ok::<(), OAuthError>(()) + } + }) + }; + #[cfg(not(feature = "web"))] + let result = { use_resource(move || async { Ok::<(), OAuthError>(()) }) }; + + match &*result.read_unchecked() { + Some(Ok(())) => { + // #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + // { + // if let Some(window) = web_sys::window() { + // window.close().ok(); + // } + // } + #[cfg(target_arch = "wasm32")] + { + use gloo_storage::Storage; + let mut prev = gloo_storage::LocalStorage::get::("cached_route").ok(); + if let Some(prev) = prev.take() { + dioxus_logger::tracing::info!("Navigating to previous page"); + let nav = use_navigator(); + gloo_storage::LocalStorage::delete("cached_route"); + nav.replace(prev); + } + } + rsx! { + div { + h1 { "Success" } + p { "You have successfully authenticated. You can close this browser window." } + } + } + } + Some(Err(err)) => { + error!("Auth Error: {}", err); + rsx! { + + div { + h1 { "Error" } + p { "{err}" } + } + } + } + None => rsx! { + div { + h1 { "Loading..." } + } + }, + } +} diff --git a/crates/weaver-app/src/views/mod.rs b/crates/weaver-app/src/views/mod.rs index fbc1808..428cd98 100644 --- a/crates/weaver-app/src/views/mod.rs +++ b/crates/weaver-app/src/views/mod.rs @@ -22,3 +22,6 @@ pub use notebook::{Notebook, NotebookIndex}; mod record; pub use record::RecordView; + +mod callback; +pub use callback::Callback; diff --git a/crates/weaver-app/src/views/navbar.rs b/crates/weaver-app/src/views/navbar.rs index cbbc32d..d496f6c 100644 --- a/crates/weaver-app/src/views/navbar.rs +++ b/crates/weaver-app/src/views/navbar.rs @@ -1,6 +1,10 @@ -use crate::data::use_handle; use crate::Route; +use crate::components::button::{Button, ButtonVariant}; +use crate::components::login::LoginModal; +use crate::data::use_handle; +use crate::fetch::CachedFetcher; use dioxus::prelude::*; +use jacquard::types::string::AtIdentifier; const THEME_DEFAULTS_CSS: Asset = asset!("/assets/styling/theme-defaults.css"); const NAVBAR_CSS: Asset = asset!("/assets/styling/navbar.css"); @@ -13,6 +17,9 @@ const NAVBAR_CSS: Asset = asset!("/assets/styling/navbar.css"); #[component] pub fn Navbar() -> Element { let route = use_route::(); + let mut auth_state = use_context::>(); + let mut show_login_modal = use_signal(|| false); + let fetcher = use_context::(); rsx! { document::Link { rel: "stylesheet", href: THEME_DEFAULTS_CSS } @@ -60,6 +67,34 @@ pub fn Navbar() -> Element { _ => rsx! {} } } + if auth_state.read().is_authenticated() { + if let Some(did) = &auth_state.read().did { + Button { + variant: ButtonVariant::Ghost, + onclick: move |_| { + let fetcher = fetcher.clone(); + auth_state.write().clear(); + async move { + fetcher.downgrade_to_unauthenticated().await; + } + }, + span { class: "auth-handle", "@{use_handle(AtIdentifier::Did(did.clone()))?}" } + } + } + } else { + div { + class: "auth-button", + Button { + variant: ButtonVariant::Ghost, + onclick: move |_| show_login_modal.set(true), + span { class: "auth-handle", "Sign In" } + } + } + + } + LoginModal { + open: show_login_modal + } } // The `Outlet` component is used to render the next component inside the layout. In this case, it will render either diff --git a/crates/weaver-app/src/views/notebook.rs b/crates/weaver-app/src/views/notebook.rs index 1cd76ef..4d46f96 100644 --- a/crates/weaver-app/src/views/notebook.rs +++ b/crates/weaver-app/src/views/notebook.rs @@ -1,6 +1,7 @@ use crate::{ + Route, components::{EntryCard, NotebookCover, NotebookCss}, - fetch, Route, + fetch, }; use dioxus::prelude::*; use jacquard::{ @@ -15,7 +16,7 @@ const ENTRY_CARD_CSS: Asset = asset!("/assets/styling/entry-card.css"); /// The component takes a `id` prop of type `i32` from the route enum. Whenever the id changes, the component function will be /// re-run and the rendered HTML will be updated. #[component] -pub fn Notebook(ident: AtIdentifier<'static>, book_title: SmolStr) -> Element { +pub fn Notebook(ident: ReadSignal>, book_title: SmolStr) -> Element { rsx! { NotebookCss { ident: ident.to_smolstr(), notebook: book_title } Outlet:: {} @@ -23,40 +24,36 @@ pub fn Notebook(ident: AtIdentifier<'static>, book_title: SmolStr) -> Element { } #[component] -pub fn NotebookIndex(ident: AtIdentifier<'static>, book_title: SmolStr) -> Element { +pub fn NotebookIndex( + ident: ReadSignal>, + book_title: ReadSignal, +) -> Element { let fetcher = use_context::(); - let book_title_clone = book_title.clone(); - // Fetch full notebook to get author count - let ident_for_notebook = ident.clone(); - let book_title_for_notebook = book_title.clone(); let data_fetcher = fetcher.clone(); - let notebook_data = use_resource(use_reactive!(|( - ident_for_notebook, - book_title_for_notebook, - )| { + let notebook_data = use_resource(move || { let fetcher = data_fetcher.clone(); async move { fetcher - .get_notebook(ident_for_notebook, book_title_for_notebook) + .get_notebook(ident(), book_title()) .await .ok() .flatten() } - })); + }); // Also fetch entries let entry_fetcher = fetcher.clone(); - let entries_resource = use_resource(use_reactive!(|(ident, book_title)| { + let entries_resource = use_resource(move || { let fetcher = entry_fetcher.clone(); async move { fetcher - .list_notebook_entries(ident, book_title) + .list_notebook_entries(ident(), book_title()) .await .ok() .flatten() } - })); + }); rsx! { document::Link { rel: "stylesheet", href: ENTRY_CARD_CSS } @@ -71,7 +68,7 @@ pub fn NotebookIndex(ident: AtIdentifier<'static>, book_title: SmolStr) -> Eleme aside { class: "notebook-sidebar", NotebookCover { notebook: notebook_view.clone(), - title: book_title_clone.to_string() + title: book_title().to_string() } } @@ -80,7 +77,7 @@ pub fn NotebookIndex(ident: AtIdentifier<'static>, book_title: SmolStr) -> Eleme for entry in entries { EntryCard { entry: entry.clone(), - book_title: book_title_clone.clone(), + book_title: book_title(), author_count } } diff --git a/crates/weaver-app/src/views/record.rs b/crates/weaver-app/src/views/record.rs index 919c64d..7c80e93 100644 --- a/crates/weaver-app/src/views/record.rs +++ b/crates/weaver-app/src/views/record.rs @@ -1,6 +1,5 @@ use crate::fetch::CachedFetcher; use dioxus::prelude::*; -use hex_fmt::HexFmt; use humansize::format_size; use jacquard::{ client::AgentSessionExt, @@ -17,9 +16,9 @@ enum ViewMode { } #[component] -pub fn RecordView(uri: SmolStr) -> Element { +pub fn RecordView(uri: ReadSignal) -> Element { let fetcher = use_context::(); - let at_uri = AtUri::new_owned(uri.clone()); + let at_uri = AtUri::new_owned(uri()); if let Err(err) = &at_uri { let error = format!("{:?}", err); return rsx! { @@ -33,8 +32,9 @@ pub fn RecordView(uri: SmolStr) -> Element { let uri = use_signal(|| at_uri.unwrap()); let mut view_mode = use_signal(|| ViewMode::Pretty); let record = use_resource(move || { - let fetcher = fetcher.clone(); - async move { fetcher.client.fetch_record_slingshot(&uri()).await } + let client = fetcher.get_client(); + + async move { client.fetch_record_slingshot(&uri()).await } }); if let Some(Ok(record)) = &*record.read_unchecked() { let record_value = record.value.clone().into_static(); diff --git a/crates/weaver-cli/src/main.rs b/crates/weaver-cli/src/main.rs index b0b0878..86a202a 100644 --- a/crates/weaver-cli/src/main.rs +++ b/crates/weaver-cli/src/main.rs @@ -386,8 +386,9 @@ async fn publish_notebook(source: PathBuf, title: String, store_path: PathBuf) - .build(); // Use WeaverExt to upsert entry (handles notebook + entry creation/updates) + use jacquard::http_client::HttpClient; use weaver_common::WeaverExt; - let (entry_uri, was_created) = (*agent) + let (entry_uri, was_created) = agent .upsert_entry(&title, entry_title.as_ref(), entry) .await?; diff --git a/crates/weaver-common/src/agent.rs b/crates/weaver-common/src/agent.rs index 61a4319..5b4bcd1 100644 --- a/crates/weaver-common/src/agent.rs +++ b/crates/weaver-common/src/agent.rs @@ -7,7 +7,7 @@ pub use weaver_api::sh_weaver::notebook::{ use crate::error::WeaverError; pub use jacquard; use jacquard::bytes::Bytes; -use jacquard::client::{Agent, AgentError, AgentErrorKind, AgentSession, AgentSessionExt}; +use jacquard::client::{AgentError, AgentErrorKind, AgentSession, AgentSessionExt}; use jacquard::error::ClientError; use jacquard::prelude::*; use jacquard::types::blob::{BlobRef, MimeType}; @@ -22,511 +22,690 @@ use weaver_api::com_atproto::repo::strong_ref::StrongRef; use weaver_api::sh_weaver::notebook::entry; use weaver_api::sh_weaver::publish::blob::Blob as PublishedBlob; -use crate::{PublishResult, W_TICKER, WeaverExt, normalize_title_path}; - -impl WeaverExt for Agent { - async fn publish_notebook(&self, _path: &Path) -> Result, WeaverError> { - // TODO: Implementation - todo!("publish_notebook not yet implemented") +use crate::{PublishResult, W_TICKER, normalize_title_path}; + +/// Extension trait providing weaver-specific multi-step operations on Agent +/// +/// This trait extends jacquard's Agent with notebook-specific workflows that +/// involve multiple atproto operations (uploading blobs, creating records, etc.) +/// +/// For single-step operations, use jacquard's built-in methods directly: +/// - `agent.create_record()` - Create a single record +/// - `agent.get_record()` - Get a single record +/// - `agent.upload_blob()` - Upload a single blob +/// +/// This trait is for multi-step workflows that coordinate between multiple operations. +//#[trait_variant::make(Send)] +pub trait WeaverExt: AgentSessionExt + XrpcExt { + /// Publish a notebook directory to the user's PDS + /// + /// Multi-step workflow: + /// 1. Parse markdown files in directory + /// 2. Extract and upload images/assets → BlobRefs + /// 3. Transform markdown refs to point at uploaded blobs + /// 4. Create entry records for each file + /// 5. Create book record with entry refs + /// + /// Returns the AT-URI of the published book + fn publish_notebook( + &self, + path: &Path, + ) -> impl Future, WeaverError>> { + async { todo!() } } - async fn publish_blob<'a>( + /// Publish a blob to the user's PDS + /// + /// Multi-step workflow: + /// 1. Upload blob to PDS + /// 2. Create blob record with CID + /// + /// Returns the AT-URI of the published blob + fn publish_blob<'a>( &self, blob: Bytes, url_path: &'a str, prev: Option, - ) -> Result<(StrongRef<'a>, PublishedBlob<'a>), WeaverError> { - let mime_type = - MimeType::new_owned(blob.sniff_mime_type().unwrap_or("application/octet-stream")); - - let blob = self.upload_blob(blob, mime_type).await?; - let publish_record = PublishedBlob::new() - .path(url_path) - .upload(BlobRef::Blob(blob)) - .build(); - let tid = W_TICKER.lock().await.next(prev); - let record = self - .create_record(publish_record.clone(), Some(RecordKey::any(tid.as_str())?)) - .await?; - let strong_ref = StrongRef::new().uri(record.uri).cid(record.cid).build(); + ) -> impl Future, PublishedBlob<'a>), WeaverError>> { + async move { + let mime_type = + MimeType::new_owned(blob.sniff_mime_type().unwrap_or("application/octet-stream")); + + let blob = self.upload_blob(blob, mime_type).await?; + let publish_record = PublishedBlob::new() + .path(url_path) + .upload(BlobRef::Blob(blob)) + .build(); + let tid = W_TICKER.lock().await.next(prev); + let record = self + .create_record(publish_record.clone(), Some(RecordKey::any(tid.as_str())?)) + .await?; + let strong_ref = StrongRef::new().uri(record.uri).cid(record.cid).build(); + + Ok((strong_ref, publish_record)) + } + } + + fn confirm_record_ref( + &self, + uri: &AtUri<'_>, + ) -> impl Future, WeaverError>> { + async move { + let rkey = uri.rkey().ok_or_else(|| { + AgentError::from( + ClientError::invalid_request("AtUri missing rkey") + .with_help("ensure the URI includes a record key after the collection"), + ) + })?; + + // Resolve authority (DID or handle) to get DID and PDS + use jacquard::types::ident::AtIdentifier; + let (repo_did, pds_url) = match uri.authority() { + AtIdentifier::Did(did) => { + let pds = + self.pds_for_did(did).await.map_err(|e| { + AgentError::from(ClientError::from(e).with_context( + "DID document resolution failed during record retrieval", + )) + })?; + (did.clone(), pds) + } + AtIdentifier::Handle(handle) => self.pds_for_handle(handle).await.map_err(|e| { + AgentError::from( + ClientError::from(e) + .with_context("handle resolution failed during record retrieval"), + ) + })?, + }; + + // Make stateless XRPC call to that PDS (no auth required for public records) + use weaver_api::com_atproto::repo::get_record::GetRecord; + let request = GetRecord::new() + .repo(AtIdentifier::Did(repo_did)) + .collection( + uri.collection() + .expect("collection should exist if rkey does") + .clone(), + ) + .rkey(rkey.clone()) + .build(); + + let response: Response = { + let http_request = xrpc::build_http_request(&pds_url, &request, &self.opts().await) + .map_err(|e| AgentError::from(ClientError::transport(e)))?; - Ok((strong_ref, publish_record)) + let http_response = self + .send_http(http_request) + .await + .map_err(|e| AgentError::from(ClientError::transport(e)))?; + + xrpc::process_response(http_response) + } + .map_err(|e| AgentError::new(AgentErrorKind::Client, Some(e.into())))?; + let record = response.parse().map_err(|e| AgentError::xrpc(e))?; + let strong_ref = StrongRef::new() + .uri(record.uri) + .cid(record.cid.expect("when does this NOT have a CID?")) + .build(); + Ok(strong_ref.into_static()) + } } - async fn upsert_notebook( + /// Find or create a notebook by title, returning its URI and entry list + /// + /// If the notebook doesn't exist, creates it with the given DID as author. + fn upsert_notebook( &self, title: &str, author_did: &Did<'_>, - ) -> Result<(AtUri<'static>, Vec>), WeaverError> { - use jacquard::types::collection::Collection; - use jacquard::types::nsid::Nsid; - use jacquard::xrpc::XrpcExt; - use weaver_api::com_atproto::repo::list_records::ListRecords; - use weaver_api::sh_weaver::notebook::book::Book; - - // Find the PDS for this DID - let pds_url = self.pds_for_did(author_did).await.map_err(|e| { - AgentError::from(ClientError::from(e).with_context("Failed to resolve PDS for DID")) - })?; - - // Search for existing notebook with this title - let resp = self - .xrpc(pds_url) - .send( - &ListRecords::new() - .repo(author_did.clone()) - .collection(Nsid::raw(Book::NSID)) - .limit(100) - .build(), - ) - .await - .map_err(|e| AgentError::from(ClientError::from(e)))?; - - if let Ok(list) = resp.parse() { - for record in list.records { - let notebook: Book = jacquard::from_data(&record.value).map_err(|_| { - AgentError::from(ClientError::invalid_request( - "Failed to parse notebook record", - )) - })?; - if let Some(book_title) = notebook.title - && book_title == title - { - let entries = notebook - .entry_list - .iter() - .cloned() - .map(IntoStatic::into_static) - .collect(); - return Ok((record.uri.into_static(), entries)); + ) -> impl Future, Vec>), WeaverError>> + where + Self: Sized, + { + async move { + use jacquard::types::collection::Collection; + use jacquard::types::nsid::Nsid; + use weaver_api::com_atproto::repo::list_records::ListRecords; + use weaver_api::sh_weaver::notebook::book::Book; + + // Find the PDS for this DID + let pds_url = self.pds_for_did(author_did).await.map_err(|e| { + AgentError::from(ClientError::from(e).with_context("Failed to resolve PDS for DID")) + })?; + + // Search for existing notebook with this title + let resp = self + .xrpc(pds_url) + .send( + &ListRecords::new() + .repo(author_did.clone()) + .collection(Nsid::raw(Book::NSID)) + .limit(100) + .build(), + ) + .await + .map_err(|e| AgentError::from(ClientError::from(e)))?; + + if let Ok(list) = resp.parse() { + for record in list.records { + let notebook: Book = jacquard::from_data(&record.value).map_err(|_| { + AgentError::from(ClientError::invalid_request( + "Failed to parse notebook record", + )) + })?; + if let Some(book_title) = notebook.title + && book_title == title + { + let entries = notebook + .entry_list + .iter() + .cloned() + .map(IntoStatic::into_static) + .collect(); + return Ok((record.uri.into_static(), entries)); + } } } - } - // Notebook doesn't exist, create it - use weaver_api::sh_weaver::actor::Author; - let path = normalize_title_path(title); - let author = Author::new().did(author_did.clone()).build(); - let book = Book::new() - .authors(vec![author]) - .entry_list(vec![]) - .maybe_title(Some(title.into())) - .maybe_path(Some(path.into())) - .maybe_created_at(Some(jacquard::types::string::Datetime::now())) - .build(); - - let response = self.create_record(book, None).await?; - Ok((response.uri, Vec::new())) + // Notebook doesn't exist, create it + use weaver_api::sh_weaver::actor::Author; + let path = normalize_title_path(title); + let author = Author::new().did(author_did.clone()).build(); + let book = Book::new() + .authors(vec![author]) + .entry_list(vec![]) + .maybe_title(Some(title.into())) + .maybe_path(Some(path.into())) + .maybe_created_at(Some(jacquard::types::string::Datetime::now())) + .build(); + + let response = self.create_record(book, None).await?; + Ok((response.uri, Vec::new())) + } } - async fn upsert_entry( + /// Find or create an entry within a notebook by title + /// + /// Multi-step workflow: + /// 1. Find the notebook by title + /// 2. Check notebook's entry_list for entry with matching title + /// 3. If found: update the entry with new content + /// 4. If not found: create new entry and append to notebook's entry_list + /// + /// Returns (entry_uri, was_created) + fn upsert_entry( &self, notebook_title: &str, entry_title: &str, entry: entry::Entry<'_>, - ) -> Result<(AtUri<'static>, bool), WeaverError> { - // Get our own DID - let (did, _) = self.info().await.ok_or_else(|| { - AgentError::from(ClientError::invalid_request("No session info available")) - })?; - - // Find or create notebook - let (notebook_uri, entry_refs) = self.upsert_notebook(notebook_title, &did).await?; - - // Check if entry with this title exists in the notebook - for entry_ref in &entry_refs { - let existing = self - .get_record::(&entry_ref.uri) - .await - .map_err(|e| AgentError::from(ClientError::from(e)))?; - if let Ok(existing_entry) = existing.parse() { - if existing_entry.value.title == entry_title { - // Update existing entry - self.update_record::(&entry_ref.uri, |e| { - e.content = entry.content.clone(); - e.embeds = entry.embeds.clone(); - e.tags = entry.tags.clone(); - }) - .await?; - return Ok((entry_ref.uri.clone().into_static(), false)); + ) -> impl Future, bool), WeaverError>> + where + Self: Sized, + { + async move { + // Get our own DID + let (did, _) = self.session_info().await.ok_or_else(|| { + AgentError::from(ClientError::invalid_request("No session info available")) + })?; + + // Find or create notebook + let (notebook_uri, entry_refs) = self.upsert_notebook(notebook_title, &did).await?; + + // Check if entry with this title exists in the notebook + for entry_ref in &entry_refs { + let existing = self + .get_record::(&entry_ref.uri) + .await + .map_err(|e| AgentError::from(ClientError::from(e)))?; + if let Ok(existing_entry) = existing.parse() { + if existing_entry.value.title == entry_title { + // Update existing entry + self.update_record::(&entry_ref.uri, |e| { + e.content = entry.content.clone(); + e.embeds = entry.embeds.clone(); + e.tags = entry.tags.clone(); + }) + .await?; + return Ok((entry_ref.uri.clone().into_static(), false)); + } } } - } - // Entry doesn't exist, create it - let response = self.create_record(entry, None).await?; - let entry_uri = response.uri.clone(); + // Entry doesn't exist, create it + let response = self.create_record(entry, None).await?; + let entry_uri = response.uri.clone(); - // Add to notebook's entry_list - use weaver_api::sh_weaver::notebook::book::Book; - let new_ref = StrongRef::new().uri(response.uri).cid(response.cid).build(); + // Add to notebook's entry_list + use weaver_api::sh_weaver::notebook::book::Book; + let new_ref = StrongRef::new().uri(response.uri).cid(response.cid).build(); - self.update_record::(¬ebook_uri, |book| { - book.entry_list.push(new_ref); - }) - .await?; + self.update_record::(¬ebook_uri, |book| { + book.entry_list.push(new_ref); + }) + .await?; - Ok((entry_uri, true)) + Ok((entry_uri, true)) + } } - async fn view_notebook( + /// View functions - generic versions that work with any Agent + + /// Fetch a notebook and construct NotebookView with author profiles + fn view_notebook( &self, uri: &AtUri<'_>, - ) -> Result<(NotebookView<'static>, Vec>), WeaverError> { - use jacquard::to_data; - use weaver_api::sh_weaver::notebook::AuthorListView; - use weaver_api::sh_weaver::notebook::book::Book; - - let notebook = self - .get_record::(uri) - .await - .map_err(|e| AgentError::from(e))? - .into_output() - .map_err(|_| { - AgentError::from(ClientError::invalid_request("Failed to parse Book record")) - })?; + ) -> impl Future, Vec>), WeaverError>> + { + async move { + use jacquard::to_data; + use weaver_api::sh_weaver::notebook::AuthorListView; + use weaver_api::sh_weaver::notebook::book::Book; + + let notebook = self + .get_record::(uri) + .await + .map_err(|e| AgentError::from(e))? + .into_output() + .map_err(|_| { + AgentError::from(ClientError::invalid_request("Failed to parse Book record")) + })?; - let title = notebook.value.title.clone(); - let tags = notebook.value.tags.clone(); + let title = notebook.value.title.clone(); + let tags = notebook.value.tags.clone(); - let mut authors = Vec::new(); + let mut authors = Vec::new(); - for (index, author) in notebook.value.authors.iter().enumerate() { - let (profile_uri, profile_view) = self.hydrate_profile_view(&author.did).await?; - authors.push( - AuthorListView::new() - .maybe_uri(profile_uri) - .record(profile_view) - .index(index as i64) + for (index, author) in notebook.value.authors.iter().enumerate() { + let (profile_uri, profile_view) = self.hydrate_profile_view(&author.did).await?; + authors.push( + AuthorListView::new() + .maybe_uri(profile_uri) + .record(profile_view) + .index(index as i64) + .build(), + ); + } + let entries = notebook + .value + .entry_list + .iter() + .cloned() + .map(IntoStatic::into_static) + .collect(); + + Ok(( + NotebookView::new() + .cid(notebook.cid.ok_or_else(|| { + AgentError::from(ClientError::invalid_request("Notebook missing CID")) + })?) + .uri(notebook.uri) + .indexed_at(jacquard::types::string::Datetime::now()) + .maybe_title(title) + .maybe_tags(tags) + .authors(authors) + .record(to_data(¬ebook.value).map_err(|_| { + AgentError::from(ClientError::invalid_request( + "Failed to serialize notebook", + )) + })?) .build(), - ); + entries, + )) } - let entries = notebook - .value - .entry_list - .iter() - .cloned() - .map(IntoStatic::into_static) - .collect(); - - Ok(( - NotebookView::new() - .cid(notebook.cid.ok_or_else(|| { - AgentError::from(ClientError::invalid_request("Notebook missing CID")) - })?) - .uri(notebook.uri) - .indexed_at(jacquard::types::string::Datetime::now()) - .maybe_title(title) - .maybe_tags(tags) - .authors(authors) - .record(to_data(¬ebook.value).map_err(|_| { - AgentError::from(ClientError::invalid_request("Failed to serialize notebook")) - })?) - .build(), - entries, - )) } - async fn fetch_entry_view<'a>( + /// Fetch an entry and construct EntryView + fn fetch_entry_view<'a>( &self, notebook: &NotebookView<'a>, entry_ref: &StrongRef<'_>, - ) -> Result, WeaverError> { - use jacquard::to_data; - use weaver_api::sh_weaver::notebook::entry::Entry; - - let entry_uri = Entry::uri(entry_ref.uri.clone()) - .map_err(|_| AgentError::from(ClientError::invalid_request("Invalid entry URI")))?; - let entry = self.fetch_record(&entry_uri).await?; - - let title = entry.value.title.clone(); - let tags = entry.value.tags.clone(); - - Ok(EntryView::new() - .cid(entry.cid.ok_or_else(|| { - AgentError::from(ClientError::invalid_request("Entry missing CID")) - })?) - .uri(entry.uri) - .indexed_at(jacquard::types::string::Datetime::now()) - .record(to_data(&entry.value).map_err(|_| { - AgentError::from(ClientError::invalid_request("Failed to serialize entry")) - })?) - .maybe_tags(tags) - .title(title) - .authors(notebook.authors.clone()) - .build()) + ) -> impl Future, WeaverError>> { + async move { + use jacquard::to_data; + use weaver_api::sh_weaver::notebook::entry::Entry; + + let entry_uri = Entry::uri(entry_ref.uri.clone()) + .map_err(|_| AgentError::from(ClientError::invalid_request("Invalid entry URI")))?; + let entry = self.fetch_record(&entry_uri).await?; + + let title = entry.value.title.clone(); + let tags = entry.value.tags.clone(); + + Ok(EntryView::new() + .cid(entry.cid.ok_or_else(|| { + AgentError::from(ClientError::invalid_request("Entry missing CID")) + })?) + .uri(entry.uri) + .indexed_at(jacquard::types::string::Datetime::now()) + .record(to_data(&entry.value).map_err(|_| { + AgentError::from(ClientError::invalid_request("Failed to serialize entry")) + })?) + .maybe_tags(tags) + .title(title) + .authors(notebook.authors.clone()) + .build()) + } } - async fn entry_by_title<'a>( + /// Search for an entry by title within a notebook's entry list + fn entry_by_title<'a>( &self, notebook: &NotebookView<'a>, entries: &[StrongRef<'_>], title: &str, - ) -> Result, entry::Entry<'a>)>, WeaverError> { - use weaver_api::sh_weaver::notebook::BookEntryRef; - use weaver_api::sh_weaver::notebook::entry::Entry; - - for (index, entry_ref) in entries.iter().enumerate() { - let resp = self - .get_record::(&entry_ref.uri) - .await - .map_err(|e| AgentError::from(e))?; - if let Ok(entry) = resp.parse() { - if entry.value.path == title || entry.value.title == title { - // Build BookEntryView with prev/next - let entry_view = self.fetch_entry_view(notebook, entry_ref).await?; - - let prev_entry = if index > 0 { - let prev_entry_ref = &entries[index - 1]; - self.fetch_entry_view(notebook, prev_entry_ref).await.ok() - } else { - None + ) -> impl Future, entry::Entry<'a>)>, WeaverError>> + { + async move { + use weaver_api::sh_weaver::notebook::BookEntryRef; + use weaver_api::sh_weaver::notebook::entry::Entry; + + for (index, entry_ref) in entries.iter().enumerate() { + let resp = self + .get_record::(&entry_ref.uri) + .await + .map_err(|e| AgentError::from(e))?; + if let Ok(entry) = resp.parse() { + if entry.value.path == title || entry.value.title == title { + // Build BookEntryView with prev/next + let entry_view = self.fetch_entry_view(notebook, entry_ref).await?; + + let prev_entry = if index > 0 { + let prev_entry_ref = &entries[index - 1]; + self.fetch_entry_view(notebook, prev_entry_ref).await.ok() + } else { + None + } + .map(|e| BookEntryRef::new().entry(e).build()); + + let next_entry = if index < entries.len() - 1 { + let next_entry_ref = &entries[index + 1]; + self.fetch_entry_view(notebook, next_entry_ref).await.ok() + } else { + None + } + .map(|e| BookEntryRef::new().entry(e).build()); + + let book_entry_view = BookEntryView::new() + .entry(entry_view) + .maybe_next(next_entry) + .maybe_prev(prev_entry) + .index(index as i64) + .build(); + + return Ok(Some((book_entry_view, entry.value.into_static()))); } - .map(|e| BookEntryRef::new().entry(e).build()); - - let next_entry = if index < entries.len() - 1 { - let next_entry_ref = &entries[index + 1]; - self.fetch_entry_view(notebook, next_entry_ref).await.ok() - } else { - None - } - .map(|e| BookEntryRef::new().entry(e).build()); - - let book_entry_view = BookEntryView::new() - .entry(entry_view) - .maybe_next(next_entry) - .maybe_prev(prev_entry) - .index(index as i64) - .build(); - - return Ok(Some((book_entry_view, entry.value.into_static()))); } } + Ok(None) } - Ok(None) } - async fn notebook_by_title( + /// Search for a notebook by title for a given DID or handle + fn notebook_by_title( &self, ident: &jacquard::types::ident::AtIdentifier<'_>, title: &str, - ) -> Result, Vec>)>, WeaverError> { - use jacquard::types::collection::Collection; - use jacquard::types::nsid::Nsid; - use jacquard::xrpc::XrpcExt; - use weaver_api::com_atproto::repo::list_records::ListRecords; - use weaver_api::sh_weaver::notebook::AuthorListView; - use weaver_api::sh_weaver::notebook::book::Book; - - let (repo_did, pds_url) = match ident { - jacquard::types::ident::AtIdentifier::Did(did) => { - let pds = self.pds_for_did(did).await.map_err(|e| { - AgentError::from( - ClientError::from(e).with_context("Failed to resolve PDS for DID"), - ) - })?; - (did.clone(), pds) - } - jacquard::types::ident::AtIdentifier::Handle(handle) => { - self.pds_for_handle(handle).await.map_err(|e| { - AgentError::from(ClientError::from(e).with_context("Failed to resolve handle")) - })? - } - }; - - // TODO: use the cursor to search through all records with this NSID for the repo - let resp = self - .xrpc(pds_url) - .send( - &ListRecords::new() - .repo(repo_did) - .collection(Nsid::raw(Book::NSID)) - .limit(100) - .build(), - ) - .await - .map_err(|e| AgentError::from(ClientError::from(e)))?; - - if let Ok(list) = resp.parse() { - for record in list.records { - let notebook: Book = jacquard::from_data(&record.value).map_err(|_| { - AgentError::from(ClientError::invalid_request( - "Failed to parse notebook record", - )) - })?; - if let Some(book_title) = notebook.path - && book_title == title - { - let tags = notebook.tags.clone(); - - let mut authors = Vec::new(); - - for (index, author) in notebook.authors.iter().enumerate() { - let (profile_uri, profile_view) = - self.hydrate_profile_view(&author.did).await?; - authors.push( - AuthorListView::new() - .maybe_uri(profile_uri) - .record(profile_view) - .index(index as i64) - .build(), - ); - } - let entries = notebook - .entry_list - .iter() - .cloned() - .map(IntoStatic::into_static) - .collect(); - - return Ok(Some(( - NotebookView::new() - .cid(record.cid) - .uri(record.uri) - .indexed_at(jacquard::types::string::Datetime::now()) - .title(book_title) - .maybe_tags(tags) - .authors(authors) - .record(record.value.clone()) - .build() - .into_static(), - entries, - ))); - } else if let Some(book_title) = notebook.title - && book_title == title - { - let tags = notebook.tags.clone(); - - let mut authors = Vec::new(); - - for (index, author) in notebook.authors.iter().enumerate() { - let (profile_uri, profile_view) = - self.hydrate_profile_view(&author.did).await?; - authors.push( - AuthorListView::new() - .maybe_uri(profile_uri) - .record(profile_view) - .index(index as i64) - .build(), - ); - } - let entries = notebook - .entry_list - .iter() - .cloned() - .map(IntoStatic::into_static) - .collect(); - - return Ok(Some(( - NotebookView::new() - .cid(record.cid) - .uri(record.uri) - .indexed_at(jacquard::types::string::Datetime::now()) - .title(book_title) - .maybe_tags(tags) - .authors(authors) - .record(record.value.clone()) - .build() - .into_static(), - entries, - ))); + ) -> impl Future< + Output = Result, Vec>)>, WeaverError>, + > + where + Self: Sized, + { + async move { + use jacquard::types::collection::Collection; + use jacquard::types::nsid::Nsid; + use weaver_api::com_atproto::repo::list_records::ListRecords; + use weaver_api::sh_weaver::notebook::AuthorListView; + use weaver_api::sh_weaver::notebook::book::Book; + + let (repo_did, pds_url) = match ident { + jacquard::types::ident::AtIdentifier::Did(did) => { + let pds = self.pds_for_did(did).await.map_err(|e| { + AgentError::from( + ClientError::from(e).with_context("Failed to resolve PDS for DID"), + ) + })?; + (did.clone(), pds) } - } - } - - Ok(None) - } + jacquard::types::ident::AtIdentifier::Handle(handle) => { + self.pds_for_handle(handle).await.map_err(|e| { + AgentError::from( + ClientError::from(e).with_context("Failed to resolve handle"), + ) + })? + } + }; - async fn confirm_record_ref(&self, uri: &AtUri<'_>) -> Result, WeaverError> { - let rkey = uri.rkey().ok_or_else(|| { - AgentError::from( - ClientError::invalid_request("AtUri missing rkey") - .with_help("ensure the URI includes a record key after the collection"), - ) - })?; - - // Resolve authority (DID or handle) to get DID and PDS - use jacquard::types::ident::AtIdentifier; - let (repo_did, pds_url) = match uri.authority() { - AtIdentifier::Did(did) => { - let pds = self.pds_for_did(did).await.map_err(|e| { - AgentError::from( - ClientError::from(e) - .with_context("DID document resolution failed during record retrieval"), - ) - })?; - (did.clone(), pds) - } - AtIdentifier::Handle(handle) => self.pds_for_handle(handle).await.map_err(|e| { - AgentError::from( - ClientError::from(e) - .with_context("handle resolution failed during record retrieval"), + // TODO: use the cursor to search through all records with this NSID for the repo + let resp = self + .xrpc(pds_url) + .send( + &ListRecords::new() + .repo(repo_did) + .collection(Nsid::raw(Book::NSID)) + .limit(100) + .build(), ) - })?, - }; - - // Make stateless XRPC call to that PDS (no auth required for public records) - use weaver_api::com_atproto::repo::get_record::GetRecord; - let request = GetRecord::new() - .repo(AtIdentifier::Did(repo_did)) - .collection( - uri.collection() - .expect("collection should exist if rkey does") - .clone(), - ) - .rkey(rkey.clone()) - .build(); - - let response: Response = { - let http_request = xrpc::build_http_request(&pds_url, &request, &self.opts().await) - .map_err(|e| AgentError::from(ClientError::transport(e)))?; - - let http_response = self - .send_http(http_request) .await - .map_err(|e| AgentError::from(ClientError::transport(e)))?; + .map_err(|e| AgentError::from(ClientError::from(e)))?; - xrpc::process_response(http_response) + if let Ok(list) = resp.parse() { + for record in list.records { + let notebook: Book = jacquard::from_data(&record.value).map_err(|_| { + AgentError::from(ClientError::invalid_request( + "Failed to parse notebook record", + )) + })?; + if let Some(book_title) = notebook.path + && book_title == title + { + let tags = notebook.tags.clone(); + + let mut authors = Vec::new(); + + for (index, author) in notebook.authors.iter().enumerate() { + let (profile_uri, profile_view) = + self.hydrate_profile_view(&author.did).await?; + authors.push( + AuthorListView::new() + .maybe_uri(profile_uri) + .record(profile_view) + .index(index as i64) + .build(), + ); + } + let entries = notebook + .entry_list + .iter() + .cloned() + .map(IntoStatic::into_static) + .collect(); + + return Ok(Some(( + NotebookView::new() + .cid(record.cid) + .uri(record.uri) + .indexed_at(jacquard::types::string::Datetime::now()) + .title(book_title) + .maybe_tags(tags) + .authors(authors) + .record(record.value.clone()) + .build() + .into_static(), + entries, + ))); + } else if let Some(book_title) = notebook.title + && book_title == title + { + let tags = notebook.tags.clone(); + + let mut authors = Vec::new(); + + for (index, author) in notebook.authors.iter().enumerate() { + let (profile_uri, profile_view) = + self.hydrate_profile_view(&author.did).await?; + authors.push( + AuthorListView::new() + .maybe_uri(profile_uri) + .record(profile_view) + .index(index as i64) + .build(), + ); + } + let entries = notebook + .entry_list + .iter() + .cloned() + .map(IntoStatic::into_static) + .collect(); + + return Ok(Some(( + NotebookView::new() + .cid(record.cid) + .uri(record.uri) + .indexed_at(jacquard::types::string::Datetime::now()) + .title(book_title) + .maybe_tags(tags) + .authors(authors) + .record(record.value.clone()) + .build() + .into_static(), + entries, + ))); + } + } + } + + Ok(None) } - .map_err(|e| AgentError::new(AgentErrorKind::Client, Some(e.into())))?; - let record = response.parse().map_err(|e| AgentError::xrpc(e))?; - let strong_ref = StrongRef::new() - .uri(record.uri) - .cid(record.cid.expect("when does this NOT have a CID?")) - .build(); - Ok(strong_ref.into_static()) } - async fn hydrate_profile_view( + /// Hydrate a profile view from either weaver or bsky profile + fn hydrate_profile_view( &self, did: &Did<'_>, - ) -> Result< - ( - Option>, - weaver_api::sh_weaver::actor::ProfileDataView<'static>, - ), - WeaverError, + ) -> impl Future< + Output = Result< + ( + Option>, + weaver_api::sh_weaver::actor::ProfileDataView<'static>, + ), + WeaverError, + >, > { - use weaver_api::app_bsky::actor::{ - ProfileViewDetailed, get_profile::GetProfile, profile::Profile as BskyProfile, - }; - use weaver_api::sh_weaver::actor::{ - ProfileDataView, ProfileDataViewInner, ProfileView, profile::Profile as WeaverProfile, - }; - - let handles = self.resolve_did_doc_owned(&did).await?.handles(); - let handle = handles.first().ok_or_else(|| { - AgentError::from(ClientError::invalid_request("couldn't resolve handle")) - })?; - - // Try weaver profile first - let weaver_uri = WeaverProfile::uri(format!("at://{}/sh.weaver.actor.profile/self", did)) - .map_err(|_| { - AgentError::from(ClientError::invalid_request("Invalid weaver profile URI")) - })?; - if let Ok(weaver_record) = self.fetch_record(&weaver_uri).await { - // Convert blobs to CDN URLs - let avatar = weaver_record + async move { + use weaver_api::app_bsky::actor::{ + ProfileViewDetailed, get_profile::GetProfile, profile::Profile as BskyProfile, + }; + use weaver_api::sh_weaver::actor::{ + ProfileDataView, ProfileDataViewInner, ProfileView, + profile::Profile as WeaverProfile, + }; + + let handles = self.resolve_did_doc_owned(&did).await?.handles(); + let handle = handles.first().ok_or_else(|| { + AgentError::from(ClientError::invalid_request("couldn't resolve handle")) + })?; + + // Try weaver profile first + let weaver_uri = + WeaverProfile::uri(format!("at://{}/sh.weaver.actor.profile/self", did)).map_err( + |_| { + AgentError::from(ClientError::invalid_request("Invalid weaver profile URI")) + }, + )?; + if let Ok(weaver_record) = self.fetch_record(&weaver_uri).await { + // Convert blobs to CDN URLs + let avatar = weaver_record + .value + .avatar + .as_ref() + .map(|blob| { + let cid = blob.blob().cid(); + jacquard::types::string::Uri::new_owned(format!( + "https://cdn.bsky.app/img/avatar/plain/{}/{}", + did, cid + )) + }) + .transpose() + .map_err(|_| { + AgentError::from(ClientError::invalid_request("Invalid avatar URI")) + })?; + let banner = weaver_record + .value + .banner + .as_ref() + .map(|blob| { + let cid = blob.blob().cid(); + jacquard::types::string::Uri::new_owned(format!( + "https://cdn.bsky.app/img/banner/plain/{}/{}", + did, cid + )) + }) + .transpose() + .map_err(|_| { + AgentError::from(ClientError::invalid_request("Invalid banner URI")) + })?; + + let profile_view = ProfileView::new() + .did(did.clone()) + .handle(handle.clone()) + .maybe_display_name(weaver_record.value.display_name.clone()) + .maybe_description(weaver_record.value.description.clone()) + .maybe_avatar(avatar) + .maybe_banner(banner) + .maybe_bluesky(weaver_record.value.bluesky) + .maybe_tangled(weaver_record.value.tangled) + .maybe_streamplace(weaver_record.value.streamplace) + .maybe_location(weaver_record.value.location.clone()) + .maybe_links(weaver_record.value.links.clone()) + .maybe_pronouns(weaver_record.value.pronouns.clone()) + .maybe_pinned(weaver_record.value.pinned.clone()) + .indexed_at(jacquard::types::string::Datetime::now()) + .maybe_created_at(weaver_record.value.created_at) + .build(); + + return Ok(( + Some(weaver_uri.as_uri().clone().into_static()), + ProfileDataView::new() + .inner(ProfileDataViewInner::ProfileView(Box::new(profile_view))) + .build() + .into_static(), + )); + } + + if let Ok(bsky_resp) = self + .send(GetProfile::new().actor(did.clone()).build()) + .await + { + if let Ok(output) = bsky_resp.parse() { + let bsky_uri = + BskyProfile::uri(format!("at://{}/app.bsky.actor.profile/self", did)) + .map_err(|_| { + AgentError::from(ClientError::invalid_request( + "Invalid bsky profile URI", + )) + })?; + return Ok(( + Some(bsky_uri.as_uri().clone().into_static()), + ProfileDataView::new() + .inner(ProfileDataViewInner::ProfileViewDetailed(Box::new( + output.value.into_static(), + ))) + .build() + .into_static(), + )); + } + } + + // Fallback: fetch bsky profile record directly and construct minimal ProfileViewDetailed + let bsky_uri = BskyProfile::uri(format!("at://{}/app.bsky.actor.profile/self", did)) + .map_err(|_| { + AgentError::from(ClientError::invalid_request("Invalid bsky profile URI")) + })?; + let bsky_record = self.fetch_record(&bsky_uri).await?; + + let avatar = bsky_record .value .avatar .as_ref() @@ -541,14 +720,14 @@ impl WeaverExt for Agent { .map_err(|_| { AgentError::from(ClientError::invalid_request("Invalid avatar URI")) })?; - let banner = weaver_record + let banner = bsky_record .value .banner .as_ref() .map(|blob| { let cid = blob.blob().cid(); jacquard::types::string::Uri::new_owned(format!( - "https://cdn.bsky.app/img/banner/plain/{}/{}", + "https://cdn.bsky.app/img/feed_fullsize/plain/{}/{}", did, cid )) }) @@ -557,214 +736,141 @@ impl WeaverExt for Agent { AgentError::from(ClientError::invalid_request("Invalid banner URI")) })?; - let profile_view = ProfileView::new() + let profile_detailed = ProfileViewDetailed::new() .did(did.clone()) .handle(handle.clone()) - .maybe_display_name(weaver_record.value.display_name.clone()) - .maybe_description(weaver_record.value.description.clone()) + .maybe_display_name(bsky_record.value.display_name.clone()) + .maybe_description(bsky_record.value.description.clone()) .maybe_avatar(avatar) .maybe_banner(banner) - .maybe_bluesky(weaver_record.value.bluesky) - .maybe_tangled(weaver_record.value.tangled) - .maybe_streamplace(weaver_record.value.streamplace) - .maybe_location(weaver_record.value.location.clone()) - .maybe_links(weaver_record.value.links.clone()) - .maybe_pronouns(weaver_record.value.pronouns.clone()) - .maybe_pinned(weaver_record.value.pinned.clone()) .indexed_at(jacquard::types::string::Datetime::now()) - .maybe_created_at(weaver_record.value.created_at) + .maybe_created_at(bsky_record.value.created_at) .build(); - return Ok(( - Some(weaver_uri.as_uri().clone().into_static()), + Ok(( + Some(bsky_uri.as_uri().clone().into_static()), ProfileDataView::new() - .inner(ProfileDataViewInner::ProfileView(Box::new(profile_view))) + .inner(ProfileDataViewInner::ProfileViewDetailed(Box::new( + profile_detailed, + ))) .build() .into_static(), - )); + )) } - - if let Ok(bsky_resp) = self - .send(GetProfile::new().actor(did.clone()).build()) - .await - { - if let Ok(output) = bsky_resp.parse() { - let bsky_uri = BskyProfile::uri(format!( - "at://{}/app.bsky.actor.profile/self", - did - )) - .map_err(|_| { - AgentError::from(ClientError::invalid_request("Invalid bsky profile URI")) - })?; - return Ok(( - Some(bsky_uri.as_uri().clone().into_static()), - ProfileDataView::new() - .inner(ProfileDataViewInner::ProfileViewDetailed(Box::new( - output.value.into_static(), - ))) - .build() - .into_static(), - )); - } - } - - // Fallback: fetch bsky profile record directly and construct minimal ProfileViewDetailed - let bsky_uri = BskyProfile::uri(format!("at://{}/app.bsky.actor.profile/self", did)) - .map_err(|_| { - AgentError::from(ClientError::invalid_request("Invalid bsky profile URI")) - })?; - let bsky_record = self.fetch_record(&bsky_uri).await?; - - let avatar = bsky_record - .value - .avatar - .as_ref() - .map(|blob| { - let cid = blob.blob().cid(); - jacquard::types::string::Uri::new_owned(format!( - "https://cdn.bsky.app/img/avatar/plain/{}/{}", - did, cid - )) - }) - .transpose() - .map_err(|_| AgentError::from(ClientError::invalid_request("Invalid avatar URI")))?; - let banner = bsky_record - .value - .banner - .as_ref() - .map(|blob| { - let cid = blob.blob().cid(); - jacquard::types::string::Uri::new_owned(format!( - "https://cdn.bsky.app/img/feed_fullsize/plain/{}/{}", - did, cid - )) - }) - .transpose() - .map_err(|_| AgentError::from(ClientError::invalid_request("Invalid banner URI")))?; - - let profile_detailed = ProfileViewDetailed::new() - .did(did.clone()) - .handle(handle.clone()) - .maybe_display_name(bsky_record.value.display_name.clone()) - .maybe_description(bsky_record.value.description.clone()) - .maybe_avatar(avatar) - .maybe_banner(banner) - .indexed_at(jacquard::types::string::Datetime::now()) - .maybe_created_at(bsky_record.value.created_at) - .build(); - - Ok(( - Some(bsky_uri.as_uri().clone().into_static()), - ProfileDataView::new() - .inner(ProfileDataViewInner::ProfileViewDetailed(Box::new( - profile_detailed, - ))) - .build() - .into_static(), - )) } - async fn view_entry<'a>( + /// View an entry at a specific index with prev/next navigation + fn view_entry<'a>( &self, notebook: &NotebookView<'a>, entries: &[StrongRef<'_>], index: usize, - ) -> Result, WeaverError> { - use weaver_api::sh_weaver::notebook::BookEntryRef; - - let entry_ref = entries - .get(index) - .ok_or_else(|| AgentError::from(ClientError::invalid_request("entry out of bounds")))?; - let entry = self.fetch_entry_view(notebook, entry_ref).await?; - - let prev_entry = if index > 0 { - let prev_entry_ref = &entries[index - 1]; - self.fetch_entry_view(notebook, prev_entry_ref).await.ok() - } else { - None - } - .map(|e| BookEntryRef::new().entry(e).build()); + ) -> impl Future, WeaverError>> { + async move { + use weaver_api::sh_weaver::notebook::BookEntryRef; - let next_entry = if index < entries.len() - 1 { - let next_entry_ref = &entries[index + 1]; - self.fetch_entry_view(notebook, next_entry_ref).await.ok() - } else { - None + let entry_ref = entries.get(index).ok_or_else(|| { + AgentError::from(ClientError::invalid_request("entry out of bounds")) + })?; + let entry = self.fetch_entry_view(notebook, entry_ref).await?; + + let prev_entry = if index > 0 { + let prev_entry_ref = &entries[index - 1]; + self.fetch_entry_view(notebook, prev_entry_ref).await.ok() + } else { + None + } + .map(|e| BookEntryRef::new().entry(e).build()); + + let next_entry = if index < entries.len() - 1 { + let next_entry_ref = &entries[index + 1]; + self.fetch_entry_view(notebook, next_entry_ref).await.ok() + } else { + None + } + .map(|e| BookEntryRef::new().entry(e).build()); + + Ok(BookEntryView::new() + .entry(entry) + .maybe_next(next_entry) + .maybe_prev(prev_entry) + .index(index as i64) + .build()) } - .map(|e| BookEntryRef::new().entry(e).build()); - - Ok(BookEntryView::new() - .entry(entry) - .maybe_next(next_entry) - .maybe_prev(prev_entry) - .index(index as i64) - .build()) } - async fn view_page<'a>( + /// View a page at a specific index with prev/next navigation + fn view_page<'a>( &self, notebook: &NotebookView<'a>, pages: &[StrongRef<'_>], index: usize, - ) -> Result, WeaverError> { - use weaver_api::sh_weaver::notebook::BookEntryRef; - - let entry_ref = pages - .get(index) - .ok_or_else(|| AgentError::from(ClientError::invalid_request("entry out of bounds")))?; - let entry = self.fetch_page_view(notebook, entry_ref).await?; - - let prev_entry = if index > 0 { - let prev_entry_ref = &pages[index - 1]; - self.fetch_page_view(notebook, prev_entry_ref).await.ok() - } else { - None - } - .map(|e| BookEntryRef::new().entry(e).build()); + ) -> impl Future, WeaverError>> { + async move { + use weaver_api::sh_weaver::notebook::BookEntryRef; + + let entry_ref = pages.get(index).ok_or_else(|| { + AgentError::from(ClientError::invalid_request("entry out of bounds")) + })?; + let entry = self.fetch_page_view(notebook, entry_ref).await?; + + let prev_entry = if index > 0 { + let prev_entry_ref = &pages[index - 1]; + self.fetch_page_view(notebook, prev_entry_ref).await.ok() + } else { + None + } + .map(|e| BookEntryRef::new().entry(e).build()); - let next_entry = if index < pages.len() - 1 { - let next_entry_ref = &pages[index + 1]; - self.fetch_page_view(notebook, next_entry_ref).await.ok() - } else { - None + let next_entry = if index < pages.len() - 1 { + let next_entry_ref = &pages[index + 1]; + self.fetch_page_view(notebook, next_entry_ref).await.ok() + } else { + None + } + .map(|e| BookEntryRef::new().entry(e).build()); + + Ok(BookEntryView::new() + .entry(entry) + .maybe_next(next_entry) + .maybe_prev(prev_entry) + .index(index as i64) + .build()) } - .map(|e| BookEntryRef::new().entry(e).build()); - - Ok(BookEntryView::new() - .entry(entry) - .maybe_next(next_entry) - .maybe_prev(prev_entry) - .index(index as i64) - .build()) } - async fn fetch_page_view<'a>( + /// Fetch a page view (like fetch_entry_view but for pages) + fn fetch_page_view<'a>( &self, notebook: &NotebookView<'a>, entry_ref: &StrongRef<'_>, - ) -> Result, WeaverError> { - use jacquard::to_data; - use weaver_api::sh_weaver::notebook::page::Page; - - let entry_uri = Page::uri(entry_ref.uri.clone()) - .map_err(|_| AgentError::from(ClientError::invalid_request("Invalid page URI")))?; - let entry = self.fetch_record(&entry_uri).await?; - - let title = entry.value.title.clone(); - let tags = entry.value.tags.clone(); - - Ok(EntryView::new() - .cid(entry.cid.ok_or_else(|| { - AgentError::from(ClientError::invalid_request("Page missing CID")) - })?) - .uri(entry.uri) - .indexed_at(jacquard::types::string::Datetime::now()) - .record(to_data(&entry.value).map_err(|_| { - AgentError::from(ClientError::invalid_request("Failed to serialize page")) - })?) - .maybe_tags(tags) - .title(title) - .authors(notebook.authors.clone()) - .build()) + ) -> impl Future, WeaverError>> { + async move { + use jacquard::to_data; + use weaver_api::sh_weaver::notebook::page::Page; + + let entry_uri = Page::uri(entry_ref.uri.clone()) + .map_err(|_| AgentError::from(ClientError::invalid_request("Invalid page URI")))?; + let entry = self.fetch_record(&entry_uri).await?; + + let title = entry.value.title.clone(); + let tags = entry.value.tags.clone(); + + Ok(EntryView::new() + .cid(entry.cid.ok_or_else(|| { + AgentError::from(ClientError::invalid_request("Page missing CID")) + })?) + .uri(entry.uri) + .indexed_at(jacquard::types::string::Datetime::now()) + .record(to_data(&entry.value).map_err(|_| { + AgentError::from(ClientError::invalid_request("Failed to serialize page")) + })?) + .maybe_tags(tags) + .title(title) + .authors(notebook.authors.clone()) + .build()) + } } } + +impl WeaverExt for T {} diff --git a/crates/weaver-common/src/constellation.rs b/crates/weaver-common/src/constellation.rs index 535e685..cd52245 100644 --- a/crates/weaver-common/src/constellation.rs +++ b/crates/weaver-common/src/constellation.rs @@ -9,6 +9,7 @@ use jacquard::{ use serde::{Deserialize, Serialize}; const DEFAULT_CURSOR_LIMIT: u64 = 16; +#[allow(unused)] const DEFAULT_CURSOR_LIMIT_MAX: u64 = 100; fn get_default_cursor_limit() -> u64 { diff --git a/crates/weaver-common/src/error.rs b/crates/weaver-common/src/error.rs index 79d0dd2..d025911 100644 --- a/crates/weaver-common/src/error.rs +++ b/crates/weaver-common/src/error.rs @@ -56,7 +56,6 @@ pub enum WeaverError { #[derive(thiserror::Error, Debug, Diagnostic)] #[error("parse error: {}",self.kind)] #[diagnostic(code(weaver::parse))] - pub struct ParseError { #[diagnostic_source] kind: ParseErrorKind, diff --git a/crates/weaver-common/src/lib.rs b/crates/weaver-common/src/lib.rs index 439c29c..e4db919 100644 --- a/crates/weaver-common/src/lib.rs +++ b/crates/weaver-common/src/lib.rs @@ -6,169 +6,21 @@ pub mod error; pub mod worker_rt; // Re-export jacquard for convenience +pub use agent::WeaverExt; pub use error::WeaverError; pub use jacquard; use jacquard::CowStr; -use jacquard::bytes::Bytes; -use jacquard::client::{Agent, AgentSession, AgentSessionExt}; +use jacquard::client::{Agent, AgentSession}; use jacquard::prelude::*; use jacquard::types::ident::AtIdentifier; use jacquard::types::string::{AtUri, Cid, Did, Handle}; -use jacquard::types::tid::{Ticker, Tid}; -use std::path::Path; +use jacquard::types::tid::Ticker; use std::sync::LazyLock; use tokio::sync::Mutex; use weaver_api::com_atproto::repo::strong_ref::StrongRef; -use weaver_api::sh_weaver::notebook::entry; -use weaver_api::sh_weaver::publish::blob::Blob as PublishedBlob; static W_TICKER: LazyLock> = LazyLock::new(|| Mutex::new(Ticker::new())); -/// Extension trait providing weaver-specific multi-step operations on Agent -/// -/// This trait extends jacquard's Agent with notebook-specific workflows that -/// involve multiple atproto operations (uploading blobs, creating records, etc.) -/// -/// For single-step operations, use jacquard's built-in methods directly: -/// - `agent.create_record()` - Create a single record -/// - `agent.get_record()` - Get a single record -/// - `agent.upload_blob()` - Upload a single blob -/// -/// This trait is for multi-step workflows that coordinate between multiple operations. -//#[trait_variant::make(Send)] -pub trait WeaverExt: AgentSessionExt { - /// Publish a notebook directory to the user's PDS - /// - /// Multi-step workflow: - /// 1. Parse markdown files in directory - /// 2. Extract and upload images/assets → BlobRefs - /// 3. Transform markdown refs to point at uploaded blobs - /// 4. Create entry records for each file - /// 5. Create book record with entry refs - /// - /// Returns the AT-URI of the published book - fn publish_notebook( - &self, - path: &Path, - ) -> impl Future, WeaverError>>; - - /// Publish a blob to the user's PDS - /// - /// Multi-step workflow: - /// 1. Upload blob to PDS - /// 2. Create blob record with CID - /// - /// Returns the AT-URI of the published blob - fn publish_blob<'a>( - &self, - blob: Bytes, - url_path: &'a str, - prev: Option, - ) -> impl Future, PublishedBlob<'a>), WeaverError>>; - - fn confirm_record_ref( - &self, - uri: &AtUri<'_>, - ) -> impl Future, WeaverError>>; - - /// Find or create a notebook by title, returning its URI and entry list - /// - /// If the notebook doesn't exist, creates it with the given DID as author. - fn upsert_notebook( - &self, - title: &str, - author_did: &Did<'_>, - ) -> impl Future, Vec>), WeaverError>>; - - /// Find or create an entry within a notebook by title - /// - /// Multi-step workflow: - /// 1. Find the notebook by title - /// 2. Check notebook's entry_list for entry with matching title - /// 3. If found: update the entry with new content - /// 4. If not found: create new entry and append to notebook's entry_list - /// - /// Returns (entry_uri, was_created) - fn upsert_entry( - &self, - notebook_title: &str, - entry_title: &str, - entry: entry::Entry<'_>, - ) -> impl Future, bool), WeaverError>>; - - /// View functions - generic versions that work with any Agent - - /// Fetch a notebook and construct NotebookView with author profiles - fn view_notebook( - &self, - uri: &AtUri<'_>, - ) -> impl Future, Vec>), WeaverError>>; - - /// Fetch an entry and construct EntryView - fn fetch_entry_view<'a>( - &self, - notebook: &agent::NotebookView<'a>, - entry_ref: &StrongRef<'_>, - ) -> impl Future, WeaverError>>; - - /// Search for an entry by title within a notebook's entry list - fn entry_by_title<'a>( - &self, - notebook: &agent::NotebookView<'a>, - entries: &[StrongRef<'_>], - title: &str, - ) -> impl Future, entry::Entry<'a>)>, WeaverError>>; - - /// Search for a notebook by title for a given DID or handle - fn notebook_by_title( - &self, - ident: &jacquard::types::ident::AtIdentifier<'_>, - title: &str, - ) -> impl Future< - Output = Result< - Option<(agent::NotebookView<'static>, Vec>)>, - WeaverError, - >, - >; - - /// Hydrate a profile view from either weaver or bsky profile - fn hydrate_profile_view( - &self, - did: &Did<'_>, - ) -> impl Future< - Output = Result< - ( - Option>, - weaver_api::sh_weaver::actor::ProfileDataView<'static>, - ), - WeaverError, - >, - >; - - /// View an entry at a specific index with prev/next navigation - fn view_entry<'a>( - &self, - notebook: &agent::NotebookView<'a>, - entries: &[StrongRef<'_>], - index: usize, - ) -> impl Future, WeaverError>>; - - /// View a page at a specific index with prev/next navigation - fn view_page<'a>( - &self, - notebook: &agent::NotebookView<'a>, - pages: &[StrongRef<'_>], - index: usize, - ) -> impl Future, WeaverError>>; - - /// Fetch a page view (like fetch_entry_view but for pages) - fn fetch_page_view<'a>( - &self, - notebook: &agent::NotebookView<'a>, - entry_ref: &StrongRef<'_>, - ) -> impl Future, WeaverError>>; -} - /// Result of publishing a notebook #[derive(Debug, Clone)] pub struct PublishResult<'a> { -- knotmirror 0.0.1