atproto blogging
1//! AuthorList component for displaying multiple authors with progressive disclosure.
2
3use crate::components::{AppLink, AppLinkTarget};
4use dioxus::prelude::*;
5use jacquard::IntoStatic;
6use jacquard::types::ident::AtIdentifier;
7use jacquard::types::string::{Did, Handle, Uri};
8use weaver_api::sh_weaver::actor::ProfileDataViewInner;
9use weaver_api::sh_weaver::notebook::AuthorListView;
10
11const AUTHOR_CSS: Asset = asset!("./author.css");
12
13/// Normalized author data extracted from ProfileDataViewInner variants.
14#[derive(Clone, PartialEq)]
15pub struct AuthorInfo {
16 pub did: Did<'static>,
17 pub handle: Handle<'static>,
18 pub display_name: Option<String>,
19 pub avatar_url: Option<Uri<'static>>,
20}
21
22impl AuthorInfo {
23 /// Check if this author matches an AtIdentifier (comparing DID or handle as appropriate).
24 pub fn matches_ident(&self, ident: &AtIdentifier<'_>) -> bool {
25 match ident {
26 AtIdentifier::Did(did) => self.did == *did,
27 AtIdentifier::Handle(handle) => self.handle == *handle,
28 }
29 }
30}
31
32/// Extract normalized author info from ProfileDataViewInner.
33/// Returns None for unknown/unhandled variants.
34pub fn extract_author_info(inner: &ProfileDataViewInner<'_>) -> Option<AuthorInfo> {
35 match inner {
36 ProfileDataViewInner::ProfileView(p) => Some(AuthorInfo {
37 did: p.did.clone().into_static(),
38 handle: p.handle.clone().into_static(),
39 display_name: p.display_name.as_ref().map(|n| n.to_string()),
40 avatar_url: p.avatar.clone().map(|u| u.into_static()),
41 }),
42 ProfileDataViewInner::ProfileViewDetailed(p) => Some(AuthorInfo {
43 did: p.did.clone().into_static(),
44 handle: p.handle.clone().into_static(),
45 display_name: p.display_name.as_ref().map(|n| n.to_string()),
46 avatar_url: p.avatar.clone().map(|u| u.into_static()),
47 }),
48 ProfileDataViewInner::TangledProfileView(p) => Some(AuthorInfo {
49 did: p.did.clone().into_static(),
50 handle: p.handle.clone().into_static(),
51 display_name: None,
52 avatar_url: None,
53 }),
54 _ => None,
55 }
56}
57
58#[derive(Clone, Copy, PartialEq)]
59enum DisplayMode {
60 Hidden,
61 Full,
62 Compact,
63 Collapsed,
64}
65
66fn determine_display_mode(
67 author_infos: &[AuthorInfo],
68 profile_ident: &Option<AtIdentifier<'static>>,
69) -> DisplayMode {
70 let count = author_infos.len();
71
72 // Context-aware: single author matching profile ident = hidden
73 if count == 1 {
74 if let Some(pident) = profile_ident {
75 if author_infos[0].matches_ident(pident) {
76 return DisplayMode::Hidden;
77 }
78 }
79 }
80
81 match count {
82 0 => DisplayMode::Hidden,
83 1 | 2 => DisplayMode::Full,
84 3 | 4 => DisplayMode::Compact,
85 _ => DisplayMode::Collapsed,
86 }
87}
88
89#[derive(Props, Clone, PartialEq)]
90pub struct AuthorListProps {
91 /// The authors to display.
92 pub authors: Vec<AuthorListView<'static>>,
93
94 /// Optional profile identity for context-aware visibility.
95 /// If set and there's only 1 author matching this identity, render nothing.
96 #[props(default)]
97 pub profile_ident: Option<AtIdentifier<'static>>,
98
99 /// Optional resource owner identity - this author will be sorted first.
100 #[props(default)]
101 pub owner_ident: Option<AtIdentifier<'static>>,
102
103 /// Avatar size in the full block display (default: 42).
104 #[props(default = 42)]
105 pub avatar_size: u32,
106
107 /// Additional CSS class for the container.
108 #[props(default)]
109 pub class: Option<String>,
110}
111
112/// Displays a list of authors with progressive disclosure based on count.
113///
114/// - 1-2 authors: Full block (avatar + name + handle)
115/// - 3-4 authors: Compact (names only, comma-separated)
116/// - 5+ authors: Collapsed ("Name, Name, et al.")
117///
118/// Compact/collapsed modes expand on click to show full dropdown.
119#[component]
120pub fn AuthorList(props: AuthorListProps) -> Element {
121 let mut expanded = use_signal(|| false);
122
123 let container_class = props.class.as_deref().unwrap_or("");
124
125 // Pre-extract all author infos, filtering out unknown variants
126 let mut author_infos: Vec<AuthorInfo> = props
127 .authors
128 .iter()
129 .filter_map(|a| extract_author_info(&a.record.inner))
130 .collect();
131
132 // Sort owner first if specified
133 if let Some(ref owner) = props.owner_ident {
134 author_infos.sort_by_key(|info| if info.matches_ident(owner) { 0 } else { 1 });
135 }
136
137 let mode = determine_display_mode(&author_infos, &props.profile_ident);
138
139 match mode {
140 DisplayMode::Hidden => rsx! {},
141
142 DisplayMode::Full => rsx! {
143 document::Stylesheet { href: AUTHOR_CSS }
144 div { class: "author-list author-list-full {container_class}",
145 for info in author_infos.iter() {
146 AuthorBlock { info: info.clone(), avatar_size: props.avatar_size }
147 }
148 }
149 },
150
151 DisplayMode::Compact => rsx! {
152 document::Stylesheet { href: AUTHOR_CSS }
153 div {
154 class: "author-list author-list-compact {container_class}",
155 onclick: move |_| expanded.set(true),
156 for (i, info) in author_infos.iter().enumerate() {
157 if i > 0 {
158 span { class: "author-separator", ", " }
159 }
160 AuthorInline { info: info.clone() }
161 }
162
163 if expanded() {
164 AuthorDropdown {
165 authors: author_infos.clone(),
166 avatar_size: props.avatar_size,
167 on_close: move |_| expanded.set(false),
168 }
169 }
170 }
171 },
172
173 DisplayMode::Collapsed => {
174 let first_two: Vec<_> = author_infos.iter().take(2).cloned().collect();
175 let remaining = author_infos.len().saturating_sub(2);
176
177 rsx! {
178 document::Stylesheet { href: AUTHOR_CSS }
179 div {
180 class: "author-list author-list-collapsed {container_class}",
181 onclick: move |_| expanded.set(true),
182 for (i, info) in first_two.iter().enumerate() {
183 if i > 0 {
184 span { class: "author-separator", ", " }
185 }
186 AuthorInline { info: info.clone() }
187 }
188 span { class: "author-et-al", " et al. ({remaining} more)" }
189
190 if expanded() {
191 AuthorDropdown {
192 authors: author_infos.clone(),
193 avatar_size: props.avatar_size,
194 on_close: move |_| expanded.set(false),
195 }
196 }
197 }
198 }
199 }
200 }
201}
202
203/// Full author display with avatar, name, and handle (as a link).
204#[component]
205fn AuthorBlock(info: AuthorInfo, avatar_size: u32) -> Element {
206 let display = info
207 .display_name
208 .as_deref()
209 .unwrap_or_else(|| info.handle.as_ref());
210 let handle_display = info.handle.as_ref();
211
212 rsx! {
213 AppLink {
214 to: AppLinkTarget::Profile {
215 ident: AtIdentifier::Handle(info.handle.clone())
216 },
217 class: Some("embed-author author-block".to_string()),
218 if let Some(ref avatar) = info.avatar_url {
219 img {
220 class: "embed-avatar",
221 src: avatar.as_ref(),
222 alt: "",
223 width: "{avatar_size}",
224 height: "{avatar_size}",
225 }
226 }
227 span { class: "embed-author-info",
228 span { class: "embed-author-name", "{display}" }
229 span { class: "embed-author-handle", "@{handle_display}" }
230 }
231 }
232 }
233}
234
235/// Inline author name only (as a link), for compact display.
236#[component]
237fn AuthorInline(info: AuthorInfo) -> Element {
238 let display = info
239 .display_name
240 .as_deref()
241 .unwrap_or_else(|| info.handle.as_ref());
242
243 rsx! {
244 AppLink {
245 to: AppLinkTarget::Profile {
246 ident: AtIdentifier::Handle(info.handle.clone())
247 },
248 class: Some("author-inline".to_string()),
249 "{display}"
250 }
251 }
252}
253
254/// Dropdown overlay showing all authors in full block display.
255#[component]
256fn AuthorDropdown(
257 authors: Vec<AuthorInfo>,
258 avatar_size: u32,
259 on_close: EventHandler<()>,
260) -> Element {
261 rsx! {
262 div {
263 class: "author-list-dropdown-overlay",
264 onclick: move |e| {
265 e.stop_propagation();
266 on_close.call(());
267 },
268 div {
269 class: "author-list-dropdown-content",
270 onclick: move |e| e.stop_propagation(),
271 div { class: "author-list-dropdown",
272 for info in authors.iter() {
273 AuthorBlock { info: info.clone(), avatar_size }
274 }
275 }
276 }
277 }
278 }
279}