atproto blogging
1use crate::auth::AuthStore;
2use crate::cache_impl;
3use dioxus::Result;
4use jacquard::AuthorizationToken;
5use jacquard::CowStr;
6use jacquard::IntoStatic;
7use jacquard::client::Agent;
8use jacquard::client::AgentError;
9use jacquard::client::AgentKind;
10use jacquard::error::ClientError;
11use jacquard::error::XrpcResult;
12use jacquard::from_data;
13use jacquard::from_data_owned;
14use jacquard::identity::JacquardResolver;
15use jacquard::identity::lexicon_resolver::{
16 LexiconResolutionError, LexiconSchemaResolver, ResolvedLexiconSchema,
17};
18use jacquard::identity::resolver::DidDocResponse;
19use jacquard::identity::resolver::IdentityError;
20use jacquard::identity::resolver::ResolverOptions;
21use jacquard::oauth::client::OAuthClient;
22use jacquard::oauth::client::OAuthSession;
23use jacquard::prelude::*;
24use jacquard::types::string::Did;
25use jacquard::types::string::Handle;
26use jacquard::types::string::Nsid;
27use jacquard::xrpc::XrpcResponse;
28use jacquard::xrpc::*;
29use jacquard::{
30 smol_str::{SmolStr, format_smolstr},
31 types::aturi::AtUri,
32 types::ident::AtIdentifier,
33};
34use serde::{Deserialize, Serialize};
35use std::future::Future;
36use std::{sync::Arc, time::Duration};
37use tokio::sync::RwLock;
38use weaver_api::app_bsky::actor::get_profile::GetProfile;
39use weaver_api::app_bsky::actor::profile::Profile as BskyProfile;
40use weaver_api::sh_weaver::actor::ProfileDataViewInner;
41use weaver_api::sh_weaver::notebook::EntryView;
42use weaver_api::{
43 com_atproto::repo::strong_ref::StrongRef,
44 sh_weaver::{
45 actor::ProfileDataView,
46 notebook::{BookEntryView, NotebookView, entry::Entry},
47 },
48};
49use weaver_common::WeaverError;
50use weaver_common::WeaverExt;
51use weaver_common::agent::title_matches;
52
53#[derive(Debug, Clone, Deserialize, Serialize)]
54struct UfosRecord {
55 collection: String,
56 did: String,
57 record: serde_json::Value,
58 rkey: String,
59 time_us: u64,
60}
61
62/// Data for a standalone entry (may or may not have notebook context)
63#[derive(Clone, PartialEq)]
64pub struct StandaloneEntryData {
65 pub entry: Entry<'static>,
66 pub entry_view: EntryView<'static>,
67 /// Present if entry is in exactly one notebook
68 pub notebook_context: Option<NotebookContext>,
69}
70
71/// Notebook context for an entry
72#[derive(Clone, PartialEq)]
73pub struct NotebookContext {
74 pub notebook: NotebookView<'static>,
75 /// BookEntryView with prev/next navigation
76 pub book_entry_view: BookEntryView<'static>,
77}
78
79/// Data for a WhiteWind blog entry
80#[derive(Clone, PartialEq)]
81pub struct WhiteWindEntryData {
82 pub entry: weaver_api::com_whtwnd::blog::entry::Entry<'static>,
83 pub profile: ProfileDataView<'static>,
84}
85
86/// Data for a Leaflet document
87#[derive(Clone, PartialEq)]
88pub struct LeafletDocumentData {
89 pub document: weaver_api::pub_leaflet::document::Document<'static>,
90 pub profile: ProfileDataView<'static>,
91 /// Publication base_path for constructing external URL (e.g., "connectedplaces.leaflet.pub")
92 pub publication_base_path: Option<String>,
93 /// Pre-rendered HTML content
94 pub rendered_html: Option<String>,
95}
96
97/// Data for a site.standard / blog.pckt document
98#[cfg(feature = "pckt")]
99#[derive(Clone, PartialEq)]
100pub struct PcktDocumentData {
101 pub document: weaver_api::site_standard::document::Document<'static>,
102 pub profile: ProfileDataView<'static>,
103 /// Publication URL for constructing external URL (e.g., "https://crypto.pckt.blog")
104 pub publication_url: Option<String>,
105 /// Pre-rendered HTML content
106 pub rendered_html: Option<String>,
107}
108
109pub struct Client {
110 pub oauth_client: Arc<OAuthClient<JacquardResolver, AuthStore>>,
111 pub session: RwLock<Option<Arc<Agent<OAuthSession<JacquardResolver, AuthStore>>>>>,
112}
113
114impl Client {
115 pub fn new(oauth_client: OAuthClient<JacquardResolver, AuthStore>) -> Self {
116 Self {
117 oauth_client: Arc::new(oauth_client),
118 session: RwLock::new(None),
119 }
120 }
121}
122
123impl HttpClient for Client {
124 type Error = IdentityError;
125
126 #[cfg(not(target_arch = "wasm32"))]
127 fn send_http(
128 &self,
129 request: http::Request<Vec<u8>>,
130 ) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>> + Send
131 {
132 self.oauth_client.send_http(request)
133 }
134
135 #[cfg(target_arch = "wasm32")]
136 fn send_http(
137 &self,
138 request: http::Request<Vec<u8>>,
139 ) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>> {
140 self.oauth_client.send_http(request)
141 }
142}
143
144impl XrpcClient for Client {
145 #[doc = " Get the base URI for the client."]
146 fn base_uri(&self) -> impl Future<Output = CowStr<'static>> + Send {
147 async {
148 let guard = self.session.read().await;
149 if let Some(session) = guard.clone() {
150 session.base_uri().await
151 } else {
152 // When unauthenticated, use index if configured
153 #[cfg(feature = "use-index")]
154 if !crate::env::WEAVER_INDEXER_URL.is_empty() {
155 return CowStr::from(crate::env::WEAVER_INDEXER_URL);
156 }
157 self.oauth_client.base_uri().await
158 }
159 }
160 }
161
162 #[doc = " Send an XRPC request and parse the response"]
163 #[cfg(not(target_arch = "wasm32"))]
164 fn send<R>(&self, request: R) -> impl Future<Output = XrpcResult<XrpcResponse<R>>> + Send
165 where
166 R: XrpcRequest + Send + Sync,
167 <R as XrpcRequest>::Response: Send + Sync,
168 Self: Sync,
169 {
170 async {
171 let guard = self.session.read().await;
172 if let Some(session) = guard.clone() {
173 session.send(request).await
174 } else {
175 self.oauth_client.send(request).await
176 }
177 }
178 }
179
180 #[doc = " Send an XRPC request and parse the response"]
181 #[cfg(not(target_arch = "wasm32"))]
182 fn send_with_opts<R>(
183 &self,
184 request: R,
185 opts: CallOptions<'_>,
186 ) -> impl Future<Output = XrpcResult<XrpcResponse<R>>> + Send
187 where
188 R: XrpcRequest + Send + Sync,
189 <R as XrpcRequest>::Response: Send + Sync,
190 Self: Sync,
191 {
192 async {
193 let guard = self.session.read().await;
194 if let Some(session) = guard.clone() {
195 session.send_with_opts(request, opts).await
196 } else {
197 self.oauth_client.send_with_opts(request, opts).await
198 }
199 }
200 }
201
202 #[doc = " Send an XRPC request and parse the response"]
203 #[cfg(target_arch = "wasm32")]
204 fn send<R>(&self, request: R) -> impl Future<Output = XrpcResult<XrpcResponse<R>>>
205 where
206 R: XrpcRequest + Send + Sync,
207 <R as XrpcRequest>::Response: Send + Sync,
208 {
209 async {
210 let guard = self.session.read().await;
211 if let Some(session) = guard.clone() {
212 session.send(request).await
213 } else {
214 self.oauth_client.send(request).await
215 }
216 }
217 }
218
219 #[doc = " Send an XRPC request and parse the response"]
220 #[cfg(target_arch = "wasm32")]
221 fn send_with_opts<R>(
222 &self,
223 request: R,
224 opts: CallOptions<'_>,
225 ) -> impl Future<Output = XrpcResult<XrpcResponse<R>>>
226 where
227 R: XrpcRequest + Send + Sync,
228 <R as XrpcRequest>::Response: Send + Sync,
229 {
230 async {
231 let guard = self.session.read().await;
232 if let Some(session) = guard.clone() {
233 session.send_with_opts(request, opts).await
234 } else {
235 self.oauth_client.send_with_opts(request, opts).await
236 }
237 }
238 }
239
240 #[doc = " Set the base URI for the client."]
241 fn set_base_uri(&self, url: jacquard::url::Url) -> impl Future<Output = ()> + Send {
242 async {
243 let guard = self.session.read().await;
244 if let Some(session) = guard.clone() {
245 session.set_base_uri(url).await
246 } else {
247 self.oauth_client.set_base_uri(url).await
248 }
249 }
250 }
251
252 #[doc = " Get the call options for the client."]
253 fn opts(&self) -> impl Future<Output = CallOptions<'_>> + Send {
254 async {
255 let guard = self.session.read().await;
256 if let Some(session) = guard.clone() {
257 session.opts().await.into_static()
258 } else {
259 self.oauth_client.opts().await
260 }
261 }
262 }
263
264 #[doc = " Set the call options for the client."]
265 fn set_opts(&self, opts: CallOptions) -> impl Future<Output = ()> + Send {
266 async {
267 let guard = self.session.read().await;
268 if let Some(session) = guard.clone() {
269 session.set_opts(opts).await
270 } else {
271 self.oauth_client.set_opts(opts).await
272 }
273 }
274 }
275}
276
277impl IdentityResolver for Client {
278 #[doc = " Access options for validation decisions in default methods"]
279 fn options(&self) -> &ResolverOptions {
280 self.oauth_client.client.options()
281 }
282
283 #[doc = " Resolve handle"]
284 #[cfg(not(target_arch = "wasm32"))]
285 fn resolve_handle(
286 &self,
287 handle: &Handle<'_>,
288 ) -> impl Future<Output = core::result::Result<Did<'static>, IdentityError>> + Send
289 where
290 Self: Sync,
291 {
292 self.oauth_client.client.resolve_handle(handle)
293 }
294
295 #[doc = " Resolve DID document"]
296 #[cfg(not(target_arch = "wasm32"))]
297 fn resolve_did_doc(
298 &self,
299 did: &Did<'_>,
300 ) -> impl Future<Output = core::result::Result<DidDocResponse, IdentityError>> + Send
301 where
302 Self: Sync,
303 {
304 self.oauth_client.client.resolve_did_doc(did)
305 }
306
307 #[doc = " Resolve handle"]
308 #[cfg(target_arch = "wasm32")]
309 fn resolve_handle(
310 &self,
311 handle: &Handle<'_>,
312 ) -> impl Future<Output = core::result::Result<Did<'static>, IdentityError>> {
313 self.oauth_client.client.resolve_handle(handle)
314 }
315
316 #[doc = " Resolve DID document"]
317 #[cfg(target_arch = "wasm32")]
318 fn resolve_did_doc(
319 &self,
320 did: &Did<'_>,
321 ) -> impl Future<Output = core::result::Result<DidDocResponse, IdentityError>> {
322 self.oauth_client.client.resolve_did_doc(did)
323 }
324}
325
326impl LexiconSchemaResolver for Client {
327 #[cfg(not(target_arch = "wasm32"))]
328 async fn resolve_lexicon_schema(
329 &self,
330 nsid: &Nsid<'_>,
331 ) -> std::result::Result<ResolvedLexiconSchema<'static>, LexiconResolutionError> {
332 self.oauth_client.client.resolve_lexicon_schema(nsid).await
333 }
334
335 #[cfg(target_arch = "wasm32")]
336 async fn resolve_lexicon_schema(
337 &self,
338 nsid: &Nsid<'_>,
339 ) -> std::result::Result<ResolvedLexiconSchema<'static>, LexiconResolutionError> {
340 self.oauth_client.client.resolve_lexicon_schema(nsid).await
341 }
342}
343
344impl AgentSession for Client {
345 #[doc = " Identify the kind of session."]
346 fn session_kind(&self) -> AgentKind {
347 self.oauth_client.session_kind()
348 }
349
350 #[doc = " Return current DID and an optional session id (always Some for OAuth)."]
351 async fn session_info(&self) -> Option<(Did<'static>, Option<CowStr<'static>>)> {
352 let guard = self.session.read().await;
353 if let Some(session) = guard.clone() {
354 session.info().await
355 } else {
356 None
357 }
358 }
359
360 #[doc = " Current base endpoint."]
361 async fn endpoint(&self) -> CowStr<'static> {
362 let guard = self.session.read().await;
363 if let Some(session) = guard.clone() {
364 session.endpoint().await
365 } else {
366 self.oauth_client.endpoint().await
367 }
368 }
369
370 #[doc = " Override per-session call options."]
371 async fn set_options<'a>(&'a self, opts: CallOptions<'a>) {
372 let guard = self.session.read().await;
373 if let Some(session) = guard.clone() {
374 session.set_options(opts).await
375 } else {
376 self.oauth_client.set_options(opts).await
377 }
378 }
379
380 #[doc = " Refresh the session and return a fresh AuthorizationToken."]
381 async fn refresh(&self) -> XrpcResult<AuthorizationToken<'static>> {
382 let guard = self.session.read().await;
383 if let Some(session) = guard.clone() {
384 session.refresh().await
385 } else {
386 Err(ClientError::auth(
387 jacquard::error::AuthError::NotAuthenticated,
388 ))
389 }
390 }
391}
392
393#[derive(Clone)]
394pub struct Fetcher {
395 pub client: Arc<Client>,
396 #[cfg(feature = "server")]
397 book_cache: cache_impl::Cache<
398 (AtIdentifier<'static>, SmolStr),
399 Arc<(NotebookView<'static>, Vec<BookEntryView<'static>>)>,
400 >,
401 /// Maps notebook title OR path to ident (book_cache accepts either as key)
402 #[cfg(feature = "server")]
403 notebook_key_cache: cache_impl::Cache<SmolStr, AtIdentifier<'static>>,
404 #[cfg(feature = "server")]
405 entry_cache: cache_impl::Cache<
406 (AtIdentifier<'static>, SmolStr),
407 Arc<(BookEntryView<'static>, Entry<'static>)>,
408 >,
409 #[cfg(feature = "server")]
410 profile_cache: cache_impl::Cache<AtIdentifier<'static>, Arc<ProfileDataView<'static>>>,
411 #[cfg(feature = "server")]
412 standalone_entry_cache:
413 cache_impl::Cache<(AtIdentifier<'static>, SmolStr), Arc<StandaloneEntryData>>,
414}
415
416impl Fetcher {
417 pub fn new(client: OAuthClient<JacquardResolver, AuthStore>) -> Self {
418 // Set indexer URL for unauthenticated requests
419 #[cfg(feature = "use-index")]
420 if !crate::env::WEAVER_INDEXER_URL.is_empty() {
421 if let Ok(url) = jacquard::url::Url::parse(crate::env::WEAVER_INDEXER_URL) {
422 if let Ok(mut guard) = client.endpoint.try_write() {
423 use jacquard::cowstr::ToCowStr;
424
425 *guard = Some(url.to_cowstr().into_static());
426 }
427 }
428 }
429
430 Self {
431 client: Arc::new(Client::new(client)),
432 #[cfg(feature = "server")]
433 book_cache: cache_impl::new_cache(100, std::time::Duration::from_secs(30)),
434 #[cfg(feature = "server")]
435 notebook_key_cache: cache_impl::new_cache(500, std::time::Duration::from_secs(30)),
436 #[cfg(feature = "server")]
437 entry_cache: cache_impl::new_cache(100, std::time::Duration::from_secs(30)),
438 #[cfg(feature = "server")]
439 profile_cache: cache_impl::new_cache(100, std::time::Duration::from_secs(1800)),
440 #[cfg(feature = "server")]
441 standalone_entry_cache: cache_impl::new_cache(100, std::time::Duration::from_secs(30)),
442 }
443 }
444
445 pub async fn upgrade_to_authenticated(
446 &self,
447 session: OAuthSession<JacquardResolver, crate::auth::AuthStore>,
448 ) {
449 let agent = Arc::new(Agent::new(session));
450
451 // When use-index is enabled, set the atproto_proxy header for service proxying
452 #[cfg(feature = "use-index")]
453 if !crate::env::WEAVER_INDEXER_DID.is_empty() {
454 let proxy_value = format!("{}#atproto_index", crate::env::WEAVER_INDEXER_DID);
455 let mut opts = agent.opts().await;
456 opts.atproto_proxy = Some(CowStr::from(proxy_value));
457 agent.set_opts(opts).await;
458 }
459
460 let mut session_slot = self.client.session.write().await;
461 *session_slot = Some(agent);
462 }
463
464 pub async fn downgrade_to_unauthenticated(&self) {
465 let mut session_slot = self.client.session.write().await;
466 if let Some(session) = session_slot.take() {
467 session.inner().logout().await.ok();
468 }
469 }
470
471 #[allow(dead_code)]
472 pub async fn current_did(&self) -> Option<Did<'static>> {
473 let session_slot = self.client.session.read().await;
474 if let Some(session) = session_slot.as_ref() {
475 session.info().await.map(|(d, _)| d)
476 } else {
477 None
478 }
479 }
480
481 pub fn get_client(&self) -> Arc<Client> {
482 self.client.clone()
483 }
484
485 pub async fn get_notebook(
486 &self,
487 ident: AtIdentifier<'static>,
488 title: SmolStr,
489 ) -> Result<Option<Arc<(NotebookView<'static>, Vec<BookEntryView<'static>>)>>> {
490 #[cfg(feature = "server")]
491 if let Some(cached) = cache_impl::get(&self.book_cache, &(ident.clone(), title.clone())) {
492 return Ok(Some(cached));
493 }
494
495 let client = self.get_client();
496 if let Some((notebook, entries)) = client
497 .notebook_by_title(&ident, &title)
498 .await
499 .map_err(|e| dioxus::CapturedError::from_display(e))?
500 {
501 let stored = Arc::new((notebook, entries));
502 #[cfg(feature = "server")]
503 {
504 // Cache by title
505 cache_impl::insert(&self.notebook_key_cache, title.clone(), ident.clone());
506 cache_impl::insert(&self.book_cache, (ident.clone(), title), stored.clone());
507 // Also cache by path if available
508 if let Some(path) = stored.0.path.as_ref() {
509 let path: SmolStr = path.as_ref().into();
510 cache_impl::insert(&self.notebook_key_cache, path.clone(), ident.clone());
511 cache_impl::insert(&self.book_cache, (ident, path), stored.clone());
512 }
513 }
514 Ok(Some(stored))
515 } else {
516 Err(dioxus::CapturedError::from_display("Notebook not found"))
517 }
518 }
519
520 /// Get notebook by title or path (for image resolution without knowing owner).
521 /// Checks notebook_key_cache first, falls back to UFOS discovery.
522 #[cfg(feature = "server")]
523 pub async fn get_notebook_by_key(
524 &self,
525 key: &str,
526 ) -> Result<Option<Arc<(NotebookView<'static>, Vec<BookEntryView<'static>>)>>> {
527 let key: SmolStr = key.into();
528
529 // Check cache first (key could be title or path)
530 if let Some(ident) = cache_impl::get(&self.notebook_key_cache, &key) {
531 return self.get_notebook(ident, key).await;
532 }
533
534 // Fallback: query UFOS and populate caches
535 let notebooks = self.fetch_notebooks_from_ufos().await?;
536 let notebook = notebooks.into_iter().find(|arc| {
537 let (view, _) = arc.as_ref();
538 view.title.as_deref() == Some(key.as_str())
539 || view.path.as_deref() == Some(key.as_str())
540 });
541 if let Some(notebook) = notebook {
542 let ident = notebook.0.uri.authority().clone().into_static();
543 return self.get_notebook(ident, key).await;
544 }
545 Ok(None)
546 }
547
548 pub async fn get_entry(
549 &self,
550 ident: AtIdentifier<'static>,
551 book_title: SmolStr,
552 entry_title: SmolStr,
553 ) -> Result<Option<Arc<(BookEntryView<'static>, Entry<'static>)>>> {
554 #[cfg(feature = "server")]
555 if let Some(cached) =
556 cache_impl::get(&self.entry_cache, &(ident.clone(), entry_title.clone()))
557 {
558 return Ok(Some(cached));
559 }
560
561 if let Some(result) = self.get_notebook(ident.clone(), book_title).await? {
562 let (notebook, entries) = result.as_ref();
563 if let Some(entry) = entries.iter().find(|e| {
564 if let Some(path) = e.entry.path.as_deref() {
565 path == entry_title.as_str()
566 } else if let Some(title) = e.entry.title.as_deref() {
567 title_matches(title, &entry_title)
568 } else {
569 false
570 }
571 }) {
572 let stored = Arc::new((
573 entry.clone(),
574 from_data_owned(entry.entry.record.clone()).expect("should deserialize"),
575 ));
576 #[cfg(feature = "server")]
577 cache_impl::insert(&self.entry_cache, (ident, entry_title), stored.clone());
578 Ok(Some(stored))
579 } else {
580 Err(dioxus::CapturedError::from_display("Entry not found"))
581 }
582 } else {
583 Err(dioxus::CapturedError::from_display("Notebook not found"))
584 }
585 }
586
587 #[cfg(feature = "use-index")]
588 pub async fn fetch_notebooks_from_ufos(
589 &self,
590 ) -> Result<Vec<Arc<(NotebookView<'static>, Vec<StrongRef<'static>>)>>> {
591 use weaver_api::sh_weaver::notebook::book::Book;
592 use weaver_api::sh_weaver::notebook::get_notebook_feed::GetNotebookFeed;
593
594 let client = self.get_client();
595
596 let resp = client
597 .send(GetNotebookFeed::new().limit(100).build())
598 .await
599 .map_err(|e| dioxus::CapturedError::from_display(e))?;
600
601 let output = resp
602 .into_output()
603 .map_err(|e| dioxus::CapturedError::from_display(e))?;
604
605 let mut notebooks = Vec::new();
606
607 for notebook in output.notebooks {
608 // Extract entry_list from the record
609 let book: Book = jacquard::from_data(¬ebook.record)
610 .map_err(|e| dioxus::CapturedError::from_display(e))?;
611 let book = book.into_static();
612
613 let entries: Vec<StrongRef<'static>> = book
614 .entry_list
615 .into_iter()
616 .map(IntoStatic::into_static)
617 .collect();
618
619 let ident = notebook.uri.authority().clone().into_static();
620 let title = notebook
621 .title
622 .as_ref()
623 .map(|t| SmolStr::new(t.as_ref()))
624 .unwrap_or_else(|| SmolStr::new("Untitled"));
625
626 let result = Arc::new((notebook.into_static(), entries));
627 #[cfg(feature = "server")]
628 {
629 cache_impl::insert(&self.notebook_key_cache, title.clone(), ident.clone());
630 #[cfg(not(feature = "use-index"))]
631 cache_impl::insert(&self.book_cache, (ident.clone(), title), result.clone());
632
633 if let Some(path) = result.0.path.as_ref() {
634 let path: SmolStr = path.as_ref().into();
635 cache_impl::insert(&self.notebook_key_cache, path.clone(), ident.clone());
636 #[cfg(not(feature = "use-index"))]
637 cache_impl::insert(&self.book_cache, (ident, path), result.clone());
638 }
639 }
640 notebooks.push(result);
641 }
642
643 Ok(notebooks)
644 }
645
646 #[cfg(not(feature = "use-index"))]
647 pub async fn fetch_notebooks_from_ufos(
648 &self,
649 ) -> Result<Vec<Arc<(NotebookView<'static>, Vec<StrongRef<'static>>)>>> {
650 use jacquard::{IntoStatic, types::aturi::AtUri};
651
652 let url = "https://ufos-api.microcosm.blue/records?collection=sh.weaver.notebook.book";
653 let response = reqwest::get(url)
654 .await
655 .map_err(|e| dioxus::CapturedError::from_display(e))?;
656
657 let records: Vec<UfosRecord> = response
658 .json()
659 .await
660 .map_err(|e| dioxus::CapturedError::from_display(e))?;
661
662 let mut notebooks = Vec::new();
663 let client = self.get_client();
664
665 for ufos_record in records {
666 // Construct URI
667 let uri_str = format_smolstr!(
668 "at://{}/{}/{}",
669 ufos_record.did,
670 ufos_record.collection,
671 ufos_record.rkey
672 );
673 let uri = AtUri::new_owned(uri_str).map_err(|e| {
674 dioxus::CapturedError::from_display(format_smolstr!("Invalid URI: {}", e).as_str())
675 })?;
676 match client.view_notebook(&uri).await {
677 Ok((notebook, entries)) => {
678 let ident = uri.authority().clone().into_static();
679 let title = notebook
680 .title
681 .as_ref()
682 .map(|t| SmolStr::new(t.as_ref()))
683 .unwrap_or_else(|| SmolStr::new("Untitled"));
684
685 let result = Arc::new((notebook, entries));
686 #[cfg(feature = "server")]
687 {
688 // Cache by title
689 cache_impl::insert(&self.notebook_key_cache, title.clone(), ident.clone());
690
691 #[cfg(not(feature = "use-index"))]
692 cache_impl::insert(
693 &self.book_cache,
694 (ident.clone(), title),
695 result.clone(),
696 );
697 // Also cache by path if available
698 if let Some(path) = result.0.path.as_ref() {
699 let path: SmolStr = path.as_ref().into();
700 cache_impl::insert(
701 &self.notebook_key_cache,
702 path.clone(),
703 ident.clone(),
704 );
705
706 #[cfg(not(feature = "use-index"))]
707 cache_impl::insert(&self.book_cache, (ident, path), result.clone());
708 }
709 }
710 notebooks.push(result);
711 }
712 Err(_) => continue, // Skip notebooks that fail to load
713 }
714 }
715
716 Ok(notebooks)
717 }
718
719 /// Fetch entries from index feed (reverse chronological)
720 #[cfg(feature = "use-index")]
721 pub async fn fetch_entries_from_ufos(
722 &self,
723 ) -> Result<Vec<Arc<(EntryView<'static>, Entry<'static>, u64)>>> {
724 use jacquard::IntoStatic;
725 use weaver_api::sh_weaver::notebook::entry::Entry;
726 use weaver_api::sh_weaver::notebook::get_entry_feed::GetEntryFeed;
727
728 let client = self.get_client();
729
730 let resp = client
731 .send(GetEntryFeed::new().limit(100).build())
732 .await
733 .map_err(|e| dioxus::CapturedError::from_display(e))?;
734
735 let output = resp
736 .into_output()
737 .map_err(|e| dioxus::CapturedError::from_display(e))?;
738
739 let mut entries = Vec::new();
740
741 for feed_entry in output.feed {
742 let entry_view = feed_entry.entry;
743 // indexed_at is ISO datetime, parse to get millisecond timestamp
744 let timestamp = chrono::DateTime::parse_from_rfc3339(entry_view.indexed_at.as_str())
745 .map(|dt| dt.timestamp_millis() as u64)
746 .unwrap_or(0);
747
748 let entry: Entry = jacquard::from_data(&entry_view.record)
749 .map_err(|e| dioxus::CapturedError::from_display(e))?;
750 let entry = entry.into_static();
751
752 entries.push(Arc::new((entry_view.into_static(), entry, timestamp)));
753 }
754
755 Ok(entries)
756 }
757
758 /// Fetch entries from UFOS discovery service (reverse chronological)
759 #[cfg(not(feature = "use-index"))]
760 pub async fn fetch_entries_from_ufos(
761 &self,
762 ) -> Result<Vec<Arc<(EntryView<'static>, Entry<'static>, u64)>>> {
763 use jacquard::{IntoStatic, types::aturi::AtUri, types::ident::AtIdentifier};
764
765 let url = "https://ufos-api.microcosm.blue/records?collection=sh.weaver.notebook.entry";
766
767 let response = reqwest::get(url).await.map_err(|e| {
768 tracing::error!("[fetch_entries_from_ufos] request failed: {:?}", e);
769 dioxus::CapturedError::from_display(e)
770 })?;
771
772 let mut records: Vec<UfosRecord> = response.json().await.map_err(|e| {
773 tracing::error!("[fetch_entries_from_ufos] json parse failed: {:?}", e);
774 dioxus::CapturedError::from_display(e)
775 })?;
776 records.sort_by(|a, b| b.time_us.cmp(&a.time_us));
777
778 let mut entries = Vec::new();
779 let client = self.get_client();
780
781 for ufos_record in records {
782 let did = match Did::new(&ufos_record.did) {
783 Ok(d) => d.into_static(),
784 Err(e) => {
785 tracing::warn!(
786 "[fetch_entries_from_ufos] invalid DID {}: {:?}",
787 ufos_record.did,
788 e
789 );
790 continue;
791 }
792 };
793 let ident = AtIdentifier::Did(did);
794 match client.fetch_entry_by_rkey(&ident, &ufos_record.rkey).await {
795 Ok((entry_view, entry)) => {
796 entries.push(Arc::new((
797 entry_view.into_static(),
798 entry.into_static(),
799 ufos_record.time_us,
800 )));
801 }
802 Err(e) => {
803 tracing::warn!(
804 "[fetch_entries_from_ufos] failed to load entry {}: {:?}",
805 ufos_record.rkey,
806 e
807 );
808 continue;
809 }
810 }
811 }
812
813 Ok(entries)
814 }
815
816 #[cfg(feature = "use-index")]
817 pub async fn fetch_notebooks_for_did(
818 &self,
819 ident: &AtIdentifier<'_>,
820 ) -> Result<Vec<Arc<(NotebookView<'static>, Vec<StrongRef<'static>>)>>> {
821 use weaver_api::sh_weaver::actor::get_actor_notebooks::GetActorNotebooks;
822 use weaver_api::sh_weaver::notebook::book::Book;
823
824 let client = self.get_client();
825
826 let resp = client
827 .send(
828 GetActorNotebooks::new()
829 .actor(ident.clone())
830 .limit(100)
831 .build(),
832 )
833 .await
834 .map_err(|e| dioxus::CapturedError::from_display(e))?;
835
836 let output = resp
837 .into_output()
838 .map_err(|e| dioxus::CapturedError::from_display(e))?;
839
840 let mut notebooks = Vec::new();
841
842 for notebook in output.notebooks {
843 // Extract entry_list from the record
844 let book: Book = jacquard::from_data(¬ebook.record)
845 .map_err(|e| dioxus::CapturedError::from_display(e))?;
846 let book = book.into_static();
847
848 let entries: Vec<StrongRef<'static>> = book
849 .entry_list
850 .into_iter()
851 .map(IntoStatic::into_static)
852 .collect();
853
854 let ident_static = notebook.uri.authority().clone().into_static();
855 let title = notebook
856 .title
857 .as_ref()
858 .map(|t| SmolStr::new(t.as_ref()))
859 .unwrap_or_else(|| SmolStr::new("Untitled"));
860
861 let result = Arc::new((notebook.into_static(), entries));
862 #[cfg(feature = "server")]
863 {
864 cache_impl::insert(
865 &self.notebook_key_cache,
866 title.clone(),
867 ident_static.clone(),
868 );
869 if let Some(path) = result.0.path.as_ref() {
870 let path: SmolStr = path.as_ref().into();
871 cache_impl::insert(
872 &self.notebook_key_cache,
873 path.clone(),
874 ident_static.clone(),
875 );
876 }
877 }
878 notebooks.push(result);
879 }
880
881 Ok(notebooks)
882 }
883
884 #[cfg(not(feature = "use-index"))]
885 pub async fn fetch_notebooks_for_did(
886 &self,
887 ident: &AtIdentifier<'_>,
888 ) -> Result<Vec<Arc<(NotebookView<'static>, Vec<StrongRef<'static>>)>>> {
889 use jacquard::{
890 IntoStatic,
891 types::{collection::Collection, nsid::Nsid},
892 xrpc::XrpcExt,
893 };
894 use weaver_api::{
895 com_atproto::repo::list_records::ListRecords, sh_weaver::notebook::book::Book,
896 };
897
898 let client = self.get_client();
899
900 // Resolve DID and PDS
901 let (repo_did, pds_url) = match ident {
902 AtIdentifier::Did(did) => {
903 let pds = client
904 .pds_for_did(did)
905 .await
906 .map_err(|e| dioxus::CapturedError::from_display(e))?;
907 (did.clone(), pds)
908 }
909 AtIdentifier::Handle(handle) => client
910 .pds_for_handle(handle)
911 .await
912 .map_err(|e| dioxus::CapturedError::from_display(e))?,
913 };
914
915 // Fetch all notebook records for this repo
916 tracing::info!(
917 "fetch_notebooks_for_did: pds_url={}, repo_did={}",
918 pds_url,
919 repo_did
920 );
921
922 let resp = client
923 .xrpc(pds_url.clone())
924 .send(
925 &ListRecords::new()
926 .repo(repo_did)
927 .collection(Nsid::raw(Book::NSID))
928 .limit(100)
929 .build(),
930 )
931 .await
932 .map_err(|e| {
933 tracing::error!(
934 "fetch_notebooks_for_did: xrpc failed: {} pds url {}",
935 e,
936 pds_url
937 );
938 dioxus::CapturedError::from_display(e)
939 })?;
940
941 let mut notebooks = Vec::new();
942
943 if let Ok(list) = resp.parse() {
944 for record in list.records {
945 // View the notebook (which hydrates authors)
946 match client.view_notebook(&record.uri).await {
947 Ok((notebook, entries)) => {
948 let ident = record.uri.authority().clone().into_static();
949 let title = notebook
950 .title
951 .as_ref()
952 .map(|t| SmolStr::new(t.as_ref()))
953 .unwrap_or_else(|| SmolStr::new("Untitled"));
954
955 let result = Arc::new((notebook, entries));
956 #[cfg(feature = "server")]
957 {
958 // Cache by title
959 cache_impl::insert(
960 &self.notebook_key_cache,
961 title.clone(),
962 ident.clone(),
963 );
964 cache_impl::insert(
965 &self.book_cache,
966 (ident.clone(), title),
967 result.clone(),
968 );
969 // Also cache by path if available
970 if let Some(path) = result.0.path.as_ref() {
971 let path: SmolStr = path.as_ref().into();
972 cache_impl::insert(
973 &self.notebook_key_cache,
974 path.clone(),
975 ident.clone(),
976 );
977 cache_impl::insert(&self.book_cache, (ident, path), result.clone());
978 }
979 }
980 notebooks.push(result);
981 }
982 Err(e) => {
983 tracing::warn!(
984 "fetch_notebooks_for_did: view_notebook failed for {}: {}",
985 record.uri,
986 e
987 );
988 continue;
989 }
990 }
991 }
992 }
993 Ok(notebooks)
994 }
995
996 /// Fetch all entries for a DID (for profile timeline)
997 #[cfg(feature = "use-index")]
998 pub async fn fetch_entries_for_did(
999 &self,
1000 ident: &AtIdentifier<'_>,
1001 ) -> Result<Vec<Arc<(EntryView<'static>, Entry<'static>)>>> {
1002 use weaver_api::sh_weaver::actor::get_actor_entries::GetActorEntries;
1003
1004 let client = self.get_client();
1005
1006 let resp = client
1007 .send(
1008 GetActorEntries::new()
1009 .actor(ident.clone())
1010 .limit(100)
1011 .build(),
1012 )
1013 .await
1014 .map_err(|e| dioxus::CapturedError::from_display(e))?;
1015
1016 let output = resp
1017 .into_output()
1018 .map_err(|e| dioxus::CapturedError::from_display(e))?;
1019
1020 let mut entries = Vec::new();
1021
1022 for entry_view in output.entries {
1023 // Deserialize Entry from the record field
1024 let entry: Entry = jacquard::from_data(&entry_view.record)
1025 .map_err(|e| dioxus::CapturedError::from_display(e))?;
1026 let entry = entry.into_static();
1027
1028 entries.push(Arc::new((entry_view.into_static(), entry)));
1029 }
1030
1031 Ok(entries)
1032 }
1033
1034 /// Fetch all entries for a DID (for profile timeline)
1035 #[cfg(not(feature = "use-index"))]
1036 pub async fn fetch_entries_for_did(
1037 &self,
1038 ident: &AtIdentifier<'_>,
1039 ) -> Result<Vec<Arc<(EntryView<'static>, Entry<'static>)>>> {
1040 use jacquard::{
1041 IntoStatic,
1042 types::{collection::Collection, nsid::Nsid},
1043 xrpc::XrpcExt,
1044 };
1045 use weaver_api::com_atproto::repo::list_records::ListRecords;
1046
1047 let client = self.get_client();
1048
1049 // Resolve DID and PDS
1050 let (repo_did, pds_url) = match ident {
1051 AtIdentifier::Did(did) => {
1052 let pds = client
1053 .pds_for_did(did)
1054 .await
1055 .map_err(|e| dioxus::CapturedError::from_display(e))?;
1056 (did.clone(), pds)
1057 }
1058 AtIdentifier::Handle(handle) => client
1059 .pds_for_handle(handle)
1060 .await
1061 .map_err(|e| dioxus::CapturedError::from_display(e))?,
1062 };
1063
1064 // Fetch all entry records for this repo
1065 let resp = client
1066 .xrpc(pds_url)
1067 .send(
1068 &ListRecords::new()
1069 .repo(repo_did)
1070 .collection(Nsid::raw(Entry::NSID))
1071 .limit(100)
1072 .build(),
1073 )
1074 .await
1075 .map_err(|e| dioxus::CapturedError::from_display(e))?;
1076
1077 let mut entries = Vec::new();
1078 let ident_static = ident.clone().into_static();
1079
1080 if let Ok(list) = resp.parse() {
1081 for record in list.records {
1082 // Extract rkey from URI
1083 let rkey = record.uri.rkey().map(|r| r.0.as_str()).unwrap_or_default();
1084
1085 // Fetch the entry with hydration
1086 match client.fetch_entry_by_rkey(&ident_static, rkey).await {
1087 Ok((entry_view, entry)) => {
1088 entries.push(Arc::new((entry_view.into_static(), entry.into_static())));
1089 }
1090 Err(e) => {
1091 tracing::warn!(
1092 "[fetch_entries_for_did] failed to load entry {}: {:?}",
1093 rkey,
1094 e
1095 );
1096 continue;
1097 }
1098 }
1099 }
1100 }
1101
1102 Ok(entries)
1103 }
1104
1105 pub async fn list_notebook_entries(
1106 &self,
1107 ident: AtIdentifier<'static>,
1108 book_title: SmolStr,
1109 ) -> Result<Option<Vec<BookEntryView<'static>>>> {
1110 if let Some(result) = self.get_notebook(ident.clone(), book_title).await? {
1111 Ok(Some(result.as_ref().1.clone()))
1112 } else {
1113 Err(dioxus::CapturedError::from_display("Notebook not found"))
1114 }
1115 }
1116
1117 pub async fn fetch_profile(
1118 &self,
1119 ident: &AtIdentifier<'_>,
1120 ) -> Result<Arc<ProfileDataView<'static>>> {
1121 #[cfg(feature = "server")]
1122 use jacquard::IntoStatic;
1123
1124 #[cfg(feature = "server")]
1125 let ident_static = ident.clone().into_static();
1126
1127 #[cfg(feature = "server")]
1128 if let Some(cached) = cache_impl::get(&self.profile_cache, &ident_static) {
1129 return Ok(cached);
1130 }
1131
1132 let client = self.get_client();
1133
1134 let (_uri, profile_view) = client
1135 .hydrate_profile_view(&ident)
1136 .await
1137 .map_err(|e| dioxus::CapturedError::from_display(e))?;
1138
1139 let result = Arc::new(profile_view);
1140 #[cfg(feature = "server")]
1141 cache_impl::insert(&self.profile_cache, ident_static, result.clone());
1142
1143 Ok(result)
1144 }
1145
1146 /// Fetch an entry by rkey with optional notebook context lookup.
1147 pub async fn get_entry_by_rkey(
1148 &self,
1149 ident: AtIdentifier<'static>,
1150 rkey: SmolStr,
1151 ) -> Result<Option<Arc<StandaloneEntryData>>> {
1152 use jacquard::types::aturi::AtUri;
1153
1154 #[cfg(feature = "server")]
1155 if let Some(cached) =
1156 cache_impl::get(&self.standalone_entry_cache, &(ident.clone(), rkey.clone()))
1157 {
1158 return Ok(Some(cached));
1159 }
1160
1161 let client = self.get_client();
1162
1163 // Fetch entry directly by rkey
1164 let (entry_view, entry) = client
1165 .fetch_entry_by_rkey(&ident, &rkey)
1166 .await
1167 .map_err(|e| dioxus::CapturedError::from_display(e))?;
1168
1169 // Try to find notebook context via constellation
1170 let entry_uri = entry_view.uri.clone();
1171 let at_uri = AtUri::new(entry_uri.as_ref()).map_err(|e| {
1172 dioxus::CapturedError::from_display(
1173 format_smolstr!("Invalid entry URI: {}", e).as_str(),
1174 )
1175 })?;
1176
1177 let (total, first_notebook) = client
1178 .find_notebooks_for_entry(&at_uri)
1179 .await
1180 .map_err(|e| dioxus::CapturedError::from_display(e))?;
1181
1182 // Only provide notebook context if entry is in exactly one notebook
1183 let notebook_context = if total == 1 {
1184 if let Some(notebook_id) = first_notebook {
1185 // Construct notebook URI from RecordId
1186 let notebook_uri_str = format_smolstr!(
1187 "at://{}/{}/{}",
1188 notebook_id.did.as_str(),
1189 notebook_id.collection.as_str(),
1190 notebook_id.rkey.0.as_str()
1191 );
1192 let notebook_uri = AtUri::new_owned(notebook_uri_str).map_err(|e| {
1193 dioxus::CapturedError::from_display(
1194 format_smolstr!("Invalid notebook URI: {}", e).as_str(),
1195 )
1196 })?;
1197
1198 // Fetch notebook and find entry position
1199 if let Ok((notebook, entries)) = client.view_notebook(¬ebook_uri).await {
1200 if let Ok(Some(book_entry_view)) = client
1201 .entry_in_notebook_by_rkey(¬ebook, &entries, &rkey)
1202 .await
1203 {
1204 Some(NotebookContext {
1205 notebook: notebook.into_static(),
1206 book_entry_view: book_entry_view.into_static(),
1207 })
1208 } else {
1209 None
1210 }
1211 } else {
1212 None
1213 }
1214 } else {
1215 None
1216 }
1217 } else {
1218 None
1219 };
1220
1221 let result = Arc::new(StandaloneEntryData {
1222 entry,
1223 entry_view,
1224 notebook_context,
1225 });
1226 #[cfg(feature = "server")]
1227 cache_impl::insert(&self.standalone_entry_cache, (ident, rkey), result.clone());
1228
1229 Ok(Some(result))
1230 }
1231
1232 /// Fetch an entry by rkey within a specific notebook context.
1233 ///
1234 /// The book_title parameter provides the notebook context.
1235 /// Returns BookEntryView without prev/next if entry is in multiple notebooks.
1236 pub async fn get_notebook_entry_by_rkey(
1237 &self,
1238 ident: AtIdentifier<'static>,
1239 book_title: SmolStr,
1240 rkey: SmolStr,
1241 ) -> Result<Option<Arc<(BookEntryView<'static>, Entry<'static>)>>> {
1242 use jacquard::types::aturi::AtUri;
1243
1244 #[cfg(feature = "server")]
1245 if let Some(cached) = cache_impl::get(&self.entry_cache, &(ident.clone(), rkey.clone())) {
1246 return Ok(Some(cached));
1247 }
1248
1249 let client = self.get_client();
1250
1251 // Fetch entry directly by rkey
1252 let (entry_view, entry) = client
1253 .fetch_entry_by_rkey(&ident, &rkey)
1254 .await
1255 .map_err(|e| dioxus::CapturedError::from_display(e))?;
1256
1257 // Fetch notebook by title
1258 let notebook_result = client
1259 .notebook_by_title(&ident, &book_title)
1260 .await
1261 .map_err(|e| dioxus::CapturedError::from_display(e))?;
1262
1263 let (notebook, entries) = match notebook_result {
1264 Some((n, e)) => (n, e),
1265 None => return Err(dioxus::CapturedError::from_display("Notebook not found")),
1266 };
1267
1268 // Find entry position in notebook
1269 let book_entry_view = entries
1270 .iter()
1271 .find(|e| e.entry.uri.rkey().as_ref().map(|k| k.as_ref()) == Some(rkey.as_ref()));
1272
1273 let mut book_entry_view = match book_entry_view {
1274 Some(bev) => bev.clone(),
1275 None => {
1276 // Entry not in this notebook's entry list - return basic view without nav
1277 use weaver_api::sh_weaver::notebook::BookEntryView;
1278 BookEntryView::new().entry(entry_view).index(0).build()
1279 }
1280 };
1281
1282 // Check if entry is in multiple notebooks - if so, clear prev/next
1283 let entry_uri = book_entry_view.entry.uri.clone();
1284 let at_uri = AtUri::new(entry_uri.as_ref()).map_err(|e| {
1285 dioxus::CapturedError::from_display(
1286 format_smolstr!("Invalid entry URI: {}", e).as_str(),
1287 )
1288 })?;
1289
1290 let (total, _) = client
1291 .find_notebooks_for_entry(&at_uri)
1292 .await
1293 .map_err(|e| dioxus::CapturedError::from_display(e))?;
1294
1295 if total >= 2 {
1296 // Entry is in multiple notebooks - clear prev/next to avoid ambiguity
1297 book_entry_view = BookEntryView::new()
1298 .entry(book_entry_view.entry)
1299 .index(book_entry_view.index)
1300 .build();
1301 }
1302
1303 let result = Arc::new((book_entry_view.into_static(), entry));
1304 #[cfg(feature = "server")]
1305 cache_impl::insert(&self.entry_cache, (ident, rkey), result.clone());
1306
1307 Ok(Some(result))
1308 }
1309}
1310
1311impl HttpClient for Fetcher {
1312 type Error = IdentityError;
1313
1314 #[cfg(not(target_arch = "wasm32"))]
1315 fn send_http(
1316 &self,
1317 request: http::Request<Vec<u8>>,
1318 ) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>> + Send
1319 {
1320 async {
1321 let client = self.get_client();
1322 client.send_http(request).await
1323 }
1324 }
1325
1326 #[cfg(target_arch = "wasm32")]
1327 fn send_http(
1328 &self,
1329 request: http::Request<Vec<u8>>,
1330 ) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>> {
1331 async {
1332 let client = self.get_client();
1333 client.send_http(request).await
1334 }
1335 }
1336}
1337
1338impl XrpcClient for Fetcher {
1339 #[doc = " Get the base URI for the client."]
1340 fn base_uri(&self) -> impl Future<Output = CowStr<'static>> + Send {
1341 self.client.base_uri()
1342 }
1343
1344 #[doc = " Send an XRPC request and parse the response"]
1345 #[cfg(not(target_arch = "wasm32"))]
1346 fn send<R>(&self, request: R) -> impl Future<Output = XrpcResult<XrpcResponse<R>>> + Send
1347 where
1348 R: XrpcRequest + Send + Sync,
1349 <R as XrpcRequest>::Response: Send + Sync,
1350 Self: Sync,
1351 {
1352 self.client.send(request)
1353 }
1354
1355 #[doc = " Send an XRPC request and parse the response"]
1356 #[cfg(not(target_arch = "wasm32"))]
1357 fn send_with_opts<R>(
1358 &self,
1359 request: R,
1360 opts: CallOptions<'_>,
1361 ) -> impl Future<Output = XrpcResult<XrpcResponse<R>>> + Send
1362 where
1363 R: XrpcRequest + Send + Sync,
1364 <R as XrpcRequest>::Response: Send + Sync,
1365 Self: Sync,
1366 {
1367 self.client.send_with_opts(request, opts)
1368 }
1369
1370 #[doc = " Send an XRPC request and parse the response"]
1371 #[cfg(target_arch = "wasm32")]
1372 fn send<R>(&self, request: R) -> impl Future<Output = XrpcResult<XrpcResponse<R>>>
1373 where
1374 R: XrpcRequest + Send + Sync,
1375 <R as XrpcRequest>::Response: Send + Sync,
1376 {
1377 self.client.send(request)
1378 }
1379
1380 #[doc = " Send an XRPC request and parse the response"]
1381 #[cfg(target_arch = "wasm32")]
1382 fn send_with_opts<R>(
1383 &self,
1384 request: R,
1385 opts: CallOptions<'_>,
1386 ) -> impl Future<Output = XrpcResult<XrpcResponse<R>>>
1387 where
1388 R: XrpcRequest + Send + Sync,
1389 <R as XrpcRequest>::Response: Send + Sync,
1390 {
1391 self.client.send_with_opts(request, opts)
1392 }
1393
1394 #[doc = " Set the base URI for the client."]
1395 fn set_base_uri(&self, url: jacquard::url::Url) -> impl Future<Output = ()> + Send {
1396 self.client.set_base_uri(url)
1397 }
1398
1399 #[doc = " Get the call options for the client."]
1400 fn opts(&self) -> impl Future<Output = CallOptions<'_>> + Send {
1401 self.client.opts()
1402 }
1403
1404 #[doc = " Set the call options for the client."]
1405 fn set_opts(&self, opts: CallOptions) -> impl Future<Output = ()> + Send {
1406 self.client.set_opts(opts)
1407 }
1408}
1409
1410impl IdentityResolver for Fetcher {
1411 #[doc = " Access options for validation decisions in default methods"]
1412 fn options(&self) -> &ResolverOptions {
1413 self.client.options()
1414 }
1415
1416 #[doc = " Resolve handle"]
1417 #[cfg(not(target_arch = "wasm32"))]
1418 fn resolve_handle(
1419 &self,
1420 handle: &Handle<'_>,
1421 ) -> impl Future<Output = core::result::Result<Did<'static>, IdentityError>> + Send
1422 where
1423 Self: Sync,
1424 {
1425 self.client.resolve_handle(handle)
1426 }
1427
1428 #[doc = " Resolve DID document"]
1429 #[cfg(not(target_arch = "wasm32"))]
1430 fn resolve_did_doc(
1431 &self,
1432 did: &Did<'_>,
1433 ) -> impl Future<Output = core::result::Result<DidDocResponse, IdentityError>> + Send
1434 where
1435 Self: Sync,
1436 {
1437 self.client.resolve_did_doc(did)
1438 }
1439
1440 #[doc = " Resolve handle"]
1441 #[cfg(target_arch = "wasm32")]
1442 fn resolve_handle(
1443 &self,
1444 handle: &Handle<'_>,
1445 ) -> impl Future<Output = core::result::Result<Did<'static>, IdentityError>> {
1446 self.client.resolve_handle(handle)
1447 }
1448
1449 #[doc = " Resolve DID document"]
1450 #[cfg(target_arch = "wasm32")]
1451 fn resolve_did_doc(
1452 &self,
1453 did: &Did<'_>,
1454 ) -> impl Future<Output = core::result::Result<DidDocResponse, IdentityError>> {
1455 self.client.resolve_did_doc(did)
1456 }
1457}
1458
1459impl LexiconSchemaResolver for Fetcher {
1460 #[cfg(not(target_arch = "wasm32"))]
1461 async fn resolve_lexicon_schema(
1462 &self,
1463 nsid: &Nsid<'_>,
1464 ) -> std::result::Result<ResolvedLexiconSchema<'static>, LexiconResolutionError> {
1465 self.client.resolve_lexicon_schema(nsid).await
1466 }
1467
1468 #[cfg(target_arch = "wasm32")]
1469 async fn resolve_lexicon_schema(
1470 &self,
1471 nsid: &Nsid<'_>,
1472 ) -> std::result::Result<ResolvedLexiconSchema<'static>, LexiconResolutionError> {
1473 self.client.resolve_lexicon_schema(nsid).await
1474 }
1475}
1476
1477// ============================================================================
1478// Collaboration & Edit methods (use-index gated)
1479// ============================================================================
1480
1481impl Fetcher {
1482 /// Get edit history for a resource from weaver-index.
1483 ///
1484 /// Returns edit roots and diffs for the given resource URI.
1485 #[cfg(feature = "use-index")]
1486 pub async fn get_edit_history(
1487 &self,
1488 resource_uri: &AtUri<'_>,
1489 ) -> Result<weaver_api::sh_weaver::edit::get_edit_history::GetEditHistoryOutput<'static>> {
1490 use weaver_api::sh_weaver::edit::get_edit_history::GetEditHistory;
1491
1492 let client = self.get_client();
1493 let resp = client
1494 .send(GetEditHistory::new().resource(resource_uri.clone()).build())
1495 .await
1496 .map_err(|e| dioxus::CapturedError::from_display(e))?;
1497
1498 resp.into_output()
1499 .map(|o| o.into_static())
1500 .map_err(|e| dioxus::CapturedError::from_display(e))
1501 }
1502
1503 /// List drafts for an actor from weaver-index.
1504 #[cfg(feature = "use-index")]
1505 pub async fn list_drafts(
1506 &self,
1507 actor: &AtIdentifier<'_>,
1508 ) -> Result<weaver_api::sh_weaver::edit::list_drafts::ListDraftsOutput<'static>> {
1509 use weaver_api::sh_weaver::edit::list_drafts::ListDrafts;
1510
1511 let client = self.get_client();
1512 let resp = client
1513 .send(ListDrafts::new().actor(actor.clone()).build())
1514 .await
1515 .map_err(|e| dioxus::CapturedError::from_display(e))?;
1516
1517 resp.into_output()
1518 .map(|o| o.into_static())
1519 .map_err(|e| dioxus::CapturedError::from_display(e))
1520 }
1521
1522 /// Get resource sessions from weaver-index.
1523 ///
1524 /// Returns active collaboration sessions for the given resource.
1525 #[cfg(feature = "use-index")]
1526 pub async fn get_resource_sessions(
1527 &self,
1528 resource_uri: &AtUri<'_>,
1529 ) -> Result<
1530 weaver_api::sh_weaver::collab::get_resource_sessions::GetResourceSessionsOutput<'static>,
1531 > {
1532 use weaver_api::sh_weaver::collab::get_resource_sessions::GetResourceSessions;
1533
1534 let client = self.get_client();
1535 let resp = client
1536 .send(
1537 GetResourceSessions::new()
1538 .resource(resource_uri.clone())
1539 .build(),
1540 )
1541 .await
1542 .map_err(|e| dioxus::CapturedError::from_display(e))?;
1543
1544 resp.into_output()
1545 .map(|o| o.into_static())
1546 .map_err(|e| dioxus::CapturedError::from_display(e))
1547 }
1548
1549 /// Get resource participants from weaver-index.
1550 ///
1551 /// Returns owner and collaborators who can edit the resource.
1552 #[cfg(feature = "use-index")]
1553 pub async fn get_resource_participants(
1554 &self,
1555 resource_uri: &AtUri<'_>,
1556 ) -> Result<
1557 weaver_api::sh_weaver::collab::get_resource_participants::GetResourceParticipantsOutput<
1558 'static,
1559 >,
1560 > {
1561 use weaver_api::sh_weaver::collab::get_resource_participants::GetResourceParticipants;
1562
1563 let client = self.get_client();
1564 let resp = client
1565 .send(
1566 GetResourceParticipants::new()
1567 .resource(resource_uri.clone())
1568 .build(),
1569 )
1570 .await
1571 .map_err(|e| dioxus::CapturedError::from_display(e))?;
1572
1573 resp.into_output()
1574 .map(|o| o.into_static())
1575 .map_err(|e| dioxus::CapturedError::from_display(e))
1576 }
1577
1578 /// Get contributors for a resource from weaver-index.
1579 #[cfg(feature = "use-index")]
1580 pub async fn get_contributors(
1581 &self,
1582 resource_uri: &AtUri<'_>,
1583 ) -> Result<weaver_api::sh_weaver::edit::get_contributors::GetContributorsOutput<'static>> {
1584 use weaver_api::sh_weaver::edit::get_contributors::GetContributors;
1585
1586 let client = self.get_client();
1587 let resp = client
1588 .send(
1589 GetContributors::new()
1590 .resource(resource_uri.clone())
1591 .build(),
1592 )
1593 .await
1594 .map_err(|e| dioxus::CapturedError::from_display(e))?;
1595
1596 resp.into_output()
1597 .map(|o| o.into_static())
1598 .map_err(|e| dioxus::CapturedError::from_display(e))
1599 }
1600}
1601
1602impl AgentSession for Fetcher {
1603 #[doc = " Identify the kind of session."]
1604 fn session_kind(&self) -> AgentKind {
1605 self.client.session_kind()
1606 }
1607
1608 #[doc = " Return current DID and an optional session id (always Some for OAuth)."]
1609 async fn session_info(&self) -> Option<(Did<'static>, Option<CowStr<'static>>)> {
1610 self.client.session_info().await
1611 }
1612
1613 async fn endpoint(&self) -> CowStr<'static> {
1614 self.client.endpoint().await
1615 }
1616
1617 async fn set_options<'a>(&'a self, opts: CallOptions<'a>) {
1618 self.client.set_options(opts).await
1619 }
1620
1621 async fn refresh(&self) -> XrpcResult<AuthorizationToken<'static>> {
1622 self.client.refresh().await
1623 }
1624}