···33//! Stores both human-readable content (for debugging) and the full CRDT
44//! snapshot (for undo history preservation across sessions).
55//!
66-//! Storage key strategy:
77-//! - New entries: `"draft:new:{uuid}"`
88-//! - Editing existing: `"draft:{at-uri}"`
66+//! ## Storage key strategy (localStorage)
77+//!
88+//! - New entries: `"new:{tid}"` where tid is a timestamp-based ID
99+//! - Editing existing: `"{at-uri}"` the full AT-URI of the entry
1010+//!
1111+//! ## PDS canonical format
1212+//!
1313+//! When syncing to PDS via DraftRef, keys are transformed to canonical
1414+//! format: `"{did}:{rkey}"` for discoverability and topic derivation.
1515+//! This transformation happens in sync.rs `build_doc_ref()`.
9161017#[cfg(all(target_family = "wasm", target_os = "unknown"))]
1118use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
+297-17
crates/weaver-app/src/components/editor/sync.rs
···3434use weaver_api::com_atproto::repo::strong_ref::StrongRef;
3535use weaver_api::com_atproto::sync::get_blob::GetBlob;
3636use weaver_api::sh_weaver::edit::diff::Diff;
3737+use weaver_api::sh_weaver::edit::draft::Draft;
3738use weaver_api::sh_weaver::edit::root::Root;
3839use weaver_api::sh_weaver::edit::{DocRef, DocRefValue, DraftRef, EntryRef};
3940use weaver_common::constellation::{GetBacklinksQuery, RecordId};
···46474748const ROOT_NSID: &str = "sh.weaver.edit.root";
4849const DIFF_NSID: &str = "sh.weaver.edit.diff";
5050+const DRAFT_NSID: &str = "sh.weaver.edit.draft";
4951const CONSTELLATION_URL: &str = "https://constellation.microcosm.blue";
50525153/// Build a DocRef for either a published entry or an unpublished draft.
5254///
5355/// If entry_uri and entry_cid are provided, creates an EntryRef.
5454-/// Otherwise, creates a DraftRef with the given draft key.
5656+/// Otherwise, creates a DraftRef with a synthetic AT-URI for Constellation indexing.
5757+///
5858+/// The synthetic URI format is: `at://{did}/sh.weaver.edit.draft/{rkey}`
5959+/// This allows Constellation to index drafts as backlinks, enabling discovery.
5560fn build_doc_ref(
6161+ did: &Did<'_>,
5662 draft_key: &str,
5763 entry_uri: Option<&AtUri<'_>>,
5864 entry_cid: Option<&Cid<'_>>,
···6874 })),
6975 extra_data: None,
7076 },
7171- _ => DocRef {
7272- value: DocRefValue::DraftRef(Box::new(DraftRef {
7373- draft_key: CowStr::from(draft_key.to_string()),
7777+ _ => {
7878+ // Transform localStorage key to synthetic AT-URI for Constellation indexing
7979+ // localStorage uses "new:{tid}" or AT-URI, PDS uses "at://{did}/sh.weaver.edit.draft/{rkey}"
8080+ let rkey = if let Some(tid) = draft_key.strip_prefix("new:") {
8181+ // New draft: extract TID as rkey
8282+ tid.to_string()
8383+ } else if draft_key.starts_with("at://") {
8484+ // Editing existing entry: use the entry's rkey
8585+ draft_key
8686+ .split('/')
8787+ .last()
8888+ .unwrap_or(draft_key)
8989+ .to_string()
9090+ } else if draft_key.starts_with("did:") && draft_key.contains(':') {
9191+ // Old canonical format "did:xxx:rkey" - extract rkey
9292+ draft_key
9393+ .rsplit(':')
9494+ .next()
9595+ .unwrap_or(draft_key)
9696+ .to_string()
9797+ } else {
9898+ // Fallback: use as-is
9999+ draft_key.to_string()
100100+ };
101101+102102+ // Build AT-URI pointing to actual draft record: at://{did}/sh.weaver.edit.draft/{rkey}
103103+ let canonical_uri = format!("at://{}/{}/{}", did, DRAFT_NSID, rkey);
104104+105105+ DocRef {
106106+ value: DocRefValue::DraftRef(Box::new(DraftRef {
107107+ draft_key: CowStr::from(canonical_uri),
108108+ extra_data: None,
109109+ })),
74110 extra_data: None,
7575- })),
7676- extra_data: None,
7777- },
111111+ }
112112+ }
78113 }
114114+}
115115+116116+/// Extract (authority, rkey) from a canonical draft key (synthetic AT-URI).
117117+///
118118+/// Parses `at://{authority}/sh.weaver.edit.draft/{rkey}` and returns the components.
119119+/// Authority can be a DID or handle.
120120+#[allow(dead_code)]
121121+pub fn parse_draft_key(
122122+ draft_key: &str,
123123+) -> Option<(jacquard::types::ident::AtIdentifier<'static>, String)> {
124124+ let uri = AtUri::new(draft_key).ok()?;
125125+ let authority = uri.authority().clone().into_static();
126126+ let rkey = uri.rkey()?.0.as_str().to_string();
127127+ Some((authority, rkey))
79128}
8012981130/// Result of a sync operation.
···129178 Ok(output.records.into_iter().next().map(|r| r.into_static()))
130179}
131180181181+/// Find the edit root for a draft using constellation backlinks.
182182+///
183183+/// Queries constellation for `sh.weaver.edit.root` records that reference
184184+/// the given draft URI via the `.doc.value.draft_key` path.
185185+///
186186+/// The draft_uri should be in canonical format: `at://{did}/sh.weaver.edit.draft/{rkey}`
187187+pub async fn find_edit_root_for_draft(
188188+ fetcher: &Fetcher,
189189+ draft_uri: &AtUri<'_>,
190190+) -> Result<Option<RecordId<'static>>, WeaverError> {
191191+ let constellation_url = Url::parse(CONSTELLATION_URL)
192192+ .map_err(|e| WeaverError::InvalidNotebook(format!("Invalid constellation URL: {}", e)))?;
193193+194194+ let query = GetBacklinksQuery {
195195+ subject: Uri::At(draft_uri.clone().into_static()),
196196+ source: format!("{}:.doc.value.draft_key", ROOT_NSID).into(),
197197+ cursor: None,
198198+ did: vec![],
199199+ limit: 1,
200200+ };
201201+202202+ let response = fetcher
203203+ .client
204204+ .xrpc(constellation_url)
205205+ .send(&query)
206206+ .await
207207+ .map_err(|e| WeaverError::InvalidNotebook(format!("Constellation query failed: {}", e)))?;
208208+209209+ let output = response.into_output().map_err(|e| {
210210+ WeaverError::InvalidNotebook(format!("Failed to parse constellation response: {}", e))
211211+ })?;
212212+213213+ Ok(output.records.into_iter().next().map(|r| r.into_static()))
214214+}
215215+216216+/// Build a canonical draft URI from localStorage key and DID.
217217+///
218218+/// Transforms localStorage format ("new:{tid}" or AT-URI) to
219219+/// draft record URI format: `at://{did}/sh.weaver.edit.draft/{rkey}`
220220+pub fn build_draft_uri(did: &Did<'_>, draft_key: &str) -> AtUri<'static> {
221221+ let rkey = if let Some(tid) = draft_key.strip_prefix("new:") {
222222+ tid.to_string()
223223+ } else if draft_key.starts_with("at://") {
224224+ draft_key
225225+ .split('/')
226226+ .last()
227227+ .unwrap_or(draft_key)
228228+ .to_string()
229229+ } else {
230230+ draft_key.to_string()
231231+ };
232232+233233+ let uri_str = format!("at://{}/{}/{}", did, DRAFT_NSID, rkey);
234234+ // Safe to unwrap: we're constructing a valid AT-URI
235235+ AtUri::new(&uri_str).unwrap().into_static()
236236+}
237237+238238+/// Extract the rkey (TID) from a localStorage draft key.
239239+fn extract_draft_rkey(draft_key: &str) -> String {
240240+ if let Some(tid) = draft_key.strip_prefix("new:") {
241241+ tid.to_string()
242242+ } else if draft_key.starts_with("at://") {
243243+ draft_key
244244+ .split('/')
245245+ .last()
246246+ .unwrap_or(draft_key)
247247+ .to_string()
248248+ } else {
249249+ draft_key.to_string()
250250+ }
251251+}
252252+253253+/// Create the draft stub record on PDS.
254254+///
255255+/// This creates a minimal `sh.weaver.edit.draft` record that acts as an anchor
256256+/// for edit.root/diff records and enables draft discovery via listRecords.
257257+async fn create_draft_stub(
258258+ fetcher: &Fetcher,
259259+ did: &Did<'_>,
260260+ rkey: &str,
261261+) -> Result<(AtUri<'static>, Cid<'static>), WeaverError> {
262262+ // Build minimal draft record with just createdAt
263263+ let draft = Draft::new()
264264+ .created_at(jacquard::types::datetime::Datetime::now())
265265+ .build();
266266+267267+ let draft_data = to_data(&draft)
268268+ .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to serialize draft: {}", e)))?;
269269+270270+ let record_key = RecordKey::any(rkey)
271271+ .map_err(|e| WeaverError::InvalidNotebook(e.to_string()))?;
272272+273273+ let collection = Nsid::new(DRAFT_NSID).map_err(WeaverError::AtprotoString)?;
274274+275275+ let request = CreateRecord::new()
276276+ .repo(AtIdentifier::Did(did.clone().into_static()))
277277+ .collection(collection)
278278+ .rkey(record_key)
279279+ .record(draft_data)
280280+ .build();
281281+282282+ let response = fetcher
283283+ .send(request)
284284+ .await
285285+ .map_err(jacquard::client::AgentError::from)?;
286286+287287+ let output = response
288288+ .into_output()
289289+ .map_err(|e| WeaverError::InvalidNotebook(e.to_string()))?;
290290+291291+ Ok((output.uri.into_static(), output.cid.into_static()))
292292+}
293293+294294+/// Remote draft info from PDS.
295295+#[derive(Clone, Debug)]
296296+pub struct RemoteDraft {
297297+ /// The draft record URI
298298+ pub uri: AtUri<'static>,
299299+ /// The rkey (TID) of the draft
300300+ pub rkey: String,
301301+ /// When the draft was created
302302+ pub created_at: String,
303303+}
304304+305305+/// List all drafts from PDS for the current user.
306306+///
307307+/// Returns a list of draft records from `sh.weaver.edit.draft` collection.
308308+pub async fn list_drafts_from_pds(fetcher: &Fetcher) -> Result<Vec<RemoteDraft>, WeaverError> {
309309+ use weaver_api::com_atproto::repo::list_records::ListRecords;
310310+311311+ let did = fetcher
312312+ .current_did()
313313+ .await
314314+ .ok_or_else(|| WeaverError::InvalidNotebook("Not authenticated".into()))?;
315315+316316+ let client = fetcher.get_client();
317317+ let collection = Nsid::new(DRAFT_NSID).map_err(WeaverError::AtprotoString)?;
318318+319319+ let request = ListRecords::new()
320320+ .repo(did)
321321+ .collection(collection)
322322+ .limit(100)
323323+ .build();
324324+325325+ let response = client
326326+ .send(request)
327327+ .await
328328+ .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to list drafts: {}", e)))?;
329329+330330+ let output = response.into_output().map_err(|e| {
331331+ WeaverError::InvalidNotebook(format!("Failed to parse list records response: {}", e))
332332+ })?;
333333+334334+ tracing::debug!("list_drafts_from_pds: found {} records", output.records.len());
335335+336336+ let mut drafts = Vec::new();
337337+ for record in output.records {
338338+ let rkey = record
339339+ .uri
340340+ .rkey()
341341+ .map(|r| r.0.as_str().to_string())
342342+ .unwrap_or_default();
343343+344344+ tracing::debug!(" Draft record: uri={}, rkey={}", record.uri, rkey);
345345+346346+ // Parse the draft record to get createdAt
347347+ let created_at = jacquard::from_data::<weaver_api::sh_weaver::edit::draft::Draft>(&record.value)
348348+ .map(|d| d.created_at.to_string())
349349+ .unwrap_or_default();
350350+351351+ drafts.push(RemoteDraft {
352352+ uri: record.uri.into_static(),
353353+ rkey,
354354+ created_at,
355355+ });
356356+ }
357357+358358+ Ok(drafts)
359359+}
360360+132361/// Find all diffs for a root record using constellation backlinks.
133362#[allow(dead_code)]
134363pub async fn find_diffs_for_root(
···178407///
179408/// Uploads the current Loro snapshot as a blob and creates an `sh.weaver.edit.root`
180409/// record referencing the entry (or draft key if unpublished).
410410+///
411411+/// For drafts, also creates the `sh.weaver.edit.draft` stub record first.
181412pub async fn create_edit_root(
182413 fetcher: &Fetcher,
183414 doc: &EditorDocument,
···191422 .await
192423 .ok_or_else(|| WeaverError::InvalidNotebook("Not authenticated".into()))?;
193424425425+ // For drafts, create the stub record first (makes it discoverable via listRecords)
426426+ if entry_uri.is_none() {
427427+ let rkey = extract_draft_rkey(draft_key);
428428+ // Try to create draft stub, ignore if it already exists
429429+ match create_draft_stub(fetcher, &did, &rkey).await {
430430+ Ok((uri, _cid)) => {
431431+ tracing::debug!("Created draft stub: {}", uri);
432432+ }
433433+ Err(e) => {
434434+ // Check if it's a "record already exists" error - that's fine
435435+ let err_str = e.to_string();
436436+ if !err_str.contains("RecordAlreadyExists") && !err_str.contains("already exists") {
437437+ tracing::warn!("Failed to create draft stub (continuing anyway): {}", e);
438438+ }
439439+ }
440440+ }
441441+ }
442442+194443 // Export full snapshot
195444 let snapshot = doc.export_snapshot();
196445···202451 .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to upload snapshot: {}", e)))?;
203452204453 // Build DocRef - use EntryRef if published, DraftRef if not
205205- let doc_ref = build_doc_ref(draft_key, entry_uri, entry_cid);
454454+ let doc_ref = build_doc_ref(&did, draft_key, entry_uri, entry_cid);
206455207456 // Build root record
208457 let root = Root::new().doc(doc_ref).snapshot(blob_ref).build();
···277526 };
278527279528 // Build DocRef - use EntryRef if published, DraftRef if not
280280- let doc_ref = build_doc_ref(draft_key, entry_uri, entry_cid);
529529+ let doc_ref = build_doc_ref(&did, draft_key, entry_uri, entry_cid);
281530282531 // Build root reference
283532 let root_ref = StrongRef::new()
···464713 None => return Ok(None),
465714 };
466715716716+ load_edit_state_from_root_id(fetcher, root_id).await
717717+}
718718+719719+/// Load edit state from the PDS for a draft.
720720+///
721721+/// Finds the edit root via constellation backlinks using the draft URI,
722722+/// fetches all diffs, and returns the snapshot + updates.
723723+pub async fn load_edit_state_from_draft(
724724+ fetcher: &Fetcher,
725725+ draft_uri: &AtUri<'_>,
726726+) -> Result<Option<PdsEditState>, WeaverError> {
727727+ // Find the edit root for this draft
728728+ let root_id = match find_edit_root_for_draft(fetcher, draft_uri).await? {
729729+ Some(id) => id,
730730+ None => return Ok(None),
731731+ };
732732+733733+ load_edit_state_from_root_id(fetcher, root_id).await
734734+}
735735+736736+/// Internal helper to load edit state given a root record ID.
737737+async fn load_edit_state_from_root_id(
738738+ fetcher: &Fetcher,
739739+ root_id: RecordId<'static>,
740740+) -> Result<Option<PdsEditState>, WeaverError> {
467741 // Build root URI
468742 let root_uri = AtUri::new(&format!(
469743 "at://{}/{}/{}",
···591865/// Loads from localStorage and PDS (if available), then merges both using Loro's
592866/// CRDT merge. The result is a pre-merged LoroDoc that can be converted to an
593867/// EditorDocument inside a reactive context using `use_hook`.
868868+///
869869+/// For unpublished drafts, attempts to discover edit state via Constellation
870870+/// using the synthetic draft URI.
594871pub async fn load_and_merge_document(
595872 fetcher: &Fetcher,
596873 draft_key: &str,
···601878 // Load snapshot + entry_ref from localStorage
602879 let local_data = load_snapshot_from_storage(draft_key);
603880604604- // Load from PDS (only if we have an entry URI)
881881+ // Load from PDS - try entry URI first, then draft discovery
605882 let pds_state = if let Some(uri) = entry_uri {
883883+ // Published entry: query by entry URI
606884 load_edit_state_from_pds(fetcher, uri).await?
885885+ } else if let Some(did) = fetcher.current_did().await {
886886+ // Unpublished draft: try to discover via draft URI
887887+ let draft_uri = build_draft_uri(&did, draft_key);
888888+ load_edit_state_from_draft(fetcher, &draft_uri).await?
607889 } else {
890890+ // Not authenticated, can't query PDS
608891 None
609892 };
610893···7611044 let doc = props.document.clone();
7621045 let draft_key = props.draft_key.clone();
7631046764764- // Check if we're authenticated and have an entry to sync
10471047+ // Check if we're authenticated (drafts can sync via DraftRef even without entry)
7651048 let is_authenticated = auth_state.read().is_authenticated();
766766- let has_entry = doc.entry_ref().is_some();
76710497681050 // Auto-sync trigger signal - set to true to trigger a sync
7691051 let mut trigger_sync = use_signal(|| false);
···8341116 return;
8351117 }
8361118837837- // Check if authenticated and has entry
838838- if !is_authenticated || !has_entry {
11191119+ // Check if authenticated (drafts can sync too via DraftRef)
11201120+ if !is_authenticated {
8391121 return;
8401122 }
8411123···8891171 trigger_sync.set(true);
8901172 };
8911173892892- // Determine display state
11741174+ // Determine display state (drafts can sync too via DraftRef)
8931175 let display_state = if !is_authenticated {
8941176 SyncState::Disabled
895895- } else if !has_entry {
896896- SyncState::Disabled // Can't sync unpublished entries
8971177 } else {
8981178 *sync_state.read()
8991179 };
+35-12
crates/weaver-app/src/components/entry.rs
···398398 .format("%B %d, %Y")
399399 .to_string();
400400401401- // Check ownership
402402- let is_owner = {
401401+ // Check edit access via permissions
402402+ let can_edit = {
403403 let current_did = auth_state.read().did.clone();
404404- match (¤t_did, &ident) {
405405- (Some(did), AtIdentifier::Did(ident_did)) => *did == *ident_did,
406406- _ => false,
404404+ match ¤t_did {
405405+ Some(did) => {
406406+ if let Some(ref perms) = entry_view.permissions {
407407+ perms.editors.iter().any(|grant| grant.did == *did)
408408+ } else {
409409+ // Fall back to ownership check
410410+ match &ident {
411411+ AtIdentifier::Did(ident_did) => *did == *ident_did,
412412+ _ => false,
413413+ }
414414+ }
415415+ }
416416+ None => false,
407417 }
408418 };
409419···441451 div { class: "entry-card-date",
442452 time { datetime: "{entry_view.indexed_at.as_str()}", "{formatted_date}" }
443453 }
444444- if is_owner {
454454+ if can_edit {
445455 EntryActions {
446456 entry_uri,
447457 entry_cid: entry_view.cid.clone().into_static(),
448458 entry_title: title.to_string(),
449459 in_notebook: true,
450460 notebook_title: Some(book_title.clone()),
461461+ permissions: entry_view.permissions.clone(),
451462 on_removed: Some(EventHandler::new(move |_| hidden.set(true)))
452463 }
453464 }
···568579 // Get first author if we're showing it
569580 let first_author = if show_author { entry_view.authors.first() } else { None };
570581571571- // Check ownership for actions
582582+ // Check edit access via permissions
572583 let auth_state = use_context::<Signal<AuthState>>();
573573- let is_owner = {
584584+ let can_edit = {
574585 let current_did = auth_state.read().did.clone();
575575- match (¤t_did, &ident) {
576576- (Some(did), AtIdentifier::Did(ident_did)) => *did == *ident_did,
577577- _ => false,
586586+ match ¤t_did {
587587+ Some(did) => {
588588+ if let Some(ref perms) = entry_view.permissions {
589589+ perms.editors.iter().any(|grant| grant.did == *did)
590590+ } else {
591591+ // Fall back to ownership check
592592+ match &ident {
593593+ AtIdentifier::Did(ident_did) => *did == *ident_did,
594594+ _ => false,
595595+ }
596596+ }
597597+ }
598598+ None => false,
578599 }
579600 };
580601···604625 time { datetime: "{entry.created_at.as_str()}", "{formatted_date}" }
605626 }
606627 }
607607- if show_actions && is_owner {
628628+ if show_actions && can_edit {
608629 crate::components::EntryActions {
609630 entry_uri: entry_view.uri.clone().into_static(),
610631 entry_cid: entry_view.cid.clone().into_static(),
611632 entry_title: title.to_string(),
612633 in_notebook: false,
613634 is_pinned,
635635+ permissions: entry_view.permissions.clone(),
614636 on_pinned_changed
615637 }
616638 }
···739761 entry_title,
740762 in_notebook: book_title.is_some(),
741763 notebook_title: book_title.clone(),
764764+ permissions: entry_view.permissions.clone(),
742765 on_removed: Some(EventHandler::new(on_removed))
743766 }
744767 }
+20-6
crates/weaver-app/src/components/entry_actions.rs
···1515use weaver_api::com_atproto::repo::put_record::PutRecord;
1616use weaver_api::com_atproto::repo::strong_ref::StrongRef;
1717use weaver_api::sh_weaver::actor::profile::Profile as WeaverProfile;
1818+use weaver_api::sh_weaver::notebook::PermissionsState;
18191920const ENTRY_ACTIONS_CSS: Asset = asset!("/assets/styling/entry-actions.css");
2021···3536 /// Whether this entry is currently pinned
3637 #[props(default = false)]
3738 pub is_pinned: bool,
3939+ /// Permissions state for edit access checking (if available)
4040+ #[props(default)]
4141+ pub permissions: Option<PermissionsState<'static>>,
3842 /// Callback when entry is removed from notebook (for optimistic UI update)
3943 #[props(default)]
4044 pub on_removed: Option<EventHandler<()>>,
···5862 let mut pinning = use_signal(|| false);
5963 let mut error = use_signal(|| None::<String>);
60646161- // Check ownership - compare auth DID with entry's authority
6565+ // Check edit access - use permissions if available, fall back to ownership check
6266 let current_did = auth_state.read().did.clone();
6363- let entry_authority = props.entry_uri.authority();
6464- let is_owner = match (¤t_did, entry_authority) {
6565- (Some(current), AtIdentifier::Did(entry_did)) => *current == *entry_did,
6666- _ => false,
6767+ let can_edit = match ¤t_did {
6868+ Some(did) => {
6969+ if let Some(ref perms) = props.permissions {
7070+ // Use ACL-based permissions
7171+ perms.editors.iter().any(|grant| grant.did == *did)
7272+ } else {
7373+ // Fall back to ownership check
7474+ match props.entry_uri.authority() {
7575+ AtIdentifier::Did(entry_did) => *did == *entry_did,
7676+ _ => false,
7777+ }
7878+ }
7979+ }
8080+ None => false,
6781 };
68826969- if !is_owner {
8383+ if !can_edit {
7084 return rsx! {};
7185 }
7286
+36-25
crates/weaver-app/src/components/identity.rs
···9090 use jacquard::from_data;
9191 use weaver_api::sh_weaver::notebook::book::Book;
92929393+ let auth_state = use_context::<Signal<AuthState>>();
9494+9395 // Use client-only versions to avoid SSR issues with concurrent server futures
9496 let (_profile_res, profile) = data::use_profile_data_client(ident);
9597 let (_notebooks_res, notebooks) = data::use_notebooks_for_did_client(ident);
9698 let (_entries_res, all_entries) = data::use_entries_for_did_client(ident);
9999+100100+ // Check if viewing own profile
101101+ let is_own_profile = use_memo(move || {
102102+ let current_did = auth_state.read().did.clone();
103103+ match (¤t_did, ident()) {
104104+ (Some(did), AtIdentifier::Did(profile_did)) => *did == profile_did,
105105+ _ => false,
106106+ }
107107+ });
9710898109 // Extract pinned URIs from profile (only Weaver ProfileView has pinned)
99110 let pinned_uris = use_memo(move || {
···320331 div { class: "repository-layout",
321332 // Profile sidebar (desktop) / header (mobile)
322333 aside { class: "repository-sidebar",
323323- ProfileDisplay { profile, notebooks, entry_count: *entry_count.read() }
334334+ ProfileDisplay { profile, notebooks, entry_count: *entry_count.read(), is_own_profile: is_own_profile() }
324335 }
325336326337 // Main content area
···622633 if let Some(ref date) = created_at {
623634 div { class: "entry-preview-date", "{date}" }
624635 }
625625- if is_owner {
626626- crate::components::EntryActions {
627627- entry_uri,
628628- entry_cid: entry_view.entry.cid.clone().into_static(),
629629- entry_title: entry_title.to_string(),
630630- in_notebook: true,
631631- notebook_title: Some(book_title.clone())
632632- }
636636+ // EntryActions handles visibility via permissions
637637+ crate::components::EntryActions {
638638+ entry_uri,
639639+ entry_cid: entry_view.entry.cid.clone().into_static(),
640640+ entry_title: entry_title.to_string(),
641641+ in_notebook: true,
642642+ notebook_title: Some(book_title.clone()),
643643+ permissions: entry_view.entry.permissions.clone()
633644 }
634645 }
635646 if let Some(ref html) = preview_html {
···693704 if let Some(ref date) = created_at {
694705 div { class: "entry-preview-date", "{date}" }
695706 }
696696- if is_owner {
697697- crate::components::EntryActions {
698698- entry_uri,
699699- entry_cid: first_entry.entry.cid.clone().into_static(),
700700- entry_title: entry_title.to_string(),
701701- in_notebook: true,
702702- notebook_title: Some(book_title.clone())
703703- }
707707+ // EntryActions handles visibility via permissions
708708+ crate::components::EntryActions {
709709+ entry_uri,
710710+ entry_cid: first_entry.entry.cid.clone().into_static(),
711711+ entry_title: entry_title.to_string(),
712712+ in_notebook: true,
713713+ notebook_title: Some(book_title.clone()),
714714+ permissions: first_entry.entry.permissions.clone()
704715 }
705716 }
706717 if let Some(ref html) = preview_html {
···773784 if let Some(ref date) = created_at {
774785 div { class: "entry-preview-date", "{date}" }
775786 }
776776- if is_owner {
777777- crate::components::EntryActions {
778778- entry_uri,
779779- entry_cid: last_entry.entry.cid.clone().into_static(),
780780- entry_title: entry_title.to_string(),
781781- in_notebook: true,
782782- notebook_title: Some(book_title.clone())
783783- }
787787+ // EntryActions handles visibility via permissions
788788+ crate::components::EntryActions {
789789+ entry_uri,
790790+ entry_cid: last_entry.entry.cid.clone().into_static(),
791791+ entry_title: entry_title.to_string(),
792792+ in_notebook: true,
793793+ notebook_title: Some(book_title.clone()),
794794+ permissions: last_entry.entry.permissions.clone()
784795 }
785796 }
786797 if let Some(ref html) = preview_html {
+3
crates/weaver-app/src/components/mod.rs
···2828pub mod record_editor;
2929pub mod record_view;
30303131+pub mod collab;
3232+pub use collab::{CollaboratorAvatars, CollaboratorsPanel, InviteDialog, InvitesList};
3333+3134use dioxus::prelude::*;
32353336#[derive(PartialEq, Props, Clone)]
+147-2
crates/weaver-app/src/components/profile.rs
···2233use std::sync::Arc;
4455+use crate::Route;
66+use crate::components::button::{Button, ButtonVariant};
77+use crate::components::collab::api::{ReceivedInvite, accept_invite, fetch_received_invites};
58use crate::components::{
69 BskyIcon, TangledIcon,
710 avatar::{Avatar, AvatarImage},
811};
1212+use crate::fetch::Fetcher;
913use dioxus::prelude::*;
1014use weaver_api::com_atproto::repo::strong_ref::StrongRef;
1115use weaver_api::sh_weaver::actor::{ProfileDataView, ProfileDataViewInner};
···1822 profile: Memo<Option<ProfileDataView<'static>>>,
1923 notebooks: Memo<Option<Vec<(NotebookView<'static>, Vec<StrongRef<'static>>)>>>,
2024 #[props(default)] entry_count: usize,
2525+ #[props(default)] is_own_profile: bool,
2126) -> Element {
2227 match &*profile.read() {
2328 Some(profile_view) => {
···63686469 // Links
6570 ProfileLinks { profile_view }
7171+7272+ // Invites (only on own profile)
7373+ if is_own_profile {
7474+ ProfileInvites {}
7575+ }
6676 }
6767-6868-6977 }
7078 }
7179 }
···322330 _ => rsx! {},
323331 }
324332}
333333+334334+/// Shows pending collaboration invites on the user's own profile.
335335+#[component]
336336+fn ProfileInvites() -> Element {
337337+ let fetcher = use_context::<Fetcher>();
338338+339339+ // Fetch received invites
340340+ let invites_resource = {
341341+ let fetcher = fetcher.clone();
342342+ use_resource(move || {
343343+ let fetcher = fetcher.clone();
344344+ async move {
345345+ fetch_received_invites(&fetcher)
346346+ .await
347347+ .ok()
348348+ .unwrap_or_default()
349349+ }
350350+ })
351351+ };
352352+353353+ let invites: Vec<ReceivedInvite> = invites_resource().unwrap_or_default();
354354+355355+ // Don't render section if no invites
356356+ if invites.is_empty() {
357357+ return rsx! {};
358358+ }
359359+360360+ rsx! {
361361+ div { class: "profile-invites",
362362+ h3 { class: "profile-invites-header", "Collaboration Invites" }
363363+364364+ div { class: "profile-invites-list",
365365+ for invite in invites {
366366+ ProfileInviteCard { invite }
367367+ }
368368+ }
369369+ }
370370+ }
371371+}
372372+373373+/// A single invite card in the profile sidebar.
374374+#[component]
375375+fn ProfileInviteCard(invite: ReceivedInvite) -> Element {
376376+ let fetcher = use_context::<Fetcher>();
377377+ let nav = use_navigator();
378378+ let mut is_accepting = use_signal(|| false);
379379+ let mut accepted = use_signal(|| false);
380380+ let mut error = use_signal(|| None::<String>);
381381+382382+ let invite_uri = invite.uri.clone();
383383+ let invite_cid = invite.cid.clone();
384384+ let resource_uri = invite.resource_uri.clone();
385385+ let resource_uri_nav = invite.resource_uri.clone();
386386+387387+ let handle_accept = move |_| {
388388+ let fetcher = fetcher.clone();
389389+ let invite_uri = invite_uri.clone();
390390+ let invite_cid = invite_cid.clone();
391391+ let resource_uri = resource_uri.clone();
392392+ let resource_uri_nav = resource_uri_nav.clone();
393393+394394+ spawn(async move {
395395+ is_accepting.set(true);
396396+ error.set(None);
397397+398398+ let invite_ref = StrongRef::new().uri(invite_uri).cid(invite_cid).build();
399399+400400+ match accept_invite(&fetcher, invite_ref, resource_uri).await {
401401+ Ok(_) => {
402402+ accepted.set(true);
403403+ // Navigate to the resource after a short delay
404404+ #[cfg(target_arch = "wasm32")]
405405+ {
406406+ use gloo_timers::future::TimeoutFuture;
407407+ TimeoutFuture::new(500).await;
408408+ }
409409+ // Navigate to the entry - parse AT-URI into path segments
410410+ // at://did/collection/rkey -> ["did", "collection", "rkey"]
411411+ let uri_str = resource_uri_nav.to_string();
412412+ let uri_parts: Vec<String> = uri_str
413413+ .strip_prefix("at://")
414414+ .unwrap_or(&uri_str)
415415+ .split('/')
416416+ .map(|s| s.to_string())
417417+ .collect();
418418+ nav.push(Route::RecordPage { uri: uri_parts });
419419+ }
420420+ Err(e) => {
421421+ error.set(Some(format!("Failed: {}", e)));
422422+ }
423423+ }
424424+425425+ is_accepting.set(false);
426426+ });
427427+ };
428428+429429+ // Extract inviter display (last part of DID for now)
430430+ let inviter_display = invite
431431+ .inviter
432432+ .as_ref()
433433+ .split(':')
434434+ .last()
435435+ .unwrap_or("unknown")
436436+ .chars()
437437+ .take(12)
438438+ .collect::<String>();
439439+440440+ rsx! {
441441+ div { class: "profile-invite-card",
442442+ div { class: "profile-invite-from",
443443+ "From: "
444444+ span { class: "profile-invite-did", "{inviter_display}…" }
445445+ }
446446+447447+ if let Some(msg) = &invite.message {
448448+ p { class: "profile-invite-message", "{msg}" }
449449+ }
450450+451451+ if let Some(err) = error() {
452452+ div { class: "profile-invite-error", "{err}" }
453453+ }
454454+455455+ div { class: "profile-invite-actions",
456456+ if accepted() {
457457+ span { class: "profile-invite-accepted", "Accepted ✓" }
458458+ } else {
459459+ Button {
460460+ variant: ButtonVariant::Primary,
461461+ onclick: handle_accept,
462462+ disabled: is_accepting(),
463463+ if is_accepting() { "Accepting..." } else { "Accept" }
464464+ }
465465+ }
466466+ }
467467+ }
468468+ }
469469+}
···11811181}
1182118211831183// ============================================================================
11841184+// Edit Access Checking (Ownership + Collaboration)
11851185+// ============================================================================
11861186+11871187+use weaver_api::sh_weaver::actor::ProfileDataViewInner;
11881188+use weaver_api::sh_weaver::notebook::{AuthorListView, PermissionsState};
11891189+11901190+/// Extract DID from a ProfileDataView by matching on the inner variant.
11911191+pub fn extract_did_from_author(author: &AuthorListView<'_>) -> Option<Did<'static>> {
11921192+ match &author.record.inner {
11931193+ ProfileDataViewInner::ProfileView(p) => Some(p.did.clone().into_static()),
11941194+ ProfileDataViewInner::ProfileViewDetailed(p) => Some(p.did.clone().into_static()),
11951195+ ProfileDataViewInner::TangledProfileView(p) => Some(p.did.clone().into_static()),
11961196+ _ => None,
11971197+ }
11981198+}
11991199+12001200+/// Check if the current user can edit a resource based on the permissions state.
12011201+///
12021202+/// Returns a memo that is:
12031203+/// - `Some(true)` if the user is authenticated and their DID is in permissions.editors
12041204+/// - `Some(false)` if the user is authenticated but not in editors
12051205+/// - `None` if the user is not authenticated or permissions not yet loaded
12061206+///
12071207+/// This checks the ACL-based permissions (who CAN edit), not authors (who contributed).
12081208+pub fn use_can_edit(
12091209+ permissions: Memo<Option<PermissionsState<'static>>>,
12101210+) -> Memo<Option<bool>> {
12111211+ let auth_state = use_context::<Signal<AuthState>>();
12121212+12131213+ use_memo(move || {
12141214+ let current_did = auth_state.read().did.clone()?;
12151215+ let perms = permissions()?;
12161216+12171217+ // Check if current user's DID is in the editors list
12181218+ let can_edit = perms
12191219+ .editors
12201220+ .iter()
12211221+ .any(|grant| grant.did == current_did);
12221222+12231223+ Some(can_edit)
12241224+ })
12251225+}
12261226+12271227+/// Legacy: Check if the current user can edit based on authors list.
12281228+///
12291229+/// Use `use_can_edit` with permissions instead when available.
12301230+/// This is kept for backwards compatibility during transition.
12311231+pub fn use_can_edit_from_authors(
12321232+ authors: Memo<Vec<AuthorListView<'static>>>,
12331233+) -> Memo<Option<bool>> {
12341234+ let auth_state = use_context::<Signal<AuthState>>();
12351235+12361236+ use_memo(move || {
12371237+ let current_did = auth_state.read().did.clone()?;
12381238+ let author_list = authors();
12391239+12401240+ let can_edit = author_list
12411241+ .iter()
12421242+ .filter_map(extract_did_from_author)
12431243+ .any(|did| did == current_did);
12441244+12451245+ Some(can_edit)
12461246+ })
12471247+}
12481248+12491249+/// Check edit access for a resource URI using the WeaverExt trait methods.
12501250+///
12511251+/// This performs an async check that queries Constellation for collaboration records.
12521252+/// Use this when you have a resource URI but not the pre-populated authors list.
12531253+pub fn use_can_edit_resource(
12541254+ resource_uri: ReadSignal<AtUri<'static>>,
12551255+) -> Resource<Option<bool>> {
12561256+ let auth_state = use_context::<Signal<AuthState>>();
12571257+ let fetcher = use_context::<crate::fetch::Fetcher>();
12581258+12591259+ use_resource(move || {
12601260+ let fetcher = fetcher.clone();
12611261+ let uri = resource_uri();
12621262+ async move {
12631263+ use weaver_common::agent::WeaverExt;
12641264+12651265+ let current_did = auth_state.read().did.clone()?;
12661266+12671267+ // Check ownership first (fast path)
12681268+ if let AtIdentifier::Did(owner_did) = uri.authority() {
12691269+ if *owner_did == current_did {
12701270+ return Some(true);
12711271+ }
12721272+ }
12731273+12741274+ // Check collaboration via Constellation
12751275+ match fetcher.can_user_edit_resource(&uri, ¤t_did).await {
12761276+ Ok(can_edit) => Some(can_edit),
12771277+ Err(_) => Some(false),
12781278+ }
12791279+ }
12801280+ })
12811281+}
12821282+12831283+// ============================================================================
11841284// Standalone Entry by Rkey Hooks
11851285// ============================================================================
11861286