···3//! Stores both human-readable content (for debugging) and the full CRDT
4//! snapshot (for undo history preservation across sessions).
5//!
6-//! Storage key strategy:
7-//! - New entries: `"draft:new:{uuid}"`
8-//! - Editing existing: `"draft:{at-uri}"`
0000000910#[cfg(all(target_family = "wasm", target_os = "unknown"))]
11use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
···3//! Stores both human-readable content (for debugging) and the full CRDT
4//! snapshot (for undo history preservation across sessions).
5//!
6+//! ## Storage key strategy (localStorage)
7+//!
8+//! - New entries: `"new:{tid}"` where tid is a timestamp-based ID
9+//! - Editing existing: `"{at-uri}"` the full AT-URI of the entry
10+//!
11+//! ## PDS canonical format
12+//!
13+//! When syncing to PDS via DraftRef, keys are transformed to canonical
14+//! format: `"{did}:{rkey}"` for discoverability and topic derivation.
15+//! This transformation happens in sync.rs `build_doc_ref()`.
1617#[cfg(all(target_family = "wasm", target_os = "unknown"))]
18use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
+297-17
crates/weaver-app/src/components/editor/sync.rs
···34use weaver_api::com_atproto::repo::strong_ref::StrongRef;
35use weaver_api::com_atproto::sync::get_blob::GetBlob;
36use weaver_api::sh_weaver::edit::diff::Diff;
037use weaver_api::sh_weaver::edit::root::Root;
38use weaver_api::sh_weaver::edit::{DocRef, DocRefValue, DraftRef, EntryRef};
39use weaver_common::constellation::{GetBacklinksQuery, RecordId};
···4647const ROOT_NSID: &str = "sh.weaver.edit.root";
48const DIFF_NSID: &str = "sh.weaver.edit.diff";
049const CONSTELLATION_URL: &str = "https://constellation.microcosm.blue";
5051/// Build a DocRef for either a published entry or an unpublished draft.
52///
53/// If entry_uri and entry_cid are provided, creates an EntryRef.
54-/// Otherwise, creates a DraftRef with the given draft key.
00055fn build_doc_ref(
056 draft_key: &str,
57 entry_uri: Option<&AtUri<'_>>,
58 entry_cid: Option<&Cid<'_>>,
···68 })),
69 extra_data: None,
70 },
71- _ => DocRef {
72- value: DocRefValue::DraftRef(Box::new(DraftRef {
73- draft_key: CowStr::from(draft_key.to_string()),
00000000000000000000000000000074 extra_data: None,
75- })),
76- extra_data: None,
77- },
78 }
0000000000000079}
8081/// Result of a sync operation.
···129 Ok(output.records.into_iter().next().map(|r| r.into_static()))
130}
131000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000132/// Find all diffs for a root record using constellation backlinks.
133#[allow(dead_code)]
134pub async fn find_diffs_for_root(
···178///
179/// Uploads the current Loro snapshot as a blob and creates an `sh.weaver.edit.root`
180/// record referencing the entry (or draft key if unpublished).
00181pub async fn create_edit_root(
182 fetcher: &Fetcher,
183 doc: &EditorDocument,
···191 .await
192 .ok_or_else(|| WeaverError::InvalidNotebook("Not authenticated".into()))?;
193000000000000000000194 // Export full snapshot
195 let snapshot = doc.export_snapshot();
196···202 .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to upload snapshot: {}", e)))?;
203204 // Build DocRef - use EntryRef if published, DraftRef if not
205- let doc_ref = build_doc_ref(draft_key, entry_uri, entry_cid);
206207 // Build root record
208 let root = Root::new().doc(doc_ref).snapshot(blob_ref).build();
···277 };
278279 // Build DocRef - use EntryRef if published, DraftRef if not
280- let doc_ref = build_doc_ref(draft_key, entry_uri, entry_cid);
281282 // Build root reference
283 let root_ref = StrongRef::new()
···464 None => return Ok(None),
465 };
4660000000000000000000000000467 // Build root URI
468 let root_uri = AtUri::new(&format!(
469 "at://{}/{}/{}",
···591/// Loads from localStorage and PDS (if available), then merges both using Loro's
592/// CRDT merge. The result is a pre-merged LoroDoc that can be converted to an
593/// EditorDocument inside a reactive context using `use_hook`.
000594pub async fn load_and_merge_document(
595 fetcher: &Fetcher,
596 draft_key: &str,
···601 // Load snapshot + entry_ref from localStorage
602 let local_data = load_snapshot_from_storage(draft_key);
603604- // Load from PDS (only if we have an entry URI)
605 let pds_state = if let Some(uri) = entry_uri {
0606 load_edit_state_from_pds(fetcher, uri).await?
0000607 } else {
0608 None
609 };
610···761 let doc = props.document.clone();
762 let draft_key = props.draft_key.clone();
763764- // Check if we're authenticated and have an entry to sync
765 let is_authenticated = auth_state.read().is_authenticated();
766- let has_entry = doc.entry_ref().is_some();
767768 // Auto-sync trigger signal - set to true to trigger a sync
769 let mut trigger_sync = use_signal(|| false);
···834 return;
835 }
836837- // Check if authenticated and has entry
838- if !is_authenticated || !has_entry {
839 return;
840 }
841···889 trigger_sync.set(true);
890 };
891892- // Determine display state
893 let display_state = if !is_authenticated {
894 SyncState::Disabled
895- } else if !has_entry {
896- SyncState::Disabled // Can't sync unpublished entries
897 } else {
898 *sync_state.read()
899 };
···34use weaver_api::com_atproto::repo::strong_ref::StrongRef;
35use weaver_api::com_atproto::sync::get_blob::GetBlob;
36use weaver_api::sh_weaver::edit::diff::Diff;
37+use weaver_api::sh_weaver::edit::draft::Draft;
38use weaver_api::sh_weaver::edit::root::Root;
39use weaver_api::sh_weaver::edit::{DocRef, DocRefValue, DraftRef, EntryRef};
40use weaver_common::constellation::{GetBacklinksQuery, RecordId};
···4748const ROOT_NSID: &str = "sh.weaver.edit.root";
49const DIFF_NSID: &str = "sh.weaver.edit.diff";
50+const DRAFT_NSID: &str = "sh.weaver.edit.draft";
51const CONSTELLATION_URL: &str = "https://constellation.microcosm.blue";
5253/// Build a DocRef for either a published entry or an unpublished draft.
54///
55/// If entry_uri and entry_cid are provided, creates an EntryRef.
56+/// Otherwise, creates a DraftRef with a synthetic AT-URI for Constellation indexing.
57+///
58+/// The synthetic URI format is: `at://{did}/sh.weaver.edit.draft/{rkey}`
59+/// This allows Constellation to index drafts as backlinks, enabling discovery.
60fn build_doc_ref(
61+ did: &Did<'_>,
62 draft_key: &str,
63 entry_uri: Option<&AtUri<'_>>,
64 entry_cid: Option<&Cid<'_>>,
···74 })),
75 extra_data: None,
76 },
77+ _ => {
78+ // Transform localStorage key to synthetic AT-URI for Constellation indexing
79+ // localStorage uses "new:{tid}" or AT-URI, PDS uses "at://{did}/sh.weaver.edit.draft/{rkey}"
80+ let rkey = if let Some(tid) = draft_key.strip_prefix("new:") {
81+ // New draft: extract TID as rkey
82+ tid.to_string()
83+ } else if draft_key.starts_with("at://") {
84+ // Editing existing entry: use the entry's rkey
85+ draft_key
86+ .split('/')
87+ .last()
88+ .unwrap_or(draft_key)
89+ .to_string()
90+ } else if draft_key.starts_with("did:") && draft_key.contains(':') {
91+ // Old canonical format "did:xxx:rkey" - extract rkey
92+ draft_key
93+ .rsplit(':')
94+ .next()
95+ .unwrap_or(draft_key)
96+ .to_string()
97+ } else {
98+ // Fallback: use as-is
99+ draft_key.to_string()
100+ };
101+102+ // Build AT-URI pointing to actual draft record: at://{did}/sh.weaver.edit.draft/{rkey}
103+ let canonical_uri = format!("at://{}/{}/{}", did, DRAFT_NSID, rkey);
104+105+ DocRef {
106+ value: DocRefValue::DraftRef(Box::new(DraftRef {
107+ draft_key: CowStr::from(canonical_uri),
108+ extra_data: None,
109+ })),
110 extra_data: None,
111+ }
112+ }
0113 }
114+}
115+116+/// Extract (authority, rkey) from a canonical draft key (synthetic AT-URI).
117+///
118+/// Parses `at://{authority}/sh.weaver.edit.draft/{rkey}` and returns the components.
119+/// Authority can be a DID or handle.
120+#[allow(dead_code)]
121+pub fn parse_draft_key(
122+ draft_key: &str,
123+) -> Option<(jacquard::types::ident::AtIdentifier<'static>, String)> {
124+ let uri = AtUri::new(draft_key).ok()?;
125+ let authority = uri.authority().clone().into_static();
126+ let rkey = uri.rkey()?.0.as_str().to_string();
127+ Some((authority, rkey))
128}
129130/// Result of a sync operation.
···178 Ok(output.records.into_iter().next().map(|r| r.into_static()))
179}
180181+/// Find the edit root for a draft using constellation backlinks.
182+///
183+/// Queries constellation for `sh.weaver.edit.root` records that reference
184+/// the given draft URI via the `.doc.value.draft_key` path.
185+///
186+/// The draft_uri should be in canonical format: `at://{did}/sh.weaver.edit.draft/{rkey}`
187+pub async fn find_edit_root_for_draft(
188+ fetcher: &Fetcher,
189+ draft_uri: &AtUri<'_>,
190+) -> Result<Option<RecordId<'static>>, WeaverError> {
191+ let constellation_url = Url::parse(CONSTELLATION_URL)
192+ .map_err(|e| WeaverError::InvalidNotebook(format!("Invalid constellation URL: {}", e)))?;
193+194+ let query = GetBacklinksQuery {
195+ subject: Uri::At(draft_uri.clone().into_static()),
196+ source: format!("{}:.doc.value.draft_key", ROOT_NSID).into(),
197+ cursor: None,
198+ did: vec![],
199+ limit: 1,
200+ };
201+202+ let response = fetcher
203+ .client
204+ .xrpc(constellation_url)
205+ .send(&query)
206+ .await
207+ .map_err(|e| WeaverError::InvalidNotebook(format!("Constellation query failed: {}", e)))?;
208+209+ let output = response.into_output().map_err(|e| {
210+ WeaverError::InvalidNotebook(format!("Failed to parse constellation response: {}", e))
211+ })?;
212+213+ Ok(output.records.into_iter().next().map(|r| r.into_static()))
214+}
215+216+/// Build a canonical draft URI from localStorage key and DID.
217+///
218+/// Transforms localStorage format ("new:{tid}" or AT-URI) to
219+/// draft record URI format: `at://{did}/sh.weaver.edit.draft/{rkey}`
220+pub fn build_draft_uri(did: &Did<'_>, draft_key: &str) -> AtUri<'static> {
221+ let rkey = if let Some(tid) = draft_key.strip_prefix("new:") {
222+ tid.to_string()
223+ } else if draft_key.starts_with("at://") {
224+ draft_key
225+ .split('/')
226+ .last()
227+ .unwrap_or(draft_key)
228+ .to_string()
229+ } else {
230+ draft_key.to_string()
231+ };
232+233+ let uri_str = format!("at://{}/{}/{}", did, DRAFT_NSID, rkey);
234+ // Safe to unwrap: we're constructing a valid AT-URI
235+ AtUri::new(&uri_str).unwrap().into_static()
236+}
237+238+/// Extract the rkey (TID) from a localStorage draft key.
239+fn extract_draft_rkey(draft_key: &str) -> String {
240+ if let Some(tid) = draft_key.strip_prefix("new:") {
241+ tid.to_string()
242+ } else if draft_key.starts_with("at://") {
243+ draft_key
244+ .split('/')
245+ .last()
246+ .unwrap_or(draft_key)
247+ .to_string()
248+ } else {
249+ draft_key.to_string()
250+ }
251+}
252+253+/// Create the draft stub record on PDS.
254+///
255+/// This creates a minimal `sh.weaver.edit.draft` record that acts as an anchor
256+/// for edit.root/diff records and enables draft discovery via listRecords.
257+async fn create_draft_stub(
258+ fetcher: &Fetcher,
259+ did: &Did<'_>,
260+ rkey: &str,
261+) -> Result<(AtUri<'static>, Cid<'static>), WeaverError> {
262+ // Build minimal draft record with just createdAt
263+ let draft = Draft::new()
264+ .created_at(jacquard::types::datetime::Datetime::now())
265+ .build();
266+267+ let draft_data = to_data(&draft)
268+ .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to serialize draft: {}", e)))?;
269+270+ let record_key = RecordKey::any(rkey)
271+ .map_err(|e| WeaverError::InvalidNotebook(e.to_string()))?;
272+273+ let collection = Nsid::new(DRAFT_NSID).map_err(WeaverError::AtprotoString)?;
274+275+ let request = CreateRecord::new()
276+ .repo(AtIdentifier::Did(did.clone().into_static()))
277+ .collection(collection)
278+ .rkey(record_key)
279+ .record(draft_data)
280+ .build();
281+282+ let response = fetcher
283+ .send(request)
284+ .await
285+ .map_err(jacquard::client::AgentError::from)?;
286+287+ let output = response
288+ .into_output()
289+ .map_err(|e| WeaverError::InvalidNotebook(e.to_string()))?;
290+291+ Ok((output.uri.into_static(), output.cid.into_static()))
292+}
293+294+/// Remote draft info from PDS.
295+#[derive(Clone, Debug)]
296+pub struct RemoteDraft {
297+ /// The draft record URI
298+ pub uri: AtUri<'static>,
299+ /// The rkey (TID) of the draft
300+ pub rkey: String,
301+ /// When the draft was created
302+ pub created_at: String,
303+}
304+305+/// List all drafts from PDS for the current user.
306+///
307+/// Returns a list of draft records from `sh.weaver.edit.draft` collection.
308+pub async fn list_drafts_from_pds(fetcher: &Fetcher) -> Result<Vec<RemoteDraft>, WeaverError> {
309+ use weaver_api::com_atproto::repo::list_records::ListRecords;
310+311+ let did = fetcher
312+ .current_did()
313+ .await
314+ .ok_or_else(|| WeaverError::InvalidNotebook("Not authenticated".into()))?;
315+316+ let client = fetcher.get_client();
317+ let collection = Nsid::new(DRAFT_NSID).map_err(WeaverError::AtprotoString)?;
318+319+ let request = ListRecords::new()
320+ .repo(did)
321+ .collection(collection)
322+ .limit(100)
323+ .build();
324+325+ let response = client
326+ .send(request)
327+ .await
328+ .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to list drafts: {}", e)))?;
329+330+ let output = response.into_output().map_err(|e| {
331+ WeaverError::InvalidNotebook(format!("Failed to parse list records response: {}", e))
332+ })?;
333+334+ tracing::debug!("list_drafts_from_pds: found {} records", output.records.len());
335+336+ let mut drafts = Vec::new();
337+ for record in output.records {
338+ let rkey = record
339+ .uri
340+ .rkey()
341+ .map(|r| r.0.as_str().to_string())
342+ .unwrap_or_default();
343+344+ tracing::debug!(" Draft record: uri={}, rkey={}", record.uri, rkey);
345+346+ // Parse the draft record to get createdAt
347+ let created_at = jacquard::from_data::<weaver_api::sh_weaver::edit::draft::Draft>(&record.value)
348+ .map(|d| d.created_at.to_string())
349+ .unwrap_or_default();
350+351+ drafts.push(RemoteDraft {
352+ uri: record.uri.into_static(),
353+ rkey,
354+ created_at,
355+ });
356+ }
357+358+ Ok(drafts)
359+}
360+361/// Find all diffs for a root record using constellation backlinks.
362#[allow(dead_code)]
363pub async fn find_diffs_for_root(
···407///
408/// Uploads the current Loro snapshot as a blob and creates an `sh.weaver.edit.root`
409/// record referencing the entry (or draft key if unpublished).
410+///
411+/// For drafts, also creates the `sh.weaver.edit.draft` stub record first.
412pub async fn create_edit_root(
413 fetcher: &Fetcher,
414 doc: &EditorDocument,
···422 .await
423 .ok_or_else(|| WeaverError::InvalidNotebook("Not authenticated".into()))?;
424425+ // For drafts, create the stub record first (makes it discoverable via listRecords)
426+ if entry_uri.is_none() {
427+ let rkey = extract_draft_rkey(draft_key);
428+ // Try to create draft stub, ignore if it already exists
429+ match create_draft_stub(fetcher, &did, &rkey).await {
430+ Ok((uri, _cid)) => {
431+ tracing::debug!("Created draft stub: {}", uri);
432+ }
433+ Err(e) => {
434+ // Check if it's a "record already exists" error - that's fine
435+ let err_str = e.to_string();
436+ if !err_str.contains("RecordAlreadyExists") && !err_str.contains("already exists") {
437+ tracing::warn!("Failed to create draft stub (continuing anyway): {}", e);
438+ }
439+ }
440+ }
441+ }
442+443 // Export full snapshot
444 let snapshot = doc.export_snapshot();
445···451 .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to upload snapshot: {}", e)))?;
452453 // Build DocRef - use EntryRef if published, DraftRef if not
454+ let doc_ref = build_doc_ref(&did, draft_key, entry_uri, entry_cid);
455456 // Build root record
457 let root = Root::new().doc(doc_ref).snapshot(blob_ref).build();
···526 };
527528 // Build DocRef - use EntryRef if published, DraftRef if not
529+ let doc_ref = build_doc_ref(&did, draft_key, entry_uri, entry_cid);
530531 // Build root reference
532 let root_ref = StrongRef::new()
···713 None => return Ok(None),
714 };
715716+ load_edit_state_from_root_id(fetcher, root_id).await
717+}
718+719+/// Load edit state from the PDS for a draft.
720+///
721+/// Finds the edit root via constellation backlinks using the draft URI,
722+/// fetches all diffs, and returns the snapshot + updates.
723+pub async fn load_edit_state_from_draft(
724+ fetcher: &Fetcher,
725+ draft_uri: &AtUri<'_>,
726+) -> Result<Option<PdsEditState>, WeaverError> {
727+ // Find the edit root for this draft
728+ let root_id = match find_edit_root_for_draft(fetcher, draft_uri).await? {
729+ Some(id) => id,
730+ None => return Ok(None),
731+ };
732+733+ load_edit_state_from_root_id(fetcher, root_id).await
734+}
735+736+/// Internal helper to load edit state given a root record ID.
737+async fn load_edit_state_from_root_id(
738+ fetcher: &Fetcher,
739+ root_id: RecordId<'static>,
740+) -> Result<Option<PdsEditState>, WeaverError> {
741 // Build root URI
742 let root_uri = AtUri::new(&format!(
743 "at://{}/{}/{}",
···865/// Loads from localStorage and PDS (if available), then merges both using Loro's
866/// CRDT merge. The result is a pre-merged LoroDoc that can be converted to an
867/// EditorDocument inside a reactive context using `use_hook`.
868+///
869+/// For unpublished drafts, attempts to discover edit state via Constellation
870+/// using the synthetic draft URI.
871pub async fn load_and_merge_document(
872 fetcher: &Fetcher,
873 draft_key: &str,
···878 // Load snapshot + entry_ref from localStorage
879 let local_data = load_snapshot_from_storage(draft_key);
880881+ // Load from PDS - try entry URI first, then draft discovery
882 let pds_state = if let Some(uri) = entry_uri {
883+ // Published entry: query by entry URI
884 load_edit_state_from_pds(fetcher, uri).await?
885+ } else if let Some(did) = fetcher.current_did().await {
886+ // Unpublished draft: try to discover via draft URI
887+ let draft_uri = build_draft_uri(&did, draft_key);
888+ load_edit_state_from_draft(fetcher, &draft_uri).await?
889 } else {
890+ // Not authenticated, can't query PDS
891 None
892 };
893···1044 let doc = props.document.clone();
1045 let draft_key = props.draft_key.clone();
10461047+ // Check if we're authenticated (drafts can sync via DraftRef even without entry)
1048 let is_authenticated = auth_state.read().is_authenticated();
010491050 // Auto-sync trigger signal - set to true to trigger a sync
1051 let mut trigger_sync = use_signal(|| false);
···1116 return;
1117 }
11181119+ // Check if authenticated (drafts can sync too via DraftRef)
1120+ if !is_authenticated {
1121 return;
1122 }
1123···1171 trigger_sync.set(true);
1172 };
11731174+ // Determine display state (drafts can sync too via DraftRef)
1175 let display_state = if !is_authenticated {
1176 SyncState::Disabled
001177 } else {
1178 *sync_state.read()
1179 };
···398 .format("%B %d, %Y")
399 .to_string();
400401+ // Check edit access via permissions
402+ let can_edit = {
403 let current_did = auth_state.read().did.clone();
404+ match ¤t_did {
405+ Some(did) => {
406+ if let Some(ref perms) = entry_view.permissions {
407+ perms.editors.iter().any(|grant| grant.did == *did)
408+ } else {
409+ // Fall back to ownership check
410+ match &ident {
411+ AtIdentifier::Did(ident_did) => *did == *ident_did,
412+ _ => false,
413+ }
414+ }
415+ }
416+ None => false,
417 }
418 };
419···451 div { class: "entry-card-date",
452 time { datetime: "{entry_view.indexed_at.as_str()}", "{formatted_date}" }
453 }
454+ if can_edit {
455 EntryActions {
456 entry_uri,
457 entry_cid: entry_view.cid.clone().into_static(),
458 entry_title: title.to_string(),
459 in_notebook: true,
460 notebook_title: Some(book_title.clone()),
461+ permissions: entry_view.permissions.clone(),
462 on_removed: Some(EventHandler::new(move |_| hidden.set(true)))
463 }
464 }
···579 // Get first author if we're showing it
580 let first_author = if show_author { entry_view.authors.first() } else { None };
581582+ // Check edit access via permissions
583 let auth_state = use_context::<Signal<AuthState>>();
584+ let can_edit = {
585 let current_did = auth_state.read().did.clone();
586+ match ¤t_did {
587+ Some(did) => {
588+ if let Some(ref perms) = entry_view.permissions {
589+ perms.editors.iter().any(|grant| grant.did == *did)
590+ } else {
591+ // Fall back to ownership check
592+ match &ident {
593+ AtIdentifier::Did(ident_did) => *did == *ident_did,
594+ _ => false,
595+ }
596+ }
597+ }
598+ None => false,
599 }
600 };
601···625 time { datetime: "{entry.created_at.as_str()}", "{formatted_date}" }
626 }
627 }
628+ if show_actions && can_edit {
629 crate::components::EntryActions {
630 entry_uri: entry_view.uri.clone().into_static(),
631 entry_cid: entry_view.cid.clone().into_static(),
632 entry_title: title.to_string(),
633 in_notebook: false,
634 is_pinned,
635+ permissions: entry_view.permissions.clone(),
636 on_pinned_changed
637 }
638 }
···761 entry_title,
762 in_notebook: book_title.is_some(),
763 notebook_title: book_title.clone(),
764+ permissions: entry_view.permissions.clone(),
765 on_removed: Some(EventHandler::new(on_removed))
766 }
767 }
+20-6
crates/weaver-app/src/components/entry_actions.rs
···15use weaver_api::com_atproto::repo::put_record::PutRecord;
16use weaver_api::com_atproto::repo::strong_ref::StrongRef;
17use weaver_api::sh_weaver::actor::profile::Profile as WeaverProfile;
01819const ENTRY_ACTIONS_CSS: Asset = asset!("/assets/styling/entry-actions.css");
20···35 /// Whether this entry is currently pinned
36 #[props(default = false)]
37 pub is_pinned: bool,
00038 /// Callback when entry is removed from notebook (for optimistic UI update)
39 #[props(default)]
40 pub on_removed: Option<EventHandler<()>>,
···58 let mut pinning = use_signal(|| false);
59 let mut error = use_signal(|| None::<String>);
6061- // Check ownership - compare auth DID with entry's authority
62 let current_did = auth_state.read().did.clone();
63- let entry_authority = props.entry_uri.authority();
64- let is_owner = match (¤t_did, entry_authority) {
65- (Some(current), AtIdentifier::Did(entry_did)) => *current == *entry_did,
66- _ => false,
000000000067 };
6869- if !is_owner {
70 return rsx! {};
71 }
72
···15use weaver_api::com_atproto::repo::put_record::PutRecord;
16use weaver_api::com_atproto::repo::strong_ref::StrongRef;
17use weaver_api::sh_weaver::actor::profile::Profile as WeaverProfile;
18+use weaver_api::sh_weaver::notebook::PermissionsState;
1920const ENTRY_ACTIONS_CSS: Asset = asset!("/assets/styling/entry-actions.css");
21···36 /// Whether this entry is currently pinned
37 #[props(default = false)]
38 pub is_pinned: bool,
39+ /// Permissions state for edit access checking (if available)
40+ #[props(default)]
41+ pub permissions: Option<PermissionsState<'static>>,
42 /// Callback when entry is removed from notebook (for optimistic UI update)
43 #[props(default)]
44 pub on_removed: Option<EventHandler<()>>,
···62 let mut pinning = use_signal(|| false);
63 let mut error = use_signal(|| None::<String>);
6465+ // Check edit access - use permissions if available, fall back to ownership check
66 let current_did = auth_state.read().did.clone();
67+ let can_edit = match ¤t_did {
68+ Some(did) => {
69+ if let Some(ref perms) = props.permissions {
70+ // Use ACL-based permissions
71+ perms.editors.iter().any(|grant| grant.did == *did)
72+ } else {
73+ // Fall back to ownership check
74+ match props.entry_uri.authority() {
75+ AtIdentifier::Did(entry_did) => *did == *entry_did,
76+ _ => false,
77+ }
78+ }
79+ }
80+ None => false,
81 };
8283+ if !can_edit {
84 return rsx! {};
85 }
86
+36-25
crates/weaver-app/src/components/identity.rs
···90 use jacquard::from_data;
91 use weaver_api::sh_weaver::notebook::book::Book;
920093 // Use client-only versions to avoid SSR issues with concurrent server futures
94 let (_profile_res, profile) = data::use_profile_data_client(ident);
95 let (_notebooks_res, notebooks) = data::use_notebooks_for_did_client(ident);
96 let (_entries_res, all_entries) = data::use_entries_for_did_client(ident);
0000000009798 // Extract pinned URIs from profile (only Weaver ProfileView has pinned)
99 let pinned_uris = use_memo(move || {
···320 div { class: "repository-layout",
321 // Profile sidebar (desktop) / header (mobile)
322 aside { class: "repository-sidebar",
323- ProfileDisplay { profile, notebooks, entry_count: *entry_count.read() }
324 }
325326 // Main content area
···622 if let Some(ref date) = created_at {
623 div { class: "entry-preview-date", "{date}" }
624 }
625- if is_owner {
626- crate::components::EntryActions {
627- entry_uri,
628- entry_cid: entry_view.entry.cid.clone().into_static(),
629- entry_title: entry_title.to_string(),
630- in_notebook: true,
631- notebook_title: Some(book_title.clone())
632- }
633 }
634 }
635 if let Some(ref html) = preview_html {
···693 if let Some(ref date) = created_at {
694 div { class: "entry-preview-date", "{date}" }
695 }
696- if is_owner {
697- crate::components::EntryActions {
698- entry_uri,
699- entry_cid: first_entry.entry.cid.clone().into_static(),
700- entry_title: entry_title.to_string(),
701- in_notebook: true,
702- notebook_title: Some(book_title.clone())
703- }
704 }
705 }
706 if let Some(ref html) = preview_html {
···773 if let Some(ref date) = created_at {
774 div { class: "entry-preview-date", "{date}" }
775 }
776- if is_owner {
777- crate::components::EntryActions {
778- entry_uri,
779- entry_cid: last_entry.entry.cid.clone().into_static(),
780- entry_title: entry_title.to_string(),
781- in_notebook: true,
782- notebook_title: Some(book_title.clone())
783- }
784 }
785 }
786 if let Some(ref html) = preview_html {
···90 use jacquard::from_data;
91 use weaver_api::sh_weaver::notebook::book::Book;
9293+ let auth_state = use_context::<Signal<AuthState>>();
94+95 // Use client-only versions to avoid SSR issues with concurrent server futures
96 let (_profile_res, profile) = data::use_profile_data_client(ident);
97 let (_notebooks_res, notebooks) = data::use_notebooks_for_did_client(ident);
98 let (_entries_res, all_entries) = data::use_entries_for_did_client(ident);
99+100+ // Check if viewing own profile
101+ let is_own_profile = use_memo(move || {
102+ let current_did = auth_state.read().did.clone();
103+ match (¤t_did, ident()) {
104+ (Some(did), AtIdentifier::Did(profile_did)) => *did == profile_did,
105+ _ => false,
106+ }
107+ });
108109 // Extract pinned URIs from profile (only Weaver ProfileView has pinned)
110 let pinned_uris = use_memo(move || {
···331 div { class: "repository-layout",
332 // Profile sidebar (desktop) / header (mobile)
333 aside { class: "repository-sidebar",
334+ ProfileDisplay { profile, notebooks, entry_count: *entry_count.read(), is_own_profile: is_own_profile() }
335 }
336337 // Main content area
···633 if let Some(ref date) = created_at {
634 div { class: "entry-preview-date", "{date}" }
635 }
636+ // EntryActions handles visibility via permissions
637+ crate::components::EntryActions {
638+ entry_uri,
639+ entry_cid: entry_view.entry.cid.clone().into_static(),
640+ entry_title: entry_title.to_string(),
641+ in_notebook: true,
642+ notebook_title: Some(book_title.clone()),
643+ permissions: entry_view.entry.permissions.clone()
644 }
645 }
646 if let Some(ref html) = preview_html {
···704 if let Some(ref date) = created_at {
705 div { class: "entry-preview-date", "{date}" }
706 }
707+ // EntryActions handles visibility via permissions
708+ crate::components::EntryActions {
709+ entry_uri,
710+ entry_cid: first_entry.entry.cid.clone().into_static(),
711+ entry_title: entry_title.to_string(),
712+ in_notebook: true,
713+ notebook_title: Some(book_title.clone()),
714+ permissions: first_entry.entry.permissions.clone()
715 }
716 }
717 if let Some(ref html) = preview_html {
···784 if let Some(ref date) = created_at {
785 div { class: "entry-preview-date", "{date}" }
786 }
787+ // EntryActions handles visibility via permissions
788+ crate::components::EntryActions {
789+ entry_uri,
790+ entry_cid: last_entry.entry.cid.clone().into_static(),
791+ entry_title: entry_title.to_string(),
792+ in_notebook: true,
793+ notebook_title: Some(book_title.clone()),
794+ permissions: last_entry.entry.permissions.clone()
795 }
796 }
797 if let Some(ref html) = preview_html {
+3
crates/weaver-app/src/components/mod.rs
···28pub mod record_editor;
29pub mod record_view;
3000031use dioxus::prelude::*;
3233#[derive(PartialEq, Props, Clone)]
···28pub mod record_editor;
29pub mod record_view;
3031+pub mod collab;
32+pub use collab::{CollaboratorAvatars, CollaboratorsPanel, InviteDialog, InvitesList};
33+34use dioxus::prelude::*;
3536#[derive(PartialEq, Props, Clone)]
···1181}
11821183// ============================================================================
1184+// Edit Access Checking (Ownership + Collaboration)
1185+// ============================================================================
1186+1187+use weaver_api::sh_weaver::actor::ProfileDataViewInner;
1188+use weaver_api::sh_weaver::notebook::{AuthorListView, PermissionsState};
1189+1190+/// Extract DID from a ProfileDataView by matching on the inner variant.
1191+pub fn extract_did_from_author(author: &AuthorListView<'_>) -> Option<Did<'static>> {
1192+ match &author.record.inner {
1193+ ProfileDataViewInner::ProfileView(p) => Some(p.did.clone().into_static()),
1194+ ProfileDataViewInner::ProfileViewDetailed(p) => Some(p.did.clone().into_static()),
1195+ ProfileDataViewInner::TangledProfileView(p) => Some(p.did.clone().into_static()),
1196+ _ => None,
1197+ }
1198+}
1199+1200+/// Check if the current user can edit a resource based on the permissions state.
1201+///
1202+/// Returns a memo that is:
1203+/// - `Some(true)` if the user is authenticated and their DID is in permissions.editors
1204+/// - `Some(false)` if the user is authenticated but not in editors
1205+/// - `None` if the user is not authenticated or permissions not yet loaded
1206+///
1207+/// This checks the ACL-based permissions (who CAN edit), not authors (who contributed).
1208+pub fn use_can_edit(
1209+ permissions: Memo<Option<PermissionsState<'static>>>,
1210+) -> Memo<Option<bool>> {
1211+ let auth_state = use_context::<Signal<AuthState>>();
1212+1213+ use_memo(move || {
1214+ let current_did = auth_state.read().did.clone()?;
1215+ let perms = permissions()?;
1216+1217+ // Check if current user's DID is in the editors list
1218+ let can_edit = perms
1219+ .editors
1220+ .iter()
1221+ .any(|grant| grant.did == current_did);
1222+1223+ Some(can_edit)
1224+ })
1225+}
1226+1227+/// Legacy: Check if the current user can edit based on authors list.
1228+///
1229+/// Use `use_can_edit` with permissions instead when available.
1230+/// This is kept for backwards compatibility during transition.
1231+pub fn use_can_edit_from_authors(
1232+ authors: Memo<Vec<AuthorListView<'static>>>,
1233+) -> Memo<Option<bool>> {
1234+ let auth_state = use_context::<Signal<AuthState>>();
1235+1236+ use_memo(move || {
1237+ let current_did = auth_state.read().did.clone()?;
1238+ let author_list = authors();
1239+1240+ let can_edit = author_list
1241+ .iter()
1242+ .filter_map(extract_did_from_author)
1243+ .any(|did| did == current_did);
1244+1245+ Some(can_edit)
1246+ })
1247+}
1248+1249+/// Check edit access for a resource URI using the WeaverExt trait methods.
1250+///
1251+/// This performs an async check that queries Constellation for collaboration records.
1252+/// Use this when you have a resource URI but not the pre-populated authors list.
1253+pub fn use_can_edit_resource(
1254+ resource_uri: ReadSignal<AtUri<'static>>,
1255+) -> Resource<Option<bool>> {
1256+ let auth_state = use_context::<Signal<AuthState>>();
1257+ let fetcher = use_context::<crate::fetch::Fetcher>();
1258+1259+ use_resource(move || {
1260+ let fetcher = fetcher.clone();
1261+ let uri = resource_uri();
1262+ async move {
1263+ use weaver_common::agent::WeaverExt;
1264+1265+ let current_did = auth_state.read().did.clone()?;
1266+1267+ // Check ownership first (fast path)
1268+ if let AtIdentifier::Did(owner_did) = uri.authority() {
1269+ if *owner_did == current_did {
1270+ return Some(true);
1271+ }
1272+ }
1273+1274+ // Check collaboration via Constellation
1275+ match fetcher.can_user_edit_resource(&uri, ¤t_did).await {
1276+ Ok(can_edit) => Some(can_edit),
1277+ Err(_) => Some(false),
1278+ }
1279+ }
1280+ })
1281+}
1282+1283+// ============================================================================
1284// Standalone Entry by Rkey Hooks
1285// ============================================================================
1286