atproto blogging
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}