ATlast — you'll never need to find your favorites on another platform again. Find your favs in the ATmosphere.
atproto
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}