diff --git a/crates/weaver-api/lexicons/sh_weaver_actor_defs.json b/crates/weaver-api/lexicons/sh_weaver_actor_defs.json index 81bb07a..18bb350 100644 --- a/crates/weaver-api/lexicons/sh_weaver_actor_defs.json +++ b/crates/weaver-api/lexicons/sh_weaver_actor_defs.json @@ -13,14 +13,9 @@ "type": "string", "format": "did" }, - "displayName": { - "type": "string", - "maxLength": 640, - "maxGraphemes": 64 - }, - "handle": { - "type": "string", - "format": "handle" + "signature": { + "type": "bytes", + "description": "signed bytes of the corresponding notebook record in the author's repo" } } }, @@ -78,6 +73,10 @@ "type": "string", "format": "uri" }, + "bluesky": { + "type": "boolean", + "description": "Include link to this account on Bluesky." + }, "createdAt": { "type": "string", "format": "datetime" @@ -137,11 +136,19 @@ "description": "Pronouns to use in user-generated content.", "ref": "#pronounsList" }, + "streamplace": { + "type": "boolean", + "description": "Include link to this account on stream.place." + }, "subscribedCount": { "type": "integer" }, "subscriberCount": { "type": "integer" + }, + "tangled": { + "type": "boolean", + "description": "Include link to this account on Tangled." } } }, diff --git a/crates/weaver-api/src/sh_weaver/actor.rs b/crates/weaver-api/src/sh_weaver/actor.rs index 72df499..f231dea 100644 --- a/crates/weaver-api/src/sh_weaver/actor.rs +++ b/crates/weaver-api/src/sh_weaver/actor.rs @@ -23,12 +23,9 @@ pub mod profile; pub struct Author<'a> { #[serde(borrow)] pub did: jacquard_common::types::string::Did<'a>, + /// signed bytes of the corresponding notebook record in the author's repo #[serde(skip_serializing_if = "std::option::Option::is_none")] - #[serde(borrow)] - pub display_name: Option>, - #[serde(skip_serializing_if = "std::option::Option::is_none")] - #[serde(borrow)] - pub handle: Option>, + pub signature: Option, } pub mod author_state { @@ -68,8 +65,7 @@ pub struct AuthorBuilder<'a, S: author_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 ()>, } @@ -86,7 +82,7 @@ impl<'a> AuthorBuilder<'a, author_state::Empty> { pub fn new() -> Self { AuthorBuilder { _phantom_state: ::core::marker::PhantomData, - __unsafe_private_named: (None, None, None), + __unsafe_private_named: (None, None), _phantom: ::core::marker::PhantomData, } } @@ -112,43 +108,18 @@ where } impl<'a, S: author_state::State> AuthorBuilder<'a, S> { - /// Set the `displayName` field (optional) - pub fn display_name( - mut self, - value: impl Into>>, - ) -> Self { + /// Set the `signature` field (optional) + pub fn signature(mut self, value: impl Into>) -> Self { self.__unsafe_private_named.1 = value.into(); self } - /// Set the `displayName` field to an Option value (optional) - pub fn maybe_display_name( - mut self, - value: Option>, - ) -> Self { + /// Set the `signature` field to an Option value (optional) + pub fn maybe_signature(mut self, value: Option) -> Self { self.__unsafe_private_named.1 = value; self } } -impl<'a, S: author_state::State> AuthorBuilder<'a, S> { - /// Set the `handle` field (optional) - pub fn handle( - mut self, - value: impl Into>>, - ) -> Self { - self.__unsafe_private_named.2 = value.into(); - self - } - /// Set the `handle` field to an Option value (optional) - pub fn maybe_handle( - mut self, - value: Option>, - ) -> Self { - self.__unsafe_private_named.2 = value; - self - } -} - impl<'a, S> AuthorBuilder<'a, S> where S: author_state::State, @@ -158,8 +129,7 @@ where pub fn build(self) -> Author<'a> { Author { did: self.__unsafe_private_named.0.unwrap(), - display_name: self.__unsafe_private_named.1, - handle: self.__unsafe_private_named.2, + signature: self.__unsafe_private_named.1, extra_data: Default::default(), } } @@ -173,8 +143,7 @@ where ) -> Author<'a> { Author { did: self.__unsafe_private_named.0.unwrap(), - display_name: self.__unsafe_private_named.1, - handle: self.__unsafe_private_named.2, + signature: self.__unsafe_private_named.1, extra_data: Some(extra_data), } } @@ -224,36 +193,12 @@ fn lexicon_doc_sh_weaver_actor_defs() -> ::jacquard_lexicon::lexicon::LexiconDoc ); map.insert( ::jacquard_common::smol_str::SmolStr::new_static( - "displayName", + "signature", ), - ::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString { - description: None, - format: None, - default: None, - min_length: None, - max_length: Some(640usize), - min_graphemes: None, - max_graphemes: Some(64usize), - r#enum: None, - r#const: None, - known_values: None, - }), - ); - map.insert( - ::jacquard_common::smol_str::SmolStr::new_static("handle"), - ::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString { + ::jacquard_lexicon::lexicon::LexObjectProperty::Bytes(::jacquard_lexicon::lexicon::LexBytes { description: None, - format: Some( - ::jacquard_lexicon::lexicon::LexStringFormat::Handle, - ), - default: None, - min_length: None, max_length: None, - min_graphemes: None, - max_graphemes: None, - r#enum: None, - r#const: None, - known_values: None, + min_length: None, }), ); map @@ -376,6 +321,14 @@ fn lexicon_doc_sh_weaver_actor_defs() -> ::jacquard_lexicon::lexicon::LexiconDoc known_values: None, }), ); + map.insert( + ::jacquard_common::smol_str::SmolStr::new_static("bluesky"), + ::jacquard_lexicon::lexicon::LexObjectProperty::Boolean(::jacquard_lexicon::lexicon::LexBoolean { + description: None, + default: None, + r#const: None, + }), + ); map.insert( ::jacquard_common::smol_str::SmolStr::new_static( "createdAt", @@ -557,6 +510,16 @@ fn lexicon_doc_sh_weaver_actor_defs() -> ::jacquard_lexicon::lexicon::LexiconDoc ), }), ); + map.insert( + ::jacquard_common::smol_str::SmolStr::new_static( + "streamplace", + ), + ::jacquard_lexicon::lexicon::LexObjectProperty::Boolean(::jacquard_lexicon::lexicon::LexBoolean { + description: None, + default: None, + r#const: None, + }), + ); map.insert( ::jacquard_common::smol_str::SmolStr::new_static( "subscribedCount", @@ -583,6 +546,14 @@ fn lexicon_doc_sh_weaver_actor_defs() -> ::jacquard_lexicon::lexicon::LexiconDoc r#const: None, }), ); + map.insert( + ::jacquard_common::smol_str::SmolStr::new_static("tangled"), + ::jacquard_lexicon::lexicon::LexObjectProperty::Boolean(::jacquard_lexicon::lexicon::LexBoolean { + description: None, + default: None, + r#const: None, + }), + ); map }, }), @@ -949,36 +920,6 @@ impl<'a> ::jacquard_lexicon::schema::LexiconSchema for Author<'a> { fn validate( &self, ) -> ::std::result::Result<(), ::jacquard_lexicon::validation::ConstraintError> { - if let Some(ref value) = self.display_name { - #[allow(unused_comparisons)] - if ::len(value.as_ref()) > 640usize { - return Err(::jacquard_lexicon::validation::ConstraintError::MaxLength { - path: ::jacquard_lexicon::validation::ValidationPath::from_field( - "display_name", - ), - max: 640usize, - actual: ::len(value.as_ref()), - }); - } - } - if let Some(ref value) = self.display_name { - { - let count = ::unicode_segmentation::UnicodeSegmentation::graphemes( - value.as_ref(), - true, - ) - .count(); - if count > 64usize { - return Err(::jacquard_lexicon::validation::ConstraintError::MaxGraphemes { - path: ::jacquard_lexicon::validation::ValidationPath::from_field( - "display_name", - ), - max: 64usize, - actual: count, - }); - } - } - } Ok(()) } } @@ -1319,6 +1260,9 @@ pub struct ProfileView<'a> { #[serde(skip_serializing_if = "std::option::Option::is_none")] #[serde(borrow)] pub banner: Option>, + /// Include link to this account on Bluesky. + #[serde(skip_serializing_if = "std::option::Option::is_none")] + pub bluesky: Option, #[serde(skip_serializing_if = "std::option::Option::is_none")] pub created_at: Option, #[serde(skip_serializing_if = "std::option::Option::is_none")] @@ -1351,10 +1295,16 @@ pub struct ProfileView<'a> { #[serde(skip_serializing_if = "std::option::Option::is_none")] #[serde(borrow)] pub pronouns: Option>, + /// Include link to this account on stream.place. + #[serde(skip_serializing_if = "std::option::Option::is_none")] + pub streamplace: Option, #[serde(skip_serializing_if = "std::option::Option::is_none")] pub subscribed_count: Option, #[serde(skip_serializing_if = "std::option::Option::is_none")] pub subscriber_count: Option, + /// Include link to this account on Tangled. + #[serde(skip_serializing_if = "std::option::Option::is_none")] + pub tangled: Option, } pub mod profile_view_state { @@ -1407,6 +1357,7 @@ pub struct ProfileViewBuilder<'a, S: profile_view_state::State> { __unsafe_private_named: ( ::core::option::Option>, ::core::option::Option>, + ::core::option::Option, ::core::option::Option, ::core::option::Option>, ::core::option::Option>, @@ -1418,8 +1369,10 @@ pub struct ProfileViewBuilder<'a, S: profile_view_state::State> { ::core::option::Option>, ::core::option::Option>, ::core::option::Option>, + ::core::option::Option, ::core::option::Option, ::core::option::Option, + ::core::option::Option, ), _phantom: ::core::marker::PhantomData<&'a ()>, } @@ -1452,6 +1405,9 @@ impl<'a> ProfileViewBuilder<'a, profile_view_state::Empty> { None, None, None, + None, + None, + None, ), _phantom: ::core::marker::PhantomData, } @@ -1496,13 +1452,26 @@ impl<'a, S: profile_view_state::State> ProfileViewBuilder<'a, S> { } } +impl<'a, S: profile_view_state::State> ProfileViewBuilder<'a, S> { + /// Set the `bluesky` field (optional) + pub fn bluesky(mut self, value: impl Into>) -> Self { + self.__unsafe_private_named.2 = value.into(); + self + } + /// Set the `bluesky` field to an Option value (optional) + pub fn maybe_bluesky(mut self, value: Option) -> Self { + self.__unsafe_private_named.2 = value; + self + } +} + impl<'a, S: profile_view_state::State> ProfileViewBuilder<'a, S> { /// Set the `createdAt` field (optional) pub fn created_at( mut self, value: impl Into>, ) -> Self { - self.__unsafe_private_named.2 = value.into(); + self.__unsafe_private_named.3 = value.into(); self } /// Set the `createdAt` field to an Option value (optional) @@ -1510,7 +1479,7 @@ impl<'a, S: profile_view_state::State> ProfileViewBuilder<'a, S> { mut self, value: Option, ) -> Self { - self.__unsafe_private_named.2 = value; + self.__unsafe_private_named.3 = value; self } } @@ -1521,7 +1490,7 @@ impl<'a, S: profile_view_state::State> ProfileViewBuilder<'a, S> { mut self, value: impl Into>>, ) -> Self { - self.__unsafe_private_named.3 = value.into(); + self.__unsafe_private_named.4 = value.into(); self } /// Set the `description` field to an Option value (optional) @@ -1529,7 +1498,7 @@ impl<'a, S: profile_view_state::State> ProfileViewBuilder<'a, S> { mut self, value: Option>, ) -> Self { - self.__unsafe_private_named.3 = value; + self.__unsafe_private_named.4 = value; self } } @@ -1544,7 +1513,7 @@ where mut self, value: impl Into>, ) -> ProfileViewBuilder<'a, profile_view_state::SetDid> { - self.__unsafe_private_named.4 = ::core::option::Option::Some(value.into()); + self.__unsafe_private_named.5 = ::core::option::Option::Some(value.into()); ProfileViewBuilder { _phantom_state: ::core::marker::PhantomData, __unsafe_private_named: self.__unsafe_private_named, @@ -1559,7 +1528,7 @@ impl<'a, S: profile_view_state::State> ProfileViewBuilder<'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 `displayName` field to an Option value (optional) @@ -1567,7 +1536,7 @@ impl<'a, S: profile_view_state::State> ProfileViewBuilder<'a, S> { mut self, value: Option>, ) -> Self { - self.__unsafe_private_named.5 = value; + self.__unsafe_private_named.6 = value; self } } @@ -1582,7 +1551,7 @@ where mut self, value: impl Into>, ) -> ProfileViewBuilder<'a, profile_view_state::SetHandle> { - self.__unsafe_private_named.6 = ::core::option::Option::Some(value.into()); + self.__unsafe_private_named.7 = ::core::option::Option::Some(value.into()); ProfileViewBuilder { _phantom_state: ::core::marker::PhantomData, __unsafe_private_named: self.__unsafe_private_named, @@ -1597,7 +1566,7 @@ impl<'a, S: profile_view_state::State> ProfileViewBuilder<'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 `indexedAt` field to an Option value (optional) @@ -1605,7 +1574,7 @@ impl<'a, S: profile_view_state::State> ProfileViewBuilder<'a, S> { mut self, value: Option, ) -> Self { - self.__unsafe_private_named.7 = value; + self.__unsafe_private_named.8 = value; self } } @@ -1616,7 +1585,7 @@ impl<'a, S: profile_view_state::State> ProfileViewBuilder<'a, S> { mut self, value: impl Into>>>, ) -> Self { - self.__unsafe_private_named.8 = value.into(); + self.__unsafe_private_named.9 = value.into(); self } /// Set the `labels` field to an Option value (optional) @@ -1624,7 +1593,7 @@ impl<'a, S: profile_view_state::State> ProfileViewBuilder<'a, S> { mut self, value: Option>>, ) -> Self { - self.__unsafe_private_named.8 = value; + self.__unsafe_private_named.9 = value; self } } @@ -1635,7 +1604,7 @@ impl<'a, S: profile_view_state::State> ProfileViewBuilder<'a, S> { mut self, value: impl Into>>>, ) -> Self { - self.__unsafe_private_named.9 = value.into(); + self.__unsafe_private_named.10 = value.into(); self } /// Set the `links` field to an Option value (optional) @@ -1643,7 +1612,7 @@ impl<'a, S: profile_view_state::State> ProfileViewBuilder<'a, S> { mut self, value: Option>>, ) -> Self { - self.__unsafe_private_named.9 = value; + self.__unsafe_private_named.10 = value; self } } @@ -1654,12 +1623,12 @@ impl<'a, S: profile_view_state::State> ProfileViewBuilder<'a, S> { mut self, value: impl Into>>, ) -> Self { - self.__unsafe_private_named.10 = value.into(); + self.__unsafe_private_named.11 = value.into(); self } /// Set the `location` field to an Option value (optional) pub fn maybe_location(mut self, value: Option>) -> Self { - self.__unsafe_private_named.10 = value; + self.__unsafe_private_named.11 = value; self } } @@ -1670,7 +1639,7 @@ impl<'a, S: profile_view_state::State> ProfileViewBuilder<'a, S> { mut self, value: impl Into>>, ) -> Self { - self.__unsafe_private_named.11 = value.into(); + self.__unsafe_private_named.12 = value.into(); self } /// Set the `pinned` field to an Option value (optional) @@ -1678,7 +1647,7 @@ impl<'a, S: profile_view_state::State> ProfileViewBuilder<'a, S> { mut self, value: Option>, ) -> Self { - self.__unsafe_private_named.11 = value; + self.__unsafe_private_named.12 = value; self } } @@ -1689,7 +1658,7 @@ impl<'a, S: profile_view_state::State> ProfileViewBuilder<'a, S> { mut self, value: impl Into>>, ) -> Self { - self.__unsafe_private_named.12 = value.into(); + self.__unsafe_private_named.13 = value.into(); self } /// Set the `pronouns` field to an Option value (optional) @@ -1697,7 +1666,20 @@ impl<'a, S: profile_view_state::State> ProfileViewBuilder<'a, S> { mut self, value: Option>, ) -> Self { - self.__unsafe_private_named.12 = value; + self.__unsafe_private_named.13 = value; + self + } +} + +impl<'a, S: profile_view_state::State> ProfileViewBuilder<'a, S> { + /// Set the `streamplace` field (optional) + pub fn streamplace(mut self, value: impl Into>) -> Self { + self.__unsafe_private_named.14 = value.into(); + self + } + /// Set the `streamplace` field to an Option value (optional) + pub fn maybe_streamplace(mut self, value: Option) -> Self { + self.__unsafe_private_named.14 = value; self } } @@ -1705,12 +1687,12 @@ impl<'a, S: profile_view_state::State> ProfileViewBuilder<'a, S> { impl<'a, S: profile_view_state::State> ProfileViewBuilder<'a, S> { /// Set the `subscribedCount` field (optional) pub fn subscribed_count(mut self, value: impl Into>) -> Self { - self.__unsafe_private_named.13 = value.into(); + self.__unsafe_private_named.15 = value.into(); self } /// Set the `subscribedCount` field to an Option value (optional) pub fn maybe_subscribed_count(mut self, value: Option) -> Self { - self.__unsafe_private_named.13 = value; + self.__unsafe_private_named.15 = value; self } } @@ -1718,12 +1700,25 @@ impl<'a, S: profile_view_state::State> ProfileViewBuilder<'a, S> { impl<'a, S: profile_view_state::State> ProfileViewBuilder<'a, S> { /// Set the `subscriberCount` field (optional) pub fn subscriber_count(mut self, value: impl Into>) -> Self { - self.__unsafe_private_named.14 = value.into(); + self.__unsafe_private_named.16 = value.into(); self } /// Set the `subscriberCount` field to an Option value (optional) pub fn maybe_subscriber_count(mut self, value: Option) -> Self { - self.__unsafe_private_named.14 = value; + self.__unsafe_private_named.16 = value; + self + } +} + +impl<'a, S: profile_view_state::State> ProfileViewBuilder<'a, S> { + /// Set the `tangled` field (optional) + pub fn tangled(mut self, value: impl Into>) -> Self { + self.__unsafe_private_named.17 = value.into(); + self + } + /// Set the `tangled` field to an Option value (optional) + pub fn maybe_tangled(mut self, value: Option) -> Self { + self.__unsafe_private_named.17 = value; self } } @@ -1739,19 +1734,22 @@ where ProfileView { avatar: self.__unsafe_private_named.0, banner: self.__unsafe_private_named.1, - created_at: self.__unsafe_private_named.2, - description: self.__unsafe_private_named.3, - did: self.__unsafe_private_named.4.unwrap(), - display_name: self.__unsafe_private_named.5, - handle: self.__unsafe_private_named.6.unwrap(), - indexed_at: self.__unsafe_private_named.7, - labels: self.__unsafe_private_named.8, - links: self.__unsafe_private_named.9, - location: self.__unsafe_private_named.10, - pinned: self.__unsafe_private_named.11, - pronouns: self.__unsafe_private_named.12, - subscribed_count: self.__unsafe_private_named.13, - subscriber_count: self.__unsafe_private_named.14, + bluesky: self.__unsafe_private_named.2, + created_at: self.__unsafe_private_named.3, + description: self.__unsafe_private_named.4, + did: self.__unsafe_private_named.5.unwrap(), + display_name: self.__unsafe_private_named.6, + handle: self.__unsafe_private_named.7.unwrap(), + indexed_at: self.__unsafe_private_named.8, + labels: self.__unsafe_private_named.9, + links: self.__unsafe_private_named.10, + location: self.__unsafe_private_named.11, + pinned: self.__unsafe_private_named.12, + pronouns: self.__unsafe_private_named.13, + streamplace: self.__unsafe_private_named.14, + subscribed_count: self.__unsafe_private_named.15, + subscriber_count: self.__unsafe_private_named.16, + tangled: self.__unsafe_private_named.17, extra_data: Default::default(), } } @@ -1766,19 +1764,22 @@ where ProfileView { avatar: self.__unsafe_private_named.0, banner: self.__unsafe_private_named.1, - created_at: self.__unsafe_private_named.2, - description: self.__unsafe_private_named.3, - did: self.__unsafe_private_named.4.unwrap(), - display_name: self.__unsafe_private_named.5, - handle: self.__unsafe_private_named.6.unwrap(), - indexed_at: self.__unsafe_private_named.7, - labels: self.__unsafe_private_named.8, - links: self.__unsafe_private_named.9, - location: self.__unsafe_private_named.10, - pinned: self.__unsafe_private_named.11, - pronouns: self.__unsafe_private_named.12, - subscribed_count: self.__unsafe_private_named.13, - subscriber_count: self.__unsafe_private_named.14, + bluesky: self.__unsafe_private_named.2, + created_at: self.__unsafe_private_named.3, + description: self.__unsafe_private_named.4, + did: self.__unsafe_private_named.5.unwrap(), + display_name: self.__unsafe_private_named.6, + handle: self.__unsafe_private_named.7.unwrap(), + indexed_at: self.__unsafe_private_named.8, + labels: self.__unsafe_private_named.9, + links: self.__unsafe_private_named.10, + location: self.__unsafe_private_named.11, + pinned: self.__unsafe_private_named.12, + pronouns: self.__unsafe_private_named.13, + streamplace: self.__unsafe_private_named.14, + subscribed_count: self.__unsafe_private_named.15, + subscriber_count: self.__unsafe_private_named.16, + tangled: self.__unsafe_private_named.17, extra_data: Some(extra_data), } } diff --git a/crates/weaver-app/assets/styling/profile.css b/crates/weaver-app/assets/styling/profile.css new file mode 100644 index 0000000..5dafe5c --- /dev/null +++ b/crates/weaver-app/assets/styling/profile.css @@ -0,0 +1,136 @@ +/* Profile display - sidebar on desktop, header on mobile */ + +.profile-display { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 4px; +} + +.profile-banner { + width: 100%; + height: 120px; + overflow: hidden; + border-radius: 4px 4px 0 0; +} + +.profile-banner img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.profile-content { + padding: 1.5rem; +} + +.profile-identity { + margin-bottom: 1.5rem; +} + +.profile-identity .avatar { + width: 120px; + height: 120px; + margin-bottom: 1rem; +} + +.profile-name-section { + margin-bottom: 0.75rem; +} + +.profile-display-name { + font-size: 1.5rem; + font-weight: 600; + color: var(--color-text); + margin: 0 0 0.25rem 0; + font-family: var(--font-heading); +} + +.profile-pronouns { + font-size: 1rem; + font-weight: 400; + color: var(--color-muted); +} + +.profile-handle { + font-size: 0.95rem; + color: var(--color-subtle); + margin-bottom: 0.5rem; +} + +.profile-location { + font-size: 0.9rem; + color: var(--color-muted); +} + +.profile-description { + color: var(--color-text); + line-height: 1.5; + margin-top: 0.75rem; + white-space: pre-wrap; +} + +.profile-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); + gap: 1rem; + padding: 1rem 0; + margin: 1rem 0; + border-top: 1px solid var(--color-border); + border-bottom: 1px solid var(--color-border); +} + +.profile-stat { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.profile-stat-label { + font-size: 0.85rem; + color: var(--color-subtle); +} + +.profile-stat-value { + font-size: 1.25rem; + font-weight: 600; + color: var(--color-primary); +} + +.profile-links { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.profile-link { + color: var(--color-link); + text-decoration: none; + font-size: 0.9rem; + transition: color 0.15s ease; +} + +.profile-link:hover { + color: var(--color-primary); +} + +.profile-link-platform { + font-weight: 500; +} + +.profile-loading { + padding: 2rem; + text-align: center; + color: var(--color-muted); +} + +/* Mobile layout */ +@media (max-width: 768px) { + .profile-identity .avatar { + width: 80px; + height: 80px; + } + + .profile-display-name { + font-size: 1.25rem; + } +} diff --git a/crates/weaver-app/src/components/mod.rs b/crates/weaver-app/src/components/mod.rs index 22d5e3a..18c93a5 100644 --- a/crates/weaver-app/src/components/mod.rs +++ b/crates/weaver-app/src/components/mod.rs @@ -11,3 +11,6 @@ pub use entry::{Entry, EntryCard}; pub mod identity; pub use identity::{NotebookCard, Repository, RepositoryIndex}; pub mod avatar; + +pub mod profile; +pub use profile::ProfileDisplay; diff --git a/crates/weaver-app/src/components/profile.rs b/crates/weaver-app/src/components/profile.rs new file mode 100644 index 0000000..e931932 --- /dev/null +++ b/crates/weaver-app/src/components/profile.rs @@ -0,0 +1,285 @@ +#![allow(non_snake_case)] + +use crate::{ + components::avatar::{Avatar, AvatarImage}, + Route, +}; +use dioxus::prelude::*; +use jacquard::types::ident::AtIdentifier; +use weaver_api::sh_weaver::actor::ProfileDataViewInner; + +const PROFILE_CSS: Asset = asset!("/assets/styling/profile.css"); + +#[component] +pub fn ProfileDisplay(ident: AtIdentifier<'static>) -> Element { + // Fetch profile data + let profile = crate::data::use_profile_data(ident.clone())?; + + match profile().as_ref() { + Some(profile_view) => rsx! { + document::Link { rel: "stylesheet", href: PROFILE_CSS } + + div { class: "profile-display", + // Banner if present + {match &profile_view.inner { + ProfileDataViewInner::ProfileView(p) => { + if let Some(ref banner) = p.banner { + rsx! { + div { class: "profile-banner", + img { src: "{banner.as_ref()}", alt: "Profile banner" } + } + } + } else { + rsx! { } + } + } + _ => rsx! { } + }} + + div { class: "profile-content", + // Avatar and identity + ProfileIdentity { profile_view: profile_view.clone(), ident: ident.clone() } + + // Stats + ProfileStats { ident: ident.clone() } + + // Links + ProfileLinks { profile_view: profile_view.clone(), ident: ident.clone() } + } + } + }, + None => rsx! { + div { class: "profile-display profile-loading", + "Loading profile..." + } + }, + } +} + +#[component] +fn ProfileIdentity( + profile_view: weaver_api::sh_weaver::actor::ProfileDataView<'static>, + ident: AtIdentifier<'static>, +) -> Element { + match &profile_view.inner { + ProfileDataViewInner::ProfileView(profile) => { + let display_name = profile + .display_name + .as_ref() + .map(|n| n.as_ref()) + .unwrap_or("Unknown"); + + // Format pronouns + let pronouns_text = if let Some(ref pronouns) = profile.pronouns { + if !pronouns.is_empty() { + Some( + pronouns + .iter() + .map(|p| p.as_ref()) + .collect::>() + .join(", "), + ) + } else { + None + } + } else { + None + }; + + rsx! { + div { class: "profile-identity", + if let Some(ref avatar) = profile.avatar { + Avatar { + AvatarImage { src: avatar.as_ref() } + } + } + + div { class: "profile-name-section", + h1 { class: "profile-display-name", + "{display_name}" + if let Some(ref pronouns) = pronouns_text { + span { class: "profile-pronouns", " ({pronouns})" } + } + } + div { class: "profile-handle", "@{ident}" } + + if let Some(ref location) = profile.location { + div { class: "profile-location", "{location}" } + } + } + + if let Some(ref description) = profile.description { + div { class: "profile-description", "{description}" } + } + } + } + } + ProfileDataViewInner::ProfileViewDetailed(profile) => { + let display_name = profile + .display_name + .as_ref() + .map(|n| n.as_ref()) + .unwrap_or("Unknown"); + + rsx! { + div { class: "profile-identity", + if let Some(ref avatar) = profile.avatar { + Avatar { + AvatarImage { src: avatar.as_ref() } + } + } + + div { class: "profile-name-section", + h1 { class: "profile-display-name", "{display_name}" } + div { class: "profile-handle", "@{ident}" } + } + + if let Some(ref description) = profile.description { + div { class: "profile-description", "{description}" } + } + } + } + } + ProfileDataViewInner::TangledProfileView(profile) => { + rsx! { + div { class: "profile-identity", + div { class: "profile-name-section", + h1 { class: "profile-display-name", "@{profile.handle.as_ref()}" } + div { class: "profile-handle", "{ident}" } + + if let Some(ref location) = profile.location { + div { class: "profile-location", "{location}" } + } + } + + if let Some(ref description) = profile.description { + div { class: "profile-description", "{description}" } + } + } + } + } + _ => rsx! { + div { class: "profile-identity", + "Unknown profile type" + } + }, + } +} + +#[component] +fn ProfileStats(ident: AtIdentifier<'static>) -> Element { + // Fetch notebook count + let notebooks = crate::data::use_notebooks_for_did(ident.clone())?; + + let notebook_count = notebooks().as_ref().map(|n| n.len()).unwrap_or(0); + + rsx! { + div { class: "profile-stats", + div { class: "profile-stat", + span { class: "profile-stat-label", "Notebooks" } + span { class: "profile-stat-value", "{notebook_count}" } + } + // TODO: Add entry count, subscriber counts when available + } + } +} + +#[component] +fn ProfileLinks( + profile_view: weaver_api::sh_weaver::actor::ProfileDataView<'static>, + ident: AtIdentifier<'static>, +) -> Element { + match &profile_view.inner { + ProfileDataViewInner::ProfileView(profile) => { + rsx! { + div { class: "profile-links", + // Generic links + if let Some(ref links) = profile.links { + for link in links.iter() { + a { + href: "{link.as_ref()}", + target: "_blank", + rel: "noopener noreferrer", + class: "profile-link", + "{link.as_ref()}" + } + } + } + + // Platform-specific links + if profile.bluesky.unwrap_or(false) { + a { + href: "https://bsky.app/profile/{ident}", + target: "_blank", + rel: "noopener noreferrer", + class: "profile-link profile-link-platform", + "View on Bluesky" + } + } + + if profile.tangled.unwrap_or(false) { + a { + href: "https://tangled.dev/{ident}", + target: "_blank", + rel: "noopener noreferrer", + class: "profile-link profile-link-platform", + "View on Tangled" + } + } + + if profile.streamplace.unwrap_or(false) { + a { + href: "https://stream.place/{ident}", + target: "_blank", + rel: "noopener noreferrer", + class: "profile-link profile-link-platform", + "View on stream.place" + } + } + } + } + } + ProfileDataViewInner::ProfileViewDetailed(_profile) => { + // Bluesky ProfileViewDetailed - doesn't have weaver platform flags + rsx! { + div { class: "profile-links", + a { + href: "https://bsky.app/profile/{ident}", + target: "_blank", + rel: "noopener noreferrer", + class: "profile-link profile-link-platform", + "View on Bluesky" + } + } + } + } + ProfileDataViewInner::TangledProfileView(profile) => { + rsx! { + div { class: "profile-links", + if let Some(ref links) = profile.links { + for link in links.iter() { + a { + href: "{link.as_ref()}", + target: "_blank", + rel: "noopener noreferrer", + class: "profile-link", + "{link.as_ref()}" + } + } + } + + if profile.bluesky { + a { + href: "https://bsky.app/profile/{ident}", + target: "_blank", + rel: "noopener noreferrer", + class: "profile-link profile-link-platform", + "View on Bluesky" + } + } + } + } + } + _ => rsx! {}, + } +} diff --git a/crates/weaver-app/src/data.rs b/crates/weaver-app/src/data.rs index 5736345..455684b 100644 --- a/crates/weaver-app/src/data.rs +++ b/crates/weaver-app/src/data.rs @@ -236,6 +236,127 @@ async fn render_markdown_impl(content: Entry<'static>, did: Did<'static>) -> Str html_buf } +/// Fetches profile data for a given identifier +#[cfg(feature = "fullstack-server")] +pub fn use_profile_data( + ident: AtIdentifier<'static>, +) -> Result>>, RenderError> { + let fetcher = use_context::(); + let ident = use_signal(|| ident); + let res = use_server_future(move || { + let fetcher = fetcher.clone(); + async move { + fetcher + .fetch_profile(&ident()) + .await + .ok() + .map(|arc| serde_json::to_value(&*arc).ok()) + .flatten() + } + })?; + Ok(use_memo(move || { + if let Some(Some(value)) = &*res.read_unchecked() { + jacquard::from_json_value::(value.clone()).ok() + } else { + None + } + })) +} + +/// Fetches profile data client-side only (no SSR) +#[cfg(not(feature = "fullstack-server"))] +pub fn use_profile_data( + ident: AtIdentifier<'static>, +) -> Result>>, RenderError> { + let fetcher = use_context::(); + let r = use_resource(use_reactive!(|ident| { + let fetcher = fetcher.clone(); + async move { fetcher.fetch_profile(&ident).await.ok().map(|arc| (*arc).clone()) } + })); + Ok(use_memo(move || { + r.read_unchecked().as_ref().and_then(|v| v.clone()) + })) +} + +/// Fetches notebooks for a specific DID +#[cfg(feature = "fullstack-server")] +pub fn use_notebooks_for_did( + ident: AtIdentifier<'static>, +) -> Result< + Memo< + Option< + Vec<( + weaver_api::sh_weaver::notebook::NotebookView<'static>, + Vec>, + )>, + >, + >, + RenderError, +> { + let fetcher = use_context::(); + let ident = use_signal(|| ident); + let res = use_server_future(move || { + let fetcher = fetcher.clone(); + async move { + fetcher + .fetch_notebooks_for_did(&ident()) + .await + .ok() + .map(|notebooks| { + notebooks + .iter() + .map(|arc| serde_json::to_value(arc.as_ref()).ok()) + .collect::>>() + }) + .flatten() + } + })?; + Ok(use_memo(move || { + if let Some(Some(values)) = &*res.read_unchecked() { + values + .iter() + .map(|v| jacquard::from_json_value::<( + weaver_api::sh_weaver::notebook::NotebookView, + Vec, + )>(v.clone()).ok()) + .collect::>>() + } else { + None + } + })) +} + +/// Fetches notebooks client-side only (no SSR) +#[cfg(not(feature = "fullstack-server"))] +pub fn use_notebooks_for_did( + ident: AtIdentifier<'static>, +) -> Result< + Memo< + Option< + Vec<( + weaver_api::sh_weaver::notebook::NotebookView<'static>, + Vec>, + )>, + >, + >, + RenderError, +> { + let fetcher = use_context::(); + let r = use_resource(use_reactive!(|ident| { + let fetcher = fetcher.clone(); + async move { + fetcher + .fetch_notebooks_for_did(&ident) + .await + .ok() + .map(|notebooks| notebooks.iter().map(|arc| (*arc).clone()).collect()) + } + })); + Ok(use_memo(move || { + r.read_unchecked().as_ref().and_then(|v| v.clone()) + })) +} + #[cfg(feature = "fullstack-server")] #[put("/cache/{ident}/{cid}?name", cache: Extension>)] pub async fn cache_blob(ident: SmolStr, cid: SmolStr, name: Option) -> Result<()> { diff --git a/crates/weaver-app/src/fetch.rs b/crates/weaver-app/src/fetch.rs index 3d3e07f..2908dfc 100644 --- a/crates/weaver-app/src/fetch.rs +++ b/crates/weaver-app/src/fetch.rs @@ -6,7 +6,10 @@ use serde::{Deserialize, Serialize}; use std::{sync::Arc, time::Duration}; use weaver_api::{ com_atproto::repo::strong_ref::StrongRef, - sh_weaver::notebook::{entry::Entry, BookEntryView, NotebookView}, + sh_weaver::{ + actor::ProfileDataView, + notebook::{entry::Entry, BookEntryView, NotebookView}, + }, }; use weaver_common::WeaverExt; @@ -30,6 +33,7 @@ pub struct CachedFetcher { (AtIdentifier<'static>, SmolStr), Arc<(BookEntryView<'static>, Entry<'static>)>, >, + profile_cache: cache_impl::Cache, Arc>>, } impl CachedFetcher { @@ -38,6 +42,7 @@ impl CachedFetcher { client, book_cache: cache_impl::new_cache(100, Duration::from_secs(1200)), entry_cache: cache_impl::new_cache(100, Duration::from_secs(600)), + profile_cache: cache_impl::new_cache(100, Duration::from_secs(1800)), } } @@ -235,4 +240,37 @@ impl CachedFetcher { Ok(None) } } + + pub async fn fetch_profile( + &self, + ident: &AtIdentifier<'_>, + ) -> Result>> { + use jacquard::IntoStatic; + + let ident_static = ident.clone().into_static(); + + if let Some(cached) = cache_impl::get(&self.profile_cache, &ident_static) { + return Ok(cached); + } + + let did = match ident { + AtIdentifier::Did(d) => d.clone(), + AtIdentifier::Handle(h) => self + .client + .resolve_handle(h) + .await + .map_err(|e| dioxus::CapturedError::from_display(e))?, + }; + + let (_uri, profile_view) = self + .client + .hydrate_profile_view(&did) + .await + .map_err(|e| dioxus::CapturedError::from_display(e))?; + + let result = Arc::new(profile_view); + cache_impl::insert(&self.profile_cache, ident_static, result.clone()); + + Ok(result) + } } diff --git a/crates/weaver-common/src/agent.rs b/crates/weaver-common/src/agent.rs index e5275f4..950c68e 100644 --- a/crates/weaver-common/src/agent.rs +++ b/crates/weaver-common/src/agent.rs @@ -564,6 +564,9 @@ impl WeaverExt for Agent { .maybe_description(weaver_record.value.description.clone()) .maybe_avatar(avatar) .maybe_banner(banner) + .maybe_bluesky(weaver_record.value.bluesky) + .maybe_tangled(weaver_record.value.tangled) + .maybe_streamplace(weaver_record.value.streamplace) .maybe_location(weaver_record.value.location.clone()) .maybe_links(weaver_record.value.links.clone()) .maybe_pronouns(weaver_record.value.pronouns.clone()) diff --git a/lexicons/actor/defs.json b/lexicons/actor/defs.json index 6691e8a..4ceca1e 100644 --- a/lexicons/actor/defs.json +++ b/lexicons/actor/defs.json @@ -43,6 +43,18 @@ "type": "ref", "ref": "#pinnedList" }, + "bluesky": { + "type": "boolean", + "description": "Include link to this account on Bluesky." + }, + "tangled": { + "type": "boolean", + "description": "Include link to this account on Tangled." + }, + "streamplace": { + "type": "boolean", + "description": "Include link to this account on stream.place." + }, "labels": { "type": "array", "items": { "type": "ref", "ref": "com.atproto.label.defs#label" } @@ -112,7 +124,7 @@ "properties": { "did": { "type": "string", "format": "did" }, "signature": { - "type": "bytes" + "type": "bytes", "description": "signed bytes of the corresponding notebook record in the author's repo" } }