entry list on profile

Orual cd5b03e1 f83a2ef7

+432 -27
+273 -19
crates/weaver-app/src/components/identity.rs
··· 1 1 use crate::auth::AuthState; 2 - use crate::components::{ProfileActions, ProfileActionsMenubar}; 2 + use crate::components::{FeedEntryCard, ProfileActions, ProfileActionsMenubar}; 3 3 use crate::{Route, data, fetch}; 4 4 use dioxus::prelude::*; 5 5 use jacquard::{smol_str::SmolStr, types::ident::AtIdentifier}; 6 + use std::collections::HashSet; 6 7 use weaver_api::com_atproto::repo::strong_ref::StrongRef; 7 - use weaver_api::sh_weaver::notebook::NotebookView; 8 + use weaver_api::sh_weaver::notebook::{EntryView, NotebookView, entry::Entry}; 9 + 10 + /// A single item in the profile timeline (either notebook or standalone entry) 11 + #[derive(Clone, PartialEq)] 12 + pub enum ProfileTimelineItem { 13 + Notebook { 14 + notebook: NotebookView<'static>, 15 + entries: Vec<StrongRef<'static>>, 16 + /// Most recent entry's created_at for sorting 17 + sort_date: jacquard::types::string::Datetime, 18 + }, 19 + StandaloneEntry { 20 + entry_view: EntryView<'static>, 21 + entry: Entry<'static>, 22 + }, 23 + } 24 + 25 + impl ProfileTimelineItem { 26 + pub fn sort_date(&self) -> &jacquard::types::string::Datetime { 27 + match self { 28 + Self::Notebook { sort_date, .. } => sort_date, 29 + Self::StandaloneEntry { entry, .. } => &entry.created_at, 30 + } 31 + } 32 + } 8 33 9 34 /// OpenGraph and Twitter Card meta tags for profile/repository pages 10 35 #[component] ··· 56 81 #[component] 57 82 pub fn RepositoryIndex(ident: ReadSignal<AtIdentifier<'static>>) -> Element { 58 83 use crate::components::ProfileDisplay; 84 + use jacquard::from_data; 85 + use weaver_api::sh_weaver::notebook::book::Book; 86 + 59 87 let (notebooks_result, notebooks) = data::use_notebooks_for_did(ident); 88 + let (entries_result, all_entries) = data::use_entries_for_did(ident); 60 89 let (profile_result, profile) = crate::data::use_profile_data(ident); 61 90 62 91 #[cfg(feature = "fullstack-server")] 63 92 notebooks_result?; 64 93 65 94 #[cfg(feature = "fullstack-server")] 95 + entries_result?; 96 + 97 + #[cfg(feature = "fullstack-server")] 66 98 profile_result?; 67 99 100 + // Extract pinned URIs from profile (only Weaver ProfileView has pinned) 101 + let pinned_uris = use_memo(move || { 102 + use jacquard::IntoStatic; 103 + use jacquard::types::aturi::AtUri; 104 + use weaver_api::sh_weaver::actor::ProfileDataViewInner; 105 + 106 + let Some(prof) = profile.read().as_ref().cloned() else { 107 + return Vec::<AtUri<'static>>::new(); 108 + }; 109 + 110 + match &prof.inner { 111 + ProfileDataViewInner::ProfileView(p) => p 112 + .pinned 113 + .as_ref() 114 + .map(|pins| pins.iter().map(|r| r.uri.clone().into_static()).collect()) 115 + .unwrap_or_default(), 116 + _ => Vec::new(), 117 + } 118 + }); 119 + 120 + // Compute standalone entries (entries not in any notebook) 121 + let standalone_entries = use_memo(move || { 122 + let nbs = notebooks.read(); 123 + let ents = all_entries.read(); 124 + 125 + let (Some(nbs), Some(ents)) = (nbs.as_ref(), ents.as_ref()) else { 126 + return Vec::new(); 127 + }; 128 + 129 + // Collect all entry URIs from all notebook entry_lists 130 + let notebook_entry_uris: HashSet<&str> = nbs 131 + .iter() 132 + .flat_map(|(_, refs)| refs.iter().map(|r| r.uri.as_ref())) 133 + .collect(); 134 + 135 + // Filter entries not in any notebook 136 + ents.iter() 137 + .filter(|(view, _)| !notebook_entry_uris.contains(view.uri.as_ref())) 138 + .cloned() 139 + .collect::<Vec<_>>() 140 + }); 141 + 142 + // Helper to check if a URI is pinned 143 + fn is_pinned(uri: &str, pinned: &[jacquard::types::aturi::AtUri<'static>]) -> bool { 144 + pinned.iter().any(|p| p.as_ref() == uri) 145 + } 146 + 147 + // Build pinned items (matching notebooks/entries against pinned URIs) 148 + let pinned_items = use_memo(move || { 149 + let nbs = notebooks.read(); 150 + let standalone = standalone_entries.read(); 151 + let ents = all_entries.read(); 152 + let pinned = pinned_uris.read(); 153 + 154 + let mut items: Vec<ProfileTimelineItem> = Vec::new(); 155 + 156 + // Check notebooks 157 + if let Some(nbs) = nbs.as_ref() { 158 + if let Some(all_ents) = ents.as_ref() { 159 + for (notebook, entry_refs) in nbs { 160 + if is_pinned(notebook.uri.as_ref(), &pinned) { 161 + let sort_date = entry_refs 162 + .iter() 163 + .filter_map(|r| { 164 + all_ents 165 + .iter() 166 + .find(|(v, _)| v.uri.as_ref() == r.uri.as_ref()) 167 + }) 168 + .map(|(_, entry)| entry.created_at.clone()) 169 + .max() 170 + .unwrap_or_else(|| notebook.indexed_at.clone()); 171 + 172 + items.push(ProfileTimelineItem::Notebook { 173 + notebook: notebook.clone(), 174 + entries: entry_refs.clone(), 175 + sort_date, 176 + }); 177 + } 178 + } 179 + } 180 + } 181 + 182 + // Check standalone entries 183 + for (view, entry) in standalone.iter() { 184 + if is_pinned(view.uri.as_ref(), &pinned) { 185 + items.push(ProfileTimelineItem::StandaloneEntry { 186 + entry_view: view.clone(), 187 + entry: entry.clone(), 188 + }); 189 + } 190 + } 191 + 192 + // Sort pinned by their order in the pinned list 193 + items.sort_by_key(|item| { 194 + let uri = match item { 195 + ProfileTimelineItem::Notebook { notebook, .. } => notebook.uri.as_ref(), 196 + ProfileTimelineItem::StandaloneEntry { entry_view, .. } => entry_view.uri.as_ref(), 197 + }; 198 + pinned 199 + .iter() 200 + .position(|p| p.as_ref() == uri) 201 + .unwrap_or(usize::MAX) 202 + }); 203 + 204 + items 205 + }); 206 + 207 + // Build merged timeline sorted by date (newest first), excluding pinned items 208 + let timeline = use_memo(move || { 209 + let nbs = notebooks.read(); 210 + let standalone = standalone_entries.read(); 211 + let ents = all_entries.read(); 212 + let pinned = pinned_uris.read(); 213 + 214 + let mut items: Vec<ProfileTimelineItem> = Vec::new(); 215 + 216 + // Add notebooks (excluding pinned) 217 + if let Some(nbs) = nbs.as_ref() { 218 + if let Some(all_ents) = ents.as_ref() { 219 + for (notebook, entry_refs) in nbs { 220 + if !is_pinned(notebook.uri.as_ref(), &pinned) { 221 + let sort_date = entry_refs 222 + .iter() 223 + .filter_map(|r| { 224 + all_ents 225 + .iter() 226 + .find(|(v, _)| v.uri.as_ref() == r.uri.as_ref()) 227 + }) 228 + .map(|(_, entry)| entry.created_at.clone()) 229 + .max() 230 + .unwrap_or_else(|| notebook.indexed_at.clone()); 231 + 232 + items.push(ProfileTimelineItem::Notebook { 233 + notebook: notebook.clone(), 234 + entries: entry_refs.clone(), 235 + sort_date, 236 + }); 237 + } 238 + } 239 + } 240 + } 241 + 242 + // Add standalone entries (excluding pinned) 243 + for (view, entry) in standalone.iter() { 244 + if !is_pinned(view.uri.as_ref(), &pinned) { 245 + items.push(ProfileTimelineItem::StandaloneEntry { 246 + entry_view: view.clone(), 247 + entry: entry.clone(), 248 + }); 249 + } 250 + } 251 + 252 + // Sort by date descending (newest first) 253 + items.sort_by(|a, b| b.sort_date().cmp(&a.sort_date())); 254 + 255 + items 256 + }); 257 + 258 + // Count standalone entries for stats 259 + let entry_count = use_memo(move || all_entries.read().as_ref().map(|e| e.len()).unwrap_or(0)); 260 + 68 261 // Build OG metadata when profile is available 69 262 let og_meta = match &*profile.read() { 70 263 Some(profile_view) => { ··· 130 323 div { class: "repository-layout", 131 324 // Profile sidebar (desktop) / header (mobile) 132 325 aside { class: "repository-sidebar", 133 - ProfileDisplay { profile, notebooks } 326 + ProfileDisplay { profile, notebooks, entry_count: *entry_count.read() } 134 327 } 135 328 136 329 // Main content area ··· 138 331 // Mobile menubar (hidden on desktop) 139 332 ProfileActionsMenubar { ident } 140 333 141 - div { class: "notebooks-list", 142 - match &*notebooks.read() { 143 - Some(notebook_list) => rsx! { 144 - for notebook in notebook_list.iter() { 145 - { 146 - let view = &notebook.0; 147 - let entries = &notebook.1; 148 - rsx! { 149 - div { 150 - key: "{view.cid}", 151 - NotebookCard { 152 - notebook: view.clone(), 153 - entry_refs: entries.clone() 334 + div { class: "profile-timeline", 335 + // Pinned items section 336 + { 337 + let pinned = pinned_items.read(); 338 + if !pinned.is_empty() { 339 + rsx! { 340 + div { class: "pinned-section", 341 + h3 { class: "pinned-header", "Pinned" } 342 + for (idx, item) in pinned.iter().enumerate() { 343 + { 344 + match item { 345 + ProfileTimelineItem::Notebook { notebook, entries, .. } => { 346 + rsx! { 347 + div { 348 + key: "pinned-notebook-{notebook.cid}", 349 + class: "pinned-item", 350 + NotebookCard { 351 + notebook: notebook.clone(), 352 + entry_refs: entries.clone() 353 + } 354 + } 355 + } 356 + } 357 + ProfileTimelineItem::StandaloneEntry { entry_view, entry } => { 358 + rsx! { 359 + div { 360 + key: "pinned-entry-{idx}", 361 + class: "pinned-item standalone-entry-item", 362 + FeedEntryCard { 363 + entry_view: entry_view.clone(), 364 + entry: entry.clone() 365 + } 366 + } 367 + } 368 + } 154 369 } 155 370 } 156 371 } 157 372 } 158 373 } 159 - }, 160 - None => rsx! { 161 - div { "Loading notebooks..." } 374 + } else { 375 + rsx! {} 376 + } 377 + } 378 + 379 + // Chronological timeline 380 + { 381 + let timeline_items = timeline.read(); 382 + if timeline_items.is_empty() && pinned_items.read().is_empty() { 383 + rsx! { div { class: "timeline-empty", "No content yet" } } 384 + } else { 385 + rsx! { 386 + for (idx, item) in timeline_items.iter().enumerate() { 387 + { 388 + match item { 389 + ProfileTimelineItem::Notebook { notebook, entries, .. } => { 390 + rsx! { 391 + div { 392 + key: "notebook-{notebook.cid}", 393 + NotebookCard { 394 + notebook: notebook.clone(), 395 + entry_refs: entries.clone() 396 + } 397 + } 398 + } 399 + } 400 + ProfileTimelineItem::StandaloneEntry { entry_view, entry } => { 401 + rsx! { 402 + div { 403 + key: "entry-{idx}", 404 + class: "standalone-entry-item", 405 + FeedEntryCard { 406 + entry_view: entry_view.clone(), 407 + entry: entry.clone() 408 + } 409 + } 410 + } 411 + } 412 + } 413 + } 414 + } 415 + } 162 416 } 163 417 } 164 418 }
+9 -8
crates/weaver-app/src/components/profile.rs
··· 17 17 pub fn ProfileDisplay( 18 18 profile: Memo<Option<ProfileDataView<'static>>>, 19 19 notebooks: Memo<Option<Vec<(NotebookView<'static>, Vec<StrongRef<'static>>)>>>, 20 + #[props(default)] entry_count: usize, 20 21 ) -> Element { 21 22 match &*profile.read() { 22 23 Some(profile_view) => { ··· 58 59 div { 59 60 class: "profile-extras", 60 61 // Stats 61 - ProfileStats { notebooks: notebooks } 62 + ProfileStats { notebooks, entry_count } 62 63 63 64 // Links 64 65 ProfileLinks { profile_view } ··· 194 195 #[component] 195 196 fn ProfileStats( 196 197 notebooks: Memo<Option<Vec<(NotebookView<'static>, Vec<StrongRef<'static>>)>>>, 198 + #[props(default)] entry_count: usize, 197 199 ) -> Element { 198 - // Fetch notebook count 199 - let notebook_count = if let Some(notebooks) = &*notebooks.read() { 200 - notebooks.len() 201 - } else { 202 - 0 203 - }; 200 + let notebook_count = notebooks.read().as_ref().map(|n| n.len()).unwrap_or(0); 204 201 205 202 rsx! { 206 203 div { class: "profile-stats", 207 204 div { class: "profile-stat", 208 205 span { class: "profile-stat-label", "{notebook_count} notebooks" } 209 206 } 210 - // TODO: Add entry count, subscriber counts when available 207 + if entry_count > 0 { 208 + div { class: "profile-stat", 209 + span { class: "profile-stat-label", "{entry_count} entries" } 210 + } 211 + } 211 212 } 212 213 } 213 214 }
+76
crates/weaver-app/src/data.rs
··· 656 656 (res, memo) 657 657 } 658 658 659 + /// Fetches all entries for a specific DID with SSR support 660 + #[cfg(feature = "fullstack-server")] 661 + pub fn use_entries_for_did( 662 + ident: ReadSignal<AtIdentifier<'static>>, 663 + ) -> ( 664 + Result<Resource<Option<Vec<(serde_json::Value, serde_json::Value)>>>, RenderError>, 665 + Memo<Option<Vec<(EntryView<'static>, Entry<'static>)>>>, 666 + ) { 667 + let fetcher = use_context::<crate::fetch::Fetcher>(); 668 + let res = use_server_future(use_reactive!(|ident| { 669 + let fetcher = fetcher.clone(); 670 + async move { 671 + fetcher 672 + .fetch_entries_for_did(&ident()) 673 + .await 674 + .ok() 675 + .map(|entries| { 676 + entries 677 + .iter() 678 + .filter_map(|arc| { 679 + let (view, entry) = arc.as_ref(); 680 + let view_json = serde_json::to_value(view).ok()?; 681 + let entry_json = serde_json::to_value(entry).ok()?; 682 + Some((view_json, entry_json)) 683 + }) 684 + .collect::<Vec<_>>() 685 + }) 686 + } 687 + })); 688 + let memo = use_memo(use_reactive!(|res| { 689 + let res = res.as_ref().ok()?; 690 + if let Some(Some(values)) = &*res.read() { 691 + let result: Vec<_> = values 692 + .iter() 693 + .filter_map(|(view_json, entry_json)| { 694 + let view = jacquard::from_json_value::<EntryView>(view_json.clone()).ok()?; 695 + let entry = jacquard::from_json_value::<Entry>(entry_json.clone()).ok()?; 696 + Some((view, entry)) 697 + }) 698 + .collect(); 699 + Some(result) 700 + } else { 701 + None 702 + } 703 + })); 704 + (res, memo) 705 + } 706 + 707 + /// Fetches all entries for a specific DID client-side only (no SSR) 708 + #[cfg(not(feature = "fullstack-server"))] 709 + pub fn use_entries_for_did( 710 + ident: ReadSignal<AtIdentifier<'static>>, 711 + ) -> ( 712 + Resource<Option<Vec<(EntryView<'static>, Entry<'static>)>>>, 713 + Memo<Option<Vec<(EntryView<'static>, Entry<'static>)>>>, 714 + ) { 715 + let fetcher = use_context::<crate::fetch::Fetcher>(); 716 + let res = use_resource(move || { 717 + let fetcher = fetcher.clone(); 718 + async move { 719 + fetcher 720 + .fetch_entries_for_did(&ident()) 721 + .await 722 + .ok() 723 + .map(|entries| { 724 + entries 725 + .iter() 726 + .map(|arc| arc.as_ref().clone()) 727 + .collect::<Vec<_>>() 728 + }) 729 + } 730 + }); 731 + let memo = use_memo(move || res.read().clone().flatten()); 732 + (res, memo) 733 + } 734 + 659 735 /// Fetches notebooks from UFOS with SSR support in fullstack mode 660 736 #[cfg(feature = "fullstack-server")] 661 737 pub fn use_notebooks_from_ufos() -> (
+74
crates/weaver-app/src/fetch.rs
··· 721 721 Ok(notebooks) 722 722 } 723 723 724 + /// Fetch all entries for a DID (for profile timeline) 725 + pub async fn fetch_entries_for_did( 726 + &self, 727 + ident: &AtIdentifier<'_>, 728 + ) -> Result<Vec<Arc<(EntryView<'static>, Entry<'static>)>>> { 729 + use jacquard::{ 730 + IntoStatic, 731 + types::{collection::Collection, nsid::Nsid}, 732 + xrpc::XrpcExt, 733 + }; 734 + use weaver_api::com_atproto::repo::list_records::ListRecords; 735 + 736 + let client = self.get_client(); 737 + 738 + // Resolve DID and PDS 739 + let (repo_did, pds_url) = match ident { 740 + AtIdentifier::Did(did) => { 741 + let pds = client 742 + .pds_for_did(did) 743 + .await 744 + .map_err(|e| dioxus::CapturedError::from_display(e))?; 745 + (did.clone(), pds) 746 + } 747 + AtIdentifier::Handle(handle) => client 748 + .pds_for_handle(handle) 749 + .await 750 + .map_err(|e| dioxus::CapturedError::from_display(e))?, 751 + }; 752 + 753 + // Fetch all entry records for this repo 754 + let resp = client 755 + .xrpc(pds_url) 756 + .send( 757 + &ListRecords::new() 758 + .repo(repo_did) 759 + .collection(Nsid::raw(Entry::NSID)) 760 + .limit(100) 761 + .build(), 762 + ) 763 + .await 764 + .map_err(|e| dioxus::CapturedError::from_display(e))?; 765 + 766 + let mut entries = Vec::new(); 767 + let ident_static = ident.clone().into_static(); 768 + 769 + if let Ok(list) = resp.parse() { 770 + for record in list.records { 771 + // Extract rkey from URI 772 + let rkey = record 773 + .uri 774 + .rkey() 775 + .map(|r| r.0.as_str()) 776 + .unwrap_or_default(); 777 + 778 + // Fetch the entry with hydration 779 + match client.fetch_entry_by_rkey(&ident_static, rkey).await { 780 + Ok((entry_view, entry)) => { 781 + entries.push(Arc::new((entry_view.into_static(), entry.into_static()))); 782 + } 783 + Err(e) => { 784 + tracing::warn!( 785 + "[fetch_entries_for_did] failed to load entry {}: {:?}", 786 + rkey, 787 + e 788 + ); 789 + continue; 790 + } 791 + } 792 + } 793 + } 794 + 795 + Ok(entries) 796 + } 797 + 724 798 pub async fn list_notebook_entries( 725 799 &self, 726 800 ident: AtIdentifier<'static>,