at main 462 lines 17 kB view raw
1#![allow(non_snake_case)] 2 3use std::sync::Arc; 4 5use crate::components::button::{Button, ButtonVariant}; 6use crate::components::collab::api::{ReceivedInvite, accept_invite, fetch_received_invites}; 7use crate::components::{ 8 BskyIcon, TangledIcon, 9 avatar::{Avatar, AvatarImage}, 10}; 11use crate::env::WEAVER_APP_HOST; 12use crate::fetch::Fetcher; 13use dioxus::prelude::*; 14use weaver_api::com_atproto::repo::strong_ref::StrongRef; 15use weaver_api::sh_weaver::actor::{ProfileDataView, ProfileDataViewInner}; 16use weaver_common::agent::NotebookView; 17 18const PROFILE_CSS: Asset = asset!("/assets/styling/profile.css"); 19 20#[component] 21pub fn ProfileDisplay( 22 profile: Memo<Option<ProfileDataView<'static>>>, 23 notebooks: Memo<Option<Vec<(NotebookView<'static>, Vec<StrongRef<'static>>)>>>, 24 #[props(default)] entry_count: usize, 25 #[props(default)] is_own_profile: bool, 26) -> Element { 27 match &*profile.read() { 28 Some(profile_view) => { 29 let profile_view = Arc::new(profile_view.clone()); 30 rsx! { 31 document::Stylesheet { href: PROFILE_CSS } 32 33 div { class: "profile-display", 34 // Banner if present 35 {match &profile_view.inner { 36 ProfileDataViewInner::ProfileView(p) => { 37 if let Some(ref banner) = p.banner { 38 rsx! { 39 div { class: "profile-banner", 40 img { src: "{banner.as_ref()}", alt: "Profile banner" } 41 } 42 } 43 } else { 44 rsx! { } 45 } 46 } 47 ProfileDataViewInner::ProfileViewDetailed(p) => { 48 if let Some(ref banner) = p.banner { 49 rsx! { 50 div { class: "profile-banner", 51 img { src: "{banner.as_ref()}", alt: "Profile banner" } 52 } 53 } 54 } else { 55 rsx! { } 56 } 57 } 58 _ => rsx! { } 59 }} 60 61 div { class: "profile-content", 62 // Avatar and identity 63 ProfileIdentity { profile_view: profile_view.clone() } 64 div { 65 class: "profile-extras", 66 // Stats 67 ProfileStats { notebooks, entry_count } 68 69 // Links 70 ProfileLinks { profile_view } 71 72 // Invites (only on own profile) 73 if is_own_profile { 74 ProfileInvites {} 75 } 76 } 77 } 78 } 79 } 80 } 81 _ => rsx! { 82 div { class: "profile-display profile-loading", 83 "Loading profile..." 84 } 85 }, 86 } 87} 88 89#[component] 90fn ProfileIdentity(profile_view: Arc<ProfileDataView<'static>>) -> Element { 91 match &profile_view.inner { 92 ProfileDataViewInner::ProfileView(profile) => { 93 let display_name = profile 94 .display_name 95 .as_ref() 96 .map(|n| n.as_ref()) 97 .unwrap_or("Unknown"); 98 99 // Format pronouns 100 let pronouns_text = if let Some(ref pronouns) = profile.pronouns { 101 if !pronouns.is_empty() { 102 Some( 103 pronouns 104 .iter() 105 .map(|p| p.as_ref()) 106 .collect::<Vec<_>>() 107 .join(", "), 108 ) 109 } else { 110 None 111 } 112 } else { 113 None 114 }; 115 116 rsx! { 117 div { class: "profile-identity", 118 div { 119 class: "profile-block", 120 if let Some(ref avatar) = profile.avatar { 121 Avatar { 122 AvatarImage { src: avatar.as_ref() } 123 } 124 } 125 126 div { class: "profile-name-section", 127 h1 { class: "profile-display-name", 128 "{display_name}" 129 if let Some(ref pronouns) = pronouns_text { 130 span { class: "profile-pronouns", " ({pronouns})" } 131 } 132 } 133 div { class: "profile-handle", "@{profile.handle}" } 134 135 if let Some(ref location) = profile.location { 136 div { class: "profile-location", "{location}" } 137 } 138 } 139 } 140 141 142 if let Some(ref description) = profile.description { 143 div { class: "profile-description", "{description}" } 144 } 145 } 146 } 147 } 148 ProfileDataViewInner::ProfileViewDetailed(profile) => { 149 let display_name = profile 150 .display_name 151 .as_ref() 152 .map(|n| n.as_ref()) 153 .unwrap_or("Unknown"); 154 155 rsx! { 156 div { class: "profile-identity", 157 div { 158 class: "profile-block", 159 if let Some(ref avatar) = profile.avatar { 160 Avatar { 161 AvatarImage { src: avatar.as_ref() } 162 } 163 } 164 165 div { class: "profile-name-section", 166 h1 { class: "profile-display-name", "{display_name}" } 167 div { class: "profile-handle", "@{profile.handle}" } 168 } 169 } 170 171 if let Some(ref description) = profile.description { 172 div { class: "profile-description", "{description}" } 173 } 174 } 175 } 176 } 177 ProfileDataViewInner::TangledProfileView(profile) => { 178 rsx! { 179 div { class: "profile-identity", 180 div { class: "profile-name-section", 181 h1 { class: "profile-display-name", "@{profile.handle.as_ref()}" } 182 //div { class: "profile-handle", "{profile.handle.as_ref()}" } 183 184 if let Some(ref location) = profile.location { 185 div { class: "profile-location", "{location}" } 186 } 187 } 188 189 if let Some(ref description) = profile.description { 190 div { class: "profile-description", "{description}" } 191 } 192 } 193 } 194 } 195 _ => rsx! { 196 div { class: "profile-identity", 197 "Unknown profile type" 198 } 199 }, 200 } 201} 202 203#[component] 204fn ProfileStats( 205 notebooks: Memo<Option<Vec<(NotebookView<'static>, Vec<StrongRef<'static>>)>>>, 206 #[props(default)] entry_count: usize, 207) -> Element { 208 let notebook_count = notebooks.read().as_ref().map(|n| n.len()).unwrap_or(0); 209 210 rsx! { 211 div { class: "profile-stats", 212 div { class: "profile-stat", 213 span { class: "profile-stat-label", "{notebook_count} notebooks" } 214 } 215 if entry_count > 0 { 216 div { class: "profile-stat", 217 span { class: "profile-stat-label", "{entry_count} entries" } 218 } 219 } 220 } 221 } 222} 223 224#[component] 225fn ProfileLinks(profile_view: Arc<ProfileDataView<'static>>) -> Element { 226 match &profile_view.inner { 227 ProfileDataViewInner::ProfileView(profile) => { 228 rsx! { 229 div { class: "profile-links", 230 // Generic links 231 if let Some(ref links) = profile.links { 232 for link in links.iter() { 233 a { 234 href: "{link.as_ref()}", 235 target: "_blank", 236 rel: "noopener noreferrer", 237 class: "profile-link", 238 "{link.as_ref()}" 239 } 240 } 241 } 242 243 // Platform-specific links 244 if profile.bluesky.unwrap_or(false) { 245 a { 246 href: "https://bsky.app/profile/{profile.did}", 247 target: "_blank", 248 rel: "noopener noreferrer", 249 class: "profile-link profile-link-platform", 250 BskyIcon { width: 20, height: 20, style: "vertical-align: text-bottom" } 251 " Bluesky" 252 } 253 } 254 255 if profile.tangled.unwrap_or(false) { 256 a { 257 href: "https://tangled.org/{profile.did}", 258 target: "_blank", 259 rel: "noopener noreferrer", 260 class: "profile-link profile-link-platform", 261 TangledIcon { width: 20, height: 20, style: "vertical-align: text-bottom" } 262 " Tangled" 263 } 264 } 265 266 if profile.streamplace.unwrap_or(false) { 267 a { 268 href: "https://stream.place/{profile.did}", 269 target: "_blank", 270 rel: "noopener noreferrer", 271 class: "profile-link profile-link-platform", 272 "View on stream.place" 273 } 274 } 275 } 276 } 277 } 278 ProfileDataViewInner::ProfileViewDetailed(profile) => { 279 // Bluesky ProfileViewDetailed - doesn't have weaver platform flags 280 rsx! { 281 div { class: "profile-links", 282 a { 283 href: "https://bsky.app/profile/{profile.did}", 284 target: "_blank", 285 rel: "noopener noreferrer", 286 class: "profile-link profile-link-platform", 287 BskyIcon { width: 20, height: 20, style: "vertical-align: text-bottom" } 288 " Bluesky" 289 } 290 291 } 292 } 293 } 294 ProfileDataViewInner::TangledProfileView(profile) => { 295 rsx! { 296 div { class: "profile-links", 297 if let Some(ref links) = profile.links { 298 for link in links.iter() { 299 a { 300 href: "{link.as_ref()}", 301 target: "_blank", 302 rel: "noopener noreferrer", 303 class: "profile-link", 304 "{link.as_ref()}" 305 } 306 } 307 } 308 a { 309 href: "https://tangled.org/{profile.did}", 310 target: "_blank", 311 rel: "noopener noreferrer", 312 class: "profile-link profile-link-platform", 313 TangledIcon { width: 20, height: 20, style: "vertical-align: text-bottom" } 314 " Tangled" 315 } 316 317 if profile.bluesky { 318 a { 319 href: "https://bsky.app/profile/{profile.did}", 320 target: "_blank", 321 rel: "noopener noreferrer", 322 class: "profile-link profile-link-platform", 323 BskyIcon { width: 20, height: 20, style: "vertical-align: text-bottom" } 324 " Bluesky" 325 } 326 } 327 } 328 } 329 } 330 _ => rsx! {}, 331 } 332} 333 334/// Shows pending collaboration invites on the user's own profile. 335#[component] 336fn ProfileInvites() -> Element { 337 let fetcher = use_context::<Fetcher>(); 338 339 // Fetch received invites 340 let invites_resource = { 341 let fetcher = fetcher.clone(); 342 use_resource(move || { 343 let fetcher = fetcher.clone(); 344 async move { 345 fetch_received_invites(&fetcher) 346 .await 347 .ok() 348 .unwrap_or_default() 349 } 350 }) 351 }; 352 353 let invites: Vec<ReceivedInvite> = invites_resource().unwrap_or_default(); 354 355 // Don't render section if no invites 356 if invites.is_empty() { 357 return rsx! {}; 358 } 359 360 rsx! { 361 div { class: "profile-invites", 362 h3 { class: "profile-invites-header", "Collaboration Invites" } 363 364 div { class: "profile-invites-list", 365 for invite in invites { 366 ProfileInviteCard { invite } 367 } 368 } 369 } 370 } 371} 372 373/// A single invite card in the profile sidebar. 374#[component] 375fn ProfileInviteCard(invite: ReceivedInvite) -> Element { 376 let fetcher = use_context::<Fetcher>(); 377 let nav = use_navigator(); 378 let mut is_accepting = use_signal(|| false); 379 let mut accepted = use_signal(|| false); 380 let mut error = use_signal(|| None::<String>); 381 382 let invite_uri = invite.uri.clone(); 383 let invite_cid = invite.cid.clone(); 384 let resource_uri = invite.resource_uri.clone(); 385 let resource_uri_nav = invite.resource_uri.clone(); 386 387 let handle_accept = move |_| { 388 let fetcher = fetcher.clone(); 389 let invite_uri = invite_uri.clone(); 390 let invite_cid = invite_cid.clone(); 391 let resource_uri = resource_uri.clone(); 392 let resource_uri_nav = resource_uri_nav.clone(); 393 394 spawn(async move { 395 is_accepting.set(true); 396 error.set(None); 397 398 let invite_ref = StrongRef::new().uri(invite_uri).cid(invite_cid).build(); 399 400 match accept_invite(&fetcher, invite_ref, resource_uri).await { 401 Ok(_) => { 402 accepted.set(true); 403 // Navigate to the resource after a short delay 404 #[cfg(target_arch = "wasm32")] 405 { 406 use gloo_timers::future::TimeoutFuture; 407 TimeoutFuture::new(500).await; 408 } 409 // Navigate to record page on main domain 410 let url = format!("{}/record/{}", WEAVER_APP_HOST, resource_uri_nav); 411 nav.push(url); 412 } 413 Err(e) => { 414 error.set(Some(format!("Failed: {}", e))); 415 } 416 } 417 418 is_accepting.set(false); 419 }); 420 }; 421 422 // Extract inviter display (last part of DID for now) 423 let inviter_display = invite 424 .inviter 425 .as_ref() 426 .split(':') 427 .last() 428 .unwrap_or("unknown") 429 .chars() 430 .take(12) 431 .collect::<String>(); 432 433 rsx! { 434 div { class: "profile-invite-card", 435 div { class: "profile-invite-from", 436 "From: " 437 span { class: "profile-invite-did", "{inviter_display}…" } 438 } 439 440 if let Some(msg) = &invite.message { 441 p { class: "profile-invite-message", "{msg}" } 442 } 443 444 if let Some(err) = error() { 445 div { class: "profile-invite-error", "{err}" } 446 } 447 448 div { class: "profile-invite-actions", 449 if accepted() { 450 span { class: "profile-invite-accepted", "Accepted ✓" } 451 } else { 452 Button { 453 variant: ButtonVariant::Primary, 454 onclick: handle_accept, 455 disabled: is_accepting(), 456 if is_accepting() { "Accepting..." } else { "Accept" } 457 } 458 } 459 } 460 } 461 } 462}