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