atproto blogging
1//! Collaborator avatars display for the editor meta row.
2
3use std::sync::Arc;
4
5use crate::auth::AuthState;
6use crate::fetch::Fetcher;
7use dioxus::prelude::*;
8use jacquard::types::ident::AtIdentifier;
9use jacquard::types::string::AtUri;
10use weaver_api::sh_weaver::actor::{ProfileDataView, ProfileDataViewInner};
11
12use super::api::find_all_participants;
13use super::CollaboratorsPanel;
14
15/// Props for the CollaboratorAvatars component.
16#[derive(Props, Clone, PartialEq)]
17pub struct CollaboratorAvatarsProps {
18 /// The resource URI to show collaborators for.
19 pub resource_uri: AtUri<'static>,
20 /// CID of the resource.
21 pub resource_cid: String,
22 /// Optional title for display in the panel.
23 #[props(default)]
24 pub resource_title: Option<String>,
25}
26
27/// Shows collaborator avatars with a button to manage collaborators.
28/// Displays all participants (collaborators with accepted invites) regardless of
29/// whether you're the owner or a collaborator.
30#[component]
31pub fn CollaboratorAvatars(props: CollaboratorAvatarsProps) -> Element {
32 let auth_state = use_context::<Signal<AuthState>>();
33 let fetcher = use_context::<Fetcher>();
34 let mut show_panel = use_signal(|| false);
35
36 let resource_uri = props.resource_uri.clone();
37
38 // Fetch all participants (owner + collaborators) with their profiles
39 let collaborators = {
40 let fetcher = fetcher.clone();
41 let resource_uri = resource_uri.clone();
42 use_resource(move || {
43 let fetcher = fetcher.clone();
44 let resource_uri = resource_uri.clone();
45 let _auth = auth_state.read().did.clone(); // Reactivity trigger
46 async move {
47 let dids = find_all_participants(&fetcher, &resource_uri)
48 .await
49 .unwrap_or_default();
50
51 // Fetch profile for each participant
52 let mut profiles = Vec::new();
53 for did in dids {
54 let ident = AtIdentifier::Did(did);
55 if let Ok(profile) = fetcher.fetch_profile(&ident).await {
56 profiles.push(profile);
57 }
58 }
59 profiles
60 }
61 })
62 };
63
64 let collabs: Vec<Arc<ProfileDataView<'static>>> = collaborators().unwrap_or_default();
65 let collab_count = collabs.len();
66
67 rsx! {
68 div { class: "collaborator-avatars",
69 onclick: move |_| show_panel.set(true),
70
71 // Show up to 3 avatar circles
72 for (i, profile) in collabs.iter().take(3).enumerate() {
73 {
74 let (avatar, display_name, handle) = match &profile.inner {
75 ProfileDataViewInner::ProfileView(p) => (
76 p.avatar.as_ref(),
77 p.display_name.as_ref().map(|s| s.as_ref()),
78 p.handle.as_ref(),
79 ),
80 ProfileDataViewInner::ProfileViewDetailed(p) => (
81 p.avatar.as_ref(),
82 p.display_name.as_ref().map(|s| s.as_ref()),
83 p.handle.as_ref(),
84 ),
85 ProfileDataViewInner::TangledProfileView(p) => (
86 None,
87 None,
88 p.handle.as_ref(),
89 ),
90 _ => (None, None, "unknown"),
91 };
92 let title = display_name.unwrap_or(handle);
93 let initials = get_initials(display_name, handle);
94
95 rsx! {
96 div {
97 class: "collab-avatar",
98 style: "z-index: {3 - i}",
99 title: "{title}",
100
101 if let Some(avatar_url) = avatar {
102 img {
103 class: "collab-avatar-img",
104 src: avatar_url.as_ref(),
105 alt: "{title}",
106 }
107 } else {
108 "{initials}"
109 }
110 }
111 }
112 }
113 }
114
115 // Show +N if more than 3
116 if collab_count > 3 {
117 div { class: "collab-avatar collab-overflow",
118 "+{collab_count - 3}"
119 }
120 }
121
122 // Always show the add button
123 div { class: "collab-avatar collab-add",
124 title: "Manage collaborators",
125 "+"
126 }
127 }
128
129 if show_panel() {
130 CollaboratorsPanel {
131 resource_uri: props.resource_uri.clone(),
132 resource_cid: props.resource_cid.clone(),
133 resource_title: props.resource_title.clone(),
134 on_close: move |_| show_panel.set(false),
135 }
136 }
137 }
138}
139
140/// Get initials from display name or handle.
141fn get_initials(display_name: Option<&str>, handle: &str) -> String {
142 if let Some(name) = display_name {
143 name.split_whitespace()
144 .take(2)
145 .filter_map(|w| w.chars().next())
146 .collect::<String>()
147 .to_uppercase()
148 } else {
149 handle.chars().next().unwrap_or('?').to_uppercase().to_string()
150 }
151}