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

prevent duplicate follows per lexicon

authored by byarielm.fyi and committed by byarielm.fyi 3670c6e9 64536a65

verified
+79
netlify/functions/batch-follow-users.ts
··· 1 import { Handler, HandlerEvent, HandlerResponse } from "@netlify/functions"; 2 import { SessionManager } from "./session-manager"; 3 import cookie from "cookie"; 4 5 export const handler: Handler = async ( ··· 57 const { agent, did: userDid } = 58 await SessionManager.getAgentForSession(sessionId); 59 60 // Follow all users 61 const results = []; 62 let consecutiveErrors = 0; 63 const MAX_CONSECUTIVE_ERRORS = 3; 64 65 for (const did of dids) { 66 try { 67 await agent.api.com.atproto.repo.createRecord({ 68 repo: userDid, ··· 77 results.push({ 78 did, 79 success: true, 80 error: null, 81 }); 82 83 // Reset error counter on success 84 consecutiveErrors = 0; 85 } catch (error) { ··· 88 results.push({ 89 did, 90 success: false, 91 error: error instanceof Error ? error.message : "Follow failed", 92 }); 93 ··· 112 113 const successCount = results.filter((r) => r.success).length; 114 const failCount = results.filter((r) => !r.success).length; 115 116 return { 117 statusCode: 200, ··· 124 total: dids.length, 125 succeeded: successCount, 126 failed: failCount, 127 results, 128 }), 129 };
··· 1 import { Handler, HandlerEvent, HandlerResponse } from "@netlify/functions"; 2 import { SessionManager } from "./session-manager"; 3 + import { getDbClient } from "./db"; 4 import cookie from "cookie"; 5 6 export const handler: Handler = async ( ··· 58 const { agent, did: userDid } = 59 await SessionManager.getAgentForSession(sessionId); 60 61 + // Check existing follows before attempting to follow 62 + const alreadyFollowing = new Set<string>(); 63 + try { 64 + let cursor: string | undefined = undefined; 65 + let hasMore = true; 66 + const didsSet = new Set(dids); 67 + 68 + while (hasMore && didsSet.size > 0) { 69 + const response = await agent.api.com.atproto.repo.listRecords({ 70 + repo: userDid, 71 + collection: followLexicon, 72 + limit: 100, 73 + cursor, 74 + }); 75 + 76 + for (const record of response.data.records) { 77 + const followRecord = record.value as any; 78 + if (followRecord?.subject && didsSet.has(followRecord.subject)) { 79 + alreadyFollowing.add(followRecord.subject); 80 + didsSet.delete(followRecord.subject); 81 + } 82 + } 83 + 84 + cursor = response.data.cursor; 85 + hasMore = !!cursor; 86 + 87 + if (didsSet.size === 0) { 88 + break; 89 + } 90 + } 91 + } catch (error) { 92 + console.error("Error checking existing follows:", error); 93 + // Continue - we'll handle duplicates in the follow loop 94 + } 95 + 96 // Follow all users 97 const results = []; 98 let consecutiveErrors = 0; 99 const MAX_CONSECUTIVE_ERRORS = 3; 100 + const sql = getDbClient(); 101 102 for (const did of dids) { 103 + // Skip if already following 104 + if (alreadyFollowing.has(did)) { 105 + results.push({ 106 + did, 107 + success: true, 108 + alreadyFollowing: true, 109 + error: null, 110 + }); 111 + 112 + // Update database follow status 113 + try { 114 + await sql` 115 + UPDATE atproto_matches 116 + SET follow_status = follow_status || jsonb_build_object(${followLexicon}, true), 117 + last_follow_check = NOW() 118 + WHERE atproto_did = ${did} 119 + `; 120 + } catch (dbError) { 121 + console.error("Failed to update follow status in DB:", dbError); 122 + } 123 + 124 + continue; 125 + } 126 + 127 try { 128 await agent.api.com.atproto.repo.createRecord({ 129 repo: userDid, ··· 138 results.push({ 139 did, 140 success: true, 141 + alreadyFollowing: false, 142 error: null, 143 }); 144 145 + // Update database follow status 146 + try { 147 + await sql` 148 + UPDATE atproto_matches 149 + SET follow_status = follow_status || jsonb_build_object(${followLexicon}, true), 150 + last_follow_check = NOW() 151 + WHERE atproto_did = ${did} 152 + `; 153 + } catch (dbError) { 154 + console.error("Failed to update follow status in DB:", dbError); 155 + } 156 + 157 // Reset error counter on success 158 consecutiveErrors = 0; 159 } catch (error) { ··· 162 results.push({ 163 did, 164 success: false, 165 + alreadyFollowing: false, 166 error: error instanceof Error ? error.message : "Follow failed", 167 }); 168 ··· 187 188 const successCount = results.filter((r) => r.success).length; 189 const failCount = results.filter((r) => !r.success).length; 190 + const alreadyFollowingCount = results.filter( 191 + (r) => r.alreadyFollowing, 192 + ).length; 193 194 return { 195 statusCode: 200, ··· 202 total: dids.length, 203 succeeded: successCount, 204 failed: failCount, 205 + alreadyFollowing: alreadyFollowingCount, 206 results, 207 }), 208 };
+51
netlify/functions/batch-search-actors.ts
··· 148 }); 149 } 150 151 return { 152 statusCode: 200, 153 headers: {
··· 148 }); 149 } 150 151 + // Check follow status for all matched DIDs in chosen lexicon 152 + const followLexicon = body.followLexicon || "app.bsky.graph.follow"; 153 + 154 + if (allDids.length > 0) { 155 + try { 156 + let cursor: string | undefined = undefined; 157 + let hasMore = true; 158 + const didsSet = new Set(allDids); 159 + const followedDids = new Set<string>(); 160 + const repoDid = await SessionManager.getDIDForSession(sessionId); 161 + 162 + if (repoDid === null) { 163 + throw new Error("Could not retrieve DID for session."); 164 + } 165 + 166 + // Query user's follow graph 167 + while (hasMore && didsSet.size > 0) { 168 + const response = await agent.api.com.atproto.repo.listRecords({ 169 + repo: repoDid, 170 + collection: followLexicon, 171 + limit: 100, 172 + cursor, 173 + }); 174 + 175 + // Check each record 176 + for (const record of response.data.records) { 177 + const followRecord = record.value as any; 178 + if (followRecord?.subject && didsSet.has(followRecord.subject)) { 179 + followedDids.add(followRecord.subject); 180 + } 181 + } 182 + 183 + cursor = response.data.cursor; 184 + hasMore = !!cursor; 185 + } 186 + 187 + // Add follow status to results 188 + results.forEach((result) => { 189 + result.actors = result.actors.map((actor: any) => ({ 190 + ...actor, 191 + followStatus: { 192 + [followLexicon]: followedDids.has(actor.did), 193 + }, 194 + })); 195 + }); 196 + } catch (error) { 197 + console.error("Failed to check follow status during search:", error); 198 + // Continue without follow status - non-critical 199 + } 200 + } 201 + 202 return { 203 statusCode: 200, 204 headers: {
+135
netlify/functions/check-follow-status.ts
···
··· 1 + import { Handler, HandlerEvent, HandlerResponse } from "@netlify/functions"; 2 + import { SessionManager } from "./session-manager"; 3 + import cookie from "cookie"; 4 + 5 + export const handler: Handler = async ( 6 + event: HandlerEvent, 7 + ): Promise<HandlerResponse> => { 8 + if (event.httpMethod !== "POST") { 9 + return { 10 + statusCode: 405, 11 + headers: { "Content-Type": "application/json" }, 12 + body: JSON.stringify({ error: "Method not allowed" }), 13 + }; 14 + } 15 + 16 + try { 17 + // Parse request body 18 + const body = JSON.parse(event.body || "{}"); 19 + const dids: string[] = body.dids || []; 20 + const followLexicon: string = body.followLexicon || "app.bsky.graph.follow"; 21 + 22 + if (!Array.isArray(dids) || dids.length === 0) { 23 + return { 24 + statusCode: 400, 25 + headers: { "Content-Type": "application/json" }, 26 + body: JSON.stringify({ 27 + error: "dids array is required and must not be empty", 28 + }), 29 + }; 30 + } 31 + 32 + // Limit batch size 33 + if (dids.length > 100) { 34 + return { 35 + statusCode: 400, 36 + headers: { "Content-Type": "application/json" }, 37 + body: JSON.stringify({ error: "Maximum 100 DIDs per batch" }), 38 + }; 39 + } 40 + 41 + // Get session from cookie 42 + const cookies = event.headers.cookie 43 + ? cookie.parse(event.headers.cookie) 44 + : {}; 45 + const sessionId = cookies.atlast_session; 46 + 47 + if (!sessionId) { 48 + return { 49 + statusCode: 401, 50 + headers: { "Content-Type": "application/json" }, 51 + body: JSON.stringify({ error: "No session cookie" }), 52 + }; 53 + } 54 + 55 + // Get authenticated agent using SessionManager 56 + const { agent, did: userDid } = 57 + await SessionManager.getAgentForSession(sessionId); 58 + 59 + // Build follow status map 60 + const followStatus: Record<string, boolean> = {}; 61 + 62 + // Initialize all as not following 63 + dids.forEach((did) => { 64 + followStatus[did] = false; 65 + }); 66 + 67 + // Query user's follow graph for the specific lexicon 68 + try { 69 + let cursor: string | undefined = undefined; 70 + let hasMore = true; 71 + const didsSet = new Set(dids); 72 + 73 + while (hasMore && didsSet.size > 0) { 74 + const response = await agent.api.com.atproto.repo.listRecords({ 75 + repo: userDid, 76 + collection: followLexicon, 77 + limit: 100, 78 + cursor, 79 + }); 80 + 81 + // Check each record 82 + for (const record of response.data.records) { 83 + const followRecord = record.value as any; 84 + if (followRecord?.subject && didsSet.has(followRecord.subject)) { 85 + followStatus[followRecord.subject] = true; 86 + didsSet.delete(followRecord.subject); // Found it, no need to keep checking 87 + } 88 + } 89 + 90 + cursor = response.data.cursor; 91 + hasMore = !!cursor; 92 + 93 + // If we've found all DIDs, break early 94 + if (didsSet.size === 0) { 95 + break; 96 + } 97 + } 98 + } catch (error) { 99 + console.error("Error querying follow graph:", error); 100 + // On error, return all as false (not following) - fail safe 101 + } 102 + 103 + return { 104 + statusCode: 200, 105 + headers: { 106 + "Content-Type": "application/json", 107 + "Access-Control-Allow-Origin": "*", 108 + }, 109 + body: JSON.stringify({ followStatus }), 110 + }; 111 + } catch (error) { 112 + console.error("Check follow status error:", error); 113 + 114 + // Handle authentication errors specifically 115 + if (error instanceof Error && error.message.includes("session")) { 116 + return { 117 + statusCode: 401, 118 + headers: { "Content-Type": "application/json" }, 119 + body: JSON.stringify({ 120 + error: "Invalid or expired session", 121 + details: error.message, 122 + }), 123 + }; 124 + } 125 + 126 + return { 127 + statusCode: 500, 128 + headers: { "Content-Type": "application/json" }, 129 + body: JSON.stringify({ 130 + error: "Failed to check follow status", 131 + details: error instanceof Error ? error.message : "Unknown error", 132 + }), 133 + }; 134 + } 135 + };
+11 -5
netlify/functions/db-helpers.ts
··· 56 matchScore: number, 57 postCount: number, 58 followerCount: number, 59 ): Promise<number> { 60 const sql = getDbClient(); 61 const result = await sql` 62 INSERT INTO atproto_matches ( 63 source_account_id, atproto_did, atproto_handle, 64 atproto_display_name, atproto_avatar, match_score, 65 - post_count, follower_count 66 ) 67 VALUES ( 68 ${sourceAccountId}, ${atprotoDid}, ${atprotoHandle}, 69 ${atprotoDisplayName || null}, ${atprotoAvatar || null}, ${matchScore}, 70 - ${postCount || 0}, ${followerCount || 0} 71 ) 72 ON CONFLICT (source_account_id, atproto_did) DO UPDATE SET 73 atproto_handle = ${atprotoHandle}, ··· 76 match_score = ${matchScore}, 77 post_count = ${postCount}, 78 follower_count = ${followerCount}, 79 last_verified = NOW() 80 RETURNING id 81 `; ··· 192 matchScore: number; 193 postCount?: number; 194 followerCount?: number; 195 }>, 196 ): Promise<Map<string, number>> { 197 const sql = getDbClient(); ··· 207 const matchScore = matches.map((m) => m.matchScore); 208 const postCount = matches.map((m) => m.postCount || 0); 209 const followerCount = matches.map((m) => m.followerCount || 0); 210 211 const result = await sql` 212 INSERT INTO atproto_matches ( 213 source_account_id, atproto_did, atproto_handle, 214 atproto_display_name, atproto_avatar, atproto_description, 215 - match_score, post_count, follower_count 216 ) 217 SELECT * FROM UNNEST( 218 ${sourceAccountId}::integer[], ··· 223 ${atprotoDescription}::text[], 224 ${matchScore}::integer[], 225 ${postCount}::integer[], 226 - ${followerCount}::integer[] 227 ) AS t( 228 source_account_id, atproto_did, atproto_handle, 229 atproto_display_name, atproto_avatar, match_score, 230 - post_count, follower_count 231 ) 232 ON CONFLICT (source_account_id, atproto_did) DO UPDATE SET 233 atproto_handle = EXCLUDED.atproto_handle, ··· 237 match_score = EXCLUDED.match_score, 238 post_count = EXCLUDED.post_count, 239 follower_count = EXCLUDED.follower_count, 240 last_verified = NOW() 241 RETURNING id, source_account_id, atproto_did 242 `;
··· 56 matchScore: number, 57 postCount: number, 58 followerCount: number, 59 + followStatus?: Record<string, boolean>, 60 ): Promise<number> { 61 const sql = getDbClient(); 62 const result = await sql` 63 INSERT INTO atproto_matches ( 64 source_account_id, atproto_did, atproto_handle, 65 atproto_display_name, atproto_avatar, match_score, 66 + post_count, follower_count, follow_status 67 ) 68 VALUES ( 69 ${sourceAccountId}, ${atprotoDid}, ${atprotoHandle}, 70 ${atprotoDisplayName || null}, ${atprotoAvatar || null}, ${matchScore}, 71 + ${postCount || 0}, ${followerCount || 0}, ${JSON.stringify(followStatus || {})} 72 ) 73 ON CONFLICT (source_account_id, atproto_did) DO UPDATE SET 74 atproto_handle = ${atprotoHandle}, ··· 77 match_score = ${matchScore}, 78 post_count = ${postCount}, 79 follower_count = ${followerCount}, 80 + follow_status = COALESCE(atproto_matches.follow_status, '{}'::jsonb) || ${JSON.stringify(followStatus || {})}, 81 last_verified = NOW() 82 RETURNING id 83 `; ··· 194 matchScore: number; 195 postCount?: number; 196 followerCount?: number; 197 + followStatus?: Record<string, boolean>; 198 }>, 199 ): Promise<Map<string, number>> { 200 const sql = getDbClient(); ··· 210 const matchScore = matches.map((m) => m.matchScore); 211 const postCount = matches.map((m) => m.postCount || 0); 212 const followerCount = matches.map((m) => m.followerCount || 0); 213 + const followStatus = matches.map((m) => JSON.stringify(m.followStatus || {})); 214 215 const result = await sql` 216 INSERT INTO atproto_matches ( 217 source_account_id, atproto_did, atproto_handle, 218 atproto_display_name, atproto_avatar, atproto_description, 219 + match_score, post_count, follower_count, follow_status 220 ) 221 SELECT * FROM UNNEST( 222 ${sourceAccountId}::integer[], ··· 227 ${atprotoDescription}::text[], 228 ${matchScore}::integer[], 229 ${postCount}::integer[], 230 + ${followerCount}::integer[], 231 + ${followStatus}::jsonb[] 232 ) AS t( 233 source_account_id, atproto_did, atproto_handle, 234 atproto_display_name, atproto_avatar, match_score, 235 + post_count, follower_count, follow_status 236 ) 237 ON CONFLICT (source_account_id, atproto_did) DO UPDATE SET 238 atproto_handle = EXCLUDED.atproto_handle, ··· 242 match_score = EXCLUDED.match_score, 243 post_count = EXCLUDED.post_count, 244 follower_count = EXCLUDED.follower_count, 245 + follow_status = COALESCE(atproto_matches.follow_status, '{}'::jsonb) || EXCLUDED.follow_status, 246 last_verified = NOW() 247 RETURNING id, source_account_id, atproto_did 248 `;
+7 -3
netlify/functions/db.ts
··· 108 found_at TIMESTAMP DEFAULT NOW(), 109 last_verified TIMESTAMP, 110 is_active BOOLEAN DEFAULT TRUE, 111 UNIQUE(source_account_id, atproto_did) 112 ) 113 `; ··· 143 ) 144 `; 145 146 - // ==================== ENHANCED INDEXES FOR PHASE 2 ==================== 147 - 148 // Existing indexes 149 await sql`CREATE INDEX IF NOT EXISTS idx_source_accounts_to_check ON source_accounts(source_platform, match_found, last_checked)`; 150 await sql`CREATE INDEX IF NOT EXISTS idx_source_accounts_platform ON source_accounts(source_platform)`; ··· 156 await sql`CREATE INDEX IF NOT EXISTS idx_user_match_status_did_followed ON user_match_status(did, followed)`; 157 await sql`CREATE INDEX IF NOT EXISTS idx_notification_queue_pending ON notification_queue(sent, created_at) WHERE sent = false`; 158 159 - // NEW: Enhanced indexes for common query patterns 160 161 // For sorting 162 await sql`CREATE INDEX IF NOT EXISTS idx_atproto_matches_stats ON atproto_matches(source_account_id, found_at DESC, post_count DESC, follower_count DESC)`; ··· 183 184 // For bulk operations - normalized username lookups 185 await sql`CREATE INDEX IF NOT EXISTS idx_source_accounts_normalized ON source_accounts(normalized_username, source_platform)`; 186 187 console.log("✅ Database indexes created/verified"); 188 }
··· 108 found_at TIMESTAMP DEFAULT NOW(), 109 last_verified TIMESTAMP, 110 is_active BOOLEAN DEFAULT TRUE, 111 + follow_status JSONB DEFAULT '{}', 112 + last_follow_check TIMESTAMP, 113 UNIQUE(source_account_id, atproto_did) 114 ) 115 `; ··· 145 ) 146 `; 147 148 // Existing indexes 149 await sql`CREATE INDEX IF NOT EXISTS idx_source_accounts_to_check ON source_accounts(source_platform, match_found, last_checked)`; 150 await sql`CREATE INDEX IF NOT EXISTS idx_source_accounts_platform ON source_accounts(source_platform)`; ··· 156 await sql`CREATE INDEX IF NOT EXISTS idx_user_match_status_did_followed ON user_match_status(did, followed)`; 157 await sql`CREATE INDEX IF NOT EXISTS idx_notification_queue_pending ON notification_queue(sent, created_at) WHERE sent = false`; 158 159 + // ======== Enhanced indexes for common query patterns ========= 160 161 // For sorting 162 await sql`CREATE INDEX IF NOT EXISTS idx_atproto_matches_stats ON atproto_matches(source_account_id, found_at DESC, post_count DESC, follower_count DESC)`; ··· 183 184 // For bulk operations - normalized username lookups 185 await sql`CREATE INDEX IF NOT EXISTS idx_source_accounts_normalized ON source_accounts(normalized_username, source_platform)`; 186 + 187 + // Follow status indexes 188 + await sql`CREATE INDEX IF NOT EXISTS idx_atproto_matches_follow_status ON atproto_matches USING gin(follow_status)`; 189 + await sql`CREATE INDEX IF NOT EXISTS idx_atproto_matches_follow_check ON atproto_matches(last_follow_check)`; 190 191 console.log("✅ Database indexes created/verified"); 192 }
+17 -14
netlify/functions/get-upload-details.ts
··· 82 // Fetch paginated results with optimized query 83 const results = await sql` 84 SELECT 85 - sa.source_username, 86 - sa.normalized_username, 87 - usf.source_date, 88 - am.atproto_did, 89 - am.atproto_handle, 90 - am.atproto_display_name, 91 - am.atproto_avatar, 92 - am.atproto_description, 93 - am.match_score, 94 - am.post_count, 95 - am.follower_count, 96 - am.found_at, 97 - ums.followed, 98 - ums.dismissed, 99 -- Calculate if this is a new match (found after upload creation) 100 CASE WHEN am.found_at > uu.created_at THEN 1 ELSE 0 END as is_new_match 101 FROM user_source_follows usf ··· 153 foundAt: row.found_at, 154 followed: row.followed || false, 155 dismissed: row.dismissed || false, 156 }); 157 } 158 });
··· 82 // Fetch paginated results with optimized query 83 const results = await sql` 84 SELECT 85 + sa.source_username, 86 + sa.normalized_username, 87 + usf.source_date, 88 + am.atproto_did, 89 + am.atproto_handle, 90 + am.atproto_display_name, 91 + am.atproto_avatar, 92 + am.atproto_description, 93 + am.match_score, 94 + am.post_count, 95 + am.follower_count, 96 + am.found_at, 97 + am.follow_status, 98 + am.last_follow_check, 99 + ums.followed, 100 + ums.dismissed, 101 -- Calculate if this is a new match (found after upload creation) 102 CASE WHEN am.found_at > uu.created_at THEN 1 ELSE 0 END as is_new_match 103 FROM user_source_follows usf ··· 155 foundAt: row.found_at, 156 followed: row.followed || false, 157 dismissed: row.dismissed || false, 158 + followStatus: row.follow_status || {}, 159 }); 160 } 161 });
+4
src/App.tsx
··· 11 import { useFileUpload } from "./hooks/useFileUpload"; 12 import { useTheme } from "./hooks/useTheme"; 13 import Firefly from "./components/Firefly"; 14 import { DEFAULT_SETTINGS } from "./types/settings"; 15 import type { UserSettings } from "./types/settings"; 16 ··· 86 setCurrentStep("loading"); 87 88 const uploadId = crypto.randomUUID(); 89 90 searchAllUsers(initialResults, setStatusMessage, () => { 91 setCurrentStep("results"); ··· 302 isFollowing={isFollowing} 303 currentStep={currentStep} 304 sourcePlatform={currentPlatform} 305 reducedMotion={reducedMotion} 306 isDark={isDark} 307 onToggleTheme={toggleTheme}
··· 11 import { useFileUpload } from "./hooks/useFileUpload"; 12 import { useTheme } from "./hooks/useTheme"; 13 import Firefly from "./components/Firefly"; 14 + import { ATPROTO_APPS } from "./constants/atprotoApps"; 15 import { DEFAULT_SETTINGS } from "./types/settings"; 16 import type { UserSettings } from "./types/settings"; 17 ··· 87 setCurrentStep("loading"); 88 89 const uploadId = crypto.randomUUID(); 90 + const followLexicon = 91 + ATPROTO_APPS[currentDestinationAppId]?.followLexicon; 92 93 searchAllUsers(initialResults, setStatusMessage, () => { 94 setCurrentStep("results"); ··· 305 isFollowing={isFollowing} 306 currentStep={currentStep} 307 sourcePlatform={currentPlatform} 308 + destinationAppId={currentDestinationAppId} 309 reducedMotion={reducedMotion} 310 isDark={isDark} 311 onToggleTheme={toggleTheme}
+18 -7
src/components/SearchResultCard.tsx
··· 7 UserCheck, 8 } from "lucide-react"; 9 import { PLATFORMS } from "../constants/platforms"; 10 import type { SearchResult } from "../types"; 11 12 interface SearchResultCardProps { 13 result: SearchResult; ··· 16 onToggleExpand: () => void; 17 onToggleMatchSelection: (did: string) => void; 18 sourcePlatform: string; 19 } 20 21 export default function SearchResultCard({ ··· 25 onToggleExpand, 26 onToggleMatchSelection, 27 sourcePlatform, 28 }: SearchResultCardProps) { 29 const displayMatches = isExpanded 30 ? result.atprotoMatches ··· 32 const hasMoreMatches = result.atprotoMatches.length > 1; 33 const platform = PLATFORMS[sourcePlatform] || PLATFORMS.tiktok; 34 35 return ( 36 <div className="bg-white/50 dark:bg-slate-900/50 rounded-2xl shadow-sm overflow-hidden border-2 border-cyan-500/30 dark:border-purple-500/30"> 37 {/* Source User */} ··· 64 ) : ( 65 <div className=""> 66 {displayMatches.map((match) => { 67 - const isFollowed = match.followed; 68 const isSelected = result.selectedMatches?.has(match.did); 69 return ( 70 <div 71 key={match.did} ··· 133 {/* Select/Follow Button */} 134 <button 135 onClick={() => onToggleMatchSelection(match.did)} 136 - disabled={isFollowed} 137 className={`p-2 rounded-full font-medium transition-all flex-shrink-0 self-start ${ 138 - isFollowed 139 - ? "bg-purple-100 dark:bg-slate-900 border-2 border-purple-500 dark:border-cyan-500 text-purple-950 dark:text-cyan-50 shadow-md cursor-not-allowed opacity-60" 140 : isSelected 141 ? "bg-purple-100 dark:bg-slate-900 border-2 border-purple-500 dark:border-cyan-500 text-purple-950 dark:text-cyan-50 shadow-md" 142 : "bg-slate-200/50 dark:bg-slate-900/50 border-2 border-cyan-500/30 dark:border-purple-500/30 text-purple-750 dark:text-cyan-250 hover:border-orange-500 dark:hover:border-amber-400" 143 }`} 144 title={ 145 - isFollowed 146 - ? "Already followed" 147 : isSelected 148 ? "Selected to follow" 149 : "Select to follow" 150 } 151 > 152 - {isFollowed ? ( 153 <Check className="w-4 h-4" /> 154 ) : isSelected ? ( 155 <UserCheck className="w-4 h-4" />
··· 7 UserCheck, 8 } from "lucide-react"; 9 import { PLATFORMS } from "../constants/platforms"; 10 + import { ATPROTO_APPS } from "../constants/atprotoApps"; 11 import type { SearchResult } from "../types"; 12 + import type { AtprotoAppId } from "../types/settings"; 13 14 interface SearchResultCardProps { 15 result: SearchResult; ··· 18 onToggleExpand: () => void; 19 onToggleMatchSelection: (did: string) => void; 20 sourcePlatform: string; 21 + destinationAppId?: AtprotoAppId; 22 } 23 24 export default function SearchResultCard({ ··· 28 onToggleExpand, 29 onToggleMatchSelection, 30 sourcePlatform, 31 + destinationAppId = "bluesky", 32 }: SearchResultCardProps) { 33 const displayMatches = isExpanded 34 ? result.atprotoMatches ··· 36 const hasMoreMatches = result.atprotoMatches.length > 1; 37 const platform = PLATFORMS[sourcePlatform] || PLATFORMS.tiktok; 38 39 + // Get current follow lexicon 40 + const currentApp = ATPROTO_APPS[destinationAppId]; 41 + const currentLexicon = currentApp?.followLexicon || "app.bsky.graph.follow"; 42 + 43 return ( 44 <div className="bg-white/50 dark:bg-slate-900/50 rounded-2xl shadow-sm overflow-hidden border-2 border-cyan-500/30 dark:border-purple-500/30"> 45 {/* Source User */} ··· 72 ) : ( 73 <div className=""> 74 {displayMatches.map((match) => { 75 + // Check follow status for current lexicon 76 + const isFollowedInCurrentApp = 77 + match.followStatus?.[currentLexicon] ?? match.followed ?? false; 78 const isSelected = result.selectedMatches?.has(match.did); 79 + 80 return ( 81 <div 82 key={match.did} ··· 144 {/* Select/Follow Button */} 145 <button 146 onClick={() => onToggleMatchSelection(match.did)} 147 + disabled={isFollowedInCurrentApp} 148 className={`p-2 rounded-full font-medium transition-all flex-shrink-0 self-start ${ 149 + isFollowedInCurrentApp 150 + ? "bg-purple-100 dark:bg-slate-900 border-2 border-purple-500 dark:border-cyan-500 text-purple-950 dark:text-cyan-50 shadow-md cursor-not-allowed opacity-50" 151 : isSelected 152 ? "bg-purple-100 dark:bg-slate-900 border-2 border-purple-500 dark:border-cyan-500 text-purple-950 dark:text-cyan-50 shadow-md" 153 : "bg-slate-200/50 dark:bg-slate-900/50 border-2 border-cyan-500/30 dark:border-purple-500/30 text-purple-750 dark:text-cyan-250 hover:border-orange-500 dark:hover:border-amber-400" 154 }`} 155 title={ 156 + isFollowedInCurrentApp 157 + ? `Already following on ${currentApp?.name || "this app"}` 158 : isSelected 159 ? "Selected to follow" 160 : "Select to follow" 161 } 162 > 163 + {isFollowedInCurrentApp ? ( 164 <Check className="w-4 h-4" /> 165 ) : isSelected ? ( 166 <UserCheck className="w-4 h-4" />
+72 -7
src/hooks/useFollows.ts
··· 13 destinationAppId: AtprotoAppId, 14 ) { 15 const [isFollowing, setIsFollowing] = useState(false); 16 17 async function followSelectedUsers( 18 onUpdate: (message: string) => void, ··· 31 return; 32 } 33 34 - // Follow users 35 const selectedUsers = searchResults.flatMap((result, resultIndex) => 36 result.atprotoMatches 37 .filter((match) => result.selectedMatches?.has(match.did)) ··· 45 return; 46 } 47 48 setIsFollowing(true); 49 onUpdate( 50 - `Following ${selectedUsers.length} users on ${destinationName}...`, 51 ); 52 let totalFollowed = 0; 53 let totalFailed = 0; ··· 55 try { 56 const { BATCH_SIZE } = FOLLOW_CONFIG; 57 58 - for (let i = 0; i < selectedUsers.length; i += BATCH_SIZE) { 59 - const batch = selectedUsers.slice(i, i + BATCH_SIZE); 60 const dids = batch.map((user) => user.did); 61 62 try { ··· 77 atprotoMatches: searchResult.atprotoMatches.map( 78 (match) => 79 match.did === result.did 80 - ? { ...match, followed: true } 81 : match, 82 ), 83 } ··· 89 }); 90 91 onUpdate( 92 - `Followed ${totalFollowed} of ${selectedUsers.length} users`, 93 ); 94 } catch (error) { 95 totalFailed += batch.length; ··· 99 // Rate limit handling is in the backend 100 } 101 102 - const finalMsg = `Successfully followed ${totalFollowed} users${totalFailed > 0 ? `. ${totalFailed} failed.` : ""}`; 103 onUpdate(finalMsg); 104 } catch (error) { 105 console.error("Batch follow error:", error); ··· 111 112 return { 113 isFollowing, 114 followSelectedUsers, 115 }; 116 }
··· 13 destinationAppId: AtprotoAppId, 14 ) { 15 const [isFollowing, setIsFollowing] = useState(false); 16 + const [isCheckingFollowStatus, setIsCheckingFollowStatus] = useState(false); 17 18 async function followSelectedUsers( 19 onUpdate: (message: string) => void, ··· 32 return; 33 } 34 35 + // Get selected users 36 const selectedUsers = searchResults.flatMap((result, resultIndex) => 37 result.atprotoMatches 38 .filter((match) => result.selectedMatches?.has(match.did)) ··· 46 return; 47 } 48 49 + // Check follow status before attempting to follow 50 + setIsCheckingFollowStatus(true); 51 + onUpdate(`Checking follow status for ${selectedUsers.length} users...`); 52 + 53 + let followStatusMap: Record<string, boolean> = {}; 54 + try { 55 + const dids = selectedUsers.map((u) => u.did); 56 + followStatusMap = await apiClient.checkFollowStatus(dids, followLexicon); 57 + } catch (error) { 58 + console.error("Failed to check follow status:", error); 59 + // Continue without filtering - backend will handle duplicates 60 + } finally { 61 + setIsCheckingFollowStatus(false); 62 + } 63 + 64 + // Filter out users already being followed 65 + const usersToFollow = selectedUsers.filter( 66 + (user) => !followStatusMap[user.did], 67 + ); 68 + const alreadyFollowingCount = selectedUsers.length - usersToFollow.length; 69 + 70 + if (alreadyFollowingCount > 0) { 71 + onUpdate( 72 + `${alreadyFollowingCount} user${alreadyFollowingCount > 1 ? "s" : ""} already followed. Following ${usersToFollow.length} remaining...`, 73 + ); 74 + 75 + // Update UI to show already followed status 76 + setSearchResults((prev) => 77 + prev.map((result) => ({ 78 + ...result, 79 + atprotoMatches: result.atprotoMatches.map((match) => { 80 + if (followStatusMap[match.did]) { 81 + return { 82 + ...match, 83 + followStatus: { 84 + ...match.followStatus, 85 + [followLexicon]: true, 86 + }, 87 + }; 88 + } 89 + return match; 90 + }), 91 + })), 92 + ); 93 + } 94 + 95 + if (usersToFollow.length === 0) { 96 + onUpdate("All selected users are already being followed!"); 97 + return; 98 + } 99 + 100 setIsFollowing(true); 101 onUpdate( 102 + `Following ${usersToFollow.length} users on ${destinationName}...`, 103 ); 104 let totalFollowed = 0; 105 let totalFailed = 0; ··· 107 try { 108 const { BATCH_SIZE } = FOLLOW_CONFIG; 109 110 + for (let i = 0; i < usersToFollow.length; i += BATCH_SIZE) { 111 + const batch = usersToFollow.slice(i, i + BATCH_SIZE); 112 const dids = batch.map((user) => user.did); 113 114 try { ··· 129 atprotoMatches: searchResult.atprotoMatches.map( 130 (match) => 131 match.did === result.did 132 + ? { 133 + ...match, 134 + followed: true, // Backward compatibility 135 + followStatus: { 136 + ...match.followStatus, 137 + [followLexicon]: true, 138 + }, 139 + } 140 : match, 141 ), 142 } ··· 148 }); 149 150 onUpdate( 151 + `Followed ${totalFollowed} of ${usersToFollow.length} users`, 152 ); 153 } catch (error) { 154 totalFailed += batch.length; ··· 158 // Rate limit handling is in the backend 159 } 160 161 + const finalMsg = 162 + `Successfully followed ${totalFollowed} users` + 163 + (alreadyFollowingCount > 0 164 + ? ` (${alreadyFollowingCount} already followed)` 165 + : "") + 166 + (totalFailed > 0 ? `. ${totalFailed} failed.` : ""); 167 onUpdate(finalMsg); 168 } catch (error) { 169 console.error("Batch follow error:", error); ··· 175 176 return { 177 isFollowing, 178 + isCheckingFollowStatus, 179 followSelectedUsers, 180 }; 181 }
+5 -1
src/hooks/useSearch.ts
··· 43 resultsToSearch: SearchResult[], 44 onProgressUpdate: (message: string) => void, 45 onComplete: () => void, 46 ) { 47 if (!session || resultsToSearch.length === 0) return; 48 ··· 80 ); 81 82 try { 83 - const data = await apiClient.batchSearchActors(usernames); 84 85 // Reset error counter on success 86 consecutiveErrors = 0;
··· 43 resultsToSearch: SearchResult[], 44 onProgressUpdate: (message: string) => void, 45 onComplete: () => void, 46 + followLexicon?: string, 47 ) { 48 if (!session || resultsToSearch.length === 0) return; 49 ··· 81 ); 82 83 try { 84 + const data = await apiClient.batchSearchActors( 85 + usernames, 86 + followLexicon, 87 + ); 88 89 // Reset error counter on success 90 consecutiveErrors = 0;
+20
src/lib/apiClient/mockApiClient.ts
··· 29 description: `Mock profile for ${username}`, 30 postCount: Math.floor(Math.random() * 1000), 31 followerCount: Math.floor(Math.random() * 5000), 32 })); 33 } 34 ··· 70 localStorage.removeItem("mock_uploads"); 71 }, 72 73 async getUploads(): Promise<{ uploads: any[] }> { 74 await delay(300); 75 console.log("[MOCK] Getting uploads"); ··· 110 111 async batchSearchActors( 112 usernames: string[], 113 ): Promise<{ results: BatchSearchResult[] }> { 114 await delay(800); // Simulate API delay 115 console.log("[MOCK] Searching for:", usernames);
··· 29 description: `Mock profile for ${username}`, 30 postCount: Math.floor(Math.random() * 1000), 31 followerCount: Math.floor(Math.random() * 5000), 32 + followStatus: { 33 + "app.bsky.graph.follow": Math.random() < 0.3, // 30% already following 34 + }, 35 })); 36 } 37 ··· 73 localStorage.removeItem("mock_uploads"); 74 }, 75 76 + async checkFollowStatus( 77 + dids: string[], 78 + followLexicon: string, 79 + ): Promise<Record<string, boolean>> { 80 + await delay(300); 81 + console.log("[MOCK] Checking follow status for:", dids.length, "DIDs"); 82 + 83 + // Mock: 30% chance each user is already followed 84 + const followStatus: Record<string, boolean> = {}; 85 + dids.forEach((did) => { 86 + followStatus[did] = Math.random() < 0.3; 87 + }); 88 + 89 + return followStatus; 90 + }, 91 + 92 async getUploads(): Promise<{ uploads: any[] }> { 93 await delay(300); 94 console.log("[MOCK] Getting uploads"); ··· 129 130 async batchSearchActors( 131 usernames: string[], 132 + followLexicon?: string, 133 ): Promise<{ results: BatchSearchResult[] }> { 134 await delay(800); // Simulate API delay 135 console.log("[MOCK] Searching for:", usernames);
+38 -3
src/lib/apiClient/realApiClient.ts
··· 247 return { results: allResults }; 248 }, 249 250 // Search Operations 251 async batchSearchActors( 252 usernames: string[], 253 ): Promise<{ results: BatchSearchResult[] }> { 254 // Create cache key from sorted usernames (so order doesn't matter) 255 - const cacheKey = `search-${usernames.slice().sort().join(",")}`; 256 const cached = cache.get<any>(cacheKey, 10 * 60 * 1000); 257 if (cached) { 258 console.log( ··· 267 method: "POST", 268 credentials: "include", 269 headers: { "Content-Type": "application/json" }, 270 - body: JSON.stringify({ usernames }), 271 }); 272 273 if (!res.ok) { ··· 291 total: number; 292 succeeded: number; 293 failed: number; 294 results: BatchFollowResult[]; 295 }> { 296 const res = await fetch("/.netlify/functions/batch-follow-users", { ··· 306 307 const data = await res.json(); 308 309 - // Invalidate uploads cache after following 310 cache.invalidate("uploads"); 311 cache.invalidatePattern("upload-details"); 312 313 return data; 314 },
··· 247 return { results: allResults }; 248 }, 249 250 + // NEW: Check follow status 251 + async checkFollowStatus( 252 + dids: string[], 253 + followLexicon: string, 254 + ): Promise<Record<string, boolean>> { 255 + // Check cache first 256 + const cacheKey = `follow-status-${followLexicon}-${dids.slice().sort().join(",")}`; 257 + const cached = cache.get<Record<string, boolean>>(cacheKey, 2 * 60 * 1000); // 2 minute cache 258 + if (cached) { 259 + console.log("Returning cached follow status"); 260 + return cached; 261 + } 262 + 263 + const res = await fetch("/.netlify/functions/check-follow-status", { 264 + method: "POST", 265 + credentials: "include", 266 + headers: { "Content-Type": "application/json" }, 267 + body: JSON.stringify({ dids, followLexicon }), 268 + }); 269 + 270 + if (!res.ok) { 271 + throw new Error("Failed to check follow status"); 272 + } 273 + 274 + const data = await res.json(); 275 + 276 + // Cache for 2 minutes 277 + cache.set(cacheKey, data.followStatus, 2 * 60 * 1000); 278 + 279 + return data.followStatus; 280 + }, 281 + 282 // Search Operations 283 async batchSearchActors( 284 usernames: string[], 285 + followLexicon?: string, 286 ): Promise<{ results: BatchSearchResult[] }> { 287 // Create cache key from sorted usernames (so order doesn't matter) 288 + const cacheKey = `search-${followLexicon || "default"}-${usernames.slice().sort().join(",")}`; 289 const cached = cache.get<any>(cacheKey, 10 * 60 * 1000); 290 if (cached) { 291 console.log( ··· 300 method: "POST", 301 credentials: "include", 302 headers: { "Content-Type": "application/json" }, 303 + body: JSON.stringify({ usernames, followLexicon }), 304 }); 305 306 if (!res.ok) { ··· 324 total: number; 325 succeeded: number; 326 failed: number; 327 + alreadyFollowing: number; 328 results: BatchFollowResult[]; 329 }> { 330 const res = await fetch("/.netlify/functions/batch-follow-users", { ··· 340 341 const data = await res.json(); 342 343 + // Invalidate caches after following 344 cache.invalidate("uploads"); 345 cache.invalidatePattern("upload-details"); 346 + cache.invalidatePattern("follow-status"); 347 348 return data; 349 },
+4
src/pages/Results.tsx
··· 2 import { PLATFORMS } from "../constants/platforms"; 3 import AppHeader from "../components/AppHeader"; 4 import SearchResultCard from "../components/SearchResultCard"; 5 6 interface atprotoSession { 7 did: string; ··· 41 isFollowing: boolean; 42 currentStep: string; 43 sourcePlatform: string; 44 reducedMotion?: boolean; 45 isDark?: boolean; 46 onToggleTheme?: () => void; ··· 63 isFollowing, 64 currentStep, 65 sourcePlatform, 66 reducedMotion = false, 67 isDark = false, 68 onToggleTheme, ··· 185 onToggleMatchSelection(originalIndex, did) 186 } 187 sourcePlatform={sourcePlatform} 188 /> 189 ); 190 })}
··· 2 import { PLATFORMS } from "../constants/platforms"; 3 import AppHeader from "../components/AppHeader"; 4 import SearchResultCard from "../components/SearchResultCard"; 5 + import type { AtprotoAppId } from "../types/settings"; 6 7 interface atprotoSession { 8 did: string; ··· 42 isFollowing: boolean; 43 currentStep: string; 44 sourcePlatform: string; 45 + destinationAppId: AtprotoAppId; 46 reducedMotion?: boolean; 47 isDark?: boolean; 48 onToggleTheme?: () => void; ··· 65 isFollowing, 66 currentStep, 67 sourcePlatform, 68 + destinationAppId, 69 reducedMotion = false, 70 isDark = false, 71 onToggleTheme, ··· 188 onToggleMatchSelection(originalIndex, did) 189 } 190 sourcePlatform={sourcePlatform} 191 + destinationAppId={destinationAppId} 192 /> 193 ); 194 })}
+3 -1
src/types/index.ts
··· 21 avatar?: string; 22 matchScore: number; 23 description?: string; 24 - followed?: boolean; 25 postCount?: number; 26 followerCount?: number; 27 foundAt?: string; ··· 62 export interface BatchFollowResult { 63 did: string; 64 success: boolean; 65 error: string | null; 66 } 67
··· 21 avatar?: string; 22 matchScore: number; 23 description?: string; 24 + followed?: boolean; // DEPRECATED - kept for backward compatibility 25 + followStatus?: Record<string, boolean>; 26 postCount?: number; 27 followerCount?: number; 28 foundAt?: string; ··· 63 export interface BatchFollowResult { 64 did: string; 65 success: boolean; 66 + alreadyFollowing?: boolean; 67 error: string | null; 68 } 69