diff --git a/crates/weaver-api/lexicons/sh_weaver_edit_draft.json b/crates/weaver-api/lexicons/sh_weaver_edit_draft.json new file mode 100644 index 0000000..c363bc1 --- /dev/null +++ b/crates/weaver-api/lexicons/sh_weaver_edit_draft.json @@ -0,0 +1,23 @@ +{ + "lexicon": 1, + "id": "sh.weaver.edit.draft", + "defs": { + "main": { + "type": "record", + "description": "Stub record for unpublished drafts. Acts as an anchor for edit.root/diff records and enables draft discovery via listRecords.", + "key": "tid", + "record": { + "type": "object", + "required": [ + "createdAt" + ], + "properties": { + "createdAt": { + "type": "string", + "format": "datetime" + } + } + } + } + } +} \ No newline at end of file diff --git a/crates/weaver-api/lexicons/sh_weaver_notebook_defs.json b/crates/weaver-api/lexicons/sh_weaver_notebook_defs.json index 0caf785..6a76272 100644 --- a/crates/weaver-api/lexicons/sh_weaver_notebook_defs.json +++ b/crates/weaver-api/lexicons/sh_weaver_notebook_defs.json @@ -105,6 +105,10 @@ "type": "ref", "ref": "#path" }, + "permissions": { + "type": "ref", + "ref": "#permissionsState" + }, "record": { "type": "unknown" }, @@ -155,6 +159,10 @@ "type": "ref", "ref": "#path" }, + "permissions": { + "type": "ref", + "ref": "#permissionsState" + }, "record": { "type": "unknown" }, @@ -177,6 +185,65 @@ "description": "The path of the notebook.", "maxLength": 100 }, + "permissionGrant": { + "type": "object", + "description": "A single permission grant. For resource authority: source=resource URI, grantedAt=createdAt. For invitees: source=invite URI, grantedAt=accept createdAt.", + "required": [ + "did", + "scope", + "source", + "grantedAt" + ], + "properties": { + "did": { + "type": "string", + "format": "did" + }, + "grantedAt": { + "type": "string", + "description": "For authority: record createdAt. For invitees: accept createdAt", + "format": "datetime" + }, + "scope": { + "type": "string", + "description": "direct = this resource (includes authority), inherited = via notebook invite", + "knownValues": [ + "direct", + "inherited" + ] + }, + "source": { + "type": "string", + "description": "For authority: resource URI. For invitees: invite URI", + "format": "at-uri" + } + } + }, + "permissionsState": { + "type": "object", + "description": "ACL-style permissions for a resource. Separate from authors (who contributed).", + "required": [ + "editors" + ], + "properties": { + "editors": { + "type": "array", + "description": "DIDs that can edit this resource", + "items": { + "type": "ref", + "ref": "#permissionGrant" + } + }, + "viewers": { + "type": "array", + "description": "DIDs that can view (future use)", + "items": { + "type": "ref", + "ref": "#permissionGrant" + } + } + } + }, "renderedView": { "type": "object", "description": "View of a rendered and cached notebook entry", diff --git a/crates/weaver-api/src/sh_weaver/edit.rs b/crates/weaver-api/src/sh_weaver/edit.rs index 518cf9a..9f6ddc9 100644 --- a/crates/weaver-api/src/sh_weaver/edit.rs +++ b/crates/weaver-api/src/sh_weaver/edit.rs @@ -7,6 +7,7 @@ pub mod cursor; pub mod diff; +pub mod draft; pub mod root; #[jacquard_derive::lexicon] diff --git a/crates/weaver-api/src/sh_weaver/edit/draft.rs b/crates/weaver-api/src/sh_weaver/edit/draft.rs new file mode 100644 index 0000000..4435685 --- /dev/null +++ b/crates/weaver-api/src/sh_weaver/edit/draft.rs @@ -0,0 +1,264 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// Lexicon: sh.weaver.edit.draft +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +/// Stub record for unpublished drafts. Acts as an anchor for edit.root/diff records and enables draft discovery via listRecords. +#[jacquard_derive::lexicon] +#[derive( + serde::Serialize, + serde::Deserialize, + Debug, + Clone, + PartialEq, + Eq, + jacquard_derive::IntoStatic +)] +#[serde(rename_all = "camelCase")] +pub struct Draft<'a> { + pub created_at: jacquard_common::types::string::Datetime, +} + +pub mod draft_state { + + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed { + type CreatedAt; + } + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty { + type CreatedAt = Unset; + } + ///State transition - sets the `created_at` field to Set + pub struct SetCreatedAt(PhantomData S>); + impl sealed::Sealed for SetCreatedAt {} + impl State for SetCreatedAt { + type CreatedAt = Set; + } + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members { + ///Marker type for the `created_at` field + pub struct created_at(()); + } +} + +/// Builder for constructing an instance of this type +pub struct DraftBuilder<'a, S: draft_state::State> { + _phantom_state: ::core::marker::PhantomData S>, + __unsafe_private_named: ( + ::core::option::Option, + ), + _phantom: ::core::marker::PhantomData<&'a ()>, +} + +impl<'a> Draft<'a> { + /// Create a new builder for this type + pub fn new() -> DraftBuilder<'a, draft_state::Empty> { + DraftBuilder::new() + } +} + +impl<'a> DraftBuilder<'a, draft_state::Empty> { + /// Create a new builder with all fields unset + pub fn new() -> Self { + DraftBuilder { + _phantom_state: ::core::marker::PhantomData, + __unsafe_private_named: (None,), + _phantom: ::core::marker::PhantomData, + } + } +} + +impl<'a, S> DraftBuilder<'a, S> +where + S: draft_state::State, + S::CreatedAt: draft_state::IsUnset, +{ + /// Set the `createdAt` field (required) + pub fn created_at( + mut self, + value: impl Into, + ) -> DraftBuilder<'a, draft_state::SetCreatedAt> { + self.__unsafe_private_named.0 = ::core::option::Option::Some(value.into()); + DraftBuilder { + _phantom_state: ::core::marker::PhantomData, + __unsafe_private_named: self.__unsafe_private_named, + _phantom: ::core::marker::PhantomData, + } + } +} + +impl<'a, S> DraftBuilder<'a, S> +where + S: draft_state::State, + S::CreatedAt: draft_state::IsSet, +{ + /// Build the final struct + pub fn build(self) -> Draft<'a> { + Draft { + created_at: self.__unsafe_private_named.0.unwrap(), + extra_data: Default::default(), + } + } + /// Build the final struct with custom extra_data + pub fn build_with_data( + self, + extra_data: std::collections::BTreeMap< + jacquard_common::smol_str::SmolStr, + jacquard_common::types::value::Data<'a>, + >, + ) -> Draft<'a> { + Draft { + created_at: self.__unsafe_private_named.0.unwrap(), + extra_data: Some(extra_data), + } + } +} + +impl<'a> Draft<'a> { + pub fn uri( + uri: impl Into>, + ) -> Result< + jacquard_common::types::uri::RecordUri<'a, DraftRecord>, + jacquard_common::types::uri::UriError, + > { + jacquard_common::types::uri::RecordUri::try_from_uri( + jacquard_common::types::string::AtUri::new_cow(uri.into())?, + ) + } +} + +/// Typed wrapper for GetRecord response with this collection's record type. +#[derive( + serde::Serialize, + serde::Deserialize, + Debug, + Clone, + PartialEq, + Eq, + jacquard_derive::IntoStatic +)] +#[serde(rename_all = "camelCase")] +pub struct DraftGetRecordOutput<'a> { + #[serde(skip_serializing_if = "std::option::Option::is_none")] + #[serde(borrow)] + pub cid: std::option::Option>, + #[serde(borrow)] + pub uri: jacquard_common::types::string::AtUri<'a>, + #[serde(borrow)] + pub value: Draft<'a>, +} + +impl From> for Draft<'_> { + fn from(output: DraftGetRecordOutput<'_>) -> Self { + use jacquard_common::IntoStatic; + output.value.into_static() + } +} + +impl jacquard_common::types::collection::Collection for Draft<'_> { + const NSID: &'static str = "sh.weaver.edit.draft"; + type Record = DraftRecord; +} + +/// Marker type for deserializing records from this collection. +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct DraftRecord; +impl jacquard_common::xrpc::XrpcResp for DraftRecord { + const NSID: &'static str = "sh.weaver.edit.draft"; + const ENCODING: &'static str = "application/json"; + type Output<'de> = DraftGetRecordOutput<'de>; + type Err<'de> = jacquard_common::types::collection::RecordError<'de>; +} + +impl jacquard_common::types::collection::Collection for DraftRecord { + const NSID: &'static str = "sh.weaver.edit.draft"; + type Record = DraftRecord; +} + +impl<'a> ::jacquard_lexicon::schema::LexiconSchema for Draft<'a> { + fn nsid() -> &'static str { + "sh.weaver.edit.draft" + } + fn def_name() -> &'static str { + "main" + } + fn lexicon_doc() -> ::jacquard_lexicon::lexicon::LexiconDoc<'static> { + lexicon_doc_sh_weaver_edit_draft() + } + fn validate( + &self, + ) -> ::std::result::Result<(), ::jacquard_lexicon::validation::ConstraintError> { + Ok(()) + } +} + +fn lexicon_doc_sh_weaver_edit_draft() -> ::jacquard_lexicon::lexicon::LexiconDoc< + 'static, +> { + ::jacquard_lexicon::lexicon::LexiconDoc { + lexicon: ::jacquard_lexicon::lexicon::Lexicon::Lexicon1, + id: ::jacquard_common::CowStr::new_static("sh.weaver.edit.draft"), + revision: None, + description: None, + defs: { + let mut map = ::std::collections::BTreeMap::new(); + map.insert( + ::jacquard_common::smol_str::SmolStr::new_static("main"), + ::jacquard_lexicon::lexicon::LexUserType::Record(::jacquard_lexicon::lexicon::LexRecord { + description: Some( + ::jacquard_common::CowStr::new_static( + "Stub record for unpublished drafts. Acts as an anchor for edit.root/diff records and enables draft discovery via listRecords.", + ), + ), + key: Some(::jacquard_common::CowStr::new_static("tid")), + record: ::jacquard_lexicon::lexicon::LexRecordRecord::Object(::jacquard_lexicon::lexicon::LexObject { + description: None, + required: Some( + vec![ + ::jacquard_common::smol_str::SmolStr::new_static("createdAt") + ], + ), + nullable: None, + properties: { + #[allow(unused_mut)] + let mut map = ::std::collections::BTreeMap::new(); + map.insert( + ::jacquard_common::smol_str::SmolStr::new_static( + "createdAt", + ), + ::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString { + description: None, + format: Some( + ::jacquard_lexicon::lexicon::LexStringFormat::Datetime, + ), + default: None, + min_length: None, + max_length: None, + min_graphemes: None, + max_graphemes: None, + r#enum: None, + r#const: None, + known_values: None, + }), + ); + map + }, + }), + }), + ); + map + }, + } +} \ No newline at end of file diff --git a/crates/weaver-api/src/sh_weaver/notebook.rs b/crates/weaver-api/src/sh_weaver/notebook.rs index 113b90b..37c5345 100644 --- a/crates/weaver-api/src/sh_weaver/notebook.rs +++ b/crates/weaver-api/src/sh_weaver/notebook.rs @@ -454,6 +454,17 @@ fn lexicon_doc_sh_weaver_notebook_defs() -> ::jacquard_lexicon::lexicon::Lexicon r#ref: ::jacquard_common::CowStr::new_static("#path"), }), ); + map.insert( + ::jacquard_common::smol_str::SmolStr::new_static( + "permissions", + ), + ::jacquard_lexicon::lexicon::LexObjectProperty::Ref(::jacquard_lexicon::lexicon::LexRef { + description: None, + r#ref: ::jacquard_common::CowStr::new_static( + "#permissionsState", + ), + }), + ); map.insert( ::jacquard_common::smol_str::SmolStr::new_static("record"), ::jacquard_lexicon::lexicon::LexObjectProperty::Unknown(::jacquard_lexicon::lexicon::LexUnknown { @@ -580,6 +591,17 @@ fn lexicon_doc_sh_weaver_notebook_defs() -> ::jacquard_lexicon::lexicon::Lexicon r#ref: ::jacquard_common::CowStr::new_static("#path"), }), ); + map.insert( + ::jacquard_common::smol_str::SmolStr::new_static( + "permissions", + ), + ::jacquard_lexicon::lexicon::LexObjectProperty::Ref(::jacquard_lexicon::lexicon::LexRef { + description: None, + r#ref: ::jacquard_common::CowStr::new_static( + "#permissionsState", + ), + }), + ); map.insert( ::jacquard_common::smol_str::SmolStr::new_static("record"), ::jacquard_lexicon::lexicon::LexObjectProperty::Unknown(::jacquard_lexicon::lexicon::LexUnknown { @@ -640,6 +662,165 @@ fn lexicon_doc_sh_weaver_notebook_defs() -> ::jacquard_lexicon::lexicon::Lexicon known_values: None, }), ); + map.insert( + ::jacquard_common::smol_str::SmolStr::new_static("permissionGrant"), + ::jacquard_lexicon::lexicon::LexUserType::Object(::jacquard_lexicon::lexicon::LexObject { + description: Some( + ::jacquard_common::CowStr::new_static( + "A single permission grant. For resource authority: source=resource URI, grantedAt=createdAt. For invitees: source=invite URI, grantedAt=accept createdAt.", + ), + ), + required: Some( + vec![ + ::jacquard_common::smol_str::SmolStr::new_static("did"), + ::jacquard_common::smol_str::SmolStr::new_static("scope"), + ::jacquard_common::smol_str::SmolStr::new_static("source"), + ::jacquard_common::smol_str::SmolStr::new_static("grantedAt") + ], + ), + nullable: None, + properties: { + #[allow(unused_mut)] + let mut map = ::std::collections::BTreeMap::new(); + map.insert( + ::jacquard_common::smol_str::SmolStr::new_static("did"), + ::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString { + description: None, + format: Some( + ::jacquard_lexicon::lexicon::LexStringFormat::Did, + ), + default: None, + min_length: None, + max_length: None, + min_graphemes: None, + max_graphemes: None, + r#enum: None, + r#const: None, + known_values: None, + }), + ); + map.insert( + ::jacquard_common::smol_str::SmolStr::new_static( + "grantedAt", + ), + ::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString { + description: Some( + ::jacquard_common::CowStr::new_static( + "For authority: record createdAt. For invitees: accept createdAt", + ), + ), + format: Some( + ::jacquard_lexicon::lexicon::LexStringFormat::Datetime, + ), + default: None, + min_length: None, + max_length: None, + min_graphemes: None, + max_graphemes: None, + r#enum: None, + r#const: None, + known_values: None, + }), + ); + map.insert( + ::jacquard_common::smol_str::SmolStr::new_static("scope"), + ::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString { + description: Some( + ::jacquard_common::CowStr::new_static( + "direct = this resource (includes authority), inherited = via notebook invite", + ), + ), + format: None, + default: None, + min_length: None, + max_length: None, + min_graphemes: None, + max_graphemes: None, + r#enum: None, + r#const: None, + known_values: None, + }), + ); + map.insert( + ::jacquard_common::smol_str::SmolStr::new_static("source"), + ::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString { + description: Some( + ::jacquard_common::CowStr::new_static( + "For authority: resource URI. For invitees: invite URI", + ), + ), + format: Some( + ::jacquard_lexicon::lexicon::LexStringFormat::AtUri, + ), + default: None, + min_length: None, + max_length: None, + min_graphemes: None, + max_graphemes: None, + r#enum: None, + r#const: None, + known_values: None, + }), + ); + map + }, + }), + ); + map.insert( + ::jacquard_common::smol_str::SmolStr::new_static("permissionsState"), + ::jacquard_lexicon::lexicon::LexUserType::Object(::jacquard_lexicon::lexicon::LexObject { + description: Some( + ::jacquard_common::CowStr::new_static( + "ACL-style permissions for a resource. Separate from authors (who contributed).", + ), + ), + required: Some( + vec![::jacquard_common::smol_str::SmolStr::new_static("editors")], + ), + nullable: None, + properties: { + #[allow(unused_mut)] + let mut map = ::std::collections::BTreeMap::new(); + map.insert( + ::jacquard_common::smol_str::SmolStr::new_static("editors"), + ::jacquard_lexicon::lexicon::LexObjectProperty::Array(::jacquard_lexicon::lexicon::LexArray { + description: Some( + ::jacquard_common::CowStr::new_static( + "DIDs that can edit this resource", + ), + ), + items: ::jacquard_lexicon::lexicon::LexArrayItem::Ref(::jacquard_lexicon::lexicon::LexRef { + description: None, + r#ref: ::jacquard_common::CowStr::new_static( + "#permissionGrant", + ), + }), + min_length: None, + max_length: None, + }), + ); + map.insert( + ::jacquard_common::smol_str::SmolStr::new_static("viewers"), + ::jacquard_lexicon::lexicon::LexObjectProperty::Array(::jacquard_lexicon::lexicon::LexArray { + description: Some( + ::jacquard_common::CowStr::new_static( + "DIDs that can view (future use)", + ), + ), + items: ::jacquard_lexicon::lexicon::LexArrayItem::Ref(::jacquard_lexicon::lexicon::LexRef { + description: None, + r#ref: ::jacquard_common::CowStr::new_static( + "#permissionGrant", + ), + }), + min_length: None, + max_length: None, + }), + ); + map + }, + }), + ); map.insert( ::jacquard_common::smol_str::SmolStr::new_static("renderedView"), ::jacquard_lexicon::lexicon::LexUserType::Object(::jacquard_lexicon::lexicon::LexObject { @@ -1156,6 +1337,11 @@ pub struct EntryView<'a> { #[serde(skip_serializing_if = "std::option::Option::is_none")] #[serde(borrow)] pub path: std::option::Option>, + #[serde(skip_serializing_if = "std::option::Option::is_none")] + #[serde(borrow)] + pub permissions: std::option::Option< + crate::sh_weaver::notebook::PermissionsState<'a>, + >, #[serde(borrow)] pub record: jacquard_common::types::value::Data<'a>, #[serde(skip_serializing_if = "std::option::Option::is_none")] @@ -1271,6 +1457,7 @@ pub struct EntryViewBuilder<'a, S: entry_view_state::State> { ::core::option::Option>, ::core::option::Option, ::core::option::Option>, + ::core::option::Option>, ::core::option::Option>, ::core::option::Option>, ::core::option::Option>, @@ -1302,6 +1489,7 @@ impl<'a> EntryViewBuilder<'a, entry_view_state::Empty> { None, None, None, + None, ), _phantom: ::core::marker::PhantomData, } @@ -1384,6 +1572,25 @@ impl<'a, S: entry_view_state::State> EntryViewBuilder<'a, S> { } } +impl<'a, S: entry_view_state::State> EntryViewBuilder<'a, S> { + /// Set the `permissions` field (optional) + pub fn permissions( + mut self, + value: impl Into>>, + ) -> Self { + self.__unsafe_private_named.4 = value.into(); + self + } + /// Set the `permissions` field to an Option value (optional) + pub fn maybe_permissions( + mut self, + value: Option>, + ) -> Self { + self.__unsafe_private_named.4 = value; + self + } +} + impl<'a, S> EntryViewBuilder<'a, S> where S: entry_view_state::State, @@ -1394,7 +1601,7 @@ where mut self, value: impl Into>, ) -> EntryViewBuilder<'a, entry_view_state::SetRecord> { - self.__unsafe_private_named.4 = ::core::option::Option::Some(value.into()); + self.__unsafe_private_named.5 = ::core::option::Option::Some(value.into()); EntryViewBuilder { _phantom_state: ::core::marker::PhantomData, __unsafe_private_named: self.__unsafe_private_named, @@ -1409,7 +1616,7 @@ impl<'a, S: entry_view_state::State> EntryViewBuilder<'a, S> { mut self, value: impl Into>>, ) -> Self { - self.__unsafe_private_named.5 = value.into(); + self.__unsafe_private_named.6 = value.into(); self } /// Set the `renderedView` field to an Option value (optional) @@ -1417,7 +1624,7 @@ impl<'a, S: entry_view_state::State> EntryViewBuilder<'a, S> { mut self, value: Option>, ) -> Self { - self.__unsafe_private_named.5 = value; + self.__unsafe_private_named.6 = value; self } } @@ -1428,7 +1635,7 @@ impl<'a, S: entry_view_state::State> EntryViewBuilder<'a, S> { mut self, value: impl Into>>, ) -> Self { - self.__unsafe_private_named.6 = value.into(); + self.__unsafe_private_named.7 = value.into(); self } /// Set the `tags` field to an Option value (optional) @@ -1436,7 +1643,7 @@ impl<'a, S: entry_view_state::State> EntryViewBuilder<'a, S> { mut self, value: Option>, ) -> Self { - self.__unsafe_private_named.6 = value; + self.__unsafe_private_named.7 = value; self } } @@ -1447,7 +1654,7 @@ impl<'a, S: entry_view_state::State> EntryViewBuilder<'a, S> { mut self, value: impl Into>>, ) -> Self { - self.__unsafe_private_named.7 = value.into(); + self.__unsafe_private_named.8 = value.into(); self } /// Set the `title` field to an Option value (optional) @@ -1455,7 +1662,7 @@ impl<'a, S: entry_view_state::State> EntryViewBuilder<'a, S> { mut self, value: Option>, ) -> Self { - self.__unsafe_private_named.7 = value; + self.__unsafe_private_named.8 = value; self } } @@ -1470,7 +1677,7 @@ where mut self, value: impl Into>, ) -> EntryViewBuilder<'a, entry_view_state::SetUri> { - self.__unsafe_private_named.8 = ::core::option::Option::Some(value.into()); + self.__unsafe_private_named.9 = ::core::option::Option::Some(value.into()); EntryViewBuilder { _phantom_state: ::core::marker::PhantomData, __unsafe_private_named: self.__unsafe_private_named, @@ -1495,11 +1702,12 @@ where cid: self.__unsafe_private_named.1.unwrap(), indexed_at: self.__unsafe_private_named.2.unwrap(), path: self.__unsafe_private_named.3, - record: self.__unsafe_private_named.4.unwrap(), - rendered_view: self.__unsafe_private_named.5, - tags: self.__unsafe_private_named.6, - title: self.__unsafe_private_named.7, - uri: self.__unsafe_private_named.8.unwrap(), + permissions: self.__unsafe_private_named.4, + record: self.__unsafe_private_named.5.unwrap(), + rendered_view: self.__unsafe_private_named.6, + tags: self.__unsafe_private_named.7, + title: self.__unsafe_private_named.8, + uri: self.__unsafe_private_named.9.unwrap(), extra_data: Default::default(), } } @@ -1516,11 +1724,12 @@ where cid: self.__unsafe_private_named.1.unwrap(), indexed_at: self.__unsafe_private_named.2.unwrap(), path: self.__unsafe_private_named.3, - record: self.__unsafe_private_named.4.unwrap(), - rendered_view: self.__unsafe_private_named.5, - tags: self.__unsafe_private_named.6, - title: self.__unsafe_private_named.7, - uri: self.__unsafe_private_named.8.unwrap(), + permissions: self.__unsafe_private_named.4, + record: self.__unsafe_private_named.5.unwrap(), + rendered_view: self.__unsafe_private_named.6, + tags: self.__unsafe_private_named.7, + title: self.__unsafe_private_named.8, + uri: self.__unsafe_private_named.9.unwrap(), extra_data: Some(extra_data), } } @@ -1563,6 +1772,11 @@ pub struct NotebookView<'a> { #[serde(skip_serializing_if = "std::option::Option::is_none")] #[serde(borrow)] pub path: std::option::Option>, + #[serde(skip_serializing_if = "std::option::Option::is_none")] + #[serde(borrow)] + pub permissions: std::option::Option< + crate::sh_weaver::notebook::PermissionsState<'a>, + >, #[serde(borrow)] pub record: jacquard_common::types::value::Data<'a>, #[serde(skip_serializing_if = "std::option::Option::is_none")] @@ -1675,6 +1889,7 @@ pub struct NotebookViewBuilder<'a, S: notebook_view_state::State> { ::core::option::Option>, ::core::option::Option, ::core::option::Option>, + ::core::option::Option>, ::core::option::Option>, ::core::option::Option>, ::core::option::Option>, @@ -1695,7 +1910,17 @@ impl<'a> NotebookViewBuilder<'a, notebook_view_state::Empty> { pub fn new() -> Self { NotebookViewBuilder { _phantom_state: ::core::marker::PhantomData, - __unsafe_private_named: (None, None, None, None, None, None, None, None), + __unsafe_private_named: ( + None, + None, + None, + None, + None, + None, + None, + None, + None, + ), _phantom: ::core::marker::PhantomData, } } @@ -1777,6 +2002,25 @@ impl<'a, S: notebook_view_state::State> NotebookViewBuilder<'a, S> { } } +impl<'a, S: notebook_view_state::State> NotebookViewBuilder<'a, S> { + /// Set the `permissions` field (optional) + pub fn permissions( + mut self, + value: impl Into>>, + ) -> Self { + self.__unsafe_private_named.4 = value.into(); + self + } + /// Set the `permissions` field to an Option value (optional) + pub fn maybe_permissions( + mut self, + value: Option>, + ) -> Self { + self.__unsafe_private_named.4 = value; + self + } +} + impl<'a, S> NotebookViewBuilder<'a, S> where S: notebook_view_state::State, @@ -1787,7 +2031,7 @@ where mut self, value: impl Into>, ) -> NotebookViewBuilder<'a, notebook_view_state::SetRecord> { - self.__unsafe_private_named.4 = ::core::option::Option::Some(value.into()); + self.__unsafe_private_named.5 = ::core::option::Option::Some(value.into()); NotebookViewBuilder { _phantom_state: ::core::marker::PhantomData, __unsafe_private_named: self.__unsafe_private_named, @@ -1802,7 +2046,7 @@ impl<'a, S: notebook_view_state::State> NotebookViewBuilder<'a, S> { mut self, value: impl Into>>, ) -> Self { - self.__unsafe_private_named.5 = value.into(); + self.__unsafe_private_named.6 = value.into(); self } /// Set the `tags` field to an Option value (optional) @@ -1810,7 +2054,7 @@ impl<'a, S: notebook_view_state::State> NotebookViewBuilder<'a, S> { mut self, value: Option>, ) -> Self { - self.__unsafe_private_named.5 = value; + self.__unsafe_private_named.6 = value; self } } @@ -1821,7 +2065,7 @@ impl<'a, S: notebook_view_state::State> NotebookViewBuilder<'a, S> { mut self, value: impl Into>>, ) -> Self { - self.__unsafe_private_named.6 = value.into(); + self.__unsafe_private_named.7 = value.into(); self } /// Set the `title` field to an Option value (optional) @@ -1829,7 +2073,7 @@ impl<'a, S: notebook_view_state::State> NotebookViewBuilder<'a, S> { mut self, value: Option>, ) -> Self { - self.__unsafe_private_named.6 = value; + self.__unsafe_private_named.7 = value; self } } @@ -1844,7 +2088,7 @@ where mut self, value: impl Into>, ) -> NotebookViewBuilder<'a, notebook_view_state::SetUri> { - self.__unsafe_private_named.7 = ::core::option::Option::Some(value.into()); + self.__unsafe_private_named.8 = ::core::option::Option::Some(value.into()); NotebookViewBuilder { _phantom_state: ::core::marker::PhantomData, __unsafe_private_named: self.__unsafe_private_named, @@ -1869,10 +2113,11 @@ where cid: self.__unsafe_private_named.1.unwrap(), indexed_at: self.__unsafe_private_named.2.unwrap(), path: self.__unsafe_private_named.3, - record: self.__unsafe_private_named.4.unwrap(), - tags: self.__unsafe_private_named.5, - title: self.__unsafe_private_named.6, - uri: self.__unsafe_private_named.7.unwrap(), + permissions: self.__unsafe_private_named.4, + record: self.__unsafe_private_named.5.unwrap(), + tags: self.__unsafe_private_named.6, + title: self.__unsafe_private_named.7, + uri: self.__unsafe_private_named.8.unwrap(), extra_data: Default::default(), } } @@ -1889,10 +2134,11 @@ where cid: self.__unsafe_private_named.1.unwrap(), indexed_at: self.__unsafe_private_named.2.unwrap(), path: self.__unsafe_private_named.3, - record: self.__unsafe_private_named.4.unwrap(), - tags: self.__unsafe_private_named.5, - title: self.__unsafe_private_named.6, - uri: self.__unsafe_private_named.7.unwrap(), + permissions: self.__unsafe_private_named.4, + record: self.__unsafe_private_named.5.unwrap(), + tags: self.__unsafe_private_named.6, + title: self.__unsafe_private_named.7, + uri: self.__unsafe_private_named.8.unwrap(), extra_data: Some(extra_data), } } @@ -1917,6 +2163,432 @@ impl<'a> ::jacquard_lexicon::schema::LexiconSchema for NotebookView<'a> { /// The path of the notebook. pub type Path<'a> = jacquard_common::CowStr<'a>; +/// A single permission grant. For resource authority: source=resource URI, grantedAt=createdAt. For invitees: source=invite URI, grantedAt=accept createdAt. +#[jacquard_derive::lexicon] +#[derive( + serde::Serialize, + serde::Deserialize, + Debug, + Clone, + PartialEq, + Eq, + jacquard_derive::IntoStatic +)] +#[serde(rename_all = "camelCase")] +pub struct PermissionGrant<'a> { + #[serde(borrow)] + pub did: jacquard_common::types::string::Did<'a>, + /// For authority: record createdAt. For invitees: accept createdAt + pub granted_at: jacquard_common::types::string::Datetime, + /// direct = this resource (includes authority), inherited = via notebook invite + #[serde(borrow)] + pub scope: jacquard_common::CowStr<'a>, + /// For authority: resource URI. For invitees: invite URI + #[serde(borrow)] + pub source: jacquard_common::types::string::AtUri<'a>, +} + +pub mod permission_grant_state { + + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed { + type Did; + type Scope; + type Source; + type GrantedAt; + } + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty { + type Did = Unset; + type Scope = Unset; + type Source = Unset; + type GrantedAt = Unset; + } + ///State transition - sets the `did` field to Set + pub struct SetDid(PhantomData S>); + impl sealed::Sealed for SetDid {} + impl State for SetDid { + type Did = Set; + type Scope = S::Scope; + type Source = S::Source; + type GrantedAt = S::GrantedAt; + } + ///State transition - sets the `scope` field to Set + pub struct SetScope(PhantomData S>); + impl sealed::Sealed for SetScope {} + impl State for SetScope { + type Did = S::Did; + type Scope = Set; + type Source = S::Source; + type GrantedAt = S::GrantedAt; + } + ///State transition - sets the `source` field to Set + pub struct SetSource(PhantomData S>); + impl sealed::Sealed for SetSource {} + impl State for SetSource { + type Did = S::Did; + type Scope = S::Scope; + type Source = Set; + type GrantedAt = S::GrantedAt; + } + ///State transition - sets the `granted_at` field to Set + pub struct SetGrantedAt(PhantomData S>); + impl sealed::Sealed for SetGrantedAt {} + impl State for SetGrantedAt { + type Did = S::Did; + type Scope = S::Scope; + type Source = S::Source; + type GrantedAt = Set; + } + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members { + ///Marker type for the `did` field + pub struct did(()); + ///Marker type for the `scope` field + pub struct scope(()); + ///Marker type for the `source` field + pub struct source(()); + ///Marker type for the `granted_at` field + pub struct granted_at(()); + } +} + +/// Builder for constructing an instance of this type +pub struct PermissionGrantBuilder<'a, S: permission_grant_state::State> { + _phantom_state: ::core::marker::PhantomData S>, + __unsafe_private_named: ( + ::core::option::Option>, + ::core::option::Option, + ::core::option::Option>, + ::core::option::Option>, + ), + _phantom: ::core::marker::PhantomData<&'a ()>, +} + +impl<'a> PermissionGrant<'a> { + /// Create a new builder for this type + pub fn new() -> PermissionGrantBuilder<'a, permission_grant_state::Empty> { + PermissionGrantBuilder::new() + } +} + +impl<'a> PermissionGrantBuilder<'a, permission_grant_state::Empty> { + /// Create a new builder with all fields unset + pub fn new() -> Self { + PermissionGrantBuilder { + _phantom_state: ::core::marker::PhantomData, + __unsafe_private_named: (None, None, None, None), + _phantom: ::core::marker::PhantomData, + } + } +} + +impl<'a, S> PermissionGrantBuilder<'a, S> +where + S: permission_grant_state::State, + S::Did: permission_grant_state::IsUnset, +{ + /// Set the `did` field (required) + pub fn did( + mut self, + value: impl Into>, + ) -> PermissionGrantBuilder<'a, permission_grant_state::SetDid> { + self.__unsafe_private_named.0 = ::core::option::Option::Some(value.into()); + PermissionGrantBuilder { + _phantom_state: ::core::marker::PhantomData, + __unsafe_private_named: self.__unsafe_private_named, + _phantom: ::core::marker::PhantomData, + } + } +} + +impl<'a, S> PermissionGrantBuilder<'a, S> +where + S: permission_grant_state::State, + S::GrantedAt: permission_grant_state::IsUnset, +{ + /// Set the `grantedAt` field (required) + pub fn granted_at( + mut self, + value: impl Into, + ) -> PermissionGrantBuilder<'a, permission_grant_state::SetGrantedAt> { + self.__unsafe_private_named.1 = ::core::option::Option::Some(value.into()); + PermissionGrantBuilder { + _phantom_state: ::core::marker::PhantomData, + __unsafe_private_named: self.__unsafe_private_named, + _phantom: ::core::marker::PhantomData, + } + } +} + +impl<'a, S> PermissionGrantBuilder<'a, S> +where + S: permission_grant_state::State, + S::Scope: permission_grant_state::IsUnset, +{ + /// Set the `scope` field (required) + pub fn scope( + mut self, + value: impl Into>, + ) -> PermissionGrantBuilder<'a, permission_grant_state::SetScope> { + self.__unsafe_private_named.2 = ::core::option::Option::Some(value.into()); + PermissionGrantBuilder { + _phantom_state: ::core::marker::PhantomData, + __unsafe_private_named: self.__unsafe_private_named, + _phantom: ::core::marker::PhantomData, + } + } +} + +impl<'a, S> PermissionGrantBuilder<'a, S> +where + S: permission_grant_state::State, + S::Source: permission_grant_state::IsUnset, +{ + /// Set the `source` field (required) + pub fn source( + mut self, + value: impl Into>, + ) -> PermissionGrantBuilder<'a, permission_grant_state::SetSource> { + self.__unsafe_private_named.3 = ::core::option::Option::Some(value.into()); + PermissionGrantBuilder { + _phantom_state: ::core::marker::PhantomData, + __unsafe_private_named: self.__unsafe_private_named, + _phantom: ::core::marker::PhantomData, + } + } +} + +impl<'a, S> PermissionGrantBuilder<'a, S> +where + S: permission_grant_state::State, + S::Did: permission_grant_state::IsSet, + S::Scope: permission_grant_state::IsSet, + S::Source: permission_grant_state::IsSet, + S::GrantedAt: permission_grant_state::IsSet, +{ + /// Build the final struct + pub fn build(self) -> PermissionGrant<'a> { + PermissionGrant { + did: self.__unsafe_private_named.0.unwrap(), + granted_at: self.__unsafe_private_named.1.unwrap(), + scope: self.__unsafe_private_named.2.unwrap(), + source: self.__unsafe_private_named.3.unwrap(), + extra_data: Default::default(), + } + } + /// Build the final struct with custom extra_data + pub fn build_with_data( + self, + extra_data: std::collections::BTreeMap< + jacquard_common::smol_str::SmolStr, + jacquard_common::types::value::Data<'a>, + >, + ) -> PermissionGrant<'a> { + PermissionGrant { + did: self.__unsafe_private_named.0.unwrap(), + granted_at: self.__unsafe_private_named.1.unwrap(), + scope: self.__unsafe_private_named.2.unwrap(), + source: self.__unsafe_private_named.3.unwrap(), + extra_data: Some(extra_data), + } + } +} + +impl<'a> ::jacquard_lexicon::schema::LexiconSchema for PermissionGrant<'a> { + fn nsid() -> &'static str { + "sh.weaver.notebook.defs" + } + fn def_name() -> &'static str { + "permissionGrant" + } + fn lexicon_doc() -> ::jacquard_lexicon::lexicon::LexiconDoc<'static> { + lexicon_doc_sh_weaver_notebook_defs() + } + fn validate( + &self, + ) -> ::std::result::Result<(), ::jacquard_lexicon::validation::ConstraintError> { + Ok(()) + } +} + +/// ACL-style permissions for a resource. Separate from authors (who contributed). +#[jacquard_derive::lexicon] +#[derive( + serde::Serialize, + serde::Deserialize, + Debug, + Clone, + PartialEq, + Eq, + jacquard_derive::IntoStatic +)] +#[serde(rename_all = "camelCase")] +pub struct PermissionsState<'a> { + /// DIDs that can edit this resource + #[serde(borrow)] + pub editors: Vec>, + /// DIDs that can view (future use) + #[serde(skip_serializing_if = "std::option::Option::is_none")] + #[serde(borrow)] + pub viewers: std::option::Option< + Vec>, + >, +} + +pub mod permissions_state_state { + + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed { + type Editors; + } + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty { + type Editors = Unset; + } + ///State transition - sets the `editors` field to Set + pub struct SetEditors(PhantomData S>); + impl sealed::Sealed for SetEditors {} + impl State for SetEditors { + type Editors = Set; + } + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members { + ///Marker type for the `editors` field + pub struct editors(()); + } +} + +/// Builder for constructing an instance of this type +pub struct PermissionsStateBuilder<'a, S: permissions_state_state::State> { + _phantom_state: ::core::marker::PhantomData S>, + __unsafe_private_named: ( + ::core::option::Option>>, + ::core::option::Option>>, + ), + _phantom: ::core::marker::PhantomData<&'a ()>, +} + +impl<'a> PermissionsState<'a> { + /// Create a new builder for this type + pub fn new() -> PermissionsStateBuilder<'a, permissions_state_state::Empty> { + PermissionsStateBuilder::new() + } +} + +impl<'a> PermissionsStateBuilder<'a, permissions_state_state::Empty> { + /// Create a new builder with all fields unset + pub fn new() -> Self { + PermissionsStateBuilder { + _phantom_state: ::core::marker::PhantomData, + __unsafe_private_named: (None, None), + _phantom: ::core::marker::PhantomData, + } + } +} + +impl<'a, S> PermissionsStateBuilder<'a, S> +where + S: permissions_state_state::State, + S::Editors: permissions_state_state::IsUnset, +{ + /// Set the `editors` field (required) + pub fn editors( + mut self, + value: impl Into>>, + ) -> PermissionsStateBuilder<'a, permissions_state_state::SetEditors> { + self.__unsafe_private_named.0 = ::core::option::Option::Some(value.into()); + PermissionsStateBuilder { + _phantom_state: ::core::marker::PhantomData, + __unsafe_private_named: self.__unsafe_private_named, + _phantom: ::core::marker::PhantomData, + } + } +} + +impl<'a, S: permissions_state_state::State> PermissionsStateBuilder<'a, S> { + /// Set the `viewers` field (optional) + pub fn viewers( + mut self, + value: impl Into>>>, + ) -> Self { + self.__unsafe_private_named.1 = value.into(); + self + } + /// Set the `viewers` field to an Option value (optional) + pub fn maybe_viewers( + mut self, + value: Option>>, + ) -> Self { + self.__unsafe_private_named.1 = value; + self + } +} + +impl<'a, S> PermissionsStateBuilder<'a, S> +where + S: permissions_state_state::State, + S::Editors: permissions_state_state::IsSet, +{ + /// Build the final struct + pub fn build(self) -> PermissionsState<'a> { + PermissionsState { + editors: self.__unsafe_private_named.0.unwrap(), + viewers: self.__unsafe_private_named.1, + extra_data: Default::default(), + } + } + /// Build the final struct with custom extra_data + pub fn build_with_data( + self, + extra_data: std::collections::BTreeMap< + jacquard_common::smol_str::SmolStr, + jacquard_common::types::value::Data<'a>, + >, + ) -> PermissionsState<'a> { + PermissionsState { + editors: self.__unsafe_private_named.0.unwrap(), + viewers: self.__unsafe_private_named.1, + extra_data: Some(extra_data), + } + } +} + +impl<'a> ::jacquard_lexicon::schema::LexiconSchema for PermissionsState<'a> { + fn nsid() -> &'static str { + "sh.weaver.notebook.defs" + } + fn def_name() -> &'static str { + "permissionsState" + } + fn lexicon_doc() -> ::jacquard_lexicon::lexicon::LexiconDoc<'static> { + lexicon_doc_sh_weaver_notebook_defs() + } + fn validate( + &self, + ) -> ::std::result::Result<(), ::jacquard_lexicon::validation::ConstraintError> { + Ok(()) + } +} + /// View of a rendered and cached notebook entry #[jacquard_derive::lexicon] #[derive( diff --git a/crates/weaver-app/.env-dev b/crates/weaver-app/.env-dev deleted file mode 100644 index 5d82bc4..0000000 --- a/crates/weaver-app/.env-dev +++ /dev/null @@ -1,10 +0,0 @@ -WEAVER_APP_ENV="dev" -WEAVER_APP_HOST="http://localhost" -WEAVER_APP_DOMAIN="" -WEAVER_PORT=8080 -WEAVER_APP_SCOPES="atproto transition:generic" -WEAVER_CLIENT_NAME="Weaver" - -WEAVER_LOGO_URI="" -WEAVER_TOS_URI="" -WEAVER_PRIVACY_POLICY_URI="" diff --git a/crates/weaver-app/.env-pro b/crates/weaver-app/.env-pro new file mode 100644 index 0000000..76ce3db --- /dev/null +++ b/crates/weaver-app/.env-pro @@ -0,0 +1,10 @@ +WEAVER_APP_ENV="prod" +WEAVER_APP_HOST="https://alpha.weaver.sh" +WEAVER_APP_DOMAIN="https://alpha.weaver.sh" +WEAVER_PORT=8080 +WEAVER_APP_SCOPES="atproto transition:generic" +WEAVER_CLIENT_NAME="Weaver" + +WEAVER_LOGO_URI="https://alpha.weaver.sh/favicon.ico" +WEAVER_TOS_URI="" +WEAVER_PRIVACY_POLICY_URI="" diff --git a/crates/weaver-app/assets/styling/drafts.css b/crates/weaver-app/assets/styling/drafts.css index c4adc8d..73fcb67 100644 --- a/crates/weaver-app/assets/styling/drafts.css +++ b/crates/weaver-app/assets/styling/drafts.css @@ -60,6 +60,12 @@ font-weight: 500; } +.draft-badges { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + .draft-badge { font-size: 0.75rem; padding: 0.125rem 0.5rem; @@ -68,8 +74,9 @@ } .draft-badge-new { - background: var(--color-secondary); - color: var(--color-base); + background: var(--color-surface); + color: var(--color-secondary); + border: 1px solid var(--color-secondary); } .draft-badge-edit { @@ -78,6 +85,24 @@ border: 1px solid var(--color-border); } +.draft-badge-synced { + background: var(--color-surface); + color: var(--color-success); + border: 1px solid var(--color-success); +} + +.draft-badge-local { + background: var(--color-surface); + color: var(--color-warning); + border: 1px solid var(--color-warning); +} + +.draft-badge-remote { + background: var(--color-surface); + color: var(--color-primary); + border: 1px solid var(--color-primary); +} + /* Mobile adjustments */ @media (max-width: 600px) { .drafts-page { diff --git a/crates/weaver-app/assets/styling/editor.css b/crates/weaver-app/assets/styling/editor.css index 24fac13..281ccbf 100644 --- a/crates/weaver-app/assets/styling/editor.css +++ b/crates/weaver-app/assets/styling/editor.css @@ -744,3 +744,398 @@ li:has(.md-syntax-block:not(.hidden)) { .sync-status.disabled:hover { opacity: 0.6; } + +/* ========================================================================== + COLLABORATORS + ========================================================================== */ + +/* Inline avatars in meta row */ +.collaborator-avatars { + display: flex; + align-items: center; + cursor: pointer; + padding: 2px; +} + +.collaborator-avatars:hover .collab-avatar { + border-color: var(--color-primary); +} + +.collab-avatar { + width: 24px; + height: 24px; + border: 1px solid var(--color-border); + background: var(--color-surface); + color: var(--color-muted); + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 500; + font-family: var(--font-mono); + text-transform: uppercase; + margin-left: -6px; + position: relative; + transition: border-color 0.15s ease; +} + +.collab-avatar:first-child { + margin-left: 0; +} + +.collab-avatar.collab-overflow { + background: var(--color-overlay); + color: var(--color-subtle); + font-size: 10px; +} + +.collab-avatar.collab-add { + background: transparent; + border-style: dashed; + color: var(--color-muted); + font-size: 14px; +} + +.collab-avatar.collab-add:hover { + border-color: var(--color-primary); + color: var(--color-primary); +} + +/* Collaborators panel overlay */ +.collaborators-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.4); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +@media (prefers-color-scheme: dark) { + .collaborators-overlay { + background: rgba(0, 0, 0, 0.6); + } +} + +.collaborators-modal { + background: var(--color-surface); + border: 1px solid var(--color-border); + padding: 0; + max-width: 400px; + width: 90%; + max-height: 80vh; + overflow-y: auto; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1); +} + +@media (prefers-color-scheme: dark) { + .collaborators-modal { + box-shadow: none; + } +} + +/* Collaborators panel content */ +.collaborators-panel { + padding: 1rem; +} + +.collaborators-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--color-border); +} + +.collaborators-header h4 { + margin: 0; + font-size: 14px; + font-weight: 600; + color: var(--color-text); +} + +.collaborators-header-actions { + display: flex; + gap: 4px; +} + +.collaborators-list { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 0.75rem; +} + +.collaborator { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 10px; + background: var(--color-overlay); + border: 1px solid transparent; +} + +.collaborator.pending { + border-style: dashed; + border-color: var(--color-border); + background: transparent; +} + +.collaborator-did { + font-family: var(--font-mono); + font-size: 12px; + color: var(--color-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 280px; +} + +.collaborator-status { + font-size: 12px; + color: var(--color-muted); +} + +.collaborator.accepted .collaborator-status { + color: var(--color-success); +} + +.collaborators-summary { + font-size: 11px; + color: var(--color-muted); + text-align: right; +} + +.collaborators-panel .empty-state { + color: var(--color-muted); + font-size: 13px; + text-align: center; + padding: 1.5rem 0; + margin: 0; +} + +/* ========================================================================== + INVITE DIALOG + ========================================================================== */ + +.invite-dialog-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.4); + display: flex; + align-items: center; + justify-content: center; + z-index: 1001; +} + +@media (prefers-color-scheme: dark) { + .invite-dialog-overlay { + background: rgba(0, 0, 0, 0.6); + } +} + +.invite-dialog { + background: var(--color-surface); + border: 1px solid var(--color-border); + padding: 1.25rem; + max-width: 420px; + width: 90%; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1); +} + +@media (prefers-color-scheme: dark) { + .invite-dialog { + box-shadow: none; + } +} + +.invite-dialog h3 { + margin: 0 0 1rem 0; + font-size: 15px; + font-weight: 600; + color: var(--color-text); +} + +.invite-dialog .invite-resource-info { + font-size: 12px; + color: var(--color-muted); + margin-bottom: 1rem; + padding: 8px 10px; + background: var(--color-overlay); + border: 1px solid var(--color-border); +} + +.invite-field { + margin-bottom: 1rem; +} + +.invite-field label { + display: block; + font-size: 11px; + font-weight: 500; + color: var(--color-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 6px; +} + +.invite-field input, +.invite-field textarea { + width: 100%; + padding: 8px 10px; + border: 1px solid var(--color-border); + background: var(--color-base); + color: var(--color-text); + font-family: var(--font-ui); + font-size: 13px; + box-sizing: border-box; +} + +.invite-field input:focus, +.invite-field textarea:focus { + outline: none; + border-color: var(--color-primary); +} + +.invite-field textarea { + min-height: 60px; + resize: vertical; +} + +.invite-error { + background: color-mix(in srgb, var(--color-error) 10%, var(--color-surface)); + border: 1px solid var(--color-error); + color: var(--color-error); + padding: 8px 10px; + margin-bottom: 1rem; + font-size: 13px; +} + +.invite-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--color-border); +} + +/* ========================================================================== + INVITES LIST + ========================================================================== */ + +.invites-list { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.invites-section h3 { + font-size: 13px; + font-weight: 600; + color: var(--color-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + margin: 0 0 0.75rem 0; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--color-border); +} + +.invites-section .empty-state { + color: var(--color-muted); + font-size: 13px; + padding: 1rem 0; + margin: 0; +} + +.invite-card { + padding: 12px; + background: var(--color-surface); + border: 1px solid var(--color-border); + margin-bottom: 8px; +} + +.invite-card:last-child { + margin-bottom: 0; +} + +.invite-info { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 8px; +} + +.invite-from, +.invite-to { + font-size: 13px; + color: var(--color-text); +} + +.invite-resource { + font-size: 11px; + font-family: var(--font-mono); + color: var(--color-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.invite-message { + font-size: 13px; + color: var(--color-subtle); + margin: 6px 0 0 0; + padding: 8px; + background: var(--color-overlay); + border-left: 2px solid var(--color-border); +} + +.invite-actions, +.invite-status { + display: flex; + align-items: center; + gap: 8px; +} + +.invite-status .status-badge { + font-size: 11px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.03em; + padding: 3px 8px; +} + +.status-badge.pending { + background: color-mix(in srgb, var(--color-warning) 15%, transparent); + color: var(--color-warning); + border: 1px solid var(--color-warning); +} + +.status-badge.accepted { + background: color-mix(in srgb, var(--color-success) 15%, transparent); + color: var(--color-success); + border: 1px solid var(--color-success); +} + +.invite-card .error-message { + background: color-mix(in srgb, var(--color-error) 10%, var(--color-surface)); + border: 1px solid var(--color-error); + color: var(--color-error); + padding: 6px 8px; + margin-bottom: 8px; + font-size: 12px; +} + +.invite-status.accepted { + color: var(--color-success); + font-size: 12px; + font-weight: 500; +} diff --git a/crates/weaver-app/assets/styling/invites.css b/crates/weaver-app/assets/styling/invites.css new file mode 100644 index 0000000..2bac89d --- /dev/null +++ b/crates/weaver-app/assets/styling/invites.css @@ -0,0 +1,131 @@ +.invites-page { + max-width: 800px; + margin: 0 auto; + padding: 2rem; +} + +.invites-header { + margin-bottom: 2rem; +} + +.invites-header h1 { + margin: 0 0 0.5rem 0; +} + +.invites-description { + color: var(--color-subtle); + margin: 0; +} + +/* Invites list component styles */ +.invites-list { + display: flex; + flex-direction: column; + gap: 2rem; +} + +.invites-section h3 { + margin: 0 0 1rem 0; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--color-border); +} + +.empty-state { + color: var(--color-subtle); + text-align: center; + padding: 2rem; +} + +.invite-card { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 1rem; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 0; + margin-bottom: 0.5rem; +} + +.invite-card:hover { + border-color: var(--color-primary); +} + +.invite-info { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.invite-from, +.invite-to { + font-weight: 500; +} + +.invite-resource { + font-size: 0.875rem; + color: var(--color-subtle); + word-break: break-all; +} + +.invite-message { + margin: 0.5rem 0 0 0; + padding: 0.5rem; + background: var(--color-background); + border-left: 3px solid var(--color-primary); + font-style: italic; +} + +.invite-actions { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.invite-status { + display: flex; + align-items: center; +} + +.status-badge { + font-size: 0.75rem; + padding: 0.25rem 0.75rem; + border-radius: 4px; + font-weight: 500; +} + +.status-badge.pending { + background: var(--color-surface); + color: var(--color-warning); + border: 1px solid var(--color-warning); +} + +.status-badge.accepted, +.invite-status.accepted { + background: var(--color-surface); + color: var(--color-success); + border: 1px solid var(--color-success); +} + +.error-message { + color: var(--color-error); + font-size: 0.875rem; + padding: 0.5rem; + background: var(--color-error-background, rgba(220, 38, 38, 0.1)); + border-radius: 4px; +} + +/* Mobile adjustments */ +@media (max-width: 600px) { + .invites-page { + padding: 1rem; + } + + .invite-card { + padding: 0.75rem; + } + + .invite-resource { + font-size: 0.75rem; + } +} diff --git a/crates/weaver-app/assets/styling/profile.css b/crates/weaver-app/assets/styling/profile.css index b869034..fb5f045 100644 --- a/crates/weaver-app/assets/styling/profile.css +++ b/crates/weaver-app/assets/styling/profile.css @@ -194,3 +194,66 @@ border-right: 1.5px solid var(--color-border); } } + +/* Profile Invites Section */ +.profile-invites { + margin-top: 1.25rem; + padding-top: 1.25rem; + border-top: 1.5px dashed var(--color-border); +} + +.profile-invites-header { + font-size: 0.9rem; + font-weight: 600; + color: var(--color-text); + margin: 0 0 0.75rem 0; + font-family: var(--font-heading); +} + +.profile-invites-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.profile-invite-card { + padding: 0.75rem; + border: 1px solid var(--color-border); + border-radius: 4px; + background: var(--color-surface); +} + +.profile-invite-from { + font-size: 0.85rem; + color: var(--color-subtle); + margin-bottom: 0.5rem; +} + +.profile-invite-did { + color: var(--color-text); + font-family: var(--font-mono); + font-size: 0.8rem; +} + +.profile-invite-message { + font-size: 0.85rem; + color: var(--color-muted); + margin: 0.5rem 0; + font-style: italic; +} + +.profile-invite-error { + font-size: 0.8rem; + color: var(--color-error, #e53935); + margin-bottom: 0.5rem; +} + +.profile-invite-actions { + margin-top: 0.5rem; +} + +.profile-invite-accepted { + font-size: 0.85rem; + color: var(--color-success, #43a047); + font-weight: 500; +} diff --git a/crates/weaver-app/src/components/collab/api.rs b/crates/weaver-app/src/components/collab/api.rs new file mode 100644 index 0000000..2f45614 --- /dev/null +++ b/crates/weaver-app/src/components/collab/api.rs @@ -0,0 +1,245 @@ +//! API functions for collaboration invites. + +use crate::fetch::Fetcher; +use jacquard::IntoStatic; +use jacquard::prelude::*; +use jacquard::types::collection::Collection; +use jacquard::types::string::{AtUri, Cid, Datetime, Did, Nsid}; +use jacquard::types::uri::Uri; +use reqwest::Url; +use weaver_api::com_atproto::repo::list_records::ListRecords; +use weaver_api::com_atproto::repo::strong_ref::StrongRef; +use weaver_api::sh_weaver::collab::{accept::Accept, invite::Invite}; +use weaver_common::WeaverError; +use weaver_common::constellation::GetBacklinksQuery; + +const ACCEPT_NSID: &str = "sh.weaver.collab.accept"; +const CONSTELLATION_URL: &str = "https://constellation.microcosm.blue"; + +/// An invite sent by the current user. +#[derive(Clone, Debug, PartialEq)] +pub struct SentInvite { + pub uri: AtUri<'static>, + pub invitee: Did<'static>, + pub resource_uri: AtUri<'static>, + pub message: Option, + pub created_at: Datetime, + pub accepted: bool, +} + +/// An invite received by the current user. +#[derive(Clone, Debug, PartialEq)] +pub struct ReceivedInvite { + pub uri: AtUri<'static>, + pub cid: Cid<'static>, + pub inviter: Did<'static>, + pub resource_uri: AtUri<'static>, + pub resource_cid: Cid<'static>, + pub message: Option, + pub created_at: Datetime, +} + +/// An accepted invite (for listing collaborators). +#[derive(Clone, Debug, PartialEq)] +pub struct AcceptedInvite { + pub accept_uri: AtUri<'static>, + pub collaborator: Did<'static>, + pub resource_uri: AtUri<'static>, + pub accepted_at: Datetime, +} + +/// Create an invite to collaborate on a resource. +pub async fn create_invite( + fetcher: &Fetcher, + resource: StrongRef<'static>, + invitee: Did<'static>, + message: Option, +) -> Result, WeaverError> { + let mut invite_builder = Invite::new() + .resource(resource) + .invitee(invitee) + .created_at(Datetime::now()); + + if let Some(msg) = message { + invite_builder = invite_builder.message(Some(jacquard::CowStr::from(msg))); + } + + let invite = invite_builder.build(); + + let output = fetcher + .create_record(invite, None) + .await + .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to create invite: {}", e)))?; + + Ok(output.uri.into_static()) +} + +/// Accept a collaboration invite. +pub async fn accept_invite( + fetcher: &Fetcher, + invite_ref: StrongRef<'static>, + resource_uri: AtUri<'static>, +) -> Result, WeaverError> { + let accept = Accept::new() + .invite(invite_ref) + .resource(resource_uri) + .created_at(Datetime::now()) + .build(); + + let output = fetcher + .create_record(accept, None) + .await + .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to accept invite: {}", e)))?; + + Ok(output.uri.into_static()) +} + +/// Fetch invites sent by the current user. +pub async fn fetch_sent_invites(fetcher: &Fetcher) -> Result, WeaverError> { + let did = fetcher + .current_did() + .await + .ok_or_else(|| WeaverError::InvalidNotebook("Not authenticated".into()))?; + + let request = ListRecords::new() + .repo(did) + .collection(Nsid::raw(Invite::NSID)) + .limit(100) + .build(); + + let response = fetcher + .send(request) + .await + .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to list invites: {}", e)))?; + + let output = response.into_output().map_err(|e| { + WeaverError::InvalidNotebook(format!("Failed to parse list response: {}", e)) + })?; + + let mut invites = Vec::new(); + for record in output.records { + if let Ok(invite) = jacquard::from_data::(&record.value) { + let uri = record.uri.into_static(); + let accepted = check_invite_accepted(fetcher, &uri).await; + + invites.push(SentInvite { + uri, + invitee: invite.invitee.into_static(), + resource_uri: invite.resource.uri.into_static(), + message: invite.message.map(|s| s.to_string()), + created_at: invite.created_at.clone(), + accepted, + }); + } + } + + Ok(invites) +} + +/// Check if an invite has been accepted by querying for accept records. +async fn check_invite_accepted(fetcher: &Fetcher, invite_uri: &AtUri<'_>) -> bool { + let Ok(constellation_url) = Url::parse(CONSTELLATION_URL) else { + return false; + }; + + // Query for sh.weaver.collab.accept records that reference this invite via .invite.uri + let query = GetBacklinksQuery { + subject: Uri::At(invite_uri.clone().into_static()), + source: format!("{}:invite.uri", ACCEPT_NSID).into(), + cursor: None, + did: vec![], + limit: 1, + }; + + let Ok(response) = fetcher.client.xrpc(constellation_url).send(&query).await else { + return false; + }; + + let Ok(output) = response.into_output() else { + return false; + }; + + !output.records.is_empty() +} + +/// Fetch invites received by the current user (via Constellation backlinks). +/// +/// This queries Constellation to find invite records where the current user +/// is the invitee, then fetches each record from the inviter's PDS to get +/// the full invite details. +pub async fn fetch_received_invites(fetcher: &Fetcher) -> Result, WeaverError> { + let did = fetcher + .current_did() + .await + .ok_or_else(|| WeaverError::InvalidNotebook("Not authenticated".into()))?; + + let constellation_url = Url::parse(CONSTELLATION_URL) + .map_err(|e| WeaverError::InvalidNotebook(format!("Invalid constellation URL: {}", e)))?; + + // Query for sh.weaver.collab.invite records where .invitee = current user's DID + let query = GetBacklinksQuery { + subject: Uri::Did(did.clone()), + source: format!("{}:invitee", Invite::NSID).into(), + cursor: None, + did: vec![], + limit: 100, + }; + + let response = fetcher + .client + .xrpc(constellation_url) + .send(&query) + .await + .map_err(|e| WeaverError::InvalidNotebook(format!("Constellation query failed: {}", e)))?; + + let output = response.into_output().map_err(|e| { + WeaverError::InvalidNotebook(format!("Failed to parse constellation response: {}", e)) + })?; + + // For each RecordId, fetch the actual record from the inviter's PDS + let mut invites = Vec::new(); + + for record_id in output.records { + let inviter_did = record_id.did.into_static(); + + // Build the AT-URI for the invite record + let uri_string = format!( + "at://{}/{}/{}", + inviter_did, + Invite::NSID, + record_id.rkey.as_ref() + ); + let Ok(invite_uri) = AtUri::new(&uri_string) else { + continue; + }; + let invite_uri = invite_uri.into_static(); + + // Fetch the invite record from the inviter's PDS + let Ok(response) = fetcher.get_record::(&invite_uri).await else { + continue; + }; + + let Ok(record) = response.into_output() else { + continue; + }; + + let Some(cid) = record.cid else { + continue; + }; + + // record.value is already the typed Invite from get_record:: + let invite = &record.value; + + invites.push(ReceivedInvite { + uri: record.uri.into_static(), + cid: cid.into_static(), + inviter: inviter_did, + resource_uri: invite.resource.uri.clone().into_static(), + resource_cid: invite.resource.cid.clone().into_static(), + message: invite.message.as_ref().map(|s| s.to_string()), + created_at: invite.created_at.clone(), + }); + } + + Ok(invites) +} diff --git a/crates/weaver-app/src/components/collab/avatars.rs b/crates/weaver-app/src/components/collab/avatars.rs new file mode 100644 index 0000000..dfc4a78 --- /dev/null +++ b/crates/weaver-app/src/components/collab/avatars.rs @@ -0,0 +1,93 @@ +//! Collaborator avatars display for the editor meta row. + +use crate::auth::AuthState; +use crate::fetch::Fetcher; +use dioxus::prelude::*; +use jacquard::types::string::AtUri; + +use super::api::{fetch_sent_invites, SentInvite}; +use super::CollaboratorsPanel; + +/// Props for the CollaboratorAvatars component. +#[derive(Props, Clone, PartialEq)] +pub struct CollaboratorAvatarsProps { + /// The resource URI to show collaborators for. + pub resource_uri: AtUri<'static>, + /// CID of the resource. + pub resource_cid: String, + /// Optional title for display in the panel. + #[props(default)] + pub resource_title: Option, +} + +/// Shows collaborator avatars with a button to manage collaborators. +#[component] +pub fn CollaboratorAvatars(props: CollaboratorAvatarsProps) -> Element { + let auth_state = use_context::>(); + let fetcher = use_context::(); + let mut show_panel = use_signal(|| false); + + let resource_uri = props.resource_uri.clone(); + + // Fetch collaborators for this resource + let collaborators = { + let fetcher = fetcher.clone(); + let resource_uri = resource_uri.clone(); + use_resource(move || { + let fetcher = fetcher.clone(); + let resource_uri = resource_uri.clone(); + let _auth = auth_state.read().did.clone(); + async move { + fetch_sent_invites(&fetcher) + .await + .ok() + .unwrap_or_default() + .into_iter() + .filter(|i| i.resource_uri == resource_uri && i.accepted) + .collect::>() + } + }) + }; + + let collabs: Vec = collaborators().unwrap_or_default(); + let collab_count = collabs.len(); + + rsx! { + div { class: "collaborator-avatars", + onclick: move |_| show_panel.set(true), + + // Show up to 3 avatar circles + for (i, collab) in collabs.iter().take(3).enumerate() { + div { + class: "collab-avatar", + style: "z-index: {3 - i}", + title: "{collab.invitee}", + // First letter of DID as placeholder + {collab.invitee.as_ref().chars().last().unwrap_or('?').to_string()} + } + } + + // Show +N if more than 3 + if collab_count > 3 { + div { class: "collab-avatar collab-overflow", + "+{collab_count - 3}" + } + } + + // Always show the add button + div { class: "collab-avatar collab-add", + title: "Manage collaborators", + "+" + } + } + + if show_panel() { + CollaboratorsPanel { + resource_uri: props.resource_uri.clone(), + resource_cid: props.resource_cid.clone(), + resource_title: props.resource_title.clone(), + on_close: move |_| show_panel.set(false), + } + } + } +} diff --git a/crates/weaver-app/src/components/collab/collaborators.rs b/crates/weaver-app/src/components/collab/collaborators.rs new file mode 100644 index 0000000..4bf5df0 --- /dev/null +++ b/crates/weaver-app/src/components/collab/collaborators.rs @@ -0,0 +1,142 @@ +//! Panel showing current collaborators on a resource. + +use crate::auth::AuthState; +use crate::components::button::{Button, ButtonVariant}; +use crate::fetch::Fetcher; +use dioxus::prelude::*; +use jacquard::types::string::AtUri; + +use super::InviteDialog; +use super::api::{SentInvite, fetch_sent_invites}; + +/// Props for the CollaboratorsPanel component. +#[derive(Props, Clone, PartialEq)] +pub struct CollaboratorsPanelProps { + /// The resource to show collaborators for. + pub resource_uri: AtUri<'static>, + /// CID of the resource. + pub resource_cid: String, + /// Optional title for display. + #[props(default)] + pub resource_title: Option, + /// Callback when panel should close (for modal mode). + #[props(default)] + pub on_close: Option>, +} + +/// Panel showing collaborators and invite button. +#[component] +pub fn CollaboratorsPanel(props: CollaboratorsPanelProps) -> Element { + let auth_state = use_context::>(); + let fetcher = use_context::(); + let mut show_invite_dialog = use_signal(|| false); + + // Clone props we need in closures + let on_close = props.on_close.clone(); + let on_close_overlay = props.on_close.clone(); + let resource_uri = props.resource_uri.clone(); + let resource_uri_dialog = props.resource_uri.clone(); + let resource_cid = props.resource_cid.clone(); + let resource_title = props.resource_title.clone(); + + // Fetch invites for this resource to show collaborators + let invites_resource = { + let fetcher = fetcher.clone(); + use_resource(move || { + let fetcher = fetcher.clone(); + let resource_uri = resource_uri.clone(); + let _auth = auth_state.read().did.clone(); + async move { + fetch_sent_invites(&fetcher) + .await + .ok() + .unwrap_or_default() + .into_iter() + .filter(|i| i.resource_uri == resource_uri) + .collect::>() + } + }) + }; + + let invites: Vec = invites_resource().unwrap_or_default(); + let accepted_count = invites.iter().filter(|i| i.accepted).count(); + let pending_count = invites.len() - accepted_count; + + let is_modal = on_close.is_some(); + + let panel_content = rsx! { + div { class: "collaborators-panel", + div { class: "collaborators-header", + h4 { "Collaborators" } + div { class: "collaborators-header-actions", + Button { + variant: ButtonVariant::Ghost, + onclick: move |_| show_invite_dialog.set(true), + "Invite" + } + if let Some(ref handler) = on_close { + { + let handler = handler.clone(); + rsx! { + Button { + variant: ButtonVariant::Ghost, + onclick: move |_| handler.call(()), + "×" + } + } + } + } + } + } + + if invites.is_empty() { + p { class: "empty-state", "No collaborators yet" } + } else { + div { class: "collaborators-list", + for invite in &invites { + div { + class: if invite.accepted { "collaborator accepted" } else { "collaborator pending" }, + span { class: "collaborator-did", "{invite.invitee}" } + span { + class: "collaborator-status", + if invite.accepted { "✓" } else { "..." } + } + } + } + } + + div { class: "collaborators-summary", + "{accepted_count} active, {pending_count} pending" + } + } + } + + InviteDialog { + open: show_invite_dialog(), + on_close: move |_| show_invite_dialog.set(false), + resource_uri: resource_uri_dialog.clone(), + resource_cid: resource_cid.clone(), + resource_title: resource_title.clone(), + } + }; + + if is_modal { + rsx! { + div { + class: "collaborators-overlay", + onclick: move |_| { + if let Some(ref handler) = on_close_overlay { + handler.call(()); + } + }, + div { + class: "collaborators-modal", + onclick: move |e| e.stop_propagation(), + {panel_content} + } + } + } + } else { + panel_content + } +} diff --git a/crates/weaver-app/src/components/collab/invite_dialog.rs b/crates/weaver-app/src/components/collab/invite_dialog.rs new file mode 100644 index 0000000..287a39b --- /dev/null +++ b/crates/weaver-app/src/components/collab/invite_dialog.rs @@ -0,0 +1,176 @@ +//! Dialog for inviting collaborators. + +use crate::components::button::{Button, ButtonVariant}; +use crate::components::dialog::{DialogContent, DialogDescription, DialogRoot, DialogTitle}; +use crate::components::input::Input; +use crate::fetch::Fetcher; +use dioxus::prelude::*; +use jacquard::types::string::{AtUri, Cid, Handle}; +use jacquard::{IntoStatic, prelude::*}; +use weaver_api::com_atproto::repo::strong_ref::StrongRef; + +use super::api::create_invite; + +/// Props for the InviteDialog component. +#[derive(Props, Clone, PartialEq)] +pub struct InviteDialogProps { + /// Whether the dialog is open. + pub open: bool, + /// Callback when dialog should close. + pub on_close: EventHandler<()>, + /// The resource to invite collaborators to. + pub resource_uri: AtUri<'static>, + /// The CID of the resource. + pub resource_cid: String, + /// Optional title of the resource for display. + #[props(default)] + pub resource_title: Option, +} + +/// Dialog for inviting a user to collaborate on a resource. +#[component] +pub fn InviteDialog(props: InviteDialogProps) -> Element { + let fetcher = use_context::(); + let mut handle_input = use_signal(String::new); + let mut message_input = use_signal(String::new); + let mut error = use_signal(|| None::); + let mut is_sending = use_signal(|| false); + + let resource_uri = props.resource_uri.clone(); + let resource_cid = props.resource_cid.clone(); + let on_close = props.on_close.clone(); + + let send_invite = move |_| { + let fetcher = fetcher.clone(); + let handle = handle_input(); + let message = message_input(); + let resource_uri = resource_uri.clone(); + let resource_cid = resource_cid.clone(); + let on_close = on_close.clone(); + + spawn(async move { + is_sending.set(true); + error.set(None); + + // Parse and resolve handle to DID + let handle = match Handle::new(&handle) { + Ok(h) => h, + Err(e) => { + error.set(Some(format!("Invalid handle: {}", e))); + is_sending.set(false); + return; + } + }; + + let invitee_did = match fetcher.resolve_handle(&handle).await { + Ok(did) => did, + Err(e) => { + error.set(Some(format!("Could not resolve handle: {}", e))); + is_sending.set(false); + return; + } + }; + + // Build the resource StrongRef + let cid = match Cid::new(resource_cid.as_bytes()) { + Ok(c) => c.into_static(), + Err(e) => { + error.set(Some(format!("Invalid CID: {}", e))); + is_sending.set(false); + return; + } + }; + + let resource_ref = StrongRef::new().uri(resource_uri).cid(cid).build(); + + let message_opt = if message.is_empty() { + None + } else { + Some(message) + }; + + match create_invite( + &fetcher, + resource_ref, + invitee_did.into_static(), + message_opt, + ) + .await + { + Ok(_uri) => { + // Success - close dialog + handle_input.set(String::new()); + message_input.set(String::new()); + on_close.call(()); + } + Err(e) => { + error.set(Some(format!("Failed to send invite: {}", e))); + } + } + + is_sending.set(false); + }); + }; + + let resource_display = props + .resource_title + .clone() + .unwrap_or_else(|| props.resource_uri.to_string()); + + rsx! { + DialogRoot { + open: props.open, + on_open_change: move |open: bool| { + if !open { + props.on_close.call(()); + } + }, + DialogContent { + DialogTitle { "Invite Collaborator" } + DialogDescription { + "Invite someone to collaborate on {resource_display}" + } + + div { class: "invite-form", + div { class: "form-field", + label { "User handle" } + Input { + value: handle_input(), + placeholder: "user.bsky.social", + oninput: move |e: FormEvent| handle_input.set(e.value()), + } + } + + div { class: "form-field", + label { "Message (optional)" } + textarea { + class: "invite-message", + value: "{message_input}", + placeholder: "Add a message...", + oninput: move |e| message_input.set(e.value()), + rows: 3, + } + } + + if let Some(err) = error() { + div { class: "error-message", "{err}" } + } + + div { class: "dialog-actions", + Button { + variant: ButtonVariant::Primary, + onclick: send_invite, + disabled: is_sending() || handle_input().is_empty(), + if is_sending() { "Sending..." } else { "Send Invite" } + } + Button { + variant: ButtonVariant::Ghost, + onclick: move |_| props.on_close.call(()), + "Cancel" + } + } + } + } + } + } +} diff --git a/crates/weaver-app/src/components/collab/invites_list.rs b/crates/weaver-app/src/components/collab/invites_list.rs new file mode 100644 index 0000000..81b58d3 --- /dev/null +++ b/crates/weaver-app/src/components/collab/invites_list.rs @@ -0,0 +1,196 @@ +//! List of pending collaboration invites. + +use crate::auth::AuthState; +use crate::components::button::{Button, ButtonVariant}; +use crate::fetch::Fetcher; +use dioxus::prelude::*; +use jacquard::IntoStatic; +use jacquard::types::string::{AtUri, Cid}; +use weaver_api::com_atproto::repo::strong_ref::StrongRef; + +use super::api::{ + ReceivedInvite, SentInvite, accept_invite, fetch_received_invites, fetch_sent_invites, +}; + +/// Props for the InvitesList component. +#[derive(Props, Clone, PartialEq)] +pub struct InvitesListProps { + /// Filter to a specific resource (optional). + #[props(default)] + pub resource_uri: Option>, +} + +/// List showing both sent and received invites. +#[component] +pub fn InvitesList(props: InvitesListProps) -> Element { + let auth_state = use_context::>(); + let fetcher = use_context::(); + + let sent_invites = { + let fetcher = fetcher.clone(); + use_resource(move || { + let fetcher = fetcher.clone(); + let _auth = auth_state.read().did.clone(); + async move { fetch_sent_invites(&fetcher).await.ok().unwrap_or_default() } + }) + }; + + let received_invites = { + let fetcher = fetcher.clone(); + use_resource(move || { + let fetcher = fetcher.clone(); + let _auth = auth_state.read().did.clone(); + async move { + fetch_received_invites(&fetcher) + .await + .ok() + .unwrap_or_default() + } + }) + }; + + let filter_uri = props.resource_uri.clone(); + + rsx! { + div { class: "invites-list", + // Received invites section + div { class: "invites-section", + h3 { "Received Invites" } + { + let invites: Vec = received_invites() + .unwrap_or_default() + .into_iter() + .filter(|i| { + filter_uri.as_ref().map_or(true, |uri| &i.resource_uri == uri) + }) + .collect(); + + if invites.is_empty() { + rsx! { p { class: "empty-state", "No pending invites" } } + } else { + rsx! { + for invite in invites { + ReceivedInviteCard { invite: invite.clone() } + } + } + } + } + } + + // Sent invites section + div { class: "invites-section", + h3 { "Sent Invites" } + { + let invites: Vec = sent_invites() + .unwrap_or_default() + .into_iter() + .filter(|i| { + filter_uri.as_ref().map_or(true, |uri| &i.resource_uri == uri) + }) + .collect(); + + if invites.is_empty() { + rsx! { p { class: "empty-state", "No sent invites" } } + } else { + rsx! { + for invite in invites { + SentInviteCard { invite: invite.clone() } + } + } + } + } + } + } + } +} + +/// Card showing a received invite with accept/decline actions. +#[component] +fn ReceivedInviteCard(invite: ReceivedInvite) -> Element { + let fetcher = use_context::(); + let mut is_accepting = use_signal(|| false); + let mut accepted = use_signal(|| false); + let mut error = use_signal(|| None::); + + let invite_uri = invite.uri.clone(); + let invite_cid = invite.cid.clone(); + let resource_uri = invite.resource_uri.clone(); + + let handle_accept = move |_| { + let fetcher = fetcher.clone(); + let invite_uri = invite_uri.clone(); + let invite_cid = invite_cid.clone(); + let resource_uri = resource_uri.clone(); + + spawn(async move { + is_accepting.set(true); + error.set(None); + + let invite_ref = StrongRef::new().uri(invite_uri).cid(invite_cid).build(); + + match accept_invite(&fetcher, invite_ref, resource_uri).await { + Ok(_) => { + accepted.set(true); + } + Err(e) => { + error.set(Some(format!("Failed to accept: {}", e))); + } + } + + is_accepting.set(false); + }); + }; + + rsx! { + div { class: "invite-card", + div { class: "invite-info", + span { class: "invite-from", "From: {invite.inviter}" } + span { class: "invite-resource", "Resource: {invite.resource_uri}" } + if let Some(msg) = &invite.message { + p { class: "invite-message", "{msg}" } + } + } + + if let Some(err) = error() { + div { class: "error-message", "{err}" } + } + + div { class: "invite-actions", + if accepted() { + span { class: "invite-status accepted", "Accepted" } + } else { + Button { + variant: ButtonVariant::Primary, + onclick: handle_accept, + disabled: is_accepting(), + if is_accepting() { "Accepting..." } else { "Accept" } + } + } + } + } + } +} + +/// Card showing a sent invite with status. +#[component] +fn SentInviteCard(invite: SentInvite) -> Element { + rsx! { + div { class: "invite-card", + div { class: "invite-info", + span { class: "invite-to", "To: {invite.invitee}" } + span { class: "invite-resource", "Resource: {invite.resource_uri}" } + if let Some(msg) = &invite.message { + p { class: "invite-message", "{msg}" } + } + } + + div { class: "invite-status", + if invite.accepted { + span { class: "status-badge accepted", "Accepted" } + } else { + span { class: "status-badge pending", "Pending" } + } + } + } + } +} diff --git a/crates/weaver-app/src/components/collab/mod.rs b/crates/weaver-app/src/components/collab/mod.rs new file mode 100644 index 0000000..7b7e314 --- /dev/null +++ b/crates/weaver-app/src/components/collab/mod.rs @@ -0,0 +1,16 @@ +//! Collaboration components for inviting and managing collaborators. + +pub mod api; +mod avatars; +mod collaborators; +mod invite_dialog; +mod invites_list; + +pub use api::{ + accept_invite, create_invite, fetch_received_invites, fetch_sent_invites, AcceptedInvite, + ReceivedInvite, SentInvite, +}; +pub use avatars::CollaboratorAvatars; +pub use collaborators::CollaboratorsPanel; +pub use invite_dialog::InviteDialog; +pub use invites_list::InvitesList; diff --git a/crates/weaver-app/src/components/editor/component.rs b/crates/weaver-app/src/components/editor/component.rs index c19ac42..77e4073 100644 --- a/crates/weaver-app/src/components/editor/component.rs +++ b/crates/weaver-app/src/components/editor/component.rs @@ -11,6 +11,7 @@ use weaver_api::sh_weaver::embed::images::Image; use weaver_common::WeaverExt; use crate::auth::AuthState; +use crate::components::collab::CollaboratorAvatars; use crate::components::editor::ReportButton; use crate::fetch::Fetcher; @@ -711,6 +712,20 @@ fn MarkdownEditorInner( } div { class: "meta-actions", + // Show collaborator avatars when editing an existing entry + if let Some(entry_ref) = document.entry_ref() { + { + let title = document.title(); + rsx! { + CollaboratorAvatars { + resource_uri: entry_ref.uri.clone(), + resource_cid: entry_ref.cid.to_string(), + resource_title: if title.is_empty() { None } else { Some(title) }, + } + } + } + } + SyncStatus { document: document.clone(), draft_key: draft_key.to_string(), diff --git a/crates/weaver-app/src/components/editor/mod.rs b/crates/weaver-app/src/components/editor/mod.rs index b894f2d..17d7481 100644 --- a/crates/weaver-app/src/components/editor/mod.rs +++ b/crates/weaver-app/src/components/editor/mod.rs @@ -60,7 +60,8 @@ pub use storage::{ // Sync #[allow(unused_imports)] pub use sync::{ - load_and_merge_document, load_edit_state_from_pds, sync_to_pds, + load_and_merge_document, load_edit_state_from_pds, sync_to_pds, + list_drafts_from_pds, RemoteDraft, PdsEditState, SyncState, SyncStatus, }; diff --git a/crates/weaver-app/src/components/editor/storage.rs b/crates/weaver-app/src/components/editor/storage.rs index ace433e..a972cda 100644 --- a/crates/weaver-app/src/components/editor/storage.rs +++ b/crates/weaver-app/src/components/editor/storage.rs @@ -3,9 +3,16 @@ //! Stores both human-readable content (for debugging) and the full CRDT //! snapshot (for undo history preservation across sessions). //! -//! Storage key strategy: -//! - New entries: `"draft:new:{uuid}"` -//! - Editing existing: `"draft:{at-uri}"` +//! ## Storage key strategy (localStorage) +//! +//! - New entries: `"new:{tid}"` where tid is a timestamp-based ID +//! - Editing existing: `"{at-uri}"` the full AT-URI of the entry +//! +//! ## PDS canonical format +//! +//! When syncing to PDS via DraftRef, keys are transformed to canonical +//! format: `"{did}:{rkey}"` for discoverability and topic derivation. +//! This transformation happens in sync.rs `build_doc_ref()`. #[cfg(all(target_family = "wasm", target_os = "unknown"))] use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; diff --git a/crates/weaver-app/src/components/editor/sync.rs b/crates/weaver-app/src/components/editor/sync.rs index 6695b81..7908453 100644 --- a/crates/weaver-app/src/components/editor/sync.rs +++ b/crates/weaver-app/src/components/editor/sync.rs @@ -34,6 +34,7 @@ use weaver_api::com_atproto::repo::create_record::CreateRecord; use weaver_api::com_atproto::repo::strong_ref::StrongRef; use weaver_api::com_atproto::sync::get_blob::GetBlob; use weaver_api::sh_weaver::edit::diff::Diff; +use weaver_api::sh_weaver::edit::draft::Draft; use weaver_api::sh_weaver::edit::root::Root; use weaver_api::sh_weaver::edit::{DocRef, DocRefValue, DraftRef, EntryRef}; use weaver_common::constellation::{GetBacklinksQuery, RecordId}; @@ -46,13 +47,18 @@ use loro::LoroDoc; const ROOT_NSID: &str = "sh.weaver.edit.root"; const DIFF_NSID: &str = "sh.weaver.edit.diff"; +const DRAFT_NSID: &str = "sh.weaver.edit.draft"; const CONSTELLATION_URL: &str = "https://constellation.microcosm.blue"; /// Build a DocRef for either a published entry or an unpublished draft. /// /// If entry_uri and entry_cid are provided, creates an EntryRef. -/// Otherwise, creates a DraftRef with the given draft key. +/// Otherwise, creates a DraftRef with a synthetic AT-URI for Constellation indexing. +/// +/// The synthetic URI format is: `at://{did}/sh.weaver.edit.draft/{rkey}` +/// This allows Constellation to index drafts as backlinks, enabling discovery. fn build_doc_ref( + did: &Did<'_>, draft_key: &str, entry_uri: Option<&AtUri<'_>>, entry_cid: Option<&Cid<'_>>, @@ -68,16 +74,59 @@ fn build_doc_ref( })), extra_data: None, }, - _ => DocRef { - value: DocRefValue::DraftRef(Box::new(DraftRef { - draft_key: CowStr::from(draft_key.to_string()), + _ => { + // Transform localStorage key to synthetic AT-URI for Constellation indexing + // localStorage uses "new:{tid}" or AT-URI, PDS uses "at://{did}/sh.weaver.edit.draft/{rkey}" + let rkey = if let Some(tid) = draft_key.strip_prefix("new:") { + // New draft: extract TID as rkey + tid.to_string() + } else if draft_key.starts_with("at://") { + // Editing existing entry: use the entry's rkey + draft_key + .split('/') + .last() + .unwrap_or(draft_key) + .to_string() + } else if draft_key.starts_with("did:") && draft_key.contains(':') { + // Old canonical format "did:xxx:rkey" - extract rkey + draft_key + .rsplit(':') + .next() + .unwrap_or(draft_key) + .to_string() + } else { + // Fallback: use as-is + draft_key.to_string() + }; + + // Build AT-URI pointing to actual draft record: at://{did}/sh.weaver.edit.draft/{rkey} + let canonical_uri = format!("at://{}/{}/{}", did, DRAFT_NSID, rkey); + + DocRef { + value: DocRefValue::DraftRef(Box::new(DraftRef { + draft_key: CowStr::from(canonical_uri), + extra_data: None, + })), extra_data: None, - })), - extra_data: None, - }, + } + } } } +/// Extract (authority, rkey) from a canonical draft key (synthetic AT-URI). +/// +/// Parses `at://{authority}/sh.weaver.edit.draft/{rkey}` and returns the components. +/// Authority can be a DID or handle. +#[allow(dead_code)] +pub fn parse_draft_key( + draft_key: &str, +) -> Option<(jacquard::types::ident::AtIdentifier<'static>, String)> { + let uri = AtUri::new(draft_key).ok()?; + let authority = uri.authority().clone().into_static(); + let rkey = uri.rkey()?.0.as_str().to_string(); + Some((authority, rkey)) +} + /// Result of a sync operation. #[derive(Clone, Debug)] pub enum SyncResult { @@ -129,6 +178,186 @@ pub async fn find_edit_root_for_entry( Ok(output.records.into_iter().next().map(|r| r.into_static())) } +/// Find the edit root for a draft using constellation backlinks. +/// +/// Queries constellation for `sh.weaver.edit.root` records that reference +/// the given draft URI via the `.doc.value.draft_key` path. +/// +/// The draft_uri should be in canonical format: `at://{did}/sh.weaver.edit.draft/{rkey}` +pub async fn find_edit_root_for_draft( + fetcher: &Fetcher, + draft_uri: &AtUri<'_>, +) -> Result>, WeaverError> { + let constellation_url = Url::parse(CONSTELLATION_URL) + .map_err(|e| WeaverError::InvalidNotebook(format!("Invalid constellation URL: {}", e)))?; + + let query = GetBacklinksQuery { + subject: Uri::At(draft_uri.clone().into_static()), + source: format!("{}:.doc.value.draft_key", ROOT_NSID).into(), + cursor: None, + did: vec![], + limit: 1, + }; + + let response = fetcher + .client + .xrpc(constellation_url) + .send(&query) + .await + .map_err(|e| WeaverError::InvalidNotebook(format!("Constellation query failed: {}", e)))?; + + let output = response.into_output().map_err(|e| { + WeaverError::InvalidNotebook(format!("Failed to parse constellation response: {}", e)) + })?; + + Ok(output.records.into_iter().next().map(|r| r.into_static())) +} + +/// Build a canonical draft URI from localStorage key and DID. +/// +/// Transforms localStorage format ("new:{tid}" or AT-URI) to +/// draft record URI format: `at://{did}/sh.weaver.edit.draft/{rkey}` +pub fn build_draft_uri(did: &Did<'_>, draft_key: &str) -> AtUri<'static> { + let rkey = if let Some(tid) = draft_key.strip_prefix("new:") { + tid.to_string() + } else if draft_key.starts_with("at://") { + draft_key + .split('/') + .last() + .unwrap_or(draft_key) + .to_string() + } else { + draft_key.to_string() + }; + + let uri_str = format!("at://{}/{}/{}", did, DRAFT_NSID, rkey); + // Safe to unwrap: we're constructing a valid AT-URI + AtUri::new(&uri_str).unwrap().into_static() +} + +/// Extract the rkey (TID) from a localStorage draft key. +fn extract_draft_rkey(draft_key: &str) -> String { + if let Some(tid) = draft_key.strip_prefix("new:") { + tid.to_string() + } else if draft_key.starts_with("at://") { + draft_key + .split('/') + .last() + .unwrap_or(draft_key) + .to_string() + } else { + draft_key.to_string() + } +} + +/// Create the draft stub record on PDS. +/// +/// This creates a minimal `sh.weaver.edit.draft` record that acts as an anchor +/// for edit.root/diff records and enables draft discovery via listRecords. +async fn create_draft_stub( + fetcher: &Fetcher, + did: &Did<'_>, + rkey: &str, +) -> Result<(AtUri<'static>, Cid<'static>), WeaverError> { + // Build minimal draft record with just createdAt + let draft = Draft::new() + .created_at(jacquard::types::datetime::Datetime::now()) + .build(); + + let draft_data = to_data(&draft) + .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to serialize draft: {}", e)))?; + + let record_key = RecordKey::any(rkey) + .map_err(|e| WeaverError::InvalidNotebook(e.to_string()))?; + + let collection = Nsid::new(DRAFT_NSID).map_err(WeaverError::AtprotoString)?; + + let request = CreateRecord::new() + .repo(AtIdentifier::Did(did.clone().into_static())) + .collection(collection) + .rkey(record_key) + .record(draft_data) + .build(); + + let response = fetcher + .send(request) + .await + .map_err(jacquard::client::AgentError::from)?; + + let output = response + .into_output() + .map_err(|e| WeaverError::InvalidNotebook(e.to_string()))?; + + Ok((output.uri.into_static(), output.cid.into_static())) +} + +/// Remote draft info from PDS. +#[derive(Clone, Debug)] +pub struct RemoteDraft { + /// The draft record URI + pub uri: AtUri<'static>, + /// The rkey (TID) of the draft + pub rkey: String, + /// When the draft was created + pub created_at: String, +} + +/// List all drafts from PDS for the current user. +/// +/// Returns a list of draft records from `sh.weaver.edit.draft` collection. +pub async fn list_drafts_from_pds(fetcher: &Fetcher) -> Result, WeaverError> { + use weaver_api::com_atproto::repo::list_records::ListRecords; + + let did = fetcher + .current_did() + .await + .ok_or_else(|| WeaverError::InvalidNotebook("Not authenticated".into()))?; + + let client = fetcher.get_client(); + let collection = Nsid::new(DRAFT_NSID).map_err(WeaverError::AtprotoString)?; + + let request = ListRecords::new() + .repo(did) + .collection(collection) + .limit(100) + .build(); + + let response = client + .send(request) + .await + .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to list drafts: {}", e)))?; + + let output = response.into_output().map_err(|e| { + WeaverError::InvalidNotebook(format!("Failed to parse list records response: {}", e)) + })?; + + tracing::debug!("list_drafts_from_pds: found {} records", output.records.len()); + + let mut drafts = Vec::new(); + for record in output.records { + let rkey = record + .uri + .rkey() + .map(|r| r.0.as_str().to_string()) + .unwrap_or_default(); + + tracing::debug!(" Draft record: uri={}, rkey={}", record.uri, rkey); + + // Parse the draft record to get createdAt + let created_at = jacquard::from_data::(&record.value) + .map(|d| d.created_at.to_string()) + .unwrap_or_default(); + + drafts.push(RemoteDraft { + uri: record.uri.into_static(), + rkey, + created_at, + }); + } + + Ok(drafts) +} + /// Find all diffs for a root record using constellation backlinks. #[allow(dead_code)] pub async fn find_diffs_for_root( @@ -178,6 +407,8 @@ pub async fn find_diffs_for_root( /// /// Uploads the current Loro snapshot as a blob and creates an `sh.weaver.edit.root` /// record referencing the entry (or draft key if unpublished). +/// +/// For drafts, also creates the `sh.weaver.edit.draft` stub record first. pub async fn create_edit_root( fetcher: &Fetcher, doc: &EditorDocument, @@ -191,6 +422,24 @@ pub async fn create_edit_root( .await .ok_or_else(|| WeaverError::InvalidNotebook("Not authenticated".into()))?; + // For drafts, create the stub record first (makes it discoverable via listRecords) + if entry_uri.is_none() { + let rkey = extract_draft_rkey(draft_key); + // Try to create draft stub, ignore if it already exists + match create_draft_stub(fetcher, &did, &rkey).await { + Ok((uri, _cid)) => { + tracing::debug!("Created draft stub: {}", uri); + } + Err(e) => { + // Check if it's a "record already exists" error - that's fine + let err_str = e.to_string(); + if !err_str.contains("RecordAlreadyExists") && !err_str.contains("already exists") { + tracing::warn!("Failed to create draft stub (continuing anyway): {}", e); + } + } + } + } + // Export full snapshot let snapshot = doc.export_snapshot(); @@ -202,7 +451,7 @@ pub async fn create_edit_root( .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to upload snapshot: {}", e)))?; // Build DocRef - use EntryRef if published, DraftRef if not - let doc_ref = build_doc_ref(draft_key, entry_uri, entry_cid); + let doc_ref = build_doc_ref(&did, draft_key, entry_uri, entry_cid); // Build root record let root = Root::new().doc(doc_ref).snapshot(blob_ref).build(); @@ -277,7 +526,7 @@ pub async fn create_diff( }; // Build DocRef - use EntryRef if published, DraftRef if not - let doc_ref = build_doc_ref(draft_key, entry_uri, entry_cid); + let doc_ref = build_doc_ref(&did, draft_key, entry_uri, entry_cid); // Build root reference let root_ref = StrongRef::new() @@ -464,6 +713,31 @@ pub async fn load_edit_state_from_pds( None => return Ok(None), }; + load_edit_state_from_root_id(fetcher, root_id).await +} + +/// Load edit state from the PDS for a draft. +/// +/// Finds the edit root via constellation backlinks using the draft URI, +/// fetches all diffs, and returns the snapshot + updates. +pub async fn load_edit_state_from_draft( + fetcher: &Fetcher, + draft_uri: &AtUri<'_>, +) -> Result, WeaverError> { + // Find the edit root for this draft + let root_id = match find_edit_root_for_draft(fetcher, draft_uri).await? { + Some(id) => id, + None => return Ok(None), + }; + + load_edit_state_from_root_id(fetcher, root_id).await +} + +/// Internal helper to load edit state given a root record ID. +async fn load_edit_state_from_root_id( + fetcher: &Fetcher, + root_id: RecordId<'static>, +) -> Result, WeaverError> { // Build root URI let root_uri = AtUri::new(&format!( "at://{}/{}/{}", @@ -591,6 +865,9 @@ pub async fn load_edit_state_from_pds( /// Loads from localStorage and PDS (if available), then merges both using Loro's /// CRDT merge. The result is a pre-merged LoroDoc that can be converted to an /// EditorDocument inside a reactive context using `use_hook`. +/// +/// For unpublished drafts, attempts to discover edit state via Constellation +/// using the synthetic draft URI. pub async fn load_and_merge_document( fetcher: &Fetcher, draft_key: &str, @@ -601,10 +878,16 @@ pub async fn load_and_merge_document( // Load snapshot + entry_ref from localStorage let local_data = load_snapshot_from_storage(draft_key); - // Load from PDS (only if we have an entry URI) + // Load from PDS - try entry URI first, then draft discovery let pds_state = if let Some(uri) = entry_uri { + // Published entry: query by entry URI load_edit_state_from_pds(fetcher, uri).await? + } else if let Some(did) = fetcher.current_did().await { + // Unpublished draft: try to discover via draft URI + let draft_uri = build_draft_uri(&did, draft_key); + load_edit_state_from_draft(fetcher, &draft_uri).await? } else { + // Not authenticated, can't query PDS None }; @@ -761,9 +1044,8 @@ pub fn SyncStatus(props: SyncStatusProps) -> Element { let doc = props.document.clone(); let draft_key = props.draft_key.clone(); - // Check if we're authenticated and have an entry to sync + // Check if we're authenticated (drafts can sync via DraftRef even without entry) let is_authenticated = auth_state.read().is_authenticated(); - let has_entry = doc.entry_ref().is_some(); // Auto-sync trigger signal - set to true to trigger a sync let mut trigger_sync = use_signal(|| false); @@ -834,8 +1116,8 @@ pub fn SyncStatus(props: SyncStatusProps) -> Element { return; } - // Check if authenticated and has entry - if !is_authenticated || !has_entry { + // Check if authenticated (drafts can sync too via DraftRef) + if !is_authenticated { return; } @@ -889,11 +1171,9 @@ pub fn SyncStatus(props: SyncStatusProps) -> Element { trigger_sync.set(true); }; - // Determine display state + // Determine display state (drafts can sync too via DraftRef) let display_state = if !is_authenticated { SyncState::Disabled - } else if !has_entry { - SyncState::Disabled // Can't sync unpublished entries } else { *sync_state.read() }; diff --git a/crates/weaver-app/src/components/entry.rs b/crates/weaver-app/src/components/entry.rs index abea2d1..93e02d4 100644 --- a/crates/weaver-app/src/components/entry.rs +++ b/crates/weaver-app/src/components/entry.rs @@ -398,12 +398,22 @@ pub fn EntryCard( .format("%B %d, %Y") .to_string(); - // Check ownership - let is_owner = { + // Check edit access via permissions + let can_edit = { let current_did = auth_state.read().did.clone(); - match (¤t_did, &ident) { - (Some(did), AtIdentifier::Did(ident_did)) => *did == *ident_did, - _ => false, + match ¤t_did { + Some(did) => { + if let Some(ref perms) = entry_view.permissions { + perms.editors.iter().any(|grant| grant.did == *did) + } else { + // Fall back to ownership check + match &ident { + AtIdentifier::Did(ident_did) => *did == *ident_did, + _ => false, + } + } + } + None => false, } }; @@ -441,13 +451,14 @@ pub fn EntryCard( div { class: "entry-card-date", time { datetime: "{entry_view.indexed_at.as_str()}", "{formatted_date}" } } - if is_owner { + if can_edit { EntryActions { entry_uri, entry_cid: entry_view.cid.clone().into_static(), entry_title: title.to_string(), in_notebook: true, notebook_title: Some(book_title.clone()), + permissions: entry_view.permissions.clone(), on_removed: Some(EventHandler::new(move |_| hidden.set(true))) } } @@ -568,13 +579,23 @@ pub fn FeedEntryCard( // Get first author if we're showing it let first_author = if show_author { entry_view.authors.first() } else { None }; - // Check ownership for actions + // Check edit access via permissions let auth_state = use_context::>(); - let is_owner = { + let can_edit = { let current_did = auth_state.read().did.clone(); - match (¤t_did, &ident) { - (Some(did), AtIdentifier::Did(ident_did)) => *did == *ident_did, - _ => false, + match ¤t_did { + Some(did) => { + if let Some(ref perms) = entry_view.permissions { + perms.editors.iter().any(|grant| grant.did == *did) + } else { + // Fall back to ownership check + match &ident { + AtIdentifier::Did(ident_did) => *did == *ident_did, + _ => false, + } + } + } + None => false, } }; @@ -604,13 +625,14 @@ pub fn FeedEntryCard( time { datetime: "{entry.created_at.as_str()}", "{formatted_date}" } } } - if show_actions && is_owner { + if show_actions && can_edit { crate::components::EntryActions { entry_uri: entry_view.uri.clone().into_static(), entry_cid: entry_view.cid.clone().into_static(), entry_title: title.to_string(), in_notebook: false, is_pinned, + permissions: entry_view.permissions.clone(), on_pinned_changed } } @@ -739,6 +761,7 @@ pub fn EntryMetadata( entry_title, in_notebook: book_title.is_some(), notebook_title: book_title.clone(), + permissions: entry_view.permissions.clone(), on_removed: Some(EventHandler::new(on_removed)) } } diff --git a/crates/weaver-app/src/components/entry_actions.rs b/crates/weaver-app/src/components/entry_actions.rs index 0d6d0f3..d8910f5 100644 --- a/crates/weaver-app/src/components/entry_actions.rs +++ b/crates/weaver-app/src/components/entry_actions.rs @@ -15,6 +15,7 @@ use weaver_api::com_atproto::repo::delete_record::DeleteRecord; use weaver_api::com_atproto::repo::put_record::PutRecord; use weaver_api::com_atproto::repo::strong_ref::StrongRef; use weaver_api::sh_weaver::actor::profile::Profile as WeaverProfile; +use weaver_api::sh_weaver::notebook::PermissionsState; const ENTRY_ACTIONS_CSS: Asset = asset!("/assets/styling/entry-actions.css"); @@ -35,6 +36,9 @@ pub struct EntryActionsProps { /// Whether this entry is currently pinned #[props(default = false)] pub is_pinned: bool, + /// Permissions state for edit access checking (if available) + #[props(default)] + pub permissions: Option>, /// Callback when entry is removed from notebook (for optimistic UI update) #[props(default)] pub on_removed: Option>, @@ -58,15 +62,25 @@ pub fn EntryActions(props: EntryActionsProps) -> Element { let mut pinning = use_signal(|| false); let mut error = use_signal(|| None::); - // Check ownership - compare auth DID with entry's authority + // Check edit access - use permissions if available, fall back to ownership check let current_did = auth_state.read().did.clone(); - let entry_authority = props.entry_uri.authority(); - let is_owner = match (¤t_did, entry_authority) { - (Some(current), AtIdentifier::Did(entry_did)) => *current == *entry_did, - _ => false, + let can_edit = match ¤t_did { + Some(did) => { + if let Some(ref perms) = props.permissions { + // Use ACL-based permissions + perms.editors.iter().any(|grant| grant.did == *did) + } else { + // Fall back to ownership check + match props.entry_uri.authority() { + AtIdentifier::Did(entry_did) => *did == *entry_did, + _ => false, + } + } + } + None => false, }; - if !is_owner { + if !can_edit { return rsx! {}; } diff --git a/crates/weaver-app/src/components/identity.rs b/crates/weaver-app/src/components/identity.rs index 4d7e8e0..ca03052 100644 --- a/crates/weaver-app/src/components/identity.rs +++ b/crates/weaver-app/src/components/identity.rs @@ -90,11 +90,22 @@ pub fn RepositoryIndex(ident: ReadSignal>) -> Element { use jacquard::from_data; use weaver_api::sh_weaver::notebook::book::Book; + let auth_state = use_context::>(); + // Use client-only versions to avoid SSR issues with concurrent server futures let (_profile_res, profile) = data::use_profile_data_client(ident); let (_notebooks_res, notebooks) = data::use_notebooks_for_did_client(ident); let (_entries_res, all_entries) = data::use_entries_for_did_client(ident); + // Check if viewing own profile + let is_own_profile = use_memo(move || { + let current_did = auth_state.read().did.clone(); + match (¤t_did, ident()) { + (Some(did), AtIdentifier::Did(profile_did)) => *did == profile_did, + _ => false, + } + }); + // Extract pinned URIs from profile (only Weaver ProfileView has pinned) let pinned_uris = use_memo(move || { use jacquard::IntoStatic; @@ -320,7 +331,7 @@ pub fn RepositoryIndex(ident: ReadSignal>) -> Element { div { class: "repository-layout", // Profile sidebar (desktop) / header (mobile) aside { class: "repository-sidebar", - ProfileDisplay { profile, notebooks, entry_count: *entry_count.read() } + ProfileDisplay { profile, notebooks, entry_count: *entry_count.read(), is_own_profile: is_own_profile() } } // Main content area @@ -622,14 +633,14 @@ pub fn NotebookCard( if let Some(ref date) = created_at { div { class: "entry-preview-date", "{date}" } } - if is_owner { - crate::components::EntryActions { - entry_uri, - entry_cid: entry_view.entry.cid.clone().into_static(), - entry_title: entry_title.to_string(), - in_notebook: true, - notebook_title: Some(book_title.clone()) - } + // EntryActions handles visibility via permissions + crate::components::EntryActions { + entry_uri, + entry_cid: entry_view.entry.cid.clone().into_static(), + entry_title: entry_title.to_string(), + in_notebook: true, + notebook_title: Some(book_title.clone()), + permissions: entry_view.entry.permissions.clone() } } if let Some(ref html) = preview_html { @@ -693,14 +704,14 @@ pub fn NotebookCard( if let Some(ref date) = created_at { div { class: "entry-preview-date", "{date}" } } - if is_owner { - crate::components::EntryActions { - entry_uri, - entry_cid: first_entry.entry.cid.clone().into_static(), - entry_title: entry_title.to_string(), - in_notebook: true, - notebook_title: Some(book_title.clone()) - } + // EntryActions handles visibility via permissions + crate::components::EntryActions { + entry_uri, + entry_cid: first_entry.entry.cid.clone().into_static(), + entry_title: entry_title.to_string(), + in_notebook: true, + notebook_title: Some(book_title.clone()), + permissions: first_entry.entry.permissions.clone() } } if let Some(ref html) = preview_html { @@ -773,14 +784,14 @@ pub fn NotebookCard( if let Some(ref date) = created_at { div { class: "entry-preview-date", "{date}" } } - if is_owner { - crate::components::EntryActions { - entry_uri, - entry_cid: last_entry.entry.cid.clone().into_static(), - entry_title: entry_title.to_string(), - in_notebook: true, - notebook_title: Some(book_title.clone()) - } + // EntryActions handles visibility via permissions + crate::components::EntryActions { + entry_uri, + entry_cid: last_entry.entry.cid.clone().into_static(), + entry_title: entry_title.to_string(), + in_notebook: true, + notebook_title: Some(book_title.clone()), + permissions: last_entry.entry.permissions.clone() } } if let Some(ref html) = preview_html { diff --git a/crates/weaver-app/src/components/mod.rs b/crates/weaver-app/src/components/mod.rs index 1f50818..e67613d 100644 --- a/crates/weaver-app/src/components/mod.rs +++ b/crates/weaver-app/src/components/mod.rs @@ -28,6 +28,9 @@ pub mod login; pub mod record_editor; pub mod record_view; +pub mod collab; +pub use collab::{CollaboratorAvatars, CollaboratorsPanel, InviteDialog, InvitesList}; + use dioxus::prelude::*; #[derive(PartialEq, Props, Clone)] diff --git a/crates/weaver-app/src/components/profile.rs b/crates/weaver-app/src/components/profile.rs index 72acd69..edc4a3a 100644 --- a/crates/weaver-app/src/components/profile.rs +++ b/crates/weaver-app/src/components/profile.rs @@ -2,10 +2,14 @@ use std::sync::Arc; +use crate::Route; +use crate::components::button::{Button, ButtonVariant}; +use crate::components::collab::api::{ReceivedInvite, accept_invite, fetch_received_invites}; use crate::components::{ BskyIcon, TangledIcon, avatar::{Avatar, AvatarImage}, }; +use crate::fetch::Fetcher; use dioxus::prelude::*; use weaver_api::com_atproto::repo::strong_ref::StrongRef; use weaver_api::sh_weaver::actor::{ProfileDataView, ProfileDataViewInner}; @@ -18,6 +22,7 @@ pub fn ProfileDisplay( profile: Memo>>, notebooks: Memo, Vec>)>>>, #[props(default)] entry_count: usize, + #[props(default)] is_own_profile: bool, ) -> Element { match &*profile.read() { Some(profile_view) => { @@ -63,9 +68,12 @@ pub fn ProfileDisplay( // Links ProfileLinks { profile_view } - } - + // Invites (only on own profile) + if is_own_profile { + ProfileInvites {} + } + } } } } @@ -322,3 +330,140 @@ fn ProfileLinks(profile_view: Arc>) -> Element { _ => rsx! {}, } } + +/// Shows pending collaboration invites on the user's own profile. +#[component] +fn ProfileInvites() -> Element { + let fetcher = use_context::(); + + // Fetch received invites + let invites_resource = { + let fetcher = fetcher.clone(); + use_resource(move || { + let fetcher = fetcher.clone(); + async move { + fetch_received_invites(&fetcher) + .await + .ok() + .unwrap_or_default() + } + }) + }; + + let invites: Vec = invites_resource().unwrap_or_default(); + + // Don't render section if no invites + if invites.is_empty() { + return rsx! {}; + } + + rsx! { + div { class: "profile-invites", + h3 { class: "profile-invites-header", "Collaboration Invites" } + + div { class: "profile-invites-list", + for invite in invites { + ProfileInviteCard { invite } + } + } + } + } +} + +/// A single invite card in the profile sidebar. +#[component] +fn ProfileInviteCard(invite: ReceivedInvite) -> Element { + let fetcher = use_context::(); + let nav = use_navigator(); + let mut is_accepting = use_signal(|| false); + let mut accepted = use_signal(|| false); + let mut error = use_signal(|| None::); + + let invite_uri = invite.uri.clone(); + let invite_cid = invite.cid.clone(); + let resource_uri = invite.resource_uri.clone(); + let resource_uri_nav = invite.resource_uri.clone(); + + let handle_accept = move |_| { + let fetcher = fetcher.clone(); + let invite_uri = invite_uri.clone(); + let invite_cid = invite_cid.clone(); + let resource_uri = resource_uri.clone(); + let resource_uri_nav = resource_uri_nav.clone(); + + spawn(async move { + is_accepting.set(true); + error.set(None); + + let invite_ref = StrongRef::new().uri(invite_uri).cid(invite_cid).build(); + + match accept_invite(&fetcher, invite_ref, resource_uri).await { + Ok(_) => { + accepted.set(true); + // Navigate to the resource after a short delay + #[cfg(target_arch = "wasm32")] + { + use gloo_timers::future::TimeoutFuture; + TimeoutFuture::new(500).await; + } + // Navigate to the entry - parse AT-URI into path segments + // at://did/collection/rkey -> ["did", "collection", "rkey"] + let uri_str = resource_uri_nav.to_string(); + let uri_parts: Vec = uri_str + .strip_prefix("at://") + .unwrap_or(&uri_str) + .split('/') + .map(|s| s.to_string()) + .collect(); + nav.push(Route::RecordPage { uri: uri_parts }); + } + Err(e) => { + error.set(Some(format!("Failed: {}", e))); + } + } + + is_accepting.set(false); + }); + }; + + // Extract inviter display (last part of DID for now) + let inviter_display = invite + .inviter + .as_ref() + .split(':') + .last() + .unwrap_or("unknown") + .chars() + .take(12) + .collect::(); + + rsx! { + div { class: "profile-invite-card", + div { class: "profile-invite-from", + "From: " + span { class: "profile-invite-did", "{inviter_display}…" } + } + + if let Some(msg) = &invite.message { + p { class: "profile-invite-message", "{msg}" } + } + + if let Some(err) = error() { + div { class: "profile-invite-error", "{err}" } + } + + div { class: "profile-invite-actions", + if accepted() { + span { class: "profile-invite-accepted", "Accepted ✓" } + } else { + Button { + variant: ButtonVariant::Primary, + onclick: handle_accept, + disabled: is_accepting(), + if is_accepting() { "Accepting..." } else { "Accept" } + } + } + } + } + } +} diff --git a/crates/weaver-app/src/components/profile_actions.rs b/crates/weaver-app/src/components/profile_actions.rs index 98126b9..793575d 100644 --- a/crates/weaver-app/src/components/profile_actions.rs +++ b/crates/weaver-app/src/components/profile_actions.rs @@ -56,6 +56,15 @@ pub fn ProfileActions(ident: ReadSignal>) -> Element { "Drafts" } } + + Link { + to: Route::InvitesPage { ident: ident() }, + class: "profile-action-link", + Button { + variant: ButtonVariant::Ghost, + "Invites" + } + } } } } @@ -96,6 +105,14 @@ pub fn ProfileActionsMenubar(ident: ReadSignal>) -> Elemen "Drafts" } } + + Link { + to: Route::InvitesPage { ident: ident() }, + Button { + variant: ButtonVariant::Ghost, + "Invites" + } + } } } } diff --git a/crates/weaver-app/src/data.rs b/crates/weaver-app/src/data.rs index 6a2616b..9abe232 100644 --- a/crates/weaver-app/src/data.rs +++ b/crates/weaver-app/src/data.rs @@ -1180,6 +1180,106 @@ pub fn use_is_owner_async( }) } +// ============================================================================ +// Edit Access Checking (Ownership + Collaboration) +// ============================================================================ + +use weaver_api::sh_weaver::actor::ProfileDataViewInner; +use weaver_api::sh_weaver::notebook::{AuthorListView, PermissionsState}; + +/// Extract DID from a ProfileDataView by matching on the inner variant. +pub fn extract_did_from_author(author: &AuthorListView<'_>) -> Option> { + match &author.record.inner { + ProfileDataViewInner::ProfileView(p) => Some(p.did.clone().into_static()), + ProfileDataViewInner::ProfileViewDetailed(p) => Some(p.did.clone().into_static()), + ProfileDataViewInner::TangledProfileView(p) => Some(p.did.clone().into_static()), + _ => None, + } +} + +/// Check if the current user can edit a resource based on the permissions state. +/// +/// Returns a memo that is: +/// - `Some(true)` if the user is authenticated and their DID is in permissions.editors +/// - `Some(false)` if the user is authenticated but not in editors +/// - `None` if the user is not authenticated or permissions not yet loaded +/// +/// This checks the ACL-based permissions (who CAN edit), not authors (who contributed). +pub fn use_can_edit( + permissions: Memo>>, +) -> Memo> { + let auth_state = use_context::>(); + + use_memo(move || { + let current_did = auth_state.read().did.clone()?; + let perms = permissions()?; + + // Check if current user's DID is in the editors list + let can_edit = perms + .editors + .iter() + .any(|grant| grant.did == current_did); + + Some(can_edit) + }) +} + +/// Legacy: Check if the current user can edit based on authors list. +/// +/// Use `use_can_edit` with permissions instead when available. +/// This is kept for backwards compatibility during transition. +pub fn use_can_edit_from_authors( + authors: Memo>>, +) -> Memo> { + let auth_state = use_context::>(); + + use_memo(move || { + let current_did = auth_state.read().did.clone()?; + let author_list = authors(); + + let can_edit = author_list + .iter() + .filter_map(extract_did_from_author) + .any(|did| did == current_did); + + Some(can_edit) + }) +} + +/// Check edit access for a resource URI using the WeaverExt trait methods. +/// +/// This performs an async check that queries Constellation for collaboration records. +/// Use this when you have a resource URI but not the pre-populated authors list. +pub fn use_can_edit_resource( + resource_uri: ReadSignal>, +) -> Resource> { + let auth_state = use_context::>(); + let fetcher = use_context::(); + + use_resource(move || { + let fetcher = fetcher.clone(); + let uri = resource_uri(); + async move { + use weaver_common::agent::WeaverExt; + + let current_did = auth_state.read().did.clone()?; + + // Check ownership first (fast path) + if let AtIdentifier::Did(owner_did) = uri.authority() { + if *owner_did == current_did { + return Some(true); + } + } + + // Check collaboration via Constellation + match fetcher.can_user_edit_resource(&uri, ¤t_did).await { + Ok(can_edit) => Some(can_edit), + Err(_) => Some(false), + } + } + }) +} + // ============================================================================ // Standalone Entry by Rkey Hooks // ============================================================================ diff --git a/crates/weaver-app/src/env.rs b/crates/weaver-app/src/env.rs index f8a1ad7..b8c902b 100644 --- a/crates/weaver-app/src/env.rs +++ b/crates/weaver-app/src/env.rs @@ -1,11 +1,11 @@ // This file is automatically generated by build.rs #[allow(unused)] -pub const WEAVER_APP_ENV: &'static str = "prod"; +pub const WEAVER_APP_ENV: &'static str = "dev"; #[allow(unused)] -pub const WEAVER_APP_HOST: &'static str = "https://alpha.weaver.sh"; +pub const WEAVER_APP_HOST: &'static str = "http://localhost"; #[allow(unused)] -pub const WEAVER_APP_DOMAIN: &'static str = "https://alpha.weaver.sh"; +pub const WEAVER_APP_DOMAIN: &'static str = ""; #[allow(unused)] pub const WEAVER_PORT: &'static str = "8080"; #[allow(unused)] @@ -13,7 +13,7 @@ pub const WEAVER_APP_SCOPES: &'static str = "atproto transition:generic"; #[allow(unused)] pub const WEAVER_CLIENT_NAME: &'static str = "Weaver"; #[allow(unused)] -pub const WEAVER_LOGO_URI: &'static str = "https://alpha.weaver.sh/favicon.ico"; +pub const WEAVER_LOGO_URI: &'static str = ""; #[allow(unused)] pub const WEAVER_TOS_URI: &'static str = ""; #[allow(unused)] diff --git a/crates/weaver-app/src/main.rs b/crates/weaver-app/src/main.rs index cbde60b..856d552 100644 --- a/crates/weaver-app/src/main.rs +++ b/crates/weaver-app/src/main.rs @@ -17,9 +17,9 @@ use jacquard::{ use std::sync::{Arc, LazyLock}; #[allow(unused)] use views::{ - Callback, DraftEdit, DraftsList, Editor, Home, Navbar, NewDraft, Notebook, NotebookEntryByRkey, - NotebookEntryEdit, NotebookIndex, NotebookPage, RecordIndex, RecordPage, StandaloneEntry, - StandaloneEntryEdit, + Callback, DraftEdit, DraftsList, Editor, Home, InvitesPage, Navbar, NewDraft, Notebook, + NotebookEntryByRkey, NotebookEntryEdit, NotebookIndex, NotebookPage, RecordIndex, RecordPage, + StandaloneEntry, StandaloneEntryEdit, }; use crate::{ @@ -80,6 +80,9 @@ enum Route { DraftEdit { ident: AtIdentifier<'static>, tid: SmolStr }, #[route("/new?:notebook")] NewDraft { ident: AtIdentifier<'static>, notebook: Option }, + // Collaboration invites + #[route("/invites")] + InvitesPage { ident: AtIdentifier<'static> }, // Standalone entry routes #[route("/e/:rkey")] StandaloneEntry { ident: AtIdentifier<'static>, rkey: SmolStr }, diff --git a/crates/weaver-app/src/views/drafts.rs b/crates/weaver-app/src/views/drafts.rs index 0f8226b..043132e 100644 --- a/crates/weaver-app/src/views/drafts.rs +++ b/crates/weaver-app/src/views/drafts.rs @@ -4,22 +4,48 @@ use crate::Route; use crate::auth::AuthState; use crate::components::button::{Button, ButtonVariant}; use crate::components::dialog::{DialogContent, DialogDescription, DialogRoot, DialogTitle}; +use crate::components::editor::{list_drafts_from_pds, RemoteDraft}; use crate::components::editor::{delete_draft, list_drafts}; +use crate::fetch::Fetcher; use dioxus::prelude::*; use jacquard::smol_str::SmolStr; use jacquard::types::ident::AtIdentifier; +use std::collections::HashSet; const DRAFTS_CSS: Asset = asset!("/assets/styling/drafts.css"); +/// Merged draft entry showing both local and remote state. +#[derive(Clone, Debug, PartialEq)] +struct MergedDraft { + /// The rkey/tid of the draft + rkey: String, + /// Title from local storage (if available) + title: String, + /// Whether this draft exists locally + is_local: bool, + /// Whether this draft exists on PDS + is_remote: bool, + /// If editing an existing entry, the URI + editing_uri: Option, +} + /// Drafts list page - shows all drafts for the authenticated user. #[component] pub fn DraftsList(ident: ReadSignal>) -> Element { // ALL hooks must be called unconditionally at the top let auth_state = use_context::>(); + let fetcher = use_context::(); let navigator = use_navigator(); - let mut drafts = use_signal(list_drafts); + let mut local_drafts = use_signal(list_drafts); let mut show_delete_confirm = use_signal(|| None::); + // Fetch remote drafts from PDS (depends on auth state to re-run when logged in) + let remote_drafts_resource = use_resource(move || { + let fetcher = fetcher.clone(); + let _did = auth_state.read().did.clone(); // Track auth state for reactivity + async move { list_drafts_from_pds(&fetcher).await.ok().unwrap_or_default() } + }); + // Check ownership - redirect if not viewing own drafts let current_did = auth_state.read().did.clone(); let is_owner = match (¤t_did, ident()) { @@ -41,9 +67,72 @@ pub fn DraftsList(ident: ReadSignal>) -> Element { return rsx! { div { "Redirecting..." } }; } + // Merge local and remote drafts + let merged_drafts = use_memo(move || { + let local = local_drafts(); + let remote: Vec = remote_drafts_resource().unwrap_or_default(); + + tracing::debug!("Merging drafts: {} local, {} remote", local.len(), remote.len()); + for (key, _, _) in &local { + tracing::debug!(" Local draft key: {}", key); + } + for rd in &remote { + tracing::debug!(" Remote draft rkey: {}", rd.rkey); + } + + // Build set of remote rkeys for quick lookup + let remote_rkeys: HashSet = remote.iter().map(|d| d.rkey.clone()).collect(); + + // Build set of local rkeys + let local_rkeys: HashSet = local + .iter() + .map(|(key, _, _)| { + key.strip_prefix("new:").unwrap_or(key).to_string() + }) + .collect(); + + let mut merged = Vec::new(); + + // Add local drafts + for (key, title, editing_uri) in &local { + let rkey = key.strip_prefix("new:").unwrap_or(key).to_string(); + merged.push(MergedDraft { + rkey: rkey.clone(), + title: title.clone(), + is_local: true, + is_remote: remote_rkeys.contains(&rkey), + editing_uri: editing_uri.clone(), + }); + } + + // Add remote-only drafts + for remote_draft in &remote { + if !local_rkeys.contains(&remote_draft.rkey) { + tracing::info!("Adding remote-only draft: {}", remote_draft.rkey); + merged.push(MergedDraft { + rkey: remote_draft.rkey.clone(), + title: String::new(), // No local title available + is_local: false, + is_remote: true, + editing_uri: None, + }); + } + } + + // Sort by rkey (which is a TID, so newer drafts first) + merged.sort_by(|a, b| b.rkey.cmp(&a.rkey)); + + tracing::info!("Merged {} drafts total", merged.len()); + for m in &merged { + tracing::info!(" Merged: rkey={} is_local={} is_remote={}", m.rkey, m.is_local, m.is_remote); + } + + merged + }); + let mut handle_delete = move |key: String| { delete_draft(&key); - drafts.set(list_drafts()); + local_drafts.set(list_drafts()); show_delete_confirm.set(None); }; @@ -63,46 +152,63 @@ pub fn DraftsList(ident: ReadSignal>) -> Element { } } - if drafts().is_empty() { + if merged_drafts().is_empty() { div { class: "drafts-empty", p { "No drafts yet." } p { "Start writing something new!" } } } else { div { class: "drafts-list", - for (key, title, editing_uri) in drafts() { + for draft in merged_drafts() { { - let key_for_delete = key.clone(); - let is_edit_draft = editing_uri.is_some(); - let display_title = if title.is_empty() { "Untitled".to_string() } else { title }; - let tid = key.strip_prefix("new:").unwrap_or(&key); + let key_for_delete = format!("new:{}", draft.rkey); + let is_edit_draft = draft.editing_uri.is_some(); + let display_title = if draft.title.is_empty() { + "Untitled".to_string() + } else { + draft.title.clone() + }; + + // Determine sync status badge + let (sync_badge, sync_class) = match (draft.is_local, draft.is_remote) { + (true, true) => ("Synced", "draft-badge-synced"), + (true, false) => ("Local", "draft-badge-local"), + (false, true) => ("Remote", "draft-badge-remote"), + (false, false) => ("", ""), // shouldn't happen + }; + tracing::info!("Rendering draft {} - badge='{}' class='{}'", draft.rkey, sync_badge, sync_class); rsx! { div { class: "draft-card", - key: "{key}", + key: "{draft.rkey}", Link { to: Route::DraftEdit { ident: ident(), - tid: tid.to_string().into(), + tid: draft.rkey.clone().into(), }, class: "draft-card-link", div { class: "draft-card-content", h3 { class: "draft-title", "{display_title}" } - if is_edit_draft { - span { class: "draft-badge draft-badge-edit", "Editing" } - } else { - span { class: "draft-badge draft-badge-new", "New" } + div { class: "draft-badges", + if is_edit_draft { + span { class: "draft-badge draft-badge-edit", "Editing" } + } + if !sync_badge.is_empty() { + span { class: "draft-badge {sync_class}", "{sync_badge}" } + } } } } - Button { - variant: ButtonVariant::Ghost, - onclick: move |_| show_delete_confirm.set(Some(key_for_delete.clone())), - "×" + if draft.is_local { + Button { + variant: ButtonVariant::Ghost, + onclick: move |_| show_delete_confirm.set(Some(key_for_delete.clone())), + "×" + } } } } diff --git a/crates/weaver-app/src/views/invites.rs b/crates/weaver-app/src/views/invites.rs new file mode 100644 index 0000000..38f19d3 --- /dev/null +++ b/crates/weaver-app/src/views/invites.rs @@ -0,0 +1,52 @@ +//! Collaboration invites page. + +use crate::Route; +use crate::auth::AuthState; +use crate::components::collab::InvitesList; +use dioxus::prelude::*; +use jacquard::types::ident::AtIdentifier; + +const INVITES_CSS: Asset = asset!("/assets/styling/invites.css"); + +/// Page showing collaboration invites (sent and received). +#[component] +pub fn InvitesPage(ident: ReadSignal>) -> Element { + let auth_state = use_context::>(); + let navigator = use_navigator(); + + // Check ownership - only show to authenticated user viewing their own invites + let current_did = auth_state.read().did.clone(); + let is_owner = match (¤t_did, ident()) { + (Some(did), AtIdentifier::Did(ref ident_did)) => *did == *ident_did, + _ => false, + }; + + // Redirect non-owners + let ident_for_redirect = ident(); + use_effect(move || { + if !is_owner { + navigator.replace(Route::RepositoryIndex { + ident: ident_for_redirect.clone(), + }); + } + }); + + if !is_owner { + return rsx! { div { "Redirecting..." } }; + } + + rsx! { + document::Stylesheet { href: INVITES_CSS } + + div { class: "invites-page", + header { class: "invites-header", + h1 { "Collaboration Invites" } + p { class: "invites-description", + "Manage your collaboration invitations. Accept invites to collaborate on entries and notebooks." + } + } + + InvitesList {} + } + } +} diff --git a/crates/weaver-app/src/views/mod.rs b/crates/weaver-app/src/views/mod.rs index aa53f06..ee6abdd 100644 --- a/crates/weaver-app/src/views/mod.rs +++ b/crates/weaver-app/src/views/mod.rs @@ -34,3 +34,6 @@ pub use drafts::{DraftEdit, DraftsList, NewDraft, NotebookEntryEdit, StandaloneE mod entry; pub use entry::{NotebookEntryByRkey, StandaloneEntry}; + +mod invites; +pub use invites::InvitesPage; diff --git a/crates/weaver-common/src/agent.rs b/crates/weaver-common/src/agent.rs index ac9c0b4..d0158c7 100644 --- a/crates/weaver-common/src/agent.rs +++ b/crates/weaver-common/src/agent.rs @@ -1,7 +1,8 @@ use weaver_api::app_bsky::actor::get_profile::GetProfile; // Re-export view types for use elsewhere pub use weaver_api::sh_weaver::notebook::{ - AuthorListView, BookEntryRef, BookEntryView, EntryView, NotebookView, + AuthorListView, BookEntryRef, BookEntryView, EntryView, NotebookView, PermissionGrant, + PermissionsState, }; // Re-export jacquard for convenience @@ -67,7 +68,7 @@ fn title_matches(value: &str, search: &str) -> bool { /// /// This trait is for multi-step workflows that coordinate between multiple operations. //#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))] -pub trait WeaverExt: AgentSessionExt + XrpcExt + Send + Sync { +pub trait WeaverExt: AgentSessionExt + XrpcExt + Send + Sync + Sized { /// Publish a blob to the user's PDS /// /// Multi-step workflow: @@ -414,6 +415,8 @@ pub trait WeaverExt: AgentSessionExt + XrpcExt + Send + Sync { &self, uri: &AtUri<'_>, ) -> impl Future, Vec>), WeaverError>> + where + Self: Sized, { async move { use jacquard::to_data; @@ -460,6 +463,9 @@ pub trait WeaverExt: AgentSessionExt + XrpcExt + Send + Sync { .map(IntoStatic::into_static) .collect(); + // Fetch permissions for this notebook + let permissions = self.get_permissions_for_resource(uri).await?; + Ok(( NotebookView::new() .cid(notebook.cid.ok_or_else(|| { @@ -471,6 +477,7 @@ pub trait WeaverExt: AgentSessionExt + XrpcExt + Send + Sync { .maybe_path(path) .maybe_tags(tags) .authors(authors) + .permissions(permissions) .record(to_data(¬ebook.value).map_err(|_| { AgentError::from(ClientError::invalid_request( "Failed to serialize notebook", @@ -487,7 +494,10 @@ pub trait WeaverExt: AgentSessionExt + XrpcExt + Send + Sync { &self, notebook: &NotebookView<'a>, entry_ref: &StrongRef<'_>, - ) -> impl Future, WeaverError>> { + ) -> impl Future, WeaverError>> + where + Self: Sized, + { async move { use jacquard::to_data; use weaver_api::sh_weaver::notebook::entry::Entry; @@ -500,6 +510,23 @@ pub trait WeaverExt: AgentSessionExt + XrpcExt + Send + Sync { let path = entry.value.path.clone(); let tags = entry.value.tags.clone(); + // Fetch permissions for this entry (includes inherited notebook permissions) + let permissions = self.get_permissions_for_resource(&entry_uri).await?; + + // Fetch contributors (evidence-based authors) for this entry + let contributor_dids = self.find_contributors_for_resource(&entry_uri).await?; + let mut authors = Vec::new(); + for (index, did) in contributor_dids.iter().enumerate() { + let (profile_uri, profile_view) = self.hydrate_profile_view(did).await?; + authors.push( + AuthorListView::new() + .maybe_uri(profile_uri) + .record(profile_view) + .index(index as i64) + .build(), + ); + } + Ok(EntryView::new() .cid(entry.cid.ok_or_else(|| { AgentError::from(ClientError::invalid_request("Entry missing CID")) @@ -512,7 +539,8 @@ pub trait WeaverExt: AgentSessionExt + XrpcExt + Send + Sync { .maybe_tags(tags) .title(title) .path(path) - .authors(notebook.authors.clone()) + .authors(authors) + .permissions(permissions) .build()) } } @@ -527,6 +555,8 @@ pub trait WeaverExt: AgentSessionExt + XrpcExt + Send + Sync { entries: &[StrongRef<'_>], title: &str, ) -> impl Future, entry::Entry<'a>)>, WeaverError>> + where + Self: Sized, { async move { use weaver_api::sh_weaver::notebook::BookEntryRef; @@ -676,6 +706,9 @@ pub trait WeaverExt: AgentSessionExt + XrpcExt + Send + Sync { .map(IntoStatic::into_static) .collect(); + // Fetch permissions for this notebook + let permissions = self.get_permissions_for_resource(&record.uri).await?; + return Ok(Some(( NotebookView::new() .cid(record.cid) @@ -685,6 +718,7 @@ pub trait WeaverExt: AgentSessionExt + XrpcExt + Send + Sync { .maybe_path(path) .maybe_tags(tags) .authors(authors) + .permissions(permissions) .record(record.value.clone()) .build() .into_static(), @@ -933,7 +967,10 @@ pub trait WeaverExt: AgentSessionExt + XrpcExt + Send + Sync { &self, notebook: &NotebookView<'a>, entry_ref: &StrongRef<'_>, - ) -> impl Future, WeaverError>> { + ) -> impl Future, WeaverError>> + where + Self: Sized, + { async move { use jacquard::to_data; use weaver_api::sh_weaver::notebook::page::Page; @@ -945,6 +982,23 @@ pub trait WeaverExt: AgentSessionExt + XrpcExt + Send + Sync { let title = entry.value.title.clone(); let tags = entry.value.tags.clone(); + // Fetch permissions for this page (includes inherited notebook permissions) + let permissions = self.get_permissions_for_resource(&entry_uri).await?; + + // Fetch contributors (evidence-based authors) for this page + let contributor_dids = self.find_contributors_for_resource(&entry_uri).await?; + let mut authors = Vec::new(); + for (index, did) in contributor_dids.iter().enumerate() { + let (profile_uri, profile_view) = self.hydrate_profile_view(did).await?; + authors.push( + AuthorListView::new() + .maybe_uri(profile_uri) + .record(profile_view) + .index(index as i64) + .build(), + ); + } + Ok(EntryView::new() .cid(entry.cid.ok_or_else(|| { AgentError::from(ClientError::invalid_request("Page missing CID")) @@ -956,7 +1010,8 @@ pub trait WeaverExt: AgentSessionExt + XrpcExt + Send + Sync { })?) .maybe_tags(tags) .title(title) - .authors(notebook.authors.clone()) + .authors(authors) + .permissions(permissions) .build()) } } @@ -1103,16 +1158,26 @@ pub trait WeaverExt: AgentSessionExt + XrpcExt + Send + Sync { ))) })?; - // Build EntryView - without notebook authors, just the entry author + // Build entry URI for contributor/permission queries + let entry_uri = entry::Entry::uri(record.uri.clone()) + .map_err(|_| AgentError::from(ClientError::invalid_request("Invalid entry URI")))?; + + // Fetch contributors (evidence-based authors) + let contributor_dids = self.find_contributors_for_resource(&entry_uri).await?; let mut authors = Vec::new(); - let (profile_uri, profile_view) = self.hydrate_profile_view(&repo_did).await?; - authors.push( - AuthorListView::new() - .maybe_uri(profile_uri) - .record(profile_view) - .index(0) - .build(), - ); + for (index, did) in contributor_dids.iter().enumerate() { + let (profile_uri, profile_view) = self.hydrate_profile_view(did).await?; + authors.push( + AuthorListView::new() + .maybe_uri(profile_uri) + .record(profile_view) + .index(index as i64) + .build(), + ); + } + + // Fetch permissions + let permissions = self.get_permissions_for_resource(&entry_uri).await?; let entry_view = EntryView::new() .cid(record.cid.ok_or_else(|| { @@ -1127,6 +1192,7 @@ pub trait WeaverExt: AgentSessionExt + XrpcExt + Send + Sync { .title(entry_value.title.clone()) .path(entry_value.path.clone()) .authors(authors) + .permissions(permissions) .build() .into_static(); @@ -1196,6 +1262,701 @@ pub trait WeaverExt: AgentSessionExt + XrpcExt + Send + Sync { )) } } + + /// Find valid collaborators for a resource. + /// + /// Queries Constellation for invite/accept record pairs: + /// 1. Find all invites targeting this resource URI + /// 2. For each invite, check if there's a matching accept record + /// 3. Return DIDs that have both invite AND accept + fn find_collaborators_for_resource( + &self, + resource_uri: &AtUri<'_>, + ) -> impl Future>, WeaverError>> + where + Self: Sized, + { + async move { + use weaver_api::sh_weaver::collab::invite::Invite; + + const INVITE_NSID: &str = "sh.weaver.collab.invite"; + const ACCEPT_NSID: &str = "sh.weaver.collab.accept"; + + let constellation_url = Url::parse(CONSTELLATION_URL).map_err(|e| { + AgentError::from(ClientError::invalid_request(format!( + "Invalid constellation URL: {}", + e + ))) + })?; + + // Step 1: Find all invites for this resource + let invite_query = GetBacklinksQuery { + subject: Uri::At(resource_uri.clone().into_static()), + source: format!("{}:resource.uri", INVITE_NSID).into(), + cursor: None, + did: vec![], + limit: 100, + }; + + let response = self + .xrpc(constellation_url.clone()) + .send(&invite_query) + .await + .map_err(|e| { + AgentError::from(ClientError::invalid_request(format!( + "Constellation query failed: {}", + e + ))) + })?; + + let invite_output = response.into_output().map_err(|e| { + AgentError::from(ClientError::invalid_request(format!( + "Failed to parse constellation response: {}", + e + ))) + })?; + + let mut collaborators = Vec::new(); + + // Step 2: For each invite, check for a matching accept + for record_id in invite_output.records { + let invite_uri_str = format!( + "at://{}/{}/{}", + record_id.did, + INVITE_NSID, + record_id.rkey.0.as_ref() + ); + let Ok(invite_uri) = AtUri::new(&invite_uri_str) else { + continue; + }; + + // Fetch the invite to get the invitee DID + let Ok(invite_resp) = self.get_record::(&invite_uri).await else { + continue; + }; + let Ok(invite_record) = invite_resp.into_output() else { + continue; + }; + + let invitee_did = invite_record.value.invitee.clone().into_static(); + + // Query for accept records referencing this invite + let accept_query = GetBacklinksQuery { + subject: Uri::At(invite_uri.into_static()), + source: format!("{}:invite.uri", ACCEPT_NSID).into(), + cursor: None, + did: vec![invitee_did.clone()], + limit: 1, + }; + + let Ok(accept_resp) = self + .xrpc(constellation_url.clone()) + .send(&accept_query) + .await + else { + continue; + }; + + let Ok(accept_output) = accept_resp.into_output() else { + continue; + }; + + if !accept_output.records.is_empty() { + collaborators.push(invitee_did); + } + } + + Ok(collaborators) + } + } + + /// Find all versions of a record across collaborator repositories. + /// + /// For each collaborator DID, attempts to fetch `at://{did}/{collection}/{rkey}`. + /// Returns all found versions sorted by `updated_at` descending (latest first). + fn find_all_versions<'a>( + &'a self, + collection: &'a str, + rkey: &'a str, + collaborators: &'a [Did<'_>], + ) -> impl Future>, WeaverError>> + 'a + where + Self: Sized, + { + async move { + use jacquard::Data; + use weaver_api::com_atproto::repo::get_record::GetRecord; + + let mut versions = Vec::new(); + + for collab_did in collaborators { + let Ok(pds_url) = self.pds_for_did(collab_did).await else { + continue; + }; + + let Ok(record_key) = RecordKey::any(rkey) else { + continue; + }; + let request = GetRecord::new() + .repo(jacquard::types::ident::AtIdentifier::Did( + collab_did.clone(), + )) + .collection(jacquard::types::nsid::Nsid::raw(collection)) + .rkey(record_key) + .build(); + + let Ok(http_request) = + xrpc::build_http_request(&pds_url, &request, &self.opts().await) + else { + continue; + }; + + let Ok(http_response) = self.send_http(http_request).await else { + continue; + }; + + let response: Response = + match xrpc::process_response(http_response) { + Ok(r) => r, + Err(_) => continue, + }; + + let Ok(record) = response.into_output() else { + continue; + }; + + let Some(cid) = record.cid else { + continue; + }; + + let updated_at = record + .value + .query("...updatedAt") + .first() + .or_else(|| record.value.query("...createdAt").first()) + .and_then(|v: &Data| v.as_str()) + .and_then(|s| s.parse::().ok()); + + versions.push(CollaboratorVersion { + did: collab_did.clone().into_static(), + uri: record.uri.into_static(), + cid: cid.into_static(), + updated_at, + value: record.value.into_static(), + }); + } + + // Sort by updated_at descending (latest first) + versions.sort_by(|a, b| match (&b.updated_at, &a.updated_at) { + (Some(b_time), Some(a_time)) => b_time.as_ref().cmp(a_time.as_ref()), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => std::cmp::Ordering::Equal, + }); + + Ok(versions) + } + } + + /// Check if a user can edit a resource based on collaboration records. + /// + /// Returns true if the user is the resource owner OR has valid invite+accept. + fn can_user_edit_resource<'a>( + &'a self, + resource_uri: &'a AtUri<'_>, + user_did: &'a Did<'_>, + ) -> impl Future> + 'a + where + Self: Sized, + { + async move { + // Check if user is the owner + if let jacquard::types::ident::AtIdentifier::Did(owner_did) = resource_uri.authority() { + if owner_did == user_did { + return Ok(true); + } + } + + // Check for valid collaboration + let collaborators = self.find_collaborators_for_resource(resource_uri).await?; + Ok(collaborators.iter().any(|c| c == user_did)) + } + } + + /// Check if a user can edit an entry, considering notebook-level cascading. + /// + /// An entry is editable if user owns it, has entry-level collab, or has notebook-level collab. + fn can_user_edit_entry<'a>( + &'a self, + entry_uri: &'a AtUri<'_>, + user_did: &'a Did<'_>, + ) -> impl Future> + 'a + where + Self: Sized, + { + async move { + // Check entry-level access first + if self.can_user_edit_resource(entry_uri, user_did).await? { + return Ok(true); + } + + // Check notebook-level access (cascade) + if let Some(notebook_id) = self.find_notebook_for_entry(entry_uri).await? { + let notebook_uri_str = format!( + "at://{}/{}/{}", + notebook_id.did, + notebook_id.collection, + notebook_id.rkey.0.as_ref() + ); + if let Ok(notebook_uri) = AtUri::new(¬ebook_uri_str) { + if self.can_user_edit_resource(¬ebook_uri, user_did).await? { + return Ok(true); + } + } + } + + Ok(false) + } + } + + /// Get the full permissions state for a resource. + /// + /// Returns PermissionsState with all editors: + /// - Resource authority (source = resource URI, grantedAt = createdAt) + /// - Invited collaborators (source = invite URI, grantedAt = accept createdAt) + /// - For entries: inherited notebook-level collaborators + fn get_permissions_for_resource( + &self, + resource_uri: &AtUri<'_>, + ) -> impl Future, WeaverError>> + where + Self: Sized, + { + async move { + use weaver_api::sh_weaver::collab::accept::Accept; + use weaver_api::sh_weaver::collab::invite::Invite; + + const INVITE_NSID: &str = "sh.weaver.collab.invite"; + const ACCEPT_NSID: &str = "sh.weaver.collab.accept"; + + let constellation_url = Url::parse(CONSTELLATION_URL).map_err(|e| { + AgentError::from(ClientError::invalid_request(format!( + "Invalid constellation URL: {}", + e + ))) + })?; + + let mut editors = Vec::new(); + + // 1. Resource authority - creating the resource is its own grant + let authority_did = match resource_uri.authority() { + jacquard::types::ident::AtIdentifier::Did(did) => did.clone().into_static(), + jacquard::types::ident::AtIdentifier::Handle(handle) => { + let (did, _) = self.pds_for_handle(handle).await.map_err(|e| { + AgentError::from( + ClientError::from(e).with_context("Failed to resolve handle"), + ) + })?; + did.into_static() + } + }; + + // Fetch the record to get createdAt + let record = self + .get_record::(resource_uri) + .await + .map_err(|e| WeaverError::from(AgentError::from(e)))? + .into_output() + .map_err(|e| { + WeaverError::from(AgentError::from(ClientError::invalid_request(format!( + "Failed to parse record: {}", + e + )))) + })?; + let authority_granted_at = record.value.created_at; + + editors.push( + PermissionGrant::new() + .did(authority_did.clone()) + .scope("direct") + .source(resource_uri.clone().into_static()) + .granted_at(authority_granted_at) + .build() + .into_static(), + ); + + // 2. Find direct invites for this resource + let invite_query = GetBacklinksQuery { + subject: Uri::At(resource_uri.clone().into_static()), + source: format!("{}:resource.uri", INVITE_NSID).into(), + cursor: None, + did: vec![], + limit: 100, + }; + + let invite_response = self + .xrpc(constellation_url.clone()) + .send(&invite_query) + .await + .map_err(|e| { + AgentError::from(ClientError::invalid_request(format!( + "Constellation invite query failed: {}", + e + ))) + })?; + let invite_output = invite_response.into_output().map_err(|e| { + AgentError::from(ClientError::invalid_request(format!( + "Failed to parse Constellation response: {}", + e + ))) + })?; + + for record_id in invite_output.records { + let invite_uri_str = format!( + "at://{}/{}/{}", + record_id.did, + INVITE_NSID, + record_id.rkey.0.as_ref() + ); + let invite_uri = AtUri::new(&invite_uri_str).map_err(|_| { + AgentError::from(ClientError::invalid_request( + "Invalid invite URI from Constellation", + )) + })?; + + // Fetch invite to get invitee DID + let invite_record = + self.get_record::(&invite_uri) + .await + .map_err(|e| WeaverError::from(AgentError::from(e)))? + .into_output() + .map_err(|e| { + WeaverError::from(AgentError::from(ClientError::invalid_request( + format!("Failed to parse invite record: {}", e), + ))) + })?; + + let invitee_did = invite_record.value.invitee.clone().into_static(); + + // Query for accept records referencing this invite + let accept_query = GetBacklinksQuery { + subject: Uri::At(invite_uri.clone().into_static()), + source: format!("{}:invite.uri", ACCEPT_NSID).into(), + cursor: None, + did: vec![invitee_did.clone()], + limit: 1, + }; + + let accept_response = self + .xrpc(constellation_url.clone()) + .send(&accept_query) + .await + .map_err(|e| { + AgentError::from(ClientError::invalid_request(format!( + "Constellation accept query failed: {}", + e + ))) + })?; + let accept_output = accept_response.into_output().map_err(|e| { + AgentError::from(ClientError::invalid_request(format!( + "Failed to parse Constellation accept response: {}", + e + ))) + })?; + + // No accept = pending invite, not an error - just skip + let Some(accept_record_id) = accept_output.records.first() else { + continue; + }; + + let accept_uri_str = format!( + "at://{}/{}/{}", + accept_record_id.did, + ACCEPT_NSID, + accept_record_id.rkey.0.as_ref() + ); + let accept_uri = AtUri::new(&accept_uri_str).map_err(|_| { + AgentError::from(ClientError::invalid_request( + "Invalid accept URI from Constellation", + )) + })?; + let accept_record = + self.get_record::(&accept_uri) + .await + .map_err(|e| WeaverError::from(AgentError::from(e)))? + .into_output() + .map_err(|e| { + WeaverError::from(AgentError::from(ClientError::invalid_request( + format!("Failed to parse accept record: {}", e), + ))) + })?; + + editors.push( + PermissionGrant::new() + .did(invitee_did) + .scope("direct") + .source(invite_uri.into_static()) + .granted_at(accept_record.value.created_at) + .build() + .into_static(), + ); + } + + // 3. For entries, check notebook-level invites (inherited) + let is_entry = resource_uri + .collection() + .is_some_and(|c| c.as_ref() == "sh.weaver.notebook.entry"); + + if is_entry { + // Entry not in a notebook is fine - just no inherited permissions + if let Some(notebook_id) = self.find_notebook_for_entry(resource_uri).await? { + let notebook_uri_str = format!( + "at://{}/{}/{}", + notebook_id.did, + notebook_id.collection, + notebook_id.rkey.0.as_ref() + ); + let notebook_uri = AtUri::new(¬ebook_uri_str).map_err(|_| { + AgentError::from(ClientError::invalid_request( + "Invalid notebook URI from Constellation", + )) + })?; + + let notebook_invite_query = GetBacklinksQuery { + subject: Uri::At(notebook_uri.clone().into_static()), + source: format!("{}:resource.uri", INVITE_NSID).into(), + cursor: None, + did: vec![], + limit: 100, + }; + + let notebook_invite_response = self + .xrpc(constellation_url.clone()) + .send(¬ebook_invite_query) + .await + .map_err(|e| { + AgentError::from(ClientError::invalid_request(format!( + "Constellation notebook invite query failed: {}", + e + ))) + })?; + let notebook_invite_output = + notebook_invite_response.into_output().map_err(|e| { + AgentError::from(ClientError::invalid_request(format!( + "Failed to parse Constellation response: {}", + e + ))) + })?; + + for record_id in notebook_invite_output.records { + let invite_uri_str = format!( + "at://{}/{}/{}", + record_id.did, + INVITE_NSID, + record_id.rkey.0.as_ref() + ); + let invite_uri = AtUri::new(&invite_uri_str).map_err(|_| { + AgentError::from(ClientError::invalid_request( + "Invalid invite URI from Constellation", + )) + })?; + + let invite_record = self + .get_record::(&invite_uri) + .await + .map_err(|e| WeaverError::from(AgentError::from(e)))? + .into_output() + .map_err(|e| { + WeaverError::from(AgentError::from(ClientError::invalid_request( + format!("Failed to parse invite record: {}", e), + ))) + })?; + + let invitee_did = invite_record.value.invitee.clone().into_static(); + + // Skip if already in direct grants (direct takes precedence) + if editors.iter().any(|g| g.did == invitee_did) { + continue; + } + + let accept_query = GetBacklinksQuery { + subject: Uri::At(invite_uri.clone().into_static()), + source: format!("{}:.invite.uri", ACCEPT_NSID).into(), + cursor: None, + did: vec![invitee_did.clone()], + limit: 1, + }; + + let accept_response = self + .xrpc(constellation_url.clone()) + .send(&accept_query) + .await + .map_err(|e| { + AgentError::from(ClientError::invalid_request(format!( + "Constellation accept query failed: {}", + e + ))) + })?; + let accept_output = accept_response.into_output().map_err(|e| { + AgentError::from(ClientError::invalid_request(format!( + "Failed to parse Constellation accept response: {}", + e + ))) + })?; + + // No accept = pending invite, not an error - just skip + let Some(accept_record_id) = accept_output.records.first() else { + continue; + }; + + let accept_uri_str = format!( + "at://{}/{}/{}", + accept_record_id.did, + ACCEPT_NSID, + accept_record_id.rkey.0.as_ref() + ); + let accept_uri = AtUri::new(&accept_uri_str).map_err(|_| { + AgentError::from(ClientError::invalid_request( + "Invalid accept URI from Constellation", + )) + })?; + let accept_record = self + .get_record::(&accept_uri) + .await + .map_err(|e| WeaverError::from(AgentError::from(e)))? + .into_output() + .map_err(|e| { + WeaverError::from(AgentError::from(ClientError::invalid_request( + format!("Failed to parse accept record: {}", e), + ))) + })?; + + editors.push( + PermissionGrant::new() + .did(invitee_did) + .scope("inherited") + .source(invite_uri.into_static()) + .granted_at(accept_record.value.created_at) + .build() + .into_static(), + ); + } + } + } + + Ok(PermissionsState::new() + .editors(editors) + .build() + .into_static()) + } + } + + /// Find contributors (authors) for a resource based on evidence. + /// + /// Contributors are DIDs who have actually contributed to this resource: + /// 1. Edit records (edit.root or edit.diff) referencing this resource + /// 2. Published versions of the record in their repo (same rkey) + /// + /// This is separate from permissions - you can have edit permission without + /// having contributed yet. + fn find_contributors_for_resource( + &self, + resource_uri: &AtUri<'_>, + ) -> impl Future>, WeaverError>> + where + Self: Sized, + { + async move { + const EDIT_ROOT_NSID: &str = "sh.weaver.edit.root"; + + let constellation_url = Url::parse(CONSTELLATION_URL).map_err(|e| { + AgentError::from(ClientError::invalid_request(format!( + "Invalid constellation URL: {}", + e + ))) + })?; + + let mut contributors = std::collections::HashSet::new(); + + // 1. Resource authority is always a contributor + let authority_did = match resource_uri.authority() { + jacquard::types::ident::AtIdentifier::Did(did) => did.clone().into_static(), + jacquard::types::ident::AtIdentifier::Handle(handle) => { + let (did, _) = self.pds_for_handle(handle).await.map_err(|e| { + AgentError::from( + ClientError::from(e).with_context("Failed to resolve handle"), + ) + })?; + did.into_static() + } + }; + contributors.insert(authority_did); + + // 2. Find DIDs with edit records for this resource + let edit_query = GetBacklinksQuery { + subject: Uri::At(resource_uri.clone().into_static()), + source: format!("{}:doc.value.entry.uri", EDIT_ROOT_NSID).into(), + cursor: None, + did: vec![], + limit: 100, + }; + + if let Ok(response) = self.xrpc(constellation_url.clone()).send(&edit_query).await { + if let Ok(edit_output) = response.into_output() { + for record_id in edit_output.records { + contributors.insert(record_id.did.into_static()); + } + } + } + + // 3. Find collaborators who have published versions (same rkey) + let collaborators = self.find_collaborators_for_resource(resource_uri).await?; + let rkey = resource_uri.rkey(); + let collection = resource_uri.collection(); + + if let (Some(rkey), Some(collection)) = (rkey, collection) { + for collab_did in collaborators { + // Try to fetch their version of the record + let collab_uri_str = format!( + "at://{}/{}/{}", + collab_did.as_ref(), + collection, + rkey.as_ref() + ); + if let Ok(collab_uri) = AtUri::new(&collab_uri_str) { + // Check if record actually exists (200 = found, 400 = not found) + if let Ok(response) = self + .get_record::( + &collab_uri, + ) + .await + { + if response.status().is_success() { + contributors.insert(collab_did); + } + } + } + } + } + + Ok(contributors.into_iter().collect()) + } + } +} + +/// A version of a record from a collaborator's repository. +#[derive(Debug, Clone)] +pub struct CollaboratorVersion<'a> { + /// The DID of the collaborator who owns this version. + pub did: Did<'a>, + /// The full URI of this version. + pub uri: AtUri<'a>, + /// CID of this version. + pub cid: jacquard::types::string::Cid<'a>, + /// When this version was last updated. + pub updated_at: Option, + /// The raw record value. + pub value: jacquard::Data<'a>, } impl WeaverExt for T {} diff --git a/lexicons/edit/draft.json b/lexicons/edit/draft.json new file mode 100644 index 0000000..425a4af --- /dev/null +++ b/lexicons/edit/draft.json @@ -0,0 +1,21 @@ +{ + "lexicon": 1, + "id": "sh.weaver.edit.draft", + "defs": { + "main": { + "type": "record", + "description": "Stub record for unpublished drafts. Acts as an anchor for edit.root/diff records and enables draft discovery via listRecords.", + "key": "tid", + "record": { + "type": "object", + "required": ["createdAt"], + "properties": { + "createdAt": { + "type": "string", + "format": "datetime" + } + } + } + } + } +} diff --git a/lexicons/notebook/defs.json b/lexicons/notebook/defs.json index c65636a..9f521a1 100644 --- a/lexicons/notebook/defs.json +++ b/lexicons/notebook/defs.json @@ -16,6 +16,10 @@ "type": "array", "items": { "type": "ref", "ref": "#authorListView" } }, + "permissions": { + "type": "ref", + "ref": "#permissionsState" + }, "record": { "type": "unknown" }, "indexedAt": { "type": "string", "format": "datetime" } } @@ -34,6 +38,10 @@ "type": "array", "items": { "type": "ref", "ref": "#authorListView" } }, + "permissions": { + "type": "ref", + "ref": "#permissionsState" + }, "record": { "type": "unknown" }, "renderedView": { "type": "ref", @@ -134,6 +142,46 @@ "default": "weaver" } } + }, + "permissionsState": { + "type": "object", + "description": "ACL-style permissions for a resource. Separate from authors (who contributed).", + "required": ["editors"], + "properties": { + "editors": { + "type": "array", + "description": "DIDs that can edit this resource", + "items": { "type": "ref", "ref": "#permissionGrant" } + }, + "viewers": { + "type": "array", + "description": "DIDs that can view (future use)", + "items": { "type": "ref", "ref": "#permissionGrant" } + } + } + }, + "permissionGrant": { + "type": "object", + "description": "A single permission grant. For resource authority: source=resource URI, grantedAt=createdAt. For invitees: source=invite URI, grantedAt=accept createdAt.", + "required": ["did", "scope", "source", "grantedAt"], + "properties": { + "did": { "type": "string", "format": "did" }, + "scope": { + "type": "string", + "knownValues": ["direct", "inherited"], + "description": "direct = this resource (includes authority), inherited = via notebook invite" + }, + "source": { + "type": "string", + "format": "at-uri", + "description": "For authority: resource URI. For invitees: invite URI" + }, + "grantedAt": { + "type": "string", + "format": "datetime", + "description": "For authority: record createdAt. For invitees: accept createdAt" + } + } } } }