This repository has no description
0

Configure Feed

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

1import React, { useState, useEffect, useCallback } from 'react'; 2import { useParams, useNavigate } from 'react-router-dom'; 3import SearchBar from '../SearchBar/SearchBar'; 4import FeedTimeline from './FeedTimeline'; 5import ActivityChart from './ActivityChart'; 6import './CollectionsFeed.css'; // Renamed to Omnifeed.css but keeping same filename for compatibility 7import { resolveHandleToDid, getServiceEndpointForDid } from '../../accountData'; 8import MatterLoadingAnimation from '../MatterLoadingAnimation'; 9import { Helmet } from 'react-helmet'; 10import { useAuth } from '../../contexts/AuthContext'; 11 12// Define the backend API base URL 13const API_BASE_URL = 'https://api.cred.blue'; 14 15const CollectionsFeed = () => { 16 const { username } = useParams(); 17 const navigate = useNavigate(); 18 const { isAuthenticated } = useAuth(); 19 20 // Initialize state variables 21 const [handle, setHandle] = useState(username || ''); 22 const [searchTerm, setSearchTerm] = useState(username || ''); 23 const [displayName, setDisplayName] = useState(''); 24 const [did, setDid] = useState(''); 25 const [serviceEndpoint, setServiceEndpoint] = useState(''); 26 const [collections, setCollections] = useState([]); 27 const [selectedCollections, setSelectedCollections] = useState([]); 28 const [records, setRecords] = useState([]); 29 const [allRecordsForChart, setAllRecordsForChart] = useState([]); // All records for chart visualization 30 const [loading, setLoading] = useState(false); 31 const [initialLoad, setInitialLoad] = useState(true); 32 const [chartLoading, setChartLoading] = useState(false); // Separate loading state for chart data 33 const [error, setError] = useState(''); 34 const [collectionCursors, setCollectionCursors] = useState({}); 35 const [fetchingMore, setFetchingMore] = useState(false); 36 const [searchPerformed, setSearchPerformed] = useState(false); 37 const [dropdownOpen, setDropdownOpen] = useState(false); 38 const [useRkeyTimestamp, setUseRkeyTimestamp] = useState(false); 39 const [compactView, setCompactView] = useState(false); 40 const [displayCount, setDisplayCount] = useState(25); 41 const [debugInfo, setDebugInfo] = useState(null); 42 const [showDebug, setShowDebug] = useState(false); 43 const [showContent, setShowContent] = useState(false); // Add state for content visibility 44 45 // Helper functions 46 const tidToTimestamp = (tid) => { 47 try { 48 // TIDs use a custom base32 encoding 49 const charset = '234567abcdefghijklmnopqrstuvwxyz'; 50 51 // Take just the timestamp part (first 10 chars) 52 const timestampChars = tid.slice(0, 10); 53 54 // Convert from base32 55 let n = 0; 56 for (let i = 0; i < timestampChars.length; i++) { 57 const charIndex = charset.indexOf(timestampChars[i]); 58 if (charIndex === -1) return null; 59 n = n * 32 + charIndex; 60 } 61 62 // The timestamp is microseconds since 2023-01-01T00:00:00Z 63 const baseTime = new Date('2023-01-01T00:00:00Z').getTime(); 64 const dateMs = baseTime + Math.floor(n / 1000); 65 66 // Convert to ISO string 67 return new Date(dateMs).toISOString(); 68 } catch (error) { 69 console.error('Error decoding TID timestamp:', error); 70 return null; 71 } 72 }; 73 74 const extractTimestamp = (record) => { 75 // First check if createdAt exists directly in the value 76 if (record.value?.createdAt) { 77 return record.value.createdAt; 78 } 79 80 // Otherwise try to find a timestamp in the record 81 // We'll use a recursive function to search through the object 82 const findTimestamp = (obj) => { 83 if (!obj || typeof obj !== 'object') return null; 84 85 // Look for common timestamp fields 86 const timestampFields = ['createdAt', 'indexedAt', 'timestamp', 'time', 'date']; 87 for (const field of timestampFields) { 88 if (obj[field] && typeof obj[field] === 'string') { 89 // Check if it looks like an ISO date string 90 if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(obj[field])) { 91 return obj[field]; 92 } 93 } 94 } 95 96 // Recursively search through child objects 97 for (const key in obj) { 98 if (typeof obj[key] === 'object' && obj[key] !== null) { 99 const found = findTimestamp(obj[key]); 100 if (found) return found; 101 } 102 } 103 104 return null; 105 }; 106 107 // Try to find a timestamp in the record 108 return findTimestamp(record); 109 }; 110 111 // Define fetchCollectionRecords with useCallback first 112 const fetchCollectionRecords = useCallback(async (userDid, endpoint, collectionsList, isLoadMore = false) => { 113 try { 114 if (!isLoadMore) { 115 setChartLoading(true); 116 } 117 118 // Skip if no collections selected 119 if (!collectionsList || collectionsList.length === 0) { 120 console.log('No collections to fetch'); 121 setFetchingMore(false); 122 setChartLoading(false); 123 return; 124 } 125 126 console.log(`Fetching records for ${collectionsList.length} collections:`, collectionsList); 127 128 // Create a copy of the cursors 129 const newCursors = { ...collectionCursors }; 130 131 let allRecords = []; 132 let allChartRecords = []; 133 134 // Check if we need full history for the initial deep load 135 const isInitialDeepLoad = !isLoadMore && allRecordsForChart.length === 0; 136 137 // Sequential processing for each collection to avoid overloading the API 138 for (const collection of collectionsList) { 139 let hasMoreRecords = true; 140 let cursor = isLoadMore ? newCursors[collection] : null; 141 let pageCount = 0; 142 let collectionRecords = []; 143 let reachedCutoff = false; 144 145 // For initial deep load, we need to paginate as many times as needed to get all historical data 146 // For regular timeline browsing or load more, we just get one page 147 // Set a high limit for safety, but essentially allow unlimited pagination until we hit the cutoff date 148 const maxPages = isInitialDeepLoad ? 1000 : 1; 149 150 while (hasMoreRecords && pageCount < maxPages && !reachedCutoff) { 151 // Use our server-side API to fetch records - Use absolute URL 152 let url = `${API_BASE_URL}/api/collections/${encodeURIComponent(userDid)}/records?endpoint=${encodeURIComponent(endpoint)}&collection=${encodeURIComponent(collection)}&limit=100`; 153 154 // Add cursor if we have one 155 if (cursor) { 156 url += `&cursor=${encodeURIComponent(cursor)}`; 157 } 158 159 try { 160 const response = await fetch(url); 161 162 if (!response.ok) { 163 let errorMessage; 164 try { 165 const errorData = await response.json(); 166 errorMessage = errorData?.error || errorData?.details || response.statusText; 167 } catch (jsonErr) { 168 errorMessage = response.statusText || `HTTP ${response.status}`; 169 } 170 171 console.error(`Error fetching records for ${collection}: ${errorMessage}`); 172 173 // Skip this collection but continue with others 174 break; 175 } 176 177 const data = await response.json(); 178 pageCount++; 179 180 // Process each record to extract timestamps 181 if (data.records && data.records.length > 0) { 182 // Process and add timestamps to records 183 const processedRecords = data.records.map(record => { 184 const contentTimestamp = extractTimestamp(record); 185 const rkey = record.uri.split('/').pop(); 186 const rkeyTimestamp = tidToTimestamp(rkey); 187 188 return { 189 ...record, 190 collection, 191 collectionType: record.value?.$type || collection, 192 contentTimestamp, 193 rkeyTimestamp, 194 rkey, 195 }; 196 }); 197 198 // Add records to our collection records array 199 collectionRecords = [...collectionRecords, ...processedRecords]; 200 201 // Check if we need to continue fetching more pages for this collection 202 if (data.cursor && isInitialDeepLoad) { 203 cursor = data.cursor; 204 205 // Check if we've reached our history cutoff date 206 const oldestRecord = processedRecords[processedRecords.length - 1]; 207 const timestamp = useRkeyTimestamp ? oldestRecord.rkeyTimestamp : oldestRecord.contentTimestamp; 208 209 if (timestamp) { 210 const recordDate = new Date(timestamp); 211 const cutoffDate = new Date(); 212 cutoffDate.setDate(cutoffDate.getDate() - 90); // 90 days ago 213 214 if (recordDate < cutoffDate) { 215 console.log(`Reached cutoff date for ${collection}, stopping pagination`); 216 reachedCutoff = true; 217 } 218 } 219 } else { 220 hasMoreRecords = false; 221 } 222 } else { 223 hasMoreRecords = false; 224 } 225 226 // Store the cursor for this collection for future "load more" operations 227 newCursors[collection] = data.cursor; 228 } catch (error) { 229 console.error(`Error processing collection ${collection}:`, error); 230 // Continue with other collections 231 break; 232 } 233 } // End of pagination while loop 234 235 // Add all records from this collection to our full records arrays 236 allChartRecords = [...allChartRecords, ...collectionRecords]; 237 238 // For display timeline, we might want to be more selective 239 if (isLoadMore || !isInitialDeepLoad) { 240 allRecords = [...allRecords, ...collectionRecords]; 241 } 242 } // End of collections for loop 243 244 // If we didn't get any records, set an error 245 if (allChartRecords.length === 0 && !isLoadMore) { 246 setError('No records found for the selected collections.'); 247 } else if (isLoadMore && allRecords.length === 0) { 248 setError('No more records available.'); 249 } else { 250 // Clear any previous error since we got records 251 setError(''); 252 } 253 254 // Filter and sort records based on selected timestamp source 255 const filterAndSort = (recordArray) => recordArray.filter(record => { 256 // Only include records with valid timestamps based on selected mode 257 if (useRkeyTimestamp) { 258 return record.rkeyTimestamp !== null; 259 } else { 260 return record.contentTimestamp !== null; 261 } 262 }).sort((a, b) => { 263 // Sort newest first 264 const aTime = useRkeyTimestamp ? a.rkeyTimestamp : a.contentTimestamp; 265 const bTime = useRkeyTimestamp ? b.rkeyTimestamp : b.contentTimestamp; 266 return new Date(bTime) - new Date(aTime); 267 }); 268 269 // Process chart records 270 const sortedChartRecords = filterAndSort(allChartRecords); 271 272 // For timeline display 273 let displayRecords; 274 if (isInitialDeepLoad) { 275 // If this was the initial deep load, take the most recent records for display 276 displayRecords = sortedChartRecords.slice(0, 20); 277 } else { 278 // Otherwise process the display records separately 279 displayRecords = filterAndSort(allRecords); 280 } 281 282 // In the case of a refresh, sortedChartRecords only contains fresh data for selected collections 283 // We need to merge this with any existing data for other collections 284 const existingRecordsToKeep = isLoadMore ? [] : allRecordsForChart.filter(record => 285 !collectionsList.includes(record.collection) 286 ); 287 288 // Variable to hold our final merged records 289 let mergedRecords; 290 291 // For refresh, remove old data for the collections we just refreshed 292 if (!isLoadMore) { 293 // Remove duplicates that might exist in both arrays 294 // This can happen if we refreshed a collection we already had data for 295 const uniqueNewRecords = sortedChartRecords.filter(newRecord => { 296 // Check if this record has the exact same URI as an existing record 297 return !existingRecordsToKeep.some(existingRecord => 298 existingRecord.uri === newRecord.uri 299 ); 300 }); 301 302 // Merge fresh data with existing data for other collections 303 mergedRecords = [...existingRecordsToKeep, ...uniqueNewRecords]; 304 } 305 else { 306 // For load more, just add all the new records 307 mergedRecords = [...existingRecordsToKeep, ...sortedChartRecords]; 308 } 309 310 // Update state 311 setRecords(displayRecords); 312 setAllRecordsForChart(mergedRecords); 313 setCollectionCursors(newCursors); 314 setFetchingMore(false); 315 316 // Always set chartLoading to false when done, regardless of initial state 317 setChartLoading(false); 318 319 } catch (err) { 320 console.error('Error fetching collection records:', err); 321 setError(`Failed to load records: ${err.message}`); 322 setFetchingMore(false); 323 setChartLoading(false); 324 } 325 }, [navigate]); 326 327 // Now define loadUserData after fetchCollectionRecords is defined 328 const loadUserData = useCallback(async (usernameOrDid) => { 329 if (!usernameOrDid) { 330 setError('Please enter a username or DID.'); 331 return; 332 } 333 334 // Reset state for new search 335 setLoading(true); 336 setShowContent(false); // Hide content while loading 337 setError(''); 338 setDid(''); 339 setServiceEndpoint(''); 340 setCollections([]); 341 setSelectedCollections([]); 342 setRecords([]); 343 setAllRecordsForChart([]); 344 setCollectionCursors({}); 345 346 try { 347 // Continue with resolving the handle to DID 348 let userDid = usernameOrDid; 349 350 // If input doesn't look like a DID, try to resolve it as a handle 351 if (!userDid.startsWith('did:')) { 352 try { 353 userDid = await resolveHandleToDid(usernameOrDid); 354 } catch (resolveErr) { 355 setError(`Could not resolve handle: ${resolveErr.message}`); 356 setLoading(false); 357 return; 358 } 359 } 360 361 // Get service endpoint 362 let endpoint; 363 try { 364 endpoint = await getServiceEndpointForDid(userDid); 365 setServiceEndpoint(endpoint); 366 } catch (endpointError) { 367 console.error('Error getting service endpoint:', endpointError); 368 setError(`Could not determine PDS endpoint for "${userDid}". The user's server may be offline.`); 369 setInitialLoad(false); 370 setLoading(false); 371 return; 372 } 373 374 // Fetch profile information 375 try { 376 const publicApiEndpoint = "https://public.api.bsky.app"; 377 const profileResponse = await fetch(`${publicApiEndpoint}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(userDid)}`); 378 379 if (!profileResponse.ok) { 380 throw new Error(`Error fetching profile: ${profileResponse.statusText}`); 381 } 382 383 const profileData = await profileResponse.json(); 384 setHandle(profileData.handle); 385 setDisplayName(profileData.displayName || profileData.handle); 386 } catch (profileError) { 387 console.error('Error fetching profile:', profileError); 388 // Continue without profile data, not critical 389 } 390 391 // Use our server-side API to fetch collections - Use absolute URL 392 try { 393 const collectionsResponse = await fetch(`${API_BASE_URL}/api/collections/${encodeURIComponent(userDid)}?endpoint=${encodeURIComponent(endpoint)}`); 394 395 if (!collectionsResponse.ok) { 396 throw new Error(`Error fetching collections: ${collectionsResponse.statusText}`); 397 } 398 399 const collectionsData = await collectionsResponse.json(); 400 401 if (collectionsData.collections && collectionsData.collections.length > 0) { 402 const sortedCollections = [...collectionsData.collections].sort(); 403 setCollections(sortedCollections); 404 // By default, select all collections 405 setSelectedCollections(sortedCollections); 406 407 // Fetch records for each collection 408 await fetchCollectionRecords(userDid, endpoint, sortedCollections); 409 } else { 410 setError('No collections found for this user.'); 411 } 412 } catch (err) { 413 console.error(`Error fetching collections:`, err); 414 throw err; // Propagate error 415 } 416 417 setSearchPerformed(true); 418 setInitialLoad(false); 419 setLoading(false); 420 // Add a slight delay before showing content for smooth transition 421 setTimeout(() => setShowContent(true), 100); 422 } catch (err) { 423 console.error('Error loading user data:', err); 424 setError(err.message || 'An error occurred while loading data.'); 425 setInitialLoad(false); 426 setLoading(false); 427 setShowContent(true); // Show content even on error so user can see error message 428 } 429 }, [navigate, fetchCollectionRecords]); 430 431 // Now place useEffects after all the callbacks are defined 432 433 // Verify authentication first 434 useEffect(() => { 435 const verifyAuth = async () => { 436 try { 437 setLoading(true); 438 setInitialLoad(true); 439 setShowContent(false); 440 441 // Reset states if username changes 442 if (username) { 443 setError(''); 444 setSearchTerm(username); 445 } 446 447 // If a username is provided in the URL, load that user's data 448 if (username && isAuthenticated) { 449 console.log('Username provided in URL, loading data for:', username); 450 loadUserData(username); 451 } else { 452 // Only set loading to false if we're not loading a specific user 453 setLoading(false); 454 setInitialLoad(false); 455 setShowContent(true); 456 } 457 } catch (err) { 458 console.error('Auth verification failed:', err); 459 setError('Authentication failed. Please try logging in again.'); 460 setLoading(false); 461 setInitialLoad(false); 462 setShowContent(true); 463 464 // Add a delay before redirecting to show the error message 465 setTimeout(() => { 466 navigate('/login'); 467 }, 2000); 468 } 469 }; 470 471 verifyAuth(); 472 473 // Safety timeout to ensure initialLoad is cleared after 10 seconds maximum 474 const loadingTimeout = setTimeout(() => { 475 setInitialLoad(false); 476 }, 10000); 477 478 return () => { 479 clearTimeout(loadingTimeout); 480 }; 481 }, [isAuthenticated, navigate, username, loadUserData]); 482 483 // Effect to watch for selected collections changes 484 useEffect(() => { 485 // Only trigger a data fetch when filters change if we haven't fetched enough data previously 486 if (selectedCollections.length > 0 && did && serviceEndpoint && searchPerformed) { 487 const hasUnfetchedCollections = selectedCollections.some(col => 488 !allRecordsForChart.some(record => record.collection === col) 489 ); 490 491 if (hasUnfetchedCollections) { 492 // Only fetch collections we haven't fetched before 493 const collectionsToFetch = selectedCollections.filter(col => 494 !allRecordsForChart.some(record => record.collection === col) 495 ); 496 497 if (collectionsToFetch.length > 0) { 498 console.log(`Fetching data for new collections: ${collectionsToFetch.join(', ')}`); 499 fetchCollectionRecords(did, serviceEndpoint, collectionsToFetch); 500 } 501 } else { 502 console.log('All collections already fetched, just filtering existing data'); 503 } 504 } 505 }, [selectedCollections, did, serviceEndpoint, searchPerformed, allRecordsForChart, fetchCollectionRecords]); 506 507 // Toggle collection selection 508 const toggleCollection = (collection) => { 509 if (selectedCollections.includes(collection)) { 510 setSelectedCollections(prev => prev.filter(item => item !== collection)); 511 } else { 512 setSelectedCollections(prev => [...prev, collection]); 513 } 514 // Reset display count when changing collections 515 setDisplayCount(25); 516 // We don't need to manually refresh here since we added a useEffect hook 517 // that watches for changes to selectedCollections 518 }; 519 520 // Select all collections 521 const selectAllCollections = () => { 522 setSelectedCollections([...collections]); 523 // Reset display count when changing collections 524 setDisplayCount(25); 525 // We don't need to manually refresh here since we added a useEffect hook 526 // that watches for changes to selectedCollections 527 }; 528 529 // Deselect all collections 530 const deselectAllCollections = () => { 531 setSelectedCollections([]); 532 // Reset display count 533 setDisplayCount(25); 534 // No need to refresh as we'll show "no collections selected" message 535 }; 536 537 // Toggle dropdown 538 const toggleDropdown = () => { 539 setDropdownOpen(!dropdownOpen); 540 }; 541 542 // Close dropdown when clicking outside 543 const closeDropdown = useCallback((e) => { 544 if (dropdownOpen && e.target.closest('.filter-dropdown-menu') === null && 545 e.target.closest('.filter-dropdown-toggle') === null) { 546 setDropdownOpen(false); 547 } 548 }, [dropdownOpen]); 549 550 // Add event listener to detect clicks outside the dropdown 551 useEffect(() => { 552 document.addEventListener('mousedown', closeDropdown); 553 return () => { 554 document.removeEventListener('mousedown', closeDropdown); 555 }; 556 }, [closeDropdown]); 557 558 // Handle refresh button click - only updates the feed, not the chart 559 const handleRefresh = async () => { 560 if (did && serviceEndpoint) { 561 // Set a loading state but not for the chart 562 setLoading(true); 563 564 // Reset display count to show only the first 25 records after refresh 565 setDisplayCount(25); 566 567 try { 568 // Fetch just the most recent records for the feed display 569 const refreshOnlyFeed = async () => { 570 let recentRecords = []; 571 572 // Process each selected collection sequentially 573 for (const collection of selectedCollections) { 574 try { 575 // Use server-side API endpoint for fetching records - Use absolute URL 576 const url = `${API_BASE_URL}/api/collections/${encodeURIComponent(did)}/records?endpoint=${encodeURIComponent(serviceEndpoint)}&collection=${encodeURIComponent(collection)}&limit=25`; 577 578 const response = await fetch(url); 579 580 if (!response.ok) { 581 console.error(`Error refreshing ${collection}: ${response.statusText}`); 582 continue; // Skip this collection but continue with others 583 } 584 585 const data = await response.json(); 586 587 if (data.records && data.records.length > 0) { 588 // Process the records with timestamps 589 const processedRecords = data.records.map(record => { 590 const contentTimestamp = extractTimestamp(record); 591 const rkey = record.uri.split('/').pop(); 592 const rkeyTimestamp = tidToTimestamp(rkey); 593 594 return { 595 ...record, 596 collection, 597 collectionType: record.value?.$type || collection, 598 contentTimestamp, 599 rkeyTimestamp, 600 rkey, 601 }; 602 }); 603 604 // Add to our records array 605 recentRecords = [...recentRecords, ...processedRecords]; 606 } 607 } catch (err) { 608 console.error(`Error refreshing collection ${collection}:`, err); 609 // Continue with other collections 610 } 611 } 612 613 // Sort the refreshed records by timestamp (newest first) 614 const sortedRecords = recentRecords.filter(record => { 615 if (useRkeyTimestamp) { 616 return record.rkeyTimestamp !== null; 617 } else { 618 return record.contentTimestamp !== null; 619 } 620 }).sort((a, b) => { 621 const aTime = useRkeyTimestamp ? a.rkeyTimestamp : a.contentTimestamp; 622 const bTime = useRkeyTimestamp ? b.rkeyTimestamp : b.contentTimestamp; 623 return new Date(bTime) - new Date(aTime); 624 }); 625 626 // Only update the feed display records, not the chart data 627 setRecords(sortedRecords.slice(0, 25)); 628 }; 629 630 await refreshOnlyFeed(); 631 } catch (err) { 632 console.error("Error during feed refresh:", err); 633 setError('Failed to refresh records. Please try again.'); 634 } finally { 635 // Ensure loading state is reset 636 setLoading(false); 637 } 638 } 639 }; 640 641 // Handle load more button click 642 const handleLoadMore = async () => { 643 setFetchingMore(true); 644 645 // First check if we already have more records locally that we can show 646 if (hasMoreRecordsLocally) { 647 // Simply increase the display count by 25 more records 648 const nextBatchSize = 25; 649 setDisplayCount(prevCount => prevCount + nextBatchSize); 650 651 setFetchingMore(false); 652 } 653 // If we've displayed all local records but have cursors to fetch more from the API 654 else if (hasMoreRecordsRemotely) { 655 // Only load more from collections that have cursors and are selected 656 const collectionsToLoad = selectedCollections.filter(collection => collectionCursors[collection]); 657 658 if (collectionsToLoad.length > 0) { 659 try { 660 await fetchCollectionRecords(did, serviceEndpoint, collectionsToLoad, true); 661 // After fetching more, we can increase the display count to show them 662 setDisplayCount(prevCount => prevCount + 25); 663 } catch (error) { 664 setError('Failed to load more records. Please try again.'); 665 } 666 } else { 667 setFetchingMore(false); 668 } 669 } else { 670 setFetchingMore(false); 671 } 672 }; 673 674 // Filter ALL chart records based on selected collections 675 const filteredChartRecords = allRecordsForChart.filter(record => 676 selectedCollections.includes(record.collection) 677 ); 678 679 // For timeline display, directly use the chart records but limit to the current displayCount 680 // This ensures we always show the most recent records for the selected collections 681 const filteredRecords = filteredChartRecords 682 .sort((a, b) => { 683 // Sort by timestamp (newest first) 684 const aTime = useRkeyTimestamp ? a.rkeyTimestamp : a.contentTimestamp; 685 const bTime = useRkeyTimestamp ? b.rkeyTimestamp : b.contentTimestamp; 686 return new Date(bTime) - new Date(aTime); 687 }) 688 .slice(0, displayCount); // Show based on displayCount state 689 690 // Check if more records can be loaded - either from API or from our existing dataset 691 const hasMoreRecordsLocally = filteredChartRecords.length > filteredRecords.length; 692 const hasMoreRecordsRemotely = Object.keys(collectionCursors).some(collection => 693 selectedCollections.includes(collection) 694 ); 695 const canLoadMore = hasMoreRecordsLocally || hasMoreRecordsRemotely; 696 697 // Add the handleSubmit function 698 const handleSubmit = (e) => { 699 e.preventDefault(); 700 if (searchTerm.trim() !== '') { 701 // Instead of directly loading the user data, navigate to their dedicated omnifeed page 702 navigate(`/omnifeed/${searchTerm.trim()}`); 703 } 704 }; 705 706 // Add a function to fetch debug info - Use absolute API URL 707 const fetchDebugInfo = async () => { 708 try { 709 const response = await fetch(`${API_BASE_URL}/api/debug/session`, { // Use absolute URL 710 credentials: 'include' // Keep credentials if needed for debug endpoint 711 }); 712 713 if (!response.ok) { 714 setDebugInfo({ error: `Server returned ${response.status}: ${response.statusText}` }); 715 return; 716 } 717 718 const data = await response.json(); 719 setDebugInfo(data); 720 } catch (error) { 721 console.error('Error fetching debug info:', error); 722 setDebugInfo({ error: error.message }); 723 } 724 725 setShowDebug(true); 726 }; 727 728 return ( 729 <div className="collections-feed-container"> 730 <Helmet> 731 <title>{username ? `${username}'s Omnifeed` : 'Omnifeed'}</title> 732 <meta name="description" content={username ? `View ${username}'s AT Protocol collection records in chronological order` : 'View AT Protocol collection records in chronological order'} /> 733 </Helmet> 734 735 {/* Display MatterLoadingAnimation for the main loading state when username is provided */} 736 {username && loading && !error && ( 737 <div className="matter-loading-container"> 738 <MatterLoadingAnimation /> 739 </div> 740 )} 741 742 {/* Only show the main content when not in full-screen loading mode */} 743 {(!username || !loading || error || showContent) && ( 744 <div className={`search-container ${showContent ? 'fade-in' : ''}`}> 745 <h1>OmniFeed</h1> 746 <p className="feed-description"> 747 View all repository collections for a Bluesky user, including custom collections from AT Protocol apps. 748 </p> 749 750 <form onSubmit={handleSubmit} className="search-form"> 751 <div className="search-box"> 752 <input 753 type="text" 754 placeholder="Enter a Bluesky handle (e.g. cred.blue)" 755 value={searchTerm} 756 onChange={(e) => setSearchTerm(e.target.value)} 757 disabled={loading} 758 /> 759 <button 760 type="submit" 761 disabled={loading || !searchTerm || searchTerm.trim() === ''} 762 > 763 {loading ? 'Loading...' : 'Search'} 764 </button> 765 </div> 766 </form> 767 768 {/* Error message with more styling and retry button */} 769 {error && ( 770 <div className="error-container"> 771 <div className="error-message"> 772 <p>{error}</p> 773 <button 774 className="error-action-button" 775 onClick={() => { 776 setError(''); 777 if (username) { 778 loadUserData(username); 779 } 780 }} 781 > 782 Retry 783 </button> 784 </div> 785 </div> 786 )} 787 788 {/* Show simpler loading spinner when searching from the form, not the full MatterLoadingAnimation */} 789 {!username && initialLoad && !error && ( 790 <div className="loading-indicator"> 791 <div className="spinner"></div> 792 <p>Connecting to AT Protocol services...</p> 793 </div> 794 )} 795 796 {/* Main content once search is performed */} 797 {searchPerformed && !initialLoad && ( 798 <div className="user-info"> 799 {displayName && ( 800 <h2> 801 Collections for {displayName} 802 <span className="handle">@{handle}</span> 803 </h2> 804 )} 805 806 {/* Collections count and timeframe */} 807 {collections.length > 0 && ( 808 <div className="collections-meta"> 809 <p>{collections.length} collections found</p> 810 <div className="time-toggle"> 811 <label> 812 <input 813 type="checkbox" 814 checked={useRkeyTimestamp} 815 onChange={() => setUseRkeyTimestamp(!useRkeyTimestamp)} 816 /> 817 Use record IDs for timestamps 818 </label> 819 <span className="info-tooltip"> 820 ? 821 <span className="tooltip-text"> 822 Toggle between using timestamps found within the content (more accurate) or derived from record IDs (complete coverage) 823 </span> 824 </span> 825 </div> 826 </div> 827 )} 828 829 {/* Collections filter area */} 830 {collections.length > 0 && ( 831 <div className="feed-controls"> 832 <div className="filter-container"> 833 <div 834 className="filter-dropdown-toggle" 835 onClick={toggleDropdown} 836 > 837 <span> 838 Collections 839 <span className="selected-collections-count"> 840 {selectedCollections.length} 841 </span> 842 </span> 843 <span className={`arrow ${dropdownOpen ? 'open' : ''}`}></span> 844 </div> 845 846 <div className={`filter-dropdown-menu ${dropdownOpen ? 'open' : ''}`}> 847 <div className="filter-header"> 848 <div className="filter-header-top"> 849 <h3>Collections</h3> 850 <button className="filter-close-mobile" onClick={() => setDropdownOpen(false)}> 851 852 </button> 853 </div> 854 <div className="filter-actions"> 855 <button 856 className="select-all-button" 857 onClick={selectAllCollections} 858 disabled={collections.length === selectedCollections.length} 859 > 860 Select All 861 </button> 862 <button 863 className="deselect-all-button" 864 onClick={deselectAllCollections} 865 disabled={selectedCollections.length === 0} 866 > 867 Deselect All 868 </button> 869 </div> 870 </div> 871 872 <div className="collections-filter"> 873 {collections.map(collection => ( 874 <div 875 key={collection} 876 className={`collection-item ${selectedCollections.includes(collection) ? 'selected' : ''}`} 877 onClick={() => toggleCollection(collection)} 878 > 879 <input 880 type="checkbox" 881 className="collection-item-checkbox" 882 checked={selectedCollections.includes(collection)} 883 onChange={() => toggleCollection(collection)} 884 onClick={(e) => e.stopPropagation()} 885 /> 886 <span className="collection-item-name">{collection}</span> 887 </div> 888 ))} 889 </div> 890 </div> 891 892 {dropdownOpen && <div className="filter-dropdown-backdrop open" onClick={() => setDropdownOpen(false)}></div>} 893 </div> 894 895 <button 896 onClick={handleRefresh} 897 className="refresh-button" 898 disabled={loading || fetchingMore || selectedCollections.length === 0} 899 > 900 {loading ? 'Refreshing...' : 'Refresh'} 901 </button> 902 </div> 903 )} 904 905 {/* Loading indicator for chart update */} 906 {chartLoading && ( 907 <div className="chart-loading"> 908 <div className="spinner"></div> 909 <p>Loading historical data for visualization...</p> 910 </div> 911 )} 912 913 {/* Activity Chart */} 914 {!chartLoading && filteredChartRecords.length > 0 && ( 915 <div className="omni-chart-container"> 916 <h3>Activity Timeline</h3> 917 <ActivityChart 918 records={filteredChartRecords} 919 useRkeyTimestamp={useRkeyTimestamp} 920 /> 921 </div> 922 )} 923 924 {/* Feed heading */} 925 {selectedCollections.length > 0 && ( 926 <> 927 <h3 className="feed-heading">Record Feed</h3> 928 {filteredRecords.length === 0 && !loading && ( 929 <p className="no-records-message">No records found for the selected collections.</p> 930 )} 931 </> 932 )} 933 934 {/* Feed records */} 935 {selectedCollections.length === 0 ? ( 936 <p className="no-collections-selected">Select at least one collection to see records.</p> 937 ) : ( 938 <> 939 <FeedTimeline 940 records={filteredRecords} 941 useRkeyTimestamp={useRkeyTimestamp} 942 loading={loading} 943 /> 944 945 {/* Load more button */} 946 {filteredRecords.length > 0 && (hasMoreRecordsLocally || hasMoreRecordsRemotely) && ( 947 <div className="load-more-container"> 948 <button 949 onClick={handleLoadMore} 950 disabled={fetchingMore} 951 className="load-more-button" 952 > 953 {fetchingMore ? 'Loading...' : 'Load More'} 954 </button> 955 </div> 956 )} 957 </> 958 )} 959 </div> 960 )} 961 {!searchPerformed && !loading && !username && ( 962 <p>Enter a username above to get started.</p> // Initial state message 963 )} 964 </div> 965 )} 966 967 {/* Debug button */} 968 <div className="debug-controls"> 969 <button 970 className="debug-button" 971 onClick={fetchDebugInfo} 972 title="Check authentication status" 973 > 974 Debug Auth 975 </button> 976 977 {showDebug && debugInfo && ( 978 <div className="debug-panel"> 979 <div className="debug-header"> 980 <h4>Session Debug Info</h4> 981 <button onClick={() => setShowDebug(false)}>Close</button> 982 </div> 983 <pre>{JSON.stringify(debugInfo, null, 2)}</pre> 984 </div> 985 )} 986 </div> 987 </div> 988 ); 989}; 990 991export default CollectionsFeed;