#![allow(non_snake_case)]
use std::sync::Arc;
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::env::WEAVER_APP_HOST;
use crate::fetch::Fetcher;
use dioxus::prelude::*;
use weaver_api::com_atproto::repo::strong_ref::StrongRef;
use weaver_api::sh_weaver::actor::{ProfileDataView, ProfileDataViewInner};
use weaver_common::agent::NotebookView;
const PROFILE_CSS: Asset = asset!("/assets/styling/profile.css");
#[component]
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) => {
let profile_view = Arc::new(profile_view.clone());
rsx! {
document::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! { }
}
}
ProfileDataViewInner::ProfileViewDetailed(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() }
div {
class: "profile-extras",
// Stats
ProfileStats { notebooks, entry_count }
// Links
ProfileLinks { profile_view }
// Invites (only on own profile)
if is_own_profile {
ProfileInvites {}
}
}
}
}
}
}
_ => rsx! {
div { class: "profile-display profile-loading",
"Loading profile..."
}
},
}
}
#[component]
fn ProfileIdentity(profile_view: Arc>) -> 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",
div {
class: "profile-block",
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", "@{profile.handle}" }
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",
div {
class: "profile-block",
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", "@{profile.handle}" }
}
}
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", "{profile.handle.as_ref()}" }
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(
notebooks: Memo, Vec>)>>>,
#[props(default)] entry_count: usize,
) -> Element {
let notebook_count = notebooks.read().as_ref().map(|n| n.len()).unwrap_or(0);
rsx! {
div { class: "profile-stats",
div { class: "profile-stat",
span { class: "profile-stat-label", "{notebook_count} notebooks" }
}
if entry_count > 0 {
div { class: "profile-stat",
span { class: "profile-stat-label", "{entry_count} entries" }
}
}
}
}
}
#[component]
fn ProfileLinks(profile_view: Arc>) -> 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/{profile.did}",
target: "_blank",
rel: "noopener noreferrer",
class: "profile-link profile-link-platform",
BskyIcon { width: 20, height: 20, style: "vertical-align: text-bottom" }
" Bluesky"
}
}
if profile.tangled.unwrap_or(false) {
a {
href: "https://tangled.org/{profile.did}",
target: "_blank",
rel: "noopener noreferrer",
class: "profile-link profile-link-platform",
TangledIcon { width: 20, height: 20, style: "vertical-align: text-bottom" }
" Tangled"
}
}
if profile.streamplace.unwrap_or(false) {
a {
href: "https://stream.place/{profile.did}",
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/{profile.did}",
target: "_blank",
rel: "noopener noreferrer",
class: "profile-link profile-link-platform",
BskyIcon { width: 20, height: 20, style: "vertical-align: text-bottom" }
" 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()}"
}
}
}
a {
href: "https://tangled.org/{profile.did}",
target: "_blank",
rel: "noopener noreferrer",
class: "profile-link profile-link-platform",
TangledIcon { width: 20, height: 20, style: "vertical-align: text-bottom" }
" Tangled"
}
if profile.bluesky {
a {
href: "https://bsky.app/profile/{profile.did}",
target: "_blank",
rel: "noopener noreferrer",
class: "profile-link profile-link-platform",
BskyIcon { width: 20, height: 20, style: "vertical-align: text-bottom" }
" Bluesky"
}
}
}
}
}
_ => 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 record page on main domain
let url = format!("{}/record/{}", WEAVER_APP_HOST, resource_uri_nav);
nav.push(url);
}
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" }
}
}
}
}
}
}