first pass at the collab flow basically working

Orual d4a64dcb e7defa5c

+4270 -151
+23
crates/weaver-api/lexicons/sh_weaver_edit_draft.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.weaver.edit.draft", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "Stub record for unpublished drafts. Acts as an anchor for edit.root/diff records and enables draft discovery via listRecords.", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": [ 12 + "createdAt" 13 + ], 14 + "properties": { 15 + "createdAt": { 16 + "type": "string", 17 + "format": "datetime" 18 + } 19 + } 20 + } 21 + } 22 + } 23 + }
+67
crates/weaver-api/lexicons/sh_weaver_notebook_defs.json
··· 105 105 "type": "ref", 106 106 "ref": "#path" 107 107 }, 108 + "permissions": { 109 + "type": "ref", 110 + "ref": "#permissionsState" 111 + }, 108 112 "record": { 109 113 "type": "unknown" 110 114 }, ··· 155 159 "type": "ref", 156 160 "ref": "#path" 157 161 }, 162 + "permissions": { 163 + "type": "ref", 164 + "ref": "#permissionsState" 165 + }, 158 166 "record": { 159 167 "type": "unknown" 160 168 }, ··· 176 184 "type": "string", 177 185 "description": "The path of the notebook.", 178 186 "maxLength": 100 187 + }, 188 + "permissionGrant": { 189 + "type": "object", 190 + "description": "A single permission grant. For resource authority: source=resource URI, grantedAt=createdAt. For invitees: source=invite URI, grantedAt=accept createdAt.", 191 + "required": [ 192 + "did", 193 + "scope", 194 + "source", 195 + "grantedAt" 196 + ], 197 + "properties": { 198 + "did": { 199 + "type": "string", 200 + "format": "did" 201 + }, 202 + "grantedAt": { 203 + "type": "string", 204 + "description": "For authority: record createdAt. For invitees: accept createdAt", 205 + "format": "datetime" 206 + }, 207 + "scope": { 208 + "type": "string", 209 + "description": "direct = this resource (includes authority), inherited = via notebook invite", 210 + "knownValues": [ 211 + "direct", 212 + "inherited" 213 + ] 214 + }, 215 + "source": { 216 + "type": "string", 217 + "description": "For authority: resource URI. For invitees: invite URI", 218 + "format": "at-uri" 219 + } 220 + } 221 + }, 222 + "permissionsState": { 223 + "type": "object", 224 + "description": "ACL-style permissions for a resource. Separate from authors (who contributed).", 225 + "required": [ 226 + "editors" 227 + ], 228 + "properties": { 229 + "editors": { 230 + "type": "array", 231 + "description": "DIDs that can edit this resource", 232 + "items": { 233 + "type": "ref", 234 + "ref": "#permissionGrant" 235 + } 236 + }, 237 + "viewers": { 238 + "type": "array", 239 + "description": "DIDs that can view (future use)", 240 + "items": { 241 + "type": "ref", 242 + "ref": "#permissionGrant" 243 + } 244 + } 245 + } 179 246 }, 180 247 "renderedView": { 181 248 "type": "object",
+1
crates/weaver-api/src/sh_weaver/edit.rs
··· 7 7 8 8 pub mod cursor; 9 9 pub mod diff; 10 + pub mod draft; 10 11 pub mod root; 11 12 12 13 #[jacquard_derive::lexicon]
+264
crates/weaver-api/src/sh_weaver/edit/draft.rs
··· 1 + // @generated by jacquard-lexicon. DO NOT EDIT. 2 + // 3 + // Lexicon: sh.weaver.edit.draft 4 + // 5 + // This file was automatically generated from Lexicon schemas. 6 + // Any manual changes will be overwritten on the next regeneration. 7 + 8 + /// Stub record for unpublished drafts. Acts as an anchor for edit.root/diff records and enables draft discovery via listRecords. 9 + #[jacquard_derive::lexicon] 10 + #[derive( 11 + serde::Serialize, 12 + serde::Deserialize, 13 + Debug, 14 + Clone, 15 + PartialEq, 16 + Eq, 17 + jacquard_derive::IntoStatic 18 + )] 19 + #[serde(rename_all = "camelCase")] 20 + pub struct Draft<'a> { 21 + pub created_at: jacquard_common::types::string::Datetime, 22 + } 23 + 24 + pub mod draft_state { 25 + 26 + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; 27 + #[allow(unused)] 28 + use ::core::marker::PhantomData; 29 + mod sealed { 30 + pub trait Sealed {} 31 + } 32 + /// State trait tracking which required fields have been set 33 + pub trait State: sealed::Sealed { 34 + type CreatedAt; 35 + } 36 + /// Empty state - all required fields are unset 37 + pub struct Empty(()); 38 + impl sealed::Sealed for Empty {} 39 + impl State for Empty { 40 + type CreatedAt = Unset; 41 + } 42 + ///State transition - sets the `created_at` field to Set 43 + pub struct SetCreatedAt<S: State = Empty>(PhantomData<fn() -> S>); 44 + impl<S: State> sealed::Sealed for SetCreatedAt<S> {} 45 + impl<S: State> State for SetCreatedAt<S> { 46 + type CreatedAt = Set<members::created_at>; 47 + } 48 + /// Marker types for field names 49 + #[allow(non_camel_case_types)] 50 + pub mod members { 51 + ///Marker type for the `created_at` field 52 + pub struct created_at(()); 53 + } 54 + } 55 + 56 + /// Builder for constructing an instance of this type 57 + pub struct DraftBuilder<'a, S: draft_state::State> { 58 + _phantom_state: ::core::marker::PhantomData<fn() -> S>, 59 + __unsafe_private_named: ( 60 + ::core::option::Option<jacquard_common::types::string::Datetime>, 61 + ), 62 + _phantom: ::core::marker::PhantomData<&'a ()>, 63 + } 64 + 65 + impl<'a> Draft<'a> { 66 + /// Create a new builder for this type 67 + pub fn new() -> DraftBuilder<'a, draft_state::Empty> { 68 + DraftBuilder::new() 69 + } 70 + } 71 + 72 + impl<'a> DraftBuilder<'a, draft_state::Empty> { 73 + /// Create a new builder with all fields unset 74 + pub fn new() -> Self { 75 + DraftBuilder { 76 + _phantom_state: ::core::marker::PhantomData, 77 + __unsafe_private_named: (None,), 78 + _phantom: ::core::marker::PhantomData, 79 + } 80 + } 81 + } 82 + 83 + impl<'a, S> DraftBuilder<'a, S> 84 + where 85 + S: draft_state::State, 86 + S::CreatedAt: draft_state::IsUnset, 87 + { 88 + /// Set the `createdAt` field (required) 89 + pub fn created_at( 90 + mut self, 91 + value: impl Into<jacquard_common::types::string::Datetime>, 92 + ) -> DraftBuilder<'a, draft_state::SetCreatedAt<S>> { 93 + self.__unsafe_private_named.0 = ::core::option::Option::Some(value.into()); 94 + DraftBuilder { 95 + _phantom_state: ::core::marker::PhantomData, 96 + __unsafe_private_named: self.__unsafe_private_named, 97 + _phantom: ::core::marker::PhantomData, 98 + } 99 + } 100 + } 101 + 102 + impl<'a, S> DraftBuilder<'a, S> 103 + where 104 + S: draft_state::State, 105 + S::CreatedAt: draft_state::IsSet, 106 + { 107 + /// Build the final struct 108 + pub fn build(self) -> Draft<'a> { 109 + Draft { 110 + created_at: self.__unsafe_private_named.0.unwrap(), 111 + extra_data: Default::default(), 112 + } 113 + } 114 + /// Build the final struct with custom extra_data 115 + pub fn build_with_data( 116 + self, 117 + extra_data: std::collections::BTreeMap< 118 + jacquard_common::smol_str::SmolStr, 119 + jacquard_common::types::value::Data<'a>, 120 + >, 121 + ) -> Draft<'a> { 122 + Draft { 123 + created_at: self.__unsafe_private_named.0.unwrap(), 124 + extra_data: Some(extra_data), 125 + } 126 + } 127 + } 128 + 129 + impl<'a> Draft<'a> { 130 + pub fn uri( 131 + uri: impl Into<jacquard_common::CowStr<'a>>, 132 + ) -> Result< 133 + jacquard_common::types::uri::RecordUri<'a, DraftRecord>, 134 + jacquard_common::types::uri::UriError, 135 + > { 136 + jacquard_common::types::uri::RecordUri::try_from_uri( 137 + jacquard_common::types::string::AtUri::new_cow(uri.into())?, 138 + ) 139 + } 140 + } 141 + 142 + /// Typed wrapper for GetRecord response with this collection's record type. 143 + #[derive( 144 + serde::Serialize, 145 + serde::Deserialize, 146 + Debug, 147 + Clone, 148 + PartialEq, 149 + Eq, 150 + jacquard_derive::IntoStatic 151 + )] 152 + #[serde(rename_all = "camelCase")] 153 + pub struct DraftGetRecordOutput<'a> { 154 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 155 + #[serde(borrow)] 156 + pub cid: std::option::Option<jacquard_common::types::string::Cid<'a>>, 157 + #[serde(borrow)] 158 + pub uri: jacquard_common::types::string::AtUri<'a>, 159 + #[serde(borrow)] 160 + pub value: Draft<'a>, 161 + } 162 + 163 + impl From<DraftGetRecordOutput<'_>> for Draft<'_> { 164 + fn from(output: DraftGetRecordOutput<'_>) -> Self { 165 + use jacquard_common::IntoStatic; 166 + output.value.into_static() 167 + } 168 + } 169 + 170 + impl jacquard_common::types::collection::Collection for Draft<'_> { 171 + const NSID: &'static str = "sh.weaver.edit.draft"; 172 + type Record = DraftRecord; 173 + } 174 + 175 + /// Marker type for deserializing records from this collection. 176 + #[derive(Debug, serde::Serialize, serde::Deserialize)] 177 + pub struct DraftRecord; 178 + impl jacquard_common::xrpc::XrpcResp for DraftRecord { 179 + const NSID: &'static str = "sh.weaver.edit.draft"; 180 + const ENCODING: &'static str = "application/json"; 181 + type Output<'de> = DraftGetRecordOutput<'de>; 182 + type Err<'de> = jacquard_common::types::collection::RecordError<'de>; 183 + } 184 + 185 + impl jacquard_common::types::collection::Collection for DraftRecord { 186 + const NSID: &'static str = "sh.weaver.edit.draft"; 187 + type Record = DraftRecord; 188 + } 189 + 190 + impl<'a> ::jacquard_lexicon::schema::LexiconSchema for Draft<'a> { 191 + fn nsid() -> &'static str { 192 + "sh.weaver.edit.draft" 193 + } 194 + fn def_name() -> &'static str { 195 + "main" 196 + } 197 + fn lexicon_doc() -> ::jacquard_lexicon::lexicon::LexiconDoc<'static> { 198 + lexicon_doc_sh_weaver_edit_draft() 199 + } 200 + fn validate( 201 + &self, 202 + ) -> ::std::result::Result<(), ::jacquard_lexicon::validation::ConstraintError> { 203 + Ok(()) 204 + } 205 + } 206 + 207 + fn lexicon_doc_sh_weaver_edit_draft() -> ::jacquard_lexicon::lexicon::LexiconDoc< 208 + 'static, 209 + > { 210 + ::jacquard_lexicon::lexicon::LexiconDoc { 211 + lexicon: ::jacquard_lexicon::lexicon::Lexicon::Lexicon1, 212 + id: ::jacquard_common::CowStr::new_static("sh.weaver.edit.draft"), 213 + revision: None, 214 + description: None, 215 + defs: { 216 + let mut map = ::std::collections::BTreeMap::new(); 217 + map.insert( 218 + ::jacquard_common::smol_str::SmolStr::new_static("main"), 219 + ::jacquard_lexicon::lexicon::LexUserType::Record(::jacquard_lexicon::lexicon::LexRecord { 220 + description: Some( 221 + ::jacquard_common::CowStr::new_static( 222 + "Stub record for unpublished drafts. Acts as an anchor for edit.root/diff records and enables draft discovery via listRecords.", 223 + ), 224 + ), 225 + key: Some(::jacquard_common::CowStr::new_static("tid")), 226 + record: ::jacquard_lexicon::lexicon::LexRecordRecord::Object(::jacquard_lexicon::lexicon::LexObject { 227 + description: None, 228 + required: Some( 229 + vec![ 230 + ::jacquard_common::smol_str::SmolStr::new_static("createdAt") 231 + ], 232 + ), 233 + nullable: None, 234 + properties: { 235 + #[allow(unused_mut)] 236 + let mut map = ::std::collections::BTreeMap::new(); 237 + map.insert( 238 + ::jacquard_common::smol_str::SmolStr::new_static( 239 + "createdAt", 240 + ), 241 + ::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString { 242 + description: None, 243 + format: Some( 244 + ::jacquard_lexicon::lexicon::LexStringFormat::Datetime, 245 + ), 246 + default: None, 247 + min_length: None, 248 + max_length: None, 249 + min_graphemes: None, 250 + max_graphemes: None, 251 + r#enum: None, 252 + r#const: None, 253 + known_values: None, 254 + }), 255 + ); 256 + map 257 + }, 258 + }), 259 + }), 260 + ); 261 + map 262 + }, 263 + } 264 + }
+705 -33
crates/weaver-api/src/sh_weaver/notebook.rs
··· 455 455 }), 456 456 ); 457 457 map.insert( 458 + ::jacquard_common::smol_str::SmolStr::new_static( 459 + "permissions", 460 + ), 461 + ::jacquard_lexicon::lexicon::LexObjectProperty::Ref(::jacquard_lexicon::lexicon::LexRef { 462 + description: None, 463 + r#ref: ::jacquard_common::CowStr::new_static( 464 + "#permissionsState", 465 + ), 466 + }), 467 + ); 468 + map.insert( 458 469 ::jacquard_common::smol_str::SmolStr::new_static("record"), 459 470 ::jacquard_lexicon::lexicon::LexObjectProperty::Unknown(::jacquard_lexicon::lexicon::LexUnknown { 460 471 description: None, ··· 581 592 }), 582 593 ); 583 594 map.insert( 595 + ::jacquard_common::smol_str::SmolStr::new_static( 596 + "permissions", 597 + ), 598 + ::jacquard_lexicon::lexicon::LexObjectProperty::Ref(::jacquard_lexicon::lexicon::LexRef { 599 + description: None, 600 + r#ref: ::jacquard_common::CowStr::new_static( 601 + "#permissionsState", 602 + ), 603 + }), 604 + ); 605 + map.insert( 584 606 ::jacquard_common::smol_str::SmolStr::new_static("record"), 585 607 ::jacquard_lexicon::lexicon::LexObjectProperty::Unknown(::jacquard_lexicon::lexicon::LexUnknown { 586 608 description: None, ··· 638 660 r#enum: None, 639 661 r#const: None, 640 662 known_values: None, 663 + }), 664 + ); 665 + map.insert( 666 + ::jacquard_common::smol_str::SmolStr::new_static("permissionGrant"), 667 + ::jacquard_lexicon::lexicon::LexUserType::Object(::jacquard_lexicon::lexicon::LexObject { 668 + description: Some( 669 + ::jacquard_common::CowStr::new_static( 670 + "A single permission grant. For resource authority: source=resource URI, grantedAt=createdAt. For invitees: source=invite URI, grantedAt=accept createdAt.", 671 + ), 672 + ), 673 + required: Some( 674 + vec![ 675 + ::jacquard_common::smol_str::SmolStr::new_static("did"), 676 + ::jacquard_common::smol_str::SmolStr::new_static("scope"), 677 + ::jacquard_common::smol_str::SmolStr::new_static("source"), 678 + ::jacquard_common::smol_str::SmolStr::new_static("grantedAt") 679 + ], 680 + ), 681 + nullable: None, 682 + properties: { 683 + #[allow(unused_mut)] 684 + let mut map = ::std::collections::BTreeMap::new(); 685 + map.insert( 686 + ::jacquard_common::smol_str::SmolStr::new_static("did"), 687 + ::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString { 688 + description: None, 689 + format: Some( 690 + ::jacquard_lexicon::lexicon::LexStringFormat::Did, 691 + ), 692 + default: None, 693 + min_length: None, 694 + max_length: None, 695 + min_graphemes: None, 696 + max_graphemes: None, 697 + r#enum: None, 698 + r#const: None, 699 + known_values: None, 700 + }), 701 + ); 702 + map.insert( 703 + ::jacquard_common::smol_str::SmolStr::new_static( 704 + "grantedAt", 705 + ), 706 + ::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString { 707 + description: Some( 708 + ::jacquard_common::CowStr::new_static( 709 + "For authority: record createdAt. For invitees: accept createdAt", 710 + ), 711 + ), 712 + format: Some( 713 + ::jacquard_lexicon::lexicon::LexStringFormat::Datetime, 714 + ), 715 + default: None, 716 + min_length: None, 717 + max_length: None, 718 + min_graphemes: None, 719 + max_graphemes: None, 720 + r#enum: None, 721 + r#const: None, 722 + known_values: None, 723 + }), 724 + ); 725 + map.insert( 726 + ::jacquard_common::smol_str::SmolStr::new_static("scope"), 727 + ::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString { 728 + description: Some( 729 + ::jacquard_common::CowStr::new_static( 730 + "direct = this resource (includes authority), inherited = via notebook invite", 731 + ), 732 + ), 733 + format: None, 734 + default: None, 735 + min_length: None, 736 + max_length: None, 737 + min_graphemes: None, 738 + max_graphemes: None, 739 + r#enum: None, 740 + r#const: None, 741 + known_values: None, 742 + }), 743 + ); 744 + map.insert( 745 + ::jacquard_common::smol_str::SmolStr::new_static("source"), 746 + ::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString { 747 + description: Some( 748 + ::jacquard_common::CowStr::new_static( 749 + "For authority: resource URI. For invitees: invite URI", 750 + ), 751 + ), 752 + format: Some( 753 + ::jacquard_lexicon::lexicon::LexStringFormat::AtUri, 754 + ), 755 + default: None, 756 + min_length: None, 757 + max_length: None, 758 + min_graphemes: None, 759 + max_graphemes: None, 760 + r#enum: None, 761 + r#const: None, 762 + known_values: None, 763 + }), 764 + ); 765 + map 766 + }, 767 + }), 768 + ); 769 + map.insert( 770 + ::jacquard_common::smol_str::SmolStr::new_static("permissionsState"), 771 + ::jacquard_lexicon::lexicon::LexUserType::Object(::jacquard_lexicon::lexicon::LexObject { 772 + description: Some( 773 + ::jacquard_common::CowStr::new_static( 774 + "ACL-style permissions for a resource. Separate from authors (who contributed).", 775 + ), 776 + ), 777 + required: Some( 778 + vec![::jacquard_common::smol_str::SmolStr::new_static("editors")], 779 + ), 780 + nullable: None, 781 + properties: { 782 + #[allow(unused_mut)] 783 + let mut map = ::std::collections::BTreeMap::new(); 784 + map.insert( 785 + ::jacquard_common::smol_str::SmolStr::new_static("editors"), 786 + ::jacquard_lexicon::lexicon::LexObjectProperty::Array(::jacquard_lexicon::lexicon::LexArray { 787 + description: Some( 788 + ::jacquard_common::CowStr::new_static( 789 + "DIDs that can edit this resource", 790 + ), 791 + ), 792 + items: ::jacquard_lexicon::lexicon::LexArrayItem::Ref(::jacquard_lexicon::lexicon::LexRef { 793 + description: None, 794 + r#ref: ::jacquard_common::CowStr::new_static( 795 + "#permissionGrant", 796 + ), 797 + }), 798 + min_length: None, 799 + max_length: None, 800 + }), 801 + ); 802 + map.insert( 803 + ::jacquard_common::smol_str::SmolStr::new_static("viewers"), 804 + ::jacquard_lexicon::lexicon::LexObjectProperty::Array(::jacquard_lexicon::lexicon::LexArray { 805 + description: Some( 806 + ::jacquard_common::CowStr::new_static( 807 + "DIDs that can view (future use)", 808 + ), 809 + ), 810 + items: ::jacquard_lexicon::lexicon::LexArrayItem::Ref(::jacquard_lexicon::lexicon::LexRef { 811 + description: None, 812 + r#ref: ::jacquard_common::CowStr::new_static( 813 + "#permissionGrant", 814 + ), 815 + }), 816 + min_length: None, 817 + max_length: None, 818 + }), 819 + ); 820 + map 821 + }, 641 822 }), 642 823 ); 643 824 map.insert( ··· 1156 1337 #[serde(skip_serializing_if = "std::option::Option::is_none")] 1157 1338 #[serde(borrow)] 1158 1339 pub path: std::option::Option<crate::sh_weaver::notebook::Path<'a>>, 1340 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 1341 + #[serde(borrow)] 1342 + pub permissions: std::option::Option< 1343 + crate::sh_weaver::notebook::PermissionsState<'a>, 1344 + >, 1159 1345 #[serde(borrow)] 1160 1346 pub record: jacquard_common::types::value::Data<'a>, 1161 1347 #[serde(skip_serializing_if = "std::option::Option::is_none")] ··· 1271 1457 ::core::option::Option<jacquard_common::types::string::Cid<'a>>, 1272 1458 ::core::option::Option<jacquard_common::types::string::Datetime>, 1273 1459 ::core::option::Option<crate::sh_weaver::notebook::Path<'a>>, 1460 + ::core::option::Option<crate::sh_weaver::notebook::PermissionsState<'a>>, 1274 1461 ::core::option::Option<jacquard_common::types::value::Data<'a>>, 1275 1462 ::core::option::Option<crate::sh_weaver::notebook::RenderedView<'a>>, 1276 1463 ::core::option::Option<crate::sh_weaver::notebook::Tags<'a>>, ··· 1293 1480 EntryViewBuilder { 1294 1481 _phantom_state: ::core::marker::PhantomData, 1295 1482 __unsafe_private_named: ( 1483 + None, 1296 1484 None, 1297 1485 None, 1298 1486 None, ··· 1384 1572 } 1385 1573 } 1386 1574 1575 + impl<'a, S: entry_view_state::State> EntryViewBuilder<'a, S> { 1576 + /// Set the `permissions` field (optional) 1577 + pub fn permissions( 1578 + mut self, 1579 + value: impl Into<Option<crate::sh_weaver::notebook::PermissionsState<'a>>>, 1580 + ) -> Self { 1581 + self.__unsafe_private_named.4 = value.into(); 1582 + self 1583 + } 1584 + /// Set the `permissions` field to an Option value (optional) 1585 + pub fn maybe_permissions( 1586 + mut self, 1587 + value: Option<crate::sh_weaver::notebook::PermissionsState<'a>>, 1588 + ) -> Self { 1589 + self.__unsafe_private_named.4 = value; 1590 + self 1591 + } 1592 + } 1593 + 1387 1594 impl<'a, S> EntryViewBuilder<'a, S> 1388 1595 where 1389 1596 S: entry_view_state::State, ··· 1394 1601 mut self, 1395 1602 value: impl Into<jacquard_common::types::value::Data<'a>>, 1396 1603 ) -> EntryViewBuilder<'a, entry_view_state::SetRecord<S>> { 1397 - self.__unsafe_private_named.4 = ::core::option::Option::Some(value.into()); 1604 + self.__unsafe_private_named.5 = ::core::option::Option::Some(value.into()); 1398 1605 EntryViewBuilder { 1399 1606 _phantom_state: ::core::marker::PhantomData, 1400 1607 __unsafe_private_named: self.__unsafe_private_named, ··· 1409 1616 mut self, 1410 1617 value: impl Into<Option<crate::sh_weaver::notebook::RenderedView<'a>>>, 1411 1618 ) -> Self { 1412 - self.__unsafe_private_named.5 = value.into(); 1619 + self.__unsafe_private_named.6 = value.into(); 1413 1620 self 1414 1621 } 1415 1622 /// Set the `renderedView` field to an Option value (optional) ··· 1417 1624 mut self, 1418 1625 value: Option<crate::sh_weaver::notebook::RenderedView<'a>>, 1419 1626 ) -> Self { 1420 - self.__unsafe_private_named.5 = value; 1627 + self.__unsafe_private_named.6 = value; 1421 1628 self 1422 1629 } 1423 1630 } ··· 1428 1635 mut self, 1429 1636 value: impl Into<Option<crate::sh_weaver::notebook::Tags<'a>>>, 1430 1637 ) -> Self { 1431 - self.__unsafe_private_named.6 = value.into(); 1638 + self.__unsafe_private_named.7 = value.into(); 1432 1639 self 1433 1640 } 1434 1641 /// Set the `tags` field to an Option value (optional) ··· 1436 1643 mut self, 1437 1644 value: Option<crate::sh_weaver::notebook::Tags<'a>>, 1438 1645 ) -> Self { 1439 - self.__unsafe_private_named.6 = value; 1646 + self.__unsafe_private_named.7 = value; 1440 1647 self 1441 1648 } 1442 1649 } ··· 1447 1654 mut self, 1448 1655 value: impl Into<Option<crate::sh_weaver::notebook::Title<'a>>>, 1449 1656 ) -> Self { 1450 - self.__unsafe_private_named.7 = value.into(); 1657 + self.__unsafe_private_named.8 = value.into(); 1451 1658 self 1452 1659 } 1453 1660 /// Set the `title` field to an Option value (optional) ··· 1455 1662 mut self, 1456 1663 value: Option<crate::sh_weaver::notebook::Title<'a>>, 1457 1664 ) -> Self { 1458 - self.__unsafe_private_named.7 = value; 1665 + self.__unsafe_private_named.8 = value; 1459 1666 self 1460 1667 } 1461 1668 } ··· 1470 1677 mut self, 1471 1678 value: impl Into<jacquard_common::types::string::AtUri<'a>>, 1472 1679 ) -> EntryViewBuilder<'a, entry_view_state::SetUri<S>> { 1473 - self.__unsafe_private_named.8 = ::core::option::Option::Some(value.into()); 1680 + self.__unsafe_private_named.9 = ::core::option::Option::Some(value.into()); 1474 1681 EntryViewBuilder { 1475 1682 _phantom_state: ::core::marker::PhantomData, 1476 1683 __unsafe_private_named: self.__unsafe_private_named, ··· 1495 1702 cid: self.__unsafe_private_named.1.unwrap(), 1496 1703 indexed_at: self.__unsafe_private_named.2.unwrap(), 1497 1704 path: self.__unsafe_private_named.3, 1498 - record: self.__unsafe_private_named.4.unwrap(), 1499 - rendered_view: self.__unsafe_private_named.5, 1500 - tags: self.__unsafe_private_named.6, 1501 - title: self.__unsafe_private_named.7, 1502 - uri: self.__unsafe_private_named.8.unwrap(), 1705 + permissions: self.__unsafe_private_named.4, 1706 + record: self.__unsafe_private_named.5.unwrap(), 1707 + rendered_view: self.__unsafe_private_named.6, 1708 + tags: self.__unsafe_private_named.7, 1709 + title: self.__unsafe_private_named.8, 1710 + uri: self.__unsafe_private_named.9.unwrap(), 1503 1711 extra_data: Default::default(), 1504 1712 } 1505 1713 } ··· 1516 1724 cid: self.__unsafe_private_named.1.unwrap(), 1517 1725 indexed_at: self.__unsafe_private_named.2.unwrap(), 1518 1726 path: self.__unsafe_private_named.3, 1519 - record: self.__unsafe_private_named.4.unwrap(), 1520 - rendered_view: self.__unsafe_private_named.5, 1521 - tags: self.__unsafe_private_named.6, 1522 - title: self.__unsafe_private_named.7, 1523 - uri: self.__unsafe_private_named.8.unwrap(), 1727 + permissions: self.__unsafe_private_named.4, 1728 + record: self.__unsafe_private_named.5.unwrap(), 1729 + rendered_view: self.__unsafe_private_named.6, 1730 + tags: self.__unsafe_private_named.7, 1731 + title: self.__unsafe_private_named.8, 1732 + uri: self.__unsafe_private_named.9.unwrap(), 1524 1733 extra_data: Some(extra_data), 1525 1734 } 1526 1735 } ··· 1563 1772 #[serde(skip_serializing_if = "std::option::Option::is_none")] 1564 1773 #[serde(borrow)] 1565 1774 pub path: std::option::Option<crate::sh_weaver::notebook::Path<'a>>, 1775 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 1776 + #[serde(borrow)] 1777 + pub permissions: std::option::Option< 1778 + crate::sh_weaver::notebook::PermissionsState<'a>, 1779 + >, 1566 1780 #[serde(borrow)] 1567 1781 pub record: jacquard_common::types::value::Data<'a>, 1568 1782 #[serde(skip_serializing_if = "std::option::Option::is_none")] ··· 1675 1889 ::core::option::Option<jacquard_common::types::string::Cid<'a>>, 1676 1890 ::core::option::Option<jacquard_common::types::string::Datetime>, 1677 1891 ::core::option::Option<crate::sh_weaver::notebook::Path<'a>>, 1892 + ::core::option::Option<crate::sh_weaver::notebook::PermissionsState<'a>>, 1678 1893 ::core::option::Option<jacquard_common::types::value::Data<'a>>, 1679 1894 ::core::option::Option<crate::sh_weaver::notebook::Tags<'a>>, 1680 1895 ::core::option::Option<crate::sh_weaver::notebook::Title<'a>>, ··· 1695 1910 pub fn new() -> Self { 1696 1911 NotebookViewBuilder { 1697 1912 _phantom_state: ::core::marker::PhantomData, 1698 - __unsafe_private_named: (None, None, None, None, None, None, None, None), 1913 + __unsafe_private_named: ( 1914 + None, 1915 + None, 1916 + None, 1917 + None, 1918 + None, 1919 + None, 1920 + None, 1921 + None, 1922 + None, 1923 + ), 1699 1924 _phantom: ::core::marker::PhantomData, 1700 1925 } 1701 1926 } ··· 1777 2002 } 1778 2003 } 1779 2004 2005 + impl<'a, S: notebook_view_state::State> NotebookViewBuilder<'a, S> { 2006 + /// Set the `permissions` field (optional) 2007 + pub fn permissions( 2008 + mut self, 2009 + value: impl Into<Option<crate::sh_weaver::notebook::PermissionsState<'a>>>, 2010 + ) -> Self { 2011 + self.__unsafe_private_named.4 = value.into(); 2012 + self 2013 + } 2014 + /// Set the `permissions` field to an Option value (optional) 2015 + pub fn maybe_permissions( 2016 + mut self, 2017 + value: Option<crate::sh_weaver::notebook::PermissionsState<'a>>, 2018 + ) -> Self { 2019 + self.__unsafe_private_named.4 = value; 2020 + self 2021 + } 2022 + } 2023 + 1780 2024 impl<'a, S> NotebookViewBuilder<'a, S> 1781 2025 where 1782 2026 S: notebook_view_state::State, ··· 1787 2031 mut self, 1788 2032 value: impl Into<jacquard_common::types::value::Data<'a>>, 1789 2033 ) -> NotebookViewBuilder<'a, notebook_view_state::SetRecord<S>> { 1790 - self.__unsafe_private_named.4 = ::core::option::Option::Some(value.into()); 2034 + self.__unsafe_private_named.5 = ::core::option::Option::Some(value.into()); 1791 2035 NotebookViewBuilder { 1792 2036 _phantom_state: ::core::marker::PhantomData, 1793 2037 __unsafe_private_named: self.__unsafe_private_named, ··· 1802 2046 mut self, 1803 2047 value: impl Into<Option<crate::sh_weaver::notebook::Tags<'a>>>, 1804 2048 ) -> Self { 1805 - self.__unsafe_private_named.5 = value.into(); 2049 + self.__unsafe_private_named.6 = value.into(); 1806 2050 self 1807 2051 } 1808 2052 /// Set the `tags` field to an Option value (optional) ··· 1810 2054 mut self, 1811 2055 value: Option<crate::sh_weaver::notebook::Tags<'a>>, 1812 2056 ) -> Self { 1813 - self.__unsafe_private_named.5 = value; 2057 + self.__unsafe_private_named.6 = value; 1814 2058 self 1815 2059 } 1816 2060 } ··· 1821 2065 mut self, 1822 2066 value: impl Into<Option<crate::sh_weaver::notebook::Title<'a>>>, 1823 2067 ) -> Self { 1824 - self.__unsafe_private_named.6 = value.into(); 2068 + self.__unsafe_private_named.7 = value.into(); 1825 2069 self 1826 2070 } 1827 2071 /// Set the `title` field to an Option value (optional) ··· 1829 2073 mut self, 1830 2074 value: Option<crate::sh_weaver::notebook::Title<'a>>, 1831 2075 ) -> Self { 1832 - self.__unsafe_private_named.6 = value; 2076 + self.__unsafe_private_named.7 = value; 1833 2077 self 1834 2078 } 1835 2079 } ··· 1844 2088 mut self, 1845 2089 value: impl Into<jacquard_common::types::string::AtUri<'a>>, 1846 2090 ) -> NotebookViewBuilder<'a, notebook_view_state::SetUri<S>> { 1847 - self.__unsafe_private_named.7 = ::core::option::Option::Some(value.into()); 2091 + self.__unsafe_private_named.8 = ::core::option::Option::Some(value.into()); 1848 2092 NotebookViewBuilder { 1849 2093 _phantom_state: ::core::marker::PhantomData, 1850 2094 __unsafe_private_named: self.__unsafe_private_named, ··· 1869 2113 cid: self.__unsafe_private_named.1.unwrap(), 1870 2114 indexed_at: self.__unsafe_private_named.2.unwrap(), 1871 2115 path: self.__unsafe_private_named.3, 1872 - record: self.__unsafe_private_named.4.unwrap(), 1873 - tags: self.__unsafe_private_named.5, 1874 - title: self.__unsafe_private_named.6, 1875 - uri: self.__unsafe_private_named.7.unwrap(), 2116 + permissions: self.__unsafe_private_named.4, 2117 + record: self.__unsafe_private_named.5.unwrap(), 2118 + tags: self.__unsafe_private_named.6, 2119 + title: self.__unsafe_private_named.7, 2120 + uri: self.__unsafe_private_named.8.unwrap(), 1876 2121 extra_data: Default::default(), 1877 2122 } 1878 2123 } ··· 1889 2134 cid: self.__unsafe_private_named.1.unwrap(), 1890 2135 indexed_at: self.__unsafe_private_named.2.unwrap(), 1891 2136 path: self.__unsafe_private_named.3, 1892 - record: self.__unsafe_private_named.4.unwrap(), 1893 - tags: self.__unsafe_private_named.5, 1894 - title: self.__unsafe_private_named.6, 1895 - uri: self.__unsafe_private_named.7.unwrap(), 2137 + permissions: self.__unsafe_private_named.4, 2138 + record: self.__unsafe_private_named.5.unwrap(), 2139 + tags: self.__unsafe_private_named.6, 2140 + title: self.__unsafe_private_named.7, 2141 + uri: self.__unsafe_private_named.8.unwrap(), 1896 2142 extra_data: Some(extra_data), 1897 2143 } 1898 2144 } ··· 1917 2163 1918 2164 /// The path of the notebook. 1919 2165 pub type Path<'a> = jacquard_common::CowStr<'a>; 2166 + /// A single permission grant. For resource authority: source=resource URI, grantedAt=createdAt. For invitees: source=invite URI, grantedAt=accept createdAt. 2167 + #[jacquard_derive::lexicon] 2168 + #[derive( 2169 + serde::Serialize, 2170 + serde::Deserialize, 2171 + Debug, 2172 + Clone, 2173 + PartialEq, 2174 + Eq, 2175 + jacquard_derive::IntoStatic 2176 + )] 2177 + #[serde(rename_all = "camelCase")] 2178 + pub struct PermissionGrant<'a> { 2179 + #[serde(borrow)] 2180 + pub did: jacquard_common::types::string::Did<'a>, 2181 + /// For authority: record createdAt. For invitees: accept createdAt 2182 + pub granted_at: jacquard_common::types::string::Datetime, 2183 + /// direct = this resource (includes authority), inherited = via notebook invite 2184 + #[serde(borrow)] 2185 + pub scope: jacquard_common::CowStr<'a>, 2186 + /// For authority: resource URI. For invitees: invite URI 2187 + #[serde(borrow)] 2188 + pub source: jacquard_common::types::string::AtUri<'a>, 2189 + } 2190 + 2191 + pub mod permission_grant_state { 2192 + 2193 + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; 2194 + #[allow(unused)] 2195 + use ::core::marker::PhantomData; 2196 + mod sealed { 2197 + pub trait Sealed {} 2198 + } 2199 + /// State trait tracking which required fields have been set 2200 + pub trait State: sealed::Sealed { 2201 + type Did; 2202 + type Scope; 2203 + type Source; 2204 + type GrantedAt; 2205 + } 2206 + /// Empty state - all required fields are unset 2207 + pub struct Empty(()); 2208 + impl sealed::Sealed for Empty {} 2209 + impl State for Empty { 2210 + type Did = Unset; 2211 + type Scope = Unset; 2212 + type Source = Unset; 2213 + type GrantedAt = Unset; 2214 + } 2215 + ///State transition - sets the `did` field to Set 2216 + pub struct SetDid<S: State = Empty>(PhantomData<fn() -> S>); 2217 + impl<S: State> sealed::Sealed for SetDid<S> {} 2218 + impl<S: State> State for SetDid<S> { 2219 + type Did = Set<members::did>; 2220 + type Scope = S::Scope; 2221 + type Source = S::Source; 2222 + type GrantedAt = S::GrantedAt; 2223 + } 2224 + ///State transition - sets the `scope` field to Set 2225 + pub struct SetScope<S: State = Empty>(PhantomData<fn() -> S>); 2226 + impl<S: State> sealed::Sealed for SetScope<S> {} 2227 + impl<S: State> State for SetScope<S> { 2228 + type Did = S::Did; 2229 + type Scope = Set<members::scope>; 2230 + type Source = S::Source; 2231 + type GrantedAt = S::GrantedAt; 2232 + } 2233 + ///State transition - sets the `source` field to Set 2234 + pub struct SetSource<S: State = Empty>(PhantomData<fn() -> S>); 2235 + impl<S: State> sealed::Sealed for SetSource<S> {} 2236 + impl<S: State> State for SetSource<S> { 2237 + type Did = S::Did; 2238 + type Scope = S::Scope; 2239 + type Source = Set<members::source>; 2240 + type GrantedAt = S::GrantedAt; 2241 + } 2242 + ///State transition - sets the `granted_at` field to Set 2243 + pub struct SetGrantedAt<S: State = Empty>(PhantomData<fn() -> S>); 2244 + impl<S: State> sealed::Sealed for SetGrantedAt<S> {} 2245 + impl<S: State> State for SetGrantedAt<S> { 2246 + type Did = S::Did; 2247 + type Scope = S::Scope; 2248 + type Source = S::Source; 2249 + type GrantedAt = Set<members::granted_at>; 2250 + } 2251 + /// Marker types for field names 2252 + #[allow(non_camel_case_types)] 2253 + pub mod members { 2254 + ///Marker type for the `did` field 2255 + pub struct did(()); 2256 + ///Marker type for the `scope` field 2257 + pub struct scope(()); 2258 + ///Marker type for the `source` field 2259 + pub struct source(()); 2260 + ///Marker type for the `granted_at` field 2261 + pub struct granted_at(()); 2262 + } 2263 + } 2264 + 2265 + /// Builder for constructing an instance of this type 2266 + pub struct PermissionGrantBuilder<'a, S: permission_grant_state::State> { 2267 + _phantom_state: ::core::marker::PhantomData<fn() -> S>, 2268 + __unsafe_private_named: ( 2269 + ::core::option::Option<jacquard_common::types::string::Did<'a>>, 2270 + ::core::option::Option<jacquard_common::types::string::Datetime>, 2271 + ::core::option::Option<jacquard_common::CowStr<'a>>, 2272 + ::core::option::Option<jacquard_common::types::string::AtUri<'a>>, 2273 + ), 2274 + _phantom: ::core::marker::PhantomData<&'a ()>, 2275 + } 2276 + 2277 + impl<'a> PermissionGrant<'a> { 2278 + /// Create a new builder for this type 2279 + pub fn new() -> PermissionGrantBuilder<'a, permission_grant_state::Empty> { 2280 + PermissionGrantBuilder::new() 2281 + } 2282 + } 2283 + 2284 + impl<'a> PermissionGrantBuilder<'a, permission_grant_state::Empty> { 2285 + /// Create a new builder with all fields unset 2286 + pub fn new() -> Self { 2287 + PermissionGrantBuilder { 2288 + _phantom_state: ::core::marker::PhantomData, 2289 + __unsafe_private_named: (None, None, None, None), 2290 + _phantom: ::core::marker::PhantomData, 2291 + } 2292 + } 2293 + } 2294 + 2295 + impl<'a, S> PermissionGrantBuilder<'a, S> 2296 + where 2297 + S: permission_grant_state::State, 2298 + S::Did: permission_grant_state::IsUnset, 2299 + { 2300 + /// Set the `did` field (required) 2301 + pub fn did( 2302 + mut self, 2303 + value: impl Into<jacquard_common::types::string::Did<'a>>, 2304 + ) -> PermissionGrantBuilder<'a, permission_grant_state::SetDid<S>> { 2305 + self.__unsafe_private_named.0 = ::core::option::Option::Some(value.into()); 2306 + PermissionGrantBuilder { 2307 + _phantom_state: ::core::marker::PhantomData, 2308 + __unsafe_private_named: self.__unsafe_private_named, 2309 + _phantom: ::core::marker::PhantomData, 2310 + } 2311 + } 2312 + } 2313 + 2314 + impl<'a, S> PermissionGrantBuilder<'a, S> 2315 + where 2316 + S: permission_grant_state::State, 2317 + S::GrantedAt: permission_grant_state::IsUnset, 2318 + { 2319 + /// Set the `grantedAt` field (required) 2320 + pub fn granted_at( 2321 + mut self, 2322 + value: impl Into<jacquard_common::types::string::Datetime>, 2323 + ) -> PermissionGrantBuilder<'a, permission_grant_state::SetGrantedAt<S>> { 2324 + self.__unsafe_private_named.1 = ::core::option::Option::Some(value.into()); 2325 + PermissionGrantBuilder { 2326 + _phantom_state: ::core::marker::PhantomData, 2327 + __unsafe_private_named: self.__unsafe_private_named, 2328 + _phantom: ::core::marker::PhantomData, 2329 + } 2330 + } 2331 + } 2332 + 2333 + impl<'a, S> PermissionGrantBuilder<'a, S> 2334 + where 2335 + S: permission_grant_state::State, 2336 + S::Scope: permission_grant_state::IsUnset, 2337 + { 2338 + /// Set the `scope` field (required) 2339 + pub fn scope( 2340 + mut self, 2341 + value: impl Into<jacquard_common::CowStr<'a>>, 2342 + ) -> PermissionGrantBuilder<'a, permission_grant_state::SetScope<S>> { 2343 + self.__unsafe_private_named.2 = ::core::option::Option::Some(value.into()); 2344 + PermissionGrantBuilder { 2345 + _phantom_state: ::core::marker::PhantomData, 2346 + __unsafe_private_named: self.__unsafe_private_named, 2347 + _phantom: ::core::marker::PhantomData, 2348 + } 2349 + } 2350 + } 2351 + 2352 + impl<'a, S> PermissionGrantBuilder<'a, S> 2353 + where 2354 + S: permission_grant_state::State, 2355 + S::Source: permission_grant_state::IsUnset, 2356 + { 2357 + /// Set the `source` field (required) 2358 + pub fn source( 2359 + mut self, 2360 + value: impl Into<jacquard_common::types::string::AtUri<'a>>, 2361 + ) -> PermissionGrantBuilder<'a, permission_grant_state::SetSource<S>> { 2362 + self.__unsafe_private_named.3 = ::core::option::Option::Some(value.into()); 2363 + PermissionGrantBuilder { 2364 + _phantom_state: ::core::marker::PhantomData, 2365 + __unsafe_private_named: self.__unsafe_private_named, 2366 + _phantom: ::core::marker::PhantomData, 2367 + } 2368 + } 2369 + } 2370 + 2371 + impl<'a, S> PermissionGrantBuilder<'a, S> 2372 + where 2373 + S: permission_grant_state::State, 2374 + S::Did: permission_grant_state::IsSet, 2375 + S::Scope: permission_grant_state::IsSet, 2376 + S::Source: permission_grant_state::IsSet, 2377 + S::GrantedAt: permission_grant_state::IsSet, 2378 + { 2379 + /// Build the final struct 2380 + pub fn build(self) -> PermissionGrant<'a> { 2381 + PermissionGrant { 2382 + did: self.__unsafe_private_named.0.unwrap(), 2383 + granted_at: self.__unsafe_private_named.1.unwrap(), 2384 + scope: self.__unsafe_private_named.2.unwrap(), 2385 + source: self.__unsafe_private_named.3.unwrap(), 2386 + extra_data: Default::default(), 2387 + } 2388 + } 2389 + /// Build the final struct with custom extra_data 2390 + pub fn build_with_data( 2391 + self, 2392 + extra_data: std::collections::BTreeMap< 2393 + jacquard_common::smol_str::SmolStr, 2394 + jacquard_common::types::value::Data<'a>, 2395 + >, 2396 + ) -> PermissionGrant<'a> { 2397 + PermissionGrant { 2398 + did: self.__unsafe_private_named.0.unwrap(), 2399 + granted_at: self.__unsafe_private_named.1.unwrap(), 2400 + scope: self.__unsafe_private_named.2.unwrap(), 2401 + source: self.__unsafe_private_named.3.unwrap(), 2402 + extra_data: Some(extra_data), 2403 + } 2404 + } 2405 + } 2406 + 2407 + impl<'a> ::jacquard_lexicon::schema::LexiconSchema for PermissionGrant<'a> { 2408 + fn nsid() -> &'static str { 2409 + "sh.weaver.notebook.defs" 2410 + } 2411 + fn def_name() -> &'static str { 2412 + "permissionGrant" 2413 + } 2414 + fn lexicon_doc() -> ::jacquard_lexicon::lexicon::LexiconDoc<'static> { 2415 + lexicon_doc_sh_weaver_notebook_defs() 2416 + } 2417 + fn validate( 2418 + &self, 2419 + ) -> ::std::result::Result<(), ::jacquard_lexicon::validation::ConstraintError> { 2420 + Ok(()) 2421 + } 2422 + } 2423 + 2424 + /// ACL-style permissions for a resource. Separate from authors (who contributed). 2425 + #[jacquard_derive::lexicon] 2426 + #[derive( 2427 + serde::Serialize, 2428 + serde::Deserialize, 2429 + Debug, 2430 + Clone, 2431 + PartialEq, 2432 + Eq, 2433 + jacquard_derive::IntoStatic 2434 + )] 2435 + #[serde(rename_all = "camelCase")] 2436 + pub struct PermissionsState<'a> { 2437 + /// DIDs that can edit this resource 2438 + #[serde(borrow)] 2439 + pub editors: Vec<crate::sh_weaver::notebook::PermissionGrant<'a>>, 2440 + /// DIDs that can view (future use) 2441 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 2442 + #[serde(borrow)] 2443 + pub viewers: std::option::Option< 2444 + Vec<crate::sh_weaver::notebook::PermissionGrant<'a>>, 2445 + >, 2446 + } 2447 + 2448 + pub mod permissions_state_state { 2449 + 2450 + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; 2451 + #[allow(unused)] 2452 + use ::core::marker::PhantomData; 2453 + mod sealed { 2454 + pub trait Sealed {} 2455 + } 2456 + /// State trait tracking which required fields have been set 2457 + pub trait State: sealed::Sealed { 2458 + type Editors; 2459 + } 2460 + /// Empty state - all required fields are unset 2461 + pub struct Empty(()); 2462 + impl sealed::Sealed for Empty {} 2463 + impl State for Empty { 2464 + type Editors = Unset; 2465 + } 2466 + ///State transition - sets the `editors` field to Set 2467 + pub struct SetEditors<S: State = Empty>(PhantomData<fn() -> S>); 2468 + impl<S: State> sealed::Sealed for SetEditors<S> {} 2469 + impl<S: State> State for SetEditors<S> { 2470 + type Editors = Set<members::editors>; 2471 + } 2472 + /// Marker types for field names 2473 + #[allow(non_camel_case_types)] 2474 + pub mod members { 2475 + ///Marker type for the `editors` field 2476 + pub struct editors(()); 2477 + } 2478 + } 2479 + 2480 + /// Builder for constructing an instance of this type 2481 + pub struct PermissionsStateBuilder<'a, S: permissions_state_state::State> { 2482 + _phantom_state: ::core::marker::PhantomData<fn() -> S>, 2483 + __unsafe_private_named: ( 2484 + ::core::option::Option<Vec<crate::sh_weaver::notebook::PermissionGrant<'a>>>, 2485 + ::core::option::Option<Vec<crate::sh_weaver::notebook::PermissionGrant<'a>>>, 2486 + ), 2487 + _phantom: ::core::marker::PhantomData<&'a ()>, 2488 + } 2489 + 2490 + impl<'a> PermissionsState<'a> { 2491 + /// Create a new builder for this type 2492 + pub fn new() -> PermissionsStateBuilder<'a, permissions_state_state::Empty> { 2493 + PermissionsStateBuilder::new() 2494 + } 2495 + } 2496 + 2497 + impl<'a> PermissionsStateBuilder<'a, permissions_state_state::Empty> { 2498 + /// Create a new builder with all fields unset 2499 + pub fn new() -> Self { 2500 + PermissionsStateBuilder { 2501 + _phantom_state: ::core::marker::PhantomData, 2502 + __unsafe_private_named: (None, None), 2503 + _phantom: ::core::marker::PhantomData, 2504 + } 2505 + } 2506 + } 2507 + 2508 + impl<'a, S> PermissionsStateBuilder<'a, S> 2509 + where 2510 + S: permissions_state_state::State, 2511 + S::Editors: permissions_state_state::IsUnset, 2512 + { 2513 + /// Set the `editors` field (required) 2514 + pub fn editors( 2515 + mut self, 2516 + value: impl Into<Vec<crate::sh_weaver::notebook::PermissionGrant<'a>>>, 2517 + ) -> PermissionsStateBuilder<'a, permissions_state_state::SetEditors<S>> { 2518 + self.__unsafe_private_named.0 = ::core::option::Option::Some(value.into()); 2519 + PermissionsStateBuilder { 2520 + _phantom_state: ::core::marker::PhantomData, 2521 + __unsafe_private_named: self.__unsafe_private_named, 2522 + _phantom: ::core::marker::PhantomData, 2523 + } 2524 + } 2525 + } 2526 + 2527 + impl<'a, S: permissions_state_state::State> PermissionsStateBuilder<'a, S> { 2528 + /// Set the `viewers` field (optional) 2529 + pub fn viewers( 2530 + mut self, 2531 + value: impl Into<Option<Vec<crate::sh_weaver::notebook::PermissionGrant<'a>>>>, 2532 + ) -> Self { 2533 + self.__unsafe_private_named.1 = value.into(); 2534 + self 2535 + } 2536 + /// Set the `viewers` field to an Option value (optional) 2537 + pub fn maybe_viewers( 2538 + mut self, 2539 + value: Option<Vec<crate::sh_weaver::notebook::PermissionGrant<'a>>>, 2540 + ) -> Self { 2541 + self.__unsafe_private_named.1 = value; 2542 + self 2543 + } 2544 + } 2545 + 2546 + impl<'a, S> PermissionsStateBuilder<'a, S> 2547 + where 2548 + S: permissions_state_state::State, 2549 + S::Editors: permissions_state_state::IsSet, 2550 + { 2551 + /// Build the final struct 2552 + pub fn build(self) -> PermissionsState<'a> { 2553 + PermissionsState { 2554 + editors: self.__unsafe_private_named.0.unwrap(), 2555 + viewers: self.__unsafe_private_named.1, 2556 + extra_data: Default::default(), 2557 + } 2558 + } 2559 + /// Build the final struct with custom extra_data 2560 + pub fn build_with_data( 2561 + self, 2562 + extra_data: std::collections::BTreeMap< 2563 + jacquard_common::smol_str::SmolStr, 2564 + jacquard_common::types::value::Data<'a>, 2565 + >, 2566 + ) -> PermissionsState<'a> { 2567 + PermissionsState { 2568 + editors: self.__unsafe_private_named.0.unwrap(), 2569 + viewers: self.__unsafe_private_named.1, 2570 + extra_data: Some(extra_data), 2571 + } 2572 + } 2573 + } 2574 + 2575 + impl<'a> ::jacquard_lexicon::schema::LexiconSchema for PermissionsState<'a> { 2576 + fn nsid() -> &'static str { 2577 + "sh.weaver.notebook.defs" 2578 + } 2579 + fn def_name() -> &'static str { 2580 + "permissionsState" 2581 + } 2582 + fn lexicon_doc() -> ::jacquard_lexicon::lexicon::LexiconDoc<'static> { 2583 + lexicon_doc_sh_weaver_notebook_defs() 2584 + } 2585 + fn validate( 2586 + &self, 2587 + ) -> ::std::result::Result<(), ::jacquard_lexicon::validation::ConstraintError> { 2588 + Ok(()) 2589 + } 2590 + } 2591 + 1920 2592 /// View of a rendered and cached notebook entry 1921 2593 #[jacquard_derive::lexicon] 1922 2594 #[derive(
-10
crates/weaver-app/.env-dev
··· 1 - WEAVER_APP_ENV="dev" 2 - WEAVER_APP_HOST="http://localhost" 3 - WEAVER_APP_DOMAIN="" 4 - WEAVER_PORT=8080 5 - WEAVER_APP_SCOPES="atproto transition:generic" 6 - WEAVER_CLIENT_NAME="Weaver" 7 - 8 - WEAVER_LOGO_URI="" 9 - WEAVER_TOS_URI="" 10 - WEAVER_PRIVACY_POLICY_URI=""
+10
crates/weaver-app/.env-pro
··· 1 + WEAVER_APP_ENV="prod" 2 + WEAVER_APP_HOST="https://alpha.weaver.sh" 3 + WEAVER_APP_DOMAIN="https://alpha.weaver.sh" 4 + WEAVER_PORT=8080 5 + WEAVER_APP_SCOPES="atproto transition:generic" 6 + WEAVER_CLIENT_NAME="Weaver" 7 + 8 + WEAVER_LOGO_URI="https://alpha.weaver.sh/favicon.ico" 9 + WEAVER_TOS_URI="" 10 + WEAVER_PRIVACY_POLICY_URI=""
+27 -2
crates/weaver-app/assets/styling/drafts.css
··· 60 60 font-weight: 500; 61 61 } 62 62 63 + .draft-badges { 64 + display: flex; 65 + gap: 0.5rem; 66 + flex-wrap: wrap; 67 + } 68 + 63 69 .draft-badge { 64 70 font-size: 0.75rem; 65 71 padding: 0.125rem 0.5rem; ··· 68 74 } 69 75 70 76 .draft-badge-new { 71 - background: var(--color-secondary); 72 - color: var(--color-base); 77 + background: var(--color-surface); 78 + color: var(--color-secondary); 79 + border: 1px solid var(--color-secondary); 73 80 } 74 81 75 82 .draft-badge-edit { 76 83 background: var(--color-surface); 77 84 color: var(--color-subtle); 78 85 border: 1px solid var(--color-border); 86 + } 87 + 88 + .draft-badge-synced { 89 + background: var(--color-surface); 90 + color: var(--color-success); 91 + border: 1px solid var(--color-success); 92 + } 93 + 94 + .draft-badge-local { 95 + background: var(--color-surface); 96 + color: var(--color-warning); 97 + border: 1px solid var(--color-warning); 98 + } 99 + 100 + .draft-badge-remote { 101 + background: var(--color-surface); 102 + color: var(--color-primary); 103 + border: 1px solid var(--color-primary); 79 104 } 80 105 81 106 /* Mobile adjustments */
+395
crates/weaver-app/assets/styling/editor.css
··· 744 744 .sync-status.disabled:hover { 745 745 opacity: 0.6; 746 746 } 747 + 748 + /* ========================================================================== 749 + COLLABORATORS 750 + ========================================================================== */ 751 + 752 + /* Inline avatars in meta row */ 753 + .collaborator-avatars { 754 + display: flex; 755 + align-items: center; 756 + cursor: pointer; 757 + padding: 2px; 758 + } 759 + 760 + .collaborator-avatars:hover .collab-avatar { 761 + border-color: var(--color-primary); 762 + } 763 + 764 + .collab-avatar { 765 + width: 24px; 766 + height: 24px; 767 + border: 1px solid var(--color-border); 768 + background: var(--color-surface); 769 + color: var(--color-muted); 770 + display: flex; 771 + align-items: center; 772 + justify-content: center; 773 + font-size: 11px; 774 + font-weight: 500; 775 + font-family: var(--font-mono); 776 + text-transform: uppercase; 777 + margin-left: -6px; 778 + position: relative; 779 + transition: border-color 0.15s ease; 780 + } 781 + 782 + .collab-avatar:first-child { 783 + margin-left: 0; 784 + } 785 + 786 + .collab-avatar.collab-overflow { 787 + background: var(--color-overlay); 788 + color: var(--color-subtle); 789 + font-size: 10px; 790 + } 791 + 792 + .collab-avatar.collab-add { 793 + background: transparent; 794 + border-style: dashed; 795 + color: var(--color-muted); 796 + font-size: 14px; 797 + } 798 + 799 + .collab-avatar.collab-add:hover { 800 + border-color: var(--color-primary); 801 + color: var(--color-primary); 802 + } 803 + 804 + /* Collaborators panel overlay */ 805 + .collaborators-overlay { 806 + position: fixed; 807 + top: 0; 808 + left: 0; 809 + right: 0; 810 + bottom: 0; 811 + background: rgba(0, 0, 0, 0.4); 812 + display: flex; 813 + align-items: center; 814 + justify-content: center; 815 + z-index: 1000; 816 + } 817 + 818 + @media (prefers-color-scheme: dark) { 819 + .collaborators-overlay { 820 + background: rgba(0, 0, 0, 0.6); 821 + } 822 + } 823 + 824 + .collaborators-modal { 825 + background: var(--color-surface); 826 + border: 1px solid var(--color-border); 827 + padding: 0; 828 + max-width: 400px; 829 + width: 90%; 830 + max-height: 80vh; 831 + overflow-y: auto; 832 + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1); 833 + } 834 + 835 + @media (prefers-color-scheme: dark) { 836 + .collaborators-modal { 837 + box-shadow: none; 838 + } 839 + } 840 + 841 + /* Collaborators panel content */ 842 + .collaborators-panel { 843 + padding: 1rem; 844 + } 845 + 846 + .collaborators-header { 847 + display: flex; 848 + justify-content: space-between; 849 + align-items: center; 850 + margin-bottom: 1rem; 851 + padding-bottom: 0.75rem; 852 + border-bottom: 1px solid var(--color-border); 853 + } 854 + 855 + .collaborators-header h4 { 856 + margin: 0; 857 + font-size: 14px; 858 + font-weight: 600; 859 + color: var(--color-text); 860 + } 861 + 862 + .collaborators-header-actions { 863 + display: flex; 864 + gap: 4px; 865 + } 866 + 867 + .collaborators-list { 868 + display: flex; 869 + flex-direction: column; 870 + gap: 6px; 871 + margin-bottom: 0.75rem; 872 + } 873 + 874 + .collaborator { 875 + display: flex; 876 + justify-content: space-between; 877 + align-items: center; 878 + padding: 8px 10px; 879 + background: var(--color-overlay); 880 + border: 1px solid transparent; 881 + } 882 + 883 + .collaborator.pending { 884 + border-style: dashed; 885 + border-color: var(--color-border); 886 + background: transparent; 887 + } 888 + 889 + .collaborator-did { 890 + font-family: var(--font-mono); 891 + font-size: 12px; 892 + color: var(--color-text); 893 + overflow: hidden; 894 + text-overflow: ellipsis; 895 + white-space: nowrap; 896 + max-width: 280px; 897 + } 898 + 899 + .collaborator-status { 900 + font-size: 12px; 901 + color: var(--color-muted); 902 + } 903 + 904 + .collaborator.accepted .collaborator-status { 905 + color: var(--color-success); 906 + } 907 + 908 + .collaborators-summary { 909 + font-size: 11px; 910 + color: var(--color-muted); 911 + text-align: right; 912 + } 913 + 914 + .collaborators-panel .empty-state { 915 + color: var(--color-muted); 916 + font-size: 13px; 917 + text-align: center; 918 + padding: 1.5rem 0; 919 + margin: 0; 920 + } 921 + 922 + /* ========================================================================== 923 + INVITE DIALOG 924 + ========================================================================== */ 925 + 926 + .invite-dialog-overlay { 927 + position: fixed; 928 + top: 0; 929 + left: 0; 930 + right: 0; 931 + bottom: 0; 932 + background: rgba(0, 0, 0, 0.4); 933 + display: flex; 934 + align-items: center; 935 + justify-content: center; 936 + z-index: 1001; 937 + } 938 + 939 + @media (prefers-color-scheme: dark) { 940 + .invite-dialog-overlay { 941 + background: rgba(0, 0, 0, 0.6); 942 + } 943 + } 944 + 945 + .invite-dialog { 946 + background: var(--color-surface); 947 + border: 1px solid var(--color-border); 948 + padding: 1.25rem; 949 + max-width: 420px; 950 + width: 90%; 951 + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1); 952 + } 953 + 954 + @media (prefers-color-scheme: dark) { 955 + .invite-dialog { 956 + box-shadow: none; 957 + } 958 + } 959 + 960 + .invite-dialog h3 { 961 + margin: 0 0 1rem 0; 962 + font-size: 15px; 963 + font-weight: 600; 964 + color: var(--color-text); 965 + } 966 + 967 + .invite-dialog .invite-resource-info { 968 + font-size: 12px; 969 + color: var(--color-muted); 970 + margin-bottom: 1rem; 971 + padding: 8px 10px; 972 + background: var(--color-overlay); 973 + border: 1px solid var(--color-border); 974 + } 975 + 976 + .invite-field { 977 + margin-bottom: 1rem; 978 + } 979 + 980 + .invite-field label { 981 + display: block; 982 + font-size: 11px; 983 + font-weight: 500; 984 + color: var(--color-muted); 985 + text-transform: uppercase; 986 + letter-spacing: 0.05em; 987 + margin-bottom: 6px; 988 + } 989 + 990 + .invite-field input, 991 + .invite-field textarea { 992 + width: 100%; 993 + padding: 8px 10px; 994 + border: 1px solid var(--color-border); 995 + background: var(--color-base); 996 + color: var(--color-text); 997 + font-family: var(--font-ui); 998 + font-size: 13px; 999 + box-sizing: border-box; 1000 + } 1001 + 1002 + .invite-field input:focus, 1003 + .invite-field textarea:focus { 1004 + outline: none; 1005 + border-color: var(--color-primary); 1006 + } 1007 + 1008 + .invite-field textarea { 1009 + min-height: 60px; 1010 + resize: vertical; 1011 + } 1012 + 1013 + .invite-error { 1014 + background: color-mix(in srgb, var(--color-error) 10%, var(--color-surface)); 1015 + border: 1px solid var(--color-error); 1016 + color: var(--color-error); 1017 + padding: 8px 10px; 1018 + margin-bottom: 1rem; 1019 + font-size: 13px; 1020 + } 1021 + 1022 + .invite-actions { 1023 + display: flex; 1024 + gap: 8px; 1025 + justify-content: flex-end; 1026 + margin-top: 1rem; 1027 + padding-top: 1rem; 1028 + border-top: 1px solid var(--color-border); 1029 + } 1030 + 1031 + /* ========================================================================== 1032 + INVITES LIST 1033 + ========================================================================== */ 1034 + 1035 + .invites-list { 1036 + display: flex; 1037 + flex-direction: column; 1038 + gap: 1.5rem; 1039 + } 1040 + 1041 + .invites-section h3 { 1042 + font-size: 13px; 1043 + font-weight: 600; 1044 + color: var(--color-muted); 1045 + text-transform: uppercase; 1046 + letter-spacing: 0.05em; 1047 + margin: 0 0 0.75rem 0; 1048 + padding-bottom: 0.5rem; 1049 + border-bottom: 1px solid var(--color-border); 1050 + } 1051 + 1052 + .invites-section .empty-state { 1053 + color: var(--color-muted); 1054 + font-size: 13px; 1055 + padding: 1rem 0; 1056 + margin: 0; 1057 + } 1058 + 1059 + .invite-card { 1060 + padding: 12px; 1061 + background: var(--color-surface); 1062 + border: 1px solid var(--color-border); 1063 + margin-bottom: 8px; 1064 + } 1065 + 1066 + .invite-card:last-child { 1067 + margin-bottom: 0; 1068 + } 1069 + 1070 + .invite-info { 1071 + display: flex; 1072 + flex-direction: column; 1073 + gap: 4px; 1074 + margin-bottom: 8px; 1075 + } 1076 + 1077 + .invite-from, 1078 + .invite-to { 1079 + font-size: 13px; 1080 + color: var(--color-text); 1081 + } 1082 + 1083 + .invite-resource { 1084 + font-size: 11px; 1085 + font-family: var(--font-mono); 1086 + color: var(--color-muted); 1087 + overflow: hidden; 1088 + text-overflow: ellipsis; 1089 + white-space: nowrap; 1090 + } 1091 + 1092 + .invite-message { 1093 + font-size: 13px; 1094 + color: var(--color-subtle); 1095 + margin: 6px 0 0 0; 1096 + padding: 8px; 1097 + background: var(--color-overlay); 1098 + border-left: 2px solid var(--color-border); 1099 + } 1100 + 1101 + .invite-actions, 1102 + .invite-status { 1103 + display: flex; 1104 + align-items: center; 1105 + gap: 8px; 1106 + } 1107 + 1108 + .invite-status .status-badge { 1109 + font-size: 11px; 1110 + font-weight: 500; 1111 + text-transform: uppercase; 1112 + letter-spacing: 0.03em; 1113 + padding: 3px 8px; 1114 + } 1115 + 1116 + .status-badge.pending { 1117 + background: color-mix(in srgb, var(--color-warning) 15%, transparent); 1118 + color: var(--color-warning); 1119 + border: 1px solid var(--color-warning); 1120 + } 1121 + 1122 + .status-badge.accepted { 1123 + background: color-mix(in srgb, var(--color-success) 15%, transparent); 1124 + color: var(--color-success); 1125 + border: 1px solid var(--color-success); 1126 + } 1127 + 1128 + .invite-card .error-message { 1129 + background: color-mix(in srgb, var(--color-error) 10%, var(--color-surface)); 1130 + border: 1px solid var(--color-error); 1131 + color: var(--color-error); 1132 + padding: 6px 8px; 1133 + margin-bottom: 8px; 1134 + font-size: 12px; 1135 + } 1136 + 1137 + .invite-status.accepted { 1138 + color: var(--color-success); 1139 + font-size: 12px; 1140 + font-weight: 500; 1141 + }
+131
crates/weaver-app/assets/styling/invites.css
··· 1 + .invites-page { 2 + max-width: 800px; 3 + margin: 0 auto; 4 + padding: 2rem; 5 + } 6 + 7 + .invites-header { 8 + margin-bottom: 2rem; 9 + } 10 + 11 + .invites-header h1 { 12 + margin: 0 0 0.5rem 0; 13 + } 14 + 15 + .invites-description { 16 + color: var(--color-subtle); 17 + margin: 0; 18 + } 19 + 20 + /* Invites list component styles */ 21 + .invites-list { 22 + display: flex; 23 + flex-direction: column; 24 + gap: 2rem; 25 + } 26 + 27 + .invites-section h3 { 28 + margin: 0 0 1rem 0; 29 + padding-bottom: 0.5rem; 30 + border-bottom: 1px solid var(--color-border); 31 + } 32 + 33 + .empty-state { 34 + color: var(--color-subtle); 35 + text-align: center; 36 + padding: 2rem; 37 + } 38 + 39 + .invite-card { 40 + display: flex; 41 + flex-direction: column; 42 + gap: 0.75rem; 43 + padding: 1rem; 44 + background: var(--color-surface); 45 + border: 1px solid var(--color-border); 46 + border-radius: 0; 47 + margin-bottom: 0.5rem; 48 + } 49 + 50 + .invite-card:hover { 51 + border-color: var(--color-primary); 52 + } 53 + 54 + .invite-info { 55 + display: flex; 56 + flex-direction: column; 57 + gap: 0.25rem; 58 + } 59 + 60 + .invite-from, 61 + .invite-to { 62 + font-weight: 500; 63 + } 64 + 65 + .invite-resource { 66 + font-size: 0.875rem; 67 + color: var(--color-subtle); 68 + word-break: break-all; 69 + } 70 + 71 + .invite-message { 72 + margin: 0.5rem 0 0 0; 73 + padding: 0.5rem; 74 + background: var(--color-background); 75 + border-left: 3px solid var(--color-primary); 76 + font-style: italic; 77 + } 78 + 79 + .invite-actions { 80 + display: flex; 81 + gap: 0.5rem; 82 + align-items: center; 83 + } 84 + 85 + .invite-status { 86 + display: flex; 87 + align-items: center; 88 + } 89 + 90 + .status-badge { 91 + font-size: 0.75rem; 92 + padding: 0.25rem 0.75rem; 93 + border-radius: 4px; 94 + font-weight: 500; 95 + } 96 + 97 + .status-badge.pending { 98 + background: var(--color-surface); 99 + color: var(--color-warning); 100 + border: 1px solid var(--color-warning); 101 + } 102 + 103 + .status-badge.accepted, 104 + .invite-status.accepted { 105 + background: var(--color-surface); 106 + color: var(--color-success); 107 + border: 1px solid var(--color-success); 108 + } 109 + 110 + .error-message { 111 + color: var(--color-error); 112 + font-size: 0.875rem; 113 + padding: 0.5rem; 114 + background: var(--color-error-background, rgba(220, 38, 38, 0.1)); 115 + border-radius: 4px; 116 + } 117 + 118 + /* Mobile adjustments */ 119 + @media (max-width: 600px) { 120 + .invites-page { 121 + padding: 1rem; 122 + } 123 + 124 + .invite-card { 125 + padding: 0.75rem; 126 + } 127 + 128 + .invite-resource { 129 + font-size: 0.75rem; 130 + } 131 + }
+63
crates/weaver-app/assets/styling/profile.css
··· 194 194 border-right: 1.5px solid var(--color-border); 195 195 } 196 196 } 197 + 198 + /* Profile Invites Section */ 199 + .profile-invites { 200 + margin-top: 1.25rem; 201 + padding-top: 1.25rem; 202 + border-top: 1.5px dashed var(--color-border); 203 + } 204 + 205 + .profile-invites-header { 206 + font-size: 0.9rem; 207 + font-weight: 600; 208 + color: var(--color-text); 209 + margin: 0 0 0.75rem 0; 210 + font-family: var(--font-heading); 211 + } 212 + 213 + .profile-invites-list { 214 + display: flex; 215 + flex-direction: column; 216 + gap: 0.75rem; 217 + } 218 + 219 + .profile-invite-card { 220 + padding: 0.75rem; 221 + border: 1px solid var(--color-border); 222 + border-radius: 4px; 223 + background: var(--color-surface); 224 + } 225 + 226 + .profile-invite-from { 227 + font-size: 0.85rem; 228 + color: var(--color-subtle); 229 + margin-bottom: 0.5rem; 230 + } 231 + 232 + .profile-invite-did { 233 + color: var(--color-text); 234 + font-family: var(--font-mono); 235 + font-size: 0.8rem; 236 + } 237 + 238 + .profile-invite-message { 239 + font-size: 0.85rem; 240 + color: var(--color-muted); 241 + margin: 0.5rem 0; 242 + font-style: italic; 243 + } 244 + 245 + .profile-invite-error { 246 + font-size: 0.8rem; 247 + color: var(--color-error, #e53935); 248 + margin-bottom: 0.5rem; 249 + } 250 + 251 + .profile-invite-actions { 252 + margin-top: 0.5rem; 253 + } 254 + 255 + .profile-invite-accepted { 256 + font-size: 0.85rem; 257 + color: var(--color-success, #43a047); 258 + font-weight: 500; 259 + }
+245
crates/weaver-app/src/components/collab/api.rs
··· 1 + //! API functions for collaboration invites. 2 + 3 + use crate::fetch::Fetcher; 4 + use jacquard::IntoStatic; 5 + use jacquard::prelude::*; 6 + use jacquard::types::collection::Collection; 7 + use jacquard::types::string::{AtUri, Cid, Datetime, Did, Nsid}; 8 + use jacquard::types::uri::Uri; 9 + use reqwest::Url; 10 + use weaver_api::com_atproto::repo::list_records::ListRecords; 11 + use weaver_api::com_atproto::repo::strong_ref::StrongRef; 12 + use weaver_api::sh_weaver::collab::{accept::Accept, invite::Invite}; 13 + use weaver_common::WeaverError; 14 + use weaver_common::constellation::GetBacklinksQuery; 15 + 16 + const ACCEPT_NSID: &str = "sh.weaver.collab.accept"; 17 + const CONSTELLATION_URL: &str = "https://constellation.microcosm.blue"; 18 + 19 + /// An invite sent by the current user. 20 + #[derive(Clone, Debug, PartialEq)] 21 + pub struct SentInvite { 22 + pub uri: AtUri<'static>, 23 + pub invitee: Did<'static>, 24 + pub resource_uri: AtUri<'static>, 25 + pub message: Option<String>, 26 + pub created_at: Datetime, 27 + pub accepted: bool, 28 + } 29 + 30 + /// An invite received by the current user. 31 + #[derive(Clone, Debug, PartialEq)] 32 + pub struct ReceivedInvite { 33 + pub uri: AtUri<'static>, 34 + pub cid: Cid<'static>, 35 + pub inviter: Did<'static>, 36 + pub resource_uri: AtUri<'static>, 37 + pub resource_cid: Cid<'static>, 38 + pub message: Option<String>, 39 + pub created_at: Datetime, 40 + } 41 + 42 + /// An accepted invite (for listing collaborators). 43 + #[derive(Clone, Debug, PartialEq)] 44 + pub struct AcceptedInvite { 45 + pub accept_uri: AtUri<'static>, 46 + pub collaborator: Did<'static>, 47 + pub resource_uri: AtUri<'static>, 48 + pub accepted_at: Datetime, 49 + } 50 + 51 + /// Create an invite to collaborate on a resource. 52 + pub async fn create_invite( 53 + fetcher: &Fetcher, 54 + resource: StrongRef<'static>, 55 + invitee: Did<'static>, 56 + message: Option<String>, 57 + ) -> Result<AtUri<'static>, WeaverError> { 58 + let mut invite_builder = Invite::new() 59 + .resource(resource) 60 + .invitee(invitee) 61 + .created_at(Datetime::now()); 62 + 63 + if let Some(msg) = message { 64 + invite_builder = invite_builder.message(Some(jacquard::CowStr::from(msg))); 65 + } 66 + 67 + let invite = invite_builder.build(); 68 + 69 + let output = fetcher 70 + .create_record(invite, None) 71 + .await 72 + .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to create invite: {}", e)))?; 73 + 74 + Ok(output.uri.into_static()) 75 + } 76 + 77 + /// Accept a collaboration invite. 78 + pub async fn accept_invite( 79 + fetcher: &Fetcher, 80 + invite_ref: StrongRef<'static>, 81 + resource_uri: AtUri<'static>, 82 + ) -> Result<AtUri<'static>, WeaverError> { 83 + let accept = Accept::new() 84 + .invite(invite_ref) 85 + .resource(resource_uri) 86 + .created_at(Datetime::now()) 87 + .build(); 88 + 89 + let output = fetcher 90 + .create_record(accept, None) 91 + .await 92 + .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to accept invite: {}", e)))?; 93 + 94 + Ok(output.uri.into_static()) 95 + } 96 + 97 + /// Fetch invites sent by the current user. 98 + pub async fn fetch_sent_invites(fetcher: &Fetcher) -> Result<Vec<SentInvite>, WeaverError> { 99 + let did = fetcher 100 + .current_did() 101 + .await 102 + .ok_or_else(|| WeaverError::InvalidNotebook("Not authenticated".into()))?; 103 + 104 + let request = ListRecords::new() 105 + .repo(did) 106 + .collection(Nsid::raw(Invite::NSID)) 107 + .limit(100) 108 + .build(); 109 + 110 + let response = fetcher 111 + .send(request) 112 + .await 113 + .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to list invites: {}", e)))?; 114 + 115 + let output = response.into_output().map_err(|e| { 116 + WeaverError::InvalidNotebook(format!("Failed to parse list response: {}", e)) 117 + })?; 118 + 119 + let mut invites = Vec::new(); 120 + for record in output.records { 121 + if let Ok(invite) = jacquard::from_data::<Invite>(&record.value) { 122 + let uri = record.uri.into_static(); 123 + let accepted = check_invite_accepted(fetcher, &uri).await; 124 + 125 + invites.push(SentInvite { 126 + uri, 127 + invitee: invite.invitee.into_static(), 128 + resource_uri: invite.resource.uri.into_static(), 129 + message: invite.message.map(|s| s.to_string()), 130 + created_at: invite.created_at.clone(), 131 + accepted, 132 + }); 133 + } 134 + } 135 + 136 + Ok(invites) 137 + } 138 + 139 + /// Check if an invite has been accepted by querying for accept records. 140 + async fn check_invite_accepted(fetcher: &Fetcher, invite_uri: &AtUri<'_>) -> bool { 141 + let Ok(constellation_url) = Url::parse(CONSTELLATION_URL) else { 142 + return false; 143 + }; 144 + 145 + // Query for sh.weaver.collab.accept records that reference this invite via .invite.uri 146 + let query = GetBacklinksQuery { 147 + subject: Uri::At(invite_uri.clone().into_static()), 148 + source: format!("{}:invite.uri", ACCEPT_NSID).into(), 149 + cursor: None, 150 + did: vec![], 151 + limit: 1, 152 + }; 153 + 154 + let Ok(response) = fetcher.client.xrpc(constellation_url).send(&query).await else { 155 + return false; 156 + }; 157 + 158 + let Ok(output) = response.into_output() else { 159 + return false; 160 + }; 161 + 162 + !output.records.is_empty() 163 + } 164 + 165 + /// Fetch invites received by the current user (via Constellation backlinks). 166 + /// 167 + /// This queries Constellation to find invite records where the current user 168 + /// is the invitee, then fetches each record from the inviter's PDS to get 169 + /// the full invite details. 170 + pub async fn fetch_received_invites(fetcher: &Fetcher) -> Result<Vec<ReceivedInvite>, WeaverError> { 171 + let did = fetcher 172 + .current_did() 173 + .await 174 + .ok_or_else(|| WeaverError::InvalidNotebook("Not authenticated".into()))?; 175 + 176 + let constellation_url = Url::parse(CONSTELLATION_URL) 177 + .map_err(|e| WeaverError::InvalidNotebook(format!("Invalid constellation URL: {}", e)))?; 178 + 179 + // Query for sh.weaver.collab.invite records where .invitee = current user's DID 180 + let query = GetBacklinksQuery { 181 + subject: Uri::Did(did.clone()), 182 + source: format!("{}:invitee", Invite::NSID).into(), 183 + cursor: None, 184 + did: vec![], 185 + limit: 100, 186 + }; 187 + 188 + let response = fetcher 189 + .client 190 + .xrpc(constellation_url) 191 + .send(&query) 192 + .await 193 + .map_err(|e| WeaverError::InvalidNotebook(format!("Constellation query failed: {}", e)))?; 194 + 195 + let output = response.into_output().map_err(|e| { 196 + WeaverError::InvalidNotebook(format!("Failed to parse constellation response: {}", e)) 197 + })?; 198 + 199 + // For each RecordId, fetch the actual record from the inviter's PDS 200 + let mut invites = Vec::new(); 201 + 202 + for record_id in output.records { 203 + let inviter_did = record_id.did.into_static(); 204 + 205 + // Build the AT-URI for the invite record 206 + let uri_string = format!( 207 + "at://{}/{}/{}", 208 + inviter_did, 209 + Invite::NSID, 210 + record_id.rkey.as_ref() 211 + ); 212 + let Ok(invite_uri) = AtUri::new(&uri_string) else { 213 + continue; 214 + }; 215 + let invite_uri = invite_uri.into_static(); 216 + 217 + // Fetch the invite record from the inviter's PDS 218 + let Ok(response) = fetcher.get_record::<Invite>(&invite_uri).await else { 219 + continue; 220 + }; 221 + 222 + let Ok(record) = response.into_output() else { 223 + continue; 224 + }; 225 + 226 + let Some(cid) = record.cid else { 227 + continue; 228 + }; 229 + 230 + // record.value is already the typed Invite from get_record::<Invite> 231 + let invite = &record.value; 232 + 233 + invites.push(ReceivedInvite { 234 + uri: record.uri.into_static(), 235 + cid: cid.into_static(), 236 + inviter: inviter_did, 237 + resource_uri: invite.resource.uri.clone().into_static(), 238 + resource_cid: invite.resource.cid.clone().into_static(), 239 + message: invite.message.as_ref().map(|s| s.to_string()), 240 + created_at: invite.created_at.clone(), 241 + }); 242 + } 243 + 244 + Ok(invites) 245 + }
+93
crates/weaver-app/src/components/collab/avatars.rs
··· 1 + //! Collaborator avatars display for the editor meta row. 2 + 3 + use crate::auth::AuthState; 4 + use crate::fetch::Fetcher; 5 + use dioxus::prelude::*; 6 + use jacquard::types::string::AtUri; 7 + 8 + use super::api::{fetch_sent_invites, SentInvite}; 9 + use super::CollaboratorsPanel; 10 + 11 + /// Props for the CollaboratorAvatars component. 12 + #[derive(Props, Clone, PartialEq)] 13 + pub struct CollaboratorAvatarsProps { 14 + /// The resource URI to show collaborators for. 15 + pub resource_uri: AtUri<'static>, 16 + /// CID of the resource. 17 + pub resource_cid: String, 18 + /// Optional title for display in the panel. 19 + #[props(default)] 20 + pub resource_title: Option<String>, 21 + } 22 + 23 + /// Shows collaborator avatars with a button to manage collaborators. 24 + #[component] 25 + pub fn CollaboratorAvatars(props: CollaboratorAvatarsProps) -> Element { 26 + let auth_state = use_context::<Signal<AuthState>>(); 27 + let fetcher = use_context::<Fetcher>(); 28 + let mut show_panel = use_signal(|| false); 29 + 30 + let resource_uri = props.resource_uri.clone(); 31 + 32 + // Fetch collaborators for this resource 33 + let collaborators = { 34 + let fetcher = fetcher.clone(); 35 + let resource_uri = resource_uri.clone(); 36 + use_resource(move || { 37 + let fetcher = fetcher.clone(); 38 + let resource_uri = resource_uri.clone(); 39 + let _auth = auth_state.read().did.clone(); 40 + async move { 41 + fetch_sent_invites(&fetcher) 42 + .await 43 + .ok() 44 + .unwrap_or_default() 45 + .into_iter() 46 + .filter(|i| i.resource_uri == resource_uri && i.accepted) 47 + .collect::<Vec<SentInvite>>() 48 + } 49 + }) 50 + }; 51 + 52 + let collabs: Vec<SentInvite> = collaborators().unwrap_or_default(); 53 + let collab_count = collabs.len(); 54 + 55 + rsx! { 56 + div { class: "collaborator-avatars", 57 + onclick: move |_| show_panel.set(true), 58 + 59 + // Show up to 3 avatar circles 60 + for (i, collab) in collabs.iter().take(3).enumerate() { 61 + div { 62 + class: "collab-avatar", 63 + style: "z-index: {3 - i}", 64 + title: "{collab.invitee}", 65 + // First letter of DID as placeholder 66 + {collab.invitee.as_ref().chars().last().unwrap_or('?').to_string()} 67 + } 68 + } 69 + 70 + // Show +N if more than 3 71 + if collab_count > 3 { 72 + div { class: "collab-avatar collab-overflow", 73 + "+{collab_count - 3}" 74 + } 75 + } 76 + 77 + // Always show the add button 78 + div { class: "collab-avatar collab-add", 79 + title: "Manage collaborators", 80 + "+" 81 + } 82 + } 83 + 84 + if show_panel() { 85 + CollaboratorsPanel { 86 + resource_uri: props.resource_uri.clone(), 87 + resource_cid: props.resource_cid.clone(), 88 + resource_title: props.resource_title.clone(), 89 + on_close: move |_| show_panel.set(false), 90 + } 91 + } 92 + } 93 + }
+142
crates/weaver-app/src/components/collab/collaborators.rs
··· 1 + //! Panel showing current collaborators on a resource. 2 + 3 + use crate::auth::AuthState; 4 + use crate::components::button::{Button, ButtonVariant}; 5 + use crate::fetch::Fetcher; 6 + use dioxus::prelude::*; 7 + use jacquard::types::string::AtUri; 8 + 9 + use super::InviteDialog; 10 + use super::api::{SentInvite, fetch_sent_invites}; 11 + 12 + /// Props for the CollaboratorsPanel component. 13 + #[derive(Props, Clone, PartialEq)] 14 + pub struct CollaboratorsPanelProps { 15 + /// The resource to show collaborators for. 16 + pub resource_uri: AtUri<'static>, 17 + /// CID of the resource. 18 + pub resource_cid: String, 19 + /// Optional title for display. 20 + #[props(default)] 21 + pub resource_title: Option<String>, 22 + /// Callback when panel should close (for modal mode). 23 + #[props(default)] 24 + pub on_close: Option<EventHandler<()>>, 25 + } 26 + 27 + /// Panel showing collaborators and invite button. 28 + #[component] 29 + pub fn CollaboratorsPanel(props: CollaboratorsPanelProps) -> Element { 30 + let auth_state = use_context::<Signal<AuthState>>(); 31 + let fetcher = use_context::<Fetcher>(); 32 + let mut show_invite_dialog = use_signal(|| false); 33 + 34 + // Clone props we need in closures 35 + let on_close = props.on_close.clone(); 36 + let on_close_overlay = props.on_close.clone(); 37 + let resource_uri = props.resource_uri.clone(); 38 + let resource_uri_dialog = props.resource_uri.clone(); 39 + let resource_cid = props.resource_cid.clone(); 40 + let resource_title = props.resource_title.clone(); 41 + 42 + // Fetch invites for this resource to show collaborators 43 + let invites_resource = { 44 + let fetcher = fetcher.clone(); 45 + use_resource(move || { 46 + let fetcher = fetcher.clone(); 47 + let resource_uri = resource_uri.clone(); 48 + let _auth = auth_state.read().did.clone(); 49 + async move { 50 + fetch_sent_invites(&fetcher) 51 + .await 52 + .ok() 53 + .unwrap_or_default() 54 + .into_iter() 55 + .filter(|i| i.resource_uri == resource_uri) 56 + .collect::<Vec<_>>() 57 + } 58 + }) 59 + }; 60 + 61 + let invites: Vec<SentInvite> = invites_resource().unwrap_or_default(); 62 + let accepted_count = invites.iter().filter(|i| i.accepted).count(); 63 + let pending_count = invites.len() - accepted_count; 64 + 65 + let is_modal = on_close.is_some(); 66 + 67 + let panel_content = rsx! { 68 + div { class: "collaborators-panel", 69 + div { class: "collaborators-header", 70 + h4 { "Collaborators" } 71 + div { class: "collaborators-header-actions", 72 + Button { 73 + variant: ButtonVariant::Ghost, 74 + onclick: move |_| show_invite_dialog.set(true), 75 + "Invite" 76 + } 77 + if let Some(ref handler) = on_close { 78 + { 79 + let handler = handler.clone(); 80 + rsx! { 81 + Button { 82 + variant: ButtonVariant::Ghost, 83 + onclick: move |_| handler.call(()), 84 + "×" 85 + } 86 + } 87 + } 88 + } 89 + } 90 + } 91 + 92 + if invites.is_empty() { 93 + p { class: "empty-state", "No collaborators yet" } 94 + } else { 95 + div { class: "collaborators-list", 96 + for invite in &invites { 97 + div { 98 + class: if invite.accepted { "collaborator accepted" } else { "collaborator pending" }, 99 + span { class: "collaborator-did", "{invite.invitee}" } 100 + span { 101 + class: "collaborator-status", 102 + if invite.accepted { "✓" } else { "..." } 103 + } 104 + } 105 + } 106 + } 107 + 108 + div { class: "collaborators-summary", 109 + "{accepted_count} active, {pending_count} pending" 110 + } 111 + } 112 + } 113 + 114 + InviteDialog { 115 + open: show_invite_dialog(), 116 + on_close: move |_| show_invite_dialog.set(false), 117 + resource_uri: resource_uri_dialog.clone(), 118 + resource_cid: resource_cid.clone(), 119 + resource_title: resource_title.clone(), 120 + } 121 + }; 122 + 123 + if is_modal { 124 + rsx! { 125 + div { 126 + class: "collaborators-overlay", 127 + onclick: move |_| { 128 + if let Some(ref handler) = on_close_overlay { 129 + handler.call(()); 130 + } 131 + }, 132 + div { 133 + class: "collaborators-modal", 134 + onclick: move |e| e.stop_propagation(), 135 + {panel_content} 136 + } 137 + } 138 + } 139 + } else { 140 + panel_content 141 + } 142 + }
+176
crates/weaver-app/src/components/collab/invite_dialog.rs
··· 1 + //! Dialog for inviting collaborators. 2 + 3 + use crate::components::button::{Button, ButtonVariant}; 4 + use crate::components::dialog::{DialogContent, DialogDescription, DialogRoot, DialogTitle}; 5 + use crate::components::input::Input; 6 + use crate::fetch::Fetcher; 7 + use dioxus::prelude::*; 8 + use jacquard::types::string::{AtUri, Cid, Handle}; 9 + use jacquard::{IntoStatic, prelude::*}; 10 + use weaver_api::com_atproto::repo::strong_ref::StrongRef; 11 + 12 + use super::api::create_invite; 13 + 14 + /// Props for the InviteDialog component. 15 + #[derive(Props, Clone, PartialEq)] 16 + pub struct InviteDialogProps { 17 + /// Whether the dialog is open. 18 + pub open: bool, 19 + /// Callback when dialog should close. 20 + pub on_close: EventHandler<()>, 21 + /// The resource to invite collaborators to. 22 + pub resource_uri: AtUri<'static>, 23 + /// The CID of the resource. 24 + pub resource_cid: String, 25 + /// Optional title of the resource for display. 26 + #[props(default)] 27 + pub resource_title: Option<String>, 28 + } 29 + 30 + /// Dialog for inviting a user to collaborate on a resource. 31 + #[component] 32 + pub fn InviteDialog(props: InviteDialogProps) -> Element { 33 + let fetcher = use_context::<Fetcher>(); 34 + let mut handle_input = use_signal(String::new); 35 + let mut message_input = use_signal(String::new); 36 + let mut error = use_signal(|| None::<String>); 37 + let mut is_sending = use_signal(|| false); 38 + 39 + let resource_uri = props.resource_uri.clone(); 40 + let resource_cid = props.resource_cid.clone(); 41 + let on_close = props.on_close.clone(); 42 + 43 + let send_invite = move |_| { 44 + let fetcher = fetcher.clone(); 45 + let handle = handle_input(); 46 + let message = message_input(); 47 + let resource_uri = resource_uri.clone(); 48 + let resource_cid = resource_cid.clone(); 49 + let on_close = on_close.clone(); 50 + 51 + spawn(async move { 52 + is_sending.set(true); 53 + error.set(None); 54 + 55 + // Parse and resolve handle to DID 56 + let handle = match Handle::new(&handle) { 57 + Ok(h) => h, 58 + Err(e) => { 59 + error.set(Some(format!("Invalid handle: {}", e))); 60 + is_sending.set(false); 61 + return; 62 + } 63 + }; 64 + 65 + let invitee_did = match fetcher.resolve_handle(&handle).await { 66 + Ok(did) => did, 67 + Err(e) => { 68 + error.set(Some(format!("Could not resolve handle: {}", e))); 69 + is_sending.set(false); 70 + return; 71 + } 72 + }; 73 + 74 + // Build the resource StrongRef 75 + let cid = match Cid::new(resource_cid.as_bytes()) { 76 + Ok(c) => c.into_static(), 77 + Err(e) => { 78 + error.set(Some(format!("Invalid CID: {}", e))); 79 + is_sending.set(false); 80 + return; 81 + } 82 + }; 83 + 84 + let resource_ref = StrongRef::new().uri(resource_uri).cid(cid).build(); 85 + 86 + let message_opt = if message.is_empty() { 87 + None 88 + } else { 89 + Some(message) 90 + }; 91 + 92 + match create_invite( 93 + &fetcher, 94 + resource_ref, 95 + invitee_did.into_static(), 96 + message_opt, 97 + ) 98 + .await 99 + { 100 + Ok(_uri) => { 101 + // Success - close dialog 102 + handle_input.set(String::new()); 103 + message_input.set(String::new()); 104 + on_close.call(()); 105 + } 106 + Err(e) => { 107 + error.set(Some(format!("Failed to send invite: {}", e))); 108 + } 109 + } 110 + 111 + is_sending.set(false); 112 + }); 113 + }; 114 + 115 + let resource_display = props 116 + .resource_title 117 + .clone() 118 + .unwrap_or_else(|| props.resource_uri.to_string()); 119 + 120 + rsx! { 121 + DialogRoot { 122 + open: props.open, 123 + on_open_change: move |open: bool| { 124 + if !open { 125 + props.on_close.call(()); 126 + } 127 + }, 128 + DialogContent { 129 + DialogTitle { "Invite Collaborator" } 130 + DialogDescription { 131 + "Invite someone to collaborate on {resource_display}" 132 + } 133 + 134 + div { class: "invite-form", 135 + div { class: "form-field", 136 + label { "User handle" } 137 + Input { 138 + value: handle_input(), 139 + placeholder: "user.bsky.social", 140 + oninput: move |e: FormEvent| handle_input.set(e.value()), 141 + } 142 + } 143 + 144 + div { class: "form-field", 145 + label { "Message (optional)" } 146 + textarea { 147 + class: "invite-message", 148 + value: "{message_input}", 149 + placeholder: "Add a message...", 150 + oninput: move |e| message_input.set(e.value()), 151 + rows: 3, 152 + } 153 + } 154 + 155 + if let Some(err) = error() { 156 + div { class: "error-message", "{err}" } 157 + } 158 + 159 + div { class: "dialog-actions", 160 + Button { 161 + variant: ButtonVariant::Primary, 162 + onclick: send_invite, 163 + disabled: is_sending() || handle_input().is_empty(), 164 + if is_sending() { "Sending..." } else { "Send Invite" } 165 + } 166 + Button { 167 + variant: ButtonVariant::Ghost, 168 + onclick: move |_| props.on_close.call(()), 169 + "Cancel" 170 + } 171 + } 172 + } 173 + } 174 + } 175 + } 176 + }
+196
crates/weaver-app/src/components/collab/invites_list.rs
··· 1 + //! List of pending collaboration invites. 2 + 3 + use crate::auth::AuthState; 4 + use crate::components::button::{Button, ButtonVariant}; 5 + use crate::fetch::Fetcher; 6 + use dioxus::prelude::*; 7 + use jacquard::IntoStatic; 8 + use jacquard::types::string::{AtUri, Cid}; 9 + use weaver_api::com_atproto::repo::strong_ref::StrongRef; 10 + 11 + use super::api::{ 12 + ReceivedInvite, SentInvite, accept_invite, fetch_received_invites, fetch_sent_invites, 13 + }; 14 + 15 + /// Props for the InvitesList component. 16 + #[derive(Props, Clone, PartialEq)] 17 + pub struct InvitesListProps { 18 + /// Filter to a specific resource (optional). 19 + #[props(default)] 20 + pub resource_uri: Option<AtUri<'static>>, 21 + } 22 + 23 + /// List showing both sent and received invites. 24 + #[component] 25 + pub fn InvitesList(props: InvitesListProps) -> Element { 26 + let auth_state = use_context::<Signal<AuthState>>(); 27 + let fetcher = use_context::<Fetcher>(); 28 + 29 + let sent_invites = { 30 + let fetcher = fetcher.clone(); 31 + use_resource(move || { 32 + let fetcher = fetcher.clone(); 33 + let _auth = auth_state.read().did.clone(); 34 + async move { fetch_sent_invites(&fetcher).await.ok().unwrap_or_default() } 35 + }) 36 + }; 37 + 38 + let received_invites = { 39 + let fetcher = fetcher.clone(); 40 + use_resource(move || { 41 + let fetcher = fetcher.clone(); 42 + let _auth = auth_state.read().did.clone(); 43 + async move { 44 + fetch_received_invites(&fetcher) 45 + .await 46 + .ok() 47 + .unwrap_or_default() 48 + } 49 + }) 50 + }; 51 + 52 + let filter_uri = props.resource_uri.clone(); 53 + 54 + rsx! { 55 + div { class: "invites-list", 56 + // Received invites section 57 + div { class: "invites-section", 58 + h3 { "Received Invites" } 59 + { 60 + let invites: Vec<ReceivedInvite> = received_invites() 61 + .unwrap_or_default() 62 + .into_iter() 63 + .filter(|i| { 64 + filter_uri.as_ref().map_or(true, |uri| &i.resource_uri == uri) 65 + }) 66 + .collect(); 67 + 68 + if invites.is_empty() { 69 + rsx! { p { class: "empty-state", "No pending invites" } } 70 + } else { 71 + rsx! { 72 + for invite in invites { 73 + ReceivedInviteCard { invite: invite.clone() } 74 + } 75 + } 76 + } 77 + } 78 + } 79 + 80 + // Sent invites section 81 + div { class: "invites-section", 82 + h3 { "Sent Invites" } 83 + { 84 + let invites: Vec<SentInvite> = sent_invites() 85 + .unwrap_or_default() 86 + .into_iter() 87 + .filter(|i| { 88 + filter_uri.as_ref().map_or(true, |uri| &i.resource_uri == uri) 89 + }) 90 + .collect(); 91 + 92 + if invites.is_empty() { 93 + rsx! { p { class: "empty-state", "No sent invites" } } 94 + } else { 95 + rsx! { 96 + for invite in invites { 97 + SentInviteCard { invite: invite.clone() } 98 + } 99 + } 100 + } 101 + } 102 + } 103 + } 104 + } 105 + } 106 + 107 + /// Card showing a received invite with accept/decline actions. 108 + #[component] 109 + fn ReceivedInviteCard(invite: ReceivedInvite) -> Element { 110 + let fetcher = use_context::<Fetcher>(); 111 + let mut is_accepting = use_signal(|| false); 112 + let mut accepted = use_signal(|| false); 113 + let mut error = use_signal(|| None::<String>); 114 + 115 + let invite_uri = invite.uri.clone(); 116 + let invite_cid = invite.cid.clone(); 117 + let resource_uri = invite.resource_uri.clone(); 118 + 119 + let handle_accept = move |_| { 120 + let fetcher = fetcher.clone(); 121 + let invite_uri = invite_uri.clone(); 122 + let invite_cid = invite_cid.clone(); 123 + let resource_uri = resource_uri.clone(); 124 + 125 + spawn(async move { 126 + is_accepting.set(true); 127 + error.set(None); 128 + 129 + let invite_ref = StrongRef::new().uri(invite_uri).cid(invite_cid).build(); 130 + 131 + match accept_invite(&fetcher, invite_ref, resource_uri).await { 132 + Ok(_) => { 133 + accepted.set(true); 134 + } 135 + Err(e) => { 136 + error.set(Some(format!("Failed to accept: {}", e))); 137 + } 138 + } 139 + 140 + is_accepting.set(false); 141 + }); 142 + }; 143 + 144 + rsx! { 145 + div { class: "invite-card", 146 + div { class: "invite-info", 147 + span { class: "invite-from", "From: {invite.inviter}" } 148 + span { class: "invite-resource", "Resource: {invite.resource_uri}" } 149 + if let Some(msg) = &invite.message { 150 + p { class: "invite-message", "{msg}" } 151 + } 152 + } 153 + 154 + if let Some(err) = error() { 155 + div { class: "error-message", "{err}" } 156 + } 157 + 158 + div { class: "invite-actions", 159 + if accepted() { 160 + span { class: "invite-status accepted", "Accepted" } 161 + } else { 162 + Button { 163 + variant: ButtonVariant::Primary, 164 + onclick: handle_accept, 165 + disabled: is_accepting(), 166 + if is_accepting() { "Accepting..." } else { "Accept" } 167 + } 168 + } 169 + } 170 + } 171 + } 172 + } 173 + 174 + /// Card showing a sent invite with status. 175 + #[component] 176 + fn SentInviteCard(invite: SentInvite) -> Element { 177 + rsx! { 178 + div { class: "invite-card", 179 + div { class: "invite-info", 180 + span { class: "invite-to", "To: {invite.invitee}" } 181 + span { class: "invite-resource", "Resource: {invite.resource_uri}" } 182 + if let Some(msg) = &invite.message { 183 + p { class: "invite-message", "{msg}" } 184 + } 185 + } 186 + 187 + div { class: "invite-status", 188 + if invite.accepted { 189 + span { class: "status-badge accepted", "Accepted" } 190 + } else { 191 + span { class: "status-badge pending", "Pending" } 192 + } 193 + } 194 + } 195 + } 196 + }
+16
crates/weaver-app/src/components/collab/mod.rs
··· 1 + //! Collaboration components for inviting and managing collaborators. 2 + 3 + pub mod api; 4 + mod avatars; 5 + mod collaborators; 6 + mod invite_dialog; 7 + mod invites_list; 8 + 9 + pub use api::{ 10 + accept_invite, create_invite, fetch_received_invites, fetch_sent_invites, AcceptedInvite, 11 + ReceivedInvite, SentInvite, 12 + }; 13 + pub use avatars::CollaboratorAvatars; 14 + pub use collaborators::CollaboratorsPanel; 15 + pub use invite_dialog::InviteDialog; 16 + pub use invites_list::InvitesList;
+15
crates/weaver-app/src/components/editor/component.rs
··· 11 11 use weaver_common::WeaverExt; 12 12 13 13 use crate::auth::AuthState; 14 + use crate::components::collab::CollaboratorAvatars; 14 15 use crate::components::editor::ReportButton; 15 16 use crate::fetch::Fetcher; 16 17 ··· 711 712 } 712 713 713 714 div { class: "meta-actions", 715 + // Show collaborator avatars when editing an existing entry 716 + if let Some(entry_ref) = document.entry_ref() { 717 + { 718 + let title = document.title(); 719 + rsx! { 720 + CollaboratorAvatars { 721 + resource_uri: entry_ref.uri.clone(), 722 + resource_cid: entry_ref.cid.to_string(), 723 + resource_title: if title.is_empty() { None } else { Some(title) }, 724 + } 725 + } 726 + } 727 + } 728 + 714 729 SyncStatus { 715 730 document: document.clone(), 716 731 draft_key: draft_key.to_string(),
+2 -1
crates/weaver-app/src/components/editor/mod.rs
··· 60 60 // Sync 61 61 #[allow(unused_imports)] 62 62 pub use sync::{ 63 - load_and_merge_document, load_edit_state_from_pds, sync_to_pds, 63 + load_and_merge_document, load_edit_state_from_pds, sync_to_pds, 64 + list_drafts_from_pds, RemoteDraft, 64 65 PdsEditState, SyncState, SyncStatus, 65 66 }; 66 67
+10 -3
crates/weaver-app/src/components/editor/storage.rs
··· 3 3 //! Stores both human-readable content (for debugging) and the full CRDT 4 4 //! snapshot (for undo history preservation across sessions). 5 5 //! 6 - //! Storage key strategy: 7 - //! - New entries: `"draft:new:{uuid}"` 8 - //! - Editing existing: `"draft:{at-uri}"` 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()`. 9 16 10 17 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 11 18 use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
+297 -17
crates/weaver-app/src/components/editor/sync.rs
··· 34 34 use weaver_api::com_atproto::repo::strong_ref::StrongRef; 35 35 use weaver_api::com_atproto::sync::get_blob::GetBlob; 36 36 use weaver_api::sh_weaver::edit::diff::Diff; 37 + use weaver_api::sh_weaver::edit::draft::Draft; 37 38 use weaver_api::sh_weaver::edit::root::Root; 38 39 use weaver_api::sh_weaver::edit::{DocRef, DocRefValue, DraftRef, EntryRef}; 39 40 use weaver_common::constellation::{GetBacklinksQuery, RecordId}; ··· 46 47 47 48 const ROOT_NSID: &str = "sh.weaver.edit.root"; 48 49 const DIFF_NSID: &str = "sh.weaver.edit.diff"; 50 + const DRAFT_NSID: &str = "sh.weaver.edit.draft"; 49 51 const CONSTELLATION_URL: &str = "https://constellation.microcosm.blue"; 50 52 51 53 /// Build a DocRef for either a published entry or an unpublished draft. 52 54 /// 53 55 /// If entry_uri and entry_cid are provided, creates an EntryRef. 54 - /// Otherwise, creates a DraftRef with the given draft key. 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. 55 60 fn build_doc_ref( 61 + did: &Did<'_>, 56 62 draft_key: &str, 57 63 entry_uri: Option<&AtUri<'_>>, 58 64 entry_cid: Option<&Cid<'_>>, ··· 68 74 })), 69 75 extra_data: None, 70 76 }, 71 - _ => DocRef { 72 - value: DocRefValue::DraftRef(Box::new(DraftRef { 73 - draft_key: CowStr::from(draft_key.to_string()), 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 + })), 74 110 extra_data: None, 75 - })), 76 - extra_data: None, 77 - }, 111 + } 112 + } 78 113 } 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)) 79 128 } 80 129 81 130 /// Result of a sync operation. ··· 129 178 Ok(output.records.into_iter().next().map(|r| r.into_static())) 130 179 } 131 180 181 + /// 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 + 132 361 /// Find all diffs for a root record using constellation backlinks. 133 362 #[allow(dead_code)] 134 363 pub async fn find_diffs_for_root( ··· 178 407 /// 179 408 /// Uploads the current Loro snapshot as a blob and creates an `sh.weaver.edit.root` 180 409 /// record referencing the entry (or draft key if unpublished). 410 + /// 411 + /// For drafts, also creates the `sh.weaver.edit.draft` stub record first. 181 412 pub async fn create_edit_root( 182 413 fetcher: &Fetcher, 183 414 doc: &EditorDocument, ··· 191 422 .await 192 423 .ok_or_else(|| WeaverError::InvalidNotebook("Not authenticated".into()))?; 193 424 425 + // 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 + 194 443 // Export full snapshot 195 444 let snapshot = doc.export_snapshot(); 196 445 ··· 202 451 .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to upload snapshot: {}", e)))?; 203 452 204 453 // Build DocRef - use EntryRef if published, DraftRef if not 205 - let doc_ref = build_doc_ref(draft_key, entry_uri, entry_cid); 454 + let doc_ref = build_doc_ref(&did, draft_key, entry_uri, entry_cid); 206 455 207 456 // Build root record 208 457 let root = Root::new().doc(doc_ref).snapshot(blob_ref).build(); ··· 277 526 }; 278 527 279 528 // Build DocRef - use EntryRef if published, DraftRef if not 280 - let doc_ref = build_doc_ref(draft_key, entry_uri, entry_cid); 529 + let doc_ref = build_doc_ref(&did, draft_key, entry_uri, entry_cid); 281 530 282 531 // Build root reference 283 532 let root_ref = StrongRef::new() ··· 464 713 None => return Ok(None), 465 714 }; 466 715 716 + 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> { 467 741 // Build root URI 468 742 let root_uri = AtUri::new(&format!( 469 743 "at://{}/{}/{}", ··· 591 865 /// Loads from localStorage and PDS (if available), then merges both using Loro's 592 866 /// CRDT merge. The result is a pre-merged LoroDoc that can be converted to an 593 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. 594 871 pub async fn load_and_merge_document( 595 872 fetcher: &Fetcher, 596 873 draft_key: &str, ··· 601 878 // Load snapshot + entry_ref from localStorage 602 879 let local_data = load_snapshot_from_storage(draft_key); 603 880 604 - // Load from PDS (only if we have an entry URI) 881 + // Load from PDS - try entry URI first, then draft discovery 605 882 let pds_state = if let Some(uri) = entry_uri { 883 + // Published entry: query by entry URI 606 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? 607 889 } else { 890 + // Not authenticated, can't query PDS 608 891 None 609 892 }; 610 893 ··· 761 1044 let doc = props.document.clone(); 762 1045 let draft_key = props.draft_key.clone(); 763 1046 764 - // Check if we're authenticated and have an entry to sync 1047 + // Check if we're authenticated (drafts can sync via DraftRef even without entry) 765 1048 let is_authenticated = auth_state.read().is_authenticated(); 766 - let has_entry = doc.entry_ref().is_some(); 767 1049 768 1050 // Auto-sync trigger signal - set to true to trigger a sync 769 1051 let mut trigger_sync = use_signal(|| false); ··· 834 1116 return; 835 1117 } 836 1118 837 - // Check if authenticated and has entry 838 - if !is_authenticated || !has_entry { 1119 + // Check if authenticated (drafts can sync too via DraftRef) 1120 + if !is_authenticated { 839 1121 return; 840 1122 } 841 1123 ··· 889 1171 trigger_sync.set(true); 890 1172 }; 891 1173 892 - // Determine display state 1174 + // Determine display state (drafts can sync too via DraftRef) 893 1175 let display_state = if !is_authenticated { 894 1176 SyncState::Disabled 895 - } else if !has_entry { 896 - SyncState::Disabled // Can't sync unpublished entries 897 1177 } else { 898 1178 *sync_state.read() 899 1179 };
+35 -12
crates/weaver-app/src/components/entry.rs
··· 398 398 .format("%B %d, %Y") 399 399 .to_string(); 400 400 401 - // Check ownership 402 - let is_owner = { 401 + // Check edit access via permissions 402 + let can_edit = { 403 403 let current_did = auth_state.read().did.clone(); 404 - match (&current_did, &ident) { 405 - (Some(did), AtIdentifier::Did(ident_did)) => *did == *ident_did, 406 - _ => false, 404 + match &current_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, 407 417 } 408 418 }; 409 419 ··· 441 451 div { class: "entry-card-date", 442 452 time { datetime: "{entry_view.indexed_at.as_str()}", "{formatted_date}" } 443 453 } 444 - if is_owner { 454 + if can_edit { 445 455 EntryActions { 446 456 entry_uri, 447 457 entry_cid: entry_view.cid.clone().into_static(), 448 458 entry_title: title.to_string(), 449 459 in_notebook: true, 450 460 notebook_title: Some(book_title.clone()), 461 + permissions: entry_view.permissions.clone(), 451 462 on_removed: Some(EventHandler::new(move |_| hidden.set(true))) 452 463 } 453 464 } ··· 568 579 // Get first author if we're showing it 569 580 let first_author = if show_author { entry_view.authors.first() } else { None }; 570 581 571 - // Check ownership for actions 582 + // Check edit access via permissions 572 583 let auth_state = use_context::<Signal<AuthState>>(); 573 - let is_owner = { 584 + let can_edit = { 574 585 let current_did = auth_state.read().did.clone(); 575 - match (&current_did, &ident) { 576 - (Some(did), AtIdentifier::Did(ident_did)) => *did == *ident_did, 577 - _ => false, 586 + match &current_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, 578 599 } 579 600 }; 580 601 ··· 604 625 time { datetime: "{entry.created_at.as_str()}", "{formatted_date}" } 605 626 } 606 627 } 607 - if show_actions && is_owner { 628 + if show_actions && can_edit { 608 629 crate::components::EntryActions { 609 630 entry_uri: entry_view.uri.clone().into_static(), 610 631 entry_cid: entry_view.cid.clone().into_static(), 611 632 entry_title: title.to_string(), 612 633 in_notebook: false, 613 634 is_pinned, 635 + permissions: entry_view.permissions.clone(), 614 636 on_pinned_changed 615 637 } 616 638 } ··· 739 761 entry_title, 740 762 in_notebook: book_title.is_some(), 741 763 notebook_title: book_title.clone(), 764 + permissions: entry_view.permissions.clone(), 742 765 on_removed: Some(EventHandler::new(on_removed)) 743 766 } 744 767 }
+20 -6
crates/weaver-app/src/components/entry_actions.rs
··· 15 15 use weaver_api::com_atproto::repo::put_record::PutRecord; 16 16 use weaver_api::com_atproto::repo::strong_ref::StrongRef; 17 17 use weaver_api::sh_weaver::actor::profile::Profile as WeaverProfile; 18 + use weaver_api::sh_weaver::notebook::PermissionsState; 18 19 19 20 const ENTRY_ACTIONS_CSS: Asset = asset!("/assets/styling/entry-actions.css"); 20 21 ··· 35 36 /// Whether this entry is currently pinned 36 37 #[props(default = false)] 37 38 pub is_pinned: bool, 39 + /// Permissions state for edit access checking (if available) 40 + #[props(default)] 41 + pub permissions: Option<PermissionsState<'static>>, 38 42 /// Callback when entry is removed from notebook (for optimistic UI update) 39 43 #[props(default)] 40 44 pub on_removed: Option<EventHandler<()>>, ··· 58 62 let mut pinning = use_signal(|| false); 59 63 let mut error = use_signal(|| None::<String>); 60 64 61 - // Check ownership - compare auth DID with entry's authority 65 + // Check edit access - use permissions if available, fall back to ownership check 62 66 let current_did = auth_state.read().did.clone(); 63 - let entry_authority = props.entry_uri.authority(); 64 - let is_owner = match (&current_did, entry_authority) { 65 - (Some(current), AtIdentifier::Did(entry_did)) => *current == *entry_did, 66 - _ => false, 67 + let can_edit = match &current_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, 67 81 }; 68 82 69 - if !is_owner { 83 + if !can_edit { 70 84 return rsx! {}; 71 85 } 72 86
+36 -25
crates/weaver-app/src/components/identity.rs
··· 90 90 use jacquard::from_data; 91 91 use weaver_api::sh_weaver::notebook::book::Book; 92 92 93 + let auth_state = use_context::<Signal<AuthState>>(); 94 + 93 95 // Use client-only versions to avoid SSR issues with concurrent server futures 94 96 let (_profile_res, profile) = data::use_profile_data_client(ident); 95 97 let (_notebooks_res, notebooks) = data::use_notebooks_for_did_client(ident); 96 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 (&current_did, ident()) { 104 + (Some(did), AtIdentifier::Did(profile_did)) => *did == profile_did, 105 + _ => false, 106 + } 107 + }); 97 108 98 109 // Extract pinned URIs from profile (only Weaver ProfileView has pinned) 99 110 let pinned_uris = use_memo(move || { ··· 320 331 div { class: "repository-layout", 321 332 // Profile sidebar (desktop) / header (mobile) 322 333 aside { class: "repository-sidebar", 323 - ProfileDisplay { profile, notebooks, entry_count: *entry_count.read() } 334 + ProfileDisplay { profile, notebooks, entry_count: *entry_count.read(), is_own_profile: is_own_profile() } 324 335 } 325 336 326 337 // Main content area ··· 622 633 if let Some(ref date) = created_at { 623 634 div { class: "entry-preview-date", "{date}" } 624 635 } 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 - } 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() 633 644 } 634 645 } 635 646 if let Some(ref html) = preview_html { ··· 693 704 if let Some(ref date) = created_at { 694 705 div { class: "entry-preview-date", "{date}" } 695 706 } 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 - } 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() 704 715 } 705 716 } 706 717 if let Some(ref html) = preview_html { ··· 773 784 if let Some(ref date) = created_at { 774 785 div { class: "entry-preview-date", "{date}" } 775 786 } 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 - } 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() 784 795 } 785 796 } 786 797 if let Some(ref html) = preview_html {
+3
crates/weaver-app/src/components/mod.rs
··· 28 28 pub mod record_editor; 29 29 pub mod record_view; 30 30 31 + pub mod collab; 32 + pub use collab::{CollaboratorAvatars, CollaboratorsPanel, InviteDialog, InvitesList}; 33 + 31 34 use dioxus::prelude::*; 32 35 33 36 #[derive(PartialEq, Props, Clone)]
+147 -2
crates/weaver-app/src/components/profile.rs
··· 2 2 3 3 use std::sync::Arc; 4 4 5 + use crate::Route; 6 + use crate::components::button::{Button, ButtonVariant}; 7 + use crate::components::collab::api::{ReceivedInvite, accept_invite, fetch_received_invites}; 5 8 use crate::components::{ 6 9 BskyIcon, TangledIcon, 7 10 avatar::{Avatar, AvatarImage}, 8 11 }; 12 + use crate::fetch::Fetcher; 9 13 use dioxus::prelude::*; 10 14 use weaver_api::com_atproto::repo::strong_ref::StrongRef; 11 15 use weaver_api::sh_weaver::actor::{ProfileDataView, ProfileDataViewInner}; ··· 18 22 profile: Memo<Option<ProfileDataView<'static>>>, 19 23 notebooks: Memo<Option<Vec<(NotebookView<'static>, Vec<StrongRef<'static>>)>>>, 20 24 #[props(default)] entry_count: usize, 25 + #[props(default)] is_own_profile: bool, 21 26 ) -> Element { 22 27 match &*profile.read() { 23 28 Some(profile_view) => { ··· 63 68 64 69 // Links 65 70 ProfileLinks { profile_view } 71 + 72 + // Invites (only on own profile) 73 + if is_own_profile { 74 + ProfileInvites {} 75 + } 66 76 } 67 - 68 - 69 77 } 70 78 } 71 79 } ··· 322 330 _ => rsx! {}, 323 331 } 324 332 } 333 + 334 + /// Shows pending collaboration invites on the user's own profile. 335 + #[component] 336 + fn ProfileInvites() -> Element { 337 + let fetcher = use_context::<Fetcher>(); 338 + 339 + // Fetch received invites 340 + let invites_resource = { 341 + let fetcher = fetcher.clone(); 342 + use_resource(move || { 343 + let fetcher = fetcher.clone(); 344 + async move { 345 + fetch_received_invites(&fetcher) 346 + .await 347 + .ok() 348 + .unwrap_or_default() 349 + } 350 + }) 351 + }; 352 + 353 + let invites: Vec<ReceivedInvite> = invites_resource().unwrap_or_default(); 354 + 355 + // Don't render section if no invites 356 + if invites.is_empty() { 357 + return rsx! {}; 358 + } 359 + 360 + rsx! { 361 + div { class: "profile-invites", 362 + h3 { class: "profile-invites-header", "Collaboration Invites" } 363 + 364 + div { class: "profile-invites-list", 365 + for invite in invites { 366 + ProfileInviteCard { invite } 367 + } 368 + } 369 + } 370 + } 371 + } 372 + 373 + /// A single invite card in the profile sidebar. 374 + #[component] 375 + fn ProfileInviteCard(invite: ReceivedInvite) -> Element { 376 + let fetcher = use_context::<Fetcher>(); 377 + let nav = use_navigator(); 378 + let mut is_accepting = use_signal(|| false); 379 + let mut accepted = use_signal(|| false); 380 + let mut error = use_signal(|| None::<String>); 381 + 382 + let invite_uri = invite.uri.clone(); 383 + let invite_cid = invite.cid.clone(); 384 + let resource_uri = invite.resource_uri.clone(); 385 + let resource_uri_nav = invite.resource_uri.clone(); 386 + 387 + let handle_accept = move |_| { 388 + let fetcher = fetcher.clone(); 389 + let invite_uri = invite_uri.clone(); 390 + let invite_cid = invite_cid.clone(); 391 + let resource_uri = resource_uri.clone(); 392 + let resource_uri_nav = resource_uri_nav.clone(); 393 + 394 + spawn(async move { 395 + is_accepting.set(true); 396 + error.set(None); 397 + 398 + let invite_ref = StrongRef::new().uri(invite_uri).cid(invite_cid).build(); 399 + 400 + match accept_invite(&fetcher, invite_ref, resource_uri).await { 401 + Ok(_) => { 402 + accepted.set(true); 403 + // Navigate to the resource after a short delay 404 + #[cfg(target_arch = "wasm32")] 405 + { 406 + use gloo_timers::future::TimeoutFuture; 407 + TimeoutFuture::new(500).await; 408 + } 409 + // Navigate to the entry - parse AT-URI into path segments 410 + // at://did/collection/rkey -> ["did", "collection", "rkey"] 411 + let uri_str = resource_uri_nav.to_string(); 412 + let uri_parts: Vec<String> = uri_str 413 + .strip_prefix("at://") 414 + .unwrap_or(&uri_str) 415 + .split('/') 416 + .map(|s| s.to_string()) 417 + .collect(); 418 + nav.push(Route::RecordPage { uri: uri_parts }); 419 + } 420 + Err(e) => { 421 + error.set(Some(format!("Failed: {}", e))); 422 + } 423 + } 424 + 425 + is_accepting.set(false); 426 + }); 427 + }; 428 + 429 + // Extract inviter display (last part of DID for now) 430 + let inviter_display = invite 431 + .inviter 432 + .as_ref() 433 + .split(':') 434 + .last() 435 + .unwrap_or("unknown") 436 + .chars() 437 + .take(12) 438 + .collect::<String>(); 439 + 440 + rsx! { 441 + div { class: "profile-invite-card", 442 + div { class: "profile-invite-from", 443 + "From: " 444 + span { class: "profile-invite-did", "{inviter_display}…" } 445 + } 446 + 447 + if let Some(msg) = &invite.message { 448 + p { class: "profile-invite-message", "{msg}" } 449 + } 450 + 451 + if let Some(err) = error() { 452 + div { class: "profile-invite-error", "{err}" } 453 + } 454 + 455 + div { class: "profile-invite-actions", 456 + if accepted() { 457 + span { class: "profile-invite-accepted", "Accepted ✓" } 458 + } else { 459 + Button { 460 + variant: ButtonVariant::Primary, 461 + onclick: handle_accept, 462 + disabled: is_accepting(), 463 + if is_accepting() { "Accepting..." } else { "Accept" } 464 + } 465 + } 466 + } 467 + } 468 + } 469 + }
+17
crates/weaver-app/src/components/profile_actions.rs
··· 56 56 "Drafts" 57 57 } 58 58 } 59 + 60 + Link { 61 + to: Route::InvitesPage { ident: ident() }, 62 + class: "profile-action-link", 63 + Button { 64 + variant: ButtonVariant::Ghost, 65 + "Invites" 66 + } 67 + } 59 68 } 60 69 } 61 70 } ··· 94 103 Button { 95 104 variant: ButtonVariant::Ghost, 96 105 "Drafts" 106 + } 107 + } 108 + 109 + Link { 110 + to: Route::InvitesPage { ident: ident() }, 111 + Button { 112 + variant: ButtonVariant::Ghost, 113 + "Invites" 97 114 } 98 115 } 99 116 }
+100
crates/weaver-app/src/data.rs
··· 1181 1181 } 1182 1182 1183 1183 // ============================================================================ 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, &current_did).await { 1276 + Ok(can_edit) => Some(can_edit), 1277 + Err(_) => Some(false), 1278 + } 1279 + } 1280 + }) 1281 + } 1282 + 1283 + // ============================================================================ 1184 1284 // Standalone Entry by Rkey Hooks 1185 1285 // ============================================================================ 1186 1286
+4 -4
crates/weaver-app/src/env.rs
··· 1 1 // This file is automatically generated by build.rs 2 2 3 3 #[allow(unused)] 4 - pub const WEAVER_APP_ENV: &'static str = "prod"; 4 + pub const WEAVER_APP_ENV: &'static str = "dev"; 5 5 #[allow(unused)] 6 - pub const WEAVER_APP_HOST: &'static str = "https://alpha.weaver.sh"; 6 + pub const WEAVER_APP_HOST: &'static str = "http://localhost"; 7 7 #[allow(unused)] 8 - pub const WEAVER_APP_DOMAIN: &'static str = "https://alpha.weaver.sh"; 8 + pub const WEAVER_APP_DOMAIN: &'static str = ""; 9 9 #[allow(unused)] 10 10 pub const WEAVER_PORT: &'static str = "8080"; 11 11 #[allow(unused)] ··· 13 13 #[allow(unused)] 14 14 pub const WEAVER_CLIENT_NAME: &'static str = "Weaver"; 15 15 #[allow(unused)] 16 - pub const WEAVER_LOGO_URI: &'static str = "https://alpha.weaver.sh/favicon.ico"; 16 + pub const WEAVER_LOGO_URI: &'static str = ""; 17 17 #[allow(unused)] 18 18 pub const WEAVER_TOS_URI: &'static str = ""; 19 19 #[allow(unused)]
+6 -3
crates/weaver-app/src/main.rs
··· 17 17 use std::sync::{Arc, LazyLock}; 18 18 #[allow(unused)] 19 19 use views::{ 20 - Callback, DraftEdit, DraftsList, Editor, Home, Navbar, NewDraft, Notebook, NotebookEntryByRkey, 21 - NotebookEntryEdit, NotebookIndex, NotebookPage, RecordIndex, RecordPage, StandaloneEntry, 22 - StandaloneEntryEdit, 20 + Callback, DraftEdit, DraftsList, Editor, Home, InvitesPage, Navbar, NewDraft, Notebook, 21 + NotebookEntryByRkey, NotebookEntryEdit, NotebookIndex, NotebookPage, RecordIndex, RecordPage, 22 + StandaloneEntry, StandaloneEntryEdit, 23 23 }; 24 24 25 25 use crate::{ ··· 80 80 DraftEdit { ident: AtIdentifier<'static>, tid: SmolStr }, 81 81 #[route("/new?:notebook")] 82 82 NewDraft { ident: AtIdentifier<'static>, notebook: Option<SmolStr> }, 83 + // Collaboration invites 84 + #[route("/invites")] 85 + InvitesPage { ident: AtIdentifier<'static> }, 83 86 // Standalone entry routes 84 87 #[route("/e/:rkey")] 85 88 StandaloneEntry { ident: AtIdentifier<'static>, rkey: SmolStr },
+124 -18
crates/weaver-app/src/views/drafts.rs
··· 4 4 use crate::auth::AuthState; 5 5 use crate::components::button::{Button, ButtonVariant}; 6 6 use crate::components::dialog::{DialogContent, DialogDescription, DialogRoot, DialogTitle}; 7 + use crate::components::editor::{list_drafts_from_pds, RemoteDraft}; 7 8 use crate::components::editor::{delete_draft, list_drafts}; 9 + use crate::fetch::Fetcher; 8 10 use dioxus::prelude::*; 9 11 use jacquard::smol_str::SmolStr; 10 12 use jacquard::types::ident::AtIdentifier; 13 + use std::collections::HashSet; 11 14 12 15 const DRAFTS_CSS: Asset = asset!("/assets/styling/drafts.css"); 16 + 17 + /// Merged draft entry showing both local and remote state. 18 + #[derive(Clone, Debug, PartialEq)] 19 + struct MergedDraft { 20 + /// The rkey/tid of the draft 21 + rkey: String, 22 + /// Title from local storage (if available) 23 + title: String, 24 + /// Whether this draft exists locally 25 + is_local: bool, 26 + /// Whether this draft exists on PDS 27 + is_remote: bool, 28 + /// If editing an existing entry, the URI 29 + editing_uri: Option<String>, 30 + } 13 31 14 32 /// Drafts list page - shows all drafts for the authenticated user. 15 33 #[component] 16 34 pub fn DraftsList(ident: ReadSignal<AtIdentifier<'static>>) -> Element { 17 35 // ALL hooks must be called unconditionally at the top 18 36 let auth_state = use_context::<Signal<AuthState>>(); 37 + let fetcher = use_context::<Fetcher>(); 19 38 let navigator = use_navigator(); 20 - let mut drafts = use_signal(list_drafts); 39 + let mut local_drafts = use_signal(list_drafts); 21 40 let mut show_delete_confirm = use_signal(|| None::<String>); 41 + 42 + // Fetch remote drafts from PDS (depends on auth state to re-run when logged in) 43 + let remote_drafts_resource = use_resource(move || { 44 + let fetcher = fetcher.clone(); 45 + let _did = auth_state.read().did.clone(); // Track auth state for reactivity 46 + async move { list_drafts_from_pds(&fetcher).await.ok().unwrap_or_default() } 47 + }); 22 48 23 49 // Check ownership - redirect if not viewing own drafts 24 50 let current_did = auth_state.read().did.clone(); ··· 41 67 return rsx! { div { "Redirecting..." } }; 42 68 } 43 69 70 + // Merge local and remote drafts 71 + let merged_drafts = use_memo(move || { 72 + let local = local_drafts(); 73 + let remote: Vec<RemoteDraft> = remote_drafts_resource().unwrap_or_default(); 74 + 75 + tracing::debug!("Merging drafts: {} local, {} remote", local.len(), remote.len()); 76 + for (key, _, _) in &local { 77 + tracing::debug!(" Local draft key: {}", key); 78 + } 79 + for rd in &remote { 80 + tracing::debug!(" Remote draft rkey: {}", rd.rkey); 81 + } 82 + 83 + // Build set of remote rkeys for quick lookup 84 + let remote_rkeys: HashSet<String> = remote.iter().map(|d| d.rkey.clone()).collect(); 85 + 86 + // Build set of local rkeys 87 + let local_rkeys: HashSet<String> = local 88 + .iter() 89 + .map(|(key, _, _)| { 90 + key.strip_prefix("new:").unwrap_or(key).to_string() 91 + }) 92 + .collect(); 93 + 94 + let mut merged = Vec::new(); 95 + 96 + // Add local drafts 97 + for (key, title, editing_uri) in &local { 98 + let rkey = key.strip_prefix("new:").unwrap_or(key).to_string(); 99 + merged.push(MergedDraft { 100 + rkey: rkey.clone(), 101 + title: title.clone(), 102 + is_local: true, 103 + is_remote: remote_rkeys.contains(&rkey), 104 + editing_uri: editing_uri.clone(), 105 + }); 106 + } 107 + 108 + // Add remote-only drafts 109 + for remote_draft in &remote { 110 + if !local_rkeys.contains(&remote_draft.rkey) { 111 + tracing::info!("Adding remote-only draft: {}", remote_draft.rkey); 112 + merged.push(MergedDraft { 113 + rkey: remote_draft.rkey.clone(), 114 + title: String::new(), // No local title available 115 + is_local: false, 116 + is_remote: true, 117 + editing_uri: None, 118 + }); 119 + } 120 + } 121 + 122 + // Sort by rkey (which is a TID, so newer drafts first) 123 + merged.sort_by(|a, b| b.rkey.cmp(&a.rkey)); 124 + 125 + tracing::info!("Merged {} drafts total", merged.len()); 126 + for m in &merged { 127 + tracing::info!(" Merged: rkey={} is_local={} is_remote={}", m.rkey, m.is_local, m.is_remote); 128 + } 129 + 130 + merged 131 + }); 132 + 44 133 let mut handle_delete = move |key: String| { 45 134 delete_draft(&key); 46 - drafts.set(list_drafts()); 135 + local_drafts.set(list_drafts()); 47 136 show_delete_confirm.set(None); 48 137 }; 49 138 ··· 63 152 } 64 153 } 65 154 66 - if drafts().is_empty() { 155 + if merged_drafts().is_empty() { 67 156 div { class: "drafts-empty", 68 157 p { "No drafts yet." } 69 158 p { "Start writing something new!" } 70 159 } 71 160 } else { 72 161 div { class: "drafts-list", 73 - for (key, title, editing_uri) in drafts() { 162 + for draft in merged_drafts() { 74 163 { 75 - let key_for_delete = key.clone(); 76 - let is_edit_draft = editing_uri.is_some(); 77 - let display_title = if title.is_empty() { "Untitled".to_string() } else { title }; 78 - let tid = key.strip_prefix("new:").unwrap_or(&key); 164 + let key_for_delete = format!("new:{}", draft.rkey); 165 + let is_edit_draft = draft.editing_uri.is_some(); 166 + let display_title = if draft.title.is_empty() { 167 + "Untitled".to_string() 168 + } else { 169 + draft.title.clone() 170 + }; 171 + 172 + // Determine sync status badge 173 + let (sync_badge, sync_class) = match (draft.is_local, draft.is_remote) { 174 + (true, true) => ("Synced", "draft-badge-synced"), 175 + (true, false) => ("Local", "draft-badge-local"), 176 + (false, true) => ("Remote", "draft-badge-remote"), 177 + (false, false) => ("", ""), // shouldn't happen 178 + }; 179 + tracing::info!("Rendering draft {} - badge='{}' class='{}'", draft.rkey, sync_badge, sync_class); 79 180 80 181 rsx! { 81 182 div { 82 183 class: "draft-card", 83 - key: "{key}", 184 + key: "{draft.rkey}", 84 185 85 186 Link { 86 187 to: Route::DraftEdit { 87 188 ident: ident(), 88 - tid: tid.to_string().into(), 189 + tid: draft.rkey.clone().into(), 89 190 }, 90 191 class: "draft-card-link", 91 192 92 193 div { class: "draft-card-content", 93 194 h3 { class: "draft-title", "{display_title}" } 94 - if is_edit_draft { 95 - span { class: "draft-badge draft-badge-edit", "Editing" } 96 - } else { 97 - span { class: "draft-badge draft-badge-new", "New" } 195 + div { class: "draft-badges", 196 + if is_edit_draft { 197 + span { class: "draft-badge draft-badge-edit", "Editing" } 198 + } 199 + if !sync_badge.is_empty() { 200 + span { class: "draft-badge {sync_class}", "{sync_badge}" } 201 + } 98 202 } 99 203 } 100 204 } 101 205 102 - Button { 103 - variant: ButtonVariant::Ghost, 104 - onclick: move |_| show_delete_confirm.set(Some(key_for_delete.clone())), 105 - "×" 206 + if draft.is_local { 207 + Button { 208 + variant: ButtonVariant::Ghost, 209 + onclick: move |_| show_delete_confirm.set(Some(key_for_delete.clone())), 210 + "×" 211 + } 106 212 } 107 213 } 108 214 }
+52
crates/weaver-app/src/views/invites.rs
··· 1 + //! Collaboration invites page. 2 + 3 + use crate::Route; 4 + use crate::auth::AuthState; 5 + use crate::components::collab::InvitesList; 6 + use dioxus::prelude::*; 7 + use jacquard::types::ident::AtIdentifier; 8 + 9 + const INVITES_CSS: Asset = asset!("/assets/styling/invites.css"); 10 + 11 + /// Page showing collaboration invites (sent and received). 12 + #[component] 13 + pub fn InvitesPage(ident: ReadSignal<AtIdentifier<'static>>) -> Element { 14 + let auth_state = use_context::<Signal<AuthState>>(); 15 + let navigator = use_navigator(); 16 + 17 + // Check ownership - only show to authenticated user viewing their own invites 18 + let current_did = auth_state.read().did.clone(); 19 + let is_owner = match (&current_did, ident()) { 20 + (Some(did), AtIdentifier::Did(ref ident_did)) => *did == *ident_did, 21 + _ => false, 22 + }; 23 + 24 + // Redirect non-owners 25 + let ident_for_redirect = ident(); 26 + use_effect(move || { 27 + if !is_owner { 28 + navigator.replace(Route::RepositoryIndex { 29 + ident: ident_for_redirect.clone(), 30 + }); 31 + } 32 + }); 33 + 34 + if !is_owner { 35 + return rsx! { div { "Redirecting..." } }; 36 + } 37 + 38 + rsx! { 39 + document::Stylesheet { href: INVITES_CSS } 40 + 41 + div { class: "invites-page", 42 + header { class: "invites-header", 43 + h1 { "Collaboration Invites" } 44 + p { class: "invites-description", 45 + "Manage your collaboration invitations. Accept invites to collaborate on entries and notebooks." 46 + } 47 + } 48 + 49 + InvitesList {} 50 + } 51 + } 52 + }
+3
crates/weaver-app/src/views/mod.rs
··· 34 34 35 35 mod entry; 36 36 pub use entry::{NotebookEntryByRkey, StandaloneEntry}; 37 + 38 + mod invites; 39 + pub use invites::InvitesPage;
+776 -15
crates/weaver-common/src/agent.rs
··· 1 1 use weaver_api::app_bsky::actor::get_profile::GetProfile; 2 2 // Re-export view types for use elsewhere 3 3 pub use weaver_api::sh_weaver::notebook::{ 4 - AuthorListView, BookEntryRef, BookEntryView, EntryView, NotebookView, 4 + AuthorListView, BookEntryRef, BookEntryView, EntryView, NotebookView, PermissionGrant, 5 + PermissionsState, 5 6 }; 6 7 7 8 // Re-export jacquard for convenience ··· 67 68 /// 68 69 /// This trait is for multi-step workflows that coordinate between multiple operations. 69 70 //#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))] 70 - pub trait WeaverExt: AgentSessionExt + XrpcExt + Send + Sync { 71 + pub trait WeaverExt: AgentSessionExt + XrpcExt + Send + Sync + Sized { 71 72 /// Publish a blob to the user's PDS 72 73 /// 73 74 /// Multi-step workflow: ··· 414 415 &self, 415 416 uri: &AtUri<'_>, 416 417 ) -> impl Future<Output = Result<(NotebookView<'static>, Vec<StrongRef<'static>>), WeaverError>> 418 + where 419 + Self: Sized, 417 420 { 418 421 async move { 419 422 use jacquard::to_data; ··· 460 463 .map(IntoStatic::into_static) 461 464 .collect(); 462 465 466 + // Fetch permissions for this notebook 467 + let permissions = self.get_permissions_for_resource(uri).await?; 468 + 463 469 Ok(( 464 470 NotebookView::new() 465 471 .cid(notebook.cid.ok_or_else(|| { ··· 471 477 .maybe_path(path) 472 478 .maybe_tags(tags) 473 479 .authors(authors) 480 + .permissions(permissions) 474 481 .record(to_data(&notebook.value).map_err(|_| { 475 482 AgentError::from(ClientError::invalid_request( 476 483 "Failed to serialize notebook", ··· 487 494 &self, 488 495 notebook: &NotebookView<'a>, 489 496 entry_ref: &StrongRef<'_>, 490 - ) -> impl Future<Output = Result<EntryView<'a>, WeaverError>> { 497 + ) -> impl Future<Output = Result<EntryView<'a>, WeaverError>> 498 + where 499 + Self: Sized, 500 + { 491 501 async move { 492 502 use jacquard::to_data; 493 503 use weaver_api::sh_weaver::notebook::entry::Entry; ··· 500 510 let path = entry.value.path.clone(); 501 511 let tags = entry.value.tags.clone(); 502 512 513 + // Fetch permissions for this entry (includes inherited notebook permissions) 514 + let permissions = self.get_permissions_for_resource(&entry_uri).await?; 515 + 516 + // Fetch contributors (evidence-based authors) for this entry 517 + let contributor_dids = self.find_contributors_for_resource(&entry_uri).await?; 518 + let mut authors = Vec::new(); 519 + for (index, did) in contributor_dids.iter().enumerate() { 520 + let (profile_uri, profile_view) = self.hydrate_profile_view(did).await?; 521 + authors.push( 522 + AuthorListView::new() 523 + .maybe_uri(profile_uri) 524 + .record(profile_view) 525 + .index(index as i64) 526 + .build(), 527 + ); 528 + } 529 + 503 530 Ok(EntryView::new() 504 531 .cid(entry.cid.ok_or_else(|| { 505 532 AgentError::from(ClientError::invalid_request("Entry missing CID")) ··· 512 539 .maybe_tags(tags) 513 540 .title(title) 514 541 .path(path) 515 - .authors(notebook.authors.clone()) 542 + .authors(authors) 543 + .permissions(permissions) 516 544 .build()) 517 545 } 518 546 } ··· 527 555 entries: &[StrongRef<'_>], 528 556 title: &str, 529 557 ) -> impl Future<Output = Result<Option<(BookEntryView<'a>, entry::Entry<'a>)>, WeaverError>> 558 + where 559 + Self: Sized, 530 560 { 531 561 async move { 532 562 use weaver_api::sh_weaver::notebook::BookEntryRef; ··· 676 706 .map(IntoStatic::into_static) 677 707 .collect(); 678 708 709 + // Fetch permissions for this notebook 710 + let permissions = self.get_permissions_for_resource(&record.uri).await?; 711 + 679 712 return Ok(Some(( 680 713 NotebookView::new() 681 714 .cid(record.cid) ··· 685 718 .maybe_path(path) 686 719 .maybe_tags(tags) 687 720 .authors(authors) 721 + .permissions(permissions) 688 722 .record(record.value.clone()) 689 723 .build() 690 724 .into_static(), ··· 933 967 &self, 934 968 notebook: &NotebookView<'a>, 935 969 entry_ref: &StrongRef<'_>, 936 - ) -> impl Future<Output = Result<EntryView<'a>, WeaverError>> { 970 + ) -> impl Future<Output = Result<EntryView<'a>, WeaverError>> 971 + where 972 + Self: Sized, 973 + { 937 974 async move { 938 975 use jacquard::to_data; 939 976 use weaver_api::sh_weaver::notebook::page::Page; ··· 945 982 let title = entry.value.title.clone(); 946 983 let tags = entry.value.tags.clone(); 947 984 985 + // Fetch permissions for this page (includes inherited notebook permissions) 986 + let permissions = self.get_permissions_for_resource(&entry_uri).await?; 987 + 988 + // Fetch contributors (evidence-based authors) for this page 989 + let contributor_dids = self.find_contributors_for_resource(&entry_uri).await?; 990 + let mut authors = Vec::new(); 991 + for (index, did) in contributor_dids.iter().enumerate() { 992 + let (profile_uri, profile_view) = self.hydrate_profile_view(did).await?; 993 + authors.push( 994 + AuthorListView::new() 995 + .maybe_uri(profile_uri) 996 + .record(profile_view) 997 + .index(index as i64) 998 + .build(), 999 + ); 1000 + } 1001 + 948 1002 Ok(EntryView::new() 949 1003 .cid(entry.cid.ok_or_else(|| { 950 1004 AgentError::from(ClientError::invalid_request("Page missing CID")) ··· 956 1010 })?) 957 1011 .maybe_tags(tags) 958 1012 .title(title) 959 - .authors(notebook.authors.clone()) 1013 + .authors(authors) 1014 + .permissions(permissions) 960 1015 .build()) 961 1016 } 962 1017 } ··· 1103 1158 ))) 1104 1159 })?; 1105 1160 1106 - // Build EntryView - without notebook authors, just the entry author 1161 + // Build entry URI for contributor/permission queries 1162 + let entry_uri = entry::Entry::uri(record.uri.clone()) 1163 + .map_err(|_| AgentError::from(ClientError::invalid_request("Invalid entry URI")))?; 1164 + 1165 + // Fetch contributors (evidence-based authors) 1166 + let contributor_dids = self.find_contributors_for_resource(&entry_uri).await?; 1107 1167 let mut authors = Vec::new(); 1108 - let (profile_uri, profile_view) = self.hydrate_profile_view(&repo_did).await?; 1109 - authors.push( 1110 - AuthorListView::new() 1111 - .maybe_uri(profile_uri) 1112 - .record(profile_view) 1113 - .index(0) 1114 - .build(), 1115 - ); 1168 + for (index, did) in contributor_dids.iter().enumerate() { 1169 + let (profile_uri, profile_view) = self.hydrate_profile_view(did).await?; 1170 + authors.push( 1171 + AuthorListView::new() 1172 + .maybe_uri(profile_uri) 1173 + .record(profile_view) 1174 + .index(index as i64) 1175 + .build(), 1176 + ); 1177 + } 1178 + 1179 + // Fetch permissions 1180 + let permissions = self.get_permissions_for_resource(&entry_uri).await?; 1116 1181 1117 1182 let entry_view = EntryView::new() 1118 1183 .cid(record.cid.ok_or_else(|| { ··· 1127 1192 .title(entry_value.title.clone()) 1128 1193 .path(entry_value.path.clone()) 1129 1194 .authors(authors) 1195 + .permissions(permissions) 1130 1196 .build() 1131 1197 .into_static(); 1132 1198 ··· 1196 1262 )) 1197 1263 } 1198 1264 } 1265 + 1266 + /// Find valid collaborators for a resource. 1267 + /// 1268 + /// Queries Constellation for invite/accept record pairs: 1269 + /// 1. Find all invites targeting this resource URI 1270 + /// 2. For each invite, check if there's a matching accept record 1271 + /// 3. Return DIDs that have both invite AND accept 1272 + fn find_collaborators_for_resource( 1273 + &self, 1274 + resource_uri: &AtUri<'_>, 1275 + ) -> impl Future<Output = Result<Vec<Did<'static>>, WeaverError>> 1276 + where 1277 + Self: Sized, 1278 + { 1279 + async move { 1280 + use weaver_api::sh_weaver::collab::invite::Invite; 1281 + 1282 + const INVITE_NSID: &str = "sh.weaver.collab.invite"; 1283 + const ACCEPT_NSID: &str = "sh.weaver.collab.accept"; 1284 + 1285 + let constellation_url = Url::parse(CONSTELLATION_URL).map_err(|e| { 1286 + AgentError::from(ClientError::invalid_request(format!( 1287 + "Invalid constellation URL: {}", 1288 + e 1289 + ))) 1290 + })?; 1291 + 1292 + // Step 1: Find all invites for this resource 1293 + let invite_query = GetBacklinksQuery { 1294 + subject: Uri::At(resource_uri.clone().into_static()), 1295 + source: format!("{}:resource.uri", INVITE_NSID).into(), 1296 + cursor: None, 1297 + did: vec![], 1298 + limit: 100, 1299 + }; 1300 + 1301 + let response = self 1302 + .xrpc(constellation_url.clone()) 1303 + .send(&invite_query) 1304 + .await 1305 + .map_err(|e| { 1306 + AgentError::from(ClientError::invalid_request(format!( 1307 + "Constellation query failed: {}", 1308 + e 1309 + ))) 1310 + })?; 1311 + 1312 + let invite_output = response.into_output().map_err(|e| { 1313 + AgentError::from(ClientError::invalid_request(format!( 1314 + "Failed to parse constellation response: {}", 1315 + e 1316 + ))) 1317 + })?; 1318 + 1319 + let mut collaborators = Vec::new(); 1320 + 1321 + // Step 2: For each invite, check for a matching accept 1322 + for record_id in invite_output.records { 1323 + let invite_uri_str = format!( 1324 + "at://{}/{}/{}", 1325 + record_id.did, 1326 + INVITE_NSID, 1327 + record_id.rkey.0.as_ref() 1328 + ); 1329 + let Ok(invite_uri) = AtUri::new(&invite_uri_str) else { 1330 + continue; 1331 + }; 1332 + 1333 + // Fetch the invite to get the invitee DID 1334 + let Ok(invite_resp) = self.get_record::<Invite>(&invite_uri).await else { 1335 + continue; 1336 + }; 1337 + let Ok(invite_record) = invite_resp.into_output() else { 1338 + continue; 1339 + }; 1340 + 1341 + let invitee_did = invite_record.value.invitee.clone().into_static(); 1342 + 1343 + // Query for accept records referencing this invite 1344 + let accept_query = GetBacklinksQuery { 1345 + subject: Uri::At(invite_uri.into_static()), 1346 + source: format!("{}:invite.uri", ACCEPT_NSID).into(), 1347 + cursor: None, 1348 + did: vec![invitee_did.clone()], 1349 + limit: 1, 1350 + }; 1351 + 1352 + let Ok(accept_resp) = self 1353 + .xrpc(constellation_url.clone()) 1354 + .send(&accept_query) 1355 + .await 1356 + else { 1357 + continue; 1358 + }; 1359 + 1360 + let Ok(accept_output) = accept_resp.into_output() else { 1361 + continue; 1362 + }; 1363 + 1364 + if !accept_output.records.is_empty() { 1365 + collaborators.push(invitee_did); 1366 + } 1367 + } 1368 + 1369 + Ok(collaborators) 1370 + } 1371 + } 1372 + 1373 + /// Find all versions of a record across collaborator repositories. 1374 + /// 1375 + /// For each collaborator DID, attempts to fetch `at://{did}/{collection}/{rkey}`. 1376 + /// Returns all found versions sorted by `updated_at` descending (latest first). 1377 + fn find_all_versions<'a>( 1378 + &'a self, 1379 + collection: &'a str, 1380 + rkey: &'a str, 1381 + collaborators: &'a [Did<'_>], 1382 + ) -> impl Future<Output = Result<Vec<CollaboratorVersion<'static>>, WeaverError>> + 'a 1383 + where 1384 + Self: Sized, 1385 + { 1386 + async move { 1387 + use jacquard::Data; 1388 + use weaver_api::com_atproto::repo::get_record::GetRecord; 1389 + 1390 + let mut versions = Vec::new(); 1391 + 1392 + for collab_did in collaborators { 1393 + let Ok(pds_url) = self.pds_for_did(collab_did).await else { 1394 + continue; 1395 + }; 1396 + 1397 + let Ok(record_key) = RecordKey::any(rkey) else { 1398 + continue; 1399 + }; 1400 + let request = GetRecord::new() 1401 + .repo(jacquard::types::ident::AtIdentifier::Did( 1402 + collab_did.clone(), 1403 + )) 1404 + .collection(jacquard::types::nsid::Nsid::raw(collection)) 1405 + .rkey(record_key) 1406 + .build(); 1407 + 1408 + let Ok(http_request) = 1409 + xrpc::build_http_request(&pds_url, &request, &self.opts().await) 1410 + else { 1411 + continue; 1412 + }; 1413 + 1414 + let Ok(http_response) = self.send_http(http_request).await else { 1415 + continue; 1416 + }; 1417 + 1418 + let response: Response<GetRecordResponse> = 1419 + match xrpc::process_response(http_response) { 1420 + Ok(r) => r, 1421 + Err(_) => continue, 1422 + }; 1423 + 1424 + let Ok(record) = response.into_output() else { 1425 + continue; 1426 + }; 1427 + 1428 + let Some(cid) = record.cid else { 1429 + continue; 1430 + }; 1431 + 1432 + let updated_at = record 1433 + .value 1434 + .query("...updatedAt") 1435 + .first() 1436 + .or_else(|| record.value.query("...createdAt").first()) 1437 + .and_then(|v: &Data| v.as_str()) 1438 + .and_then(|s| s.parse::<jacquard::types::string::Datetime>().ok()); 1439 + 1440 + versions.push(CollaboratorVersion { 1441 + did: collab_did.clone().into_static(), 1442 + uri: record.uri.into_static(), 1443 + cid: cid.into_static(), 1444 + updated_at, 1445 + value: record.value.into_static(), 1446 + }); 1447 + } 1448 + 1449 + // Sort by updated_at descending (latest first) 1450 + versions.sort_by(|a, b| match (&b.updated_at, &a.updated_at) { 1451 + (Some(b_time), Some(a_time)) => b_time.as_ref().cmp(a_time.as_ref()), 1452 + (Some(_), None) => std::cmp::Ordering::Less, 1453 + (None, Some(_)) => std::cmp::Ordering::Greater, 1454 + (None, None) => std::cmp::Ordering::Equal, 1455 + }); 1456 + 1457 + Ok(versions) 1458 + } 1459 + } 1460 + 1461 + /// Check if a user can edit a resource based on collaboration records. 1462 + /// 1463 + /// Returns true if the user is the resource owner OR has valid invite+accept. 1464 + fn can_user_edit_resource<'a>( 1465 + &'a self, 1466 + resource_uri: &'a AtUri<'_>, 1467 + user_did: &'a Did<'_>, 1468 + ) -> impl Future<Output = Result<bool, WeaverError>> + 'a 1469 + where 1470 + Self: Sized, 1471 + { 1472 + async move { 1473 + // Check if user is the owner 1474 + if let jacquard::types::ident::AtIdentifier::Did(owner_did) = resource_uri.authority() { 1475 + if owner_did == user_did { 1476 + return Ok(true); 1477 + } 1478 + } 1479 + 1480 + // Check for valid collaboration 1481 + let collaborators = self.find_collaborators_for_resource(resource_uri).await?; 1482 + Ok(collaborators.iter().any(|c| c == user_did)) 1483 + } 1484 + } 1485 + 1486 + /// Check if a user can edit an entry, considering notebook-level cascading. 1487 + /// 1488 + /// An entry is editable if user owns it, has entry-level collab, or has notebook-level collab. 1489 + fn can_user_edit_entry<'a>( 1490 + &'a self, 1491 + entry_uri: &'a AtUri<'_>, 1492 + user_did: &'a Did<'_>, 1493 + ) -> impl Future<Output = Result<bool, WeaverError>> + 'a 1494 + where 1495 + Self: Sized, 1496 + { 1497 + async move { 1498 + // Check entry-level access first 1499 + if self.can_user_edit_resource(entry_uri, user_did).await? { 1500 + return Ok(true); 1501 + } 1502 + 1503 + // Check notebook-level access (cascade) 1504 + if let Some(notebook_id) = self.find_notebook_for_entry(entry_uri).await? { 1505 + let notebook_uri_str = format!( 1506 + "at://{}/{}/{}", 1507 + notebook_id.did, 1508 + notebook_id.collection, 1509 + notebook_id.rkey.0.as_ref() 1510 + ); 1511 + if let Ok(notebook_uri) = AtUri::new(&notebook_uri_str) { 1512 + if self.can_user_edit_resource(&notebook_uri, user_did).await? { 1513 + return Ok(true); 1514 + } 1515 + } 1516 + } 1517 + 1518 + Ok(false) 1519 + } 1520 + } 1521 + 1522 + /// Get the full permissions state for a resource. 1523 + /// 1524 + /// Returns PermissionsState with all editors: 1525 + /// - Resource authority (source = resource URI, grantedAt = createdAt) 1526 + /// - Invited collaborators (source = invite URI, grantedAt = accept createdAt) 1527 + /// - For entries: inherited notebook-level collaborators 1528 + fn get_permissions_for_resource( 1529 + &self, 1530 + resource_uri: &AtUri<'_>, 1531 + ) -> impl Future<Output = Result<PermissionsState<'static>, WeaverError>> 1532 + where 1533 + Self: Sized, 1534 + { 1535 + async move { 1536 + use weaver_api::sh_weaver::collab::accept::Accept; 1537 + use weaver_api::sh_weaver::collab::invite::Invite; 1538 + 1539 + const INVITE_NSID: &str = "sh.weaver.collab.invite"; 1540 + const ACCEPT_NSID: &str = "sh.weaver.collab.accept"; 1541 + 1542 + let constellation_url = Url::parse(CONSTELLATION_URL).map_err(|e| { 1543 + AgentError::from(ClientError::invalid_request(format!( 1544 + "Invalid constellation URL: {}", 1545 + e 1546 + ))) 1547 + })?; 1548 + 1549 + let mut editors = Vec::new(); 1550 + 1551 + // 1. Resource authority - creating the resource is its own grant 1552 + let authority_did = match resource_uri.authority() { 1553 + jacquard::types::ident::AtIdentifier::Did(did) => did.clone().into_static(), 1554 + jacquard::types::ident::AtIdentifier::Handle(handle) => { 1555 + let (did, _) = self.pds_for_handle(handle).await.map_err(|e| { 1556 + AgentError::from( 1557 + ClientError::from(e).with_context("Failed to resolve handle"), 1558 + ) 1559 + })?; 1560 + did.into_static() 1561 + } 1562 + }; 1563 + 1564 + // Fetch the record to get createdAt 1565 + let record = self 1566 + .get_record::<weaver_api::sh_weaver::notebook::entry::Entry>(resource_uri) 1567 + .await 1568 + .map_err(|e| WeaverError::from(AgentError::from(e)))? 1569 + .into_output() 1570 + .map_err(|e| { 1571 + WeaverError::from(AgentError::from(ClientError::invalid_request(format!( 1572 + "Failed to parse record: {}", 1573 + e 1574 + )))) 1575 + })?; 1576 + let authority_granted_at = record.value.created_at; 1577 + 1578 + editors.push( 1579 + PermissionGrant::new() 1580 + .did(authority_did.clone()) 1581 + .scope("direct") 1582 + .source(resource_uri.clone().into_static()) 1583 + .granted_at(authority_granted_at) 1584 + .build() 1585 + .into_static(), 1586 + ); 1587 + 1588 + // 2. Find direct invites for this resource 1589 + let invite_query = GetBacklinksQuery { 1590 + subject: Uri::At(resource_uri.clone().into_static()), 1591 + source: format!("{}:resource.uri", INVITE_NSID).into(), 1592 + cursor: None, 1593 + did: vec![], 1594 + limit: 100, 1595 + }; 1596 + 1597 + let invite_response = self 1598 + .xrpc(constellation_url.clone()) 1599 + .send(&invite_query) 1600 + .await 1601 + .map_err(|e| { 1602 + AgentError::from(ClientError::invalid_request(format!( 1603 + "Constellation invite query failed: {}", 1604 + e 1605 + ))) 1606 + })?; 1607 + let invite_output = invite_response.into_output().map_err(|e| { 1608 + AgentError::from(ClientError::invalid_request(format!( 1609 + "Failed to parse Constellation response: {}", 1610 + e 1611 + ))) 1612 + })?; 1613 + 1614 + for record_id in invite_output.records { 1615 + let invite_uri_str = format!( 1616 + "at://{}/{}/{}", 1617 + record_id.did, 1618 + INVITE_NSID, 1619 + record_id.rkey.0.as_ref() 1620 + ); 1621 + let invite_uri = AtUri::new(&invite_uri_str).map_err(|_| { 1622 + AgentError::from(ClientError::invalid_request( 1623 + "Invalid invite URI from Constellation", 1624 + )) 1625 + })?; 1626 + 1627 + // Fetch invite to get invitee DID 1628 + let invite_record = 1629 + self.get_record::<Invite>(&invite_uri) 1630 + .await 1631 + .map_err(|e| WeaverError::from(AgentError::from(e)))? 1632 + .into_output() 1633 + .map_err(|e| { 1634 + WeaverError::from(AgentError::from(ClientError::invalid_request( 1635 + format!("Failed to parse invite record: {}", e), 1636 + ))) 1637 + })?; 1638 + 1639 + let invitee_did = invite_record.value.invitee.clone().into_static(); 1640 + 1641 + // Query for accept records referencing this invite 1642 + let accept_query = GetBacklinksQuery { 1643 + subject: Uri::At(invite_uri.clone().into_static()), 1644 + source: format!("{}:invite.uri", ACCEPT_NSID).into(), 1645 + cursor: None, 1646 + did: vec![invitee_did.clone()], 1647 + limit: 1, 1648 + }; 1649 + 1650 + let accept_response = self 1651 + .xrpc(constellation_url.clone()) 1652 + .send(&accept_query) 1653 + .await 1654 + .map_err(|e| { 1655 + AgentError::from(ClientError::invalid_request(format!( 1656 + "Constellation accept query failed: {}", 1657 + e 1658 + ))) 1659 + })?; 1660 + let accept_output = accept_response.into_output().map_err(|e| { 1661 + AgentError::from(ClientError::invalid_request(format!( 1662 + "Failed to parse Constellation accept response: {}", 1663 + e 1664 + ))) 1665 + })?; 1666 + 1667 + // No accept = pending invite, not an error - just skip 1668 + let Some(accept_record_id) = accept_output.records.first() else { 1669 + continue; 1670 + }; 1671 + 1672 + let accept_uri_str = format!( 1673 + "at://{}/{}/{}", 1674 + accept_record_id.did, 1675 + ACCEPT_NSID, 1676 + accept_record_id.rkey.0.as_ref() 1677 + ); 1678 + let accept_uri = AtUri::new(&accept_uri_str).map_err(|_| { 1679 + AgentError::from(ClientError::invalid_request( 1680 + "Invalid accept URI from Constellation", 1681 + )) 1682 + })?; 1683 + let accept_record = 1684 + self.get_record::<Accept>(&accept_uri) 1685 + .await 1686 + .map_err(|e| WeaverError::from(AgentError::from(e)))? 1687 + .into_output() 1688 + .map_err(|e| { 1689 + WeaverError::from(AgentError::from(ClientError::invalid_request( 1690 + format!("Failed to parse accept record: {}", e), 1691 + ))) 1692 + })?; 1693 + 1694 + editors.push( 1695 + PermissionGrant::new() 1696 + .did(invitee_did) 1697 + .scope("direct") 1698 + .source(invite_uri.into_static()) 1699 + .granted_at(accept_record.value.created_at) 1700 + .build() 1701 + .into_static(), 1702 + ); 1703 + } 1704 + 1705 + // 3. For entries, check notebook-level invites (inherited) 1706 + let is_entry = resource_uri 1707 + .collection() 1708 + .is_some_and(|c| c.as_ref() == "sh.weaver.notebook.entry"); 1709 + 1710 + if is_entry { 1711 + // Entry not in a notebook is fine - just no inherited permissions 1712 + if let Some(notebook_id) = self.find_notebook_for_entry(resource_uri).await? { 1713 + let notebook_uri_str = format!( 1714 + "at://{}/{}/{}", 1715 + notebook_id.did, 1716 + notebook_id.collection, 1717 + notebook_id.rkey.0.as_ref() 1718 + ); 1719 + let notebook_uri = AtUri::new(&notebook_uri_str).map_err(|_| { 1720 + AgentError::from(ClientError::invalid_request( 1721 + "Invalid notebook URI from Constellation", 1722 + )) 1723 + })?; 1724 + 1725 + let notebook_invite_query = GetBacklinksQuery { 1726 + subject: Uri::At(notebook_uri.clone().into_static()), 1727 + source: format!("{}:resource.uri", INVITE_NSID).into(), 1728 + cursor: None, 1729 + did: vec![], 1730 + limit: 100, 1731 + }; 1732 + 1733 + let notebook_invite_response = self 1734 + .xrpc(constellation_url.clone()) 1735 + .send(&notebook_invite_query) 1736 + .await 1737 + .map_err(|e| { 1738 + AgentError::from(ClientError::invalid_request(format!( 1739 + "Constellation notebook invite query failed: {}", 1740 + e 1741 + ))) 1742 + })?; 1743 + let notebook_invite_output = 1744 + notebook_invite_response.into_output().map_err(|e| { 1745 + AgentError::from(ClientError::invalid_request(format!( 1746 + "Failed to parse Constellation response: {}", 1747 + e 1748 + ))) 1749 + })?; 1750 + 1751 + for record_id in notebook_invite_output.records { 1752 + let invite_uri_str = format!( 1753 + "at://{}/{}/{}", 1754 + record_id.did, 1755 + INVITE_NSID, 1756 + record_id.rkey.0.as_ref() 1757 + ); 1758 + let invite_uri = AtUri::new(&invite_uri_str).map_err(|_| { 1759 + AgentError::from(ClientError::invalid_request( 1760 + "Invalid invite URI from Constellation", 1761 + )) 1762 + })?; 1763 + 1764 + let invite_record = self 1765 + .get_record::<Invite>(&invite_uri) 1766 + .await 1767 + .map_err(|e| WeaverError::from(AgentError::from(e)))? 1768 + .into_output() 1769 + .map_err(|e| { 1770 + WeaverError::from(AgentError::from(ClientError::invalid_request( 1771 + format!("Failed to parse invite record: {}", e), 1772 + ))) 1773 + })?; 1774 + 1775 + let invitee_did = invite_record.value.invitee.clone().into_static(); 1776 + 1777 + // Skip if already in direct grants (direct takes precedence) 1778 + if editors.iter().any(|g| g.did == invitee_did) { 1779 + continue; 1780 + } 1781 + 1782 + let accept_query = GetBacklinksQuery { 1783 + subject: Uri::At(invite_uri.clone().into_static()), 1784 + source: format!("{}:.invite.uri", ACCEPT_NSID).into(), 1785 + cursor: None, 1786 + did: vec![invitee_did.clone()], 1787 + limit: 1, 1788 + }; 1789 + 1790 + let accept_response = self 1791 + .xrpc(constellation_url.clone()) 1792 + .send(&accept_query) 1793 + .await 1794 + .map_err(|e| { 1795 + AgentError::from(ClientError::invalid_request(format!( 1796 + "Constellation accept query failed: {}", 1797 + e 1798 + ))) 1799 + })?; 1800 + let accept_output = accept_response.into_output().map_err(|e| { 1801 + AgentError::from(ClientError::invalid_request(format!( 1802 + "Failed to parse Constellation accept response: {}", 1803 + e 1804 + ))) 1805 + })?; 1806 + 1807 + // No accept = pending invite, not an error - just skip 1808 + let Some(accept_record_id) = accept_output.records.first() else { 1809 + continue; 1810 + }; 1811 + 1812 + let accept_uri_str = format!( 1813 + "at://{}/{}/{}", 1814 + accept_record_id.did, 1815 + ACCEPT_NSID, 1816 + accept_record_id.rkey.0.as_ref() 1817 + ); 1818 + let accept_uri = AtUri::new(&accept_uri_str).map_err(|_| { 1819 + AgentError::from(ClientError::invalid_request( 1820 + "Invalid accept URI from Constellation", 1821 + )) 1822 + })?; 1823 + let accept_record = self 1824 + .get_record::<Accept>(&accept_uri) 1825 + .await 1826 + .map_err(|e| WeaverError::from(AgentError::from(e)))? 1827 + .into_output() 1828 + .map_err(|e| { 1829 + WeaverError::from(AgentError::from(ClientError::invalid_request( 1830 + format!("Failed to parse accept record: {}", e), 1831 + ))) 1832 + })?; 1833 + 1834 + editors.push( 1835 + PermissionGrant::new() 1836 + .did(invitee_did) 1837 + .scope("inherited") 1838 + .source(invite_uri.into_static()) 1839 + .granted_at(accept_record.value.created_at) 1840 + .build() 1841 + .into_static(), 1842 + ); 1843 + } 1844 + } 1845 + } 1846 + 1847 + Ok(PermissionsState::new() 1848 + .editors(editors) 1849 + .build() 1850 + .into_static()) 1851 + } 1852 + } 1853 + 1854 + /// Find contributors (authors) for a resource based on evidence. 1855 + /// 1856 + /// Contributors are DIDs who have actually contributed to this resource: 1857 + /// 1. Edit records (edit.root or edit.diff) referencing this resource 1858 + /// 2. Published versions of the record in their repo (same rkey) 1859 + /// 1860 + /// This is separate from permissions - you can have edit permission without 1861 + /// having contributed yet. 1862 + fn find_contributors_for_resource( 1863 + &self, 1864 + resource_uri: &AtUri<'_>, 1865 + ) -> impl Future<Output = Result<Vec<Did<'static>>, WeaverError>> 1866 + where 1867 + Self: Sized, 1868 + { 1869 + async move { 1870 + const EDIT_ROOT_NSID: &str = "sh.weaver.edit.root"; 1871 + 1872 + let constellation_url = Url::parse(CONSTELLATION_URL).map_err(|e| { 1873 + AgentError::from(ClientError::invalid_request(format!( 1874 + "Invalid constellation URL: {}", 1875 + e 1876 + ))) 1877 + })?; 1878 + 1879 + let mut contributors = std::collections::HashSet::new(); 1880 + 1881 + // 1. Resource authority is always a contributor 1882 + let authority_did = match resource_uri.authority() { 1883 + jacquard::types::ident::AtIdentifier::Did(did) => did.clone().into_static(), 1884 + jacquard::types::ident::AtIdentifier::Handle(handle) => { 1885 + let (did, _) = self.pds_for_handle(handle).await.map_err(|e| { 1886 + AgentError::from( 1887 + ClientError::from(e).with_context("Failed to resolve handle"), 1888 + ) 1889 + })?; 1890 + did.into_static() 1891 + } 1892 + }; 1893 + contributors.insert(authority_did); 1894 + 1895 + // 2. Find DIDs with edit records for this resource 1896 + let edit_query = GetBacklinksQuery { 1897 + subject: Uri::At(resource_uri.clone().into_static()), 1898 + source: format!("{}:doc.value.entry.uri", EDIT_ROOT_NSID).into(), 1899 + cursor: None, 1900 + did: vec![], 1901 + limit: 100, 1902 + }; 1903 + 1904 + if let Ok(response) = self.xrpc(constellation_url.clone()).send(&edit_query).await { 1905 + if let Ok(edit_output) = response.into_output() { 1906 + for record_id in edit_output.records { 1907 + contributors.insert(record_id.did.into_static()); 1908 + } 1909 + } 1910 + } 1911 + 1912 + // 3. Find collaborators who have published versions (same rkey) 1913 + let collaborators = self.find_collaborators_for_resource(resource_uri).await?; 1914 + let rkey = resource_uri.rkey(); 1915 + let collection = resource_uri.collection(); 1916 + 1917 + if let (Some(rkey), Some(collection)) = (rkey, collection) { 1918 + for collab_did in collaborators { 1919 + // Try to fetch their version of the record 1920 + let collab_uri_str = format!( 1921 + "at://{}/{}/{}", 1922 + collab_did.as_ref(), 1923 + collection, 1924 + rkey.as_ref() 1925 + ); 1926 + if let Ok(collab_uri) = AtUri::new(&collab_uri_str) { 1927 + // Check if record actually exists (200 = found, 400 = not found) 1928 + if let Ok(response) = self 1929 + .get_record::<weaver_api::sh_weaver::notebook::entry::Entry>( 1930 + &collab_uri, 1931 + ) 1932 + .await 1933 + { 1934 + if response.status().is_success() { 1935 + contributors.insert(collab_did); 1936 + } 1937 + } 1938 + } 1939 + } 1940 + } 1941 + 1942 + Ok(contributors.into_iter().collect()) 1943 + } 1944 + } 1945 + } 1946 + 1947 + /// A version of a record from a collaborator's repository. 1948 + #[derive(Debug, Clone)] 1949 + pub struct CollaboratorVersion<'a> { 1950 + /// The DID of the collaborator who owns this version. 1951 + pub did: Did<'a>, 1952 + /// The full URI of this version. 1953 + pub uri: AtUri<'a>, 1954 + /// CID of this version. 1955 + pub cid: jacquard::types::string::Cid<'a>, 1956 + /// When this version was last updated. 1957 + pub updated_at: Option<jacquard::types::string::Datetime>, 1958 + /// The raw record value. 1959 + pub value: jacquard::Data<'a>, 1199 1960 } 1200 1961 1201 1962 impl<T: AgentSession + IdentityResolver + XrpcExt> WeaverExt for T {}
+21
lexicons/edit/draft.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.weaver.edit.draft", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "Stub record for unpublished drafts. Acts as an anchor for edit.root/diff records and enables draft discovery via listRecords.", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["createdAt"], 12 + "properties": { 13 + "createdAt": { 14 + "type": "string", 15 + "format": "datetime" 16 + } 17 + } 18 + } 19 + } 20 + } 21 + }
+48
lexicons/notebook/defs.json
··· 16 16 "type": "array", 17 17 "items": { "type": "ref", "ref": "#authorListView" } 18 18 }, 19 + "permissions": { 20 + "type": "ref", 21 + "ref": "#permissionsState" 22 + }, 19 23 "record": { "type": "unknown" }, 20 24 "indexedAt": { "type": "string", "format": "datetime" } 21 25 } ··· 33 37 "authors": { 34 38 "type": "array", 35 39 "items": { "type": "ref", "ref": "#authorListView" } 40 + }, 41 + "permissions": { 42 + "type": "ref", 43 + "ref": "#permissionsState" 36 44 }, 37 45 "record": { "type": "unknown" }, 38 46 "renderedView": { ··· 132 140 "description": "The format of the content. This is used to determine how to render the content.", 133 141 "enum": ["commonmark", "gfm", "obsidian", "weaver"], 134 142 "default": "weaver" 143 + } 144 + } 145 + }, 146 + "permissionsState": { 147 + "type": "object", 148 + "description": "ACL-style permissions for a resource. Separate from authors (who contributed).", 149 + "required": ["editors"], 150 + "properties": { 151 + "editors": { 152 + "type": "array", 153 + "description": "DIDs that can edit this resource", 154 + "items": { "type": "ref", "ref": "#permissionGrant" } 155 + }, 156 + "viewers": { 157 + "type": "array", 158 + "description": "DIDs that can view (future use)", 159 + "items": { "type": "ref", "ref": "#permissionGrant" } 160 + } 161 + } 162 + }, 163 + "permissionGrant": { 164 + "type": "object", 165 + "description": "A single permission grant. For resource authority: source=resource URI, grantedAt=createdAt. For invitees: source=invite URI, grantedAt=accept createdAt.", 166 + "required": ["did", "scope", "source", "grantedAt"], 167 + "properties": { 168 + "did": { "type": "string", "format": "did" }, 169 + "scope": { 170 + "type": "string", 171 + "knownValues": ["direct", "inherited"], 172 + "description": "direct = this resource (includes authority), inherited = via notebook invite" 173 + }, 174 + "source": { 175 + "type": "string", 176 + "format": "at-uri", 177 + "description": "For authority: resource URI. For invitees: invite URI" 178 + }, 179 + "grantedAt": { 180 + "type": "string", 181 + "format": "datetime", 182 + "description": "For authority: record createdAt. For invitees: accept createdAt" 135 183 } 136 184 } 137 185 }