atproto blogging
1//! Feature-gated data fetching layer that abstracts over SSR and client-only modes.
2//!
3//! In fullstack-server mode, hooks use `use_server_future` with inline async closures.
4//! In client-only mode, hooks use `use_resource` with context-provided fetchers.
5
6use crate::auth::AuthState;
7#[cfg(feature = "server")]
8use crate::blobcache::BlobCache;
9use dioxus::prelude::*;
10#[cfg(feature = "fullstack-server")]
11#[allow(unused_imports)]
12use dioxus::{CapturedError, fullstack::extract::Extension};
13use jacquard::{
14 IntoStatic,
15 types::{aturi::AtUri, did::Did, string::Handle},
16};
17#[allow(unused_imports)]
18use jacquard::{
19 client::AgentSessionExt,
20 identity::resolver::IdentityError,
21 prelude::IdentityResolver,
22 smol_str::{SmolStr, format_smolstr},
23 types::{cid::Cid, string::AtIdentifier},
24};
25#[allow(unused_imports)]
26use std::sync::Arc;
27use weaver_api::com_atproto::repo::strong_ref::StrongRef;
28use weaver_api::sh_weaver::actor::ProfileDataView;
29use weaver_api::sh_weaver::notebook::{BookEntryView, EntryView, NotebookView, entry::Entry};
30use weaver_common::ResolvedContent;
31// ============================================================================
32// Wrapper Hooks (feature-gated)
33// ============================================================================
34
35/// Fetches entry data with SSR support in fullstack mode.
36#[cfg(feature = "fullstack-server")]
37pub fn use_entry_data(
38 ident: ReadSignal<AtIdentifier<'static>>,
39 book_title: ReadSignal<SmolStr>,
40 title: ReadSignal<SmolStr>,
41) -> (
42 Result<Resource<Option<(serde_json::Value, serde_json::Value)>>, RenderError>,
43 Memo<Option<(BookEntryView<'static>, Entry<'static>)>>,
44) {
45 let fetcher = use_context::<crate::fetch::Fetcher>();
46 let fetcher = fetcher.clone();
47 let res = use_server_future(use_reactive!(|(ident, book_title, title)| {
48 let fetcher = fetcher.clone();
49 async move {
50 let fetch_result = fetcher.get_entry(ident(), book_title(), title()).await;
51
52 match fetch_result {
53 Ok(Some(entry)) => {
54 let (_book_entry_view, entry_record) = (&entry.0, &entry.1);
55 if let Some(embeds) = &entry_record.embeds {
56 if let Some(images) = &embeds.images {
57 let ident_val = ident.clone();
58 let images = images.clone();
59 for image in &images.images {
60 use jacquard::smol_str::ToSmolStr;
61
62 let cid = image.image.blob().cid();
63 cache_blob(
64 ident_val.to_smolstr(),
65 cid.to_smolstr(),
66 image.name.as_ref().map(|n| n.to_smolstr()),
67 )
68 .await
69 .ok();
70 }
71 }
72 }
73 Some((
74 serde_json::to_value(entry.0.clone()).unwrap(),
75 serde_json::to_value(entry.1.clone()).unwrap(),
76 ))
77 }
78 Ok(None) => None,
79 Err(e) => {
80 tracing::error!(
81 "[use_entry_data] fetch error for {}/{}/{}: {:?}",
82 ident(),
83 book_title(),
84 title(),
85 e
86 );
87 None
88 }
89 }
90 }
91 }));
92 let memo = use_memo(use_reactive!(|res| {
93 let res = res.as_ref().ok()?;
94 if let Some(Some((ev, e))) = &*res.read() {
95 use jacquard::from_json_value;
96
97 let book_entry = from_json_value::<BookEntryView>(ev.clone()).unwrap();
98 let entry = from_json_value::<Entry>(e.clone()).unwrap();
99 Some((book_entry, entry))
100 } else {
101 None
102 }
103 }));
104 (res, memo)
105}
106/// Fetches entry data client-side only (no SSR).
107#[cfg(not(feature = "fullstack-server"))]
108pub fn use_entry_data(
109 ident: ReadSignal<AtIdentifier<'static>>,
110 book_title: ReadSignal<SmolStr>,
111 title: ReadSignal<SmolStr>,
112) -> (
113 Resource<Option<(BookEntryView<'static>, Entry<'static>)>>,
114 Memo<Option<(BookEntryView<'static>, Entry<'static>)>>,
115) {
116 let fetcher = use_context::<crate::fetch::Fetcher>();
117 let fetcher = fetcher.clone();
118 let res = use_resource(move || {
119 let fetcher = fetcher.clone();
120 async move {
121 if let Some(entry) = fetcher
122 .get_entry(ident(), book_title(), title())
123 .await
124 .ok()
125 .flatten()
126 {
127 let (_book_entry_view, entry_record) = (&entry.0, &entry.1);
128 if let Some(embeds) = &entry_record.embeds {
129 if let Some(images) = &embeds.images {
130 #[cfg(all(target_family = "wasm", target_os = "unknown",))]
131 {
132 let _ = crate::service_worker::register_entry_blobs(
133 &ident(),
134 book_title().as_str(),
135 images,
136 &fetcher,
137 )
138 .await;
139 }
140 }
141 }
142 Some((entry.0.clone(), entry.1.clone()))
143 } else {
144 None
145 }
146 }
147 });
148 let memo = use_memo(move || res.read().clone().flatten());
149 (res, memo)
150}
151
152#[cfg(feature = "fullstack-server")]
153pub fn use_get_handle(did: Did<'static>) -> Memo<AtIdentifier<'static>> {
154 let ident = use_signal(use_reactive!(|did| AtIdentifier::Did(did.clone())));
155 let old_ident = ident.read().clone();
156 let fetcher = use_context::<crate::fetch::Fetcher>();
157 let fetcher = fetcher.clone();
158 let res = use_resource(move || {
159 let client = fetcher.get_client();
160 let old_ident = old_ident.clone();
161 async move {
162 client
163 .resolve_ident_owned(&*ident.read())
164 .await
165 .map(|doc| {
166 doc.handles()
167 .first()
168 .map(|h| AtIdentifier::Handle(h.clone()).into_static())
169 })
170 .ok()
171 .flatten()
172 .unwrap_or(old_ident)
173 }
174 });
175 use_memo(move || {
176 if let Some(value) = &*res.read() {
177 value.clone()
178 } else {
179 ident.read().clone()
180 }
181 })
182}
183
184#[cfg(not(feature = "fullstack-server"))]
185pub fn use_get_handle(did: Did<'static>) -> Memo<AtIdentifier<'static>> {
186 let ident = use_signal(use_reactive!(|did| AtIdentifier::Did(did.clone())));
187 let old_ident = ident.read().clone();
188 let fetcher = use_context::<crate::fetch::Fetcher>();
189 let fetcher = fetcher.clone();
190 let res = use_resource(move || {
191 let client = fetcher.get_client();
192 let old_ident = old_ident.clone();
193 async move {
194 client
195 .resolve_ident_owned(&*ident.read())
196 .await
197 .map(|doc| {
198 doc.handles()
199 .first()
200 .map(|h| AtIdentifier::Handle(h.clone()).into_static())
201 })
202 .ok()
203 .flatten()
204 .unwrap_or(old_ident)
205 }
206 });
207 use_memo(move || {
208 if let Some(value) = &*res.read() {
209 value.clone()
210 } else {
211 ident.read().clone()
212 }
213 })
214}
215
216#[cfg(feature = "fullstack-server")]
217pub fn use_load_handle(
218 ident: Option<AtIdentifier<'static>>,
219) -> (
220 Result<Resource<Option<SmolStr>>, RenderError>,
221 Memo<Option<AtIdentifier<'static>>>,
222) {
223 let ident = use_signal(use_reactive!(|ident| ident.clone()));
224 let fetcher = use_context::<crate::fetch::Fetcher>();
225 let fetcher = fetcher.clone();
226 let res = use_server_future(use_reactive!(|ident| {
227 let client = fetcher.get_client();
228 async move {
229 if let Some(ident) = &*ident.read() {
230 use jacquard::smol_str::ToSmolStr;
231
232 client
233 .resolve_ident_owned(ident)
234 .await
235 .map(|doc| doc.handles().first().map(|h| h.to_smolstr()))
236 .unwrap_or(Some(ident.to_smolstr()))
237 } else {
238 None
239 }
240 }
241 }));
242
243 let memo = use_memo(use_reactive!(|res| {
244 if let Ok(res) = res {
245 if let Some(value) = &*res.read() {
246 if let Some(handle) = value {
247 AtIdentifier::new_owned(handle.clone()).ok()
248 } else {
249 ident.read().clone()
250 }
251 } else {
252 ident.read().clone()
253 }
254 } else {
255 ident.read().clone()
256 }
257 }));
258
259 (res, memo)
260}
261
262#[cfg(not(feature = "fullstack-server"))]
263pub fn use_load_handle(
264 ident: Option<AtIdentifier<'static>>,
265) -> (
266 Resource<Option<AtIdentifier<'static>>>,
267 Memo<Option<AtIdentifier<'static>>>,
268) {
269 let ident = use_signal(use_reactive!(|ident| ident.clone()));
270 let fetcher = use_context::<crate::fetch::Fetcher>();
271 let fetcher = fetcher.clone();
272 let res = use_resource(move || {
273 let client = fetcher.get_client();
274 async move {
275 if let Some(ident) = &*ident.read() {
276 client
277 .resolve_ident_owned(ident)
278 .await
279 .map(|doc| {
280 doc.handles()
281 .first()
282 .map(|h| AtIdentifier::Handle(h.clone()).into_static())
283 })
284 .unwrap_or(Some(ident.clone()))
285 } else {
286 None
287 }
288 }
289 });
290
291 let memo = use_memo(move || {
292 if let Some(value) = &*res.read() {
293 value.clone()
294 } else {
295 ident.read().clone()
296 }
297 });
298
299 (res, memo)
300}
301#[cfg(not(feature = "fullstack-server"))]
302pub fn use_handle(
303 ident: ReadSignal<AtIdentifier<'static>>,
304) -> (Resource<AtIdentifier<'static>>, Memo<AtIdentifier<'static>>) {
305 let old_ident = ident.read().clone();
306 let fetcher = use_context::<crate::fetch::Fetcher>();
307 let fetcher = fetcher.clone();
308 let res = use_resource(move || {
309 let client = fetcher.get_client();
310 let old_ident = old_ident.clone();
311 async move {
312 client
313 .resolve_ident_owned(&*ident.read())
314 .await
315 .map(|doc| {
316 doc.handles()
317 .first()
318 .map(|h| AtIdentifier::Handle(h.clone()).into_static())
319 })
320 .ok()
321 .flatten()
322 .unwrap_or(old_ident)
323 }
324 });
325
326 let memo = use_memo(move || {
327 if let Some(value) = &*res.read() {
328 value.clone()
329 } else {
330 ident.read().clone()
331 }
332 });
333
334 (res, memo)
335}
336#[cfg(feature = "fullstack-server")]
337pub fn use_handle(
338 ident: ReadSignal<AtIdentifier<'static>>,
339) -> (
340 Result<Resource<SmolStr>, RenderError>,
341 Memo<AtIdentifier<'static>>,
342) {
343 let old_ident = ident.read().clone();
344 let fetcher = use_context::<crate::fetch::Fetcher>();
345 let fetcher = fetcher.clone();
346 let res = use_server_future(use_reactive!(|ident| {
347 let client = fetcher.get_client();
348 let old_ident = old_ident.clone();
349 async move {
350 use jacquard::smol_str::ToSmolStr;
351
352 client
353 .resolve_ident_owned(&ident())
354 .await
355 .map(|doc| {
356 use jacquard::smol_str::ToSmolStr;
357
358 doc.handles().first().map(|h| h.to_smolstr())
359 })
360 .ok()
361 .flatten()
362 .unwrap_or(old_ident.to_smolstr())
363 }
364 }));
365
366 let memo = use_memo(use_reactive!(|res| {
367 if let Ok(res) = res {
368 if let Some(value) = &*res.read() {
369 AtIdentifier::new_owned(value).unwrap()
370 } else {
371 ident.read().clone()
372 }
373 } else {
374 ident.read().clone()
375 }
376 }));
377
378 (res, memo)
379}
380
381/// Hook to render markdown with SSR support.
382#[cfg(feature = "fullstack-server")]
383pub fn use_rendered_markdown(
384 content: ReadSignal<Entry<'static>>,
385 ident: ReadSignal<AtIdentifier<'static>>,
386) -> (
387 Result<Resource<Option<String>>, RenderError>,
388 Memo<Option<String>>,
389) {
390 let fetcher = use_context::<crate::fetch::Fetcher>();
391 let fetcher = fetcher.clone();
392 let res = use_server_future(use_reactive!(|(content, ident)| {
393 let fetcher = fetcher.clone();
394 async move {
395 let entry = content();
396 let did = match ident.read().clone() {
397 AtIdentifier::Did(d) => d,
398 AtIdentifier::Handle(h) => fetcher.get_client().resolve_handle(&h).await.ok()?,
399 };
400
401 let resolved_content = prefetch_embeds(&entry, &fetcher).await;
402
403 Some(render_markdown_impl(entry, did, resolved_content).await)
404 }
405 }));
406 let memo = use_memo(use_reactive!(|res| {
407 let res = res.as_ref().ok()?;
408 if let Some(Some(value)) = &*res.read() {
409 Some(value.clone())
410 } else {
411 None
412 }
413 }));
414 (res, memo)
415}
416
417/// Hook to render markdown client-side only (no SSR).
418#[cfg(not(feature = "fullstack-server"))]
419pub fn use_rendered_markdown(
420 content: ReadSignal<Entry<'static>>,
421 ident: ReadSignal<AtIdentifier<'static>>,
422) -> (Resource<Option<String>>, Memo<Option<String>>) {
423 let fetcher = use_context::<crate::fetch::Fetcher>();
424 let fetcher = fetcher.clone();
425 let res = use_resource(use_reactive!(|(content, ident)| {
426 let fetcher = fetcher.clone();
427 async move {
428 let entry = content();
429 let did = match ident() {
430 AtIdentifier::Did(d) => d,
431 AtIdentifier::Handle(h) => fetcher.get_client().resolve_handle(&h).await.ok()?,
432 };
433
434 let resolved_content = prefetch_embeds(&entry, &fetcher).await;
435
436 Some(render_markdown_impl(entry, did, resolved_content).await)
437 }
438 }));
439 let memo = use_memo(use_reactive!(|res| {
440 if let Some(Some(value)) = &*res.read() {
441 Some(value.clone())
442 } else {
443 None
444 }
445 }));
446 (res, memo)
447}
448
449/// Extract AT URIs for embeds from stored records or by parsing markdown.
450///
451/// Tries stored `embeds.records` first, falls back to parsing markdown content.
452fn extract_embed_uris(entry: &Entry<'_>) -> Vec<AtUri<'static>> {
453 use jacquard::IntoStatic;
454
455 // Try stored records first
456 if let Some(ref embeds) = entry.embeds {
457 if let Some(ref records) = embeds.records {
458 let stored_uris: Vec<_> = records
459 .records
460 .iter()
461 .map(|r| r.record.uri.clone().into_static())
462 .collect();
463 if !stored_uris.is_empty() {
464 return stored_uris;
465 }
466 }
467 }
468
469 // Fall back to parsing markdown for at:// URIs
470 use regex_lite::Regex;
471 use std::sync::LazyLock;
472
473 static AT_URI_REGEX: LazyLock<Regex> =
474 LazyLock::new(|| Regex::new(r"at://[^\s\)\]]+").unwrap());
475
476 let uris: Vec<_> = AT_URI_REGEX
477 .find_iter(&entry.content)
478 .filter_map(|m| AtUri::new(m.as_str()).ok().map(|u| u.into_static()))
479 .collect();
480 uris
481}
482
483/// Pre-fetch embed content for all AT URIs in an entry.
484async fn prefetch_embeds(
485 entry: &Entry<'static>,
486 fetcher: &crate::fetch::Fetcher,
487) -> weaver_common::ResolvedContent {
488 use weaver_renderer::atproto::fetch_and_render;
489
490 let mut resolved = weaver_common::ResolvedContent::new();
491 let uris = extract_embed_uris(entry);
492
493 for uri in uris {
494 match fetch_and_render(&uri, fetcher).await {
495 Ok(html) => {
496 resolved.add_embed(uri, html, None);
497 }
498 Err(e) => {
499 tracing::warn!("[prefetch_embeds] Failed to fetch {}: {}", uri, e);
500 }
501 }
502 }
503
504 resolved
505}
506
507/// Internal implementation of markdown rendering.
508async fn render_markdown_impl(
509 content: Entry<'static>,
510 did: Did<'static>,
511 resolved_content: weaver_common::ResolvedContent,
512) -> String {
513 use n0_future::stream::StreamExt;
514 use weaver_renderer::{
515 ContextIterator, NotebookProcessor,
516 atproto::{ClientContext, ClientWriter},
517 };
518
519 let ctx = ClientContext::<()>::new(content.clone(), did);
520 let parser =
521 markdown_weaver::Parser::new_ext(&content.content, weaver_renderer::default_md_options())
522 .into_offset_iter();
523 let iter = ContextIterator::default(parser);
524 let processor = NotebookProcessor::new(ctx, iter);
525
526 let events: Vec<_> = StreamExt::collect(processor).await;
527
528 let mut html_buf = String::new();
529 let writer = ClientWriter::<_, _, ()>::new(events.into_iter(), &mut html_buf, &content.content)
530 .with_embed_provider(resolved_content);
531 writer.run().ok();
532 html_buf
533}
534
535/// Fetches profile data for a given identifier
536#[cfg(feature = "fullstack-server")]
537pub fn use_profile_data(
538 ident: ReadSignal<AtIdentifier<'static>>,
539) -> (
540 Result<Resource<Option<serde_json::Value>>, RenderError>,
541 Memo<Option<ProfileDataView<'static>>>,
542) {
543 let fetcher = use_context::<crate::fetch::Fetcher>();
544 let res = use_server_future(use_reactive!(|ident| {
545 let fetcher = fetcher.clone();
546 async move {
547 fetcher
548 .fetch_profile(&ident())
549 .await
550 .ok()
551 .map(|arc| serde_json::to_value(&*arc).ok())
552 .flatten()
553 }
554 }));
555 let memo = use_memo(use_reactive!(|res| {
556 let res = res.as_ref().ok()?;
557 if let Some(Some(value)) = &*res.read() {
558 jacquard::from_json_value::<ProfileDataView>(value.clone()).ok()
559 } else {
560 None
561 }
562 }));
563 (res, memo)
564}
565
566/// Fetches profile data client-side only (no SSR)
567#[cfg(not(feature = "fullstack-server"))]
568pub fn use_profile_data(
569 ident: ReadSignal<AtIdentifier<'static>>,
570) -> (
571 Resource<Option<ProfileDataView<'static>>>,
572 Memo<Option<ProfileDataView<'static>>>,
573) {
574 let fetcher = use_context::<crate::fetch::Fetcher>();
575 let res = use_resource(move || {
576 let fetcher = fetcher.clone();
577 async move {
578 fetcher
579 .fetch_profile(&ident())
580 .await
581 .ok()
582 .map(|arc| (*arc).clone())
583 }
584 });
585 let memo = use_memo(move || res.read().clone().flatten());
586 (res, memo)
587}
588
589/// Fetches notebooks for a specific DID
590#[cfg(feature = "fullstack-server")]
591pub fn use_notebooks_for_did(
592 ident: ReadSignal<AtIdentifier<'static>>,
593) -> (
594 Result<Resource<Option<Vec<serde_json::Value>>>, RenderError>,
595 Memo<Option<Vec<(NotebookView<'static>, Vec<StrongRef<'static>>)>>>,
596) {
597 let fetcher = use_context::<crate::fetch::Fetcher>();
598 let res = use_server_future(use_reactive!(|ident| {
599 let fetcher = fetcher.clone();
600 async move {
601 fetcher
602 .fetch_notebooks_for_did(&ident())
603 .await
604 .ok()
605 .map(|notebooks| {
606 notebooks
607 .iter()
608 .map(|arc| serde_json::to_value(arc.as_ref()).ok())
609 .collect::<Option<Vec<_>>>()
610 })
611 .flatten()
612 }
613 }));
614 let memo = use_memo(use_reactive!(|res| {
615 let res = res.as_ref().ok()?;
616 if let Some(Some(values)) = &*res.read() {
617 values
618 .iter()
619 .map(|v| {
620 jacquard::from_json_value::<(NotebookView, Vec<StrongRef>)>(v.clone()).ok()
621 })
622 .collect::<Option<Vec<_>>>()
623 } else {
624 None
625 }
626 }));
627 (res, memo)
628}
629
630/// Fetches notebooks client-side only (no SSR)
631#[cfg(not(feature = "fullstack-server"))]
632pub fn use_notebooks_for_did(
633 ident: ReadSignal<AtIdentifier<'static>>,
634) -> (
635 Resource<Option<Vec<(NotebookView<'static>, Vec<StrongRef<'static>>)>>>,
636 Memo<Option<Vec<(NotebookView<'static>, Vec<StrongRef<'static>>)>>>,
637) {
638 let fetcher = use_context::<crate::fetch::Fetcher>();
639 let res = use_resource(move || {
640 let fetcher = fetcher.clone();
641 async move {
642 fetcher
643 .fetch_notebooks_for_did(&ident())
644 .await
645 .ok()
646 .map(|notebooks| {
647 notebooks
648 .iter()
649 .map(|arc| arc.as_ref().clone())
650 .collect::<Vec<_>>()
651 })
652 }
653 });
654 let memo = use_memo(move || res.read().clone().flatten());
655 (res, memo)
656}
657
658/// Fetches all entries for a specific DID with SSR support
659#[cfg(feature = "fullstack-server")]
660pub fn use_entries_for_did(
661 ident: ReadSignal<AtIdentifier<'static>>,
662) -> (
663 Result<Resource<Option<Vec<(serde_json::Value, serde_json::Value)>>>, RenderError>,
664 Memo<Option<Vec<(EntryView<'static>, Entry<'static>)>>>,
665) {
666 let fetcher = use_context::<crate::fetch::Fetcher>();
667 let res = use_server_future(use_reactive!(|ident| {
668 let fetcher = fetcher.clone();
669 async move {
670 fetcher
671 .fetch_entries_for_did(&ident())
672 .await
673 .ok()
674 .map(|entries| {
675 entries
676 .iter()
677 .filter_map(|arc| {
678 let (view, entry) = arc.as_ref();
679 let view_json = serde_json::to_value(view).ok()?;
680 let entry_json = serde_json::to_value(entry).ok()?;
681 Some((view_json, entry_json))
682 })
683 .collect::<Vec<_>>()
684 })
685 }
686 }));
687 let memo = use_memo(use_reactive!(|res| {
688 let res = res.as_ref().ok()?;
689 if let Some(Some(values)) = &*res.read() {
690 let result: Vec<_> = values
691 .iter()
692 .filter_map(|(view_json, entry_json)| {
693 let view = jacquard::from_json_value::<EntryView>(view_json.clone()).ok()?;
694 let entry = jacquard::from_json_value::<Entry>(entry_json.clone()).ok()?;
695 Some((view, entry))
696 })
697 .collect();
698 Some(result)
699 } else {
700 None
701 }
702 }));
703 (res, memo)
704}
705
706/// Fetches all entries for a specific DID client-side only (no SSR)
707#[cfg(not(feature = "fullstack-server"))]
708pub fn use_entries_for_did(
709 ident: ReadSignal<AtIdentifier<'static>>,
710) -> (
711 Resource<Option<Vec<(EntryView<'static>, Entry<'static>)>>>,
712 Memo<Option<Vec<(EntryView<'static>, Entry<'static>)>>>,
713) {
714 let fetcher = use_context::<crate::fetch::Fetcher>();
715 let res = use_resource(move || {
716 let fetcher = fetcher.clone();
717 async move {
718 fetcher
719 .fetch_entries_for_did(&ident())
720 .await
721 .ok()
722 .map(|entries| {
723 entries
724 .iter()
725 .map(|arc| arc.as_ref().clone())
726 .collect::<Vec<_>>()
727 })
728 }
729 });
730 let memo = use_memo(move || res.read().clone().flatten());
731 (res, memo)
732}
733
734// ============================================================================
735// Client-only versions (bypass SSR issues on profile page)
736// ============================================================================
737
738/// Fetches profile data client-side only - use when SSR causes issues
739pub fn use_profile_data_client(
740 ident: ReadSignal<AtIdentifier<'static>>,
741) -> (
742 Resource<Option<ProfileDataView<'static>>>,
743 Memo<Option<ProfileDataView<'static>>>,
744) {
745 let fetcher = use_context::<crate::fetch::Fetcher>();
746 let res = use_resource(move || {
747 let fetcher = fetcher.clone();
748 async move {
749 fetcher
750 .fetch_profile(&ident())
751 .await
752 .ok()
753 .map(|arc| (*arc).clone())
754 }
755 });
756 let memo = use_memo(move || res.read().clone().flatten());
757 (res, memo)
758}
759
760/// Fetches notebooks client-side only - use when SSR causes issues
761pub fn use_notebooks_for_did_client(
762 ident: ReadSignal<AtIdentifier<'static>>,
763) -> (
764 Resource<Option<Vec<(NotebookView<'static>, Vec<StrongRef<'static>>)>>>,
765 Memo<Option<Vec<(NotebookView<'static>, Vec<StrongRef<'static>>)>>>,
766) {
767 let fetcher = use_context::<crate::fetch::Fetcher>();
768 let res = use_resource(move || {
769 let fetcher = fetcher.clone();
770 async move {
771 fetcher
772 .fetch_notebooks_for_did(&ident())
773 .await
774 .ok()
775 .map(|notebooks| {
776 notebooks
777 .iter()
778 .map(|arc| arc.as_ref().clone())
779 .collect::<Vec<_>>()
780 })
781 }
782 });
783 let memo = use_memo(move || res.read().clone().flatten());
784 (res, memo)
785}
786
787/// Fetches all entries client-side only - use when SSR causes issues
788pub fn use_entries_for_did_client(
789 ident: ReadSignal<AtIdentifier<'static>>,
790) -> (
791 Resource<Option<Vec<(EntryView<'static>, Entry<'static>)>>>,
792 Memo<Option<Vec<(EntryView<'static>, Entry<'static>)>>>,
793) {
794 let fetcher = use_context::<crate::fetch::Fetcher>();
795 let res = use_resource(move || {
796 let fetcher = fetcher.clone();
797 async move {
798 fetcher
799 .fetch_entries_for_did(&ident())
800 .await
801 .ok()
802 .map(|entries| {
803 entries
804 .iter()
805 .map(|arc| arc.as_ref().clone())
806 .collect::<Vec<_>>()
807 })
808 }
809 });
810 let memo = use_memo(move || res.read().clone().flatten());
811 (res, memo)
812}
813
814/// Fetches notebooks from UFOS with SSR support in fullstack mode
815#[cfg(feature = "fullstack-server")]
816pub fn use_notebooks_from_ufos() -> (
817 Result<Resource<Option<Vec<serde_json::Value>>>, RenderError>,
818 Memo<Option<Vec<(NotebookView<'static>, Vec<StrongRef<'static>>)>>>,
819) {
820 let fetcher = use_context::<crate::fetch::Fetcher>();
821 let res = use_server_future(move || {
822 let fetcher = fetcher.clone();
823 async move {
824 fetcher
825 .fetch_notebooks_from_ufos()
826 .await
827 .ok()
828 .map(|notebooks| {
829 notebooks
830 .iter()
831 .map(|arc| serde_json::to_value(arc.as_ref()).ok())
832 .collect::<Option<Vec<_>>>()
833 })
834 .flatten()
835 }
836 });
837 let memo = use_memo(use_reactive!(|res| {
838 let res = res.as_ref().ok()?;
839 if let Some(Some(values)) = &*res.read() {
840 values
841 .iter()
842 .map(|v| {
843 jacquard::from_json_value::<(NotebookView, Vec<StrongRef>)>(v.clone()).ok()
844 })
845 .collect::<Option<Vec<_>>>()
846 } else {
847 None
848 }
849 }));
850 (res, memo)
851}
852
853/// Fetches notebooks from UFOS client-side only (no SSR)
854#[cfg(not(feature = "fullstack-server"))]
855pub fn use_notebooks_from_ufos() -> (
856 Resource<Option<Vec<(NotebookView<'static>, Vec<StrongRef<'static>>)>>>,
857 Memo<Option<Vec<(NotebookView<'static>, Vec<StrongRef<'static>>)>>>,
858) {
859 let fetcher = use_context::<crate::fetch::Fetcher>();
860 let res = use_resource(move || {
861 let fetcher = fetcher.clone();
862 async move {
863 fetcher
864 .fetch_notebooks_from_ufos()
865 .await
866 .ok()
867 .map(|notebooks| {
868 notebooks
869 .iter()
870 .map(|arc| arc.as_ref().clone())
871 .collect::<Vec<_>>()
872 })
873 }
874 });
875 let memo = use_memo(move || res.read().clone().flatten());
876 (res, memo)
877}
878
879/// Fetches entries from UFOS with SSR support in fullstack mode
880#[cfg(feature = "fullstack-server")]
881pub fn use_entries_from_ufos() -> (
882 Result<Resource<Option<Vec<(serde_json::Value, serde_json::Value, u64)>>>, RenderError>,
883 Memo<Option<Vec<(EntryView<'static>, Entry<'static>, u64)>>>,
884) {
885 let fetcher = use_context::<crate::fetch::Fetcher>();
886 let res = use_server_future(move || {
887 let fetcher = fetcher.clone();
888 async move {
889 match fetcher.fetch_entries_from_ufos().await {
890 Ok(entries) => {
891 // Cache blobs for each entry's embedded images
892 for arc in &entries {
893 let (view, entry, _) = arc.as_ref();
894 if let Some(embeds) = &entry.embeds {
895 if let Some(images) = &embeds.images {
896 use jacquard::smol_str::ToSmolStr;
897 use jacquard::types::aturi::AtUri;
898 // Extract ident from the entry's at-uri
899 if let Ok(at_uri) = AtUri::new(view.uri.as_ref()) {
900 let ident = at_uri.authority();
901 for image in &images.images {
902 let cid = image.image.blob().cid();
903 cache_blob(
904 ident.clone().to_smolstr(),
905 cid.to_smolstr(),
906 image.name.as_ref().map(|n| n.to_smolstr()),
907 )
908 .await
909 .ok();
910 }
911 }
912 }
913 }
914 }
915 Some(
916 entries
917 .iter()
918 .filter_map(|arc| {
919 let (view, entry, time) = arc.as_ref();
920 let view_json = serde_json::to_value(view).ok()?;
921 let entry_json = serde_json::to_value(entry).ok()?;
922 Some((view_json, entry_json, *time))
923 })
924 .collect::<Vec<_>>(),
925 )
926 }
927 Err(e) => {
928 tracing::error!("[use_entries_from_ufos] fetch failed: {:?}", e);
929 None
930 }
931 }
932 }
933 });
934 let memo = use_memo(use_reactive!(|res| {
935 let res = res.as_ref().ok()?;
936 if let Some(Some(values)) = &*res.read() {
937 let result: Vec<_> = values
938 .iter()
939 .filter_map(|(view_json, entry_json, time)| {
940 let view = jacquard::from_json_value::<EntryView>(view_json.clone()).ok()?;
941 let entry = jacquard::from_json_value::<Entry>(entry_json.clone()).ok()?;
942 Some((view, entry, *time))
943 })
944 .collect();
945 Some(result)
946 } else {
947 None
948 }
949 }));
950 (res, memo)
951}
952
953/// Fetches entries from UFOS client-side only (no SSR)
954#[cfg(not(feature = "fullstack-server"))]
955pub fn use_entries_from_ufos() -> (
956 Resource<Option<Vec<(EntryView<'static>, Entry<'static>, u64)>>>,
957 Memo<Option<Vec<(EntryView<'static>, Entry<'static>, u64)>>>,
958) {
959 let fetcher = use_context::<crate::fetch::Fetcher>();
960 let res = use_resource(move || {
961 let fetcher = fetcher.clone();
962 async move {
963 fetcher.fetch_entries_from_ufos().await.ok().map(|entries| {
964 entries
965 .iter()
966 .map(|arc| arc.as_ref().clone())
967 .collect::<Vec<_>>()
968 })
969 }
970 });
971 let memo = use_memo(move || res.read().clone().flatten());
972 (res, memo)
973}
974
975/// Fetches notebook metadata with SSR support in fullstack mode
976#[cfg(feature = "fullstack-server")]
977pub fn use_notebook(
978 ident: ReadSignal<AtIdentifier<'static>>,
979 book_title: ReadSignal<SmolStr>,
980) -> (
981 Result<Resource<Option<serde_json::Value>>, RenderError>,
982 Memo<Option<(NotebookView<'static>, Vec<BookEntryView<'static>>)>>,
983) {
984 let fetcher = use_context::<crate::fetch::Fetcher>();
985 let res = use_server_future(use_reactive!(|(ident, book_title)| {
986 let fetcher = fetcher.clone();
987 async move {
988 fetcher
989 .get_notebook(ident(), book_title())
990 .await
991 .ok()
992 .flatten()
993 .map(|arc| serde_json::to_value(arc.as_ref()).ok())
994 .flatten()
995 }
996 }));
997 let memo = use_memo(use_reactive!(|res| {
998 let res = res.as_ref().ok()?;
999 if let Some(Some(value)) = &*res.read() {
1000 jacquard::from_json_value::<(NotebookView, Vec<BookEntryView>)>(value.clone()).ok()
1001 } else {
1002 None
1003 }
1004 }));
1005 (res, memo)
1006}
1007
1008/// Fetches notebook metadata client-side only (no SSR)
1009#[cfg(not(feature = "fullstack-server"))]
1010pub fn use_notebook(
1011 ident: ReadSignal<AtIdentifier<'static>>,
1012 book_title: ReadSignal<SmolStr>,
1013) -> (
1014 Resource<Option<(NotebookView<'static>, Vec<BookEntryView<'static>>)>>,
1015 Memo<Option<(NotebookView<'static>, Vec<BookEntryView<'static>>)>>,
1016) {
1017 let fetcher = use_context::<crate::fetch::Fetcher>();
1018 let res = use_resource(move || {
1019 let fetcher = fetcher.clone();
1020 async move {
1021 fetcher
1022 .get_notebook(ident(), book_title())
1023 .await
1024 .ok()
1025 .flatten()
1026 .map(|arc| arc.as_ref().clone())
1027 }
1028 });
1029 let memo = use_memo(move || res.read().clone().flatten());
1030 (res, memo)
1031}
1032
1033/// Fetches notebook entries with SSR support in fullstack mode
1034#[cfg(feature = "fullstack-server")]
1035pub fn use_notebook_entries(
1036 ident: ReadSignal<AtIdentifier<'static>>,
1037 book_title: ReadSignal<SmolStr>,
1038) -> (
1039 Result<Resource<Option<Vec<serde_json::Value>>>, RenderError>,
1040 Memo<Option<Vec<BookEntryView<'static>>>>,
1041) {
1042 let fetcher = use_context::<crate::fetch::Fetcher>();
1043 let res = use_server_future(use_reactive!(|(ident, book_title)| {
1044 let fetcher = fetcher.clone();
1045 async move {
1046 fetcher
1047 .list_notebook_entries(ident(), book_title())
1048 .await
1049 .ok()
1050 .flatten()
1051 .map(|entries| {
1052 entries
1053 .iter()
1054 .map(|e| serde_json::to_value(e).ok())
1055 .collect::<Option<Vec<_>>>()
1056 })
1057 .flatten()
1058 }
1059 }));
1060 let memo = use_memo(use_reactive!(|res| {
1061 let res = res.as_ref().ok()?;
1062 if let Some(Some(values)) = &*res.read() {
1063 values
1064 .iter()
1065 .map(|v| jacquard::from_json_value::<BookEntryView>(v.clone()).ok())
1066 .collect::<Option<Vec<_>>>()
1067 } else {
1068 None
1069 }
1070 }));
1071
1072 (res, memo)
1073}
1074
1075/// Fetches notebook entries client-side only (no SSR)
1076#[cfg(not(feature = "fullstack-server"))]
1077pub fn use_notebook_entries(
1078 ident: ReadSignal<AtIdentifier<'static>>,
1079 book_title: ReadSignal<SmolStr>,
1080) -> (
1081 Resource<Option<Vec<BookEntryView<'static>>>>,
1082 Memo<Option<Vec<BookEntryView<'static>>>>,
1083) {
1084 let fetcher = use_context::<crate::fetch::Fetcher>();
1085 let r = use_resource(move || {
1086 let fetcher = fetcher.clone();
1087 async move {
1088 fetcher
1089 .list_notebook_entries(ident(), book_title())
1090 .await
1091 .ok()
1092 .flatten()
1093 }
1094 });
1095 let memo = use_memo(move || r.read().as_ref().and_then(|v| v.clone()));
1096 (r, memo)
1097}
1098
1099// ============================================================================
1100// Ownership Checking
1101// ============================================================================
1102
1103/// Check if the current authenticated user owns a resource identified by an AtIdentifier.
1104///
1105/// Returns a memo that is:
1106/// - `Some(true)` if the user is authenticated and their DID matches the resource owner
1107/// - `Some(false)` if the user is authenticated but doesn't match, or resource is a handle
1108/// - `None` if the user is not authenticated
1109///
1110/// For handles, this does a synchronous check that returns `false` since we can't resolve
1111/// handles synchronously. Use `use_is_owner_async` for handle resolution.
1112pub fn use_is_owner(resource_owner: ReadSignal<AtIdentifier<'static>>) -> Memo<Option<bool>> {
1113 let auth_state = use_context::<Signal<AuthState>>();
1114
1115 use_memo(move || {
1116 let current_did = auth_state.read().did.clone()?;
1117 let owner = resource_owner();
1118
1119 match owner {
1120 AtIdentifier::Did(did) => Some(did == current_did),
1121 AtIdentifier::Handle(_) => Some(false), // Can't resolve synchronously
1122 }
1123 })
1124}
1125
1126/// Check ownership with async handle resolution.
1127///
1128/// Returns a resource that resolves to:
1129/// - `Some(true)` if the user owns the resource
1130/// - `Some(false)` if the user doesn't own the resource
1131/// - `None` if the user is not authenticated
1132#[cfg(feature = "fullstack-server")]
1133pub fn use_is_owner_async(
1134 resource_owner: ReadSignal<AtIdentifier<'static>>,
1135) -> Resource<Option<bool>> {
1136 let auth_state = use_context::<Signal<AuthState>>();
1137 let fetcher = use_context::<crate::fetch::Fetcher>();
1138
1139 use_resource(move || {
1140 let fetcher = fetcher.clone();
1141 let owner = resource_owner();
1142 async move {
1143 let current_did = auth_state.read().did.clone()?;
1144
1145 match owner {
1146 AtIdentifier::Did(did) => Some(did == current_did),
1147 AtIdentifier::Handle(handle) => match fetcher.resolve_handle(&handle).await {
1148 Ok(resolved_did) => Some(resolved_did == current_did),
1149 Err(_) => Some(false),
1150 },
1151 }
1152 }
1153 })
1154}
1155
1156/// Check ownership with async handle resolution (client-only mode).
1157#[cfg(not(feature = "fullstack-server"))]
1158pub fn use_is_owner_async(
1159 resource_owner: ReadSignal<AtIdentifier<'static>>,
1160) -> Resource<Option<bool>> {
1161 let auth_state = use_context::<Signal<AuthState>>();
1162 let fetcher = use_context::<crate::fetch::Fetcher>();
1163
1164 use_resource(move || {
1165 let fetcher = fetcher.clone();
1166 let owner = resource_owner();
1167 async move {
1168 let current_did = auth_state.read().did.clone()?;
1169
1170 match owner {
1171 AtIdentifier::Did(did) => Some(did == current_did),
1172 AtIdentifier::Handle(handle) => match fetcher.resolve_handle(&handle).await {
1173 Ok(resolved_did) => Some(resolved_did == current_did),
1174 Err(_) => Some(false),
1175 },
1176 }
1177 }
1178 })
1179}
1180
1181// ============================================================================
1182// Edit Access Checking (Ownership + Collaboration)
1183// ============================================================================
1184
1185use weaver_api::sh_weaver::actor::ProfileDataViewInner;
1186use weaver_api::sh_weaver::notebook::{AuthorListView, PermissionsState};
1187
1188/// Extract DID from a ProfileDataView by matching on the inner variant.
1189pub fn extract_did_from_author(author: &AuthorListView<'_>) -> Option<Did<'static>> {
1190 match &author.record.inner {
1191 ProfileDataViewInner::ProfileView(p) => Some(p.did.clone().into_static()),
1192 ProfileDataViewInner::ProfileViewDetailed(p) => Some(p.did.clone().into_static()),
1193 ProfileDataViewInner::TangledProfileView(p) => Some(p.did.clone().into_static()),
1194 _ => None,
1195 }
1196}
1197
1198/// Check if the current user can edit a resource based on the permissions state.
1199///
1200/// Returns a memo that is:
1201/// - `Some(true)` if the user is authenticated and their DID is in permissions.editors
1202/// - `Some(false)` if the user is authenticated but not in editors
1203/// - `None` if the user is not authenticated or permissions not yet loaded
1204///
1205/// This checks the ACL-based permissions (who CAN edit), not authors (who contributed).
1206pub fn use_can_edit(permissions: Memo<Option<PermissionsState<'static>>>) -> Memo<Option<bool>> {
1207 let auth_state = use_context::<Signal<AuthState>>();
1208
1209 use_memo(move || {
1210 let current_did = auth_state.read().did.clone()?;
1211 let perms = permissions()?;
1212
1213 // Check if current user's DID is in the editors list
1214 let can_edit = perms.editors.iter().any(|grant| grant.did == current_did);
1215
1216 Some(can_edit)
1217 })
1218}
1219
1220/// Legacy: Check if the current user can edit based on authors list.
1221///
1222/// Use `use_can_edit` with permissions instead when available.
1223/// This is kept for backwards compatibility during transition.
1224pub fn use_can_edit_from_authors(
1225 authors: Memo<Vec<AuthorListView<'static>>>,
1226) -> Memo<Option<bool>> {
1227 let auth_state = use_context::<Signal<AuthState>>();
1228
1229 use_memo(move || {
1230 let current_did = auth_state.read().did.clone()?;
1231 let author_list = authors();
1232
1233 let can_edit = author_list
1234 .iter()
1235 .filter_map(extract_did_from_author)
1236 .any(|did| did == current_did);
1237
1238 Some(can_edit)
1239 })
1240}
1241
1242/// Check edit access for a resource URI using the WeaverExt trait methods.
1243///
1244/// This performs an async check that queries Constellation for collaboration records.
1245/// Use this when you have a resource URI but not the pre-populated authors list.
1246pub fn use_can_edit_resource(resource_uri: ReadSignal<AtUri<'static>>) -> Resource<Option<bool>> {
1247 let auth_state = use_context::<Signal<AuthState>>();
1248 let fetcher = use_context::<crate::fetch::Fetcher>();
1249
1250 use_resource(move || {
1251 let fetcher = fetcher.clone();
1252 let uri = resource_uri();
1253 async move {
1254 use weaver_common::agent::WeaverExt;
1255
1256 let current_did = auth_state.read().did.clone()?;
1257
1258 // Check ownership first (fast path)
1259 if let AtIdentifier::Did(owner_did) = uri.authority() {
1260 if *owner_did == current_did {
1261 return Some(true);
1262 }
1263 }
1264
1265 // Check collaboration via Constellation
1266 match fetcher.can_user_edit_resource(&uri, ¤t_did).await {
1267 Ok(can_edit) => Some(can_edit),
1268 Err(_) => Some(false),
1269 }
1270 }
1271 })
1272}
1273
1274// ============================================================================
1275// Standalone Entry by Rkey Hooks
1276// ============================================================================
1277
1278/// Fetches standalone entry data by rkey with SSR support.
1279/// Returns entry + optional notebook context if entry is in exactly one notebook.
1280#[cfg(feature = "fullstack-server")]
1281pub fn use_standalone_entry_data(
1282 ident: ReadSignal<AtIdentifier<'static>>,
1283 rkey: ReadSignal<SmolStr>,
1284) -> (
1285 Result<
1286 Resource<
1287 Option<(
1288 serde_json::Value,
1289 serde_json::Value,
1290 Option<(serde_json::Value, serde_json::Value)>,
1291 )>,
1292 >,
1293 RenderError,
1294 >,
1295 Memo<Option<crate::fetch::StandaloneEntryData>>,
1296) {
1297 let fetcher = use_context::<crate::fetch::Fetcher>();
1298 let res = use_server_future(use_reactive!(|(ident, rkey)| {
1299 let fetcher = fetcher.clone();
1300 async move {
1301 match fetcher.get_entry_by_rkey(ident(), rkey()).await {
1302 Ok(Some(data)) => {
1303 // Cache blobs for embedded images
1304 if let Some(embeds) = &data.entry.embeds {
1305 if let Some(images) = &embeds.images {
1306 use jacquard::smol_str::ToSmolStr;
1307 use jacquard::types::aturi::AtUri;
1308 if let Ok(at_uri) = AtUri::new(data.entry_view.uri.as_ref()) {
1309 let ident_str = at_uri.authority().to_smolstr();
1310 #[cfg(all(target_family = "wasm", target_os = "unknown"))]
1311 {
1312 tracing::debug!("Registering standalone entry blobs");
1313 let _ = crate::service_worker::register_standalone_entry_blobs(
1314 &ident(),
1315 rkey().as_str(),
1316 images,
1317 &fetcher,
1318 )
1319 .await;
1320 }
1321 for image in &images.images {
1322 let cid = image.image.blob().cid();
1323 cache_blob(
1324 ident_str.clone(),
1325 cid.to_smolstr(),
1326 image.name.as_ref().map(|n| n.to_smolstr()),
1327 )
1328 .await
1329 .ok();
1330 }
1331 }
1332 }
1333 }
1334 let entry_json = serde_json::to_value(&data.entry).ok()?;
1335 let entry_view_json = serde_json::to_value(&data.entry_view).ok()?;
1336 let notebook_ctx_json = data
1337 .notebook_context
1338 .as_ref()
1339 .map(|ctx| {
1340 let notebook_json = serde_json::to_value(&ctx.notebook).ok()?;
1341 let book_entry_json =
1342 serde_json::to_value(&ctx.book_entry_view).ok()?;
1343 Some((notebook_json, book_entry_json))
1344 })
1345 .flatten();
1346 Some((entry_json, entry_view_json, notebook_ctx_json))
1347 }
1348 Ok(None) => None,
1349 Err(e) => {
1350 tracing::error!("[use_standalone_entry_data] fetch error: {:?}", e);
1351 None
1352 }
1353 }
1354 }
1355 }));
1356
1357 let memo = use_memo(use_reactive!(|res| {
1358 use crate::fetch::{NotebookContext, StandaloneEntryData};
1359 use weaver_api::sh_weaver::notebook::{
1360 BookEntryView, EntryView, NotebookView, entry::Entry,
1361 };
1362
1363 let res = res.as_ref().ok()?;
1364 let Some(Some((entry_json, entry_view_json, notebook_ctx_json))) = res.read().clone()
1365 else {
1366 return None;
1367 };
1368
1369 let entry: Entry<'static> = jacquard::from_json_value::<Entry>(entry_json).ok()?;
1370 let entry_view: EntryView<'static> =
1371 jacquard::from_json_value::<EntryView>(entry_view_json).ok()?;
1372 let notebook_context = notebook_ctx_json
1373 .map(|(notebook_json, book_entry_json)| {
1374 let notebook: NotebookView<'static> =
1375 jacquard::from_json_value::<NotebookView>(notebook_json).ok()?;
1376 let book_entry_view: BookEntryView<'static> =
1377 jacquard::from_json_value::<BookEntryView>(book_entry_json).ok()?;
1378 Some(NotebookContext {
1379 notebook,
1380 book_entry_view,
1381 })
1382 })
1383 .flatten();
1384
1385 Some(StandaloneEntryData {
1386 entry,
1387 entry_view,
1388 notebook_context,
1389 })
1390 }));
1391
1392 (res, memo)
1393}
1394
1395/// Fetches standalone entry data client-side only (no SSR)
1396#[cfg(not(feature = "fullstack-server"))]
1397pub fn use_standalone_entry_data(
1398 ident: ReadSignal<AtIdentifier<'static>>,
1399 rkey: ReadSignal<SmolStr>,
1400) -> (
1401 Resource<Option<crate::fetch::StandaloneEntryData>>,
1402 Memo<Option<crate::fetch::StandaloneEntryData>>,
1403) {
1404 let fetcher = use_context::<crate::fetch::Fetcher>();
1405 let res = use_resource(move || {
1406 let fetcher = fetcher.clone();
1407 async move {
1408 fetcher
1409 .get_entry_by_rkey(ident(), rkey())
1410 .await
1411 .ok()
1412 .flatten()
1413 .map(|arc| (*arc).clone())
1414 }
1415 });
1416 let memo = use_memo(move || res.read().clone().flatten());
1417 (res, memo)
1418}
1419
1420/// Fetches notebook entry by rkey with SSR support.
1421#[cfg(feature = "fullstack-server")]
1422pub fn use_notebook_entry_by_rkey(
1423 ident: ReadSignal<AtIdentifier<'static>>,
1424 book_title: ReadSignal<SmolStr>,
1425 rkey: ReadSignal<SmolStr>,
1426) -> (
1427 Result<Resource<Option<(serde_json::Value, serde_json::Value)>>, RenderError>,
1428 Memo<Option<(BookEntryView<'static>, Entry<'static>)>>,
1429) {
1430 let fetcher = use_context::<crate::fetch::Fetcher>();
1431 let res = use_server_future(use_reactive!(|(ident, book_title, rkey)| {
1432 let fetcher = fetcher.clone();
1433 async move {
1434 match fetcher
1435 .get_notebook_entry_by_rkey(ident(), book_title(), rkey())
1436 .await
1437 {
1438 Ok(Some(data)) => {
1439 let book_entry_json = serde_json::to_value(&data.0).ok()?;
1440 let entry_json = serde_json::to_value(&data.1).ok()?;
1441 Some((book_entry_json, entry_json))
1442 }
1443 Ok(None) => None,
1444 Err(e) => {
1445 tracing::error!("[use_notebook_entry_by_rkey] fetch error: {:?}", e);
1446 None
1447 }
1448 }
1449 }
1450 }));
1451
1452 let memo = use_memo(use_reactive!(|res| {
1453 let res = res.as_ref().ok()?;
1454 if let Some(Some((book_entry_json, entry_json))) = &*res.read() {
1455 let book_entry: BookEntryView<'static> =
1456 jacquard::from_json_value::<BookEntryView>(book_entry_json.clone()).ok()?;
1457 let entry: Entry<'static> =
1458 jacquard::from_json_value::<Entry>(entry_json.clone()).ok()?;
1459 Some((book_entry, entry))
1460 } else {
1461 None
1462 }
1463 }));
1464
1465 (res, memo)
1466}
1467
1468/// Fetches notebook entry by rkey client-side only (no SSR)
1469#[cfg(not(feature = "fullstack-server"))]
1470pub fn use_notebook_entry_by_rkey(
1471 ident: ReadSignal<AtIdentifier<'static>>,
1472 book_title: ReadSignal<SmolStr>,
1473 rkey: ReadSignal<SmolStr>,
1474) -> (
1475 Resource<Option<(BookEntryView<'static>, Entry<'static>)>>,
1476 Memo<Option<(BookEntryView<'static>, Entry<'static>)>>,
1477) {
1478 let fetcher = use_context::<crate::fetch::Fetcher>();
1479 let res = use_resource(move || {
1480 let fetcher = fetcher.clone();
1481 async move {
1482 fetcher
1483 .get_notebook_entry_by_rkey(ident(), book_title(), rkey())
1484 .await
1485 .ok()
1486 .flatten()
1487 .map(|arc| (*arc).clone())
1488 }
1489 });
1490 let memo = use_memo(move || res.read().clone().flatten());
1491 (res, memo)
1492}
1493
1494/// Fetches WhiteWind entry by rkey (SSR)
1495#[cfg(feature = "fullstack-server")]
1496pub fn use_whitewind_entry_data(
1497 ident: ReadSignal<AtIdentifier<'static>>,
1498 rkey: ReadSignal<SmolStr>,
1499) -> (
1500 Result<Resource<Option<(serde_json::Value, serde_json::Value)>>, RenderError>,
1501 Memo<Option<crate::fetch::WhiteWindEntryData>>,
1502) {
1503 use weaver_api::com_whtwnd::blog::entry::Entry as WhiteWindEntry;
1504
1505 let fetcher = use_context::<crate::fetch::Fetcher>();
1506
1507 let res = use_server_future(move || {
1508 let fetcher = fetcher.clone();
1509 async move {
1510 use jacquard::client::AgentSessionExt;
1511
1512 let ident = ident();
1513 let rkey = rkey();
1514
1515 let uri_str = format!("at://{}/com.whtwnd.blog.entry/{}", ident, rkey);
1516 let uri = WhiteWindEntry::uri(&uri_str).ok()?;
1517 let record = fetcher.fetch_record(&uri).await.ok()?;
1518
1519 let profile = fetcher.fetch_profile(&ident).await.ok()?;
1520
1521 Some((
1522 serde_json::to_value(&record.value).ok()?,
1523 serde_json::to_value(&*profile).ok()?,
1524 ))
1525 }
1526 });
1527
1528 let memo = use_memo(use_reactive!(|res| {
1529 use weaver_api::com_whtwnd::blog::entry::Entry as WhiteWindEntry;
1530 use weaver_api::sh_weaver::actor::ProfileDataView;
1531
1532 let res = res.as_ref().ok()?;
1533 if let Some(Some((entry_json, profile_json))) = &*res.read() {
1534 let entry = jacquard::from_json_value::<WhiteWindEntry>(entry_json.clone()).ok()?;
1535 let profile =
1536 jacquard::from_json_value::<ProfileDataView>(profile_json.clone()).ok()?;
1537 Some(crate::fetch::WhiteWindEntryData { entry, profile })
1538 } else {
1539 None
1540 }
1541 }));
1542 (res, memo)
1543}
1544
1545/// Fetches WhiteWind entry by rkey (client-only)
1546#[cfg(not(feature = "fullstack-server"))]
1547pub fn use_whitewind_entry_data(
1548 ident: ReadSignal<AtIdentifier<'static>>,
1549 rkey: ReadSignal<SmolStr>,
1550) -> (
1551 Resource<Option<crate::fetch::WhiteWindEntryData>>,
1552 Memo<Option<crate::fetch::WhiteWindEntryData>>,
1553) {
1554 use jacquard::IntoStatic;
1555 use weaver_api::com_whtwnd::blog::entry::Entry as WhiteWindEntry;
1556
1557 let fetcher = use_context::<crate::fetch::Fetcher>();
1558
1559 let res = use_resource(move || {
1560 let fetcher = fetcher.clone();
1561 async move {
1562 let ident = ident();
1563 let rkey = rkey();
1564
1565 let uri_str = format!("at://{}/com.whtwnd.blog.entry/{}", ident, rkey);
1566 let uri = WhiteWindEntry::uri(&uri_str).ok()?;
1567 let record = fetcher.fetch_record(&uri).await.ok()?;
1568
1569 let profile = fetcher.fetch_profile(&ident).await.ok()?;
1570
1571 Some(crate::fetch::WhiteWindEntryData {
1572 entry: record.value.into_static(),
1573 profile: (*profile).clone(),
1574 })
1575 }
1576 });
1577
1578 let memo = use_memo(move || res.read().clone().flatten());
1579 (res, memo)
1580}
1581
1582/// Fetches Leaflet document by rkey (SSR)
1583#[cfg(feature = "fullstack-server")]
1584pub fn use_leaflet_document_data(
1585 ident: ReadSignal<AtIdentifier<'static>>,
1586 rkey: ReadSignal<SmolStr>,
1587) -> (
1588 Result<
1589 Resource<
1590 Option<(
1591 serde_json::Value,
1592 serde_json::Value,
1593 Option<String>,
1594 Option<String>,
1595 )>,
1596 >,
1597 RenderError,
1598 >,
1599 Memo<Option<crate::fetch::LeafletDocumentData>>,
1600) {
1601 use weaver_api::pub_leaflet::document::Document;
1602
1603 let fetcher = use_context::<crate::fetch::Fetcher>();
1604
1605 let res = use_server_future(move || {
1606 let fetcher = fetcher.clone();
1607 async move {
1608 use jacquard::IntoStatic;
1609 use jacquard::client::AgentSessionExt;
1610 use jacquard::prelude::IdentityResolver;
1611 use weaver_api::pub_leaflet::document::DocumentPagesItem;
1612 use weaver_api::pub_leaflet::publication::Publication;
1613 use weaver_renderer::leaflet::{LeafletRenderContext, render_linear_document};
1614
1615 let ident = ident();
1616 let rkey = rkey();
1617
1618 let uri_str = format!("at://{}/pub.leaflet.document/{}", ident, rkey);
1619 let uri = Document::uri(&uri_str).ok()?;
1620 let record = fetcher.fetch_record(&uri).await.ok()?;
1621
1622 // Fetch publication to get base_path if document has one
1623 let publication_base_path = if let Some(pub_uri) = &record.value.publication {
1624 tracing::debug!("Leaflet doc has publication: {}", pub_uri.as_ref());
1625 match Publication::uri(pub_uri.as_ref()) {
1626 Ok(typed_uri) => {
1627 tracing::debug!("Parsed publication URI successfully");
1628 match fetcher.fetch_record(&typed_uri).await {
1629 Ok(pub_record) => {
1630 // Try typed field first, fall back to extra_data (handles snake_case mismatch)
1631 let bp = pub_record
1632 .value
1633 .base_path
1634 .as_ref()
1635 .map(|p| p.as_ref().to_string())
1636 .or_else(|| {
1637 pub_record
1638 .value
1639 .extra_data
1640 .as_ref()
1641 .and_then(|m| m.get("base_path"))
1642 .and_then(|v| jacquard::from_data::<String>(v).ok())
1643 });
1644 tracing::debug!("Publication base_path: {:?}", bp);
1645 bp
1646 }
1647 Err(e) => {
1648 tracing::warn!("Failed to fetch publication: {:?}", e);
1649 None
1650 }
1651 }
1652 }
1653 Err(e) => {
1654 tracing::warn!("Failed to parse publication URI: {:?}", e);
1655 None
1656 }
1657 }
1658 } else {
1659 tracing::debug!("Leaflet doc has no publication");
1660 None
1661 };
1662
1663 // Render HTML
1664 let rendered_html = {
1665 let author_did = match &record.value.author {
1666 AtIdentifier::Did(d) => d.clone().into_static(),
1667 AtIdentifier::Handle(h) => fetcher.resolve_handle(h).await.ok()?.into_static(),
1668 };
1669 let ctx = LeafletRenderContext::new(author_did);
1670 let mut html = String::new();
1671 for page in &record.value.pages {
1672 match page {
1673 DocumentPagesItem::LinearDocument(linear_doc) => {
1674 html.push_str(
1675 &render_linear_document(linear_doc, &ctx, &fetcher).await,
1676 );
1677 }
1678 DocumentPagesItem::Canvas(_) => {
1679 html.push_str("<div class=\"embed-video-placeholder\">[Canvas layout not yet supported]</div>");
1680 }
1681 DocumentPagesItem::Unknown(_) => {
1682 html.push_str(
1683 "<div class=\"embed-video-placeholder\">[Unknown page type]</div>",
1684 );
1685 }
1686 }
1687 }
1688 Some(html)
1689 };
1690
1691 let profile = fetcher.fetch_profile(&ident).await.ok()?;
1692
1693 Some((
1694 serde_json::to_value(&record.value).ok()?,
1695 serde_json::to_value(&*profile).ok()?,
1696 publication_base_path,
1697 rendered_html,
1698 ))
1699 }
1700 });
1701
1702 let memo = use_memo(use_reactive!(|res| {
1703 use weaver_api::pub_leaflet::document::Document;
1704 use weaver_api::sh_weaver::actor::ProfileDataView;
1705
1706 let res = res.as_ref().ok()?;
1707 if let Some(Some((doc_json, profile_json, base_path, rendered_html))) = &*res.read() {
1708 let document = jacquard::from_json_value::<Document>(doc_json.clone()).ok()?;
1709 let profile =
1710 jacquard::from_json_value::<ProfileDataView>(profile_json.clone()).ok()?;
1711 Some(crate::fetch::LeafletDocumentData {
1712 document,
1713 profile,
1714 publication_base_path: base_path.clone(),
1715 rendered_html: rendered_html.clone(),
1716 })
1717 } else {
1718 None
1719 }
1720 }));
1721 (res, memo)
1722}
1723
1724/// Fetches Leaflet document by rkey (client-only)
1725#[cfg(not(feature = "fullstack-server"))]
1726pub fn use_leaflet_document_data(
1727 ident: ReadSignal<AtIdentifier<'static>>,
1728 rkey: ReadSignal<SmolStr>,
1729) -> (
1730 Resource<Option<crate::fetch::LeafletDocumentData>>,
1731 Memo<Option<crate::fetch::LeafletDocumentData>>,
1732) {
1733 use jacquard::IntoStatic;
1734 use jacquard::prelude::IdentityResolver;
1735 use weaver_api::pub_leaflet::document::{Document, DocumentPagesItem};
1736 use weaver_api::pub_leaflet::publication::Publication;
1737 use weaver_renderer::leaflet::{LeafletRenderContext, render_linear_document};
1738
1739 let fetcher = use_context::<crate::fetch::Fetcher>();
1740
1741 let res = use_resource(move || {
1742 let fetcher = fetcher.clone();
1743 async move {
1744 let ident = ident();
1745 let rkey = rkey();
1746
1747 let uri_str = format!("at://{}/pub.leaflet.document/{}", ident, rkey);
1748 let uri = Document::uri(&uri_str).ok()?;
1749 let record = fetcher.fetch_record(&uri).await.ok()?;
1750
1751 // Fetch publication to get base_path if document has one
1752 let publication_base_path = if let Some(pub_uri) = &record.value.publication {
1753 if let Ok(typed_uri) = Publication::uri(pub_uri.as_ref()) {
1754 if let Ok(pub_record) = fetcher.fetch_record(&typed_uri).await {
1755 // Try typed field first, fall back to extra_data (handles snake_case mismatch)
1756 pub_record
1757 .value
1758 .base_path
1759 .as_ref()
1760 .map(|p| p.as_ref().to_string())
1761 .or_else(|| {
1762 pub_record
1763 .value
1764 .extra_data
1765 .as_ref()
1766 .and_then(|m| m.get("base_path"))
1767 .and_then(|v| jacquard::from_data::<String>(v).ok())
1768 })
1769 } else {
1770 None
1771 }
1772 } else {
1773 None
1774 }
1775 } else {
1776 None
1777 };
1778
1779 // Render HTML
1780 let rendered_html = {
1781 let author_did = match &record.value.author {
1782 AtIdentifier::Did(d) => d.clone().into_static(),
1783 AtIdentifier::Handle(h) => fetcher.resolve_handle(h).await.ok()?.into_static(),
1784 };
1785 let ctx = LeafletRenderContext::new(author_did);
1786 let mut html = String::new();
1787 for page in &record.value.pages {
1788 match page {
1789 DocumentPagesItem::LinearDocument(linear_doc) => {
1790 html.push_str(
1791 &render_linear_document(linear_doc, &ctx, &fetcher).await,
1792 );
1793 }
1794 DocumentPagesItem::Canvas(_) => {
1795 html.push_str("<div class=\"embed-video-placeholder\">[Canvas layout not yet supported]</div>");
1796 }
1797 DocumentPagesItem::Unknown(_) => {
1798 html.push_str(
1799 "<div class=\"embed-video-placeholder\">[Unknown page type]</div>",
1800 );
1801 }
1802 }
1803 }
1804 Some(html)
1805 };
1806
1807 let profile = fetcher.fetch_profile(&ident).await.ok()?;
1808
1809 Some(crate::fetch::LeafletDocumentData {
1810 document: record.value.into_static(),
1811 profile: (*profile).clone(),
1812 publication_base_path,
1813 rendered_html,
1814 })
1815 }
1816 });
1817
1818 let memo = use_memo(move || res.read().clone().flatten());
1819 (res, memo)
1820}
1821
1822/// Fetches site.standard/blog.pckt document by rkey (SSR)
1823///
1824/// Supports both `site.standard.document` and `blog.pckt.document` collections.
1825/// For blog.pckt.document, unwraps the inner site.standard.document.
1826#[cfg(all(feature = "fullstack-server", feature = "pckt"))]
1827pub fn use_pckt_document_data(
1828 ident: ReadSignal<AtIdentifier<'static>>,
1829 rkey: ReadSignal<SmolStr>,
1830) -> (
1831 Result<
1832 Resource<
1833 Option<(
1834 serde_json::Value,
1835 serde_json::Value,
1836 Option<String>,
1837 Option<String>,
1838 )>,
1839 >,
1840 RenderError,
1841 >,
1842 Memo<Option<crate::fetch::PcktDocumentData>>,
1843) {
1844 let fetcher = use_context::<crate::fetch::Fetcher>();
1845
1846 let res = use_server_future(move || {
1847 let fetcher = fetcher.clone();
1848 async move {
1849 use jacquard::IntoStatic;
1850 use jacquard::client::AgentSessionExt;
1851 use jacquard::prelude::IdentityResolver;
1852 use weaver_api::site_standard::document::Document as SiteStandardDocument;
1853 use weaver_api::site_standard::publication::Publication;
1854 use weaver_renderer::pckt::{PcktRenderContext, render_document_content};
1855
1856 let ident = ident();
1857 let rkey = rkey();
1858
1859 // Try site.standard.document first, then blog.pckt.document
1860 use jacquard::types::aturi::AtUri;
1861
1862 let doc = {
1863 let uri_str = format!("at://{}/site.standard.document/{}", ident, rkey);
1864 if let Ok(uri) = AtUri::new(&uri_str) {
1865 if let Ok(output) = fetcher.fetch_record_slingshot(&uri).await {
1866 jacquard::from_data::<SiteStandardDocument>(&output.value)
1867 .ok()
1868 .map(|d| d.into_static())
1869 } else {
1870 None
1871 }
1872 } else {
1873 None
1874 }
1875 };
1876
1877 let doc = if let Some(d) = doc {
1878 d
1879 } else {
1880 // Try blog.pckt.document
1881 use weaver_api::blog_pckt::document::Document as PcktDocument;
1882 let uri_str = format!("at://{}/blog.pckt.document/{}", ident, rkey);
1883 let uri = PcktDocument::uri(&uri_str).ok()?;
1884 let record = fetcher.fetch_record(&uri).await.ok()?;
1885 record.value.document.into_static()
1886 };
1887
1888 // Fetch publication to get base URL
1889 use jacquard::types::string::Uri;
1890 let publication_url = if let Uri::At(site_uri) = &doc.site {
1891 if let Ok(pub_record) = fetcher.fetch_record_slingshot(site_uri).await {
1892 jacquard::from_data::<Publication>(&pub_record.value)
1893 .ok()
1894 .map(|p| p.url.as_ref().to_string())
1895 } else {
1896 None
1897 }
1898 } else {
1899 // Site is an HTTPS URL, use it directly
1900 Some(doc.site.as_str().to_string())
1901 };
1902
1903 // Render HTML
1904 let rendered_html = {
1905 let author_did = match &ident {
1906 AtIdentifier::Did(d) => d.clone().into_static(),
1907 AtIdentifier::Handle(h) => fetcher.resolve_handle(h).await.ok()?.into_static(),
1908 };
1909 let ctx = PcktRenderContext::new(author_did);
1910 if let Some(content) = &doc.content {
1911 Some(render_document_content(content, &ctx, &fetcher).await)
1912 } else {
1913 Some(String::from("<p>No content</p>"))
1914 }
1915 };
1916
1917 let profile = fetcher.fetch_profile(&ident).await.ok()?;
1918
1919 Some((
1920 serde_json::to_value(&doc).ok()?,
1921 serde_json::to_value(&*profile).ok()?,
1922 publication_url,
1923 rendered_html,
1924 ))
1925 }
1926 });
1927
1928 let memo = use_memo(use_reactive!(|res| {
1929 use weaver_api::sh_weaver::actor::ProfileDataView;
1930 use weaver_api::site_standard::document::Document as SiteStandardDocument;
1931
1932 let res = res.as_ref().ok()?;
1933 if let Some(Some((doc_json, profile_json, publication_url, rendered_html))) = &*res.read() {
1934 let document =
1935 jacquard::from_json_value::<SiteStandardDocument>(doc_json.clone()).ok()?;
1936 let profile =
1937 jacquard::from_json_value::<ProfileDataView>(profile_json.clone()).ok()?;
1938 Some(crate::fetch::PcktDocumentData {
1939 document,
1940 profile,
1941 publication_url: publication_url.clone(),
1942 rendered_html: rendered_html.clone(),
1943 })
1944 } else {
1945 None
1946 }
1947 }));
1948 (res, memo)
1949}
1950
1951/// Fetches site.standard/blog.pckt document by rkey (client-only)
1952#[cfg(all(not(feature = "fullstack-server"), feature = "pckt"))]
1953pub fn use_pckt_document_data(
1954 ident: ReadSignal<AtIdentifier<'static>>,
1955 rkey: ReadSignal<SmolStr>,
1956) -> (
1957 Resource<Option<crate::fetch::PcktDocumentData>>,
1958 Memo<Option<crate::fetch::PcktDocumentData>>,
1959) {
1960 use jacquard::IntoStatic;
1961 use jacquard::prelude::IdentityResolver;
1962 use weaver_api::site_standard::document::Document as SiteStandardDocument;
1963 use weaver_api::site_standard::publication::Publication;
1964 use weaver_renderer::pckt::{PcktRenderContext, render_document_content};
1965
1966 let fetcher = use_context::<crate::fetch::Fetcher>();
1967
1968 let res = use_resource(move || {
1969 let fetcher = fetcher.clone();
1970 async move {
1971 let ident = ident();
1972 let rkey = rkey();
1973
1974 // Try site.standard.document first, then blog.pckt.document
1975 use jacquard::types::aturi::AtUri;
1976
1977 let doc = {
1978 let uri_str = format!("at://{}/site.standard.document/{}", ident, rkey);
1979 if let Ok(uri) = AtUri::new(&uri_str) {
1980 if let Ok(output) = fetcher.fetch_record_slingshot(&uri).await {
1981 jacquard::from_data::<SiteStandardDocument>(&output.value)
1982 .ok()
1983 .map(|d| d.into_static())
1984 } else {
1985 None
1986 }
1987 } else {
1988 None
1989 }
1990 };
1991
1992 let doc = if let Some(d) = doc {
1993 d
1994 } else {
1995 // Try blog.pckt.document
1996 use weaver_api::blog_pckt::document::Document as PcktDocument;
1997 let uri_str = format!("at://{}/blog.pckt.document/{}", ident, rkey);
1998 let uri = PcktDocument::uri(&uri_str).ok()?;
1999 let record = fetcher.fetch_record(&uri).await.ok()?;
2000 record.value.document.into_static()
2001 };
2002
2003 // Fetch publication to get base URL
2004 use jacquard::types::string::Uri;
2005 let publication_url = if let Uri::At(site_uri) = &doc.site {
2006 if let Ok(pub_record) = fetcher.fetch_record_slingshot(site_uri).await {
2007 jacquard::from_data::<Publication>(&pub_record.value)
2008 .ok()
2009 .map(|p| p.url.as_ref().to_string())
2010 } else {
2011 None
2012 }
2013 } else {
2014 // Site is an HTTPS URL, use it directly
2015 Some(doc.site.as_str().to_string())
2016 };
2017
2018 // Render HTML
2019 let rendered_html = {
2020 let author_did = match &ident {
2021 AtIdentifier::Did(d) => d.clone().into_static(),
2022 AtIdentifier::Handle(h) => fetcher.resolve_handle(h).await.ok()?.into_static(),
2023 };
2024 let ctx = PcktRenderContext::new(author_did);
2025 if let Some(content) = &doc.content {
2026 Some(render_document_content(content, &ctx, &fetcher).await)
2027 } else {
2028 Some(String::from("<p>No content</p>"))
2029 }
2030 };
2031
2032 let profile = fetcher.fetch_profile(&ident).await.ok()?;
2033
2034 Some(crate::fetch::PcktDocumentData {
2035 document: doc,
2036 profile: (*profile).clone(),
2037 publication_url,
2038 rendered_html,
2039 })
2040 }
2041 });
2042
2043 let memo = use_memo(move || res.read().clone().flatten());
2044 (res, memo)
2045}
2046
2047#[cfg(feature = "fullstack-server")]
2048#[put("/cache/{ident}/{cid}?name", cache: Extension<Arc<BlobCache>>)]
2049pub async fn cache_blob(ident: SmolStr, cid: SmolStr, name: Option<SmolStr>) -> Result<()> {
2050 let ident = AtIdentifier::new_owned(ident)?;
2051 let cid = Cid::new_owned(cid.as_bytes())?;
2052 cache.cache(ident, cid, name).await
2053}
2054
2055/// Cache blob bytes directly (for pre-warming after upload).
2056/// If `notebook` is provided, uses scoped cache key `{notebook}_{name}`.
2057#[cfg(feature = "fullstack-server")]
2058#[put("/cache-bytes/{cid}?name¬ebook", cache: Extension<Arc<BlobCache>>)]
2059pub async fn cache_blob_bytes(
2060 cid: SmolStr,
2061 name: Option<SmolStr>,
2062 notebook: Option<SmolStr>,
2063 body: jacquard::bytes::Bytes,
2064) -> Result<()> {
2065 let cid = Cid::new_owned(cid.as_bytes())?;
2066 let cache_key = match (¬ebook, &name) {
2067 (Some(nb), Some(n)) => Some(format_smolstr!("{}_{}", nb, n)),
2068 (None, Some(n)) => Some(n.clone()),
2069 _ => None,
2070 };
2071 cache.insert_bytes(cid, body, cache_key);
2072 Ok(())
2073}
2074
2075// ============================================================================
2076// Custom Domain Document Resolution
2077// ============================================================================
2078
2079use weaver_api::sh_weaver::domain::DocumentView;
2080
2081/// Typed data returned from custom domain document resolution.
2082#[derive(Clone, Debug, PartialEq)]
2083pub struct CustomDomainDocumentData {
2084 pub document: DocumentView<'static>,
2085 pub rendered_html: String,
2086}
2087
2088#[cfg(feature = "fullstack-server")]
2089pub fn use_custom_domain_document_data(
2090 ident: ReadSignal<AtIdentifier<'static>>,
2091 publication_rkey: ReadSignal<SmolStr>,
2092 path: ReadSignal<String>,
2093) -> (
2094 Result<Resource<Option<(serde_json::Value, String)>>, RenderError>,
2095 Memo<Option<CustomDomainDocumentData>>,
2096) {
2097 let fetcher = use_context::<crate::fetch::Fetcher>();
2098 let fetcher = fetcher.clone();
2099
2100 let res = use_server_future(use_reactive!(|(ident, publication_rkey, path)| {
2101 let fetcher = fetcher.clone();
2102 async move {
2103 use jacquard::prelude::XrpcClient;
2104 use jacquard::smol_str::format_smolstr;
2105 use jacquard::types::aturi::AtUri;
2106 use weaver_api::sh_weaver::domain::resolve_document::ResolveDocument;
2107 use weaver_api::site_standard::document::Document;
2108 use weaver_renderer::pckt::{PcktRenderContext, render_document_content};
2109
2110 let ident_val = ident();
2111 let rkey = publication_rkey();
2112 let path_val = path();
2113
2114 let author_did = match &ident_val {
2115 AtIdentifier::Did(d) => d.clone().into_static(),
2116 AtIdentifier::Handle(h) => fetcher.resolve_handle(h).await.ok()?.into_static(),
2117 };
2118
2119 let pub_uri_str =
2120 format_smolstr!("at://{}/site.standard.publication/{}", author_did, rkey);
2121 let pub_uri = AtUri::new(&pub_uri_str).ok()?;
2122
2123 let output = fetcher
2124 .send(
2125 ResolveDocument::new()
2126 .publication(pub_uri)
2127 .path(&path_val)
2128 .build(),
2129 )
2130 .await
2131 .ok()?
2132 .into_output()
2133 .ok()?;
2134
2135 let doc_view = output.document;
2136 let document = jacquard::from_data::<Document>(&doc_view.record).ok()?;
2137
2138 let rendered_html = if let Some(content) = &document.content {
2139 let ctx = PcktRenderContext::new(author_did);
2140 render_document_content(content, &ctx, &*fetcher.get_client()).await
2141 } else {
2142 String::new()
2143 };
2144
2145 Some((serde_json::to_value(&doc_view).ok()?, rendered_html))
2146 }
2147 }));
2148
2149 let memo = use_memo(use_reactive!(|res| {
2150 let res = res.as_ref().ok()?;
2151 if let Some(Some((doc_json, html))) = &*res.read() {
2152 let document = jacquard::from_json_value::<DocumentView>(doc_json.clone()).ok()?;
2153 Some(CustomDomainDocumentData {
2154 document,
2155 rendered_html: html.clone(),
2156 })
2157 } else {
2158 None
2159 }
2160 }));
2161
2162 (res, memo)
2163}
2164
2165#[cfg(not(feature = "fullstack-server"))]
2166pub fn use_custom_domain_document_data(
2167 ident: ReadSignal<AtIdentifier<'static>>,
2168 publication_rkey: ReadSignal<SmolStr>,
2169 path: ReadSignal<String>,
2170) -> (
2171 Resource<Option<CustomDomainDocumentData>>,
2172 Memo<Option<CustomDomainDocumentData>>,
2173) {
2174 let fetcher = use_context::<crate::fetch::Fetcher>();
2175 let fetcher = fetcher.clone();
2176
2177 let res = use_resource(move || {
2178 let fetcher = fetcher.clone();
2179 async move {
2180 use jacquard::IntoStatic;
2181 use jacquard::prelude::XrpcClient;
2182 use jacquard::smol_str::format_smolstr;
2183 use jacquard::types::aturi::AtUri;
2184 use weaver_api::sh_weaver::domain::resolve_document::ResolveDocument;
2185 use weaver_api::site_standard::document::Document;
2186 use weaver_renderer::pckt::{PcktRenderContext, render_document_content};
2187
2188 let ident_val = ident();
2189 let rkey = publication_rkey();
2190 let path_val = path();
2191
2192 let author_did = match &ident_val {
2193 AtIdentifier::Did(d) => d.clone().into_static(),
2194 AtIdentifier::Handle(h) => fetcher.resolve_handle(h).await.ok()?.into_static(),
2195 };
2196
2197 let pub_uri_str =
2198 format_smolstr!("at://{}/site.standard.publication/{}", author_did, rkey);
2199 let pub_uri = AtUri::new(&pub_uri_str).ok()?;
2200
2201 let output = fetcher
2202 .send(
2203 ResolveDocument::new()
2204 .publication(pub_uri)
2205 .path(&path_val)
2206 .build(),
2207 )
2208 .await
2209 .ok()?
2210 .into_output()
2211 .ok()?;
2212
2213 let doc_view = output.document.into_static();
2214 let document = jacquard::from_data::<Document>(&doc_view.record).ok()?;
2215
2216 let rendered_html = if let Some(content) = &document.content {
2217 let ctx = PcktRenderContext::new(author_did);
2218 render_document_content(content, &ctx, &*fetcher.get_client()).await
2219 } else {
2220 String::new()
2221 };
2222
2223 Some(CustomDomainDocumentData {
2224 document: doc_view.clone(),
2225 rendered_html,
2226 })
2227 }
2228 });
2229
2230 let memo = use_memo(move || res.cloned().flatten());
2231
2232 (res, memo)
2233}