ATlast — you'll never need to find your favorites on another platform again. Find your favs in the ATmosphere.
atproto
17
fork

Configure Feed

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

at c393643d43d5811f8a49b20f600e4171d1bb22de 484 lines 17 kB view raw
1import React, { useState, useEffect, useCallback, Suspense, lazy } from "react"; 2import { ArrowRight } from "lucide-react"; 3import { useAuth } from "./hooks/useAuth"; 4import { useSearch } from "./hooks/useSearch"; 5import { useFollow } from "./hooks/useFollows"; 6import { useFileUpload } from "./hooks/useFileUpload"; 7import { useTheme } from "./hooks/useTheme"; 8import { useNotifications } from "./hooks/useNotifications"; 9import Firefly from "./components/Firefly"; 10import NotificationContainer from "./components/common/NotificationContainer"; 11import ErrorBoundary from "./components/common/ErrorBoundary"; 12import AriaLiveAnnouncer from "./components/common/AriaLiveAnnouncer"; 13import { SearchResultSkeleton } from "./components/common/LoadingSkeleton"; 14import { DEFAULT_SETTINGS } from "./types/settings"; 15import type { UserSettings, SearchResult } from "./types"; 16import { apiClient } from "./lib/api/client"; 17import { ATPROTO_APPS } from "./config/atprotoApps"; 18import { useSettingsStore } from "./stores/useSettingsStore"; 19 20// Lazy load page components 21const LoginPage = lazy(() => import("./pages/Login")); 22const HomePage = lazy(() => import("./pages/Home")); 23const LoadingPage = lazy(() => import("./pages/Loading")); 24const ResultsPage = lazy(() => import("./pages/Results")); 25 26// Loading fallback component 27const PageLoader: React.FC = () => ( 28 <div className="p-6 max-w-md mx-auto mt-8"> 29 <div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-8 text-center space-y-4"> 30 <div className="w-16 h-16 bg-firefly-banner dark:bg-firefly-banner-dark text-white rounded-2xl mx-auto flex items-center justify-center"> 31 <ArrowRight className="w-8 h-8 text-white animate-pulse" /> 32 </div> 33 <h2 className="text-xl font-bold text-gray-900 dark:text-gray-100"> 34 Loading... 35 </h2> 36 </div> 37 </div> 38); 39 40export default function App() { 41 // Auth hook 42 const { 43 session, 44 currentStep, 45 statusMessage, 46 setCurrentStep, 47 setStatusMessage, 48 login, 49 logout, 50 } = useAuth(); 51 52 // Notifications hook (only for errors now) 53 const { notifications, removeNotification, error } = useNotifications(); 54 55 // Aria-live announcements for non-error feedback (invisible, screen-reader only) 56 const [ariaAnnouncement, setAriaAnnouncement] = useState(""); 57 58 // Theme hook 59 const { isDark, reducedMotion, toggleTheme, toggleMotion } = useTheme(); 60 61 // Current platform state 62 const [currentPlatform, setCurrentPlatform] = useState<string>("tiktok"); 63 64 // Track saved uploads to prevent duplicates 65 const [savedUploads, setSavedUploads] = useState<Set<string>>(new Set()); 66 67 // Settings state 68 const userSettings = useSettingsStore((state) => state.settings); 69 const handleSettingsUpdate = useSettingsStore((state) => state.updateSettings); 70 71 // Search hook 72 const { 73 searchResults, 74 setSearchResults, 75 searchProgress, 76 expandedResults, 77 searchAllUsers, 78 toggleMatchSelection, 79 toggleExpandResult, 80 selectAllMatches, 81 deselectAllMatches, 82 totalSelected, 83 totalFound, 84 } = useSearch(session); 85 86 const currentDestinationAppId = 87 userSettings.platformDestinations[ 88 currentPlatform as keyof UserSettings["platformDestinations"] 89 ]; 90 91 // Follow hook 92 const { isFollowing, followSelectedUsers } = useFollow( 93 session, 94 searchResults, 95 setSearchResults, 96 currentDestinationAppId, 97 ); 98 99 // Save results handler (proper state management) 100 const saveResults = useCallback( 101 async (uploadId: string, platform: string, results: SearchResult[]) => { 102 if (!userSettings.saveData) { 103 console.log("Data storage disabled - skipping save to database"); 104 return; 105 } 106 107 if (savedUploads.has(uploadId)) { 108 console.log("Upload already saved:", uploadId); 109 return; 110 } 111 112 try { 113 setSavedUploads((prev) => new Set(prev).add(uploadId)); 114 await apiClient.saveResults(uploadId, platform, results); 115 console.log("Results saved successfully:", uploadId); 116 } catch (err) { 117 console.error("Background save failed:", err); 118 setSavedUploads((prev) => { 119 const next = new Set(prev); 120 next.delete(uploadId); 121 return next; 122 }); 123 } 124 }, 125 [userSettings.saveData, savedUploads], 126 ); 127 128 // File upload handler 129 const { handleFileUpload: processFileUpload } = useFileUpload( 130 (initialResults, platform) => { 131 setCurrentPlatform(platform); 132 setSearchResults(initialResults); 133 setCurrentStep("loading"); 134 135 const uploadId = crypto.randomUUID(); 136 const followLexicon = 137 ATPROTO_APPS[currentDestinationAppId]?.followLexicon; 138 139 searchAllUsers( 140 initialResults, 141 setStatusMessage, 142 (finalResults) => { 143 setCurrentStep("results"); 144 145 // Save results after search completes 146 if (finalResults.length > 0) { 147 saveResults(uploadId, platform, finalResults); 148 } 149 }, 150 followLexicon, 151 ); 152 }, 153 setStatusMessage, 154 userSettings, 155 ); 156 157 // Load previous upload handler 158 const handleLoadUpload = useCallback( 159 async (uploadId: string) => { 160 try { 161 setStatusMessage("Loading previous upload..."); 162 setCurrentStep("loading"); 163 164 const data = await apiClient.getUploadDetails(uploadId); 165 166 if (data.results.length === 0) { 167 setSearchResults([]); 168 setCurrentPlatform("tiktok"); 169 setCurrentStep("home"); 170 // No visual feedback needed - empty state will show in UI 171 setAriaAnnouncement("No previous results found."); 172 return; 173 } 174 175 // Detect platform from first result's username or default to twitter for extension imports 176 const platform = "twitter"; // Extension imports are always from Twitter for now 177 setCurrentPlatform(platform); 178 179 // Check if this is a new upload with no matches yet 180 const hasMatches = data.results.some(r => r.atprotoMatches.length > 0); 181 182 const loadedResults: SearchResult[] = data.results.map((result) => ({ 183 sourceUser: result.sourceUser, // SourceUser object { username, date } 184 sourcePlatform: platform, 185 isSearching: !hasMatches, // Search if no matches exist yet 186 atprotoMatches: result.atprotoMatches || [], 187 selectedMatches: new Set<string>( 188 (result.atprotoMatches || []) 189 .filter( 190 (match) => 191 !match.followStatus || 192 !Object.values(match.followStatus).some((status) => status), 193 ) 194 .slice(0, 1) 195 .map((match) => match.did), 196 ), 197 })); 198 199 setSearchResults(loadedResults); 200 201 // If no matches yet, trigger search BEFORE navigating to results 202 if (!hasMatches) { 203 const followLexicon = ATPROTO_APPS[currentDestinationAppId]?.followLexicon; 204 205 await searchAllUsers( 206 loadedResults, 207 (message) => setStatusMessage(message), 208 async (finalResults) => { 209 // Search complete - save results and navigate to results page 210 await saveResults(uploadId, platform, finalResults); 211 setCurrentStep("results"); 212 }, 213 followLexicon 214 ); 215 } else { 216 // Already has matches, navigate to results immediately 217 setCurrentStep("results"); 218 } 219 220 // Announce to screen readers only - visual feedback is navigation to results page 221 setAriaAnnouncement( 222 `Loaded ${loadedResults.length} results from previous upload`, 223 ); 224 } catch (err) { 225 console.error("Failed to load upload:", err); 226 error("Failed to load previous upload. Please try again."); 227 setCurrentStep("home"); 228 } 229 }, 230 [setStatusMessage, setCurrentStep, setSearchResults, setAriaAnnouncement, error, currentDestinationAppId, searchAllUsers, saveResults], 231 ); 232 233 // Login handler 234 const handleLogin = useCallback( 235 async (handle: string) => { 236 if (!handle?.trim()) { 237 error("Please enter your handle"); 238 return; 239 } 240 241 try { 242 await login(handle); 243 } catch (err) { 244 console.error("OAuth error:", err); 245 const errorMsg = `Error starting OAuth: ${err instanceof Error ? err.message : "Unknown error"}`; 246 setStatusMessage(errorMsg); 247 error(errorMsg); 248 } 249 }, 250 [login, error, setStatusMessage], 251 ); 252 253 // Logout handler 254 const handleLogout = useCallback(async () => { 255 try { 256 await logout(); 257 setSearchResults([]); 258 setCurrentPlatform("tiktok"); 259 setSavedUploads(new Set()); 260 // No visual feedback needed - user sees login page 261 setAriaAnnouncement("Logged out successfully"); 262 } catch (err) { 263 error("Failed to logout. Please try again."); 264 } 265 }, [logout, setSearchResults, setAriaAnnouncement, error]); 266 267 // Extension import handler 268 useEffect(() => { 269 const urlParams = new URLSearchParams(window.location.search); 270 const importId = urlParams.get('importId'); 271 272 if (!importId || !session) { 273 return; 274 } 275 276 // Fetch and process extension import 277 async function handleExtensionImport(id: string) { 278 try { 279 setStatusMessage('Loading import from extension...'); 280 setCurrentStep('loading'); 281 282 const response = await fetch( 283 `/.netlify/functions/get-extension-import?importId=${id}` 284 ); 285 286 if (!response.ok) { 287 throw new Error('Import not found or expired'); 288 } 289 290 const importData = await response.json(); 291 292 // Convert usernames to search results 293 const platform = importData.platform; 294 setCurrentPlatform(platform); 295 296 const initialResults: SearchResult[] = importData.usernames.map((username: string) => ({ 297 sourceUser: username, 298 sourcePlatform: platform, 299 isSearching: true, 300 atprotoMatches: [], 301 selectedMatches: new Set<string>(), 302 })); 303 304 setSearchResults(initialResults); 305 306 const uploadId = crypto.randomUUID(); 307 const followLexicon = ATPROTO_APPS[currentDestinationAppId]?.followLexicon; 308 309 // Start search 310 await searchAllUsers( 311 initialResults, 312 setStatusMessage, 313 (finalResults) => { 314 setCurrentStep('results'); 315 316 // Save results after search completes 317 if (finalResults.length > 0) { 318 saveResults(uploadId, platform, finalResults); 319 } 320 321 // Clear import ID from URL 322 const newUrl = new URL(window.location.href); 323 newUrl.searchParams.delete('importId'); 324 window.history.replaceState({}, '', newUrl); 325 }, 326 followLexicon 327 ); 328 } catch (err) { 329 console.error('Extension import error:', err); 330 error('Failed to load import from extension. Please try again.'); 331 setCurrentStep('home'); 332 333 // Clear import ID from URL on error 334 const newUrl = new URL(window.location.href); 335 newUrl.searchParams.delete('importId'); 336 window.history.replaceState({}, '', newUrl); 337 } 338 } 339 340 handleExtensionImport(importId); 341 }, [session, currentDestinationAppId, setStatusMessage, setCurrentStep, setSearchResults, searchAllUsers, saveResults, error]); 342 343 // Load results from uploadId URL parameter 344 useEffect(() => { 345 const urlParams = new URLSearchParams(window.location.search); 346 const uploadId = urlParams.get('uploadId'); 347 348 if (!uploadId || !session) { 349 return; 350 } 351 352 // Load results for this upload 353 handleLoadUpload(uploadId); 354 355 // Clean up URL parameter after loading 356 const newUrl = new URL(window.location.href); 357 newUrl.searchParams.delete('uploadId'); 358 window.history.replaceState({}, '', newUrl); 359 }, [session, handleLoadUpload]); 360 361 return ( 362 <ErrorBoundary> 363 <div className="min-h-screen relative overflow-hidden"> 364 {/* Notification Container - errors only */} 365 <NotificationContainer 366 notifications={notifications} 367 onRemove={removeNotification} 368 /> 369 370 {/* Invisible announcer for screen readers - non-error feedback */} 371 <AriaLiveAnnouncer message={ariaAnnouncement} politeness="polite" /> 372 373 {/* Status message for screen readers - loading/progress updates */} 374 <AriaLiveAnnouncer message={statusMessage} politeness="polite" /> 375 376 {/* Firefly particles - only render if motion not reduced */} 377 {!reducedMotion && ( 378 <div className="fixed inset-0 pointer-events-none" aria-hidden="true"> 379 {[...Array(15)].map((_, i) => ( 380 <Firefly 381 key={i} 382 delay={i * 0.5} 383 duration={3 + Math.random() * 2} 384 /> 385 ))} 386 </div> 387 )} 388 389 {/* Skip to main content link */} 390 <a 391 href="#main-content" 392 className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:bg-firefly-orange focus:text-white focus:px-4 focus:py-2 focus:rounded-lg" 393 > 394 Skip to main content 395 </a> 396 397 <main id="main-content"> 398 <Suspense fallback={<PageLoader />}> 399 {/* Checking Session */} 400 {currentStep === "checking" && <PageLoader />} 401 402 {/* Login Page */} 403 {currentStep === "login" && ( 404 <ErrorBoundary fallbackType="inline"> 405 <LoginPage 406 onSubmit={handleLogin} 407 session={session} 408 onNavigate={setCurrentStep} 409 reducedMotion={reducedMotion} 410 /> 411 </ErrorBoundary> 412 )} 413 414 {/* Home/Dashboard Page */} 415 {currentStep === "home" && ( 416 <ErrorBoundary fallbackType="inline"> 417 <HomePage 418 session={session} 419 onLogout={handleLogout} 420 onNavigate={setCurrentStep} 421 onFileUpload={processFileUpload} 422 onLoadUpload={handleLoadUpload} 423 currentStep={currentStep} 424 reducedMotion={reducedMotion} 425 isDark={isDark} 426 onToggleTheme={toggleTheme} 427 onToggleMotion={toggleMotion} 428 userSettings={userSettings} 429 onSettingsUpdate={handleSettingsUpdate} 430 /> 431 </ErrorBoundary> 432 )} 433 434 {/* Loading Page */} 435 {currentStep === "loading" && ( 436 <ErrorBoundary fallbackType="inline"> 437 <LoadingPage 438 session={session} 439 onLogout={handleLogout} 440 onNavigate={setCurrentStep} 441 searchProgress={searchProgress} 442 currentStep={currentStep} 443 sourcePlatform={currentPlatform} 444 isDark={isDark} 445 reducedMotion={reducedMotion} 446 onToggleTheme={toggleTheme} 447 onToggleMotion={toggleMotion} 448 /> 449 </ErrorBoundary> 450 )} 451 452 {/* Results Page */} 453 {currentStep === "results" && ( 454 <ErrorBoundary fallbackType="inline"> 455 <ResultsPage 456 session={session} 457 onLogout={handleLogout} 458 onNavigate={setCurrentStep} 459 searchResults={searchResults} 460 expandedResults={expandedResults} 461 onToggleExpand={toggleExpandResult} 462 onToggleMatchSelection={toggleMatchSelection} 463 onSelectAll={() => selectAllMatches(setStatusMessage)} 464 onDeselectAll={() => deselectAllMatches(setStatusMessage)} 465 onFollowSelected={() => followSelectedUsers(setStatusMessage)} 466 totalSelected={totalSelected} 467 totalFound={totalFound} 468 isFollowing={isFollowing} 469 currentStep={currentStep} 470 sourcePlatform={currentPlatform} 471 destinationAppId={currentDestinationAppId} 472 reducedMotion={reducedMotion} 473 isDark={isDark} 474 onToggleTheme={toggleTheme} 475 onToggleMotion={toggleMotion} 476 /> 477 </ErrorBoundary> 478 )} 479 </Suspense> 480 </main> 481 </div> 482 </ErrorBoundary> 483 ); 484}