This repository has no description
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;