This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

at main 24 kB View raw
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);