This repository has no description
1import React, { useEffect, useState, createContext, useRef, useMemo } from "react";
2import { useParams, useNavigate } from "react-router-dom";
3import { Responsive, WidthProvider } from "react-grid-layout";
4import { loadAccountData } from "../../accountData";
5import { isDID, resolveDIDToHandle } from "../../utils/didUtils";
6import Card from "../Card/Card";
7import MatterLoadingAnimation from "../MatterLoadingAnimation";
8import ScoreGauge from './ScoreGauge';
9import CircularLogo from './CircularLogo';
10import { Helmet } from 'react-helmet';
11import ProfileCard from "./components/ProfileCard";
12import NarrativeCard from "./components/NarrativeCard";
13import PostTypeCard from "./components/PostTypeCard";
14import AltTextCard from "./components/AltTextCard";
15import ActivityCard from "./components/ActivityCard";
16import ScoreBreakdownCard from "./components/ScoreBreakdownCard";
17import ErrorPage from "../ErrorPage/ErrorPage";
18import _ from 'lodash';
19
20import "react-grid-layout/css/styles.css";
21import "react-resizable/css/styles.css";
22import "./UserProfile.css";
23
24// Memoized layouts configuration
25const CARD_HEIGHT = 6;
26const breakpoints = { lg: 850, md: 700, sm: 520, xs: 460, xxs: 0 };
27const cols = { lg: 2, md: 2, sm: 1, xs: 1, xxs: 1 };
28
29// Cache configuration
30const userDataCache = new Map();
31const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
32
33// Move layouts outside component to prevent recreation
34const createLayouts = () => ({
35 lg: [
36 { i: "ScoreBreakdownCard", x: 0, y: 0, w: 1, h: CARD_HEIGHT, static: false },
37 { i: "NarrativeCard", x: 1, y: 0, w: 1, h: CARD_HEIGHT, static: false },
38 { i: "ProfileCard", x: 0, y: CARD_HEIGHT, w: 1, h: CARD_HEIGHT, static: false },
39 { i: "PostTypeCard", x: 1, y: CARD_HEIGHT, w: 1, h: CARD_HEIGHT, static: false },
40 { i: "AltTextCard", x: 0, y: CARD_HEIGHT * 2, w: 1, h: CARD_HEIGHT, static: false },
41 { i: "ActivityCard", x: 1, y: CARD_HEIGHT * 2, w: 1, h: CARD_HEIGHT, static: false }
42 ],
43 md: [
44 { i: "ScoreBreakdownCard", x: 0, y: 0, w: 1, h: CARD_HEIGHT, static: false },
45 { i: "NarrativeCard", x: 1, y: 0, w: 1, h: CARD_HEIGHT, static: false },
46 { i: "ProfileCard", x: 0, y: CARD_HEIGHT, w: 1, h: CARD_HEIGHT, static: false },
47 { i: "PostTypeCard", x: 1, y: CARD_HEIGHT, w: 1, h: CARD_HEIGHT, static: false },
48 { i: "AltTextCard", x: 0, y: CARD_HEIGHT * 2, w: 1, h: CARD_HEIGHT, static: false },
49 { i: "ActivityCard", x: 1, y: CARD_HEIGHT * 2, w: 1, h: CARD_HEIGHT, static: false }
50 ],
51 sm: [
52 { i: "ScoreBreakdownCard", x: 0, y: 8, w: 1, h: 6, static: false },
53 { i: "NarrativeCard", x: 0, y: 0, w: 1, h: 6, static: false },
54 { i: "ProfileCard", x: 0, y: 14, w: 1, h: 6, static: false },
55 { i: "PostTypeCard", x: 0, y: 22, w: 1, h: 6, static: false },
56 { i: "AltTextCard", x: 0, y: 26, w: 1, h: 6, static: false },
57 { i: "ActivityCard", x: 0, y: 30, w: 1, h: 6, static: false }
58 ],
59 xs: [
60 { i: "ScoreBreakdownCard", x: 0, y: 8, w: 1, h: 6, static: false },
61 { i: "NarrativeCard", x: 0, y: 0, w: 1, h: 6, static: false },
62 { i: "ProfileCard", x: 0, y: 14, w: 1, h: 6, static: false },
63 { i: "PostTypeCard", x: 0, y: 22, w: 1, h: 6, static: false },
64 { i: "AltTextCard", x: 0, y: 26, w: 1, h: 6, static: false },
65 { i: "ActivityCard", x: 0, y: 30, w: 1, h: 6, static: false }
66 ],
67 xxs: [
68 { i: "ScoreBreakdownCard", x: 0, y: 8, w: 1, h: 6, static: false },
69 { i: "NarrativeCard", x: 0, y: 0, w: 1, h: 8, static: false },
70 { i: "ProfileCard", x: 0, y: 14, w: 1, h: 7, static: false },
71 { i: "PostTypeCard", x: 0, y: 22, w: 1, h: 6, static: false },
72 { i: "AltTextCard", x: 0, y: 26, w: 1, h: 6, static: false },
73 { i: "ActivityCard", x: 0, y: 30, w: 1, h: 6, static: false }
74 ]
75});
76
77// Memoized save function with debouncing
78const createDebouncedSave = () => {
79 let timeout;
80 return async (userData) => {
81 if (timeout) clearTimeout(timeout);
82 timeout = setTimeout(async () => {
83 try {
84 // Basic validation before sending
85 if (!userData || !userData.did || !userData.handle) {
86 console.error('Invalid user data format');
87 return;
88 }
89
90 // Send to untrusted table
91 const response = await fetch('https://api.cred.blue/api/save-user-data', {
92 method: 'POST',
93 headers: {
94 'Content-Type': 'application/json'
95 },
96 body: JSON.stringify(userData)
97 });
98
99 if (!response.ok) {
100 const errorData = await response.json();
101 throw new Error(errorData.error || 'Failed to submit user data');
102 }
103
104 console.log('User data submitted successfully for processing');
105 } catch (error) {
106 console.error('Error submitting user data:', error);
107 }
108 }, 1000);
109 };
110};
111
112const processAccountData = (data) => {
113 if (!data) return null;
114
115 return {
116 ...data,
117 breakdown: {
118 blueskyCategories: {
119 profileQuality: {
120 score: data.blueskyScore * 0.25,
121 weight: 0.25,
122 details: {
123 profileCompleteness: data.profileCompleteness || 0,
124 altTextConsistency: data.altTextConsistencyBonus || 0,
125 customDomain: data.customDomainBonus || 0
126 }
127 },
128 communityEngagement: {
129 score: data.blueskyScore * 0.35,
130 weight: 0.35,
131 details: {
132 socialGraph: data.socialGraphScore || 0,
133 engagement: data.engagementScore || 0,
134 replyActivity: data.activityScore || 0
135 }
136 },
137 contentActivity: {
138 score: data.blueskyScore * 0.25,
139 weight: 0.25,
140 details: {
141 posts: data.activityDetails?.postsScore || 0,
142 collections: data.collectionsScore?.bskyCollectionsScore || 0,
143 labels: (data.labelBonus || 0) + (data.labelPenalty || 0)
144 }
145 },
146 recognitionStatus: {
147 score: data.blueskyScore * 0.15,
148 weight: 0.15,
149 details: {
150 teamStatus: data.handleBonuses?.teamBonus || 0,
151 contributorStatus: data.handleBonuses?.contributorBonus || 0,
152 socialStatus: data.socialStatusBonus || 0
153 }
154 }
155 },
156 atprotoCategories: {
157 decentralization: {
158 score: data.atprotoScore * 0.45,
159 weight: 0.45,
160 details: {
161 rotationKeys: data.rotationKeyBonus || 0,
162 didWeb: data.didWebBonus || 0,
163 thirdPartyPDS: data.thirdPartyPDSBonus || 0,
164 customDomain: data.customDomainBonus || 0
165 }
166 },
167 protocolActivity: {
168 score: data.atprotoScore * 0.35,
169 weight: 0.35,
170 details: {
171 nonBskyCollections: data.collectionsScore?.nonBskyCollectionsScore || 0,
172 atprotoActivity: data.atprotoActivityBonus || 0
173 }
174 },
175 accountMaturity: {
176 score: data.atprotoScore * 0.20,
177 weight: 0.20,
178 details: {
179 accountAge: data.accountAgeScore || 0,
180 contributorStatus: data.handleBonuses?.contributorBonus || 0
181 }
182 }
183 }
184 }
185 };
186};
187
188export const AccountDataContext = createContext(null);
189const ResponsiveGridLayout = WidthProvider(Responsive);
190const debouncedSaveUserData = createDebouncedSave();
191
192const UserProfile = () => {
193 const { username } = useParams();
194 const navigate = useNavigate();
195 const [accountData, setAccountData] = useState(null);
196 const [loading, setLoading] = useState(true);
197 const [error, setError] = useState(null);
198 const [showContent, setShowContent] = useState(false);
199 const cardRefs = useRef({});
200 const [cardHeights, setCardHeights] = useState({});
201
202 // Add state for saving and restoring layouts
203 const [currentLayouts, setCurrentLayouts] = useState(() => {
204 // Try to load saved layouts from localStorage
205 const savedLayouts = localStorage.getItem('userProfileLayouts');
206 return savedLayouts ? JSON.parse(savedLayouts) : createLayouts();
207 });
208
209 // Function to handle layout changes
210 const handleLayoutChange = (layout, allLayouts) => {
211 if (allLayouts) {
212 // Save to state
213 setCurrentLayouts(allLayouts);
214
215 // Save to localStorage
216 localStorage.setItem('userProfileLayouts', JSON.stringify(allLayouts));
217 }
218
219 // Update card heights after layout changes
220 setTimeout(updateCardHeights, 100);
221 };
222
223 // Improved card height calculation with extra buffer to avoid scrollbars
224 const updateCardHeights = useMemo(() => {
225 return () => {
226 const rowHeight = 50; // Same as your rowHeight prop
227 const margin = 20; // Same as your margin prop
228 const bufferFactor = 1.15; // Add 15% extra space to avoid scrollbars
229 const newHeights = {};
230 const currentWidth = window.innerWidth;
231
232 // Determine current breakpoint
233 let currentBreakpoint = 'lg';
234 for (const [bp, width] of Object.entries(breakpoints)) {
235 if (currentWidth <= width) {
236 currentBreakpoint = bp;
237 }
238 }
239
240 // Get the number of columns for the current breakpoint
241 const numCols = cols[currentBreakpoint];
242
243 // Adjust calculations based on available width
244 const containerWidth = document.querySelector('.user-profile')?.clientWidth || window.innerWidth;
245 const availableWidth = (containerWidth - (margin * (numCols + 1))) / numCols;
246
247 Object.keys(cardRefs.current).forEach(key => {
248 const element = cardRefs.current[key];
249 if (element) {
250 // Get both the card and its content element
251 const cardElement = element.firstChild;
252 const contentElement = cardElement.querySelector('.card-content');
253 const headerElement = cardElement.querySelector('.card-header');
254
255 if (contentElement && headerElement) {
256 // Clone the content element to measure its natural height without constraints
257 const clone = contentElement.cloneNode(true);
258 clone.style.position = 'absolute';
259 clone.style.visibility = 'hidden';
260 clone.style.width = `${availableWidth - 32}px`; // Account for padding
261 clone.style.height = 'auto';
262 clone.style.overflow = 'visible'; // Ensure we get the full height
263 document.body.appendChild(clone);
264
265 // Measure natural height
266 const contentHeight = clone.scrollHeight;
267
268 // Remove the clone
269 document.body.removeChild(clone);
270
271 // Add header height and padding to content height
272 const headerHeight = headerElement.offsetHeight;
273 const totalCardHeight = (contentHeight * bufferFactor) + headerHeight;
274
275 // Convert to grid units with extra space to ensure no scrollbars
276 const gridHeight = Math.max(Math.ceil(totalCardHeight / rowHeight), 6);
277
278 // Store the new height
279 newHeights[key] = gridHeight;
280 }
281 }
282 });
283
284 // Update layout with new heights
285 const updatedLayouts = {};
286
287 for (const [bp, layout] of Object.entries(currentLayouts)) {
288 updatedLayouts[bp] = layout.map(item => {
289 if (newHeights[item.i]) {
290 return { ...item, h: newHeights[item.i] };
291 }
292 return item;
293 });
294 }
295
296 // Only update if there are actual changes
297 if (!_.isEqual(updatedLayouts, currentLayouts)) {
298 setCurrentLayouts(updatedLayouts);
299 localStorage.setItem('userProfileLayouts', JSON.stringify(updatedLayouts));
300 }
301
302 // Update state with new heights
303 setCardHeights(newHeights);
304
305 // Remove any scrollbars by checking if content overflow exists
306 // This runs after state updates and DOM re-renders
307 setTimeout(() => {
308 Object.keys(cardRefs.current).forEach(key => {
309 const element = cardRefs.current[key];
310 if (element) {
311 const contentElement = element.querySelector('.card-content');
312 if (contentElement && contentElement.scrollHeight > contentElement.clientHeight) {
313 // Content still overflows, adjust the height further
314 const currentLayout = updatedLayouts[currentBreakpoint].find(item => item.i === key);
315 if (currentLayout) {
316 const additionalRows = Math.ceil((contentElement.scrollHeight - contentElement.clientHeight) / rowHeight) + 1;
317 const newGridHeight = currentLayout.h + additionalRows;
318
319 // Update just this specific card height
320 const newLayout = { ...currentLayout, h: newGridHeight };
321 updatedLayouts[currentBreakpoint] = updatedLayouts[currentBreakpoint].map(item =>
322 item.i === key ? newLayout : item
323 );
324
325 // Apply updates
326 setCurrentLayouts(updatedLayouts);
327 localStorage.setItem('userProfileLayouts', JSON.stringify(updatedLayouts));
328 }
329 }
330 }
331 });
332 }, 300);
333 };
334 }, [currentLayouts]);
335
336 // Add a function to handle window resize events more effectively
337 const handleWindowResize = useMemo(() => {
338 return _.debounce(() => {
339 // Update card heights and force layout refresh
340 updateCardHeights();
341
342 // Force a layout recalculation
343 const gridLayoutElement = document.querySelector('.react-grid-layout');
344 if (gridLayoutElement) {
345 // This forces a reflow
346 const temp = gridLayoutElement.offsetHeight;
347 }
348 }, 200);
349 }, [updateCardHeights]);
350
351 // Optimized data fetching with DID support
352 useEffect(() => {
353 const fetchAccountData = async () => {
354 try {
355 setLoading(true);
356 let handle = username;
357
358 // Handle DID resolution if necessary
359 if (isDID(username)) {
360 try {
361 handle = await resolveDIDToHandle(username);
362 // Redirect to the handle-based URL
363 navigate(`/${handle}`, { replace: true });
364 return; // The navigation will trigger a new effect
365 } catch (didError) {
366 console.error("Error resolving DID:", didError);
367 setError(`Could not resolve DID: ${didError.message}`);
368 setLoading(false);
369 return;
370 }
371 }
372
373 // Check cache first
374 const cachedData = userDataCache.get(handle);
375 if (cachedData && Date.now() - cachedData.timestamp < CACHE_TTL) {
376 setAccountData(cachedData.data);
377 setLoading(false);
378 setShowContent(true);
379 return;
380 }
381
382 const data = await loadAccountData(handle);
383 if (data.error) throw new Error(data.error);
384
385 // Process the data to ensure correct structure for the treemap
386 const processed90DaysData = processAccountData(data.accountData90Days);
387
388 // Update cache
389 userDataCache.set(handle, {
390 data: processed90DaysData,
391 timestamp: Date.now()
392 });
393
394 setAccountData(processed90DaysData);
395
396 // Debounced save to database
397 await debouncedSaveUserData(processed90DaysData);
398
399 } catch (err) {
400 console.error("Error fetching account data:", err);
401 setError(err.message);
402 } finally {
403 setLoading(false);
404 setTimeout(() => {
405 setShowContent(true);
406 updateCardHeights();
407 }, 500);
408 }
409 };
410 fetchAccountData();
411 }, [username, navigate, updateCardHeights]);
412
413 // Add effect to initialize the layout on first render with multiple updates
414 // to ensure proper sizing as components load
415 useEffect(() => {
416 // Initial update after components mount
417 setTimeout(updateCardHeights, 300);
418
419 // Secondary update after any async content has likely loaded
420 setTimeout(updateCardHeights, 1000);
421
422 // Final update to catch any late-loading content
423 setTimeout(updateCardHeights, 2000);
424
425 // Add a resize observer to each card to handle content changes
426 const resizeObserver = new ResizeObserver(_.debounce(() => {
427 updateCardHeights();
428 // Additional update after a short delay to catch any cascading changes
429 setTimeout(updateCardHeights, 500);
430 }, 300));
431
432 // Observe both the cards and their content
433 Object.values(cardRefs.current).forEach(ref => {
434 if (ref) {
435 resizeObserver.observe(ref);
436
437 // Also observe the content element for more granular changes
438 const contentElement = ref.querySelector('.card-content');
439 if (contentElement) {
440 resizeObserver.observe(contentElement);
441 }
442 }
443 });
444
445 return () => {
446 resizeObserver.disconnect();
447 };
448 }, [updateCardHeights]);
449
450 // Memoized resize handler
451 useEffect(() => {
452 window.addEventListener('resize', handleWindowResize);
453 return () => {
454 window.removeEventListener('resize', handleWindowResize);
455 handleWindowResize.cancel();
456 };
457 }, [handleWindowResize]);
458
459 if (loading) {
460 return (
461 <div className={`user-profile loading-container ${!loading && "fade-out"}`}>
462 <MatterLoadingAnimation />
463 </div>
464 );
465 }
466
467 if (error || !accountData) {
468 return <ErrorPage username={username} onNavigate={navigate} />;
469 }
470
471 const { displayName, handle: resolvedHandle } = accountData;
472
473 return (
474 <AccountDataContext.Provider value={accountData}>
475 <Helmet>
476 <title>{`${resolvedHandle}'s cred.blue Score`}</title>
477 <meta name="description" content={`Check ${resolvedHandle}'s Bluesky score and data footprint on cred.blue`} />
478 <meta property="og:title" content={`${resolvedHandle} - cred.blue Score`} />
479 <meta property="og:description" content={`Check ${resolvedHandle}'s Bluesky score and data footprint on cred.blue`} />
480 <meta property="og:url" content={`https://cred.blue/${resolvedHandle}`} />
481 <meta name="twitter:title" content={`${resolvedHandle} - cred.blue Score`} />
482 <meta name="twitter:description" content={`Check ${resolvedHandle}'s Bluesky score and data footprint on cred.blue`} />
483 </Helmet>
484 <div className={`user-profile ${showContent ? "fade-in" : "hidden"}`}>
485 <div className="user-profile-container">
486 <div className="profile-sections-wrapper">
487 {/* Right Section */}
488 <div className="profile-section right-section">
489 <div className="user-profile-main">
490 <div className="user-profile-name">
491 <h1>{displayName}</h1>
492 <h2>@{resolvedHandle}</h2>
493 </div>
494 <div className="user-profile-data-group">
495 <div className="user-profile-score">
496 <p><strong>Bluesky Score:</strong> {accountData.blueskyScore}</p>
497 <p><strong>ATProto Score:</strong> {accountData.atprotoScore}</p>
498 </div>
499 <div className="user-profile-activity">
500 <p><strong>Bluesky Status:</strong> {accountData.activityAll.bskyActivityStatus}</p>
501 <p><strong>ATProto Status:</strong> {accountData.activityAll.atprotoActivityStatus}</p>
502 </div>
503 </div>
504 <div className="share-button-container">
505 <button
506 className="share-button-profile"
507 type="button"
508 onClick={() => window.open(
509 `https://bsky.app/intent/compose?text=${encodeURIComponent(
510 `My @cred.blue score is ${accountData.combinedScore}! 🦋\n\nI've been on Bluesky for ${accountData.ageInDays} days, joined during the "${accountData.era.toLowerCase()}" era, and have a social status of "${accountData.socialStatus}"\n\nGet your score: cred.blue`
511 )}`, '_blank'
512 )}
513 >
514 Share Results
515 </button>
516 <button
517 className="comparea-button-profile"
518 type="button"
519 onClick={() => window.open(`https://cred.blue/compare`, '_blank')}
520 >
521 Compare Scores
522 </button>
523 </div>
524 <a className="bluesky-link" href={`https://bsky.app/profile/${resolvedHandle}`} target="_blank" rel="noopener noreferrer">View {resolvedHandle} on Bluesky</a>
525 </div>
526 </div>
527
528 {/* Middle Section */}
529 <div className="profile-section middle-section">
530 <div className="user-profile-header-rechart">
531 <ScoreGauge score={accountData.combinedScore} />
532 </div>
533 <div className="context-line">
534 <p>Average is ~325, highest is ~789</p>
535 </div>
536 <div className="user-profile-badges">
537 <h3>{accountData.socialStatus}</h3>
538 <h3>{accountData.postingStyle}</h3>
539 </div>
540 <div className="user-profile-age">
541 <h2>{Math.floor(accountData.ageInDays)} days old</h2>
542 </div>
543 </div>
544
545 {/* Left Section */}
546 <div className="profile-section left-section">
547 <CircularLogo
548 did={accountData.did}
549 size={370}
550 fontSize={35}
551 textColor="#004f84"
552 />
553 </div>
554 </div>
555 </div>
556
557 <ResponsiveGridLayout
558 className="layout"
559 layouts={currentLayouts}
560 breakpoints={breakpoints}
561 cols={cols}
562 rowHeight={50}
563 margin={[20, 20]}
564 isDraggable={true}
565 isResizable={true}
566 useCSSTransforms={true}
567 onLayoutChange={handleLayoutChange}
568 draggableHandle=".card-header"
569 resizeHandles={['se']}
570 compactType="vertical"
571 preventCollision={false}
572 autoSize={true}
573 onBreakpointChange={(newBreakpoint) => {
574 // Recalculate heights when breakpoint changes
575 setTimeout(updateCardHeights, 100);
576 }}
577 onWidthChange={(width, margin, cols) => {
578 // Recalculate heights when width changes
579 setTimeout(updateCardHeights, 100);
580 }}
581 >
582 <div key="ScoreBreakdownCard" className="grid-item" ref={el => cardRefs.current.ScoreBreakdownCard = el}>
583 <Card title="Score Breakdown">
584 <ScoreBreakdownCard />
585 </Card>
586 </div>
587 <div key="NarrativeCard" className="grid-item" ref={el => cardRefs.current.NarrativeCard = el}>
588 <Card title="Summary">
589 <NarrativeCard />
590 </Card>
591 </div>
592 <div key="PostTypeCard" className="grid-item" ref={el => cardRefs.current.PostTypeCard = el}>
593 <Card title="Post Type Breakdown">
594 <PostTypeCard />
595 </Card>
596 </div>
597 <div key="ProfileCard" className="grid-item" ref={el => cardRefs.current.ProfileCard = el}>
598 <Card title="Profile Data">
599 <ProfileCard />
600 </Card>
601 </div>
602 <div key="AltTextCard" className="grid-item" ref={el => cardRefs.current.AltTextCard = el}>
603 <Card title="Alt Text Consistency">
604 <AltTextCard />
605 </Card>
606 </div>
607 <div key="ActivityCard" className="grid-item" ref={el => cardRefs.current.ActivityCard = el}>
608 <Card title="Activity Overview">
609 <ActivityCard />
610 </Card>
611 </div>
612 </ResponsiveGridLayout>
613 </div>
614 </AccountDataContext.Provider>
615 );
616};
617
618export default React.memo(UserProfile);